Compare commits
90
Commits
216006b091
...
main
+29
-1
@@ -8,13 +8,26 @@ Thumbs.db
|
||||
*.swp
|
||||
|
||||
# Dependencies / build output
|
||||
node_modules/
|
||||
#
|
||||
# NO TRAILING SLASH on node_modules, deliberately. `node_modules/` matches a directory and not a
|
||||
# SYMLINK of that name — so one pointing at a shared install was committed in v0.4.7, and because it
|
||||
# pointed at a path that does not exist, a fresh checkout had no TypeScript: `npm run typecheck` said
|
||||
# "tsc: not found" and 20 tests failed for a reason that had nothing to do with the code.
|
||||
node_modules
|
||||
dist/
|
||||
dist-test/
|
||||
build/
|
||||
target/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Claude Code's own state — session checkpoints and the git worktrees it works in.
|
||||
#
|
||||
# NOT ignored until now, so `git add -A` in the repo root would have staged
|
||||
# `.claude/worktrees/`: a whole second checkout of this repository, committed into itself. Nothing
|
||||
# in here is a source file and none of it should travel with the package.
|
||||
.claude/
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
@@ -28,3 +41,18 @@ __pycache__/
|
||||
# website's replay viewer. It was therefore never committed, and a fresh clone was missing a page
|
||||
# the build copies unconditionally. Only the throwaway files this directory collects are ignored.
|
||||
/replay*.html
|
||||
|
||||
# Playtest saves — somebody's game, attached to a bug report.
|
||||
#
|
||||
# ANCHORED, and with the README exempted: the directory has to exist and say what it is for, while
|
||||
# nothing that lands in it is ever committed. A save is a seed plus the moves made, it belongs to
|
||||
# whoever sent it, and it goes stale the moment the rules move. `public/replays/` is the place for
|
||||
# one that is worth publishing.
|
||||
/playtests/*
|
||||
!/playtests/README.md
|
||||
|
||||
# The Jitsi harness is NOT scrubbed — four of its files carry a real domain and real
|
||||
# participant names (workspace AGENTS.local.md). Ignored so that a `git add -A` cannot sweep it
|
||||
# into a history that would be permanently exposed if this repo is ever made public. When it is
|
||||
# promoted in Phase 1, scrub it FIRST, then `git add -f tools/`.
|
||||
tools/
|
||||
|
||||
+6301
-1
File diff suppressed because it is too large
Load Diff
@@ -10,22 +10,69 @@ train into an occupied Subdivision. Get that wrong and two trains meet at speed.
|
||||
|
||||
## Status
|
||||
|
||||
**v0.3.1 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure,
|
||||
imports nothing outside itself, and never touches `Math.random`, so a static host is all it needs.
|
||||
**Solitaire is playable in a browser and multiplayer runs against a server.** The solitaire game runs
|
||||
entirely client-side — the engine is pure, imports nothing outside itself, and never touches
|
||||
`Math.random`, so a static host is all it needs. Multiplayer adds an authoritative server for the
|
||||
seats a shared game requires.
|
||||
|
||||
- **Rules** — fully specified. Ten gaps in the original prototype rules found and resolved.
|
||||
The current version is in [`package.json`](package.json), and every page stamps it with the commit
|
||||
and build date, so what is deployed can always be identified from the page itself. This section
|
||||
deliberately no longer names one: it went stale for six releases.
|
||||
|
||||
- **Rules** — specified. Thirteen gaps in the original prototype rules were found and closed, and the
|
||||
three that came after were all settled in v0.5.0: a coach may be set out at the Office (§A.4),
|
||||
Poling stays out of the deck at zero copies since the source records its effect only as "TBD", and
|
||||
**a Heavy Grade's orientation is rolled from the seed, never chosen by a player** — see
|
||||
[`docs/rules/implications.md`](docs/rules/implications.md) §10 for each ruling and its reasoning.
|
||||
- **Card faces** — every card's printed values specified.
|
||||
- **Architecture** — six documents, including a 20-component build plan.
|
||||
- **Code** — the engine, the bot, the balance harness, the replay viewer and the playable page. A
|
||||
game can be saved, shared, replayed and stepped back through.
|
||||
- **Not built** — multiplayer (the engine runs 2–5 player games and the bot plays them, but there is
|
||||
no server, no turn submission and no per-player view), the 22 opponent-directed cards, and real
|
||||
audio.
|
||||
- **Architecture** — seven documents, including a 20-component build plan and the multiplayer plan.
|
||||
- **Code** — the engine, the bot, the balance harness, the replay viewer, the playable page, and the
|
||||
server. A game can be saved, shared, replayed and stepped back through.
|
||||
- **Multiplayer — built and running.** `src/server/` serves a lobby (create, join, preview, leave,
|
||||
add a bot, start, and a stream), turn submission, per-session state and persistence, with its own
|
||||
tests under `test/server/`. Games survive a release rather than being destroyed by one. A player
|
||||
weighing a join reads the **whole rule set before taking a seat**; a seat survives a browser
|
||||
reload; anybody may leave and the host may clear a chair; and the four transient signals that make
|
||||
a game feel alive — sound, the timetable flash, an announcement, the badge on the card you just
|
||||
drew — reach a remote client, which they did not before v0.7.0. What is still open is in `TODO.md`
|
||||
under Multiplayer — chiefly that a lost
|
||||
session token still locks someone out of a running game from a genuinely fresh browser.
|
||||
- **Watching the table — v0.8.0.** Every accepted move, and every automatic phase that does
|
||||
anything, becomes an ordered **presentation step**: the board replays other people's turns instead
|
||||
of arriving already rearranged. This is what closes "a player cannot see what the others did",
|
||||
which stood open through v0.7.x. A bot's whole switching turn used to land in one push, because
|
||||
`driveBots()` plays it out before the push goes back; now it arrives as a run of steps, the
|
||||
district panel follows whoever is acting, and a `[N behind] … [Skip]` row says how far the board is
|
||||
from the game. Dwell is assigned **by kind** — a switching move holds the screen, turn bookkeeping
|
||||
costs nothing — and is tunable per viewer without a rebuild. Solitaire runs the same path, which is
|
||||
where its automatic phases finally get a visible beat.
|
||||
|
||||
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
|
||||
and passenger economy is still only ~2. `TODO.md` says why, and says which of it is the bot and which
|
||||
is the deck.
|
||||
**The caption and the history panel are not the same list**, since 2026-09-17. Switching is logged
|
||||
in full; a move from the middle of a turn writes its line as tone `trace`, so the step still
|
||||
carries it — the board captions the move and earns its dwell, and `dwellForStep` pays nothing for a
|
||||
step that said nothing — while the history panel filters the tone out. What the panel draws is the
|
||||
line saying somebody switched, the FIRST move, work at an **industry**, the Small Yard sort, and a
|
||||
closing summary. The last move rides in that summary rather than being kept in place: nothing knows
|
||||
a move was the last until the turn is over, by which time the line has been written and streamed to
|
||||
every client, so it cannot be revised.
|
||||
**Not yet checked in a browser:** the mechanism is proven server-side against a live SSE stream and
|
||||
the page is proven not to throw, but nobody has watched a bot switch on screen.
|
||||
- **Not built** — the opponent-directed cards (the Action and Space-use categories, held out of every
|
||||
deck until they have an implementation, along with the defensive cards whose only purpose is to
|
||||
answer them), and real audio. No screen offers a control for the opponent cards any more: the
|
||||
toggle could not do anything, so both screens state the fact in words instead.
|
||||
|
||||
Balance is *not* where it should be, and this file no longer quotes a figure for it. It used to say
|
||||
"the developer bot averages 7.0 Revenue against a target of 20", which stopped being true the moment
|
||||
the transit rule it names was defaulted to off — that rule was worth ~5.4 of the 7.0, for traffic
|
||||
nobody had to work. Measured at the current defaults the bot meant about **zero** until it began
|
||||
planning its switching turns (2026-09-14), which put it near **2.8**.
|
||||
|
||||
The three rates — passenger per coach, freight per load, train per transit — are **settings fixed when
|
||||
the game is dealt**, along with the opening hand and where an Extra may start, so the economy can be
|
||||
read on its own and the alternatives played rather than argued about. Run
|
||||
`node src/sim/harness.ts 400 standard` for today's number rather than trusting one written here;
|
||||
`TODO.md` says which of the gap is the bot and which is the deck.
|
||||
|
||||
Versions follow the convention at the top of [`CHANGELOG.md`](CHANGELOG.md): third digit for fixes,
|
||||
second for a set of features, 1.0 for the first release that deserves the name.
|
||||
@@ -37,16 +84,20 @@ station-master/
|
||||
├── CHANGELOG.md ← what changed and why, in detail, commit to commit
|
||||
├── TODO.md ← open questions, provisional numbers, things to come back to
|
||||
├── docs/
|
||||
│ ├── rules/ ← the ruleset, card reference, glossary, decision record
|
||||
│ ├── rules/ ← the ruleset, glossary, decision record (the card tables are in docs/*-deck.md)
|
||||
│ ├── architecture/ ← how it is built, and what the pieces are
|
||||
│ ├── plans/ ← worked plans for a single change, kept for the reasoning
|
||||
│ └── design/ ← board layout studies and rendering samples
|
||||
├── public/replays/ ← saved games published to the site's replay directory
|
||||
├── scripts/ ← build and deploy the static site
|
||||
├── public/
|
||||
│ ├── replays/ ← saved games published to the site's replay directory
|
||||
│ └── images/ ← art the build copies into the site
|
||||
├── scripts/ ← build the site the package serves (and a static-host deploy, unused since 2026-09-29)
|
||||
├── src/
|
||||
│ ├── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed
|
||||
│ ├── server/ ← the authoritative multiplayer server: lobby, sessions, persistence
|
||||
│ ├── sim/ ← bot, harness, replay, board rendering
|
||||
│ └── web/ ← the playable site: splash, game, replay viewer
|
||||
└── test/
|
||||
│ └── web/ ← the playable site: splash, game, lobby, replay viewer
|
||||
└── test/ ← including test/server/ for the server's own suite
|
||||
```
|
||||
|
||||
Start with [`docs/design.md`](docs/design.md) — it indexes everything. Commit messages stay high
|
||||
@@ -55,11 +106,14 @@ level; [`CHANGELOG.md`](CHANGELOG.md) carries the reasoning and the measurements
|
||||
|
||||
## Development
|
||||
|
||||
Requires **Node 22.18+**, which runs TypeScript directly by type stripping. There is no build step.
|
||||
Requires **Node 22.18+**, which runs TypeScript directly by type stripping — so there is no compile
|
||||
step, and the engine, the bot and the tests all run straight from source. (`npm run build:web` is a
|
||||
separate thing: it assembles the static SITE into `dist/`.)
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm test # node --test
|
||||
npm test # node --test, everything except the bot simulations — run after every change
|
||||
npm run test:sim # test/sim.test.ts, the bot simulations (~7 min) — run after a bot or balance change
|
||||
npm run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
@@ -70,7 +124,7 @@ syntax**: no `enum`, no parameter properties, no namespaces. `tsconfig.json` enf
|
||||
|
||||
```sh
|
||||
node src/sim/harness.ts 200 # how the bot does, with the funnel
|
||||
node src/sim/compare.ts 1600 trainCapSlack=1 # one change, paired against the current bot
|
||||
node src/sim/compare.ts 1600 noValueLays=1 # one ablation, paired against the current bot
|
||||
```
|
||||
|
||||
**Never judge a heuristic on an unpaired run.** Revenue has σ ≈ 9 across games, so two runs of the
|
||||
@@ -80,10 +134,11 @@ standard error at ±0.13, in under two minutes. Keep a change at **t ≥ 3**, an
|
||||
better/worse/identical split beside the mean: a gain carried by a few rescued games is a different
|
||||
claim from one spread across the field.
|
||||
|
||||
Variants come from `makeDeveloperBot(tweaks)`. A tweak is **temporary** — when it measures well it
|
||||
becomes the default and the flag is deleted in the same commit; when it measures badly it is deleted
|
||||
with the finding recorded in `CHANGELOG.md`. A bot that accumulates switches nobody can account for
|
||||
is the thing this machinery exists to prevent.
|
||||
Variants come from `makeDeveloperBot(tweaks)`. Every flag is an **ablation**: it turns OFF a
|
||||
heuristic that is now the bot's default play (`noPlanSwitching`, `noValueLays`, …), so an adopted
|
||||
heuristic can be re-measured when the deck or the rules move under it. A candidate that measures
|
||||
badly is deleted, with the finding recorded in `CHANGELOG.md` — a switch nobody turns on is a switch
|
||||
nobody maintains. `compare.ts` lists the flags it accepts and refuses any other name.
|
||||
|
||||
## Design notes worth knowing
|
||||
|
||||
@@ -93,18 +148,87 @@ is the thing this machinery exists to prevent.
|
||||
- **It has two entry points, not one.** `apply(state, intent)` for player actions, and
|
||||
`advance(state)` for everything the game does on its own — Mainline movement, collisions, the Stage
|
||||
clock, Superintendent rotation.
|
||||
- **State is `fold(events)`.** The event log is the source of truth, which is what gives reconnection,
|
||||
restart recovery and post-game replay from a single decision.
|
||||
- **The canonical record is the seed plus the intents.** A game is `{ seed, history: Intent[] }` and
|
||||
`fromSave` replays it exactly — that one property gives save, share, undo, restart recovery and
|
||||
post-game replay. Events are a DERIVED stream: they narrate what happened and drive the display,
|
||||
and they do not reconstruct the position. The phase driver mutates state and then describes it, so
|
||||
roughly a third of the event types are never reduced at all. Anything that needs to rebuild a game
|
||||
replays the intents.
|
||||
- **A save is only guaranteed to replay on the version that wrote it.** This is the cost of the
|
||||
property above and is not a bug to be fixed case by case: a save is a list of moves, so it reopens
|
||||
by being *re-played through the current rules*. Any rules change that makes a once-legal move
|
||||
illegal will stop an older save at that move — **a change to the deck is the likeliest breaker**,
|
||||
since a history that names a card the deck no longer deals has no legal answer at all, but any
|
||||
narrowing of what is permitted does it. It fails safe in every case: the load is declined, the
|
||||
offending move is named, and the file is left untouched, so nothing a player has is destroyed.
|
||||
Assume an older save may not open, tell players so wherever a build is announced, and read "this
|
||||
save will not load" in a bug report as this before treating it as a fault. **Versioned, migratable
|
||||
replays are a post-1.0 question** — deliberately not worth the effort while the rules are still
|
||||
moving this fast, since every migration would have to be written against rules that changed again
|
||||
next release.
|
||||
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
|
||||
- **Track is a deck card, but the opening district is dealt.** 96 of the 235 cards are track — the
|
||||
largest category — so a district is built from what you draw, and building it costs you the
|
||||
industry or train you drew instead. The one exception is setup: track is shuffled separately and
|
||||
each player is dealt **3 track + 3 other**, with the leftover track shuffled back in afterwards.
|
||||
You therefore open holding six against a limit of three, and the first turn is spent choosing.
|
||||
Provisional — see `TODO.md`.
|
||||
- **Clearing the line scores.** Every train highballed out of your Office earns one Revenue, paid
|
||||
once. It is worth +5.4 a game, more than the entire freight and passenger economy put together,
|
||||
and it pays for traffic you do not have to work. Also provisional.
|
||||
- **The Mainline Phase can stop and ask, and there are three questions it asks.** §8.1's clearance
|
||||
ruling goes to the Superintendent; the Yard Office offer and the Red Flag prompt go to the owner of
|
||||
the district a train is arriving at. `pendingDecision` is a discriminated union and `decisionActor`
|
||||
is the single place that maps a question to whoever must answer it — a new question adds a case
|
||||
there and nowhere else. **Ask before the move is committed:** returning `needsClearance` unwinds
|
||||
the whole phase and the driver re-enters from the top, so anything already mutated is applied
|
||||
twice or left half-done.
|
||||
- **A game ends by PAUSING, and the first ending is the real one.** Running out of Days, or closing
|
||||
short of the combined Revenue floor, puts the game in `awaitingExtension` rather than `finished`:
|
||||
the table is asked whether to play one more Day, unanimously, and asked again at the end of every
|
||||
Day it grants. `state.official` is written at the first ending and never rewritten, so the winner
|
||||
is always the one decided at `config.days` however long play carries on — `config.days` itself
|
||||
never moves, and `state.extraDays` counts the borrowed ones. A §3.4 collision breach is the
|
||||
exception and finishes outright, during an extended Day exactly as during the scheduled game.
|
||||
Because a save is a replay, the vote is an intent (`game.extend`), and it is the one intent that
|
||||
**carries its own player**: every seat may vote in any order, so a replay cannot derive who did.
|
||||
- **Statistics are folded, not recorded.** `state.tally` counts what the event stream says happened —
|
||||
trains through the Division and how many of them did any switching, loads made up and broken — and
|
||||
is hooked
|
||||
at the two boundaries every event crosses exactly once, `applyIntent` and `advance`. It is not
|
||||
hooked in `reduce`, which never sees the phase driver's events at all. Nothing in the rules reads
|
||||
it, so adding a counter is always safe; it rides the `Frame`, so a multiplayer client gets the same
|
||||
numbers as solitaire from one implementation. **What it cannot count is anything the events do not
|
||||
say.** `trainStoodStill` fires once per game for the X18 Circus alone, so "the longest an engine sat
|
||||
on a siding" has no signal behind it — see `TODO.md` #36 rather than assuming an event means what
|
||||
its name suggests.
|
||||
- **A game is one of four TYPES, and a type is a set of defaults rather than a ruleset.** Co-op,
|
||||
Competitive, Cutthroat and Solitaire (`src/web/presets.ts`) each name an opening hand, an Extra
|
||||
rule, three revenue rates and the victory conditions; picking one fills the form, and changing any
|
||||
of them selects **Custom**, which keeps the scoring of the type it came from. The type is *derived*
|
||||
from the numbers rather than stored, so a saved game carries no name that can disagree with what it
|
||||
actually is. Both screens that deal a game — the lobby and the New Game dialog — ask the same
|
||||
eleven questions through one shared block (`src/web/settings-form.ts`), because for two releases
|
||||
they each had a question the other lacked. What separates the types: Co-op alone pays for a
|
||||
transit, Cutthroat alone lets an Extra be planted in another player's district and has no shared
|
||||
failure condition at all beyond three collisions in a Day, and the Revenue floor is a formula in
|
||||
the table size and the length (3 per player per Day in Co-op, 2 in Competitive) rather than a
|
||||
number.
|
||||
- **Track is a deck card, but the opening district is dealt.** Track is the largest category in the
|
||||
deck by a distance — so a district is built from what you draw, and building it costs you the
|
||||
industry or train you drew instead. The opening hand is the exception, and it is **chosen when the
|
||||
game is dealt**: three random cards (the prototype rule), six random cards, or three track and
|
||||
three other from two separately shuffled piles. The last of those is the only one that guarantees
|
||||
you a district to build; deal six and you open over the limit of three, so the first turn is spent
|
||||
choosing. **Every game type now deals six** (Jesse’s call, v0.7.0) — the engine's own fallback,
|
||||
`SOLO_CONFIG`, deliberately did not move with it, because every sim measurement is taken against
|
||||
that. See `TODO.md`.
|
||||
- **What the work pays is a setting too.** Passenger revenue per coach, freight revenue per load and
|
||||
train revenue per transit each run 0–5 and are fixed when the game is dealt. The first two default
|
||||
to 1 and pay at both ends of a movement — boarding *and* detraining, loading *and* unloading. The
|
||||
third pays every player when a train runs off the end of the Division and **defaults to 0**: at 1
|
||||
it was worth more than the entire freight and passenger economy put together, for traffic nobody
|
||||
has to work. A seed alone therefore no longer names a game — the settings ride in the URL beside
|
||||
it, and every save records the rules it was dealt under.
|
||||
- **A load may not be broken in the district that made it.** Freight or passengers loaded anywhere in
|
||||
an Office Area cannot be unloaded anywhere in that same Office Area — not at another facility, not
|
||||
in a later Stage. A train has to carry them to a different district first. The printed game turns
|
||||
the chip upside down in the tray; here the load carries the seat that made it (`RollingStock.origin`
|
||||
in `src/engine/state.ts`) and it never expires. Without it a Freight House could unload the boxcar
|
||||
its own Laborers had just loaded and a platform could detrain the passengers it had just boarded,
|
||||
each paying Revenue at both ends for a load that went nowhere: worth **0.60 ± 0.10 Revenue a game**
|
||||
to the developer bot over 400 paired deals, on 78 of them.
|
||||
- **A turnout can be laid on top of a card already down.** It upgrades a straight at any rotation, or
|
||||
a curve whose arc matches its own diverging leg — both strict port supersets of what they replace,
|
||||
so an upgrade can never sever an existing join. Without it a district could only hang off track that
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
@@ -35,7 +35,7 @@ decision (you are the Superintendent, ruling on your own trains).
|
||||
| Turn arbitration between players | Card catalogue |
|
||||
| Presence, reconnection of others | Track graph and Move legality |
|
||||
| Fedora rotation between players | Board and action UI |
|
||||
| Competitive/Co-op victory modes | Event log and persistence |
|
||||
| Competitive/Co-op victory modes | Intent history and persistence |
|
||||
| Collision floor (Competitive only) | Server and protocol |
|
||||
|
||||
**Consequence: the engine is the dominant cost and it is front-loaded.** Components 4–6 barely differ
|
||||
@@ -60,7 +60,9 @@ is what makes the whole system testable without a server.
|
||||
|
||||
- **Where:** Engine · **When:** loaded once at startup, immutable thereafter
|
||||
- **Does:** the 52-card deck composition, freight and passenger facility profiles, Modifier effects,
|
||||
train consist specs, track geometries. Source: [`../rules/card-reference.md`](../rules/card-reference.md)
|
||||
train consist specs, track geometries. Source: `docs/Deck cards5.xlsx` and the PDFs, transcribed
|
||||
into `content.ts`; the generated tables in [`../home-deck.md`](../home-deck.md) and
|
||||
[`../mainline-deck.md`](../mainline-deck.md) are what it deals today
|
||||
- **MVP: S** · **Final: S** · ~200 → ~350 LOC
|
||||
- **Size driver:** it is data, not logic. Grows only if card variants are added.
|
||||
- **Note:** every number here is provisional and will be retuned repeatedly. Keep it as data files,
|
||||
@@ -141,11 +143,12 @@ is what makes the whole system testable without a server.
|
||||
- **Size driver:** at MVP one game and one player, so the pump loop is nearly all of it. Grows with
|
||||
concurrent games and per-player routing.
|
||||
|
||||
**9. Event log and persistence**
|
||||
**9. Intent history and persistence**
|
||||
|
||||
- **Where:** Server · **When:** append per event; full read on startup
|
||||
- **Does:** append-only log to disk; state is `fold(events)`. Powers restart recovery at MVP, and
|
||||
later reconnection and the replay viewer.
|
||||
- **Does:** append-only `{ engineVersion, seed, config, history: Intent[] }` to disk. Restart recovery
|
||||
is `fromSave` over the stored intents; the replay viewer reads the same file
|
||||
([`protocol.md`](protocol.md) §3 — events narrate, intents reconstruct).
|
||||
- **MVP: S** · **Final: M** · ~100 → ~350 LOC
|
||||
- **Size driver:** an append and a read-back. Grows with snapshotting, retention and indexing.
|
||||
|
||||
@@ -161,8 +164,8 @@ is what makes the whole system testable without a server.
|
||||
**11. Transport**
|
||||
|
||||
- **Where:** Server · **When:** per connection; pushes on every event
|
||||
- **Does:** HTTP for lobby operations, WebSocket for the ordered event stream, static asset serving.
|
||||
Bidirectional — most traffic is server → client.
|
||||
- **Does:** HTTP POST for lobby operations and intents, SSE for the ordered push stream, static asset
|
||||
serving ([`multiplayer.md`](multiplayer.md) D5). Most traffic is server → client.
|
||||
- **MVP: S** · **Final: M** · ~150 → ~400 LOC
|
||||
- **Size driver:** small at MVP because one client needs no fan-out. Grows with broadcast, backpressure
|
||||
and reconnect-with-replay.
|
||||
@@ -180,7 +183,7 @@ is what makes the whole system testable without a server.
|
||||
|
||||
- **Where:** Server · **When:** once, at process start
|
||||
- **Does:** reads environment configuration (bind address, port, data directory), opens or creates the
|
||||
event log, constructs the session host and transport, wires them together, handles shutdown.
|
||||
intent history, constructs the session host and transport, wires them together, handles shutdown.
|
||||
- **MVP: XS** · **Final: S** · ~80 → ~200 LOC
|
||||
- **Size driver:** trivial in itself, but it is where
|
||||
[`deployment.md`](deployment.md)'s five portability rules are actually enforced — single process
|
||||
@@ -223,9 +226,9 @@ is what makes the whole system testable without a server.
|
||||
**16. Replay viewer**
|
||||
|
||||
- **Where:** Browser · **When:** after a game ends — **pulled forward, built**
|
||||
- **Does:** read-only projection over the event log, with playback controls. A Node script
|
||||
precomputes one frame per visible event and writes a self-contained HTML file, so the browser
|
||||
never runs the engine and there is no bundling or build step.
|
||||
- **Does:** read-only playback of a stored game, with controls. A Node script replays the saved
|
||||
intents, precomputes one `Frame` per visible event with its narration, and writes a self-contained
|
||||
HTML file — so the browser never runs the engine and there is no bundling or build step.
|
||||
- **MVP: —** · **Final: S** · 0 → ~250 LOC · *built: 560 LOC (replay) + 330 (narration)*
|
||||
- **Size driver:** the narration layer, not the rendering. Turning 30 event types into readable
|
||||
sentences and deriving the "what is currently blocked" panel is most of it.
|
||||
@@ -304,7 +307,7 @@ under.
|
||||
[4]apply [5]advance [6]legalActions
|
||||
└───────┼───────┘
|
||||
▼
|
||||
[8] session host ──► [9] event log
|
||||
[8] session host ──► [9] intent history
|
||||
│
|
||||
▼
|
||||
[10] view projection
|
||||
@@ -393,7 +396,7 @@ Crew Tray onto a card holding two standing cars, which couples them automaticall
|
||||
| 4 | Apply | Validates the Move via component 3; rejects with `WOULD_REVERSE` / `NOT_OPERATIONAL_RAIL` / `TRACK_OCCUPIED` if illegal. |
|
||||
| 3 | Track graph | Confirms the path exists without a direction change. |
|
||||
| 4 | Apply | Moves the tray, couples the standing cars **in track order**, checks the four-slot limit, decrements Moves. Emits `TrayMoved` and `CarsCoupled`. |
|
||||
| 9 | Event log | Appends both events to disk. |
|
||||
| 9 | Intent history | Appends the accepted `switch.move` intent to the game's history on disk. |
|
||||
| 8 | Session host | Calls `advance` — no automatic work is due mid-Local-Ops, so it returns `needsInput`. |
|
||||
| 10 | View projection | Rebuilds the view. Nothing is redacted here; the board is public. |
|
||||
| 6 | Legal-action enumeration | Recomputes affordances from the new state — fewer Moves remain, and a fuller consist may now be at its four-slot limit. |
|
||||
|
||||
@@ -15,8 +15,8 @@ The requirements are modest, which is what makes both paths viable:
|
||||
| Need | Why |
|
||||
| --- | --- |
|
||||
| One long-running process | Active games are held in memory ([`overview.md`](overview.md)) |
|
||||
| HTTP + WebSocket on one port | Lobby over HTTP, game stream over WebSocket ([`protocol.md`](protocol.md)) |
|
||||
| A writable data directory | The append-only event log ([`lobby-and-sessions.md`](lobby-and-sessions.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 `{ seed, config, history }` per game ([`lobby-and-sessions.md`](lobby-and-sessions.md)) |
|
||||
| Static asset serving | The browser client |
|
||||
| No outbound network access | The game talks to nobody |
|
||||
| No scheduled work | Nothing in the rules is real-time ([`overview.md`](overview.md)) |
|
||||
@@ -59,7 +59,7 @@ network access to that StartOS box. How they get it is the user's configuration
|
||||
something the application chooses or should claim.
|
||||
|
||||
**One thing the architecture must respect.** A packaged service should not assume it is reachable at
|
||||
a fixed, publicly-routable URL. Anything that bakes an origin into the client — absolute WebSocket
|
||||
a fixed, publicly-routable URL. Anything that bakes an origin into the client — absolute stream
|
||||
URLs, hard-coded hostnames in links, CORS allow-lists pinned to one domain — will break. Serve the
|
||||
client from the same origin as the API and use relative URLs throughout. This costs nothing on a
|
||||
plain web host and is required on StartOS, so it should simply be the rule.
|
||||
|
||||
@@ -131,24 +131,24 @@ Office; everything else is Secondary Track.
|
||||
```
|
||||
OfficeArea
|
||||
owner : PlayerIndex
|
||||
officeType : whistlePost | depot | station | terminal
|
||||
tier : whistlePost | depot | station | terminal -- `OfficeTier`; `officeType` in early drafts
|
||||
grid : Map<GridCoord, TrackCard> -- sparse; cards are placed during play
|
||||
officeCoord : GridCoord -- where the Office card sits
|
||||
runningRow : integer -- the grid row that is the Running Track
|
||||
limitsWest : GridCoord -- moves outward as the Running Track grows
|
||||
limitsEast : GridCoord
|
||||
adOccupancy : CrewTrayId[] -- length ≤ adTrackCount(officeType)
|
||||
adOccupancy : CrewTrayId[] -- length ≤ adTrackCount(tier)
|
||||
```
|
||||
|
||||
```
|
||||
adTrackCount: whistlePost 1 | depot 2 | station 3 | terminal 4 -- §11.1
|
||||
isControlPoint = officeType != whistlePost
|
||||
isPassengerFacility = officeType != whistlePost
|
||||
isControlPoint = tier != whistlePost
|
||||
isPassengerFacility = tier != whistlePost
|
||||
```
|
||||
|
||||
**Office cards are geometrically interchangeable** (§11.3). All four tiers carry the same track
|
||||
footprint — a through track plus a plain junction stub above and below — and differ *only* in the
|
||||
three properties above. An upgrade therefore changes `officeType` and nothing else; it must never
|
||||
three properties above. An upgrade therefore changes `tier` and nothing else; it must never
|
||||
touch `grid`, `connections`, or anything attached to the Office card. The junction stubs carry no
|
||||
directional restriction: §A.1's turnout rule governs drawn turnout cards only.
|
||||
|
||||
@@ -187,7 +187,7 @@ These are the ones that will be got wrong if they are not written down explicitl
|
||||
9. **An Office upgrade must preserve every connection** (§11.3). All four tiers share identical
|
||||
geometry precisely so the upgrade is a property change, not a card swap. An implementation that
|
||||
models the upgrade as "remove old card, place new card" will silently orphan any Secondary Track
|
||||
hanging off the Office — mutate `officeType` in place instead.
|
||||
hanging off the Office — mutate `tier` in place instead.
|
||||
10. **The Office card's stubs are not turnouts.** Do not route them through the §A.1 directional
|
||||
logic; a train may pass between the Running Track and either Secondary row freely.
|
||||
|
||||
@@ -258,8 +258,9 @@ Facility
|
||||
modifiers : ModifierRef[] -- adjacent cards raising capacity, track length or workers
|
||||
```
|
||||
|
||||
All of these are populated from [`../rules/card-reference.md`](../rules/card-reference.md), which is
|
||||
the authoritative per-card catalogue.
|
||||
All of these are populated from `content.ts`, transcribed from the catalogue spreadsheets; the
|
||||
generated tables in [`../home-deck.md`](../home-deck.md) and [`../mainline-deck.md`](../mainline-deck.md)
|
||||
are the per-card reference (`card-reference.md` is the superseded placeholder).
|
||||
|
||||
**`industryTrack` is where cars are spotted for loading and unloading**, and it is the field that
|
||||
stops being Operational Rail while `menAtWork` holds any load (constraint 4 below). Its `length` is
|
||||
@@ -275,8 +276,9 @@ the car (§9.3). A Porter earns a point in one action. This asymmetry is deliber
|
||||
per Stage, and stocking a green box or clearing a red one consumes the whole of it (§6.3). That
|
||||
one-action-per-Stage budget is what actually limits Revenue — roughly one point per action, ceiling
|
||||
12 per Day. Worker counts mostly determine how often a facility idles. See
|
||||
[`../rules/card-reference.md`](../rules/card-reference.md#7-economy-summary) for the full model; it is
|
||||
what §3's targets are calibrated against.
|
||||
[`../rules/card-reference.md`](../rules/card-reference.md#7-economy-summary) for the model as it was
|
||||
first worked out (that document is otherwise superseded); the figures §3's targets were calibrated
|
||||
against are the measurements in `TODO.md`'s Play balance section.
|
||||
|
||||
---
|
||||
|
||||
@@ -328,8 +330,10 @@ Outcome
|
||||
|
||||
Termination checks, in the order they must be evaluated:
|
||||
|
||||
1. **Collision floor** (Competitive only) — `collisionsToday >= 3` ends the game immediately, all
|
||||
players lose (§3.4). Checked the moment a collision resolves, not at end of Stage.
|
||||
1. **Collision floor** (every mode) — `collisionsToday >= maxCollisionsPerDay` or
|
||||
`collisionsTotal >= maxCollisionsTotal` ends the game, all players lose (§3.4). Judged at the
|
||||
Shift Change that closes the Stage, on the Stage just played and before the Day rolls over — so
|
||||
Load/Unload still scores in the Stage of the breach, and a breach in Stage 12 counts.
|
||||
2. **Target reached** (firstToTarget) — checked whenever Revenue increases.
|
||||
3. **Days elapsed** (highestAfterDays) — at the end of the final Day, apply the collective Revenue
|
||||
floor `3 × players × Days` for Competitive (§3.5), or the mode target for Solitaire and Co-op
|
||||
|
||||
@@ -10,20 +10,59 @@ Written against [`../rules/rules-v0.2.md`](../rules/rules-v0.2.md).
|
||||
|
||||
## 1. Identity
|
||||
|
||||
No accounts to begin with. A player is a **display name** plus a **session token** the server issues
|
||||
on join and the client stores locally.
|
||||
No accounts. Access to the server is a **join secret** (§2); identity within a game is a **display
|
||||
name** plus a **session token** the server issues on join and the client stores locally.
|
||||
|
||||
```
|
||||
Session
|
||||
token : opaque, unguessable
|
||||
gameId
|
||||
playerIndex
|
||||
player : PlayerIndex
|
||||
displayName
|
||||
```
|
||||
|
||||
The token is what makes reconnection work: it proves "I am the player who was sitting at seat 2,"
|
||||
which is the only identity claim the game needs. Keep it out of URLs so it is not shoulder-surfed or
|
||||
pasted into a chat.
|
||||
**It names the PLAYER, not the seat.** Those became different things in v0.4.0, and the difference is
|
||||
exactly the case this field has to survive: under Employee Rotation (§4) a player changes chairs while
|
||||
their Revenue and their identity stay with them. A token naming a seat would sit a returning player
|
||||
down in someone else's Office. The seat is derived with `seatOf(state, player)` whenever it is needed,
|
||||
which is one lookup and always current.
|
||||
|
||||
The token is what makes reconnection work: it proves "I am the player who was in this game," which is
|
||||
the only identity claim the game needs. Keep it out of URLs so it is not shoulder-surfed or pasted
|
||||
into a chat.
|
||||
|
||||
**Tokens are per-origin.** The server may be reached by more than one address — `stationmaster.<domain>`
|
||||
and `<ip>:<port>` are both expected — and browser storage is scoped to the origin. A player who joined
|
||||
at one address must come back to that address, or they are a stranger with no token. Say so in the
|
||||
UI at join time rather than letting someone discover it when they cannot get back in.
|
||||
|
||||
**A lost token is recoverable, administratively** (Gitea#33). Everything above makes the token the
|
||||
single point of failure: it lives in one browser's storage, and a cleared profile, a private window or
|
||||
a different browser ends the seat with the game still running and the session still on disk. Seen at a
|
||||
real table — the returning player met an empty lobby while their token sat intact in `sessions.json`,
|
||||
and the only way back was an administrator reading the file off the volume and the player pasting it
|
||||
into a devtools console.
|
||||
|
||||
So there is a supported path, in two halves that are gated differently on purpose:
|
||||
|
||||
```
|
||||
POST /api/games/<id>/claim { player } → { code, expiresAt, … } admin secret
|
||||
POST /api/claim { code } → { token, gameId, player, gameCode }
|
||||
```
|
||||
|
||||
**The link carries the code, never the token** — which is the rule three paragraphs up, applied. A
|
||||
recovery link is exactly the sort of thing that gets pasted into a chat, so what travels in the URL is
|
||||
single-use and expires in thirty minutes (`server/claims.ts`), and the page trades it for the real
|
||||
token over a POST as it loads (`?claim=` in `web/main.ts`, which strips it from the address bar either
|
||||
way). A leaked code is worthless once spent; a leaked token is the seat for the rest of the game.
|
||||
|
||||
**Minting is administrative; spending is not.** Deciding that a particular person has lost a
|
||||
particular seat is a judgement no route can make safely — anyone able to mint their own code could
|
||||
take any chair at the table. Spending needs no secret because the player following the link is the one
|
||||
person in the story who holds none; the code *is* the authorisation, and it is the same shape
|
||||
(unguessable, one-time) as the token it hands back. The codes are held in memory: they are minted on
|
||||
demand and spent within minutes, so a restart dropping them is the right failure, and persisting them
|
||||
would put a credential-equivalent on the volume to solve a problem measured in seconds.
|
||||
|
||||
Real accounts can be layered on later without touching the rules engine, which is exactly why
|
||||
[`overview.md`](overview.md) keeps that boundary sharp.
|
||||
@@ -33,40 +72,104 @@ Real accounts can be layered on later without touching the rules engine, which i
|
||||
## 2. Creating and joining
|
||||
|
||||
```
|
||||
Lobby.Create { config } → { gameId, gameCode, token }
|
||||
Lobby.Join { gameCode, displayName } → { token, playerIndex }
|
||||
Lobby.Create { secret, config, displayName, players, seed } → { gameId, gameCode, token, player }
|
||||
Lobby.Preview { secret, gameCode } → { gameCode, hostName, config, players, seated }
|
||||
Lobby.Join { secret, gameCode, displayName } → { gameId, gameCode, token, player }
|
||||
Lobby.Leave { token, seat? } → { ok, closed? }
|
||||
```
|
||||
|
||||
A **game code** — short, human-speakable, e.g. `RAIL-4471` — is the whole discovery mechanism. No
|
||||
matchmaking, no browsing, no public game list. Players are already talking to each other; the code
|
||||
just needs to survive being read aloud.
|
||||
**These four messages are the one family with no types behind them**, because the engine has no
|
||||
concept of a lobby — `intents.ts` starts at `localOps.choose`. Everywhere else, the code is the
|
||||
protocol ([`protocol.md`](protocol.md)); here the prose is, until the server exists.
|
||||
|
||||
The creating player is the **host**: they set the config (§3) and start the game. Host-only rights
|
||||
end at `Lobby.Start` — the rules give no player special standing during play, and the Superintendent
|
||||
role rotates independently (§5).
|
||||
**The join secret gates the door.** Server-wide, set by environment variable, passed out of band by
|
||||
whoever runs the server (`multiplayer.md` D14). Anyone holding it may create a game and may join any
|
||||
created game that has not started. It is not per-game and it is not an account — it is the cheapest
|
||||
thing that stops a clearnet-reachable box from being someone else's game server. Typed once and kept
|
||||
per-origin, alongside the token.
|
||||
|
||||
**Player counts.** Solitaire is 1. Competitive and Co-op need at least 2. The upper bound is a
|
||||
practical judgment rather than a rules limit: the Division grows by one Office and one Mainline card
|
||||
per player (§4.3), and every added player lengthens the Superintendent's rotation and the
|
||||
phase-major waiting. **6 is a sensible cap** for the prototype.
|
||||
A **game code** — short, human-speakable, e.g. `RAIL-4471` — is the discovery mechanism *inside* the
|
||||
door. No matchmaking, no browsing, no public game list. Players are already talking to each other; the
|
||||
code just needs to survive being read aloud. The seating screen also offers it as an invite **link**
|
||||
(`…/play.html?lobby&code=RAIL-4471`), which is what a chat message wants — the link carries the code
|
||||
and never the join secret, because the secret is the door key and travels out of band by design.
|
||||
|
||||
**A player reads the rules before taking a chair.** `Lobby.Preview` answers the same join secret with
|
||||
the whole config, the host's name and who is seated, and takes no seat — added 2026-08-23, when the
|
||||
alternative was sitting down blind and (until the same pass) having no way back out. **It never
|
||||
carries the seed**: the seed decides every shuffle and every roll in the game, so it belongs to the
|
||||
host alone.
|
||||
|
||||
**Two players may not share a display name.** The name labels the district on the Division map, it is
|
||||
what the turn chart means by "waiting on Jesse", and `record()` puts it in front of every line that
|
||||
player causes — so two of them make all three ambiguous, and the names lock at `Lobby.Start`. A
|
||||
clashing join is refused (`NAME_TAKEN`, compared trimmed and case-insensitively) rather than silently
|
||||
suffixed: a player should play under the name they chose, or be asked for another.
|
||||
|
||||
**Anybody may leave, and the host may clear a chair.** `Lobby.Leave` frees the seat, drops the token
|
||||
from `joinOrder`, **revokes it** — in memory and in `sessions.json`, since v0.8.4; until then the
|
||||
leaver's token still opened the seat the next arrival took — and passes host rights on exactly as a
|
||||
dropped connection does. Naming somebody
|
||||
else's `seat` is host-only. When the last human leaves, the lobby is deleted outright — code, file and
|
||||
index row — rather than left as a table of bots waiting for a host who no longer exists. Before this
|
||||
existed a mis-join or a player who wandered off wedged the whole table, since Start needs every chair
|
||||
filled and a bot may not be dropped onto an occupied seat.
|
||||
|
||||
**One game at a time per person is expected usage and is deliberately not enforced** (`multiplayer.md`
|
||||
§10). Enforcing it needs cross-game state whose only job is deciding when to release someone, and
|
||||
getting that wrong locks a player out of their own server.
|
||||
|
||||
The creating player is the **host**: they set the config (§3) and start the game. **If the host leaves
|
||||
before `Lobby.Start`, host rights pass to the earliest-joined remaining player.** No lobby should ever
|
||||
be stuck waiting on somebody who closed a tab, and the alternative — everyone leaves and someone makes
|
||||
a new game — throws away the seating they had already agreed. Host rights end at `Lobby.Start`
|
||||
regardless: the rules give no player special standing during play, and the Superintendent rotates
|
||||
independently (§4).
|
||||
|
||||
**Player counts.** Solitaire is 1. Competitive and Co-op are **2 to 4**. Nothing in the engine enforces
|
||||
a limit, so the lobby is where it is enforced — and the number is set to what is actually exercised
|
||||
(`test/multiplayer.test.ts` plays 2, 3 and 4 to a finish) rather than to a guess. The cost of more is
|
||||
real: the Division grows by one Office and one Mainline card per player (§4.3), and every added player
|
||||
lengthens both the Superintendent's rotation and the phase-major waiting. Raise the cap when somebody
|
||||
has played a bigger game and reported back, not before.
|
||||
|
||||
This is a lobby judgment, **not a rules limit** — §7 of the rules describes what happens at five or
|
||||
more players (the New Train round ends mid-pass when the consist fills) and the engine implements it.
|
||||
The cap is about what has been played, not about what the game can do.
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration, and when it locks
|
||||
|
||||
Set before start, immutable after:
|
||||
Set before start, immutable after — the whole of `GameConfig` (`state.ts`), which the host fills in
|
||||
by choosing a **game type** and then editing whatever they like:
|
||||
|
||||
```
|
||||
mode : solitaire | competitive | coop
|
||||
victory : firstToTarget | highestAfterDays
|
||||
length : short | standard | campaign
|
||||
optionalRules : { reducedVisibility, sisterTrains, employeeRotation, emergencyToolbox }
|
||||
mode : solitaire | competitive | coop
|
||||
days : how long the game runs
|
||||
minCombinedRevenue, maxCollisionsPerDay, maxCollisionsTotal (0 = that condition is off)
|
||||
pvpCardsAllowed : a property of the type, not a control — the cards are unbuilt (setup.ts)
|
||||
optionalRules : { reducedVisibility, employeeRotation, emergencyToolbox }
|
||||
houseRules : { startingHand, extraStart, revenue }
|
||||
```
|
||||
|
||||
**The four game types** (`src/web/presets.ts`, Jesse's design 2026-08-23) are Co-op, Competitive,
|
||||
Cutthroat and Solitaire, plus **Custom** — which is not a fifth type but the state of having edited
|
||||
one, and is scored as whichever type it was edited away from. The type is *derived* by comparing a
|
||||
config against the four, never stored, so a saved game carries no label that can disagree with its own
|
||||
numbers. Seed, player count and Day count sit ABOVE the type on both screens as **parameters**: the
|
||||
types are formulas in the table size and the length (the Revenue floor is 3 per player per Day in
|
||||
Co-op, 2 in Competitive, nothing in Cutthroat), so changing one re-derives rather than making the game
|
||||
Custom.
|
||||
|
||||
These must lock at `Lobby.Start`. Changing `length` mid-game would move the finish line; changing
|
||||
`mode` would switch which failure floors apply (§3.4, §3.5). Neither has a coherent meaning
|
||||
mid-game, so the server should refuse rather than try.
|
||||
|
||||
`mode` and the seat count are checked together at start: solitaire is exactly 1, competitive and
|
||||
co-op are 2 to 4 (§2). The engine will happily build a 7-player Division, so this is the only place
|
||||
the limit exists.
|
||||
|
||||
---
|
||||
|
||||
## 4. Seating
|
||||
@@ -82,80 +185,148 @@ So seating must be settled before the opening D12 rolls, and the lobby should sh
|
||||
visually — a player should see whose Office lies east and west of theirs before the game begins.
|
||||
|
||||
**The opening rolls** (§4.4, §4.5) happen server-side at `Lobby.Start`, from the game seed: highest
|
||||
D12 takes the Eastern Division Point, and a second roll picks the opening Superintendent. Emit both
|
||||
as events so clients can show the rolls rather than just the outcome — it is the game's first moment
|
||||
of drama and there is no reason to hide it.
|
||||
D12 takes the Eastern Division Point, and a second roll picks the opening Superintendent.
|
||||
|
||||
**Both are implemented.** The Division roll was drawn and discarded until v0.4.1 — `void
|
||||
divisionRolls`, with seating fixed by array order — so §4.4 decided nothing and the seat/player split
|
||||
was untested at runtime. It orders the chain now: `seating[seat] = player`, ascending by roll from
|
||||
seat 0 (west, beside the Western Division Point) to the last seat (east, "highest is the Eastern
|
||||
Division Point").
|
||||
|
||||
The rule names only those two ends, because at a table the players are already sitting in a chain and
|
||||
the roll only says which way round it is. There is no physical table here, so the roll orders
|
||||
everybody — it uses a number every player is already told to roll, and it makes the roll matter to
|
||||
more than the winner. Ties break toward the lower player index sitting further east, the same
|
||||
first-max-wins convention the Superintendent roll uses.
|
||||
|
||||
Both rolls are kept on the state as `openingRolls`, indexed by player, **so the lobby can show the
|
||||
rolls rather than only their outcome** — it is the game's first moment of drama and there is no reason
|
||||
to hide it. Show the chain forming.
|
||||
|
||||
**Solitaire is unchanged and must stay so:** one player is one seat, seating is trivially `[0]`, and
|
||||
every published replay depends on that.
|
||||
|
||||
**What a seat is told as the game begins** (2026-08-23). The board used to simply appear, mid-Local
|
||||
Operations, with a log already several bot turns deep and nothing marking where the game began. The
|
||||
page now holds a deliberate beat on a handoff curtain, announces the game and its type, marks the top
|
||||
of the log, and shows the code and the type in the header for the rest of the game — none of which is
|
||||
new *data*, only the first time any of it was drawn.
|
||||
|
||||
**Employee Rotation** (Appendix B), if enabled, moves every player one seat left at the end of each
|
||||
Day, carrying their Revenue and the Fedora with them. Note what this means for the model: a player's
|
||||
*seat* changes while their *score* follows them, so `Player.index` and Office ownership must be
|
||||
separable. This is the one optional rule with real structural consequences — worth wiring in from the
|
||||
start rather than retrofitting.
|
||||
Day, carrying their Revenue and the Fedora with them. **The model supports this as of v0.4.0**:
|
||||
offices and districts are keyed by seat, hands and Revenue and the Fedora by player, and `seating[]`
|
||||
maps between them, so rotating is a one-line operation and every `areaOf(state, player)` caller
|
||||
follows without changing. The *rule* is still unimplemented — nothing rotates `seating` yet — but the
|
||||
structure it needs is in place and tested, which was the point of doing it early.
|
||||
|
||||
---
|
||||
|
||||
## 5. Disconnection and reconnection
|
||||
|
||||
Turn-based with one actor at a time makes this far easier than it would be in a real-time game.
|
||||
Being turn-based makes this far easier than it would be in a real-time game. (It is no longer "one
|
||||
actor at a time" — v0.4.0 gave every player their own `TurnState` precisely so that players can act in
|
||||
parallel where the rules allow it, `multiplayer.md` D19. Nothing about disconnection changes: the game
|
||||
still waits on whoever it is waiting on.)
|
||||
|
||||
**On disconnect:** keep the seat. Do not remove the player, do not auto-play. The game simply waits
|
||||
if it was their turn. Broadcast a `PlayerDisconnected` event so everyone else can see why nothing is
|
||||
happening — silence with no explanation is the worst version of this.
|
||||
if it was their turn. Broadcast a disconnect notice so everyone else can see why nothing is happening
|
||||
— silence with no explanation is the worst version of this. There is no such event type today; it is
|
||||
server-layer news about a *connection*, not about the game, so it belongs with the transport rather
|
||||
than in `GameEvent`, which must stay replayable from a seed.
|
||||
|
||||
**On reconnect:** the client presents its token, and the server replies with a full current view plus
|
||||
the event tail it missed. Because state is `fold(events)` ([`protocol.md`](protocol.md)), catching up
|
||||
is a replay, not a special case.
|
||||
**On connect, a client is told about every other seat at once** (2026-08-23). A change notice alone
|
||||
answered "who just left", never "who is here" — so a player arriving at a table where two people had
|
||||
not opened the game yet was told nothing about them at all, which is precisely the question at the
|
||||
moment a game starts. Each entry carries `seen`, separating **was here and dropped** from **has never
|
||||
opened the game**: the first will probably be back, the second needs somebody to send them the link.
|
||||
`seen` is remembered only for as long as the process runs, so after a restart every absent seat reads
|
||||
as "not here yet" — the more cautious of the two. **Bot seats are never reported**: a bot holds no
|
||||
connection and never will, and listing one puts "waiting on Bot 1" on every screen for the whole game
|
||||
(found by playing a three-seat game, not by reading the code).
|
||||
|
||||
**On reconnect:** the client presents its token and the server replies with a **full current view**.
|
||||
Not an event tail — a returning client needs the position, not the history of how it got there, and
|
||||
the server can always produce the position because it holds the game
|
||||
([`protocol.md`](protocol.md) §3). This is why catching up is not a special case.
|
||||
|
||||
**On a player who does not come back:** the honest options at this scale are to wait, or to let the
|
||||
host end the game. An optional **turn timer** is worth having — a lobby setting, off by default,
|
||||
never part of the rules — that on expiry takes the safest legal action:
|
||||
host end the game.
|
||||
|
||||
| Phase | Timeout action |
|
||||
| --- | --- |
|
||||
| Local Operations | `Switch.End` / `Draw.End` — forfeit the remaining action |
|
||||
| New Train | `NewTrain.PassCar` if legal, else place any legal car |
|
||||
| Mainline clearance | **Deny** clearance — the safe answer; a held train costs a Stage, a wrecked one costs 5 Revenue and counts toward the collision floor |
|
||||
| Load/Unload | `LoadUnload.End` |
|
||||
**Nothing moves on an absent player's behalf.** No forcing timer, no bot stepping in. Jesse's call,
|
||||
and the reasoning is the same one that keeps bots out of running games generally: the Superintendent's
|
||||
clearance decision materially affects *other* players' scores, so anything that answers it
|
||||
automatically changes the game rather than preserving it. Bots fill empty seats **at game start
|
||||
only** (`multiplayer.md` D8). Two ways out of a permanently-halted game are recorded in `TODO.md` and
|
||||
deliberately not designed here — a bot playing minimally-damaging defensive moves for someone who
|
||||
stepped away, and a player *consenting* to resign their railroad to a bot. Both need care; neither is
|
||||
a timer.
|
||||
|
||||
Denying clearance on timeout is the right default and worth stating explicitly: the asymmetry between
|
||||
the two outcomes is large, and in Competitive mode a timed-out clearance that causes a wreck could
|
||||
end the game for everybody (§3.4).
|
||||
### The turn clock — a stopwatch, not a shot clock
|
||||
|
||||
**Do not substitute an AI player.** The Superintendent's clearance decisions materially affect other
|
||||
players' scores; a bot making them on an absent player's behalf changes the game rather than
|
||||
preserving it.
|
||||
**Record how long each player takes on each turn.** Not to enforce anything: to find out where the
|
||||
game actually goes. "A 4-player game takes an evening" is currently a guess, and the useful version of
|
||||
that answer is per-phase — whether the wait is Local Operations, or making up a train, or one player
|
||||
thinking about a clearance while three others watch. That is what tells you which part is worth
|
||||
speeding up, and it is the one measurement no amount of bot simulation can produce, because the bot
|
||||
does not think.
|
||||
|
||||
Where it lives matters:
|
||||
|
||||
- **Outside the rules engine.** The engine has no clock and must not acquire one — it is pure and
|
||||
deterministic given a seed, which is what makes every replay and every test work.
|
||||
- **Outside the canonical record.** Timings are stored beside `{ engineVersion, seed, config, history }`,
|
||||
never inside it. A replay must reproduce a game from decisions alone; if it depended on wall-clock
|
||||
data it would no longer be reproducible from a seed.
|
||||
- **In the session host**, which already sees each intent arrive and already knows whose turn it is.
|
||||
Wall-clock at the start of a turn, wall-clock at the intent that ends it, per player per phase.
|
||||
|
||||
Show the current turn's elapsed time in the UI if it is unobtrusive — a table can self-regulate on
|
||||
information alone, which is the polite version of a shot clock and costs nothing.
|
||||
|
||||
---
|
||||
|
||||
## 6. Persistence
|
||||
|
||||
Persist the **event log**, not a state snapshot. It is smaller, it is the thing that already exists,
|
||||
and it makes a mid-game server restart a replay rather than a recovery.
|
||||
Persist the **intents**, not a state snapshot and not the event log. This section said "event log"
|
||||
until v0.4.0, on the strength of a `state = fold(events)` claim that was never true
|
||||
([`protocol.md`](protocol.md) §3) — folding the log rebuilds card plays and switching, and not one
|
||||
train movement. The intents genuinely do reconstruct a game, they are smaller still, and `fromSave`
|
||||
already replays them, so a mid-game server restart is a replay rather than a recovery.
|
||||
|
||||
```
|
||||
games : gameId → { config, seed, status, createdAt, gameCode }
|
||||
events : gameId → ordered event list
|
||||
sessions : token → { gameId, playerIndex, displayName }
|
||||
games : gameId → { engineVersion, config, seed, status, createdAt, gameCode }
|
||||
history : gameId → ordered Intent list
|
||||
sessions : token → { gameId, player, displayName }
|
||||
```
|
||||
|
||||
`engineVersion` is stored beside the seed because a replay is only faithful under the rules it was
|
||||
recorded with. A version mismatch on load must be refused explicitly rather than replayed and quietly
|
||||
diverged (`multiplayer.md` Phase 3).
|
||||
|
||||
Requirements are modest enough that the storage choice is genuinely open — SQLite on a single node
|
||||
covers this comfortably, and so would flat files with an append-only log per game. The constraint
|
||||
worth honouring is that the **event log is append-only**: rewriting history breaks the one property
|
||||
that makes replay trustworthy.
|
||||
covers this comfortably, and so would flat files with an append-only list per game. The constraint
|
||||
worth honouring is that the **history is append-only**: rewriting it breaks the one property that
|
||||
makes replay trustworthy. (Undo is not an exception — it replays the history *without* the last
|
||||
intent, and a server does not offer it at all.)
|
||||
|
||||
**Snapshotting** is an optimisation, not a requirement. If a Campaign game's log grows long enough
|
||||
that replay feels slow, periodically store a state snapshot with its `eventSeq` and replay forward
|
||||
from there. Do not build this until it is needed.
|
||||
**Snapshotting** is an optimisation, not a requirement. If a Campaign game's history grows long
|
||||
enough that replay feels slow, periodically store a state snapshot with its intent count and replay
|
||||
forward from there. Do not build this until it is needed. Measured today: a finished solitaire game
|
||||
is ~350 intents and a few hundred bytes.
|
||||
|
||||
**Retention.** Finished games **keep their event log, seed and config**, because post-game replay is
|
||||
a wanted capability (see [`overview.md`](overview.md#post-game-replay--a-desired-future-capability))
|
||||
and the log is the only thing it needs. Do not delete finished games by default.
|
||||
**Retention.** Finished games **keep their history, seed and config**, because post-game replay is a
|
||||
wanted capability (see [`overview.md`](overview.md#post-game-replay--a-desired-future-capability))
|
||||
and those three are the only things it needs — the replay viewer on the site already reads exactly
|
||||
that file. Do not delete finished games by default.
|
||||
|
||||
Retention is an operator setting rather than an application decision — a self-hosted instance among
|
||||
friends may as well keep everything, since a finished game's log is small. If a cap is wanted, expire
|
||||
by age or by total count and say so plainly in the UI, because a replay that silently stops existing
|
||||
is worse than one that was never offered.
|
||||
|
||||
**Turn timings** (§5) are stored per game beside the history, never inside it, and are retained on the
|
||||
same terms — they are only interesting in aggregate, and only across enough games to see a pattern.
|
||||
|
||||
Session tokens are the exception: those can expire on a short window once their game has finished.
|
||||
|
||||
---
|
||||
@@ -167,7 +338,8 @@ At small self-hosted scale these are not needed, and building them early costs m
|
||||
- **Matchmaking or a public game browser.** The game code covers discovery.
|
||||
- **Ranking, ladders, persistent profiles.** §3's timed modes produce comparable scores, so a
|
||||
high-score table would be a natural first addition — but it is a feature, not infrastructure.
|
||||
- **Spectators.** Straightforward to add later, since a spectator is just a view with no `private`
|
||||
section and no ability to send intents — the same shape post-game replay needs.
|
||||
- **Spectators.** Straightforward to add later: a spectator is a `Frame` with no hand and no ability
|
||||
to send intents. `snapshot` already takes a viewing seat, so the only new thing is a viewer that
|
||||
belongs to no seat — the same shape post-game replay already uses.
|
||||
- **Horizontal scaling and cross-process coordination.** One process holds every active game
|
||||
in memory.
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
# 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`), events that render standalone,
|
||||
per-player redacted views.
|
||||
|
||||
Four things moved:
|
||||
|
||||
**`state = fold(events)` is not true, and the intents are canonical instead.** `applyIntent` does go
|
||||
through the reducer, but the phase driver does not — it mutates and then emits a descriptive event —
|
||||
so fourteen of the forty-six event types are never reduced, including the clock and the whole Mainline
|
||||
phase. This costs nothing, because the plan never needed it: persistence is `{ seed, history }` (§10)
|
||||
and the wire carries `Frame`s rather than events (D2/D3). Reconnection is a fresh `Frame`, not an
|
||||
event tail.
|
||||
|
||||
**The save format is already the wire format.** A game is `{ seed, history: Intent[] }` and `fromSave`
|
||||
reconstructs it exactly. That is what makes persistence nearly free (§7).
|
||||
|
||||
**`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 /api/save ?token= — the seat's own save; 403 SAVE_AFTER_FINISH in a running Competitive game
|
||||
GET /api/health { ok, service, engineVersion, games } — unauthenticated; see below
|
||||
GET /api/games every game and lobby, summarised ┐
|
||||
GET /api/games/<id>/save the save, for keeping or replaying ├ ADMIN_SECRET
|
||||
DELETE /api/games/<id> ends a game, and returns its save ┘
|
||||
GET / the client
|
||||
```
|
||||
|
||||
`/api/health` exists because the client cannot otherwise tell a server from a static host. The same
|
||||
`dist/` is served both ways and the bundle is identical (D4), and every other route 404s an unknown
|
||||
path exactly as a static host does — so the splash asks, and closes its multiplayer door only when
|
||||
nothing names itself in reply. It is unauthenticated on purpose: it reveals that a Station Master
|
||||
server is answering and nothing else, no game and no seat. Its `games` field — `{ active, lobby }`
|
||||
— is what the StartOS package's health check reports as "3 games in progress".
|
||||
|
||||
**The three administrative routes are gated by `ADMIN_SECRET`, which is deliberately not the join
|
||||
secret.** Every player holds the join secret, so gating a delete with it would let anyone at the
|
||||
table destroy anyone else's game; this one belongs to whoever runs the server. It arrives in an
|
||||
`x-admin-secret` header rather than the query string, so it stays out of logs and referrers. When
|
||||
the variable is unset the routes answer 404 exactly as any unknown path does, so a server that was
|
||||
never given an administrator does not advertise that it has one.
|
||||
|
||||
**A delete returns the deleted game's save.** The intents are the game (D5), so what comes back is
|
||||
the whole thing and not a summary of it — the record survives even though the game does not, and
|
||||
nothing is destroyed without being handed to whoever destroyed it first.
|
||||
|
||||
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`.)*
|
||||
|
||||
**Seating is decided by §4.4's D12** as of v0.4.1, so `seating` is a real permutation rather than the
|
||||
identity mapping. Everything "round the table" — acting order, the deal, the Fedora — is seat
|
||||
arithmetic via `playerLeftOf`, and the three places that were doing it with player indices were found
|
||||
and fixed by turning the roll on. That is the point: the seat/player split is now exercised by every
|
||||
multi-player game instead of only by tests that rotate `seating` by hand.
|
||||
|
||||
**Identity: a session token scoped to one game**, exactly as `lobby-and-sessions.md` describes.
|
||||
Joining issues it; presenting it *is* the rejoin, because it already names the game and the person:
|
||||
|
||||
```
|
||||
Session token, gameId, player, displayName
|
||||
```
|
||||
|
||||
It names the **player**, not the seat — the two stopped being the same thing in v0.4.0, and Employee
|
||||
Rotation is precisely the case where a token naming a chair would seat someone in the wrong Office.
|
||||
The seat is `seatOf(state, player)`, one lookup and always current. (This said `seat` until the
|
||||
`lobby-and-sessions.md` review.)
|
||||
|
||||
The join secret is separate and server-wide — typed once and kept per-origin for convenience. It
|
||||
gates entry; the token identifies a seat.
|
||||
|
||||
**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 2-4** for competitive/coop (solitaire is 1) | Per `lobby-and-sessions.md` §2 — sized to what is actually exercised (`test/multiplayer.test.ts` plays 2, 3 and 4 to a finish), not to a guess. This entry read "6" until Phase 4 (v0.5.1); that number was never implemented or tested anywhere and the two docs had drifted apart. Raise it once somebody has played a bigger game and reported back, not before. |
|
||||
| **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.
|
||||
16. **Turn timings**, stored beside the history and never inside it — wall-clock per player per
|
||||
phase, so "how long does a 4-player game take, and which phase is the wait?" becomes a measured
|
||||
answer instead of a guess (`lobby-and-sessions.md` §5).
|
||||
|
||||
**Done when:** the server restarts mid-game and both clients carry on.
|
||||
|
||||
### Phase 4 — Lobby, sessions, reconnection · M
|
||||
|
||||
17. Join secret; per-game session token naming the **player** (not the seat); display name.
|
||||
18. Create/join by game code; bot seats; seating UI showing the west-to-east chain; config locked at
|
||||
start; **2–4 players enforced here**, since the engine enforces nothing.
|
||||
19. Disconnect keeps the seat and announces it; reconnect replies with a full `Frame`.
|
||||
20. Host rights pass to the earliest-joined remaining player if the host leaves before start.
|
||||
|
||||
**No forcing turn timer** — cut in the `lobby-and-sessions.md` review, and in `TODO.md` as something
|
||||
to explore only if halted games turn out to be a real problem. Nothing moves on an absent player's
|
||||
behalf.
|
||||
|
||||
**Done when:** four people join from four browsers at two different addresses, one closes the tab and
|
||||
rejoins where they left off.
|
||||
|
||||
### Phase 5 — Multiplayer content · M
|
||||
|
||||
21. The 10 Action and 12 Space-use cards; flip `opponentCardsInDeck` in `setup.ts`.
|
||||
22. Facing Point Locks, Water Column and Overpass stop being dormant — already wired.
|
||||
|
||||
**Done when:** an opponent-directed card resolves against another player and the Enhancement that
|
||||
answers it fires.
|
||||
|
||||
### Phase 6 — Package for StartOS · S
|
||||
|
||||
23. `.s9pk` per the workspace guide: interface, health check, backup of the data directory.
|
||||
|
||||
**Done when:** it installs on a StartOS box and players on two different addresses play a game.
|
||||
|
||||
**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.
|
||||
@@ -67,12 +67,18 @@ Three flows, and keeping them distinct is what keeps the implementation tractabl
|
||||
```
|
||||
|
||||
- **Intents** are what a player wants to do. They are proposals; they can be rejected.
|
||||
- **Events** are what happened. They are facts, ordered, and form the game's history.
|
||||
- **Events** are what happened. They are facts, ordered, and standalone — they narrate the game.
|
||||
They do **not** reconstruct it: see `protocol.md` §3, and the note below.
|
||||
- **Views** are per-player projections of state, with other players' hands redacted.
|
||||
|
||||
An event log that fully determines state is worth building even at this scale. It gives
|
||||
reconnection (replay to catch up), persistence (store the log, not a snapshot), and debugging (replay
|
||||
a reported bug exactly) for one design decision.
|
||||
A recorded, ordered history is worth keeping even at this scale. It gives persistence (store the
|
||||
history, not a snapshot) and debugging (replay a reported bug exactly) for one design decision.
|
||||
|
||||
**That history is the INTENTS, not the events.** This paragraph originally said an event log "fully
|
||||
determines state", and it does not — the phase driver mutates state and then emits a descriptive
|
||||
event, so fourteen of the forty-six event types are never reduced, including every train movement.
|
||||
A game is `{ seed, history: Intent[] }` and `fromSave` replays it exactly. Reconnection is therefore a
|
||||
fresh view rather than a catch-up replay, which is simpler anyway.
|
||||
|
||||
### Post-game replay — a desired future capability
|
||||
|
||||
@@ -84,9 +90,13 @@ traffic, the wrecks, who was Superintendent when. For a game whose drama is larg
|
||||
you are playing your own Office, this is worth more than it would be in most games. You spend the
|
||||
game watching your own station; the replay is where you find out what the railroad was doing.
|
||||
|
||||
State is already `fold(events)` over an ordered, append-only log, and all randomness derives from a
|
||||
stored seed. Replay is therefore a **read-only projection over the existing log** — no new rules-engine
|
||||
surface, no second code path, nothing the server has to do differently during play.
|
||||
A game is already reconstructible from `{ seed, history: Intent[] }`, and all randomness derives from
|
||||
that stored seed. Replay is therefore a **read-only re-run of the stored intents** — no new
|
||||
rules-engine surface, no second code path, nothing the server has to do differently during play.
|
||||
|
||||
(This paragraph said "state is `fold(events)`" until v0.4.0. It is not: the phase driver mutates state
|
||||
and then emits a descriptive event, so folding the log rebuilds card plays and switching but not one
|
||||
train movement or clock tick. The intents are what is canonical.)
|
||||
|
||||
Three constraints keep it cheap, and all three are free if honoured from the start:
|
||||
|
||||
@@ -115,13 +125,16 @@ trains move and wrecks happen while the Superintendent rules on clearances.
|
||||
That means the server must push. A request/response API alone would leave four players polling to
|
||||
watch a fifth switch cars.
|
||||
|
||||
**WebSocket, with the HTTP endpoints alongside it** for lobby operations (list games, create, join)
|
||||
where a request/response shape is the natural fit. Server-Sent Events would also serve, since the
|
||||
push is nearly one-directional and intents are rare enough to send over HTTP — worth keeping in mind
|
||||
if the eventual stack makes SSE materially simpler.
|
||||
**Server-Sent Events, with HTTP POST alongside** for lobby operations and for intents, where a
|
||||
request/response shape is the natural fit. This paragraph originally leaned the other way, toward
|
||||
WebSocket with SSE as the fallback; it was settled the other way in
|
||||
[`multiplayer.md`](multiplayer.md) D5, because the push is nearly one-directional, intents are rare,
|
||||
and SSE reconnects itself through anything in the path without a protocol upgrade to negotiate.
|
||||
|
||||
What matters more than the choice: **every state change reaches clients as an event on one ordered
|
||||
stream**. Clients apply events in order and never mutate state locally in a way that could drift.
|
||||
What matters more than the choice: **every state change reaches clients on one ordered stream**, and
|
||||
clients never mutate state locally in a way that could drift. What travels on that stream is a
|
||||
redacted `Frame`, not the raw events — see `multiplayer.md` D2/D3 for why one reducer and one
|
||||
redaction chokepoint beat shipping the history to every client.
|
||||
|
||||
---
|
||||
|
||||
@@ -141,7 +154,7 @@ is a lobby setting layered on top of the rules, never part of them.
|
||||
Worth stating, because small self-hosted scale makes several standard concerns disappear:
|
||||
|
||||
- **No horizontal scaling.** A handful of concurrent games fits in one process. Game state lives in
|
||||
memory; the event log is persisted for durability, not for coordination.
|
||||
memory; the intent history is persisted for durability, not for coordination.
|
||||
- **No matchmaking service.** Players share a game code (see `lobby-and-sessions.md`).
|
||||
- **No accounts system, initially.** A display name plus a session token is enough to join and
|
||||
reconnect. Real accounts can be layered on later without touching the game engine.
|
||||
@@ -159,7 +172,7 @@ Keep the **rules engine** free of any knowledge of networking, storage, or playe
|
||||
│
|
||||
game session owns one game's state, applies intents, emits events
|
||||
▲
|
||||
lobby / transport connections, reconnection, persistence, HTTP + WebSocket
|
||||
lobby / transport connections, reconnection, persistence, HTTP POST + SSE
|
||||
```
|
||||
|
||||
The rules engine being pure and deterministic is what makes the whole thing testable — you can drive
|
||||
|
||||
+110
-214
@@ -1,269 +1,165 @@
|
||||
# Protocol
|
||||
|
||||
The client↔server message vocabulary, and how per-player views are redacted. Written against
|
||||
[`../rules/rules-v0.2.md`](../rules/rules-v0.2.md) and [`game-state.md`](game-state.md).
|
||||
The client↔server message vocabulary, and how per-player views are redacted.
|
||||
|
||||
Three message families, matching the flows in [`overview.md`](overview.md):
|
||||
**The vocabulary is code, not prose.** This document used to spell out every message with an
|
||||
illustrative name and an illustrative payload. It was written before the engine existed, and the
|
||||
engine then went and defined all three families for real — so the document became a second,
|
||||
drifting, subtly-wrong copy of three TypeScript files. It is now a map of those files plus the rules
|
||||
that live nowhere else.
|
||||
|
||||
- **Intent** — client → server. A proposal. May be rejected.
|
||||
- **Event** — server → clients. A fact. Ordered, and the game's history.
|
||||
- **View** — server → one client. A redacted state snapshot.
|
||||
| Family | Direction | Authority |
|
||||
| --- | --- | --- |
|
||||
| **Intent** — a proposal, may be rejected | client → server | [`src/engine/intents.ts`](../../src/engine/intents.ts) — `Intent`, 28 variants |
|
||||
| **Event** — a fact, ordered; narration, not the record (§3) | server → clients | [`src/engine/events.ts`](../../src/engine/events.ts) — `GameEvent`, 46 variants |
|
||||
| **View** — a redacted projection | server → one client | [`src/sim/view.ts`](../../src/sim/view.ts) — `Frame` and `snapshot(state, …, seat)` |
|
||||
| **Rejection** — why an intent was refused | server → one client | `RejectionCode` in `intents.ts` |
|
||||
|
||||
Message names below are illustrative. What matters is the *set* of decisions a player can make, which
|
||||
is fixed by the rules.
|
||||
Read those files for the shapes. Each variant carries its own doc comment explaining the rule behind
|
||||
it and, where the field arrangement is load-bearing, why it is arranged that way. What follows is
|
||||
what the types cannot say.
|
||||
|
||||
The transport that carries these — SSE down, HTTP POST up — and the decision to push `Frame` + `Menu`
|
||||
rather than a raw event stream, are in [`multiplayer.md`](multiplayer.md) (D2/D3, D5). Written against
|
||||
[`../rules/rules-v0.2.md`](../rules/rules-v0.2.md) and [`game-state.md`](game-state.md); the flows are
|
||||
in [`overview.md`](overview.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Intents
|
||||
|
||||
Every intent carries `gameId`, `playerId`, and a `seq` the server uses to reject duplicates from a
|
||||
reconnecting client.
|
||||
**Intents are already the wire format.** A saved game is `{ seed, history: Intent[] }` and `fromSave`
|
||||
replays it, which means the client→server message type was fixed the day save/restore worked. There
|
||||
is no separate protocol layer to design and nothing to keep in sync.
|
||||
|
||||
### 1.1 Local Operations Phase
|
||||
Every intent is validated server-side even when the client only ever offers legal ones — `legalActions`
|
||||
and `check` are the same code, so a client-side menu is a convenience, never a guarantee.
|
||||
The framing an intent needs on the wire (`gameId`, who sent it, a `seq` for de-duplicating a
|
||||
reconnecting client's resend) is added by the transport and is not part of `Intent` itself: the engine
|
||||
is given the actor by its caller.
|
||||
|
||||
The phase opens with a three-way exclusive choice (§6). Choosing one forecloses the others for that
|
||||
Stage.
|
||||
### What is deliberately NOT an intent
|
||||
|
||||
```
|
||||
LocalOps.Choose { option: switch | draw | freightAgent }
|
||||
```
|
||||
These are the places where offering a choice would be a rules violation, and they are worth stating
|
||||
here because "the message is missing" looks like an oversight in a way that "the message exists" never
|
||||
does.
|
||||
|
||||
**If `switch`** — six Moves, divisible across Crew Trays (§6.1). Five Moves under Reduced Visibility
|
||||
during night Stages (Appendix B).
|
||||
- **Picking up cars.** Moving into standing cars couples them automatically and mandatorily (§A.4).
|
||||
The engine does it while resolving `switch.move`.
|
||||
- **Which cars come off.** `switch.dropCars` takes a *count*: cars come off in seated order (§A.3), so
|
||||
the only free choice is how many, and from which end (`fromNose`).
|
||||
- **Mainline movement.** The whole phase is automatic except the Superintendent's clearance decision
|
||||
(`mainline.clearance`, §8.1) — which arrives out of turn order and may concern another player's
|
||||
train. It is the one place a player acts during someone else's traffic.
|
||||
- **Scheduling.** `trainScheduled` comes from the 1D12 (§7); nobody chooses it.
|
||||
|
||||
```
|
||||
Switch.Move { trayId, to: GridCoord }
|
||||
Switch.DropCars { trayId, count } -- from the tray end; order is fixed (§A.3)
|
||||
Switch.SetDirection{ trayId, direction } -- costs a Move; a Move never reverses (§2.4)
|
||||
Switch.End { } -- forfeit remaining Moves
|
||||
```
|
||||
### Two loops the client must not flatten
|
||||
|
||||
Pick-up is **not** an intent. Moving into standing cars couples them automatically and mandatorily
|
||||
(§A.4) — the server does it as part of resolving `Switch.Move`. Offering a choice would be a rules
|
||||
violation.
|
||||
|
||||
`Switch.DropCars` takes a count, not a set of car ids: cars come off in seated order (§A.3), so the
|
||||
only free choice is how many.
|
||||
|
||||
**If `draw`** (§6.2):
|
||||
|
||||
```
|
||||
Draw.FromHomeOffice { }
|
||||
Draw.FromDepartment { slot: 0|1|2 }
|
||||
Card.Play { cardId, placement? } -- placement required for track/facility/office cards
|
||||
Card.Discard { cardId, toSlot: 0|1|2 }
|
||||
Draw.End { } -- server rejects while hand > 3
|
||||
```
|
||||
|
||||
`placement` is a `GridCoord` for track, Facility and Office cards (§11.2); absent for train cards,
|
||||
which go to the timetable or to an Extra's staging.
|
||||
|
||||
**If `freightAgent`** — exactly one of three operations (§6.3):
|
||||
|
||||
```
|
||||
FreightAgent.StockToOutbound { facilityId, stockType, loaded }
|
||||
FreightAgent.InboundToClass { facilityId, stockIndex }
|
||||
FreightAgent.UnjamToClass { facilityId, from: outbound|inbound|menAtWork, index }
|
||||
```
|
||||
|
||||
### 1.2 New Train Phase
|
||||
|
||||
Per train being made up, each player adds one car in Superintendent-then-left order (§7):
|
||||
|
||||
```
|
||||
NewTrain.PlaceCar { trainId, stockType, loaded }
|
||||
NewTrain.PassCar { trainId } -- only legal when no suitable car exists in the Division Yard
|
||||
```
|
||||
|
||||
**This cycles** (§7, Gap 9). The server keeps going round the table — one car per player per pass —
|
||||
until the consist is full or no suitable car remains in the Division Yard. It is not a single pass, so
|
||||
the client must not assume one prompt per player per train. At five or more players the round ends
|
||||
mid-pass when the consist fills; players not yet reached simply are not prompted.
|
||||
|
||||
`PassCar` must be validated, not trusted: §7 requires the player to "make every effort to find a
|
||||
suitable car." The server checks the Division Yard against the train's `consistSpec` and rejects a
|
||||
pass when a legal car is available. A pass by every player in a full pass is the loop's other
|
||||
termination condition.
|
||||
|
||||
For a played Extra (§7, Gap 4c) the owning player chooses its whole consist:
|
||||
|
||||
```
|
||||
Extra.Launch { trainCardId, at: westDP | eastDP, direction }
|
||||
Extra.LoadConsist { trainCardId, stock: [{stockType, loaded}] } -- ≤3 cars + caboose
|
||||
```
|
||||
|
||||
### 1.3 Mainline Phase
|
||||
|
||||
The phase is automatic **except** for the Superintendent's clearance decision (§8.1, fourth
|
||||
condition). The server pauses movement, sets `clock.pendingDecision`, and waits:
|
||||
|
||||
```
|
||||
Mainline.Clearance { trainId, allow: boolean }
|
||||
```
|
||||
|
||||
Only the current Superintendent may send this, and only for the train named in the pending decision.
|
||||
This intent arrives out of the normal turn order and may concern another player's train — the one
|
||||
place in the game where a player acts during someone else's traffic.
|
||||
|
||||
The Red Flag card (Emergency Toolbox, Appendix B) also interrupts here:
|
||||
|
||||
```
|
||||
RedFlag.Play { } -- averts an imminent collision; card is removed from the game
|
||||
```
|
||||
|
||||
### 1.4 Load/Unload Phase
|
||||
|
||||
Each Laborer and Porter is usable once per Stage (§9.1). Resolve one at a time.
|
||||
|
||||
```
|
||||
Porter.Board { facilityId } -- §9.2, +1 Revenue
|
||||
Porter.Detrain { facilityId } -- §9.2, +1 Revenue
|
||||
Laborer.AdvanceLoad { facilityId, loadRef } -- Green → MEN → AT → WORK → car (§9.3)
|
||||
Laborer.BeginUnload { facilityId, carIndex } -- first step of unloading
|
||||
LoadUnload.End { }
|
||||
```
|
||||
|
||||
A freight load needs four `Laborer.AdvanceLoad` intents to score one point; a Porter scores in one
|
||||
(§9.3). The client should show this clearly — it is the single most surprising thing about the game's
|
||||
economy for a new player.
|
||||
|
||||
### 1.5 Lobby
|
||||
|
||||
Covered in [`lobby-and-sessions.md`](lobby-and-sessions.md); listed here for completeness.
|
||||
|
||||
```
|
||||
Lobby.Create { config }
|
||||
Lobby.Join { gameCode, displayName }
|
||||
Lobby.Leave { }
|
||||
Lobby.SetConfig { config } -- host only, before start
|
||||
Lobby.Start { } -- host only
|
||||
```
|
||||
- **New Train cycles.** §7 goes round the table one car per player per pass until the consist is full
|
||||
or no suitable car remains. It is *not* one prompt per player per train. At five or more players a
|
||||
round can end mid-pass with players never prompted.
|
||||
- **`newTrain.passCar` is validated, not trusted.** §7 requires "every effort to find a suitable car",
|
||||
so the engine checks the Division Yard against the consist spec and refuses a pass when a legal car
|
||||
is there.
|
||||
|
||||
---
|
||||
|
||||
## 2. Rejections
|
||||
|
||||
An intent is rejected with a reason a client can render, not a stack trace:
|
||||
`RejectionCode` in `intents.ts` is the closed set. `applyIntent` returns
|
||||
`{ ok: false, code, message }` — a reason a client can show, never a stack trace. The `message` today
|
||||
is the code restated (`apply.ts`); a client that wants prose should key off `code`, which is stable,
|
||||
rather than parsing it.
|
||||
|
||||
```
|
||||
Rejected { seq, code, message, ... }
|
||||
|
||||
codes: NOT_YOUR_TURN | WRONG_PHASE | OPTION_ALREADY_CHOSEN | NO_MOVES_REMAINING
|
||||
ILLEGAL_MOVE | TRACK_OCCUPIED | NOT_OPERATIONAL_RAIL | WOULD_REVERSE
|
||||
CONSIST_FULL | CONSIST_ORDER | HAND_LIMIT | CARD_NOT_IN_HAND
|
||||
NO_PLACEMENT | NOT_CONNECTED | RESOURCE_SPENT | SUITABLE_CAR_EXISTS
|
||||
NOT_SUPERINTENDENT | NO_PENDING_DECISION
|
||||
```
|
||||
|
||||
The client may pre-validate to grey out illegal moves — good UX — but the server's answer is the only
|
||||
one that counts (`overview.md`). Rejections should be rare in a well-built client and are therefore
|
||||
worth logging server-side: a spike usually means client and server rules have drifted.
|
||||
A client may pre-validate to grey out illegal moves — that is what `legalActions` is for, and it is
|
||||
good UX — but the server's answer is the only one that counts. **Rejections should therefore be rare,
|
||||
which makes them worth logging server-side: a spike means client and server rules have drifted.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Events
|
||||
|
||||
Events are the authoritative history. State is `fold(events)`, which is what gives reconnection,
|
||||
persistence and replay for one design decision.
|
||||
**Events narrate; they do not reconstruct.** This section claimed `state = fold(events)` until
|
||||
v0.4.0, and it was never true: `applyIntent` goes through `reduce`, but the phase driver mutates state
|
||||
and *then* emits a descriptive event, so fourteen of the forty-six event types are never reduced — the
|
||||
clock, and the whole Mainline phase, which is to say every train movement in the game.
|
||||
|
||||
**Phase and clock**
|
||||
**The canonical record is `{ seed, history: Intent[] }`**, replayed by `fromSave`. That is what save,
|
||||
share, undo, restart recovery and post-game replay all run on, and it is what persistence stores
|
||||
(`multiplayer.md` §10). Events drive the display and the notifications. Do not build anything on
|
||||
folding them without first making the phase driver reduce, which is a rewrite of the most rule-dense
|
||||
code in the project and buys nothing the current plan uses.
|
||||
|
||||
```
|
||||
StageBegan { day, stage }
|
||||
PhaseBegan { phase }
|
||||
ActorChanged { playerIndex | null }
|
||||
SuperintendentChanged { playerIndex }
|
||||
DayEnded { day, revenues }
|
||||
```
|
||||
**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.
|
||||
|
||||
**Local operations**
|
||||
Two consequences worth knowing before adding an event:
|
||||
|
||||
```
|
||||
LocalOpsOptionChosen { playerIndex, option }
|
||||
TrayMoved { trayId, from, to, movesRemaining }
|
||||
CarsCoupled { trayId, stock[] } -- automatic, from a Move
|
||||
CarsDropped { trayId, at, stock[] }
|
||||
CardDrawn { playerIndex, source, cardId? } -- cardId omitted for other players
|
||||
CardPlayed { playerIndex, cardId, placement? }
|
||||
OfficeUpgraded { playerIndex, from, to } -- property change; connections unaffected (§11.3)
|
||||
CardDiscarded { playerIndex, cardId, toSlot }
|
||||
DeckReshuffled { }
|
||||
```
|
||||
|
||||
**Trains**
|
||||
|
||||
```
|
||||
TrainScheduled { trainCardId, timetableSlot } -- from the 1D12 roll (§7)
|
||||
TrainMadeUp { trainCardId, trayId, at }
|
||||
CarPlacedOnTrain { playerIndex, trayId, stock }
|
||||
ClearanceRequested { trainId, followingInto, occupiedBy }
|
||||
ClearanceGiven { trainId, allow }
|
||||
TrainHighballed { trayId, from, to }
|
||||
TrainMoved { trayId, from, to }
|
||||
TrainCompleted { trayId, atDivisionPoint }
|
||||
```
|
||||
|
||||
**Consequences**
|
||||
|
||||
```
|
||||
CollisionOccurred { at, trains[], struckCars[], faultPlayer, penalty: 5 }
|
||||
RedFlagPlayed { playerIndex, avertedAt }
|
||||
RevenueChanged { playerIndex, delta, total, reason }
|
||||
CollisionCountChanged { collisionsToday }
|
||||
GameEnded { result, winner?, reason }
|
||||
```
|
||||
|
||||
`CollisionOccurred` carries `faultPlayer` explicitly rather than leaving clients to derive it. Fault
|
||||
depends on *where* the wreck happened — Superintendent for a Mainline card, the local player between
|
||||
his Limits (§10) — and getting that wrong misattributes a −5 and, in Competitive, contributes to a
|
||||
floor that ends the game.
|
||||
- **`trainsDestroyed` carries the player at fault (`player`) explicitly** rather than leaving clients to
|
||||
derive it (the event was called `collisionOccurred` in this design; the engine emits `trainsDestroyed`).
|
||||
Fault depends on *where* the wreck happened — Superintendent for a Mainline card, the local player
|
||||
between their Limits (§10) — and getting it wrong misattributes a −5 and, in Competitive, feeds a
|
||||
floor that ends the game.
|
||||
- **Events are not what goes over the wire.** The server applies the intent and pushes the resulting
|
||||
`Frame` (`multiplayer.md` D2/D3). Events are narration and cues, not the protocol — and a reconnect
|
||||
gets a fresh `Frame` rather than the tail it missed.
|
||||
- **What events EARN does go over the wire, as four transient signals** (2026-08-23): the sound cues,
|
||||
the timetable slot a D12 just filled, a one-line announcement, and the id of the card that just came
|
||||
into this seat's hand. They ride beside the `Frame` rather than on it because they mark a *moment*
|
||||
and are consumed — putting them on the Frame would re-fire them on every redraw. Before this a
|
||||
remote client got none of them, so multiplayer had no sound at all, no flash and no announcements
|
||||
while solitaire had all three. The first three are shared and identical in every seat's push; the
|
||||
fourth is **not** — `game.justDrawn` is one field for the whole game and does not say whose card it
|
||||
is, so the server remembers who drew and sends it to that seat alone
|
||||
(`test/server/session.test.ts`, "the four transient signals"). A reconnect gets none of the shared
|
||||
three: a fresh connection is drawing a state, and replaying the sounds of everything it missed is a
|
||||
burst of noise about the past.
|
||||
|
||||
---
|
||||
|
||||
## 4. Views and redaction
|
||||
|
||||
Each client receives a projection with other players' private state removed.
|
||||
Each client receives `snapshot(state, …, seat)` — a `Frame`, which is already a projection built for
|
||||
rendering and already takes the viewing seat. Nothing else is sent.
|
||||
|
||||
| State | Visibility |
|
||||
| --- | --- |
|
||||
| Board: track grids, Offices, Limits, trains, standing cars | **Public** |
|
||||
| Facilities: boxes, `MEN AT WORK`, Laborer/Porter usage | **Public** |
|
||||
| Division Yard, Classification Yard contents | **Public** — physical piles on the table |
|
||||
| Salvage Yard | **Public** — face up, explicitly so players can audit discards (§2.6) |
|
||||
| Salvage Yard | **Public** — face up, so players can audit discards (§2.6) |
|
||||
| Department slots (the three face-up cards) | **Public** |
|
||||
| Home Office deck **contents and order** | **Secret** — never sent, to anyone |
|
||||
| Home Office deck **count** | Public — players can see the pile's height |
|
||||
| A player's hand | **Owner only**; others see the count |
|
||||
| Red Flag held | Public — it is a known starting card (Appendix B) |
|
||||
| Red Flag held | Public — a known starting card (Appendix B) |
|
||||
| Revenue totals | **Public** — the race is the game |
|
||||
| RNG seed | **Secret** — sending it leaks all future shuffles |
|
||||
| RNG seed and state | **Secret** — either leaks every future shuffle and roll |
|
||||
|
||||
```
|
||||
View {
|
||||
public : PublicState -- identical for every client
|
||||
private : { hand: CardId[], redFlag: boolean }
|
||||
you : PlayerIndex
|
||||
canAct : boolean
|
||||
legalIntents? : IntentSummary[] -- optional server-computed affordances
|
||||
}
|
||||
```
|
||||
**The redaction surface is four fields, not sixty event types** — `seed`, `rngState`,
|
||||
`deckReshuffled.order`, and `cardDrawn.cardId` when the draw was from the Home Office. The full
|
||||
argument, including why `trainScheduled` is public despite being a die roll, is in
|
||||
[`multiplayer.md` §7](multiplayer.md). It reduces to: **call `snapshot` and never send `GameState`.**
|
||||
|
||||
**Two redaction traps.** First, `CardDrawn` from the Home Office must omit `cardId` for every client
|
||||
except the drawer — the obvious version of this event leaks the draw to the table. Second, the deck
|
||||
*count* is public but the *order* is secret; a naive implementation that ships the deck array and
|
||||
tells the client not to look is not redaction.
|
||||
|
||||
`legalIntents` is optional but worth it: the server already computes legality to validate, so
|
||||
returning the affordance set costs little and removes any need for the client to reimplement rules
|
||||
like turnout directionality or the four-slot consist limit.
|
||||
**This is enforced, not assumed.** The redaction test is the single most important test in the
|
||||
multiplayer work: everything else degrades gracefully, a redaction bug hands a player the deck.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ordering and idempotency
|
||||
|
||||
- Events carry a monotonic `eventSeq` per game. Clients apply strictly in order and request a replay
|
||||
on a gap rather than guessing.
|
||||
- Events carry a monotonic sequence per game. Clients apply strictly in order and request a replay on
|
||||
a gap rather than guessing.
|
||||
- Intents carry a client `seq`. The server ignores a repeat of one it has already applied, so a
|
||||
reconnecting client can safely resend anything it is unsure about.
|
||||
- The server never applies two intents concurrently within a game. With one actor at a time this is
|
||||
free — a per-game queue is sufficient and there is no need for anything cleverer at this scale.
|
||||
reconnecting client can safely resend anything it is unsure about. **The count is the server's,
|
||||
for the life of the game**: the connect push carries the seat's last accepted `seq` (`lastSeq`)
|
||||
and the client continues from it, never from 1 — a page that restarted its own count after a
|
||||
reload re-sent a number the server had already applied, and the move was silently swallowed as
|
||||
a resend (v0.8.4).
|
||||
- **The server never applies two intents concurrently within a game.** A per-game queue is sufficient
|
||||
and there is nothing cleverer to do at this scale. It is a real queue (`http.ts`'s `inTurn`), not a
|
||||
reliance on the single thread: the handler awaits the disk write between applying and answering,
|
||||
and two moves arriving together used to interleave across that await (v0.8.4). 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.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Station Master — Components and Markers
|
||||
|
||||
**Version 0.8.6** · 2026-09-29
|
||||
|
||||
**Scope:** non-card physical components and supplies. Card-created facilities, workers, deck piles,
|
||||
hand state, timetable state and other markers are documented with their cards or in the
|
||||
[rules book](rules.md).
|
||||
|
||||
The figures below are checked against `ROLLING_STOCK_SUPPLY` and the supply constants in
|
||||
`src/engine/content.ts`. They are physical inventory rather than deck tuning, which is why they are
|
||||
printed here at all — per-category CARD counts are not published, because they move with play balance.
|
||||
|
||||
## Rolling stock
|
||||
|
||||
Rolling stock has a type and a load state. In the interface, coloured cars are loaded and white cars are empty. The Division Yard starts with the following supply.
|
||||
|
||||
| Type | Loaded | Empty | Total | Use |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| Coach | 8 | 8 | 16 | Passenger work. |
|
||||
| Boxcar | 10 | 10 | 20 | Freight House outbound/inbound; Grocer’s Warehouse inbound. |
|
||||
| Hopper | 8 | 8 | 16 | Mine Tipple outbound; Power Plant inbound. |
|
||||
| Reefer | 5 | 5 | 10 | Packing Sheds outbound; Grocer’s Warehouse inbound. |
|
||||
| Tank car | 6 | 6 | 12 | Refinery outbound; Power Plant inbound. |
|
||||
| Caboose | 6 | 0 | 6 | Required or permitted at the rear of the applicable train cards. |
|
||||
| **Total** | **43** | **35** | **78** | |
|
||||
|
||||
The engine is not rolling stock and does not count against the four-car Crew Tray limit.
|
||||
|
||||
## Other supplies
|
||||
|
||||
| Component | Count | Note |
|
||||
| --- | ---: | --- |
|
||||
| Crew Trays | players + 3 | Engine and tray are one combined resource; there is no "engine without a tray". |
|
||||
| Office cards | 4 | Every player starts on one. The starting Office is not drawn from the deck. |
|
||||
| Limits signs | 8 | "2N + spares", so relocating one is never a supply question. |
|
||||
|
||||
## The two yards
|
||||
|
||||
### Division Yard
|
||||
|
||||
The Division Yard is the active supply. It provides:
|
||||
|
||||
- loaded cars to a Freight Agent stocking a green outbound box;
|
||||
- empty cars for building up trains;
|
||||
- an empty coach when passengers detrain; and
|
||||
- an empty matching freight car when a loaded freight car begins unloading.
|
||||
|
||||
When a train completes a run or is destroyed, its caboose returns to the Division Yard. When a car is taken from the Division Yard and that yard has become entirely empty, every car from the Classification Yard moves back into the Division Yard immediately.
|
||||
|
||||
### Classification Yard
|
||||
|
||||
The Classification Yard collects used rolling stock: cars displaced by boarding passengers, cars
|
||||
cleared from inbound red boxes, cars removed by unjamming a facility, ordinary cars from completed
|
||||
or destroyed trains, and empty cars replaced by a completed outbound freight load. It is not a
|
||||
player-selectable source. It returns to service only when the Division Yard is empty.
|
||||
|
||||
> **This is a one-way ratchet for COACHES, and it decides games.** Boarding sends an emptied coach
|
||||
> here; detraining takes a fresh empty out of the *Division* Yard. Nothing returns a coach to the
|
||||
> Division Yard except the bare-yard refill — and a yard kept topped up with freight cars returning
|
||||
> from industries may never run bare. Measured over one three-Day game, every coach was here by the
|
||||
> middle of Day 2 and stayed. See [Rules](rules.md) §4.6.
|
||||
|
||||
## Crew Trays and trains
|
||||
|
||||
Crew Trays represent an engine and its coupled consist. The game creates **players + 3** free trays at setup. A tray is scarce: a due train without a free tray is held for that Stage, and Extras wait until one becomes free.
|
||||
|
||||
Within a tray, the engine can pull, push, or be between cars while switching. To depart an Office for the Mainline, it must be made up with the engine at an outer end; if it has a caboose, the caboose must be at the opposite outer end. A train may have at most four cars. A scheduled train can run under-strength but never with more than its card allows.
|
||||
|
||||
## Fedora
|
||||
|
||||
The **Fedora** is the physical marker for the Superintendent. The player with it resolves
|
||||
following-train clearance decisions, takes the Yard Office and Red Flag questions, and is the player
|
||||
every round round the table starts with. A Mainline collision is their fault by §10, and costs 5
|
||||
Revenue. The initial holder is the first player tied for the highest Superintendent setup D12 roll.
|
||||
|
||||
It passes at the end of Stages 3, 6, 9 and 12 — every third Stage, at the Supervisor Shift. The
|
||||
handover is **announced on screen and written into the history**: it is the one thing in the game
|
||||
that changes hands on the clock rather than because somebody did something, so nobody is watching
|
||||
for it. Note that the Supervisor Shift refreshes every Laborer and Porter *every* Stage
|
||||
while the Fedora moves only every third.
|
||||
|
||||
## D12 and seeded randomness
|
||||
|
||||
The D12 is used by the engine for:
|
||||
|
||||
- the initial seating roll in a multi-player engine game;
|
||||
- the initial Superintendent roll;
|
||||
- a timetabled train’s timetable slot; and
|
||||
- all shuffled deck order and automatic setup selection through the same seeded random stream.
|
||||
|
||||
The rules engine uses a deterministic 32-bit seeded random generator. The same numeric seed, player configuration, house rules, and accepted intent sequence reconstruct the same game. A seed by itself is not enough when house rules differ.
|
||||
|
||||
## The Office, tier by tier
|
||||
|
||||
Every player has one Office card. It is a property of the district rather than a card that is
|
||||
swapped, so an upgrade raises the numbers in place and leaves anything built beside it alone.
|
||||
|
||||
| Office | A/D tracks | Porters | Passengers out / in | Control Point |
|
||||
| --- | ---: | ---: | --- | :---: |
|
||||
| Whistle Post | 1 | 0 | 0 / 0 | — |
|
||||
| Depot | 2 | 1 | 1 / 1 | yes |
|
||||
| Station | 3 | 2 | 2 / 2 | yes |
|
||||
| Terminal | 4 | 3 | 3 / 3 | yes |
|
||||
|
||||
**A/D tracks are what decide whether an arrival is a collision.** A train pulling in to an Office
|
||||
with every track occupied collides (§8.3) unless an **Interlocking** holds it out on the Limit
|
||||
Track. A train held that way takes the first track to free, ahead of anything arriving after it.
|
||||
|
||||
**A Whistle Post is not a Passenger Facility.** It has no Porters and no passenger boxes, so nobody
|
||||
boards or gets off there however well the district is built, and the passenger Modifiers — Waiting
|
||||
Area, Restaurant, Hotel — cannot be played at one.
|
||||
|
||||
**A Control Point divides the Mainline into Subdivisions**, which is what §8.1 reasons about. A
|
||||
table of Whistle Posts is one Subdivision from end to end; every upgrade splits one in two and buys
|
||||
the Division capacity.
|
||||
|
||||
**Games open on a Depot** unless the table turns on "Players start with Whistle Posts, not Depots"
|
||||
when the game is created. See the Home deck reference.
|
||||
+58
-64
@@ -20,13 +20,31 @@ These stay as-is. Everything below is derived from them.
|
||||
> placeholder for exactly this material, and the balance measurements in Gap 12 were taken against a
|
||||
> ruleset that does not match the design.
|
||||
|
||||
## For players and testers
|
||||
|
||||
Written to be handed to somebody who is about to play, rather than to somebody building the game.
|
||||
|
||||
| Document | What it is |
|
||||
| --- | --- |
|
||||
| [`quickstart.md`](quickstart.md) | **Start here if you have never played.** The point of the game, how a Stage runs, what is on screen, how you win, a first twenty minutes, and what to report. |
|
||||
| [`rules.md`](rules.md) | **The rules in full**, as the engine actually runs them, with a FAQ. |
|
||||
| [`home-deck.md`](home-deck.md) | How the Home Office deck is dealt, drawn and played out. |
|
||||
| [`mainline-deck.md`](mainline-deck.md) | The Mainline cards, how the deck is dealt, and what a card does to a train crossing it. |
|
||||
| [`components.md`](components.md) | Rolling stock, the two yards, Crew Trays, the Fedora, the D12. |
|
||||
|
||||
**None of these carry a version in the filename**, and that is deliberate (Jesse, 2026-09-21): they
|
||||
are kept current with every release rather than published as editions, so the name is always the
|
||||
latest and each says at the top which build it describes. Four of them were stamped `v0.4.5` until
|
||||
v0.8.0.17 — the prototype rules edition they were first written against, never the version they
|
||||
described — which read as though they documented a build five minor versions old.
|
||||
|
||||
## Rules
|
||||
|
||||
| Document | What it is |
|
||||
| --- | --- |
|
||||
| [`rules/rules-v0.1.md`](rules/rules-v0.1.md) | Faithful markdown transcription of the PDFs. No corrections. The baseline everything diffs against. |
|
||||
| [`rules/rules-v0.2.md`](rules/rules-v0.2.md) | **The working ruleset.** v0.1 with all ten gaps resolved, each change marked with its gap number. |
|
||||
| [`rules/card-reference.md`](rules/card-reference.md) | What is printed on every card, plus the economy summary. The spec an engine or a print-and-play layout consumes. |
|
||||
| [`rules/card-reference.md`](rules/card-reference.md) | **⚠ SUPERSEDED** — an invented 52-card placeholder, kept for its economy summary and its history. For what is printed on every card, read the generated tables in [`home-deck.md`](home-deck.md) and [`mainline-deck.md`](mainline-deck.md). |
|
||||
| [`rules/glossary.md`](rules/glossary.md) | Every defined term, alphabetized. |
|
||||
| [`rules/open-questions.md`](rules/open-questions.md) | All thirteen gaps, each with the options considered, the decision, and the rationale. |
|
||||
| [`rules/implications.md`](rules/implications.md) | **Read this first.** What the four recovered design files (`Deck cards2.xlsx`, `Mainline Cards.pdf`, `Trains3.pdf`, `tracks.png`) change — and which decisions they supersede. |
|
||||
@@ -45,76 +63,55 @@ must do.
|
||||
| [`architecture/protocol.md`](architecture/protocol.md) | Intents, events, and per-player view redaction. |
|
||||
| [`architecture/lobby-and-sessions.md`](architecture/lobby-and-sessions.md) | Create/join, seating, reconnection, persistence. |
|
||||
| [`architecture/deployment.md`](architecture/deployment.md) | Plain web host vs StartOS service — and the one constraint that differs. |
|
||||
| [`architecture/multiplayer.md`](architecture/multiplayer.md) | **The multiplayer build plan.** The authoritative server as a layer added on top, with solitaire unchanged and server-free — plus every decision taken, listed for review. |
|
||||
|
||||
## Current status
|
||||
|
||||
Rules formalized, card faces specified, architecture documented, and the rules engine built and
|
||||
simulated. Eleven of thirteen gaps are closed; **Gap 12 (balance variance) and Gap 13 (deck scaling)
|
||||
remain open**, both with data behind them.
|
||||
**v0.8.2.** Rules formalized, card faces specified, architecture documented, and the game
|
||||
playable **solitaire and multiplayer** in a browser against an authoritative server. See
|
||||
[`../CHANGELOG.md`](../CHANGELOG.md) for what each version changed and [`../TODO.md`](../TODO.md) for
|
||||
what is open; this section is the shape of the project, not a running tally, because a
|
||||
hand-maintained tally is what drifted last time.
|
||||
|
||||
Gaps 8–13 were all found by working the design forward — building it or simulating it — rather than
|
||||
by reading the PDFs. None arises in tabletop play, where a person simply does the sensible thing.
|
||||
**What is built.** The rules engine, the developer bot, the balance harness, the replay viewer, the
|
||||
playable page — and the server: lobby, game codes, seating, bots, per-seat reconnection, persistence
|
||||
by replaying the intent history, and an ordered replay of other players' turns on each player's own
|
||||
screen. It ships as a StartOS package. **999 fast tests and 35 simulation tests.**
|
||||
|
||||
**What is not.** The 22 opponent-directed cards — the Action and Space-use categories — are held out
|
||||
of every dealt deck until they have an implementation, along with the two defensive cards whose only
|
||||
purpose is to answer them. Real audio: everything the game plays is synthesised from oscillators.
|
||||
|
||||
**Balance is not where it should be, and no conclusion should be read from the revenue numbers yet.**
|
||||
The rebalance pass is deliberately deferred until the rules stop moving — card counts, industry counts
|
||||
and the track mix all need moving together. `TODO.md` carries the standing distortions and the
|
||||
measurements behind them.
|
||||
|
||||
**Three rules are genuinely open**, and they are the reason the README does not claim the rules are
|
||||
finished: where the Local's coach stands while its engine works (a §A.4 question, not a train-card
|
||||
question); Poling, the one card in the deck with no defined behaviour; and whether a Heavy Grade's
|
||||
orientation is rolled or chosen at setup. Thirteen gaps in the prototype rules were found and closed;
|
||||
these three came after.
|
||||
|
||||
**The event log narrates; the intents reconstruct.** Settled in v0.4.0 after the documentation had
|
||||
claimed `state = fold(events)` for months. It is not true — the phase driver mutates state and then
|
||||
describes it — so the canonical record is `{ seed, history: Intent[] }` and persistence will be built
|
||||
on that. [`architecture/protocol.md`](architecture/protocol.md) §3 has the reasoning;
|
||||
`test/events.test.ts` pins it.
|
||||
|
||||
**The economy, in one line:** Local Operations actions are the main currency — one per Stage, twelve
|
||||
per Day — but **inbound work bypasses them**, which is where the game's variance comes from. See
|
||||
`card-reference.md` §7.
|
||||
|
||||
**Wanted, not yet designed:** post-game replay — watching a finished game back at speed. The
|
||||
architecture already produces what it needs (an ordered append-only event log plus a stored seed), so
|
||||
the note in `architecture/overview.md` exists to keep the capability from being designed out. It is
|
||||
explicitly not scheduled.
|
||||
|
||||
**Provisional numbers** — the victory targets are now confirmed at the mean by simulation. Still
|
||||
untested by play: Crew Tray count, Laborer counts, and whether the variance in Gap 12 is a flaw.
|
||||
|
||||
**Next steps.** The specification is complete enough to build against or to play on paper.
|
||||
|
||||
The build path is laid out in [`architecture/components.md`](architecture/components.md) — 20
|
||||
components, a dependency graph, and a twelve-step order. Its **MVP is solitaire: one Office, a fixed
|
||||
number of Days, a server talking to a single browser, display functional rather than pretty.** The
|
||||
milestones worth knowing:
|
||||
|
||||
- **Step 4** — a full solitaire game runs to completion headless, proving the rules before a pixel is
|
||||
drawn.
|
||||
- **Step 6** — the balance harness validates or retunes the provisional numbers, while changing them
|
||||
is still free.
|
||||
- **Step 10** — MVP complete; a human plays end to end.
|
||||
per Day — but **inbound work bypasses them**, which is where the game's variance comes from.
|
||||
|
||||
**Stack: TypeScript**, chosen so the engine runs in both the server and the browser — one
|
||||
implementation of the movement rules, and instant affordances without a round-trip. Node 22 runs
|
||||
TypeScript natively, so there is no build step during development.
|
||||
TypeScript natively, so there is no build step during development, which also means **erasable syntax
|
||||
only**: no `enum`, no parameter properties, no namespaces.
|
||||
|
||||
> **The recovered design files are now transcribed and the nine open questions answered** — see
|
||||
> [`rules/implications.md`](rules/implications.md) §1b for exactly what is implemented. The deck is
|
||||
> 115 cards (93 in solitaire), Mainline cards have terrain and crossing times, and the balance
|
||||
> numbers below predate all of it.
|
||||
|
||||
**Steps 1–6 are done** — the rules engine (components 1–7), heuristic bots (17) and the balance
|
||||
harness (18), with 134 tests passing.
|
||||
|
||||
**The step 4 milestone is met: a full solitaire game runs to completion, headless.** Games are
|
||||
reproducible from a seed, terminate from every seed tried, conserve all 62 rolling stock pieces, and
|
||||
develop properly.
|
||||
|
||||
**End-of-game statistics** (`src/sim/stats.ts`) report revenue by source, traffic, development,
|
||||
action mix and strategy buckets — plus an **anomaly detector** that treats "this event never fired"
|
||||
as a finding. It caught two bugs on its first run.
|
||||
|
||||
**The step 6 milestone is met, with a caveat.** The harness found **five engine bugs that no test had
|
||||
caught**, and once they were fixed the numbers came good: **~35% win rate and 5.0 Revenue/player/Day,
|
||||
matching the Gap 10e prediction of 5-6.**
|
||||
|
||||
The caveat is large: those numbers were themselves measured against a **scoring bug**, since fixed.
|
||||
Honest figures are ~0.9 Revenue/Day and a **0% win rate** — the targets are currently unreachable.
|
||||
The bot is still the prime suspect (it uses ~10 Laborer actions per game out of ~900 available), so
|
||||
this is **Gap 12** and remains open. One genuine signal did emerge: **mixed freight/passenger
|
||||
strategies outscore pure ones**, with pure freight much the worst.
|
||||
|
||||
**Gap 11 is decided** — track orientation is chosen on placement, settled by measurement.
|
||||
**Gap 13** answers the deck-size question: do not double it; scale only the buildable cards with
|
||||
player count.
|
||||
|
||||
Next: step 7 begins the server (components 8, 9, 20).
|
||||
Running alongside, and independent of all of it: **print-and-play components.**
|
||||
The generated tables in [`home-deck.md`](home-deck.md) and [`mainline-deck.md`](mainline-deck.md)
|
||||
carry every card face as the game actually deals it, so layout and art are the only remaining work
|
||||
before a table playtest — which answers the one question
|
||||
simulation cannot, whether it is fun.
|
||||
|
||||
Run the harness with `node src/sim/harness.ts [games] [length]`.
|
||||
|
||||
@@ -123,6 +120,3 @@ writes a self-contained HTML file — open it in any browser and step through th
|
||||
Division, the Office Area grid, every facility's boxes and `MEN|AT|WORK` track, plain-English
|
||||
narration of each event, and a **Blocked** panel explaining why nothing is moving.
|
||||
|
||||
Running alongside, and independent of all of it: **print-and-play components.** `card-reference.md`
|
||||
specifies every card face, so layout and art are the only remaining work before a table playtest —
|
||||
which answers the one question simulation cannot, whether it is fun.
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
# Track order for standing cars, and the cut you left on your own card
|
||||
|
||||
> **BUILT IN v0.4.7.** Kept as the record of why the model is what it is — the reproductions below
|
||||
> are the evidence for west-to-east, and none of them is re-derivable from the code that fixed them.
|
||||
>
|
||||
> Two departures from the plan as written, both recorded here so the document does not read as a
|
||||
> description of the code:
|
||||
>
|
||||
> - **Step 3 does not flip the card's cut.** The plan asked for `board-svg` to apply the tray strip's
|
||||
> `facing === 'e' ? reverse : as-is` to the standing cars too. Once `standing` is defined west to
|
||||
> east that flip would be wrong — the board is a map and draws west on the left, so the array is
|
||||
> already in drawing order. What the renderer got instead is the half that actually answers the
|
||||
> report: the row is split at the train, west cars left and east cars right, with the engine in the
|
||||
> gap, and each car's tooltip says whether it is ahead of or behind the engine.
|
||||
> - **One new field after all.** The plan argued a cut's end is *derivable* from position in the
|
||||
> array. It is not, in the one case that matters: a train may set out off both ends on the same
|
||||
> square, and nothing in the array records where the train itself stands among them. That is
|
||||
> `CrewTray.standingWest`, kept on the tray rather than the card because it describes a
|
||||
> relationship — with no train there, a cut has no near or far side.
|
||||
>
|
||||
> **Step 5 was decided (i)**: taking your own cut back on the square you are standing on is undoing
|
||||
> the drop, exempt from the printed pick-up restrictions, and it refunds the freight budget.
|
||||
|
||||
Plan for the next session. Two reported problems that turned out to be one problem.
|
||||
|
||||
---
|
||||
|
||||
## The reports
|
||||
|
||||
1. **Drops.** "If I put cars off the nose on a given track, and my next move is go forward, I need to
|
||||
couple those cars right back on. If I drop cars off the back and I move back, then I will
|
||||
automatically recouple the cars onto the back of my train. Right after dropping my cars I need to
|
||||
be able to see if those cars are ahead or behind the train."
|
||||
2. **Ordering.** "When dropping all 4 cars, order was reversed. It worked properly if we dropped cars
|
||||
individually. Also, when adding four cars, again the order was reversed."
|
||||
|
||||
---
|
||||
|
||||
## The single root cause
|
||||
|
||||
`TrackCard.standing` is a bare `RollingStock[]` whose doc comment claims "in track order (§A.3)" and
|
||||
which in fact has **no defined orientation at all**. `CrewTray.consist` *is* oriented — nose first,
|
||||
relative to `facing` — so every transfer between the two is an orientation change that nothing
|
||||
performs.
|
||||
|
||||
§A.3 states the requirement plainly:
|
||||
|
||||
> It is critical that cars are loaded into and unloaded from the Crew Tray, and occupy the track, in
|
||||
> the same order they originally held, **left-to-right**.
|
||||
|
||||
Left-to-right is west-to-east. That is the orientation `standing` is missing.
|
||||
|
||||
### Reproduced, against the working tree
|
||||
|
||||
Scratch scripts only; reproduce with a tray on a plain straight, consist `[boxcar hopper reefer
|
||||
tank]`, `engineAt = 0`.
|
||||
|
||||
**(a) Batch size changes the parked order.** Tail drops:
|
||||
|
||||
| drops | `card.standing` afterwards |
|
||||
| --- | --- |
|
||||
| `count: 4` | `[boxcar hopper reefer tank]` |
|
||||
| `count: 1` ×4 | `[tank reefer hopper boxcar]` |
|
||||
| `count: 2` ×2 | `[reefer tank boxcar hopper]` — physically impossible |
|
||||
|
||||
`carsDropped` always does `carsOn(card).push(...stock)` (`apply.ts:1657`). Each successive tail cut is
|
||||
left *outboard* of the previous one on the track, so it belongs at the other end of the array. Nose
|
||||
drops are batch-invariant by luck, so only tail drops show it.
|
||||
|
||||
**(b) Coupling is orientation-blind.** A train running **east** onto a parked cut and a train running
|
||||
**west** onto the same cut yield the **identical** consist. They must mirror. `couples` is
|
||||
accumulated in path order in `exploreMoves` (`track.ts:448`) and `unshift`ed wholesale in
|
||||
`carsCoupled` (`apply.ts:1618`), with no reference to which way the engine met the cars.
|
||||
|
||||
**(c) The display mismatch — what was actually seen on screen.** `board-svg.ts:736` draws the tray
|
||||
strip as `t.facing === 'e' ? [...items].reverse() : items`, so the nose lands on the right for an
|
||||
east-facing train (a deliberate fix, documented in the comment above it). The card's standing cars at
|
||||
`board-svg.ts:~633` are drawn in raw array order with no such flip. The same physical cut therefore
|
||||
reads one way in the tray and the other way on the card — and which one looks "reversed" flips with
|
||||
batch size *and* with train facing.
|
||||
|
||||
The round trip (drop, run away, back up, collect) is internally self-consistent today, which is why
|
||||
this survived: whatever order got parked comes back in that order. Only the picture disagreed.
|
||||
|
||||
### Why this settles the earlier design question
|
||||
|
||||
The previous session offered two models for recording which end a cut sits at:
|
||||
|
||||
- **Option A** — one cut per card plus a `standingEnd?: Port` marker. Cheap.
|
||||
- **Option B** — give `standing` a real orientation.
|
||||
|
||||
Option A leaves (a), (b) and (c) in place. **Do Option B.** With orientation defined, "which end is
|
||||
the cut at" is *derivable* from position in the array and needs no new field — so B is also the
|
||||
smaller change once the ordering bugs are counted as in scope, which they now are.
|
||||
|
||||
---
|
||||
|
||||
## The model
|
||||
|
||||
**`TrackCard.standing` (and `Facility.industryTrack.cars`, via `carsOn`) is ordered WEST to EAST.**
|
||||
|
||||
That is the printed rule, it is the convention the board already draws in, and it is the only
|
||||
orientation that is a property of the board rather than of whichever train last touched the card.
|
||||
|
||||
Two derived quantities, both cheap:
|
||||
|
||||
- **A cut's end.** With a train on the card, cars before the train in track order are at its west
|
||||
end, cars after it are at its east end. Combined with `railFacing`, that is "ahead" or "behind".
|
||||
- **Approach order.** A train travelling east meets a card's cars in array order; travelling west, in
|
||||
reverse array order.
|
||||
|
||||
The nose/tail blocking rule then reads: **a move that leaves the card through the end the cut sits at
|
||||
must couple that cut first** — onto the nose when running forward, onto the tail when reversing,
|
||||
which is what the existing `toNose: !i.reverse` already gives.
|
||||
|
||||
### The two conversions, stated once
|
||||
|
||||
- **Consist → track.** `consist` is nose-first; nose is `facing`. So an east-facing train's consist
|
||||
reads east-to-west and must be **reversed** on the way onto the card; a west-facing train's reads
|
||||
west-to-east and goes on **as-is**.
|
||||
- **Track → consist.** Build `couples` **near-to-far along the direction of travel** (reverse each
|
||||
card's array when travelling west, and visit cards in path order). Then a nose coupling is
|
||||
`unshift(...[...couples].reverse())` and a tail coupling is `push(...couples)` unchanged.
|
||||
|
||||
Check the tail case by hand: backing east into a cut, the nearest car couples closest to the existing
|
||||
tail, and `consist` runs nose-to-tail, so near-before-far pushed in order is right. Check the nose
|
||||
case: running east into a cut, the *farthest* car ends up nose-most, so the reverse is right.
|
||||
|
||||
---
|
||||
|
||||
## Work, in order
|
||||
|
||||
Each step should land green on its own.
|
||||
|
||||
### 1. Define the orientation and fix the drop
|
||||
|
||||
- `state.ts` — rewrite the `standing` doc comment to state west-to-east outright, and say `carsOn`
|
||||
inherits it. That comment is currently the thing that is wrong.
|
||||
- `apply.ts` `carsDropped` reducer (~1649) — insert at the correct end for the dropping train's
|
||||
`facing`, reversing the cut where the conversion above says to. A tail cut from an east-facing
|
||||
train goes at the **west** end of whatever is already on the card; a nose cut from the same train
|
||||
goes at the **east** end.
|
||||
- Tests: batch-invariance. `count: 4`, `1×4` and `2×2` must all produce the same `standing`, from
|
||||
both ends, at both facings. Direct regression for report (2).
|
||||
|
||||
### 2. Fix the coupling
|
||||
|
||||
- `track.ts` `exploreMoves` (~448) — build `couples` near-to-far along travel, per the conversion
|
||||
above. Needs the exit port at each hop, which the walk already has in `MoveStep`.
|
||||
- `apply.ts` `carsCoupled` (~1612) — reverse on `toNose`.
|
||||
- Tests: the mirror property. Approaching the same parked cut eastbound and westbound must give
|
||||
mirrored consists, never identical ones.
|
||||
- Tests: round trip at both facings and all batch sizes — drop, run clear, back up, collect; the
|
||||
consist must come back identical.
|
||||
|
||||
### 3. Fix the picture
|
||||
|
||||
- `view.ts` `CellView` — `cars: string[]` is flat and carries no relation to `train`. It needs enough
|
||||
for the renderer to place the cut relative to the engine. Preferred: keep `cars` in track order
|
||||
(west-to-east, matching state) and let the renderer flip, exactly as it already does for the tray
|
||||
strip.
|
||||
- `board-svg.ts` (~633) — apply the same `facing === 'e' ? reverse : as-is` flip to the cut that line
|
||||
736 applies to the tray, and lay the cut on the correct **side** of the train chip rather than in
|
||||
the fixed slot row. This is the real UI work and answers "I need to see if those cars are ahead or
|
||||
behind".
|
||||
- Tooltips: "standing here" becomes "standing ahead of the engine" / "behind the engine" when a train
|
||||
shares the card.
|
||||
|
||||
### 4. Layer on the blocking rule
|
||||
|
||||
- `track.ts` `exploreMoves` — seed the walk's `couples` with the start card's cut when the cut sits at
|
||||
the exit end. Today the walk begins at `neighbour(start, initialExit)` and never reads
|
||||
`carsOn(startCard)` at all, so a train drives through its own cut in both directions. Regularise
|
||||
the loop-back case at the same time: `couples` already picks up the start card if the walk circles
|
||||
back to it, because only `results.push`/`block` are guarded by `!sameCoord(node.coord, start)`.
|
||||
- `apply.ts` `switch.move` build (~1230) — `lifted` is `[...dest.path.map(s => s.coord), i.to]` and can
|
||||
never contain `start`. It must, or the recoupled cut is not cleared off the card.
|
||||
- `ctx.consistSize` — the start-card cut must count toward `MAX_CONSIST`, so `tooManyCars` bites at
|
||||
the start square. Its message should name the cut on your own card or it will read as nonsense.
|
||||
- Tests: encode §A.5's two worked examples move-for-move. Both must remain playable; the trailing
|
||||
example's step 1 ("drop the back two on Card F, move train to Card B") is the specific case that
|
||||
must *not* recouple, and it is the strongest single check that the rule is right.
|
||||
|
||||
### 5. Decide the printed-rule interaction — Jesse's call
|
||||
|
||||
Dropping a freight car spends the square's budget for trains 3/4 (`oneFreightPerLocation`). Recoupling
|
||||
it on the way out would then be refused with `FREIGHT_WORKED_HERE`, so the train could only ever back
|
||||
away from its own cut. Same shape for X13 `dropOnly` and X22 `pickUpEmptiesOnly`.
|
||||
|
||||
Two defensible answers:
|
||||
|
||||
- **(i)** Recoupling your own cut on the square you are standing on is *undoing the drop* — exempt
|
||||
from those restrictions, and it refunds the freight budget.
|
||||
- **(ii)** It is a pickup like any other; a train that sets out off its nose is committed to
|
||||
reversing. Harsher, but never strands a train permanently, since backing up stays legal.
|
||||
|
||||
Leaning (i): (ii) makes a legal-looking drop silently one-way, which is exactly the kind of trap the
|
||||
"why can't I move" panel exists to prevent.
|
||||
|
||||
### 6. Bot and fixtures
|
||||
|
||||
- `bot.ts` — the "drop the nose cut to un-bury the engine" heuristic (~1268) and "spot the back car at
|
||||
this facility" (~1352) now have direction consequences. The bot cannot produce an illegal move
|
||||
(legality is enforced upstream) but it will silently get worse until its scoring knows about the
|
||||
cut.
|
||||
- `public/replays/seed-*.json` will diverge — legality changes, so bot play changes. Regenerate, as
|
||||
was done last release.
|
||||
- **`docs/station-master-seed58228926-day6.json` no longer replays at all.** It stops at intent #2,
|
||||
`mainline.modify c108` → `NO_SUCH_CARD`, because uncommitted deck changes shifted card ids. It
|
||||
cannot serve as the regression case for these reports. Capture a fresh save against current code
|
||||
reproducing the same switching sequence, and keep it.
|
||||
|
||||
---
|
||||
|
||||
## Files expected to change
|
||||
|
||||
| File | Why |
|
||||
| --- | --- |
|
||||
| `src/engine/state.ts` | `standing` orientation, doc comment |
|
||||
| `src/engine/track.ts` | direction-aware `couples`; start-card cut; consist limit |
|
||||
| `src/engine/apply.ts` | `carsDropped`, `carsCoupled`, `switch.move` `lifted`, budget decision |
|
||||
| `src/sim/view.ts` | `CellView.cars` orientation and its relation to `train` |
|
||||
| `src/sim/board-svg.ts` | flip the cut, place it beside the chip, tooltips |
|
||||
| `src/sim/narrate.ts` | say which end was set out; distinguish recoupling own cut (~138) |
|
||||
| `src/sim/bot.ts` | direction-aware set-out heuristics |
|
||||
| `test/apply.test.ts`, `test/train-rules.test.ts`, `test/web.test.ts`, `test/replay.test.ts` | drop/couple batteries, §A.5 examples |
|
||||
| `public/replays/seed-*.json` | regenerate |
|
||||
|
||||
## Not in scope
|
||||
|
||||
- Leaving a coach at the Office (§A.4 / trains 7/8) — separate open item in `TODO.md`.
|
||||
- Any change to `maneuver.flyingSwitch`'s target rules. It drops onto a card with no train on it, so
|
||||
the cut has no "end" to sit at; it just needs to land in west-to-east order like any other cut.
|
||||
@@ -0,0 +1,398 @@
|
||||
# Station Master — Home Deck
|
||||
|
||||
**Version 0.8.6** · 2026-09-29
|
||||
|
||||
**Scope:** the Home Office deck — how it is dealt, drawn, discarded and reshuffled, and what the
|
||||
rules are for playing each kind of card out of it.
|
||||
|
||||
> **The card tables in this document are GENERATED from `src/engine/content.ts`** and checked by a
|
||||
> test, so they cannot disagree with the game. `npm run build:cards` rebuilds them; do not edit a
|
||||
> table by hand. The prose around them is how the deck WORKS; the tables are what is in it.
|
||||
>
|
||||
> **Card counts are not published.** Counts move with play balance, so a printed count answers a
|
||||
> question that has a different answer
|
||||
> after the next retune. Where a count matters it is rendered as a yes/no — whether the deck deals
|
||||
> the card at all — which is a fact about the design.
|
||||
|
||||
## The piles
|
||||
|
||||
- **Home Office deck** — face down. The pile a Draw comes from.
|
||||
- **Three Departments** — face-up discard piles. A discard goes onto one, which is precisely so a
|
||||
rival may take it; a Draw may take the top card of a Department instead of the deck. **Taking the
|
||||
last card off a Department refills it immediately** from the Home Office deck (§6.2), so there
|
||||
are always three piles to choose from; see the Rules.
|
||||
- **Salvage Yard** — where a played-out card ends up. An Extra's card goes here after its run.
|
||||
|
||||
When the Home Office deck runs out it is rebuilt from the Salvage Yard and **all three Departments
|
||||
in full**, reshuffled from the seeded stream. A **spent timetabled train** is not collected — its
|
||||
number is on the timetable and it cannot run twice — but a *discarded* train was never played and
|
||||
is still runnable, so it comes back.
|
||||
|
||||
## Hand and turn
|
||||
|
||||
The hand limit is **three**, or four while you hold a Red Flag. You may not end a turn over the
|
||||
limit: play a card or discard one to a Department. Some cards cannot be discarded at all — an Extra
|
||||
never can, and a timetabled train cannot when the `discardTimetabled` house rule is off — so a hand
|
||||
of nothing but those has exactly one way forward, which is to play one.
|
||||
|
||||
The opening deal is a house-rule choice made when the game is dealt. The default (`threeRandom`) is
|
||||
three cards from one shuffled deck; `threeTrackThreeOther` deals three track and three others from
|
||||
two separately shuffled piles, over the hand limit, so the first turn is spent choosing
|
||||
which district you can afford to build.
|
||||
|
||||
Drawing is one of the three Local Operations options — see [Rules](rules.md)
|
||||
§4.2. Taking the option lets you draw **and** play or discard within the same turn.
|
||||
|
||||
## Track cards
|
||||
|
||||
Track cards are ordinary Home Office cards, not a separate personal supply.
|
||||
|
||||
- A placed card must **connect to existing rail**: at least one neighbour must join it.
|
||||
- The **Running Track** is the row from Limit to Limit. A card placed there must carry an east–west
|
||||
through route, or it breaks the main.
|
||||
- Placing track **on a Limit sign** extends the Running Track and moves that sign outward. The sign
|
||||
is a physical card, so it moves rather than being left stranded mid-track.
|
||||
- **Nothing may be placed outside your Limits** — track, industries and Modifiers alike. Your
|
||||
district ends at its sign.
|
||||
- Curves and turnouts are printed left- or right-handed. A card may be turned 180° but never flipped
|
||||
over, so its 45° leg never changes diagonal.
|
||||
- A **turnout may upgrade** an existing straight, or a curve whose arc is exactly the turnout's
|
||||
diverging arc. An **industry may be built over a straight** off the Running Track, on the same
|
||||
principle. Neither is allowed if the card holds standing cars or an Enhancement, and every other
|
||||
occupied square is unavailable.
|
||||
- A turnout may be **run through but not stopped on**: it is not Operational Rail, so a Move may not
|
||||
end there.
|
||||
|
||||
<!-- BEGIN CARDS: track -->
|
||||
| Track | Geometry | Hand | Operational rail | Move cost | Dealt |
|
||||
| --- | --- | --- | :---: | ---: | :---: |
|
||||
| Straight track | straight | none | yes | 1 | yes |
|
||||
| Curved track (right) | curved | right | yes | 1 | yes |
|
||||
| Curved track (left) | curved | left | yes | 1 | yes |
|
||||
| Sharp Curved Track (right) | sharpCurved | right | yes | 2 | no |
|
||||
| Sharp Curved Track (left) | sharpCurved | left | yes | 2 | no |
|
||||
| Turnout (right) | turnout | right | — | 1 | yes |
|
||||
| Turnout (left) | turnout | left | — | 1 | yes |
|
||||
|
||||
A row marked "no" is a shape the engine understands but the deck does not currently print.
|
||||
<!-- END CARDS: track -->
|
||||
|
||||
## Office cards
|
||||
|
||||
Every player begins at a **Whistle Post**, which is not drawn from the deck: one A/D track, no
|
||||
Porters, no passenger slots, and not a Control Point.
|
||||
|
||||
Office cards are **upgrades in strict sequence** — Whistle Post → Depot → Station → Terminal — and
|
||||
each upgrades the Office in place rather than replacing its card or its attached track. Modifiers
|
||||
already beside it are preserved. Each tier adds an A/D track, a Porter, and an outbound and inbound
|
||||
passenger slot; a Depot and above is a Control Point and a Passenger Facility.
|
||||
|
||||
An upgrade takes no placement: the Office is where it already is.
|
||||
|
||||
<!-- BEGIN CARDS: office -->
|
||||
Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in
|
||||
order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal**
|
||||
to Porters rather than one more.
|
||||
|
||||
| Office | Control point | Passenger facility | A/D tracks | Porters | Green | Red |
|
||||
| --- | :---: | :---: | ---: | ---: | ---: | ---: |
|
||||
| Whistle Post | — | — | 1 | 0 | 0 | 0 |
|
||||
| Depot | yes | yes | 2 | 1 | 1 | 1 |
|
||||
| Station | yes | yes | 3 | 2 | 2 | 2 |
|
||||
| Terminal | yes | yes | 4 | 3 | 3 | 3 |
|
||||
|
||||
Whistle Posts are a fixed supply of 4 outside the deck, and Limits signs a
|
||||
supply of 8.
|
||||
<!-- END CARDS: office -->
|
||||
|
||||
## Freight facilities
|
||||
|
||||
An industry is placed on a connected straight **stub off the Running Track** — never on the Running
|
||||
Track itself, and never outside the Limits. A Facility carries its own rails, so placing one places
|
||||
track.
|
||||
|
||||
**It may also be built over a straight already laid**, in the same way a turnout may upgrade one, so
|
||||
rail can go down before the industry that will serve it. Only a plain straight may be built over: a
|
||||
Facility carries east–west track, so replacing a straight cannot break a neighbour's connection,
|
||||
while a curve or a turnout carries rails the Facility does not. The square must be clear of standing
|
||||
cars and carry no Enhancement — the replaced card leaves play.
|
||||
|
||||
Each begins with one Laborer and a three-box **MEN | AT | WORK** pipeline. No Office Area may hold a
|
||||
duplicate industry, or both ends of a lockout pair — a producer and the consumer of the same
|
||||
commodity cannot be built in one district.
|
||||
|
||||
**An industry track holds four cars, like any other card.** It is *not* sized by the industry's box
|
||||
count. That distinction was a real bug: box count is how much WORK an industry can hold, not how
|
||||
much RAIL it has, and conflating the two invented a printed siding no industry card carries.
|
||||
|
||||
<!-- BEGIN CARDS: facilities -->
|
||||
Each lists the car types it works, which way its traffic flows, and the industries it may not sit
|
||||
beside. **Every lockout pair is a producer and the consumer of the same commodity** — you may
|
||||
build one end of a chain or the other, never both, which is what forces traffic to run between
|
||||
districts rather than in circles inside one. No two of the same industry may share an Office Area,
|
||||
and that rule is enforced for every kind rather than repeated in each row.
|
||||
|
||||
| Industry | Cars | Flow | Green | Red | Laborers | Locked out with |
|
||||
| --- | --- | --- | ---: | ---: | ---: | --- |
|
||||
| Freight House | boxcar | both | 1 | 1 | 1 | Grocer's Warehouse |
|
||||
| Mine Tipple | hopper | outbound | 1 | 0 | 1 | Power Plant |
|
||||
| Refinery | tank | outbound | 1 | 0 | 1 | Power Plant |
|
||||
| Power Plant | hopper, tank | inbound | 0 | 1 | 1 | Mine Tipple, Refinery |
|
||||
| Packing Sheds | reefer | outbound | 1 | 0 | 1 | Grocer's Warehouse |
|
||||
| Grocer's Warehouse | boxcar, reefer | inbound | 0 | 1 | 1 | Packing Sheds, Freight House |
|
||||
<!-- END CARDS: facilities -->
|
||||
|
||||
## Facility modifiers
|
||||
|
||||
A Modifier sits on an empty square **square against its host Facility — north, south, east or
|
||||
west**. It may not go on a diagonal: touching at a corner is not touching. It must be **inside your
|
||||
Limits**, like everything else, and may not stand in the Running Track row. One of each kind per
|
||||
Office Area.
|
||||
|
||||
**A Modifier adds a BOX, never room for a car.** A Truck Dock beside a Grocer's Warehouse gives it a
|
||||
second red box — somewhere for one more arriving load to be cleared to — and changes nothing about
|
||||
how many cars may be spotted there.
|
||||
|
||||
**A passenger Modifier needs a Passenger Facility.** A Waiting Area, Restaurant or Hotel may not be
|
||||
played at an Office that is still a **Whistle Post** — a Whistle Post is not a Passenger Facility, so
|
||||
there is nothing for the card to add to. Upgrade the Office to a **Depot** or better first.
|
||||
|
||||
**A grant the host cannot use does nothing**, and the game says so rather than pretending. An
|
||||
outbound bonus beside an inbound-only facility, or the Truck Dock's inbound bonus beside the
|
||||
outbound-only Packing Sheds, is discarded — the latter leaving that card with no effect at all.
|
||||
|
||||
<!-- BEGIN CARDS: modifiers -->
|
||||
Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger
|
||||
Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which
|
||||
is not one.
|
||||
|
||||
| Modifier | Hosts | +Green | +Red | +Laborers | +Porters |
|
||||
| --- | --- | ---: | ---: | ---: | ---: |
|
||||
| Waiting area | any Passenger Facility | 1 | — | — | 1 |
|
||||
| Restaurant | any Passenger Facility | 1 | — | — | 1 |
|
||||
| Hotel | any Passenger Facility | 1 | — | — | 1 |
|
||||
| Truck dock | Freight House, Packing Sheds, Grocer's Warehouse | — | 1 | — | — |
|
||||
| Railroad Express Agency | Freight House | 1 | — | 1 | — |
|
||||
| Forklifts | Freight House, Packing Sheds | 1 | — | 1 | — |
|
||||
| Prep Plant | Mine Tipple | 1 | — | 1 | — |
|
||||
| Coal Piles | Mine Tipple | 1 | — | 1 | — |
|
||||
| Conveyor Belts | Mine Tipple | 1 | — | 1 | — |
|
||||
| Pipelines | Refinery | 1 | — | 1 | — |
|
||||
| Oil Depot | Refinery | 1 | — | 1 | — |
|
||||
| Viscosity breakers | Refinery | 1 | — | 1 | — |
|
||||
| Transmission lines | Power Plant | — | — | 1 | — |
|
||||
| Rotary Dumps | Power Plant | — | — | 1 | — |
|
||||
| Steam Turbines | Power Plant | — | — | 1 | — |
|
||||
| Ice House | Packing Sheds, Grocer's Warehouse | 1 | — | 1 | — |
|
||||
| Local small groceries | Grocer's Warehouse | — | — | 1 | — |
|
||||
<!-- END CARDS: modifiers -->
|
||||
|
||||
## Train cards
|
||||
|
||||
Playing a **timetabled** train rolls the seeded D12 and puts its number in the first open timetable
|
||||
slot at or after the result, wrapping around the 12-slot chart. It then runs at that Stage **every
|
||||
Day**.
|
||||
|
||||
Playing an **Extra** queues it; it is made up when a Crew Tray comes free, and **the player who
|
||||
played the card chooses where it starts and loads it as they choose** (§7). Where it may start is a
|
||||
house rule — `divisionPointsOnly`, `ownOffice`, or `anyOffice` (the default) — and the Interchange is
|
||||
also available, because it is the one Mainline card with a yard. An Extra runs once and its card
|
||||
goes to the Salvage Yard.
|
||||
|
||||
The listed consist is a **maximum, not a minimum**. A train may depart with fewer cars, but not with
|
||||
more, not of the wrong category, not with a car behind the caboose, and not with the engine buried
|
||||
among its own cars. A Crew Tray holds four pieces, and a caboose counts toward the four.
|
||||
|
||||
A train made up short of what its card calls for is reported as such, with what it wanted and why
|
||||
the yard could not supply it — see Rules §4.4.
|
||||
|
||||
**Per-train consists and printed rules are in the table under § Train cards below.** It
|
||||
carries the `empties only`, `reefers only`, `drop only` and `pick up empties only` restrictions,
|
||||
every one of which the engine enforces.
|
||||
|
||||
<!-- BEGIN CARDS: trains -->
|
||||
### Timetabled
|
||||
|
||||
| # | Class | Speed | Runs | Consist | Printed rules |
|
||||
| ---: | --- | --- | --- | --- | --- |
|
||||
| 1 | Crack Limited | fast | west | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* |
|
||||
| 2 | Crack Limited | fast | east | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* |
|
||||
| 3 | Express | fast | west | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* |
|
||||
| 4 | Express | fast | east | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* |
|
||||
| 5 | The Sparrow | fast | west | 3 coaches (3 pieces) | no switching; expedite |
|
||||
| 6 | The Sparrow | fast | east | 3 coaches (3 pieces) | no switching; expedite |
|
||||
| 7 | Local | slow | west | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* |
|
||||
| 8 | Local | slow | east | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* |
|
||||
| 9 | Heavy Freight | slow | west | 3 freight + 1 caboose (4 pieces) | — |
|
||||
| 10 | Heavy Freight | slow | east | 3 freight + 1 caboose (4 pieces) | — |
|
||||
| 11 | Drag Freight | slow | west | 2 freight + 1 caboose (3 pieces) | — |
|
||||
| 12 | Drag Freight | slow | east | 2 freight + 1 caboose (3 pieces) | — |
|
||||
|
||||
### Extras
|
||||
|
||||
| # | Class | Speed | Runs | Consist | Printed rules |
|
||||
| ---: | --- | --- | --- | --- | --- |
|
||||
| X13 | Appleseed Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces), empties only | drop only; *"May drop MTs but not pick up anything."* |
|
||||
| X14 | Fruit Growers Express | fast | player's choice | 2 reefers + 1 caboose (3 pieces) | expedite; *"Reefers only. May pick up one extra loaded reefer."* |
|
||||
| X15 | Yard Xfer | slow | player's choice | 2 freight + 1 caboose (3 pieces) | — |
|
||||
| X16 | Light Engine Move | fast | player's choice | engine only | no switching; *"No cars at all."* |
|
||||
| X17 | Campaign Train | fast | player's choice | 1 coach (1 piece) | no switching; stop then expedite; stop earns point; must run loaded; *"One turn at station (speeches) then expedite. Earns a point per Office Area if the candidate is aboard."* |
|
||||
| X18 | Circus Train | slow | player's choice | 2 freight + 1 coach + 1 caboose (4 pieces) | no switching; stop earns point; must run loaded; *"One turn stopped in an Office Area (circus set-up) earns 1 point, once per Area, if fully loaded."* |
|
||||
| X19 | Military Train | slow | player's choice | 1 freight + 2 coaches (3 pieces) | no switching; no passenger work; expedite; must run loaded; *"Troops and materiel: runs loaded where the yard can supply it."* |
|
||||
| X20 | Director's private car | slow | player's choice | 2 freight + 1 coach (3 pieces) | no passenger work |
|
||||
| X21 | Freight Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces) | — |
|
||||
| X22 | Pee-Dee | slow | player's choice | 1 caboose (1 piece) | pick up empties only; *"Per-diem train. May only pick up MTs."* |
|
||||
<!-- END CARDS: trains -->
|
||||
|
||||
## Enhancements, Mainline modifiers and Maneuvers
|
||||
|
||||
- **Enhancements** are played into your district and change what a square does — the **Small Yard**
|
||||
(re-order a consist for one Move), **Interlocking**, **Yard Office** and the rest. The table below
|
||||
marks each one `live`, `dormantSolo` or `unbuilt`, which is the part only the implementation
|
||||
knows.
|
||||
- **ABS Signals is the exception, and it matters.** It is dealt as an Enhancement but is **not
|
||||
played into your district**: it goes onto a **Mainline card** — any one of them — and stops
|
||||
trains there rear-ending each other. It takes no square and is not part of anybody's Office
|
||||
Area. See [Mainline deck](mainline-deck.md).
|
||||
- **Mainline modifiers** are played onto a Mainline card: the Heavy Grade helpers and Realignment.
|
||||
See [Mainline deck](mainline-deck.md).
|
||||
- **Maneuvers** are held and spent: **Red Flags** and **Flying Switch** have their own actions.
|
||||
Poling is catalogued but its effect is recorded as "TBD in the source", so there is nothing to
|
||||
implement.
|
||||
|
||||
<!-- BEGIN CARDS: enhancements -->
|
||||
The column that only the implementation can fill in: **whether the printed effect actually
|
||||
resolves yet.** `live` is read during play; `dormantSolo` is implemented at the point of attack
|
||||
but the attack is an opponent-directed card held out of every deck, so nothing reaches it in a
|
||||
solitaire game; `unbuilt` means the effect is recorded and nothing reads it. A transcription
|
||||
cannot carry this column, which is the argument for generating the page rather than writing it.
|
||||
|
||||
| Enhancement | Placement | Requires | Effect resolves |
|
||||
| --- | --- | --- | :---: |
|
||||
| Interlocking | runningTrackStraight | — | **live** |
|
||||
| Facing Point Locks | onCard | interlocking in the district | **dormantSolo** |
|
||||
| Yard Office | secondaryTrackStraight | — | **live** |
|
||||
| Small Yard | secondaryTrackStraight | — | **live** |
|
||||
| Water Column | runningTrackStraight | — | **dormantSolo** |
|
||||
| Overpass | onCard | — | **unbuilt** |
|
||||
| Telegraph | runningTrackStraight | — | **live** |
|
||||
| Telephone | onCard | telegraph on the same card | **live** |
|
||||
| Radio | onCard | telephone on the same card | **live** |
|
||||
| ABS Signals | mainlineCard | — | **live** |
|
||||
<!-- END CARDS: enhancements -->
|
||||
|
||||
## Opponent-directed cards — not dealt
|
||||
|
||||
The **Action** and **Space-use** categories are opponent-directed and are **excluded from every
|
||||
dealt deck**, because their play rules are not implemented. They are catalogued in
|
||||
the table below so the composition is on record, and `check` refuses to play one. This is deliberate:
|
||||
silently accepting them would make a card look playable while doing nothing.
|
||||
|
||||
<!-- BEGIN CARDS: opponent -->
|
||||
**None of these is dealt in any deck today.** A card that can only be played at another player
|
||||
has no legal target in a solitaire game, and a defence with nothing to defend against is as dead
|
||||
a draw as the attack — so both halves are held out until the attacks are implemented. They are
|
||||
listed because they are the design, and because what a defence answers is the only record of why
|
||||
it exists.
|
||||
|
||||
### Action cards — opponent-directed
|
||||
|
||||
| Card | Played on | Effect | Answers |
|
||||
| --- | --- | --- | --- |
|
||||
| Derail | a moving train in the Local Phase | That train must stop for the remainder of the turn. | — |
|
||||
| Broken coupler | a moving train in the Mainline Phase | That train must stop and not move. | — |
|
||||
| Railroad crossing | any Secondary Track Straight | May not be used as a stop point for switching. May not become an Industry. | — |
|
||||
| Per Diem inventory | another player | Lose one point per 2 empty cars on Secondary Tracks. | — |
|
||||
| Demurrage charge | another player | Lose one point per 2 loaded freight cars on Secondary Tracks. | — |
|
||||
| Customer complaints | another player | Lose one point per 2 coaches in loading boxes. | — |
|
||||
| Vandalism | another player | A train passing a Hobo Jungle has a boxcar looted (converted to empty). | — |
|
||||
| Hotbox | another player | A train just arrived must set one car (chooser’s pick) onto Secondary Track until it departs. | — |
|
||||
| Outlawed | another player | A train just arrived may not depart for one turn — the crew’s hours have expired. | — |
|
||||
|
||||
### Space-use cards — opponent-directed
|
||||
|
||||
| Card | Played on | Effect | Answers |
|
||||
| --- | --- | --- | --- |
|
||||
| Bean house | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||
| Flop house | adjacent to any straight, curve, turnout | Burns tablespace. | — |
|
||||
| Watertower | adjacent to any straight, turnout on Running Track | Burns tablespace. | — |
|
||||
| Hobo Jungle | adjacent to any straight, turnout, Limit on Running Track | Burns tablespace. Vandalism can loot a boxcar passing it. | — |
|
||||
| Section House | adjacent to any straight, curve, turnout | Burns tablespace. | — |
|
||||
| City blocks | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||
| Engine Shops | adjacent to any straight, curve, turnout | Burns tablespace. | — |
|
||||
| Tenderloin District | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||
| Engineer cemetery | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||
|
||||
### Maneuver cards
|
||||
|
||||
| Card | Played on | Effect | Answers |
|
||||
| --- | --- | --- | --- |
|
||||
| Red Flags | any time | A stopped train is prevented from being hit; the approaching train is prevented from moving. | — |
|
||||
| Flying Switch | any time | Break a cut of cars away from behind the engine and roll them into an industry. | — |
|
||||
| Poling | any time | TBD in the source. | — |
|
||||
|
||||
Mainline modifier cards, for completeness — these ARE dealt:
|
||||
|
||||
| Card | Played on | Effect | Answers |
|
||||
| --- | --- | --- | --- |
|
||||
| Brakeman | a GRADE Mainline card | Faster passage downhill. | — |
|
||||
| Airbrakes | a GRADE Mainline card | Faster passage downhill. Brakeman must be in effect. | — |
|
||||
| Helpers | a GRADE Mainline card | Faster passage uphill. | — |
|
||||
| Realignment | a Mainline card | Convert one Mainline type to another. Not while a train is on it. | — |
|
||||
| Facing Point Locks | adjacent to Interlocking | Prevents Derail being played on you. | Derail |
|
||||
<!-- END CARDS: opponent -->
|
||||
|
||||
## The Second Section card
|
||||
|
||||
**Ordering a Second Section costs the card.** Played on a train that is **due out this Stage**, it
|
||||
sends a second, identical train out right behind the first. That second train needs a Crew Tray of
|
||||
its own, so there has to be one free.
|
||||
|
||||
It is the one card that deliberately creates the **following-train** situation §8.1 makes the
|
||||
Superintendent rule on — so playing it is choosing to put that question to them.
|
||||
|
||||
One copy in the deck, spent when it is played.
|
||||
|
||||
## Trains that pay for stopping
|
||||
|
||||
Two Extras pay for **standing still** rather than for running: the **Circus Train** (X18) and the
|
||||
**Campaign Train** (X17). Their card is worth nothing if it is run like an ordinary train.
|
||||
|
||||
Three conditions, all of them required:
|
||||
|
||||
- **Stopped.** The train spent a whole Mainline Phase without moving. Switching around inside a
|
||||
district does not break it — what breaks it is leaving before a Mainline Phase passes.
|
||||
- **In an Office Area.** Any square in a player's district. Standing on the Mainline or at a
|
||||
Division Point pays nothing. A **Whistle Post district counts** — the rule is about the area, not
|
||||
the Office's tier.
|
||||
- **Fully loaded.** Every car except the caboose is carrying something. A train made up short, or
|
||||
carrying an empty, earns nothing however long it stands.
|
||||
|
||||
**One point per district**, and the point goes to whoever sits in the district it stopped in. A
|
||||
Circus touring three districts is paid three times; one parked in the same district all game is paid
|
||||
once. Neither train may switch, so it cannot load itself — it must be made up loaded before it goes.
|
||||
|
||||
## The Office you start on
|
||||
|
||||
Every district opens on a **Depot** unless the table chooses otherwise. A Depot has two A/D tracks
|
||||
and is a Passenger Facility, so passengers earn from the first Stage and a second train can stand at
|
||||
an Office without wrecking.
|
||||
|
||||
**"Players start with Whistle Posts, not Depots"** is the harder game, set when the game is created.
|
||||
A Whistle Post has **one** A/D track and is not a Passenger Facility: no passenger earns anything
|
||||
until somebody draws and plays a Depot upgrade, and a second train arriving is a collision unless an
|
||||
Interlocking holds it at the Limits until the track frees.
|
||||
|
||||
The deck follows the choice. Starting on Depots, the **Depot upgrade cards are left out** — an
|
||||
upgrade must be to the next tier up, so a Depot card at a table that already has Depots is a dead
|
||||
draw. Station and Terminal are still upgrades and stay in.
|
||||
|
||||
## What a train may pick up
|
||||
|
||||
Coupling is mandatory, so a train forbidden to pick cars up may not make the move that would pick
|
||||
them up — there is no "move but leave them". A square the rails reach but the train's card forbids
|
||||
is shown as blocked, with the rule that forbids it.
|
||||
|
||||
**A train may always recover its own caboose.** A caboose at the far end is what makes a train
|
||||
ready to leave, so a card that says "may drop but not pick up anything" would otherwise let a train
|
||||
strand itself for good the moment it set its caboose down.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Station Master — Mainline Deck
|
||||
|
||||
**Version 0.8.6** · 2026-09-29
|
||||
|
||||
**Scope:** the tarot-sized Mainline cards placed between Offices — how the deck is dealt, what a
|
||||
card does to a train crossing it, and the Home Deck cards played onto one.
|
||||
|
||||
> **The card table in this document is GENERATED from `src/engine/content.ts`** and checked by a
|
||||
> test, so it cannot disagree with the game. `npm run build:cards` rebuilds it; do not edit it by
|
||||
> hand. The prose explains how the deck is used; the table is the record of what is in it.
|
||||
|
||||
## How Mainline cards work
|
||||
|
||||
At setup the game lays one Mainline card between each neighbouring pair of Offices and one beyond
|
||||
each end Office, between it and a Division Point — so a game with *N* players uses **N + 1** cards.
|
||||
They are **dealt from a finite deck without replacement**, so no Division can hold
|
||||
two of a card printed once. The two Division Points are the fixed ends of the Division and are not
|
||||
in the deck: `buildDivision` lays them itself.
|
||||
|
||||
A card does not belong to either neighbouring Office. It is shared Division.
|
||||
|
||||
### Crossing time is REGIONS, not mph
|
||||
|
||||
A card is divided into **regions**, and a train advances **one region per Stage**. Crossing time is
|
||||
therefore `regions − startRegion`, and nothing else. **The printed mph is scenery.**
|
||||
|
||||
This is the part most likely to be remembered wrong. The printed mph does not set
|
||||
the cost and a Slow train added a Stage to *every* card. It does not. Four things move a train's
|
||||
start region and nothing else does:
|
||||
|
||||
1. **The card's own back region.** The Uncontrolled Siding and the Interchange print a back region
|
||||
that is not part of the road, so a train running through begins past it.
|
||||
2. **A card that prints a Fast and a Slow start — and only Hilly does.** A fast train starts one
|
||||
region along and crosses in 1 Stage; a slow one takes 2. **No other card reads a train's
|
||||
Fast/Slow rating at all.**
|
||||
3. **The permanent Heavy Grade modifiers**, each moving a train one region up the hill.
|
||||
4. **Occupancy**: arriving to find the card occupied can put a train in the siding, a region behind.
|
||||
|
||||
No crossing ever takes less than one Stage.
|
||||
|
||||
### Traffic
|
||||
|
||||
Only **Double Track** lets two trains stand on one card, so a following train is not held behind a
|
||||
slower one. Every other card holds one train at a time. The **Uncontrolled Siding** is not a passing
|
||||
card: arriving to find a train already there puts you in the siding a region behind it — you do not
|
||||
run into it, and it costs you the extra Stage instead.
|
||||
|
||||
Whether a following train may enter an occupied card at all is the Superintendent's ruling (§8.1,
|
||||
Rules §4.5). Getting it wrong is what causes collisions.
|
||||
|
||||
## The deck
|
||||
|
||||
Ten drawable cards — Plains twice, the other eight once each — plus the two Division Point cards,
|
||||
which are not drawn.
|
||||
|
||||
| Card | Copies |
|
||||
| --- | ---: |
|
||||
| Plains | 2 |
|
||||
| Curves | 1 |
|
||||
| Hilly | 1 |
|
||||
| Heavy Grade | 1 |
|
||||
| Double Track | 1 |
|
||||
| Uncontrolled Siding | 1 |
|
||||
| Tunnel | 1 |
|
||||
| Trestle | 1 |
|
||||
| Interchange | 1 |
|
||||
| East / West Division Point | 1 each, not dealt |
|
||||
|
||||
The PDF art labels the Interchange "Yard". This reference uses **Interchange** throughout, to keep
|
||||
it apart from the Division Yard, the Classification Yard, the Yard Office and the Small Yard — five
|
||||
different things.
|
||||
|
||||
**Region counts and entry points per card are in the table under § The deck.**
|
||||
|
||||
<!-- BEGIN CARDS: mainline -->
|
||||
| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Extra may start |
|
||||
| --- | ---: | ---: | --- | :---: | :---: |
|
||||
| Plains | 1 | 0 | — | — | — |
|
||||
| Curves | 2 | 0 | — | — | — |
|
||||
| Hilly | 2 | 0 | 1 / 0 | — | — |
|
||||
| Heavy Grade | 3 | 0 | — | — | — |
|
||||
| Double Track | 1 | 0 | — | yes | — |
|
||||
| Uncontrolled Siding | 2 | 1 | — | — | — |
|
||||
| Tunnel | 2 | 0 | — | — | — |
|
||||
| Trestle | 1 | 0 | — | — | — |
|
||||
| Interchange | 2 | 1 | — | — | yes |
|
||||
|
||||
The Mainline deck is 10 cards; the two Division Points are the fixed ends of
|
||||
the Division and are not dealt. What each card does, in the words the game uses on screen:
|
||||
|
||||
- **Plains** — 1 region — one Stage each. · 1 Stage for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Curves** — 2 regions — one Stage each. · 2 Stages for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Hilly** — 2 regions — one Stage each. · This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses in 1 Stage, a slow one in 2 Stages. No other card cares which it is. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Heavy Grade** — 3 regions — one Stage each. · A grade, climbing eastward. 3 Stages to climb it and 3 Stages to run down, before help. Helpers start an UPHILL train a region further on; Brakeman does the same DOWNHILL and Airbrakes another again, and Airbrakes cannot be played without Brakeman. Never less than one Stage. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Double Track** — 1 region — one Stage each. · 1 Stage for every train. · TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held behind a slower one.
|
||||
- **Uncontrolled Siding** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region behind it. You are not in the same place, so you do not run into it; it costs you the extra Stage instead. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Tunnel** — 2 regions — one Stage each. · 2 Stages for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Trestle** — 1 region — one Stage each. · 1 Stage for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Interchange** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · An Extra beginning its run here starts in the back region and takes the extra Stage. · One train at a time — anything following has to wait for it to clear. · This is the one Mainline card with a yard, so an Extra Train may be made up and started here. Its printed car-sorting is NOT implemented — a consist is re-ordered at a Small Yard in a district.
|
||||
<!-- END CARDS: mainline -->
|
||||
|
||||
## The Interchange, and what it does NOT do
|
||||
|
||||
The Interchange prints a car-sorting capability. **It is not implemented, and never has been.** The
|
||||
one thing the card's `sortsCars` flag actually gates is that an **Extra Train may be made up and
|
||||
started here** — it is the Mainline card with a yard, which is why §7 allows it. An Extra
|
||||
starting here begins in the back region and takes the extra Stage.
|
||||
|
||||
Re-ordering a consist is done at a **Small Yard** in an Office Area, for one switching Move. See
|
||||
Rules §4.3.
|
||||
|
||||
## Heavy Grade modifiers
|
||||
|
||||
Home Office cards, played onto a Mainline card during a player's Draw option.
|
||||
|
||||
| Card | Placement and effect |
|
||||
| --- | --- |
|
||||
| Brakeman | Heavy Grade only. A **downhill** train starts one region further on. |
|
||||
| Airbrakes | Heavy Grade only, and **Brakeman must already be on that card**. A downhill train starts one region further again. |
|
||||
| Helpers | Heavy Grade only. An **uphill** train starts one region further on. |
|
||||
| Realignment | Only onto an **unoccupied** Mainline card, and only these four conversions: Plains → Double Track, Curves → Plains, Uncontrolled Siding → Double Track, Trestle → Uncontrolled Siding. |
|
||||
|
||||
A Heavy Grade is 3 regions, so it is 3 Stages to climb and 3 to run down before help. Modifiers
|
||||
never reduce a crossing below one Stage.
|
||||
|
||||
> **A Realignment can be a card with no legal target.** Only four of the nine Mainline types convert
|
||||
> at all — Plains, Curves, Uncontrolled Siding and Trestle — so a Division dealt Heavy Grade, Tunnel
|
||||
> and Double Track has nowhere to play one. A card already converted cannot be converted again, and
|
||||
> a card with a train on it is refused while the train is there. The card says which four it can
|
||||
> convert when you look at it in hand.
|
||||
|
||||
### ABS Signals — an Enhancement, but it lives out here
|
||||
|
||||
**ABS Signals is dealt from the Home Office deck as an Enhancement, and it is the one Enhancement
|
||||
that is not played into a district.** It goes onto a **Mainline card — any of them**, not only a
|
||||
grade — and takes no square in anybody's Office Area.
|
||||
|
||||
What it does: trains on that card **will not rear-end each other**. A following train is held short
|
||||
of the one ahead instead of running into it, so the Superintendent has no §8.1 judgment to make and
|
||||
no collision is scored. It is the only thing besides a Red Flag that prevents a rear-end collision
|
||||
out on the Mainline, and unlike a Red Flag it stays on the card for the rest of the game.
|
||||
|
||||
On the board a card carrying it draws a **signal mast with a lit lamp** at its top-right corner; the
|
||||
three grade helpers draw as **BRK**, **AIR** and **HLP** beside the card's name. Realignment draws
|
||||
nothing, because a realigned card simply becomes the card it was converted into.
|
||||
|
||||
## Heavy Grade orientation is settled, not missing
|
||||
|
||||
Heavy Grade orientation is **rolled from the seed**, not chosen by a player. **This is a decision,
|
||||
not a gap.**
|
||||
|
||||
The card prints "(Up)" and "Player sets orientation", which assumes the card has an owner. This one
|
||||
does not: the Division is laid `DP · Mainline · Office · Mainline · … · DP`, so a Heavy Grade always
|
||||
sits **between two districts**, or beyond an end Division Point beside one — never inside a single
|
||||
player's district.
|
||||
|
||||
Orientation is not cosmetic. Brakeman and Airbrakes help a train running **downhill**, Helpers helps
|
||||
one running **uphill**, and odd-numbered trains run west while even run east. Turning the card
|
||||
around decides which modifiers are worth anything and which direction of traffic is favoured, for
|
||||
the whole game. Handing that to one of two neighbours advantages them over the other, and neither
|
||||
has a fair claim to it.
|
||||
|
||||
So the orientation is **rolled from the game's seed**. That is deterministic, roughly even
|
||||
(51/49 east/west), identical in solitaire and multiplayer, and keeps setup non-interactive: the game
|
||||
has no setup phase, so asking would mean interrupting play before the first Local Operations.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
# Structure — a proposal (2026-09-29)
|
||||
|
||||
What the 2026-09-29 audit found about the SHAPE of the code, as distinct from its faults, and what
|
||||
to do about it. Nothing here changes behaviour; each item is a seam that would let the next fault be
|
||||
found by a test instead of by a reviewer reading three thousand lines. Ordered by payoff.
|
||||
|
||||
The faults the audit fixed in 0.8.3 and 0.8.4 were nearly all in the three largest functions or in
|
||||
the one file no test stood up. That is the argument: the structure is where the bugs were.
|
||||
|
||||
## 1. `http.ts` — a route table, and one place for authentication
|
||||
|
||||
**Today.** `startServer` is one 600-line async closure of `if (pathname === … && method === …)`
|
||||
chains. The token → session → lobby-or-game resolution is copy-pasted eight times; the admin-secret
|
||||
check, the join-secret check and the host check are each inline where they are needed. The catch at
|
||||
the bottom is the only error path, and until 0.8.4 it was itself a crash.
|
||||
|
||||
**Proposed.** A table of routes, each `{ method, path, auth, handler }`, with three auth wrappers:
|
||||
|
||||
```
|
||||
withToken(handler) // resolves { ps, lobby?, game? } from the token, 404s if none
|
||||
withHost(handler) // withToken, then refuses anyone but lobby.hostToken
|
||||
withAdmin(handler) // the x-admin-secret gate; the whole prefix is absent when unset
|
||||
```
|
||||
|
||||
Handlers become ten to thirty lines each and take a typed context. The one dispatcher owns
|
||||
`readJson`, `sendJson`, the `HttpError` catch and the `headersSent` guard — one place, so 0.8.4's
|
||||
crash fix cannot be forgotten by the next route. The per-game `inTurn` queue becomes a property of
|
||||
the game context rather than something a handler has to remember to call.
|
||||
|
||||
**Cost.** A day. Every route is exercised by `test/server/http.test.ts` now, so the move is
|
||||
mechanical and verifiable. Do it before adding the next route (the seatless display stream, #20).
|
||||
|
||||
## 2. `main.ts` — five extractions, and a `Selection` value
|
||||
|
||||
**Today.** 3200 lines, ~21 module-level `let`s (`session`, `selected`, `mode`, `pendingAt`,
|
||||
`selectedCrew`, `peekPlayer`, `zoom`, `soundOn`, `gameCode`, `remoteToken`, …), and `start()` runs at
|
||||
import. `render()` is ~470 lines; `renderActions` ~360. `test/web.test.ts` has to stub the DOM before
|
||||
importing, can never build two pages, and cannot call `render()` with a `Frame` of its choosing —
|
||||
which is why every screen fault in the audit was found by reading, not by a test.
|
||||
|
||||
**Proposed, in the order they pay:**
|
||||
|
||||
1. **`web/remote-store.ts`** — `RemoteRecord`, `readStore`/`writeStore`, `loadRemote`/`saveRemote`/
|
||||
`forgetRemote`/`knownRemote`, and `lobbyHandlers`. Pure functions over `localStorage`; testable
|
||||
with a Map. Today they are untestable except through the page.
|
||||
2. **`web/url-options.ts`** — `RULE_PARAMS`/`VICTORY_PARAMS`/`OPTIONAL_PARAMS`, `gameOptionsFromUrl`,
|
||||
`solitaireDefaults`, `rulesToUrl`. A pure round trip; the audit found the `location.search = ''`
|
||||
no-op assumption (#114) by reading this code, and a test on the round trip would have found it.
|
||||
3. **`web/setup-screen.ts`** — `wireGameTypeBlock`, `commitNewGame`, `runSolitaireSetup`.
|
||||
4. **A `Selection` object** replacing the five `let`s (`selected`, `mode`, `pendingAt`,
|
||||
`selectedCrew`, `peekPlayer`) that every click handler mutates. One value, one `reset()`, passed
|
||||
to `render` rather than read from module scope — which is what lets a test call `render(frame,
|
||||
selection)` and assert on the HTML.
|
||||
5. **`web/game-shell.ts`** — session routing: `beginRemote`, `abandonRemote`, `claimSeat`, `start`,
|
||||
`applyCapabilities`, the handoff. This is where the animation loop outliving the session (#114)
|
||||
lives, and it is easier to see once it is not surrounded by rendering.
|
||||
|
||||
Keep `render()` in `main.ts` but split the board-and-hand wiring (~240 lines of `addEventListener`)
|
||||
into `wireBoard(selection)` so what is DRAWN and what is CLICKABLE are separate functions.
|
||||
|
||||
**Cost.** Two to three days across the five, each its own commit; the first two are an afternoon
|
||||
each and carry no risk.
|
||||
|
||||
## 3. The engine — `check` per phase, and one car category
|
||||
|
||||
**Today.** `check` (~600 lines) and `reduce` (~700) are single switches mixing every phase;
|
||||
`moveTrain` nests four position kinds by three decision kinds. Car category (coach / caboose /
|
||||
freight) is computed in three places with three spellings. The seat guard 0.8.3 added to the four
|
||||
switching intents is the same four lines four times.
|
||||
|
||||
**Proposed.**
|
||||
|
||||
- Split `check` by phase — `checkLocalOps`, `checkNewTrain`, `checkLoadUnload`, `checkMainline` —
|
||||
each a switch over its own intents, dispatched by `inPhase`. The shared guards (`ownTray`, the
|
||||
option-chosen test, the Moves test) become the first lines of `checkLocalOps` rather than four
|
||||
repeats. `reduce` splits the same way.
|
||||
- One exported `carCategory(type)` in `content.ts`, used by `acceptsCar`, `newTrainPhase`,
|
||||
`consistNeeds` and `isFreight`/`carriesLoad`.
|
||||
- `maneuver.flyingSwitch` is a weaker copy of `switch.dropCars` (no `switchingRefusal`, no
|
||||
`engineAt` clamp, no `standingWest` handling). It should CALL the dropCars path with a flag,
|
||||
or be deleted until the card is dealt (it is at 0 copies). Deciding is #115's job; the structure
|
||||
point is that there must be one cut-dropping reducer.
|
||||
|
||||
**Cost.** The `check`/`reduce` split is a day and is pure motion — every test in `apply.test.ts`
|
||||
and `advance.test.ts` runs unchanged. `carCategory` is an hour.
|
||||
|
||||
## 4. One `Push` type
|
||||
|
||||
`web/session.ts` mirrors `server/session.ts`'s `Push` by hand so the browser bundle never imports
|
||||
from `src/server/`. Every envelope change (0.8.4 added `lastSeq`) is two edits. Move the wire types
|
||||
— `Push`, `LobbyPush`, `LobbyPreview` — into `src/sim/wire.ts`, which both sides already import
|
||||
from, and delete the mirror.
|
||||
|
||||
**Cost.** An hour.
|
||||
|
||||
## 5. Tests that would then exist
|
||||
|
||||
Each extraction above names the test it makes possible:
|
||||
|
||||
| after | test |
|
||||
| --- | --- |
|
||||
| route table | one test per route for the 404/403/400 answers, from a table |
|
||||
| `remote-store.ts` | the `RemoteRecord` round trip, the "seat tied to this browser" notice (#33) |
|
||||
| `url-options.ts` | `rulesToUrl(gameOptionsFromUrl(x)) === x`, and the `search = ''` case |
|
||||
| `Selection` | `render(frame, selection)` snapshot tests for each screen state |
|
||||
| `check` per phase | the seat guard once, as a property over every switching intent |
|
||||
|
||||
## What not to do
|
||||
|
||||
- Do not refactor `render()` and fix a screen bug in the same commit. The audit's value was that
|
||||
each finding could be verified against unchanged code.
|
||||
- Do not introduce a framework or a runtime dependency to do any of this. The zero-dependency rule
|
||||
is what keeps the package a 63 MB `.s9pk`.
|
||||
@@ -0,0 +1,207 @@
|
||||
# Plan — Train Switching Paths: two routes to the same square
|
||||
|
||||
**Status:** built, v0.4.8. Written against `461c4d9`; see `docs/rules/open-questions.md` Gap 14 and
|
||||
the CHANGELOG's "Two routes to the same square" for the ruling and what shipped.
|
||||
**Reported by Jesse:** *"There are times when a train can take two different paths to get to a
|
||||
destination. See game at undo 379. Train 11 can go from Eastern limits to the straight at 1,1 two
|
||||
different ways. It can go through the refinery and pick up the tanker on its nose, or it could go
|
||||
straight straight and then make the curve and skip the refinery and not pick up the car. The player
|
||||
should have the two different options."*
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect
|
||||
|
||||
A district with a passing loop — turnout down off the main, curve, industry, curve, turnout back up —
|
||||
offers two routes from one end to the rejoin square. Both are legal under §2.4 (a Move runs any
|
||||
distance without changing direction; neither route ever leaves a card by the port it entered). They
|
||||
differ in what they couple.
|
||||
|
||||
**The engine offers only one of them, and which one is an artifact of search order.**
|
||||
|
||||
`exploreMoves` (`src/engine/track.ts:386`) keys its visited set on destination *and entry port*:
|
||||
|
||||
```ts
|
||||
// src/engine/track.ts:492
|
||||
const key = `${coordKey(node.coord)}|${node.entry}`;
|
||||
if (seen.has(key)) continue;
|
||||
```
|
||||
|
||||
Both routes arrive at the rejoin square through the **same port**, so they collide on that key. The
|
||||
walk is breadth-first, so the shorter route is recorded and the longer one is discarded before it can
|
||||
become a destination.
|
||||
|
||||
### Reproduction
|
||||
|
||||
Fixture — Running Track on row 0, industry spur on row −1:
|
||||
|
||||
| | col 0 | col 1 | col 2 | col 3 | col 4 |
|
||||
|---|---|---|---|---|---|
|
||||
| **row 0** | straight | turnout `{w,e,s}` | straight | turnout `{e,w,s}` | Limits (east) |
|
||||
| **row −1** | — | curve `ne` | **Refinery + tanker** | curve `nw` | — |
|
||||
|
||||
Walking west from `(0,4)`, today's engine returns:
|
||||
|
||||
```
|
||||
(0,2) entry=e couples=[] path=(0,3)
|
||||
(-1,3) entry=n couples=[] path=(0,3)
|
||||
(-1,2) entry=e couples=[tanker] path=(0,3)->(-1,3)
|
||||
(0,0) entry=e couples=[] path=(0,3)->(0,2)->(0,1) ← only one route to (0,0)
|
||||
(-1,1) entry=e couples=[tanker] path=(0,3)->(-1,3)->(-1,2)
|
||||
```
|
||||
|
||||
The refinery route to `(0,0)` — which couples the tanker — is absent. A simple-path prototype on the
|
||||
same fixture returns both. Verified identical on v0.4.7 and v0.4.8.
|
||||
|
||||
### It is not cosmetic
|
||||
|
||||
`dest.path` feeds `carsCoupled.from` (`src/engine/apply.ts`, the `switch.move` execute branch at
|
||||
`:1303`) — the list of cards swept clean when cars are lifted. The route reaches the reducer, so
|
||||
"which route" must be part of the intent, not just the button text.
|
||||
|
||||
### How often
|
||||
|
||||
Instrumented over 60 developer-bot games (17,884 destinations enumerated):
|
||||
|
||||
| measure | count |
|
||||
|---|---|
|
||||
| destinations enumerated | 17,884 |
|
||||
| squares reachable by 2+ **distinct paths** | 208 (~1.2%) |
|
||||
| squares where the routes **differed in what they couple** | **0** |
|
||||
|
||||
So the geometry occurs and today's engine silently discards a route about 1% of the time — but the
|
||||
bot never builds a district where the discarded route *matters*. Two consequences:
|
||||
|
||||
- **Balance impact is expected to be nil.** Still confirm with `compare.ts` (see §6).
|
||||
- **The bot will never regression-test this.** It needs explicit unit tests.
|
||||
|
||||
---
|
||||
|
||||
## 2. The rules question, and Jesse's ruling
|
||||
|
||||
§A.4 says: *"If you move your Crew Tray into cars on your track, you **MUST** pick them up. You may
|
||||
not go around them."* Whether taking the main instead of the siding counts as "going around" decides
|
||||
whether this feature is legal at all.
|
||||
|
||||
**Ruling (Jesse):** the player may choose the path. Different paths may pick up different cars, or
|
||||
none.
|
||||
|
||||
The permissive reading is well-supported by the text: §A.4 says *"cars on your track"*, and the
|
||||
industry spur is a different track. Declining to enter it is not going around the tanker — it is not
|
||||
going to the tanker's track at all, which is ordinary railroading. The mandatory half still bites in
|
||||
full: once a route is chosen, every car standing on it couples, and there is no route that passes a
|
||||
car and leaves it.
|
||||
|
||||
**Doc changes:** add the clarifying sentence to §A.4 in `docs/rules/rules-v0.2.md`, and record the
|
||||
ruling in `docs/rules/open-questions.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Design decisions
|
||||
|
||||
**The discriminator is `via`: one intermediate `GridCoord` on the chosen route** (Jesse's call).
|
||||
Not an index into the destination list — intents are the canonical record and `undo` replays history
|
||||
minus one intent, so an order-dependent field would silently reinterpret saved games.
|
||||
|
||||
```ts
|
||||
// src/engine/intents.ts:19
|
||||
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean; via?: GridCoord }
|
||||
```
|
||||
|
||||
- `via` names an **intermediate** card — never the start, never the destination.
|
||||
- **Resolution:** among destinations at `to` matching `reverse`, take the first enumerated route whose
|
||||
path contains `via`. Enumeration stays shortest-first.
|
||||
- **`via` absent resolves exactly as today** (first route at `to`), so existing histories replay
|
||||
unchanged. Note v0.4.8 already established that saves are *not* preserved across rule changes —
|
||||
this is belt-and-braces, not a hard constraint.
|
||||
- **`legal.ts` emits a distinguishing `via`** per route, so a generated intent is never ambiguous.
|
||||
|
||||
**Collapse routes whose outcome is identical.** Two routes that lift the same cars from the same
|
||||
cards are not a choice; offering both is noise in the action list and doubles the bot's branching
|
||||
factor for nothing.
|
||||
|
||||
**The dedupe key must include the origin cards, not just car types.** Two routes can couple an
|
||||
identical car *multiset* lifted from *different* cards. Keying on type alone would collapse them and
|
||||
sweep the wrong card clean. Key on `(coord, entry, [(origin card, car)...])`.
|
||||
|
||||
**Simple-path walk with an enumeration cap.** Relaxing `seen` to a per-path visited set keeps
|
||||
termination (no card twice within one path), but the number of simple paths is worst-case exponential
|
||||
in a dense district. Cap enumeration and prefer shortest routes when the cap bites.
|
||||
|
||||
> Note: the 45°-diagonal matching rule appears to make closed loops geometrically impossible — a
|
||||
> rectangle of four curves cannot close, because the slopes do not match at the corners. **Do not
|
||||
> rely on this.** Use a path-local visited set and a cap regardless.
|
||||
|
||||
---
|
||||
|
||||
## 4. Change list
|
||||
|
||||
| File | Site | Change |
|
||||
|---|---|---|
|
||||
| `src/engine/track.ts` | `exploreMoves` :386, `seen` :492 | Per-path visited set; dedupe on outcome; cap. Preserve shortest-first ordering. |
|
||||
| `src/engine/intents.ts` | :19 | Add `via?: GridCoord`. |
|
||||
| `src/engine/apply.ts` | `check` :623 | Select destination by `via`, not first-by-coord. |
|
||||
| `src/engine/apply.ts` | `execute` :1303 | Same selection; `carsCoupled.from` follows the chosen path. |
|
||||
| `src/engine/apply.ts` | `destinationsFor` :1210, `movesFor` :1260 | Unchanged signatures; `movesFor` still collapses to one square per coord for the board. |
|
||||
| `src/engine/legal.ts` | :90 | Emit one intent per distinct route, each with a distinguishing `via`. |
|
||||
| `src/sim/view.ts` | `describeIntent` :724 | Find the destination by `via` so the label names the cars *that route* couples. |
|
||||
| `src/web/game.ts` | `coordOf` :113, label dedupe ~:229 | Labels must differ per route or the label-dedupe drops one button. |
|
||||
| `src/web/main.ts` | `cellRef` :660 | Route-on-hover — see §5. |
|
||||
| `src/sim/narrate.ts` | move narration | Name the route when it is not the only one. |
|
||||
|
||||
**`ownCutFor` (`apply.ts:1241`) needs no change** — it depends only on the exit port, not the route.
|
||||
|
||||
---
|
||||
|
||||
## 5. UI — route on hover (Jesse's choice)
|
||||
|
||||
v0.4.8 already built the mechanism: `coordOf(intent)` → `cellRef` → `data-square="r,c"` on each
|
||||
action button, and hovering or focusing lights that square. This feature extends it from one square
|
||||
to a route.
|
||||
|
||||
- Carry the path on the **menu**, resolved server-side, for the same reason `coordOf` is: a remote
|
||||
client holds no `GameState`.
|
||||
- Emit the route as a second attribute (`data-route="r,c r,c …"`); hovering or focusing lights every
|
||||
card the route runs over, with the destination keeping its existing stronger mark.
|
||||
- **On focus as well as hover**, matching v0.4.8 — the keyboard route is not a lesser one.
|
||||
|
||||
Target rendering:
|
||||
|
||||
```
|
||||
Switching Train 11, standing at (1, 4)
|
||||
[ move to (1,1) ]
|
||||
[ move to (1,1) — couples 1 tanker on the way (onto the nose) ] ← hover
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification
|
||||
|
||||
- **Unit test the fixture in §1** — both routes offered, one coupling the tanker and one not.
|
||||
- **`via` round-trip** — each intent `legal.ts` emits resolves back to the route it was generated for.
|
||||
- **Back-compat** — replay `public/replays/*.json` and assert identical outcomes with `via` absent.
|
||||
- **Origin-card dedupe** — two routes coupling identical car *types* from *different* cards must both
|
||||
be offered, and each must sweep its own card.
|
||||
- **Cap** — a dense district must terminate and stay bounded.
|
||||
- **Balance** — `node src/sim/compare.ts 1600`. Expect no movement (the bot never builds this
|
||||
geometry); keep only at t ≥ 3 per `README.md`, and read the better/worse/identical split.
|
||||
|
||||
**Run the suite serially: `node --test --test-concurrency=1`.** The `npm test` script runs files in
|
||||
parallel and several of them invoke the static build against a shared `dist/`, which produces ~20
|
||||
spurious failures. Measured on `461c4d9`: parallel 20 fail / serial **575 pass, 0 fail**.
|
||||
|
||||
> v0.4.8's CHANGELOG attributes those 20 failures to the tracked `node_modules` symlink. That fix was
|
||||
> real and worth making, but it is **not** the whole cause: on this checkout `tsc` resolves and
|
||||
> typecheck exits 0, yet the same 20 fail under parallel execution and pass under
|
||||
> `--test-concurrency=1`. The `dist/` race is still there. Worth fixing separately — give each
|
||||
> build-invoking test its own output directory.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open question for Jesse
|
||||
|
||||
A route's cards may be reachable only as a *subset* of a longer route's cards. If that ever happens,
|
||||
no single `via` uniquely selects the shorter route, and it is addressable only by the
|
||||
shortest-first tiebreak. It did not arise in any fixture or in 60 bot games, and it may be
|
||||
geometrically impossible — but if it does arise, the alternatives are to reject the ambiguous intent
|
||||
or to let shortest-first stand. **Shortest-first is assumed here.**
|
||||
@@ -0,0 +1,213 @@
|
||||
# Station Master — Quickstart
|
||||
|
||||
**Version 0.8.6** · 2026-09-29
|
||||
|
||||
For a player who has never played.
|
||||
|
||||
Read this once before you sit down. It is about twenty minutes of reading and will save you an hour
|
||||
of confusion. The deeper references are listed at the end.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the game is
|
||||
|
||||
You are a railroad **Office** — a town — on a shared east–west main line called the **Division**.
|
||||
Everyone's Office sits in a row along it, west to east, with **Mainline cards** between them.
|
||||
|
||||
Two jobs run at once:
|
||||
|
||||
**Your own job, in your district.** Build track. Build industries and a passenger platform. Shunt
|
||||
cars around with a switching crew to get the right car to the right place, so that freight can be
|
||||
loaded and unloaded and passengers can get on and off. Every one of those completed pieces of work
|
||||
pays **Revenue**, which is the score.
|
||||
|
||||
**The shared job, out on the Division.** Scheduled trains run across everybody's territory on a
|
||||
timetable. They arrive at your Office, and you work them. When two trains want the same stretch of
|
||||
track, the player wearing the **Superintendent's Fedora** rules on whether the second may follow the
|
||||
first. Rule wrong and they collide, which costs 5 Revenue and counts against a limit that can end
|
||||
the game.
|
||||
|
||||
The tension the game is built around: **the useful work is local and slow, and the trains are shared
|
||||
and do not wait.**
|
||||
|
||||
---
|
||||
|
||||
## 2. How you win
|
||||
|
||||
A game runs for a set number of **Days**, chosen when the game is created. Each Day is **12
|
||||
Stages**, which you can think of as two-hour clock periods from midnight.
|
||||
|
||||
At the end of the last Day, the table's **combined Revenue** is checked against a floor. **Miss it
|
||||
and everybody loses**, however well you personally did — this is the number to watch. The floor is
|
||||
worked out from the number of players and Days when the game is dealt, and whoever sets the game up
|
||||
can raise or lower it.
|
||||
|
||||
Clear the floor, and how you win depends on the game type:
|
||||
|
||||
- **Co-op** — clearing the floor is the win, together.
|
||||
- **Competitive** — clearing the floor puts the game on, and the **highest individual Revenue**
|
||||
wins. Everyone is still working towards the same floor first.
|
||||
- **Cutthroat** — competitive, with the opponent-directed cards in play, so players can act against
|
||||
each other directly. Those cards are not implemented yet, so this plays as Competitive today.
|
||||
- **Solitaire** — clear the floor by yourself.
|
||||
|
||||
**Collisions can end it early and badly.** Breaching the collision limit stops play at once in a
|
||||
collective loss: the railroad has been declared unsafe. The limits are configurable, and can be
|
||||
switched off entirely.
|
||||
|
||||
**Falling short offers another Day** rather than just ending, so a game that misses the floor can be
|
||||
played on. A game stopped by collisions cannot.
|
||||
|
||||
### Where Revenue comes from
|
||||
|
||||
| Work | Pays |
|
||||
| --- | --- |
|
||||
| A passenger boarding at your platform | 1 |
|
||||
| A passenger getting off at your platform | 1 |
|
||||
| Completing an outbound freight load | 1 |
|
||||
| Completing an inbound freight unload | 1 |
|
||||
| A train completing its run across the whole Division | paid to every player; usually set to 0 |
|
||||
|
||||
Every rate is set when the game is dealt and can be changed. As they usually stand, **your score
|
||||
comes almost entirely from working cars in your own district** — trains passing through pay nothing
|
||||
by themselves.
|
||||
|
||||
---
|
||||
|
||||
## 3. The shape of a Stage
|
||||
|
||||
Every Stage runs five phases in this order. Only two of them are your turn.
|
||||
|
||||
| # | Phase | What happens |
|
||||
| --- | --- | --- |
|
||||
| 1 | **Local Operations** | **Your turn.** Choose ONE of three things (below). Each player in turn, starting with the Superintendent and working eastward. |
|
||||
| 2 | **New Train** | Trains due this Stage are made up from the Division Yard. The table takes turns adding one car each. |
|
||||
| 3 | **Mainline** | Automatic. Trains move, lowest number first. This is where clearance rulings and collisions happen. |
|
||||
| 4 | **Cargo** | **Your turn.** Your Laborers and Porters do their work — the loading, unloading, boarding and detraining that actually pays. |
|
||||
| 5 | **Supervisor Shift** | Automatic. Laborers and Porters refresh; expedited trains depart. Every third Stage the Fedora passes. |
|
||||
|
||||
### Local Operations: you get exactly one of these
|
||||
|
||||
- **Switch** — take a crew and shunt. **Six Moves** (five at night under Reduced Visibility). This is
|
||||
how cars physically get from the yard to an industry and back. Cars you run over are coupled up
|
||||
automatically, so plan the route.
|
||||
- **Draw** — take a card, then play and/or discard. This is how your district gets built: track,
|
||||
industries, Office upgrades, modifiers, train cards.
|
||||
- **Freight Agent** — one clerical act: stock a green outbound box, clear a red inbound box, or
|
||||
unjam a facility.
|
||||
|
||||
**You cannot do two of them in one Stage.** Choosing is most of the game. A Stage spent drawing is a
|
||||
Stage not spent switching.
|
||||
|
||||
> Passengers are **not** the Freight Agent's job. They board and get off in the **Cargo** phase, with
|
||||
> a **Porter**. Reaching for the wrong role and finding nothing there is the single most common new
|
||||
> player mistake, so each role now says on screen what it is for.
|
||||
|
||||
---
|
||||
|
||||
## 4. The screen
|
||||
|
||||
**Across the top:** your Revenue, the target, the Day and Stage, collisions, and the game code.
|
||||
|
||||
**When other players are acting**, their turns are replayed on your board a step at a time rather
|
||||
than arriving already rearranged, with a `[N behind]` counter, **Pause** and **Skip**. Your own moves
|
||||
are not replayed at you — they are already on your screen.
|
||||
|
||||
**The main display, on the left**, top to bottom:
|
||||
|
||||
- **The Division — west to east.** The shared main line: every Office and the Mainline cards between
|
||||
them, with trains drawn where they are. **West is always on the left**, and a train's engine is
|
||||
drawn as an arrow (◀ or ▶) showing which way it points.
|
||||
- **Your Office Area.** Your own grid of track cards. This is where switching happens. It folds away
|
||||
outside the phases that change it, unless you pin it open.
|
||||
- **History.** What has happened, most recent first.
|
||||
|
||||
**The right-hand column:**
|
||||
|
||||
- **Your Move** — the buttons. If it is not your turn this is empty, and the board tells you who is
|
||||
acting.
|
||||
- **Cards in My Hand**, and the **Department decks** — three face-up discard piles anyone may draw
|
||||
the top of.
|
||||
- **The Yards.** The **Division Yard** is the live supply of cars. The **Classification Yard** is
|
||||
where used cars go, and it comes back **only when the Division Yard runs completely bare**.
|
||||
- **Timetable** — who is due out and when.
|
||||
- **Blocked — why nothing is moving.** *Read this panel.* When something will not work, this is
|
||||
where the game explains why, in rules terms.
|
||||
- **Facilities** — the load pipelines at each industry.
|
||||
- **This Game** — the seed or your seat, the game code, the rules this game was dealt under, and
|
||||
links to this guide and the other references.
|
||||
|
||||
---
|
||||
|
||||
## 5. Start with solitaire
|
||||
|
||||
Play a **solitaire** game first. It needs no server and nobody else, and it is the same rules.
|
||||
|
||||
1. Open the site, choose **Play solitaire**, accept the defaults, **Deal**.
|
||||
2. **Stage 1 — Draw.** You start on a **Depot** with almost nothing else. Play a track card or two to
|
||||
extend your Running Track. A Depot already works passengers and has two A/D tracks, so a second
|
||||
train can stand at your Office without wrecking; a **Station** upgrade, when one appears, buys a
|
||||
third track and another Porter.
|
||||
3. **Build one industry** on a stub off the main — not on the Running Track itself, which the game
|
||||
will not allow.
|
||||
4. **Play a train card** when you get one. It rolls onto the timetable and then runs at that Stage
|
||||
**every Day**.
|
||||
5. **When a train arrives at your Office**, switch cars to it or from it, and do the paying work in
|
||||
the **Cargo** phase.
|
||||
6. Watch the **Blocked** panel whenever you are stuck. It is usually one missing thing: no empty car
|
||||
spotted, no loaded car in the yard, a full box, a locked industry track.
|
||||
|
||||
---
|
||||
|
||||
## 6. Things that surprise new players
|
||||
|
||||
- **A turnout cannot be stopped on.** You may run through it; you may not end a Move there.
|
||||
- **Coupling is mandatory.** Run over a standing car and you take it, whether or not you wanted it.
|
||||
- **An industry track locks while a load is on its MEN | AT | WORK boxes.** No train can enter,
|
||||
cross or work there until it clears.
|
||||
- **A modifier adds a BOX, never room for a car.** It raises how much work an industry can hold, not
|
||||
how much rail it has.
|
||||
- **Nothing may be built outside your Limits** — track, industries and modifiers alike. Your district
|
||||
ends at its sign, and the sign moves outward as your Running Track grows.
|
||||
- **A train must be made up to leave.** Engine at one end; if it has a caboose, the caboose at the
|
||||
far end. A train shunted out of shape sits at your Office until you fix it — a **Small Yard** will
|
||||
re-order a consist for one Move, and each option tells you whether the result can leave.
|
||||
- **Passengers may dry up completely.** Coaches move one way — boarding sends the emptied coach to
|
||||
the Classification Yard, and it only comes back when the Division Yard is bare, which may never
|
||||
happen. When it does, the yard panel warns you and passenger trains are made up empty. **This is
|
||||
the rules working as designed.**
|
||||
- **Expedited trains leave the same Stage they arrived**, after the Cargo phase. Ordinary ones wait.
|
||||
|
||||
After playing some solitaire, play a day or two of multiplayer with bots or friends to get a feel
|
||||
for how players interact. Then you are ready to run a real division.
|
||||
|
||||
---
|
||||
|
||||
## 7. Issues / Suggestions
|
||||
|
||||
Please do report anything that looks like a bug or an area to be improved.
|
||||
|
||||
- **What you expected versus what happened**, with the Day and Stage. "Day 2 Stage 9, train 5 had
|
||||
no coaches" is worth more than "passengers seem broken".
|
||||
- **Save the game** (the **Save replay** button) and send the file. A save is the seed and the moves
|
||||
made, so it replays exactly and the problem can be looked at directly. In a Competitive
|
||||
multiplayer game the button waits until the game is over, because the file carries the seed and
|
||||
the seed is every rival's hand; note the Day and Stage and save it at the end.
|
||||
- **Anything the screen did not explain.** If you had to guess a rule, that is worth saying even
|
||||
when the game was right.
|
||||
- **Anything you went looking for and could not find.**
|
||||
|
||||
**A save replays under the rules of the build that opens it.** When a rule changes, a save made
|
||||
before it may stop part-way — the game says which move it stopped on and leaves your file untouched,
|
||||
so the build you played on will still finish it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Documentation / References
|
||||
|
||||
| For | Read |
|
||||
| --- | --- |
|
||||
| The rules in full, with the FAQ | [Rules](rules.md) |
|
||||
| How the Home Office deck is dealt and played | [Home deck](home-deck.md) |
|
||||
| The Mainline cards and what they do to a train | [Mainline deck](mainline-deck.md) |
|
||||
| Rolling stock, yards, trays, the Fedora | [Components](components.md) |
|
||||
+481
@@ -0,0 +1,481 @@
|
||||
# Station Master — Rules
|
||||
|
||||
**Version 0.8.6** · 2026-09-29
|
||||
|
||||
**Authority:** observed code paths and tests. Where a card face, a prototype document and executable
|
||||
behaviour differ, this document reports **executable behaviour** and marks unimplemented material.
|
||||
Per-card numbers are not repeated here — the Home deck and Mainline deck references carry tables generated from
|
||||
`src/engine/content.ts` and is the table of record.
|
||||
|
||||
**New to the game? Start with the [Quickstart](quickstart.md).**
|
||||
|
||||
## 1. Overview and background
|
||||
|
||||
Station Master is a railroad-operations game set in the timetable-and-train-order era. Each player operates an Office Area on a shared east–west Division. The local work is building rail, switching cars, moving passengers and freight through facilities, and keeping the Office clear. The shared work is dispatching scheduled trains across the Division. The player holding the Superintendent’s Fedora decides whether to clear a following train into an occupied Subdivision; an unsafe decision can cause a collision.
|
||||
|
||||
Revenue is the score. Passenger boarding and detraining, freight loading and unloading, completed runs, collisions, and victory conditions are all resolved by the rules configured when the game is dealt.
|
||||
|
||||
This book is divided as follows:
|
||||
|
||||
1. overview and scope;
|
||||
2. definitions;
|
||||
3. creating, saving, replaying, and configuring games;
|
||||
4. the shared basic game mechanics;
|
||||
5. the implemented solitaire game;
|
||||
6. the implemented multiplayer/engine status; and
|
||||
7. FAQs and implementation limits.
|
||||
|
||||
The companion references are the [Quickstart](quickstart.md) for a new player,
|
||||
[Mainline deck](mainline-deck.md), [Home deck](home-deck.md),
|
||||
[components](components.md), and the generated per-card table
|
||||
[Home deck](home-deck.md) and [Mainline deck](mainline-deck.md).
|
||||
|
||||
## 2. Definitions
|
||||
|
||||
| Term | Meaning |
|
||||
| --- | --- |
|
||||
| A/D track | An Office arrival/departure capacity. A Whistle Post has 1; Depot, Station, and Terminal have 2, 3, and 4. |
|
||||
| Card location | A square in an Office Area grid. A train may end a switching Move only on Operational Rail. |
|
||||
| Classification Yard | The holding yard for used rolling stock. It refills the Division Yard only when that yard is completely empty. |
|
||||
| Control Point | A Depot, Station, or Terminal. It breaks the shared Mainline into Subdivisions. A Whistle Post is not one. |
|
||||
| Crew Tray | An engine plus up to four coupled rolling-stock cars. The engine itself does not count as a car. |
|
||||
| Department pile | One of three shared face-up card stacks. Draw and discard affect only the top card. |
|
||||
| Division Point | The western or eastern end of the shared Division, where trains enter and leave it. |
|
||||
| Division Yard | The active source of rolling stock. |
|
||||
| Expedited train | A train with the expedite rule. It performs Load/Unload at an Office after arriving, then attempts to depart at the end of that same Stage. |
|
||||
| Facility | A Passenger Facility at an upgraded Office or a Freight Facility at an industry. |
|
||||
| Freight Agent | The Local Operations option that stocks one outbound box, clears one inbound box, unjams one load, or ends without acting. |
|
||||
| Limits | The two signs defining an Office Area’s Running Track. They move outward when that track is extended. |
|
||||
| Local Operations | The first player-driven phase of a Stage. A player chooses one: switch, draw, or Freight Agent. |
|
||||
| Mainline | The shared cards between Offices and Division Points. |
|
||||
| MEN \| AT \| WORK | A three-box freight pipeline. Any load on it locks its industry track. |
|
||||
| Operational Rail | A card on which a train may finish a Move. Turnouts are pass-through only; a locked industry is not usable at all. |
|
||||
| Running Track | The east–west track between an Office Area’s Limits, including its Office. |
|
||||
| Secondary Track | All local rail inside the Limits that is not Running Track. |
|
||||
| Stage | One of twelve turns in a Day. Its phases are Local Operations, New Train, Mainline, Load/Unload and Shift Change. **On screen the last two are labelled "Cargo" and "Supervisor Shift"** — same phases, the names the interface uses. |
|
||||
| Subdivision | Mainline between Division Points or Control Points. Clearance checks look through the whole next Subdivision. |
|
||||
| Superintendent | The player with the Fedora. The role decides same-direction clearances and rotates every three Stages. |
|
||||
| Timetabled train | A numbered train card scheduled to one of the 12 Stage slots, then due at that slot each Day. Odd numbers go west; even numbers go east. |
|
||||
|
||||
## 3. Create, save, replay, and game configuration
|
||||
|
||||
### 3.1 Starting a new game in the shipped client
|
||||
|
||||
The front page has three doors: **Play multiplayer**, **Play solitaire**, and **Browse replays**.
|
||||
|
||||
**Solitaire** runs entirely in your own browser and needs nothing from the server. Select **New
|
||||
game**, then choose:
|
||||
|
||||
1. a numeric seed, or leave it blank for a fresh browser-generated seed;
|
||||
2. a starting hand;
|
||||
3. passenger, freight, and train-transit Revenue rates;
|
||||
4. the number of Days, and the optional rules.
|
||||
|
||||
**Multiplayer** goes to the lobby — see §3.5. It needs the server, because the game is authoritative
|
||||
there rather than in any one browser.
|
||||
|
||||
The browser writes those choices into the URL. A particular game is defined by the **seed plus these house rules**, not the seed alone.
|
||||
|
||||
The three starting-hand choices are:
|
||||
|
||||
| Choice | Setup result |
|
||||
| --- | --- |
|
||||
| Three random cards | Default. Deal three cards from one shuffled deck. |
|
||||
| Six random cards | Deal six cards from one shuffled deck. The player begins over the normal hand limit and must play/discard down before ending the first Draw turn. |
|
||||
| Three random track and three random non-track cards | Shuffle track and non-track piles separately for the opening deal; then recombine and shuffle their remainders for all later draws. |
|
||||
|
||||
The three Revenue values are independently rounded and clamped to 0–5:
|
||||
|
||||
- passenger Revenue per coach, paid on boarding and again on detraining;
|
||||
- freight Revenue per load, paid on completion of an outbound load and again on completion of an inbound unload; and
|
||||
- train Revenue per transit, paid to every player when a train leaves the Division.
|
||||
|
||||
Defaults are 1 / 1 / 0. Zero turns that revenue source off; it does not prevent the underlying operation.
|
||||
|
||||
### 3.2 Deterministic setup and the seed
|
||||
|
||||
The engine uses one seeded random stream for shuffles, Mainline selection, opening D12 rolls, timetable rolls, and automatic Heavy Grade orientation. Given the same seed, configuration, player names/order, and accepted action history, it reconstructs the same state.
|
||||
|
||||
At setup it creates each Office Area as `Limit — Whistle Post — Limit`, makes the Division Yard, creates players + 3 Crew Trays, turns up three Department cards, and constructs the shared Division.
|
||||
|
||||
In a multi-player engine game, every player receives two seeded D12 rolls:
|
||||
|
||||
- The **division roll** orders seats from low west to high east; equal results put the lower player index farther east.
|
||||
- The **Superintendent roll** gives the initial Fedora to the first player tied for highest.
|
||||
|
||||
The opening deal starts at the Superintendent’s seat and proceeds **eastward** — increasing seat index, which is how the Division map draws the table.
|
||||
|
||||
### 3.3 Saving, resuming, and replaying solitaire
|
||||
|
||||
The local save format is JSON containing the seed, the accepted intent history, and the house rules. It does not serialize mutable game state. Loading recreates the game from the seed and replays every action through the rules engine. If an old history reaches an action that is no longer legal, replay stops there instead of forcing an invalid state.
|
||||
|
||||
The browser also stores the current local game and resumes it automatically when the page opens without an explicit seed in the URL. A downloaded save is named `station-master-seed<seed>-day<day>.json`.
|
||||
|
||||
**Undo is solitaire-only.** It removes the final accepted intent and rebuilds the game from the earlier history. Random outcomes are not rerolled: replaying the same action consumes the same seeded result. Undo can therefore change the player’s decision after seeing an outcome, but cannot fish for a different timetable die roll.
|
||||
|
||||
### 3.4 Game modes and endings
|
||||
|
||||
Three modes: **Solitaire**, **Competitive** and **Co-op**. All three are playable.
|
||||
|
||||
**Length is a free `days` count**, not a preset — any number of Days may be set. The default is 5.
|
||||
|
||||
**How a game ends and who wins:**
|
||||
|
||||
1. **The timetable runs out** at the end of the last Day. Then:
|
||||
2. **The combined Revenue floor** is checked first — `3 × players × days`. Fall short and
|
||||
**everybody loses**, whatever anyone individually scored.
|
||||
3. **Co-op** wins as a table if the floor is met.
|
||||
4. **Competitive** is won by the **highest individual Revenue** once the floor is met.
|
||||
5. **Collisions end it early.** Breaching the per-Day or total collision limit ends play at once in a
|
||||
collective loss — the railroad has been declared unsafe.
|
||||
|
||||
**Extended play (Gitea#11).** Both days-based endings — running out of timetable, and closing short
|
||||
of the Revenue floor — offer **another Day**, because they are the same event seen twice: the last
|
||||
Day ended, and this is what the books say. A collision ending is **not** extendable, and neither is a
|
||||
collision breach during an extended Day. The official result is frozen when the timetable first ran
|
||||
out, so a railroad declared unsafe on Day 9 does not retract who won on Day 5.
|
||||
|
||||
**Three optional rules, all implemented:**
|
||||
|
||||
| Rule | Effect |
|
||||
| --- | --- |
|
||||
| Reduced Visibility | Five switching Moves instead of six, in Stages 1, 2, 3, 11 and 12 — the night Stages. |
|
||||
| Employee Rotation | Every player moves one chair at the end of each Day. Revenue and the Fedora travel with the player; the Office Areas stay with the seats. |
|
||||
| Emergency Toolbox | Starts every player holding the Red Flags status, so the hand limit opens at four. |
|
||||
|
||||
(There is no "Sister Trains" flag. It appeared in an earlier draft of this document and never in the
|
||||
configuration.)
|
||||
|
||||
### 3.5 Multiplayer setup
|
||||
|
||||
Multiplayer is delivered and is what this package is for. The flow:
|
||||
|
||||
1. **Create or join.** The host creates a game and gets a **game code**; everyone else joins with
|
||||
that code. Seats fill as people arrive, and any seat left empty can be **filled with a bot**.
|
||||
2. **Start.** Once the host starts, that same page is where every player plays their turns and
|
||||
watches the table. There is nothing else to open.
|
||||
3. **The server is authoritative.** The game lives on the server, not in a browser: it survives a
|
||||
page reload, a browser restart and a service update, replaying its intent history to get back to
|
||||
where it was.
|
||||
4. **Your seat is a token in YOUR browser**, scoped to the origin you joined at. A reload finds it
|
||||
and puts you straight back. Clearing site data, a private window, or a different browser does not:
|
||||
the seat is still yours and still on the server, but that browser can no longer prove it is you.
|
||||
An administrator can mint a **single-use recovery link** (StartOS action **Restore a Seat**) that
|
||||
trades a code for the token and expires in 30 minutes.
|
||||
5. **Watching the table.** Other players' turns arrive as an ordered replay rather than as a board
|
||||
that has silently rearranged itself, with a `[N behind]` counter, Pause and Skip.
|
||||
|
||||
Solitaire needs none of this and runs with the page alone.
|
||||
|
||||
## 4. Basic game mechanics
|
||||
|
||||
### 4.1 The Stage sequence
|
||||
|
||||
Each of 12 Stages follows this sequence:
|
||||
|
||||
```text
|
||||
1. Local Operations — each player, starting with the Superintendent and proceeding eastward
|
||||
2. New Train — make up due timetabled trains, then queued second sections and Extras
|
||||
3. Mainline — automatic train movement in numeric order
|
||||
4. Load/Unload — each player, same order. On screen: "Cargo"
|
||||
5. Shift Change — expedited departures, workers. On screen: "Supervisor Shift"
|
||||
```
|
||||
|
||||
**"Proceeding left" is seat order, west to east**, which is how the Division map draws it — the
|
||||
screen says "eastward" for that reason, because a table has no shared left.
|
||||
|
||||
At a Shift Change, Laborers and Porters reset. The Fedora moves after Stages 3, 6, 9, and 12. The collision limits are judged on the Stage just played, before anything else. At Day end, dispatch-device use resets, collision count resets, the Day and Stage roll over, and victory is checked.
|
||||
|
||||
### 4.2 Local Operations: choose one option
|
||||
|
||||
On a player’s Local Operations turn, choose exactly one available option.
|
||||
|
||||
**Switch.** Select any Crew Tray currently in that player’s Office Area. It gets six Moves, or five under Reduced Visibility on the listed night Stages. A Move travels any connected distance in one direction and must end on Operational Rail. Reversing is a separate Move. A train may pass through a turnout but cannot stop on it. It may not share or pass through another train except through an Office with a free A/D track.
|
||||
|
||||
Standing cars couple automatically when the train reaches them; it may not pass them, and the resulting consist may not exceed four cars. Coupling forward places cars ahead of the engine; coupling while backing places them behind it. Setting out cars does not spend a Move, but the cut must come from an outer end of the consist and may not be left on the Office. A **Small Yard** re-orders a consist for one Move, and may also place cars **ahead
|
||||
of the engine** — which is how a cut is set up to be shoved into a facing industry. Each option on
|
||||
the menu shows the train it would build, laid out west to east as the board draws it, and says
|
||||
whether the result may leave the Office or would be held there. Flying Switch spends one Move to
|
||||
roll a tail cut into a connected Freight Facility.
|
||||
|
||||
**Draw.** Take one card from the face-down Home Office or the exposed top of one Department pile. During this option, play eligible cards and/or discard cards to Department piles, then finish at the hand limit. Track, facilities, offices, modifiers, enhancements, and train cards have the placement or scheduling rules in the deck references. Mainline modifiers are played from this option as well.
|
||||
|
||||
> **A Department that is emptied is refilled at once.** §6.2: *"If any of the Department decks is
|
||||
> empty, draw a Home Office card and place it in the empty spot."* So taking the last card off a
|
||||
> pile does not leave a gap — a fresh card is turned face up from the Home Office deck to replace
|
||||
> it, and there are always three Departments to draw from. The refill happens **only** when the
|
||||
> draw actually empties the pile; a pile with cards buried under the one taken is not refilled, or
|
||||
> the Departments would grow without limit and drain the deck into themselves.
|
||||
>
|
||||
**Freight Agent.** Make one of these operations, then the turn ends: stock one green outbound box from a matching loaded Division Yard car; clear one red inbound box to the Classification Yard; unjam one outbound, inbound, or MEN | AT | WORK load — the one you name, when a box holds more than one — to the Classification Yard; or explicitly end without acting. Freight can be stocked only when an unclaimed empty matching car is already spotted at that industry. Passengers may wait in a green Office box without a train present.
|
||||
|
||||
> **A car cleared from a red Inbound box comes back empty.** Whatever was in it has arrived — the
|
||||
> passengers are out of the station, or the load is in the industry — and the Revenue for it was
|
||||
> paid on arrival, not on clearing. What returns to the Classification Yard is the *car*, in the
|
||||
> common supply and carrying nothing.
|
||||
|
||||
Implementation note: `card.discard` is accepted during Local Operations
|
||||
without checking that the Draw option was chosen — unlike `card.play`, which does check. This is an
|
||||
implementation quirk rather than a fourth published turn option.
|
||||
|
||||
### 4.3 Track, switching, and local safety
|
||||
|
||||
The Running Track cannot be broken by a curve or other card lacking its east–west through route. Local branches use turnouts and 45° curves. Cards that touch vertically connect only if their 45° legs share the correct diagonal; mere adjacency is not rail connection.
|
||||
|
||||
An industry’s track is locked while any load is on its MEN | AT | WORK pipeline. Trains cannot enter, cross, pick up from, or set cars out there. When it is unlocked, an industry track is ordinary Operating Rail and holds up to four cars, the same as any other non-turnout, non-Limits card: a crew may set out one, two, three or four cars there, whatever it is carrying. An industry’s green and red boxes are how much work it can hold, not how much rail it has, and a Modifier beside it adds a box without adding room for a car.
|
||||
|
||||
Train-card restrictions also apply while switching. No-switching trains cannot move, set out, or sort locally. Trains 3/4 share a one-freight-car budget for pickup or setout at each grid location. Trains 7/8 cannot set out their coach. X13 cannot pick up, and X22 may pick up empty cars only.
|
||||
|
||||
### 4.4 New Train phase
|
||||
|
||||
At the Stage shown on the timetable, the engine makes up the matching timetabled train if a Crew Tray is free. It starts at the Division Point appropriate to its direction. A train may receive matching loaded or empty cars from the Division Yard until its listed maximum consist is reached or no suitable car remains. It is permitted to leave under-strength.
|
||||
|
||||
A Second Section order on the due train creates another identical timetabled train behind it when a
|
||||
free tray is available. A played Extra is made up after timetabled trains and Second Sections when a
|
||||
tray is free, and **the player who played the card chooses where it starts** — either Division
|
||||
Point, the Interchange, or an Office, according to the `extraStart` house rule — and loads it as they
|
||||
choose rather than going round the table.
|
||||
|
||||
A timetabled train's consist is built by the table: **starting with the Superintendent and working
|
||||
eastward, each player adds ONE car**, going round again until the train is full or the Division Yard
|
||||
holds nothing it can take.
|
||||
|
||||
**When the yard can supply nothing, the game says so.** The round is skipped — there is no point
|
||||
asking for a car that cannot be given — and the train is reported as made up short, naming what its
|
||||
card wanted, how many such cars are waiting in the Classification Yard, and how far the Division Yard
|
||||
is from bare. See §4.6 for why that happens to coaches in particular.
|
||||
|
||||
### 4.5 Mainline movement and Office arrival
|
||||
|
||||
Mainline movement is automatic and processes lower train numbers first; a timetabled train outranks an Extra with the same number. A train at a Division Point, at an Office A/D track, or already crossing a Mainline card attempts its applicable movement.
|
||||
|
||||
**Crossing time is the card's REGIONS**, one per Stage — not its printed mph, which is scenery, and
|
||||
not the train's Fast/Slow rating, which only **Hilly** reads. See the
|
||||
[Mainline deck](mainline-deck.md) reference for what moves a train's entry
|
||||
point. On a normal card, a train must check the entire next Subdivision before entering it:
|
||||
|
||||
- an opposing train normally blocks entry;
|
||||
- a following same-direction train asks the Superintendent to allow or deny clearance;
|
||||
- Red Flags or ABS Signals hold the follower automatically; and
|
||||
- **Double Track** — the one card two trains may stand on — allows entry without that check. The
|
||||
**Uncontrolled Siding** is not a passing card: a train arriving to find it occupied takes the
|
||||
siding a region behind, which costs it the extra Stage instead of a collision.
|
||||
|
||||
An Office arrival normally takes a free A/D track. If the Office is full, the inbound train collides and the local Office player loses 5 Revenue; Interlocking instead holds it at the Limits. A held train takes the first A/D track that frees — whether another train's arrival or a departure freed it — ahead of anything arriving after it, and no later than the end of the Mainline phase in which the track became free. A coachless inbound train may divert to a Yard Office. Cars fouling the Running Track at the Office also cause a collision.
|
||||
|
||||
An expedited train is released by the ordinary rules like any other; what Expedite restricts is where it may be left. If it is standing anywhere in the district but the Office when a Mainline phase begins, its owner is fined once for that phase. An arrival waits until a later Mainline phase. A train departing an Office must be correctly made up: engine at one end, caboose at the far end if present.
|
||||
|
||||
When a train leaves the far Division Point, its cars are returned to yards, its Crew Tray becomes free, and each player receives the configured train-transit Revenue (zero by default).
|
||||
|
||||
### 4.6 Passenger work
|
||||
|
||||
Passenger work occurs during Load/Unload, at a Depot, Station, or Terminal. Each Porter may take one action per Stage.
|
||||
|
||||
- **Board:** replace an empty coach on an eligible train at the Office with a loaded coach from a green outbound box. The removed empty coach goes to the Classification Yard. Earn configured passenger Revenue.
|
||||
- **Detrain:** replace a loaded coach on an eligible train with an empty coach from the Division Yard, placing the loaded coach into an available red inbound box. Earn configured passenger Revenue.
|
||||
|
||||
Crack Limited trains permit passenger work at Terminals only. Military Train and Director's Private
|
||||
Car permit no passenger work. A Whistle Post has no Porters.
|
||||
|
||||
> **Coaches travel one way, and it is worth knowing before you plan around passengers.** Boarding
|
||||
> sends the emptied coach to the **Classification** Yard; detraining draws a fresh empty out of the
|
||||
> **Division** Yard; and §2.2 returns the Classification Yard only when the Division Yard runs
|
||||
> completely bare. Measured over one three-Day game: sixteen coaches in the Division Yard at setup,
|
||||
> **none from Day 2 Stage 8 onward**, fifteen piled in Classification while the Division Yard held
|
||||
> steady at 46–47 freight cars and stopped draining — so the refill never fired and no passenger
|
||||
> could board or detrain anywhere for the rest of the game, while trains whose cards call for coaches
|
||||
> were made up empty.
|
||||
>
|
||||
> **This is the rules working as printed: running out of cars is part of the game.** The yard panel
|
||||
> warns while the shortage lasts, and a train made up short reports what it wanted and why none is
|
||||
> coming.
|
||||
|
||||
### 4.7 Freight work
|
||||
|
||||
Freight work also occurs during Load/Unload. Each Laborer can take one action per Stage. Every freight facility has MEN, AT, and WORK boxes.
|
||||
|
||||
For an **outbound load**, first use the Freight Agent to put a matching loaded car into the green box, with an unclaimed empty matching car spotted on the industry track. A Laborer starts the load on MEN, then advances it to AT, then WORK, then onto the empty car. Completing the last step changes that car to loaded, sends the replaced empty car to the Classification Yard, and earns configured freight Revenue.
|
||||
|
||||
For an **inbound unload**, a matching loaded car must be spotted at an inbound-capable industry and the Division Yard must contain a matching empty car. A Laborer replaces the spotted loaded car with that empty car and starts the load on WORK. Laborers move it backward through AT and MEN into a free red inbound box. That final placement earns configured freight Revenue. Clearing the red box later with the Freight Agent pays no additional Revenue.
|
||||
|
||||
## 5. Solitaire
|
||||
|
||||
Solitaire is one named player and one Office Area, running entirely in the browser with no server.
|
||||
It is **not** the only implemented game any more — see §6 — but it is the one that needs nothing but
|
||||
the page.
|
||||
|
||||
Solitaire-specific features are:
|
||||
|
||||
- a local browser save, automatic resume, and JSON download/load;
|
||||
- **unlimited step-by-step Undo** back through accepted action history — multiplayer has none,
|
||||
because a shared game cannot be rewound under the other players; and
|
||||
- a seed/rules URL suitable for sharing or reproducing a game.
|
||||
|
||||
Automatic phases get a visible beat in solitaire too, so the board plays its own moves out rather
|
||||
than jumping.
|
||||
|
||||
The active deck removes all 22 opponent-directed cards. Therefore, the defensive cards whose only purpose is to answer them (Facing Point Locks and Water Column) can be placed but have no opportunity to fire; Overpass has no effect at all. The game still includes shared-rail mechanics such as Mainline clearance, but with one player no other player can occupy the Division.
|
||||
|
||||
To win the default one-player game, finish Day 5 having met the combined Revenue floor —
|
||||
`3 × players × days`, which at one player over five Days is **15**. Below it is a loss, and the game
|
||||
offers you another Day rather than simply ending. Train-transit Revenue defaults to zero, so the
|
||||
score has to come principally from passenger and freight work.
|
||||
|
||||
## 6. Multiplayer
|
||||
|
||||
### 6.1 What the engine supports
|
||||
|
||||
The rules engine supports multiple named players; the lobby, server and
|
||||
client around it are delivered too, so this section now describes a game people actually play. The
|
||||
engine has player, seat, score, Office Area, Division, phase-order, co-op and competitive-mode data
|
||||
for multiple named players. It deals each player a hand, creates one Office Area per seat, starts acting order at the Superintendent and proceeds eastward, and models the following multiplayer-specific outcomes:
|
||||
|
||||
- the D12 seating and Superintendent rolls described in section 3;
|
||||
- individual Revenue in competitive play and shared total Revenue in co-op;
|
||||
- a collective loss on breaching the collision limits;
|
||||
- the combined Revenue floor, `3 × players × days`; and
|
||||
- train-transit Revenue awarded to every player, if that revenue setting is nonzero.
|
||||
|
||||
The engine’s setup checks only that there is at least one player and that Solitaire has exactly one player. It does not enforce a maximum player count, although the test and design material exercise two through four players.
|
||||
|
||||
### 6.2 What IS delivered
|
||||
|
||||
Everything in §3.5: a lobby with game codes, seating, bots filling empty chairs, an authoritative
|
||||
server that survives restarts and updates by replaying its intent history, per-seat reconnection, an
|
||||
administrator's single-use seat-recovery link, and an ordered replay of other players' turns on each
|
||||
player's own screen.
|
||||
|
||||
The package also exposes administrative **actions** on StartOS — list games in progress, get the
|
||||
join secret, manage a game, restore a seat — documented in the wrapper repository rather than here.
|
||||
|
||||
### 6.3 What is NOT delivered
|
||||
|
||||
**No card may be played at another player.** The 12 space-use and 10 action cards are excluded from
|
||||
every dealt deck in every mode, and `check` rejects playing one. That includes Derail, Broken
|
||||
Coupler, Railroad Crossing, the score-penalty cards, Vandalism, Hotbox, Outlawed and all
|
||||
table-space cards. Facing Point Locks and Water Column exist only as dormant defences against
|
||||
effects nothing can currently cause, and Overpass has no effect at all.
|
||||
|
||||
**No multiplayer Undo.** A shared game cannot be rewound under the other players.
|
||||
|
||||
### 6.4 How multiplayer differs from solitaire
|
||||
|
||||
Players have separate districts, hands and scores, and share the Home Office deck, the Department
|
||||
piles, the yards, the timetable, the Mainline and every traffic consequence. One player acts at a
|
||||
time.
|
||||
|
||||
**A seat is not a player**, and the distinction is load-bearing. Offices belong to seats; Revenue,
|
||||
hands, the Fedora and identity belong to players. With **Employee Rotation** on, players move one
|
||||
chair at the end of each Day and take their Revenue and the Fedora with them, while the districts
|
||||
stay where they are.
|
||||
|
||||
So multiplayer changes shared traffic, scores, turn order and how the game is won or lost — not card
|
||||
attacks, which do not exist in any mode.
|
||||
|
||||
**Saving a multiplayer game.** The server holds the save; **Save replay** downloads your copy. A save
|
||||
carries the seed, and the seed is the whole deal — every hand and the order of the deck — so in a
|
||||
**Competitive** game the download is available once the game is over. In **Co-op** it is available
|
||||
at any time.
|
||||
|
||||
## 7. Frequently asked questions
|
||||
|
||||
### Is the same seed always the same game?
|
||||
|
||||
Only with the same house rules, player configuration/order, and action history. The seed drives all engine randomness, but the starting hand and Revenue settings also change the game.
|
||||
|
||||
### Why can’t I end my draw turn?
|
||||
|
||||
Your hand exceeds three cards, or four when the Red Flags hand-limit status is active. Play or discard down to the limit.
|
||||
|
||||
### Why can’t my train move onto a turnout?
|
||||
|
||||
It may move through a turnout but cannot end a Move there because turnouts are not Operational Rail.
|
||||
|
||||
### Why can’t a train enter my industry?
|
||||
|
||||
Its MEN | AT | WORK pipeline contains a load, which locks the track; the rail geometry does not connect; another train occupies it; mandatory coupling would exceed four cars; or four cars are already standing there, which is as many as any card holds.
|
||||
|
||||
### Why won’t an industry accept a new outbound load?
|
||||
|
||||
The green box may be full, the Division Yard may lack a matching loaded car, or there is no unclaimed empty matching car already spotted on that industry’s track.
|
||||
|
||||
### Why does an inbound freight unload require an empty car in the Division Yard?
|
||||
|
||||
The engine immediately replaces the spotted loaded car with a matching empty car. It will not create a new car from nothing.
|
||||
|
||||
### Why did an arriving train collide instead of wait?
|
||||
|
||||
The Office had no free A/D track and no Interlocking. A full Office is an automatic local collision. An Interlocking holds the arrival at the Limits.
|
||||
|
||||
### Why is a train held at an Office even though the Mainline is clear?
|
||||
|
||||
It may be on Secondary Track rather than the Office, or be badly made up: its engine is between cars or its caboose is not at the far end.
|
||||
|
||||
### Why was I fined for an expedited train?
|
||||
|
||||
Expedite does not change when a train leaves — it leaves when the Mainline rules release it, like any
|
||||
other. It changes where the train may be left: if a Mainline phase begins with it standing on any
|
||||
track in the district but the Office, the owner is fined. The fine is charged once per Mainline
|
||||
phase, however many clearance questions that phase asks.
|
||||
|
||||
### Can I choose the direction of an Extra or a Heavy Grade?
|
||||
|
||||
**An Extra, yes** — the player who played the card chooses where it starts and which way it runs, and
|
||||
loads it as they choose. **A Heavy Grade, no**: orientation is rolled from the seed. That is
|
||||
a decision rather than a gap — the card sits between two districts and belongs to neither, so handing
|
||||
the choice to one neighbour would advantage them permanently. See the Mainline deck reference.
|
||||
|
||||
### Can I use an Interchange to reorder a train?
|
||||
|
||||
**No.** Its printed car-sorting is not implemented. What the Interchange offers is the one Mainline
|
||||
card with a yard, so an **Extra may be made up and started there**. Re-ordering a consist is done at
|
||||
a **Small Yard** in a district.
|
||||
|
||||
### Can I play attack cards on another player?
|
||||
|
||||
No. All action and space-use cards are excluded from every dealt deck in every mode, and playing one
|
||||
is rejected.
|
||||
|
||||
### Is multiplayer playable?
|
||||
|
||||
**Yes.** Lobby, game codes, seating, bots, an authoritative server that survives restarts, per-seat
|
||||
reconnection and a replayed view of everyone else's turns — see §3.5 and §6. The opponent-directed
|
||||
cards are unimplemented in every mode, so there are no card attacks.
|
||||
|
||||
### Why did my passenger train arrive with no coaches?
|
||||
|
||||
Almost certainly the coach ratchet in §4.6: every coach has ended up in the Classification Yard,
|
||||
which comes back only when the Division Yard runs completely bare. The yard panel warns when this
|
||||
has happened, and a train made up short says so in the log.
|
||||
|
||||
### Why is every option in the Small Yard marked "HELD at the Office"?
|
||||
|
||||
Because that train is **already made up**, so every re-order on offer would break it — most often by
|
||||
moving the caboose off the rear, which §8.2 will not let a train depart with. If the train is *not*
|
||||
currently fit to run, at least one option will be marked "MADE UP, ready to leave".
|
||||
|
||||
### Will an old save still replay?
|
||||
|
||||
Not always. A save is a seed and the list of moves, replayed through the rules of whatever build
|
||||
opens it — so a change that makes a once-legal move illegal stops the replay at that move. The game
|
||||
names the move and leaves the file untouched; the build the game was played on will still finish it.
|
||||
|
||||
## §8.1 in practice — what holds a train at the end of the line
|
||||
|
||||
A train is held, released or put to the Superintendent by what is in the **Subdivision** ahead of
|
||||
it: the run of Mainline cards between two Control Points. Every Office that is not a Control Point
|
||||
lies inside one, so a table of Whistle Posts is a single Subdivision from end to end.
|
||||
|
||||
- A train coming **towards** it is an absolute bar. The train holds, and the history says which
|
||||
train is coming and that there is no Control Point between them to pass at.
|
||||
- A train going the **same way** is the Superintendent's ruling.
|
||||
- A card printing **"trains may pass"** excuses only what is standing on **that card**. It does not
|
||||
clear the rest of the Subdivision.
|
||||
- A train **standing at an Office with no free A/D track** occupies the Subdivision too: it is about
|
||||
to re-enter and there is nowhere for the two to pass. An Office with a track still free does not
|
||||
hold anyone up.
|
||||
|
||||
**An Office never holds more trains than it has A/D tracks.** A train the Interlocking is holding at
|
||||
the Limits takes the first track to free, ahead of anything arriving afterwards — and the train that
|
||||
arrives to find it taken is held at its own Limits, or collides if there is no Interlocking. A track
|
||||
freed by a departure counts too: the held train takes it at the end of that Mainline phase.
|
||||
@@ -4,6 +4,12 @@
|
||||
> 2026-07-30 in `docs/Deck cards2.xlsx`, `Trains3.pdf` and `Mainline Cards.pdf`, and is transcribed
|
||||
> in `src/engine/content.ts`. See [`implications.md`](implications.md) for the full comparison.
|
||||
>
|
||||
> **For what the cards say today, read the generated tables in [`home-deck.md`](../home-deck.md)
|
||||
> and [`mainline-deck.md`](../mainline-deck.md)** — written by `npm run build:cards` from
|
||||
> `content.ts` and checked against it by the test suite, so they cannot fall behind the way this
|
||||
> file did. For several releases `content.ts` named *this* page as the current reference while the banner
|
||||
> here said otherwise, and a reader following the code landed on the v0.4.5 deck.
|
||||
>
|
||||
> Kept for the reasoning it records — the economy analysis in §7 was how we knew what questions to
|
||||
> ask the design. **Do not use its numbers.**
|
||||
|
||||
@@ -42,17 +48,37 @@ Operational Rail wheel icon, an industry track of the stated length, Laborer ico
|
||||
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| Mine Tipple | Hopper | Outbound only | 3 | 3 | — | 4 | 2 |
|
||||
| Produce Shed | Reefer | Outbound only | 2 | 2 | — | 3 | 2 |
|
||||
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 | 2 |
|
||||
| Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4 | 2 |
|
||||
| Power Plant | Hopper | Inbound only | 3 | — | 3 | 4 | 2 |
|
||||
| Grocer's Warehouse | Boxcar or reefer | Inbound only | 1 | — | 1 | 3 | 3 |
|
||||
| Oil Refinery | Tank car | Outbound only | 1 | 1 | — | 4 | 3 |
|
||||
| Power Plant | Hopper or tank car | Inbound only | 3 | — | 3 | 4 | 2 |
|
||||
| Freight House | Boxcar | Both | 1 | 1 | 1 | — | 6 |
|
||||
|
||||
Directions follow the commodity: coal originates at a Mine Tipple and is consumed at a Power Plant;
|
||||
produce ships out; a warehouse and a refinery do both. This gives §9 all three of its cases —
|
||||
outbound-only, inbound-only, and both.
|
||||
produce ships out; a warehouse receives. This gives §9 all three of its cases — outbound-only,
|
||||
inbound-only, and both — with the **Freight House the one card that does both**.
|
||||
|
||||
**"Freight House"** (§9.3, Appendix A) is not a card. It is the collective term for a freight facility
|
||||
that loads *and* unloads — the Grocer's Warehouse and the Oil Refinery. §9.3's "Passenger Facilities
|
||||
and Freight Houses permit cars to move each direction" therefore names exactly those two.
|
||||
**"Freight House" IS a card** (corrected v0.5.0) — a sixth industry, dealt 6 copies, one Laborer and
|
||||
one slot each direction. An earlier pass here read §9.3/Appendix A's "Passenger Facilities and
|
||||
Freight Houses permit cars to move each direction" as meaning "Freight House" was only ever a
|
||||
*collective term* for the Grocer's Warehouse and the Oil Refinery, never a card of its own — that
|
||||
reading was wrong; the engine deals it as a real sixth industry (`content.ts`'s `freightHouse`
|
||||
profile) and this table follows the engine.
|
||||
|
||||
**The Grocer's Warehouse and the Oil Refinery are ONE-WAY** (corrected v0.4.9e). The Direction column
|
||||
read "Both" for both of them, and that was the *other half* of the same mistaken reading: if "Freight
|
||||
House" named those two, §9.3 had to be describing them, so they had to be two-way. Once the Freight
|
||||
House is its own card the argument evaporates, and playtesting settled it — "Grocer's Warehouse
|
||||
should be receive only, does not ship anything out"; "Refinery: only ships out tanks, does not
|
||||
receive anything" (Jesse). `home-deck.md` prints both that way, and the modifier
|
||||
set agrees: all three Refinery modifiers (Pipelines, Oil Depot, Viscosity Breakers) grant **+1
|
||||
outbound**, which would be an odd card set for a facility that receives half the time.
|
||||
<!-- TODO v0.5.0: Mine Tipple, Produce Shed and Power Plant above (3/3/4, 2/2/3, 3/3/4) were NOT
|
||||
re-verified against the engine in this pass — only Grocer's Warehouse, Oil Refinery and Freight
|
||||
House were. `mineTipple` and `powerPlant` in `content.ts` are also base 1/1/1, same as the three
|
||||
corrected here, so these three rows and the "Throughput" reasoning below (built on the OLD
|
||||
2–3-Laborer numbers) may be stale too. Flagged in TODO.md rather than silently rewritten — this
|
||||
needs the same kind of decision Jesse made for Grocer's/Refinery/Freight House, not an assumption
|
||||
that the same correction applies. -->
|
||||
|
||||
### Throughput — why these Laborer counts
|
||||
|
||||
@@ -80,9 +106,10 @@ deliberately slower industries, unable to quite keep up with a dedicated player.
|
||||
That is why Laborer counts track Outbound capacity: Mine Tipple 3/3, Power Plant 3/3, Produce Shed
|
||||
2/2, Grocer's Warehouse 2/2. The numbers are derived from the action budget, not chosen freely.
|
||||
|
||||
The Oil Refinery is the exception at 3 Laborers against 2+2 capacity, and deliberately so: it serves
|
||||
two flows through one three-box pipeline, so its pipeline stays fuller than a one-way facility's and
|
||||
the third Laborer is earning its keep.
|
||||
<!-- The paragraph that stood here explained the Oil Refinery's third Laborer as the price of serving
|
||||
two flows through one pipeline. It serves one flow (v0.4.9e), so the explanation is gone with the
|
||||
premise; whether the Laborer count is still right is part of the same unverified block flagged
|
||||
above and in TODO.md. -->
|
||||
|
||||
Even so, Laborers are rarely what limits a player — spotting the empty car and hauling the loaded one
|
||||
away both cost switching actions from the same budget. See §7.
|
||||
@@ -138,8 +165,14 @@ above and below. Upgrades are drop-in and must never disturb a connection.
|
||||
## 5. Modifier cards
|
||||
|
||||
Five cards, one each. A Modifier is **not track**. It is placed adjacent to a Facility, on any of the
|
||||
nine nearby spots, and if adjacent to two Facilities its effect may be used on only one per Stage
|
||||
(§9).
|
||||
nine nearby spots — **diagonals included** — and if adjacent to two Facilities its effect may be used
|
||||
on only one per Stage (§9).
|
||||
|
||||
Two placement rules follow from it not being track. It is **not bounded by the Limits**: a host
|
||||
standing at the edge of the district keeps all nine of its spots, or the card would be unplayable
|
||||
exactly where the district ends. And it may **never stand in the Running Track's row**, which inside
|
||||
the Limits is always full anyway — beyond the sign that row is the ground the main grows onto, and a
|
||||
Modifier parked there would block the Limits sign from moving outward (§2.1).
|
||||
|
||||
| Card | Effect | Applies to |
|
||||
| --- | --- | --- |
|
||||
@@ -252,12 +285,12 @@ with all boxes full:
|
||||
| Car | Facility demand | Supply | Headroom |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Hopper | Mine Tipple 3×2 + Power Plant 3×2 = 12 | 12 | exactly met |
|
||||
| Tank | Oil Refinery (2+2)×2 = 8 | 8 | exactly met |
|
||||
| Boxcar | Grocer's (2+2)×2 = 8 | 12 | 4 spare |
|
||||
| Tank | Oil Refinery 2×2 = 4 | 8 | 4 spare (was "exactly met" while the Refinery was two-way) |
|
||||
| Boxcar | Grocer's 2×2 = 4 | 12 | 8 spare (same correction) |
|
||||
| Reefer | Produce Shed 2×2 = 4 | 8 | 4 spare |
|
||||
| Coach | Terminal 4+4, per Office | 16 | scales with Office count |
|
||||
|
||||
Hoppers and tank cars are exactly met in the theoretical worst case, which cannot occur in practice —
|
||||
Hoppers are exactly met in the theoretical worst case, which cannot occur in practice —
|
||||
only 10 freight facility cards exist across a 52-card deck shared by all players, and the §2.2
|
||||
Classification Yard recycle returns stock to the Division Yard whenever it empties. Both are worth
|
||||
watching in playtesting.
|
||||
|
||||
@@ -24,8 +24,8 @@ Every defined term, alphabetized for lookup. The core comes from the Definitions
|
||||
| **Extra Platform** | Modifier card: +1 green and +1 red slot at a Passenger Facility. | §12.5 |
|
||||
| **Extra Train** | A one-and-done train; its card returns to the Salvage Yard on completion. Head-on card image, so the drawing player picks its direction. Numbered with an "X" prefix; the following number gives its seniority, and it yields to the Timetabled train of that number. | §2.3, §8 |
|
||||
| **Facility** | A business which loads/unloads cargo and freight. | §2.5 |
|
||||
| **Freight Facility** | Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power Plants. Some allow only outbound, some only inbound, some both. Per-card values in §12.5. | §9, §12.5 |
|
||||
| **Freight House** | Not a card — the collective term for a freight facility permitting both directions, namely the Grocer's Warehouse and the Oil Refinery. | §9.3, §12.5 |
|
||||
| **Freight Facility** | Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power Plants, Freight Houses. Some allow only outbound, some only inbound, some both. Per-card values in §12.5. | §9, §12.5 |
|
||||
| **Freight House** | A sixth Freight Facility card (corrected v0.5.0 — an earlier pass here read it as a collective term for the Grocer's Warehouse and the Oil Refinery rather than a card of its own; it is dealt like any other industry). **The only industry that permits both directions** — the Grocer's Warehouse receives and the Oil Refinery ships, one way each (v0.4.9e). | §9.3, §12.5 |
|
||||
| **Highball** | When a train holding at an Office automatically departs. | §2.4 |
|
||||
| **Home Office** | The primary face-down deck cards are drawn from. 52 cards. | §2.6, §12.1 |
|
||||
| **Hopper** | Coal rolling stock (brown = loaded, white = empty). | §2.2 |
|
||||
|
||||
+250
-11
@@ -6,7 +6,7 @@ it into this workspace:
|
||||
| File | What it is |
|
||||
| --- | --- |
|
||||
| `Deck cards2.xlsx` | The **complete card list** — every card, its count, where it may be placed, what it does |
|
||||
| `Mainline Cards.pdf` | The **Mainline card types** — Plains, Curves, Hilly, Heavy Grade, Double Track, Uncontrolled Siding, Tunnel, Trestle, Yard, and both Division Points |
|
||||
| `Mainline Cards.pdf` | The **Mainline card types** — Plains, Curves, Hilly, Heavy Grade, Double Track, Uncontrolled Siding, Tunnel, Trestle, Interchange (printed "Yard"), and both Division Points |
|
||||
| `Trains3.pdf` | The **train cards** — all 22, with names, speed class, consist and individual operating rules |
|
||||
| `tracks.png` | **Card art** for track and industry cards |
|
||||
|
||||
@@ -40,8 +40,9 @@ All nine answers are implemented, **170 tests passing**:
|
||||
|
||||
| Answer | Implemented as |
|
||||
| --- | --- |
|
||||
| Q1 crossing time | `crossingStages()` — a 60 card takes 1 Stage, a 30 takes 2. Mainline nodes now carry a **terrain type** dealt at setup, and trains count down Stages instead of stepping through regions. The `Region` model is gone. |
|
||||
| Q2 Fast/Slow | Slow adds one Stage to every card. Hilly reads the consist (any coach = passenger). |
|
||||
| ~~Q1 crossing time~~ | **SUPERSEDED 2026-08-26 — see Q1a below.** Was: a 60 card takes 1 Stage, a 30 takes 2. |
|
||||
| ~~Q2 Fast/Slow~~ | **SUPERSEDED 2026-08-26 — see Q1a below.** Was: Slow adds one Stage to every card; Hilly reads the consist. |
|
||||
| Q1a crossing time | `crossingStages()` — a card costs one Stage per **region printed on it**, and where a train STARTS is what varies. Plains 1, Double Track 1, Trestle 1, Curves 2, Tunnel 2, Heavy Grade 3. The printed mph are scenery. Fast/Slow is read on Hilly and nowhere else. |
|
||||
| Q3 Expedite | An expedited train departs the Stage it arrives — it gets a second `moveTrain` in the same Mainline Phase, still subject to §8.1 clearance. |
|
||||
| Q4 Lockouts | `isLockedOut()` rejects the placement with `FACILITY_LOCKED`. |
|
||||
| Q5 Run-around | Nothing to do — reachability is geometric, so a built bypass already works. |
|
||||
@@ -76,16 +77,73 @@ Crossing time never falls below one Stage — a train cannot cross in no time.
|
||||
there is nothing to build. A test asserts it remains TBD, to stop anyone "fixing" it by inventing
|
||||
an effect; a silent no-op would be worse than a rejection.
|
||||
|
||||
**Q1a, answered by RAR 2026-08-26 (Gitea#3), and it replaces Q1 and Q2 together.**
|
||||
|
||||
> "Ignore speed signs. They are just graphics. Regions shown on cards indicate how many stages it
|
||||
> takes to cross. Plains is 1. Double track is 1, tunnel is 2, curves is 2, heavy grade is 3 unless
|
||||
> you have help… Some cards say fast / slow. This is an indication that if on the train card, the
|
||||
> train is listed as fast or slow, that's starting position / how many stages it takes to traverse
|
||||
> the card. Fast / Slow does not apply to every card — just those that say fast / slow on them.
|
||||
> Currently this is only hilly."
|
||||
|
||||
What this changes, against what was recorded before:
|
||||
|
||||
- **The printed 60/30 mean nothing.** Q1 read them as crossing time; they are ambiance.
|
||||
- **Fast/Slow is not a global penalty.** Q2 added a Stage to every card for a Slow train, which is
|
||||
what made a Slow train take two Stages to clear Double Track — the report that opened the issue.
|
||||
It now applies on Hilly alone, where a fast train starts in the second region.
|
||||
- **Hilly no longer reads the consist.** RAR: "I notice that you are basing stages in mainline cards
|
||||
off coach/non-coach. Actually, all trains are rated as FAST and SLOW."
|
||||
- **Heavy Grade is three regions, not two**, and the modifiers move the START rather than cutting the
|
||||
clock: Helpers start an uphill train a region on, Brakeman a downhill one, Airbrakes another again.
|
||||
- **The Uncontrolled Siding and the Interchange print a back region** that is not part of the road. A
|
||||
train running through starts past it; a train arriving to find the siding occupied takes it and
|
||||
runs a region behind, which is what keeps the two apart, and an Extra beginning its run at an
|
||||
Interchange starts there too.
|
||||
- **ABS holds a train off the card** rather than letting it collide, on any Mainline card.
|
||||
|
||||
Measured consequence, replacing the one recorded under Q2: on a **3-player Division the Mainline
|
||||
cards themselves now cost a fast train ~5.6 Stages and a slow train ~6.0**, against ~5.4 and ~9.4
|
||||
before. Fast traffic is unchanged; **slow traffic is about a third quicker**, and the Fast/Slow gap
|
||||
across a whole Division collapses from roughly four Stages to less than one. The Q2 note that "every
|
||||
Slow train is still on the road when the next Day begins, holding its Crew Tray" no longer holds, so
|
||||
the `players + 3` tray count is due a re-examination — RAR's own closing worry: "been worried about
|
||||
the time it takes to cross the division. More thunking on this is needed."
|
||||
|
||||
**Q11, answered from the source.** The Heavy Grade card prints **"(Up)"** and **"Player sets
|
||||
orientation"**, so which way it climbs is a property of the placed card, not a fixed compass
|
||||
direction. `DivisionNode.gradeUp` records the direction a train travels when **climbing**; a train
|
||||
going the other way is descending. Brakeman and Airbrakes apply to the descent, Helpers to the
|
||||
climb, so turning the card around swaps which trains each card helps.
|
||||
|
||||
One gap remains, and it is a **setup** gap rather than a rules one: `createGame` is synchronous and
|
||||
returns a ready state, so there is no point at which a player can be asked. Orientation is currently
|
||||
**rolled** — deterministic from the seed, and roughly even (51/49 east/west across 400 games) — and
|
||||
should become a real player choice when setup gains an interactive phase.
|
||||
**Settled (v0.5.0): orientation is never a player choice, and this overrides the card's own printed
|
||||
"Player sets orientation."** A Heavy Grade sits on the shared west-to-east Division chain, between
|
||||
two players (§4.2) or beyond an end Division Point next to one — never inside a single player's own
|
||||
district — so whichever direction climbs advantages one neighbour over the other, and no one player
|
||||
has a fair claim to the decision. Orientation is **rolled** from the seed instead — deterministic,
|
||||
and roughly even (51/49 east/west across 400 games) — identically for solitaire and multiplayer.
|
||||
|
||||
**Re-opened and closed again, 2026-08-23.** The question came back as "did we ever fix Heavy Grade
|
||||
to allow user placement of direction?", and the option of handing the choice to the **Superintendent**
|
||||
— the neutral office, which rotates with the Fedora every three Stages — was considered and rejected.
|
||||
Jesse's call: v0.5.0's ruling stands. What the re-examination surfaced, worth recording so this is not
|
||||
asked a third time:
|
||||
|
||||
- **The advantage is permanent; the office is not.** Orientation decides which modifier cards pay for
|
||||
the whole game (Brakeman and Airbrakes on the descent, Helpers on the climb) and therefore which
|
||||
direction of traffic is favoured — and odd trains run west while even run east. A rotating office
|
||||
making a one-way, once-and-for-all call does not dissolve the fairness problem, it just moves it.
|
||||
- **There is no setup phase to ask in.** `createGame` is pure and synchronous and the game opens at
|
||||
Day 1, Stage 1, Local Operations. The question would have to interrupt play before the first turn,
|
||||
and `clock.pendingDecision` is typed for clearance alone (`SuperintendentClearance | null`, read in
|
||||
19 places), so a second decision kind would be most of the work.
|
||||
- **Most games never meet the card.** Since v0.6.2 deals the Mainline deck without replacement there
|
||||
is at most one Heavy Grade in ten cards, drawn `players + 1` times: **20%** of solitaire games,
|
||||
rising to 50% at four players. A pre-game interrupt for a rule four games in five never see.
|
||||
|
||||
The docs were the actual defect. `README.md` still listed it among three open rules questions (all
|
||||
three closed in v0.5.0) and `mainline-deck.md` still said "the implementation
|
||||
needs a player-selection step to match the card". Both now say settled, and why.
|
||||
|
||||
**Still not implemented**: the Action (10) and Space-use (12) cards, which are genuinely
|
||||
multiplayer-only. Playing them is rejected with `NOT_IMPLEMENTED`.
|
||||
@@ -241,6 +299,27 @@ Freeing the Laborers freed the whole turn economy — passenger revenue doubled
|
||||
passenger rule changing. **Still 0 wins against a target of 20**, and freight is still only 13% of
|
||||
gross, so the loop runs now but does not yet carry the game.
|
||||
|
||||
### Where the empty-car requirement belongs
|
||||
|
||||
The rule above was later copied onto **stocking** as well, so `freightAgent.stockOutbound` refused a
|
||||
green box with no matching empty spotted. That is one step too early, and the designer's ruling is
|
||||
that it should never have been there:
|
||||
|
||||
> A freight agent should be able to load an outbound green box prior to having the car there that
|
||||
> matches the load that he's putting in there. But in order for the laborers to move it from the
|
||||
> green box into the men at work track, they would need to have an empty car of the appropriate type
|
||||
> waiting there.
|
||||
|
||||
Which is what §6.3 and §9.3 say between them. §6.3 lists the Freight Agent's act with no reference to
|
||||
the industry track at all; §9.3's **Load the car** is what requires "*a load in the Green Loading Box
|
||||
and an empty car of the required type on the industry's track*". So the two errands — fetch the
|
||||
cargo, spot the car — may be run in either order, and only the Laborer needs both done.
|
||||
|
||||
**Staging ahead cannot jam anything**, which is why the early gate bought nothing: a load in a green
|
||||
box is waiting, not stuck, and the lockout in §9.3 applies to loads on MEN | AT | WORK. `startableLoad`
|
||||
holds the real gate, counting cars against loads already staged or on the sign so two loads can never
|
||||
walk toward one car.
|
||||
|
||||
### Track was unplayable (found while wiring up Enhancements)
|
||||
|
||||
Moving track out of the deck into a per-player supply (§12.2) left **no way to play it**. Districts
|
||||
@@ -465,13 +544,19 @@ Hotel) are what grow them.
|
||||
|
||||
| # | Name | Speed | Consist | Rule |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1/2 | Crack Limited | Fast | 3 coaches | Stop at Terminals only. No switching. Expedite. |
|
||||
| 1/2 | Crack Limited | Fast | 3 coaches † | Stop at Terminals only. No switching. Expedite. |
|
||||
| 3/4 | Express | Fast | 2 freight | May drop or pick up one freight car at every location. Expedite. |
|
||||
| 5/6 | The Sparrow | Fast | 2 coaches | No switching. Expedite. |
|
||||
| 5/6 | The Sparrow | Fast | 2 coaches † | No switching. Expedite. |
|
||||
| 7/8 | Local | Slow | 1 freight + 1 coach | Coach must remain on station track if switching. |
|
||||
| 9/10 | Heavy Freight | Slow | 3 freight + caboose | |
|
||||
| 11/12 | Drag Freight | Slow | 2 freight + caboose | |
|
||||
|
||||
† **The two coach counts have since been swapped by Jesse** (Gitea#7, 2026-08-22): the Crack Limited
|
||||
now carries **2** coaches and The Sparrow **3**. The table above is left as `Trains3.pdf` prints it,
|
||||
because that is what this section is for — what the design SAYS. What the game plays is
|
||||
`src/engine/content.ts`, with the per-card table in
|
||||
[`../home-deck.md`](../home-deck.md).
|
||||
|
||||
### Extras (X13–X22) — ten distinct trains, not four generic ones
|
||||
|
||||
Appleseed Extra (MT freight only, may drop but not pick up), Fruit Growers Express (reefers only),
|
||||
@@ -513,14 +598,18 @@ We modelled a Mainline card as **2 regions, uniform**. The design has **ten dist
|
||||
| Plains | 60 | |
|
||||
| Curves | 30 | |
|
||||
| Hilly | **P60 / F30** | different speeds for passenger and freight |
|
||||
| Heavy Grade (Up) | **G** | player sets orientation; Brakeman/Airbrakes/Helpers unlock better start positions |
|
||||
| Heavy Grade (Up) | **G** | player sets orientation †; Brakeman/Airbrakes/Helpers unlock better start positions |
|
||||
| Double Track | 60 | **trains may pass** |
|
||||
| Uncontrolled Siding | 60 | trains may pass; separate "no pass" and "passing" starts |
|
||||
| Tunnel | 30 | |
|
||||
| Trestle | 60 | |
|
||||
| Yard | 60 | **sort cars into any new order**; has a Yard Limit |
|
||||
| Interchange | 60 | **sort cars into any new order**; has a Yard Limit. Printed "Yard" on the prototype card and renamed after play — it shared a word with the Division Yard, the Classification Yard, the Salvage Yard, the Yard Office and the Small Yard, and is none of them |
|
||||
| East / West Division Point | — | the ends |
|
||||
|
||||
† **"Player sets orientation" is deliberately NOT implemented** — the table records what the card
|
||||
prints, which is what this section is for. The game rolls the uphill direction from the seed instead;
|
||||
settled v0.5.0, re-confirmed 2026-08-23, reasoning in §10 Q11 above.
|
||||
|
||||
Each card shows **Start positions** — where a train enters depending on direction, train type, and
|
||||
which modifier cards are in play. The Heavy Grade card has five distinct starts (plain, brakemen,
|
||||
airbrakes, plain, helpers), so playing Brakeman literally moves your entry point further along.
|
||||
@@ -762,3 +851,153 @@ Crossing is counted in Stages (Q1), so both trains simply run their counters dow
|
||||
Until this is settled the clearance buttons describe only what the engine actually does — they say
|
||||
the train "closes up behind" rather than promising a −5 risk that cannot occur. Wording that invents
|
||||
a consequence is worse than wording that under-sells one.
|
||||
|
||||
---
|
||||
|
||||
## §7 — where an Extra starts, and which way it runs
|
||||
|
||||
**Reported from playing v0.4.9e** (Gitea#4): "When extras are played the player doing so may choose
|
||||
where the extra starts. They may choose either division point. And if the interchange mainline card
|
||||
has been played, they may start the extra on that card and choose the direction from there. If there
|
||||
is potential for conflict with other trains in that area the superintendent may hold the extra."
|
||||
|
||||
**This supersedes an earlier ruling**, and the supersession is the interesting part. §2.3 gives every
|
||||
train its direction from its number — odd runs west, even runs east — and an earlier pass extended
|
||||
that to Extras explicitly: *"the number decides, like everything else on the timetable."* That reading
|
||||
cannot survive "either Division Point". An odd Extra placed at the WEST end would run west, leave the
|
||||
Division on its first move having crossed nothing, and be paid the completion Revenue for the run.
|
||||
|
||||
So for **Extras only**, the start decides the direction:
|
||||
|
||||
| Start | Direction |
|
||||
| --- | --- |
|
||||
| Western Division Point | east |
|
||||
| Eastern Division Point | west |
|
||||
| Interchange | player's choice |
|
||||
| Control Point (any Office above a Whistle Post) | player's choice |
|
||||
|
||||
A timetabled train is unchanged: its number still decides. The Extra cards were always printed
|
||||
`direction: 'playerChoice'` (§5) and the engine had been overriding it; they now mean it.
|
||||
|
||||
### The Interchange start is a YARD, not a spot on the running line
|
||||
|
||||
§7's last clause — "the superintendent may hold the extra" — is what settles how this is modelled.
|
||||
An Extra started at an Interchange stands in that card's **yard**, off the running line, and highballs
|
||||
onto the card itself at a later Mainline Phase. Three things follow, all of them Jesse's rule rather
|
||||
than an implementation convenience:
|
||||
|
||||
1. **Placing it can never force a collision**, however busy the card is. It is not on the road yet.
|
||||
2. **A guaranteed collision holds it in the yard** for another Stage, and it tries again next Stage.
|
||||
3. **A potential collision is the Superintendent's to rule on.**
|
||||
|
||||
Those last two are exactly §8.1's two answers — an absolute bar against a facing train, a judgment
|
||||
call against a following one — so the Extra leaves the yard through the same clearance check a train
|
||||
leaves a Division Point through. Nothing new decides collisions.
|
||||
|
||||
The Interchange keeps its printed "sort cars in new order" concept, still unimplemented (§6). Being
|
||||
the card with a Yard Limit is what makes it the one Mainline card a train can be made up on.
|
||||
|
||||
### Which starts are offered is a setting
|
||||
|
||||
The Division Points and the Interchange sit on shared ground and belong to nobody; starting an Extra
|
||||
inside a player's own district does not. That is a table preference rather than a rule, so it is set
|
||||
when the game is dealt (`extraStart`): Division Points and Interchange only, plus the playing
|
||||
player's own Control Point, or plus any player's Control Point. A **Whistle Post never qualifies at
|
||||
any setting** — being a place an Extra can start is part of what upgrading buys (§11).
|
||||
|
||||
### Two bugs found underneath it
|
||||
|
||||
- **The Mainline cards were rolled, not dealt.** `buildDivision` drew uniformly from the nine card
|
||||
TYPES **with replacement**, so a Division could be dealt two Interchanges or two Tunnels, and
|
||||
Plains — printed twice in the deck — carried the same weight as cards printed once.
|
||||
`docs/mainline-deck.md` had already flagged the mismatch as needing
|
||||
correction; "an Extra may start at the Interchange if one is on the board" is what forced it, since
|
||||
that only reads as a rule if the board holds at most one. Now dealt from the printed ten-card deck
|
||||
without replacement.
|
||||
- **An Extra started anywhere but a Division Point ran empty.** `isBeingMadeUp` asked only "is this
|
||||
tray standing at a Division Point", which was the whole truth while that was the only place to
|
||||
build a train — so the Control Point start had shipped since it was added with a train that could
|
||||
never be given a consist, and the Interchange start would have shipped the same way. Found by
|
||||
playing it, not by the tests, which had only ever asserted where the tray landed.
|
||||
|
||||
---
|
||||
|
||||
## §6.2 — which train cards may be discarded
|
||||
|
||||
**SUPERSEDED ONCE. Read both rulings; the second narrows the first.**
|
||||
|
||||
**Gitea#6, v0.4.9e playtest:** "Players are not allowed to discard Train cards. They may keep the
|
||||
card in their hand for multiple stages and even multiple days, but they may not discard it. If a
|
||||
player has three train cards in their hand, and they draw a fourth, then they must play one of those
|
||||
cards." Extras counted: an Extra is a train.
|
||||
|
||||
**Gitea#9, 2026-08-24 — the ruling in force:** "Timetabled trains are at the choice of the player:
|
||||
they can either play or discard. If someone else wants to pick it up, they are more than able to.
|
||||
The reason: I don't want, if you decide to play a game longer than five days, to decide that maybe
|
||||
there are too many trains, the stations are jammed, and the railroad doesn't need any more. You can
|
||||
toss it. Someone else might disagree and pick it up."
|
||||
|
||||
So the rule is now:
|
||||
|
||||
- a **Timetabled** train may be discarded;
|
||||
- an **Extra** may not. It never joins the timetable, so it can never be what jams it, and the only
|
||||
rule it would dodge by being thrown away is the hand limit;
|
||||
- **on `main` the Timetabled half is a New Game setting** (`discardTimetabled`, on by default),
|
||||
because Jesse's reasoning is explicitly about LONG games and a five-Day game may well want
|
||||
Gitea#6's pressure. The 0.4.9 playtest line has no scaffolding for a setting and takes the plain
|
||||
rule. Both lines behave identically at their defaults.
|
||||
|
||||
**"Someone else might disagree and pick it up" needed no machinery.** A discard already goes face-up
|
||||
onto a Department pile, and a Department pile is exactly what a rival draws from. The second half of
|
||||
the ruling was already built; only the first half was a change.
|
||||
|
||||
§6.2 as transcribed says only "the player must reduce his hand to no more than three cards" with no
|
||||
exception for any card type, so both of these are rulings rather than gaps — the prototype rules do
|
||||
not address it either way.
|
||||
|
||||
### It needs no forcing mechanism, and that is the point
|
||||
|
||||
The interesting property of the rule is that the forced play falls out of two rules that already
|
||||
exist rather than needing a third:
|
||||
|
||||
1. an undiscardable card is not among the ways to shed a card; and
|
||||
2. `draw.end` already refuses while the hand is over the limit (§6.2).
|
||||
|
||||
A player holding four undiscardable trains therefore has exactly one legal way to conclude the turn —
|
||||
play one — without anything in the engine ever computing "you must play a train". The corner cannot
|
||||
lock a player in, because **playing a train card is unconditionally legal**: `card.play`'s train case
|
||||
refuses only a board placement, and a train card played when the timetable is full still leaves the
|
||||
hand (it simply schedules nothing). Confirmed by playing it: such a hand offers zero discards, no
|
||||
`draw.end`, and four plays.
|
||||
|
||||
**Gitea#9 does not retire that corner, it narrows the way in.** With the setting on, the only hand
|
||||
that reaches it is four Extras; with the setting off it is any four trains, exactly as before.
|
||||
|
||||
### One place decides, and the card says which rule refused
|
||||
|
||||
`keepReason` (`src/engine/apply.ts`) returns the sentence a player should read, or `null` if the card
|
||||
may be discarded. `check`, the hand panel and the blocked "End Local Operations" button all ask it,
|
||||
so none of them can drift from the rule. It returns a SENTENCE rather than a boolean because there
|
||||
are now two distinct reasons — "an Extra is never discarded" and "not in this game" — and a panel
|
||||
that hard-codes one of them tells half the players the wrong thing. It reaches the page as the
|
||||
Frame's `handKeepWhy`.
|
||||
|
||||
The bot needed no rule of its own either. `legal.ts` enumerates candidates and filters them through
|
||||
`check`, so the option stops being offered; and the developer bot already reaches for `card.play`
|
||||
before it reaches for a discard. Measured over 400 games: 400/400 finished, revenue unmoved, and
|
||||
**trains scheduled 1.2 → 1.3** — the rule's intended effect, small because a bot rarely held four.
|
||||
|
||||
### Consequences
|
||||
|
||||
- **Two of the three published replays discarded train cards** (2 and 15 of them) and were retired
|
||||
and re-recorded. Jesse's call: "I'm okay with retiring the replays that no longer work under those
|
||||
old rules."
|
||||
- **The opening six-card hand is not exempt.** Under *six random cards* a player opens holding six
|
||||
against a limit of three; if four or more are trains, they all go onto the timetable on turn one.
|
||||
Jesse's call: "If the opening hand has lots of trains, then lots of trains will be placed on the
|
||||
board." Rare — roughly 1.5% of deals — but deliberate.
|
||||
- **The player is told, on the card and on the button.** `handDiscardable` on the Frame marks which
|
||||
cards may be shed, the hand panel says so in the card's own tooltip, and when EVERY card held is a
|
||||
train the blocked end-turn button changes its text to say a train must be played. That is the
|
||||
Gitea#2 lesson applied: a rule the player cannot see is a board with nothing to click and no reason
|
||||
given.
|
||||
|
||||
@@ -28,6 +28,7 @@ left open, with the consequence noted.
|
||||
| 11 | Track card orientation | **SUPERSEDED** — handedness is printed; both hands supplied |
|
||||
| 12 | Balance targets | **INVALIDATED** — measured against a placeholder ruleset |
|
||||
| 13 | Deck scaling by player count | **LARGELY DISSOLVED** — track is per-player by design |
|
||||
| 14 | Two routes to the same square | **DECIDED** |
|
||||
|
||||
---
|
||||
|
||||
@@ -669,10 +670,15 @@ balance work was possible.
|
||||
| --- | --- | --- | ---: | ---: | ---: | ---: |
|
||||
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
|
||||
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
|
||||
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 |
|
||||
| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 |
|
||||
| Grocer's Warehouse | Boxcar | Inbound | 2 | — | 2 | 3 |
|
||||
| Oil Refinery | Tank | Outbound | 3 | 2 | — | 4 |
|
||||
| Power Plant | Hopper | Inbound | 3 | — | 3 | 4 |
|
||||
|
||||
*Amended v0.4.9e.* The warehouse and the refinery were briefly "Both", on the reading that "Freight
|
||||
House" was a collective term for exactly those two and therefore what §9.3's "permit cars to move
|
||||
each direction" described. The Freight House turned out to be a card of its own (v0.5.0), and
|
||||
playtesting confirmed the one-way reading the sheet always printed.
|
||||
|
||||
*Rationale.* Directions follow the commodity and give §9 all three of its stated cases. Bulk
|
||||
industries get more Laborers and a 4-car track so the types feel distinct when choosing what to
|
||||
build — but the counts stay moderate because Laborers are **not** the binding constraint (see 10e),
|
||||
@@ -717,10 +723,12 @@ leave no way at all to speed up a slow facility. Section Gang is the sole worker
|
||||
keeps that power scarce. Capacity modifiers are stronger than they sound, since banking room rather
|
||||
than worker count is what limits output.
|
||||
|
||||
**Sub-decision — what a "Freight House" is.** §9.3 and Appendix A refer to Freight Houses, but §9's
|
||||
facility list never defines one. It is **not a card**: it is the collective term for a freight
|
||||
facility that both loads and unloads — under 10a, the Grocer's Warehouse and the Oil Refinery. This
|
||||
reconciles every reference without adding a card type or disturbing Gap 4a's deck composition.
|
||||
**Sub-decision — what a "Freight House" is. SUPERSEDED (v0.5.0).** §9.3 and Appendix A refer to
|
||||
Freight Houses, but §9's facility list never defines one. This originally concluded it was **not a
|
||||
card** — the collective term for a freight facility that both loads and unloads, under 10a the
|
||||
Grocer's Warehouse and the Oil Refinery. That reading was wrong: it **is** a card, a sixth Freight
|
||||
Facility dealt 6 copies (`content.ts`'s `freightHouse` profile), which the engine has implemented
|
||||
all along. Left here for the reasoning; do not read this entry as current.
|
||||
|
||||
### 10e — Revenue/Day revalidated, and the Gap 6 targets revised
|
||||
|
||||
@@ -1003,3 +1011,28 @@ needs to get started, while giving each player material to build with.
|
||||
|
||||
**Not yet decided**, because it interacts with Gap 12's variance question: more facilities per player
|
||||
would raise the ceiling on the runaway mode as well as the floor.
|
||||
|
||||
## Gap 14 — Two routes to the same square
|
||||
|
||||
**Status:** DECIDED.
|
||||
|
||||
**Reported by Jesse:** "There are times when a train can take two different paths to get to a
|
||||
destination. See game at undo 379. Train 11 can go from Eastern limits to the straight at 1,1 two
|
||||
different ways. It can go through the refinery and pick up the tanker on its nose, or it could go
|
||||
straight and then make the curve and skip the refinery and not pick up the car. The player should
|
||||
have the two different options."
|
||||
|
||||
**The question:** a passing loop — turnout off the main, curve down, industry, curve back up — offers
|
||||
two legal routes between the same two squares, and they couple different cars. Does §A.4's "you may
|
||||
not go around" a car standing on your track forbid taking the bypass?
|
||||
|
||||
**Ruling: the player may choose the path.** Different paths may pick up different cars, or none.
|
||||
§A.4 says "cars **on your track**", and the industry spur is a different track — declining to enter
|
||||
it is not going around the car standing on it, it is not going to that car's track at all, which is
|
||||
ordinary railroading. The mandatory half still bites in full once a route is chosen: every car
|
||||
standing on the chosen route couples, and there is no route that passes a car and leaves it.
|
||||
|
||||
**Built in v0.4.8** — see `docs/plans/switching-paths.md`. The engine enumerates every distinct
|
||||
simple route, dedupes by outcome (destination, entry side, and origin-tagged cars) rather than by
|
||||
destination alone, and an intent that would otherwise be ambiguous carries a `via` naming one
|
||||
intermediate square on the chosen route.
|
||||
|
||||
@@ -452,9 +452,10 @@ Loading and unloading of Rolling Stock (passengers and freight) takes place in t
|
||||
(§11). A Whistle Post is not a Passenger Facility, so a player who has not upgraded has no
|
||||
passenger business at all.
|
||||
- **Freight Facilities:** Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power
|
||||
Plants. Per-card values in §12.5.
|
||||
- **Freight House:** **[Gap 10d]** not a facility type of its own, but the collective term for a
|
||||
freight facility permitting both directions — the Grocer's Warehouse and the Oil Refinery.
|
||||
Plants, Freight Houses. Per-card values in §12.5.
|
||||
- **Freight House:** a Freight Facility card in its own right (corrected v0.5.0 — **[Gap 10d]**
|
||||
previously read this as a collective term for the Grocer's Warehouse and the Oil Refinery rather
|
||||
than a card of its own). Permits both directions, the same as those two.
|
||||
- **Modifier Facilities:** Cards that, placed adjacent (on any of the nine nearby spots), increase a
|
||||
passenger or freight facility's capacities. If two Facilities are adjacent to the Modifier, its
|
||||
effects may only be used on one Facility per **Stage** **[Gap 7]**.
|
||||
@@ -593,10 +594,16 @@ horizontal row through the Office, Limit-to-Limit; everything else is Secondary
|
||||
- Plain track, turnouts and Facility cards are all played from hand into an empty adjacent grid spot,
|
||||
and **must connect to existing track**. Facility cards carry their own rails — placing a Facility
|
||||
places track.
|
||||
- Track stays **inside the Limits at every row**, not only on the Running Track. §2.1 makes the sign
|
||||
"the limit of your control area" and §3 defines Secondary Track as "all tracks in your limits", so
|
||||
a siding may run *under* a sign — the sign stands on the boundary — and never past it. Widening a
|
||||
district therefore means extending the main first.
|
||||
- Extending the **Running Track** horizontally moves that player's Limits sign outward with it (§2.1).
|
||||
Growth is uncapped in the prototype.
|
||||
- Modifier Facility cards are not track. They are placed adjacent to a Facility, on any of the nine
|
||||
nearby spots (§9).
|
||||
nearby spots (§9) — diagonals included, and not bounded by the Limits, since a host at the edge of
|
||||
the district would otherwise have nowhere to put one. The one square they may not take is in the
|
||||
Running Track's own row, which is where the main grows.
|
||||
|
||||
A longer Running Track is more track to keep clear of arriving trains, so territory carries its own
|
||||
collision risk.
|
||||
@@ -729,8 +736,8 @@ Modifier effects, and track geometries — is catalogued in
|
||||
| --- | --- | --- | ---: | ---: | ---: | ---: |
|
||||
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
|
||||
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
|
||||
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 |
|
||||
| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 |
|
||||
| Grocer's Warehouse | Boxcar | Inbound | 2 | — | 2 | 3 |
|
||||
| Oil Refinery | Tank | Outbound | 3 | 2 | — | 4 |
|
||||
| Power Plant | Hopper | Inbound | 3 | — | 3 | 4 |
|
||||
|
||||
| Office | Porters | Green slots | Red slots |
|
||||
@@ -747,8 +754,11 @@ Modifier effects, and track geometries — is catalogued in
|
||||
| Extra Platform | +1 green and +1 red slot (passenger) |
|
||||
| Section Gang | +1 Laborer or +1 Porter |
|
||||
|
||||
**"Freight House"** (§9.3, Appendix A) is not a card — it is the collective term for a freight
|
||||
facility that both loads and unloads, namely the Grocer's Warehouse and the Oil Refinery.
|
||||
**"Freight House"** (§9.3, Appendix A) is a Freight Facility card (corrected v0.5.0 — previously
|
||||
read as not a card, only a collective term for a facility that both loads and unloads). It is the
|
||||
**only** industry that permits both directions: the Grocer's Warehouse receives and the Oil Refinery
|
||||
ships, one way each (corrected v0.4.9e from playtesting — the "Both" reading was the other half of
|
||||
the same mistake about what "Freight House" meant).
|
||||
|
||||
---
|
||||
|
||||
@@ -795,7 +805,10 @@ same order they originally held, left-to-right.
|
||||
- The exception is your Office track, which has the number of A/D tracks listed (§11.1). As long as
|
||||
fewer trains are holding there than A/D tracks, you may enter and pass through.
|
||||
- If you move your Crew Tray into cars on your track, you **MUST** pick them up. You may not go around
|
||||
them.
|
||||
them. Where a passing loop offers two routes to the same square, choosing not to enter a siding is
|
||||
not "going around" a car standing on it — that car is on a different track, and declining to run
|
||||
onto it is ordinary railroading. Once a route is chosen, every car standing on it still must be
|
||||
coupled in full.
|
||||
- A Crew Tray must always have an engine, and may move a maximum of four Rolling Stock — cabooses
|
||||
included **[Gap 4c]**.
|
||||
- You automatically couple to cars you meet during your Moves. The same is **not** true of trains
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Station Master 0.5.0 — Test Plan
|
||||
|
||||
Written 2026-08-21, against an uncommitted working tree. Covers only the changes made in this
|
||||
session's work — victory-condition unification, the New Train phase fix, and the multiplayer server
|
||||
(Phases 2-3). Nothing here is committed yet; see the Coordination Note below before running any of it
|
||||
against a tree that has since moved.
|
||||
|
||||
## Coordination note — shared working tree
|
||||
|
||||
Another thread is bug-fixing the 0.4.9 series **in this same uncommitted working tree**, concurrently.
|
||||
`git status` shows changes I did not make in: `apply.ts`, `events.ts`, `track.ts`, `board-svg.ts`,
|
||||
`docs/rules/*.md`, `package-lock.json`, `scripts/build-web.ts`, `.gitignore`, plus new
|
||||
`docs/station-master-seed*.json` files still appearing as of this writing. **This plan does not cover
|
||||
any of that** — it is scoped to the files below, which are the ones I actually touched.
|
||||
|
||||
Practical implications:
|
||||
- Before running this plan, confirm the files under "Files this plan covers" still read the way this
|
||||
document describes them — the other thread's commits could in principle touch the same functions
|
||||
(`apply.ts`'s `check()` is used directly by `src/server/session.ts`, for instance).
|
||||
- Run the full regression gate (`npm run typecheck && npm test`) fresh, not from a memory of an earlier
|
||||
green run — both threads are editing live.
|
||||
- The two bodies of work are unrelated in intent (0.4.9 patch fixes vs. 0.5.0 multiplayer groundwork)
|
||||
but share a tree, so a clean split into separate commits before either is finalized is worth doing
|
||||
deliberately rather than by accident.
|
||||
|
||||
### Files this plan covers
|
||||
|
||||
```
|
||||
Engine: src/engine/advance.ts, src/engine/content.ts, src/engine/setup.ts, src/engine/state.ts
|
||||
Web: src/web/game.ts, src/web/main.ts, src/web/play.html, src/web/session.ts
|
||||
Sim: src/sim/view.ts, src/sim/frame-delta.ts (new), src/sim/compare.ts, src/sim/harness.ts,
|
||||
src/sim/replay.ts
|
||||
Server: src/server/ (new — index.ts, http.ts, session.ts, persistence.ts)
|
||||
Tests: test/advance.test.ts, test/multiplayer.test.ts, test/web.test.ts, test/setup.test.ts,
|
||||
test/redaction.test.ts (new), test/frame-delta.test.ts (new), test/server/ (new)
|
||||
Docs: TODO.md, package.json (test script only)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 0. Automated regression gate — run first, every time
|
||||
|
||||
```
|
||||
npm run typecheck
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected: typecheck clean, **all tests passing** (632 at the time this plan was written — the exact
|
||||
number will drift as the other thread's work lands; what matters is zero failures). If either command
|
||||
is red, stop and diagnose before doing anything below — a red gate means the manual steps are testing
|
||||
against a broken build.
|
||||
|
||||
**If you only have time for one thing before the break ends, run this.** Everything else in this
|
||||
document is either already covered by it, or is the manual/live verification that can't be — flagged
|
||||
per-section below.
|
||||
|
||||
---
|
||||
|
||||
## 1. Regression baseline — solitaire must play exactly as before
|
||||
|
||||
The engine and dialog changes touch shared code paths solitaire also uses. None of this should have
|
||||
changed solitaire's actual behavior.
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|---|---|---|
|
||||
| 1.1 | Fresh deal | `npm run serve:web`, open `/play.html`, no URL params | A new random-seed solitaire game deals normally, board and hand render |
|
||||
| 1.2 | Full game to completion | Play (or let a bot/harness play) a solitaire game to Day 5 | Game ends with a win/loss exactly as it would have before — `minCombinedRevenue` defaults to `collectiveRevenueFloor(1,5)=15`, not the old fixed `20`; a game scoring 15-19 that used to lose now wins (**intentional** — see §3, not a bug) |
|
||||
| 1.3 | Save / restore | Play a few turns, reload the page (auto-restores from `localStorage`) | Game resumes exactly where it left off, house rules and victory dials intact |
|
||||
| 1.4 | Save file download | Click "Save replay" mid-game | Downloads a small JSON; re-opening it in the replay viewer plays back identically |
|
||||
| 1.5 | Undo | Play a few turns, click Undo repeatedly to the start | Each step back is clean; log never shows a move that "un-happened" |
|
||||
| 1.6 | Existing published replays | Open each file under `public/replays/` in the replay viewer | All three still play back — `LEGACY_HOUSE_RULES` fallback for saves with no `houseRules` field is unaffected by this session's changes |
|
||||
|
||||
Automated coverage: `test/advance.test.ts`, `test/web.test.ts`, `test/session.test.ts` already assert
|
||||
most of this at the unit level. §1 is about confirming nothing *visibly* changed for a solitaire
|
||||
player, which only a live playthrough shows.
|
||||
|
||||
---
|
||||
|
||||
## 2. New Train phase car-placement rotation (engine fix)
|
||||
|
||||
**What changed:** the Superintendent used to place every car of every train alone, even in
|
||||
competitive mode, against §7's written rule. Now the round rotates Superintendent-then-left, one car
|
||||
per player, per `tray.consist.length`.
|
||||
|
||||
Automated: `test/multiplayer.test.ts`, "the New Train phase car-placement round rotates (§7, Gap 9)" —
|
||||
asserts the exact actor sequence for 2p (wraps: 0,1,0) and 3p (no wrap: 0,1,2) against a real train
|
||||
profile.
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|---|---|---|
|
||||
| 2.1 | Manual multi-seat check | Drive a 3-4 player competitive game via the bot harness (`node src/sim/harness.ts`) or by hand through `src/server/`, and watch a Timetabled train get made up | Car-placement decisions visibly move between seats rather than one player filling the whole consist |
|
||||
| 2.2 | Solitaire unaffected | Play any solitaire game where a train is made up | No visible change — one player, nothing to rotate, `tray.consist.length % 1 === 0` always |
|
||||
|
||||
Not covered, flagged in `TODO.md` (do not re-test, it's expected to be unreachable): `newTrain.passCar`
|
||||
still has a dormant `check()`/`reduce()` gap, harmless because `trainNeedingCars` never offers a tray
|
||||
with nothing suitable in the yard.
|
||||
|
||||
---
|
||||
|
||||
## 3. Victory conditions unified (`GameConfig` dials)
|
||||
|
||||
**What changed:** `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`,
|
||||
`pvpCardsAllowed` replace the old `length`-preset/`target`/flat collision constant, across all three
|
||||
modes. Automated coverage: `test/advance.test.ts`'s "victory conditions (§3, Gap 10e) — unified
|
||||
2026-08-20" describe block (8 tests) covers every case below at the engine level already. This section
|
||||
is for confirming the *dialog* actually produces the numbers the engine then honors.
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|---|---|---|
|
||||
| 3.1 | `minCombinedRevenue` — loss | New Game, set it to a number higher than achievable, play to Day N | Loss, reason `revenueFloor`, regardless of individual score |
|
||||
| 3.2 | `minCombinedRevenue` — win | Set it to 0, play to Day N with any score | Win — `0` genuinely disables the floor |
|
||||
| 3.3 | `maxCollisionsPerDay` — **new solitaire behavior** | Set to 1, deliberately cause 1 collision (e.g. leave every A/D track occupied on an arrival) | Game ends **immediately**, mid-Stage, loss, reason `collisionFloor` — solitaire could never lose this way before this change |
|
||||
| 3.4 | `maxCollisionsTotal` | Set `maxCollisionsPerDay=0` (off), `maxCollisionsTotal=2`, cause 2 collisions across different Days | Game ends on the 2nd collision, whichever Day it falls on |
|
||||
| 3.5 | Both collision caps at 0 | Set both to 0, cause several collisions | Game does **not** end early — same as pre-change solitaire (collisions still cost Revenue, just don't end the game) |
|
||||
| 3.6 | Mode radio defaults | Open New Game, click Competitive | `minCombinedRevenue` suggests `3 × 4 × days` (nominal 4-player assumption — no lobby yet to ask real seat count), `pvpCardsAllowed` checks and enables, Deal disables with a "needs a server" note |
|
||||
| 3.7 | Mode radio — Co-op | Click Co-op | `pvpCardsAllowed` unchecks and **greys out** (forced off, not just defaulted off — no valid target when everyone's on one side), Deal disables |
|
||||
| 3.8 | Mode radio — back to Solitaire | Click Solitaire again | Deal re-enables, `pvpCardsAllowed` unchecks and greys, all four dials show the numbers the *currently playing* game actually has (not the preset) |
|
||||
| 3.9 | URL round-trip | Deal a game with non-default `days`/`minrev`/`colday`/`coltotal`, copy the URL, open it in a new tab | Identical settings — all four now travel via `?days=&minrev=&colday=&coltotal=` alongside the existing `?hand=&passenger=&freight=&transit=` |
|
||||
|
||||
**Not yet clicked through in a real browser** (no browser binary in the sandbox this was built in —
|
||||
verified only via a simulated-DOM test harness, `test/web.test.ts`'s "the New Game dialog" describe
|
||||
block, which drives the actual compiled bundle). §3.6-3.9 specifically should get a real click-through
|
||||
before calling this done.
|
||||
|
||||
---
|
||||
|
||||
## 4. Multiplayer server — Phase 2 (session core)
|
||||
|
||||
New in this release: `src/server/` (session host, HTTP/SSE, static serving), `src/web/session.ts`'s
|
||||
`createRemoteSession`, `src/sim/frame-delta.ts`. Automated: `test/server/session.test.ts` (12 cases,
|
||||
pure logic, no sockets), `test/redaction.test.ts` (4 cases — the exhaustive "no seat sees another
|
||||
seat's secrets" check), `test/frame-delta.test.ts` (5 cases).
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|---|---|---|
|
||||
| 4.1 | Boot | `JOIN_SECRET=x PORT=8081 node src/server/index.ts` | Starts, logs the bind address and `dist/` path |
|
||||
| 4.2 | Join secret gate | `curl -X POST localhost:8081/api/game` (no `?secret=`) | `403 bad or missing secret` |
|
||||
| 4.3 | Create game | `POST /api/game?secret=x` with `{config, playerNames}` | `200 {ok:true, playerCount:N}`; a second call while a game exists → `409` |
|
||||
| 4.4 | Per-seat SSE | Open `/api/stream?seat=0` and `?seat=1` for a 2-player game | Only the current actor's push has a non-null `menu`; the other seat's is `null` |
|
||||
| 4.5 | Turn enforcement | `POST /api/intent` as the **non-acting** seat | `{ok:false, code:"NOT_YOUR_TURN"}`, no broadcast, no state change |
|
||||
| 4.6 | Accept + broadcast | `POST /api/intent` as the acting seat, a legal intent | `{ok:true}`; **both** SSE streams receive a push with fresh narration |
|
||||
| 4.7 | Idempotent resend | Submit the same `{seq, intent}` twice | Second call: `{ok:true}`, **no** new SSE push (already applied, silently ignored) |
|
||||
| 4.8 | Illegal intent | Submit something `check()` would reject | `{ok:false, code:"<real RejectionCode>"}`; the rejection text does **not** appear in the *other* seat's narration (private feedback, §session.ts's design note) |
|
||||
| 4.9 | Board delta | Submit an intent that doesn't move anything on the board (e.g. `localOps.choose`) | The next push's `frame.division`/`frame.cells` are `null` (unchanged-since-last-push-to-this-seat) |
|
||||
| 4.10 | Heartbeat | Hold an SSE connection open >20s idle | A `: ping` comment line appears (EventSource ignores it; keeps a proxy from reaping the socket) |
|
||||
| 4.11 | **Real browser — not yet done** | Two actual browser tabs, `?seat=0` and `?seat=1`, playing a shared game by clicking | Confirm the whole loop works through the actual UI, not just curl: turn indicator, disabled controls for the non-acting seat, board updates on both sides, a rejected action shows *something* sensible on screen rather than silently doing nothing |
|
||||
|
||||
§4.1-4.10 were run live against a real server process during this session (not just unit-tested) — see
|
||||
the session transcript for the actual curl commands and captured output. §4.11 genuinely was not done;
|
||||
no browser was available in the environment this was built in.
|
||||
|
||||
---
|
||||
|
||||
## 5. Multiplayer server — Phase 3 (persistence and resumption)
|
||||
|
||||
New: `src/server/persistence.ts`, `game.ts`'s `fromMultiplayerSave`, `session.ts`'s `exportSave`/
|
||||
`resumeSession`/turn-timing tracking. Automated: `test/server/persistence.test.ts` (6 cases),
|
||||
`test/server/session.test.ts`'s "turn timings" and "persistence hooks" describe blocks.
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|---|---|---|
|
||||
| 5.1 | Immediate persistence | `POST /api/game`, then check `DATA_DIR/game.json` | Exists immediately, empty `history`, correct `engineVersion` (= `package.json`'s `version`) |
|
||||
| 5.2 | History grows | Submit a few intents | `game.json`'s `history` array grows by one entry per accepted intent, in order |
|
||||
| 5.3 | Turn timings recorded | Submit enough intents to cross a full turn (player/phase/day/stage change) | `DATA_DIR/turn-timings.json` gains an entry: `{player, phase, day, stage, startedAt, endedAt}`, `endedAt >= startedAt` |
|
||||
| 5.4 | **Restart and resume** (the actual "done when" for Phase 3) | Kill the server process mid-game, restart it against the same `DATA_DIR` | Log line: `"Resumed a saved game... N intents replayed"`; reconnecting both `?seat=` streams shows the exact same Day/Stage/phase/whose-turn as before the kill, correct narration attribution ("Player X chose to...", not anonymous) |
|
||||
| 5.5 | Version-mismatch refusal | Hand-edit `game.json`'s `engineVersion` to a bogus value, restart | Log line refusing to load, naming both versions; server starts with **no** active game (confirm via `POST /api/game` succeeding, not `409`ing); the file is **not** deleted or modified |
|
||||
| 5.6 | Atomic writes, no stray temp files | After several intents, check `DATA_DIR` | Only `game.json`/`turn-timings.json` present — no leftover `.tmp` files from an interrupted write |
|
||||
|
||||
§5.1-5.6 were all run live during this session (kill-and-restart included) — see the transcript. This
|
||||
is the most thoroughly live-verified section of the whole plan.
|
||||
|
||||
---
|
||||
|
||||
## 6. Things this session found but did *not* fix — verify they're still correctly deferred
|
||||
|
||||
These are logged in `TODO.md`, not bugs to chase here — listed so a tester doesn't rediscover them and
|
||||
assume something regressed.
|
||||
|
||||
| Item | Where | Current state to confirm |
|
||||
|---|---|---|
|
||||
| `fromSave`'s replay loses "Player X" narration attribution | `src/web/game.ts` | Still present in `fromSave` (untouched, out of scope); **fixed** in the new `fromMultiplayerSave` — §5.4's narration check is what confirms the fix landed where it needed to |
|
||||
| `newTrain.passCar`'s `check()`/`reduce()` gap | `src/engine/apply.ts` | Still dormant/unreachable — confirm no new caller has started exercising it (would only matter if the *other* thread's 0.4.9 work touches this area) |
|
||||
| No real browser click-through | This session | §3.6-3.9, §4.11 — the two gaps a human still needs to close |
|
||||
|
||||
---
|
||||
|
||||
## Summary checklist
|
||||
|
||||
- [ ] §0 automated gate green
|
||||
- [ ] §1 solitaire regression (six checks)
|
||||
- [ ] §2 New Train rotation, manual multi-seat check
|
||||
- [ ] §3 victory conditions, especially §3.6-3.9 (dialog, not yet browser-verified)
|
||||
- [ ] §4 server core, especially §4.11 (real browser, not yet done)
|
||||
- [ ] §5 persistence — already live-verified this session; worth a second independent run
|
||||
- [ ] §6 confirm the three known-and-deferred items are still exactly as described
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.0.1",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "station-master",
|
||||
"version": "0.0.1",
|
||||
"version": "0.7.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.2",
|
||||
"typescript": "^7.0.2"
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.3.1",
|
||||
"version": "0.8.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
@@ -9,10 +9,14 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test test/**/*.test.ts",
|
||||
"pretest": "tsc --noEmit && node scripts/build-web.ts",
|
||||
"test": "node --test $(ls test/*.test.ts test/**/*.test.ts | grep -v '^test/sim.test.ts$')",
|
||||
"pretest:sim": "tsc --noEmit",
|
||||
"test:sim": "node --test test/sim.test.ts",
|
||||
"build:web": "node scripts/build-web.ts",
|
||||
"serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
|
||||
"deploy:web": "node scripts/deploy-web.ts"
|
||||
"deploy:web": "node scripts/deploy-web.ts",
|
||||
"build:cards": "node scripts/build-card-reference.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.2",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Playtest saves
|
||||
|
||||
Save files that came in with a bug report, or that were kept from a session worth re-reading.
|
||||
|
||||
**Nothing in here is committed** — `.gitignore` keeps the directory empty as far as git is
|
||||
concerned, except for this file. They are somebody's game, not part of the project, and they go
|
||||
stale the moment the rules move.
|
||||
|
||||
## What a save is
|
||||
|
||||
A seed and the list of moves made — a few hundred bytes of JSON. That is enough to rebuild the
|
||||
whole game, which is why a replay can be emailed like a text file. Written by **Save replay** on the
|
||||
play screen; a multiplayer game's copy lives on the server, under its data directory.
|
||||
|
||||
## How to open one
|
||||
|
||||
Open `replays.html` on the site (or a local `npm run serve:web`) and use **Open a save file** at the
|
||||
bottom of the page — it takes a file straight off disk, no upload anywhere. Step through it frame by
|
||||
frame to find the position being reported.
|
||||
|
||||
A save replays only under the rules it was dealt with; every save records them, and one written
|
||||
before a rules change may stop part-way. That is expected, and it is why these are kept beside a
|
||||
report rather than in the repository.
|
||||
|
||||
## Publishing one
|
||||
|
||||
A replay worth keeping for everyone goes in `public/replays/` instead, where the build publishes it
|
||||
into the site's replay directory with an index entry.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Generate the card tables inside `docs/home-deck.md` and `docs/mainline-deck.md`.
|
||||
*
|
||||
* WHY GENERATED RATHER THAN WRITTEN. Nothing fails when a hand-written table falls behind a
|
||||
* constant, so the tables are emitted from the same exported catalogues the engine instantiates
|
||||
* from, and `test/card-reference.test.ts` re-runs this generator and asserts the checked-in docs
|
||||
* match. Change a card face and the suite goes red until the docs are regenerated.
|
||||
*
|
||||
* EACH TABLE LANDS UNDER THE SECTION IT BELONGS TO, between a marker pair the deck documents carry:
|
||||
*
|
||||
* <!-- BEGIN CARDS: track --> …generated… <!-- END CARDS: track -->
|
||||
*
|
||||
* Everything around the markers is hand-written and is never touched. Only the Mainline card table
|
||||
* goes to `mainline-deck.md`; every other table belongs to the Home Office deck.
|
||||
*
|
||||
* `npm run build:cards` writes them. Nothing at runtime reads them; they are for people.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
ACTION_CARDS, ALL_TRAINS, ENHANCEMENT_CARDS, ENHANCEMENT_RULES, EXTRA_TRAINS, INDUSTRY_PROFILES,
|
||||
LIMITS_SUPPLY, MAINLINE_DECK, MAINLINE_MODIFIER_CARDS, MAINLINE_PROFILES, MANEUVER_CARDS,
|
||||
MODIFIER_PROFILES, OFFICE_PROFILES, SPACE_USE_CARDS, TIMETABLED_TRAINS, TRACK_CARDS,
|
||||
WHISTLE_POST_SUPPLY, consistSize, isOpponentOnly, mainlineDescription,
|
||||
} from '../src/engine/content.ts';
|
||||
import type { ConsistSpec, SimpleCard, TrainProfile, TrainRules } from '../src/engine/content.ts';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
/** Title Case a camelCase key, so `oneFreightPerLocation` reads as a rule rather than an identifier. */
|
||||
const words = (k: string): string => k.replace(/([A-Z])/g, ' $1').toLowerCase().trim();
|
||||
|
||||
const consistOf = (c: ConsistSpec): string => {
|
||||
const parts: string[] = [];
|
||||
if (c.freight > 0) {
|
||||
const kinds = c.freightTypes ? c.freightTypes.join(' or ') : 'freight';
|
||||
parts.push(`${c.freight} ${kinds}${c.freight === 1 ? '' : c.freightTypes ? 's' : ''}`);
|
||||
}
|
||||
if (c.coach > 0) parts.push(`${c.coach} coach${c.coach === 1 ? '' : 'es'}`);
|
||||
if (c.caboose > 0) parts.push(`${c.caboose} caboose`);
|
||||
if (!parts.length) return 'engine only';
|
||||
const n = consistSize(c);
|
||||
// "Empties only" qualifies the whole consist rather than adding to it, so it reads after the count.
|
||||
return `${parts.join(' + ')} (${n} piece${n === 1 ? '' : 's'})${c.emptiesOnly ? ', empties only' : ''}`;
|
||||
};
|
||||
|
||||
const rulesOf = (r: TrainRules): string => {
|
||||
const out: string[] = [];
|
||||
for (const [k, v] of Object.entries(r)) {
|
||||
if (k === 'note' || v === false || v === undefined) continue;
|
||||
out.push(words(k));
|
||||
}
|
||||
if (typeof r.note === 'string') out.push(`*"${r.note}"*`);
|
||||
return out.length ? out.join('; ') : '—';
|
||||
};
|
||||
|
||||
const trainRow = (t: TrainProfile): string =>
|
||||
`| ${t.isExtra ? 'X' : ''}${t.number} | ${t.name} | ${t.speed} | ` +
|
||||
`${t.direction === 'playerChoice' ? "player's choice" : t.direction} | ${consistOf(t.consist)} | ${rulesOf(t.rules)} |`;
|
||||
|
||||
/** Generated blocks, keyed by the marker name the deck documents wrap them in. */
|
||||
const blocks = new Map<string, string[]>();
|
||||
let current: string[] = [];
|
||||
/** Start a new generated block. Everything `w` writes lands here until the next `section`. */
|
||||
const section = (key: string): void => {
|
||||
current = [];
|
||||
blocks.set(key, current);
|
||||
};
|
||||
const w = (s = ''): void => void current.push(s);
|
||||
|
||||
section('trains');
|
||||
w('### Timetabled');
|
||||
w();
|
||||
w('| # | Class | Speed | Runs | Consist | Printed rules |');
|
||||
w('| ---: | --- | --- | --- | --- | --- |');
|
||||
for (const t of TIMETABLED_TRAINS) w(trainRow(t));
|
||||
w();
|
||||
w('### Extras');
|
||||
w();
|
||||
w('| # | Class | Speed | Runs | Consist | Printed rules |');
|
||||
w('| ---: | --- | --- | --- | --- | --- |');
|
||||
for (const t of EXTRA_TRAINS) w(trainRow(t));
|
||||
w();
|
||||
section('mainline');
|
||||
w('| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Extra may start |');
|
||||
w('| --- | ---: | ---: | --- | :---: | :---: |');
|
||||
for (const m of MAINLINE_PROFILES) {
|
||||
const ss = m.speedStarts ? `${m.speedStarts.fast} / ${m.speedStarts.slow}` : '—';
|
||||
w(`| ${m.name} | ${m.regions} | ${m.defaultStart} | ${ss} | ${m.trainsMayPass ? 'yes' : '—'} | ${m.sortsCars ? 'yes' : '—'} |`);
|
||||
}
|
||||
w();
|
||||
w(`The Mainline deck is ${MAINLINE_DECK.length} cards; the two Division Points are the fixed ends of`);
|
||||
w('the Division and are not dealt. What each card does, in the words the game uses on screen:');
|
||||
w();
|
||||
for (const m of MAINLINE_PROFILES) w(`- **${m.name}** — ${mainlineDescription(m.kind)}`);
|
||||
w();
|
||||
section('office');
|
||||
w('Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in');
|
||||
w('order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal**');
|
||||
w('to Porters rather than one more.');
|
||||
w();
|
||||
w('| Office | Control point | Passenger facility | A/D tracks | Porters | Green | Red |');
|
||||
w('| --- | :---: | :---: | ---: | ---: | ---: | ---: |');
|
||||
for (const o of OFFICE_PROFILES) {
|
||||
w(`| ${o.name} | ${o.isControlPoint ? 'yes' : '—'} | ${o.isPassengerFacility ? 'yes' : '—'} | ` +
|
||||
`${o.adTracks} | ${o.porters} | ${o.passengerOut} | ${o.passengerIn} |`);
|
||||
}
|
||||
w();
|
||||
w(`Whistle Posts are a fixed supply of ${WHISTLE_POST_SUPPLY} outside the deck, and Limits signs a`);
|
||||
w(`supply of ${LIMITS_SUPPLY}.`);
|
||||
w();
|
||||
section('facilities');
|
||||
w('Each lists the car types it works, which way its traffic flows, and the industries it may not sit');
|
||||
w('beside. **Every lockout pair is a producer and the consumer of the same commodity** — you may');
|
||||
w('build one end of a chain or the other, never both, which is what forces traffic to run between');
|
||||
w('districts rather than in circles inside one. No two of the same industry may share an Office Area,');
|
||||
w('and that rule is enforced for every kind rather than repeated in each row.');
|
||||
w();
|
||||
w('| Industry | Cars | Flow | Green | Red | Laborers | Locked out with |');
|
||||
w('| --- | --- | --- | ---: | ---: | ---: | --- |');
|
||||
for (const f of INDUSTRY_PROFILES) {
|
||||
const lo = f.lockouts.length
|
||||
? f.lockouts.map((k) => INDUSTRY_PROFILES.find((p) => p.kind === k)?.name ?? k).join(', ')
|
||||
: '—';
|
||||
w(`| ${f.name} | ${f.carTypes.join(', ')} | ${f.flow} | ${f.baseOut} | ${f.baseIn} | ${f.baseLoaders} | ${lo} |`);
|
||||
}
|
||||
w();
|
||||
section('modifiers');
|
||||
w('Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger');
|
||||
w('Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which');
|
||||
w('is not one.');
|
||||
w();
|
||||
w('| Modifier | Hosts | +Green | +Red | +Laborers | +Porters |');
|
||||
w('| --- | --- | ---: | ---: | ---: | ---: |');
|
||||
for (const m of MODIFIER_PROFILES) {
|
||||
const hosts = m.hosts
|
||||
.map((h) => (h === 'office' ? 'any Passenger Facility' : INDUSTRY_PROFILES.find((p) => p.kind === h)?.name ?? h))
|
||||
.join(', ');
|
||||
w(`| ${m.name} | ${hosts} | ${m.addOut || '—'} | ${m.addIn || '—'} | ${m.addLoaders || '—'} | ${m.addPorters || '—'} |`);
|
||||
}
|
||||
w();
|
||||
section('track');
|
||||
w('| Track | Geometry | Hand | Operational rail | Move cost | Dealt |');
|
||||
w('| --- | --- | --- | :---: | ---: | :---: |');
|
||||
for (const t of TRACK_CARDS) {
|
||||
w(`| ${t.name} | ${t.geometry} | ${t.hand} | ${t.isOperationalRail ? 'yes' : '—'} | ${t.moveCost} | ${t.copiesInDeck ? 'yes' : 'no'} |`);
|
||||
}
|
||||
w();
|
||||
w('A row marked "no" is a shape the engine understands but the deck does not currently print.');
|
||||
w();
|
||||
section('enhancements');
|
||||
w('The column that only the implementation can fill in: **whether the printed effect actually');
|
||||
w('resolves yet.** `live` is read during play; `dormantSolo` is implemented at the point of attack');
|
||||
w('but the attack is an opponent-directed card held out of every deck, so nothing reaches it in a');
|
||||
w('solitaire game; `unbuilt` means the effect is recorded and nothing reads it. A transcription');
|
||||
w('cannot carry this column, which is the argument for generating the page rather than writing it.');
|
||||
w();
|
||||
w('| Enhancement | Placement | Requires | Effect resolves |');
|
||||
w('| --- | --- | --- | :---: |');
|
||||
for (const r of ENHANCEMENT_RULES) {
|
||||
const card = ENHANCEMENT_CARDS.find((c) => c.key === r.key);
|
||||
const needs = r.requiresOnSameCard
|
||||
? `${r.requiresOnSameCard} on the same card`
|
||||
: r.requiresInDistrict
|
||||
? `${r.requiresInDistrict} in the district`
|
||||
: '—';
|
||||
w(`| ${card?.name ?? r.key} | ${r.placement} | ${needs} | **${r.effect}** |`);
|
||||
}
|
||||
w();
|
||||
section('opponent');
|
||||
w('**None of these is dealt in any deck today.** A card that can only be played at another player');
|
||||
w('has no legal target in a solitaire game, and a defence with nothing to defend against is as dead');
|
||||
w('a draw as the attack — so both halves are held out until the attacks are implemented. They are');
|
||||
w('listed because they are the design, and because what a defence answers is the only record of why');
|
||||
w('it exists.');
|
||||
w();
|
||||
const pvp = (title: string, cards: readonly SimpleCard[], category: string): void => {
|
||||
w(`### ${title}${isOpponentOnly(category) ? ' — opponent-directed' : ''}`);
|
||||
w();
|
||||
w('| Card | Played on | Effect | Answers |');
|
||||
w('| --- | --- | --- | --- |');
|
||||
for (const c of cards) w(`| ${c.name} | ${c.placement} | ${c.effect} | ${c.answers ?? '—'} |`);
|
||||
w();
|
||||
};
|
||||
pvp('Action cards', ACTION_CARDS, 'action');
|
||||
pvp('Space-use cards', SPACE_USE_CARDS, 'spaceUse');
|
||||
pvp('Maneuver cards', MANEUVER_CARDS, 'maneuver');
|
||||
w('Mainline modifier cards, for completeness — these ARE dealt:');
|
||||
w();
|
||||
w('| Card | Played on | Effect | Answers |');
|
||||
w('| --- | --- | --- | --- |');
|
||||
for (const c of MAINLINE_MODIFIER_CARDS) w(`| ${c.name} | ${c.placement} | ${c.effect} | ${c.answers ?? '—'} |`);
|
||||
w();
|
||||
|
||||
/**
|
||||
* Splice each block into its document, between the markers that name it.
|
||||
*
|
||||
* Strict on purpose: a block with nowhere to go, or a marker pair with no block, is a mistake that
|
||||
* would otherwise show up as a silently missing table. Both throw.
|
||||
*/
|
||||
const WHERE: Record<string, string> = {
|
||||
mainline: 'docs/mainline-deck.md',
|
||||
};
|
||||
const DEFAULT_DOC = 'docs/home-deck.md';
|
||||
|
||||
const edited = new Map<string, string>();
|
||||
for (const [key, body] of blocks) {
|
||||
const rel = WHERE[key] ?? DEFAULT_DOC;
|
||||
const text = edited.get(rel) ?? readFileSync(join(root, rel), 'utf8');
|
||||
const begin = `<!-- BEGIN CARDS: ${key} -->`;
|
||||
const finish = `<!-- END CARDS: ${key} -->`;
|
||||
const from = text.indexOf(begin);
|
||||
const to = text.indexOf(finish);
|
||||
if (from < 0 || to < 0) throw new Error(`${rel} has no markers for "${key}" — expected ${begin} … ${finish}`);
|
||||
if (to < from) throw new Error(`${rel}: markers for "${key}" are the wrong way round`);
|
||||
// Trailing blank lines are trimmed so the block sits the same way however the section ends.
|
||||
const inner = body.join('\n').replace(/\n+$/, '');
|
||||
edited.set(rel, `${text.slice(0, from + begin.length)}\n${inner}\n${text.slice(to)}`);
|
||||
}
|
||||
|
||||
for (const [rel, text] of edited) {
|
||||
writeFileSync(join(root, rel), text);
|
||||
console.log(`built -> ${rel}`);
|
||||
}
|
||||
+106
-3
@@ -10,12 +10,21 @@
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { DOC_PAGES, docPage } from './docs-page.ts';
|
||||
import { renderMarkdown } from './markdown.ts';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const dist = join(root, 'dist');
|
||||
/**
|
||||
* Overridable so a test can point the build at an isolated directory instead of the shared
|
||||
* `dist/` the rest of the suite reads — see `test/web.test.ts`'s "builds, and needs nothing but a
|
||||
* static host". Must be a sibling of `dist/`, not nested inside it: this script's own `rmSync`
|
||||
* below wipes the whole directory it is given, so nesting one inside the other would still race.
|
||||
*/
|
||||
const dist = join(root, process.env['BUILD_DIST_DIR'] ?? 'dist');
|
||||
|
||||
rmSync(dist, { recursive: true, force: true });
|
||||
mkdirSync(dist, { recursive: true });
|
||||
@@ -54,7 +63,27 @@ execFileSync(
|
||||
*/
|
||||
function buildStamp(): string {
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string };
|
||||
let git = 'nogit';
|
||||
/**
|
||||
* THE FALLBACK HAS TO BE UNIQUE PER BUILD, because this string is also the cache-bust key.
|
||||
*
|
||||
* It used to be the literal `nogit`, which is exactly what the `.s9pk` build produces — the
|
||||
* Dockerfile copies the working tree in without `.git`, so `git rev-parse` fails there every time.
|
||||
* Every packaged release therefore published `?v=nogit`, byte-identical to the release before it,
|
||||
* and a returning player's browser had no reason to refetch a single module. v0.7.5's setup screen
|
||||
* and v0.7.6's fix to it both shipped correctly to `phoenix.local` and neither reached the browser
|
||||
* that asked for them (Jesse, twice, 2026-08-29 — "setup did not work").
|
||||
*
|
||||
* A marker plus the build's own timestamp is always distinct, needs nothing from the environment,
|
||||
* and stays honest: two builds of the same commit ARE two deploys, and a cache key that says so
|
||||
* costs one refetch, while one that lies costs a release nobody receives.
|
||||
*
|
||||
* NOT THE VERSION, which is what this used to lead with. The stamp below already begins with
|
||||
* `v${pkg.version}`, so on exactly the builds that take this path — every `.s9pk`, which has no
|
||||
* `.git` — the header read "v0.8.0.10 · 0.8.0.10-mfq2p1 · …" and the version appeared twice
|
||||
* (Jesse, playtest 2026-09-16). The timestamp alone carries the uniqueness; the version is
|
||||
* already said once, properly, at the front.
|
||||
*/
|
||||
let git = `nogit-${Date.now().toString(36)}`;
|
||||
try {
|
||||
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
|
||||
.toString()
|
||||
@@ -151,6 +180,80 @@ if (existsSync(replaySrc)) {
|
||||
}
|
||||
writeFileSync(join(replayOut, 'manifest.json'), JSON.stringify(manifest, null, 1));
|
||||
|
||||
/**
|
||||
* Static images — the splash artwork and whatever joins it. Copied verbatim, unlike the replays
|
||||
* above: an image needs no validation, only a place in `dist/` to be served from.
|
||||
*/
|
||||
const imageSrc = join(root, 'public/images');
|
||||
if (existsSync(imageSrc)) {
|
||||
const imageOut = join(dist, 'images');
|
||||
mkdirSync(imageOut, { recursive: true });
|
||||
for (const f of readdirSync(imageSrc)) copyFileSync(join(imageSrc, f), join(imageOut, f));
|
||||
}
|
||||
|
||||
/**
|
||||
* The player-facing documentation, RENDERED and published beside the game.
|
||||
*
|
||||
* MARKDOWN IS STILL THE ONE COPY. `docs/*.md` is what is written and reviewed; this turns it into
|
||||
* a page at build time. A hand-written HTML twin would drift from it on the first edit, which is
|
||||
* the whole lesson of TODO #15a and of the documentation pass that found four references a month
|
||||
* out of date.
|
||||
*
|
||||
* WHY RENDER AT ALL. They were served as `text/plain`, which is honest and unreadable: a card
|
||||
* reference is mostly tables, and as plain text a table is rows of pipes. That was TODO #109, taken
|
||||
* deliberately as the short version to get the references in front of testers for one round.
|
||||
*
|
||||
* NO MARKDOWN LIBRARY. `scripts/markdown.ts` covers the subset these five documents use, and this
|
||||
* project has no runtime dependencies at all — one would be a poor first.
|
||||
*
|
||||
* THE `.md` IS PUBLISHED TOO, beside the page. It costs nothing, it is what a reader who wants the
|
||||
* source or a diff actually wants, and it keeps every link that was handed out while the documents
|
||||
* were served as text working rather than 404ing.
|
||||
*/
|
||||
const GUIDE_DOCS: readonly string[] = DOC_PAGES.map((d) => `${d.slug}.md`);
|
||||
|
||||
/** `rules.md` → `rules.html`, so a link between documents lands on the rendered page. */
|
||||
const docLink = (href: string): string =>
|
||||
/^https?:/.test(href) || href.startsWith('#') ? href : href.replace(/\.md(#|$)/, '.html$1');
|
||||
|
||||
/**
|
||||
* The title and version line, lifted out of the Markdown body.
|
||||
*
|
||||
* The documents open with `# Title` then `**Version x.y.z** · date`, and the page draws both in its
|
||||
* own header — so rendering them again in the body would print each twice. Taken by pattern rather
|
||||
* than by line count, and the body is only trimmed where the pattern actually matched.
|
||||
*/
|
||||
function splitHead(src: string): { title: string; version: string; body: string } {
|
||||
const m = /^#\s+(.+?)\n+\*\*Version\s+([^*]+)\*\*\s*·\s*([^\n]+)\n/.exec(src);
|
||||
if (!m) return { title: 'Station Master', version: '', body: src };
|
||||
return {
|
||||
title: m[1]!.replace(/^Station Master\s*[—-]\s*/, '').trim(),
|
||||
version: `<b>Version ${m[2]!.trim()}</b> · ${m[3]!.trim()}`,
|
||||
body: src.slice(m[0].length),
|
||||
};
|
||||
}
|
||||
|
||||
for (const rel of GUIDE_DOCS) {
|
||||
const src = join(root, 'docs', rel);
|
||||
if (!existsSync(src)) {
|
||||
// Loud rather than silent: a missing document is a broken link on a page already published, and
|
||||
// the build is the only place that can still notice.
|
||||
console.error(`WARNING: docs/${rel} is missing — a published link will 404`);
|
||||
continue;
|
||||
}
|
||||
const md = readFileSync(src, 'utf8');
|
||||
const out = join(dist, rel);
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
writeFileSync(out, md);
|
||||
|
||||
const { title, version, body } = splitHead(md);
|
||||
const { html, headings } = renderMarkdown(body, docLink);
|
||||
writeFileSync(
|
||||
join(dist, rel.replace(/\.md$/, '.html')),
|
||||
docPage({ slug: rel.replace(/\.md$/, ''), title, version, body: html, headings }),
|
||||
);
|
||||
}
|
||||
|
||||
// A tiny note for whoever unzips this later and wonders what it needs.
|
||||
writeFileSync(
|
||||
join(dist, 'README.txt'),
|
||||
|
||||
+96
-35
@@ -1,11 +1,29 @@
|
||||
/**
|
||||
* Build the solitaire site and push it to a File Browser instance.
|
||||
* Build the solitaire site and push it to a FileBrowser instance.
|
||||
*
|
||||
* Written against File Browser v2.63's REST API, read from its own bundle rather than guessed:
|
||||
* REWRITTEN FOR **FileBrowser Quantum** (2026-08-23). The host was upgraded from File Browser v2.63
|
||||
* to the Quantum fork, whose API is different in three ways at once, and every deploy failed with
|
||||
* `login failed: 404 404 page not found` — the old `/api/login` simply is not there any more.
|
||||
*
|
||||
* POST /api/login {username, password, recaptcha} -> JWT as plain text
|
||||
* POST /api/resources/<dir>/ X-Auth: <jwt> -> create a directory
|
||||
* POST /api/resources/<file>?override=true X-Auth: <jwt>, body = bytes -> upload
|
||||
* Read from the running instance's own bundle rather than guessed, the same way the v2.63 version
|
||||
* was (`/public/static/assets/index-*.js`, gzipped — pipe it through `gunzip` before grepping), and
|
||||
* each path confirmed against the live host by the response code: an endpoint that exists answers a
|
||||
* bad password with **401**, one that does not answers **404**.
|
||||
*
|
||||
* POST /api/auth/login?username=<u>&recaptcha=
|
||||
* headers X-Password: <urlencoded>, X-Secret: <otp or empty> -> sets a session COOKIE
|
||||
* GET /api/settings/sources -> the named sources
|
||||
* POST /api/resources?path=<p>&source=<s>&isDir=true -> create a directory
|
||||
* POST /api/resources?path=<p>&source=<s>&override=true body=bytes -> upload
|
||||
*
|
||||
* THREE THINGS MOVED, and each would break on its own:
|
||||
* 1. AUTH IS A COOKIE, not an `X-Auth: <jwt>` header. Login returns no usable token in its body;
|
||||
* the session arrives in `Set-Cookie` and every later request has to carry it back.
|
||||
* 2. THE PASSWORD IS A HEADER, `X-Password`, URL-encoded — not a JSON body field.
|
||||
* 3. THE PATH IS A QUERY PARAMETER, `?path=`, not part of the URL, and every resource call also
|
||||
* needs a **`source`** naming which configured store to write to. Quantum throws "no source
|
||||
* provided" without it. `FB_SOURCE` names it; left unset, the sole configured source is used,
|
||||
* and if there is more than one this stops and lists them rather than guessing.
|
||||
*
|
||||
* File Browser is the STORE, not the server — Start9 Pages serves the uploaded folder as the site.
|
||||
* So the job here is simply to land the built files in the right folder, intact.
|
||||
@@ -18,6 +36,8 @@
|
||||
* Optional:
|
||||
* FB_URL default https://phoenix.local:58157
|
||||
* FB_DEST default websites/stationmaster — the folder Start9 Pages serves from
|
||||
* FB_SOURCE which configured source to write to; discovered automatically when there is one
|
||||
* FB_OTP the one-time code, if the account has two-factor enabled
|
||||
* FB_INSECURE set to 1 for a self-signed certificate (usual for a .local StartOS host)
|
||||
* SITE_URL default https://65.78.82.12:54697/ — the public address Start9 Pages serves at
|
||||
* --dry-run list what would be sent, contact nothing
|
||||
@@ -44,6 +64,8 @@ const URL_BASE = (process.env['FB_URL'] ?? 'https://phoenix.local:58157').replac
|
||||
const DEST = `/${(process.env['FB_DEST'] ?? 'websites/stationmaster').replace(/^\/+|\/+$/g, '')}`;
|
||||
const USER = process.env['FB_USER'] ?? '';
|
||||
const PASS = process.env['FB_PASS'] ?? '';
|
||||
const OTP = process.env['FB_OTP'] ?? '';
|
||||
const SOURCE_ENV = process.env['FB_SOURCE'] ?? '';
|
||||
const DRY = process.argv.includes('--dry-run');
|
||||
|
||||
/**
|
||||
@@ -75,37 +97,80 @@ const CONTENT_TYPES: Record<string, string> = {
|
||||
'.txt': 'text/plain',
|
||||
};
|
||||
|
||||
/**
|
||||
* Log in and return the session cookie every later request must carry.
|
||||
*
|
||||
* The password goes in a HEADER and URL-encoded, which is Quantum's own client does
|
||||
* (`X-Password: encodeURIComponent(password)`). The body carries nothing useful on success — the
|
||||
* session is in `Set-Cookie`, so a deploy that ignored the cookie would authenticate and then be
|
||||
* rejected by every upload.
|
||||
*/
|
||||
async function login(): Promise<string> {
|
||||
const res = await fetch(`${URL_BASE}/api/login`, {
|
||||
const url = `${URL_BASE}/api/auth/login?username=${encodeURIComponent(USER)}&recaptcha=`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: USER, password: PASS, recaptcha: '' }),
|
||||
headers: { 'X-Password': encodeURIComponent(PASS), 'X-Secret': OTP },
|
||||
});
|
||||
const body = await res.text();
|
||||
if (!res.ok) throw new Error(`login failed: ${res.status} ${body || res.statusText}`);
|
||||
if (!body.trim()) throw new Error('login returned an empty token');
|
||||
return body.trim();
|
||||
}
|
||||
|
||||
async function makeDir(jwt: string, path: string): Promise<void> {
|
||||
// Trailing slash is what marks a directory in this API. A 409 means it already exists, which is
|
||||
// the normal case on every deploy after the first.
|
||||
const res = await fetch(`${URL_BASE}/api/resources${encodePath(path)}/`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Auth': jwt },
|
||||
});
|
||||
if (!res.ok && res.status !== 409) {
|
||||
throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`);
|
||||
if (!res.ok) {
|
||||
// 401 here is a wrong username/password; 404 would mean this build has moved the API again.
|
||||
throw new Error(`login failed: ${res.status} ${body || res.statusText}`);
|
||||
}
|
||||
const cookies = res.headers.getSetCookie();
|
||||
if (cookies.length === 0) throw new Error('login succeeded but set no session cookie');
|
||||
return cookies.map((c) => c.split(';')[0]).join('; ');
|
||||
}
|
||||
|
||||
async function upload(jwt: string, localPath: string, remotePath: string): Promise<void> {
|
||||
/**
|
||||
* WHICH STORE TO WRITE TO. Quantum can serve several named sources and refuses any resource call
|
||||
* that does not name one ("no source provided"), which is the parameter the v2.63 API had no
|
||||
* concept of. One configured source is the normal case and is used without asking; more than one is
|
||||
* ambiguous, and guessing would silently deploy the site into the wrong store.
|
||||
*/
|
||||
async function resolveSource(cookie: string): Promise<string> {
|
||||
if (SOURCE_ENV) return SOURCE_ENV;
|
||||
const res = await fetch(`${URL_BASE}/api/settings/sources`, { headers: { cookie } });
|
||||
if (!res.ok) throw new Error(`could not list sources: ${res.status} ${await res.text()}`);
|
||||
const names = Object.keys((await res.json()) ?? {});
|
||||
if (names.length === 1) return names[0]!;
|
||||
if (names.length === 0) throw new Error('the server reports no sources at all');
|
||||
throw new Error(`several sources configured (${names.join(', ')}) — pick one with FB_SOURCE=<name>`);
|
||||
}
|
||||
|
||||
function resourceUrl(source: string, path: string, extra: Record<string, string>): string {
|
||||
const params = new URLSearchParams({ path, source, ...extra });
|
||||
return `${URL_BASE}/api/resources?${params}`;
|
||||
}
|
||||
|
||||
async function makeDir(cookie: string, source: string, path: string): Promise<void> {
|
||||
const res = await fetch(resourceUrl(source, path, { isDir: 'true' }), {
|
||||
method: 'POST',
|
||||
headers: { cookie },
|
||||
});
|
||||
if (res.ok) return;
|
||||
/**
|
||||
* "Already there" is the normal case on every deploy after the first, and Quantum is not
|
||||
* consistent about which code it reports it with. So the two failures worth stopping for are
|
||||
* named — a rejected session, and a server that broke — and every other 4xx is treated as the
|
||||
* directory already existing. A directory that genuinely is not there fails loudly at the upload
|
||||
* a moment later, which is a better place to find out than a guess here.
|
||||
*/
|
||||
const fatal = res.status === 401 || res.status === 403 || res.status >= 500;
|
||||
if (fatal) throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function upload(
|
||||
cookie: string,
|
||||
source: string,
|
||||
localPath: string,
|
||||
remotePath: string,
|
||||
): Promise<void> {
|
||||
const bytes = readFileSync(localPath);
|
||||
const ext = remotePath.slice(remotePath.lastIndexOf('.'));
|
||||
const res = await fetch(`${URL_BASE}/api/resources${encodePath(remotePath)}?override=true`, {
|
||||
const res = await fetch(resourceUrl(source, remotePath, { override: 'true' }), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Auth': jwt,
|
||||
cookie,
|
||||
'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream',
|
||||
'Content-Length': String(bytes.byteLength),
|
||||
},
|
||||
@@ -114,11 +179,6 @@ async function upload(jwt: string, localPath: string, remotePath: string): Promi
|
||||
if (!res.ok) throw new Error(`upload ${remotePath} failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
/** Encode each segment but keep the separators, so a path stays a path. */
|
||||
function encodePath(p: string): string {
|
||||
return p.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log('building…');
|
||||
@@ -147,15 +207,16 @@ if (DRY) {
|
||||
);
|
||||
}
|
||||
|
||||
const jwt = await login();
|
||||
console.log('logged in');
|
||||
const cookie = await login();
|
||||
const source = await resolveSource(cookie);
|
||||
console.log(`logged in — writing to source "${source}"`);
|
||||
|
||||
await makeDir(jwt, DEST);
|
||||
for (const d of dirs) await makeDir(jwt, `${DEST}/${d}`);
|
||||
await makeDir(cookie, source, DEST);
|
||||
for (const d of dirs) await makeDir(cookie, source, `${DEST}/${d}`);
|
||||
|
||||
let done = 0;
|
||||
for (const f of files) {
|
||||
await upload(jwt, join(dist, f), `${DEST}/${f}`);
|
||||
await upload(cookie, source, join(dist, f), `${DEST}/${f}`);
|
||||
done++;
|
||||
console.log(` [${String(done).padStart(2)}/${files.length}] ${f}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* The page a documentation file is rendered into.
|
||||
*
|
||||
* ONE TEMPLATE FOR ALL FIVE, so the guide reads as one publication rather than five files that
|
||||
* happen to be linked. It carries the same dark palette, the same type and the same blue as the
|
||||
* game, because a player arrives here from the board and should not feel they have left the site.
|
||||
*
|
||||
* WHAT THE PAGE ADDS OVER THE MARKDOWN, and why each is here rather than in the source:
|
||||
*
|
||||
* - a **nav** across the five documents, so the set is navigable from any one of them. The
|
||||
* Markdown cannot carry this: it would have to be repeated in every file and would drift.
|
||||
* - a **contents list** built from the headings actually rendered, so it cannot fall out of step
|
||||
* with the document the way a hand-written one does.
|
||||
* - **anchors** on every heading, so a section can be linked to in a bug report.
|
||||
* - a **measure** of about 70 characters. Long lines are the single biggest thing making plain
|
||||
* text hard to read, and these documents are long.
|
||||
*
|
||||
* PRINTS SANELY TOO: the nav and contents drop out, the palette goes to ink on paper, and tables
|
||||
* keep their rules. A rules reference is a thing people print.
|
||||
*/
|
||||
|
||||
import type { Heading } from './markdown.ts';
|
||||
|
||||
export type DocPage = {
|
||||
/** Published filename, without the extension — also the nav's identity for "you are here". */
|
||||
slug: string;
|
||||
/** What the nav calls it. */
|
||||
nav: string;
|
||||
};
|
||||
|
||||
export const DOC_PAGES: readonly DocPage[] = [
|
||||
{ slug: 'quickstart', nav: 'Quickstart' },
|
||||
{ slug: 'rules', nav: 'Rules' },
|
||||
{ slug: 'home-deck', nav: 'Home deck' },
|
||||
{ slug: 'mainline-deck', nav: 'Mainline deck' },
|
||||
{ slug: 'components', nav: 'Components' },
|
||||
];
|
||||
|
||||
export const DOCS_CSS = `
|
||||
:root{
|
||||
--bg:#12161c; --panel:#161b22; --line:#2c333d; --fg:#cfd6e0; --dim:#8b94a3;
|
||||
--head:#cfe0f5; --link:#5aa9e6; --accent:#9fb6d8; --rule:#39424e;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html{scroll-behavior:smooth}
|
||||
body{
|
||||
margin:0;background:var(--bg);color:var(--fg);
|
||||
font:15px/1.65 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
-webkit-text-size-adjust:100%;
|
||||
}
|
||||
a{color:var(--link)}
|
||||
a:hover{color:#9fd0f5}
|
||||
|
||||
/* THE NAV. Sticky, because these documents are long and the set has to stay reachable from the
|
||||
middle of one. Horizontally scrollable on a phone rather than wrapping into three rows. */
|
||||
.docnav{
|
||||
position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);
|
||||
display:flex;align-items:center;gap:4px;padding:8px 16px;overflow-x:auto;
|
||||
}
|
||||
.docnav .home{color:var(--head);font-weight:700;margin-right:10px;text-decoration:none;white-space:nowrap}
|
||||
.docnav a.tab{
|
||||
color:var(--dim);text-decoration:none;padding:4px 10px;border-radius:6px;white-space:nowrap;
|
||||
border:1px solid transparent;font-size:13px;
|
||||
}
|
||||
.docnav a.tab:hover{color:var(--fg);background:#1f2733}
|
||||
.docnav a.tab[aria-current="page"]{color:#f2e6cf;background:#2b3444;border-color:#c8912f}
|
||||
|
||||
.wrap{max-width:78ch;margin:0 auto;padding:22px 16px 72px}
|
||||
|
||||
/* THE HEADER — what this document is and which build it describes, lifted out of the prose so the
|
||||
version is the first thing on the page, as the process rules require. */
|
||||
.dochead{border-bottom:1px solid var(--rule);padding-bottom:12px;margin-bottom:8px}
|
||||
.dochead h1{margin:0 0 6px;font-size:26px;line-height:1.25;color:var(--head);letter-spacing:.01em}
|
||||
.dochead .ver{color:var(--dim);font-size:13px}
|
||||
.dochead .ver b{color:#c8912f;font-weight:700}
|
||||
|
||||
/* CONTENTS, built from the headings actually rendered. Collapsed by default on a phone. */
|
||||
.toc{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 14px;margin:18px 0 26px}
|
||||
.toc summary{cursor:pointer;color:var(--accent);font-size:13px;font-weight:600;letter-spacing:.04em;text-transform:uppercase}
|
||||
.toc ol{list-style:none;margin:10px 0 2px;padding:0;columns:2;column-gap:26px}
|
||||
.toc li{margin:0 0 4px;break-inside:avoid}
|
||||
.toc li.l3{padding-left:14px;font-size:13px}
|
||||
.toc a{text-decoration:none;color:var(--fg)}
|
||||
.toc a:hover{color:var(--link)}
|
||||
@media (max-width:640px){.toc ol{columns:1}}
|
||||
|
||||
h2,h3,h4{color:var(--head);line-height:1.3;margin:28px 0 8px}
|
||||
h2{font-size:20px;border-bottom:1px solid var(--rule);padding-bottom:5px}
|
||||
h3{font-size:16px}
|
||||
h4{font-size:14px;color:var(--accent);text-transform:uppercase;letter-spacing:.05em}
|
||||
p{margin:0 0 12px}
|
||||
strong{color:#e8eef7}
|
||||
hr{border:none;border-top:1px solid var(--rule);margin:26px 0}
|
||||
|
||||
/* The anchor beside a heading: invisible until the heading is hovered, so it never competes with
|
||||
the words but is always there to copy. */
|
||||
.anchor{margin-left:.45em;color:var(--rule);text-decoration:none;font-weight:400;opacity:0}
|
||||
h1:hover .anchor,h2:hover .anchor,h3:hover .anchor,h4:hover .anchor{opacity:1}
|
||||
.anchor:hover{color:var(--link)}
|
||||
|
||||
ul,ol{margin:0 0 12px;padding-left:22px}
|
||||
li{margin:0 0 5px}
|
||||
li>ul,li>ol{margin-top:5px}
|
||||
|
||||
code{background:#1d232c;border:1px solid var(--line);border-radius:4px;padding:1px 5px;
|
||||
font:13px ui-monospace,SFMono-Regular,Menlo,monospace;color:#e0c89a}
|
||||
pre{background:#1d232c;border:1px solid var(--line);border-radius:7px;padding:12px 14px;overflow-x:auto}
|
||||
pre code{background:none;border:none;padding:0;color:var(--fg)}
|
||||
|
||||
blockquote{
|
||||
margin:16px 0;padding:10px 14px;background:#181f28;
|
||||
border-left:3px solid #c8912f;border-radius:0 7px 7px 0;
|
||||
}
|
||||
blockquote > :last-child{margin-bottom:0}
|
||||
|
||||
/* TABLES THAT LOOK LIKE TABLES. This is the whole reason the documentation is rendered rather than
|
||||
served as text: a card reference is mostly tables, and as plain text they are rows of pipes. */
|
||||
.tablewrap{overflow-x:auto;margin:0 0 16px;border:1px solid var(--line);border-radius:8px}
|
||||
table{border-collapse:collapse;width:100%;font-size:14px}
|
||||
thead th{
|
||||
background:#1f2733;color:var(--accent);text-align:left;font-weight:600;
|
||||
padding:8px 12px;border-bottom:1px solid var(--line);white-space:nowrap;
|
||||
}
|
||||
td{padding:7px 12px;border-bottom:1px solid #222a34;vertical-align:top}
|
||||
tbody tr:last-child td{border-bottom:none}
|
||||
tbody tr:nth-child(even){background:#151a21}
|
||||
tbody tr:hover{background:#1b222b}
|
||||
.ta-right{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.ta-center{text-align:center}
|
||||
th.ta-right,th.ta-center{text-align:inherit}
|
||||
|
||||
footer{margin-top:40px;padding-top:14px;border-top:1px solid var(--rule);color:var(--dim);font-size:12px}
|
||||
footer a{color:var(--accent)}
|
||||
|
||||
@media print{
|
||||
.docnav,.toc,.anchor{display:none}
|
||||
body{background:#fff;color:#111;font-size:11pt}
|
||||
h1,h2,h3,h4,strong{color:#000}
|
||||
a{color:#000;text-decoration:underline}
|
||||
.wrap{max-width:none;padding:0}
|
||||
.tablewrap{border-color:#999}
|
||||
thead th{background:#eee;color:#000;border-bottom-color:#999}
|
||||
td{border-bottom-color:#ccc}
|
||||
tbody tr:nth-child(even){background:#f6f6f6}
|
||||
blockquote{background:#f4f4f4;border-left-color:#888}
|
||||
code,pre{background:#f4f4f4;border-color:#ccc;color:#111}
|
||||
}
|
||||
`;
|
||||
|
||||
/** The contents list, from the headings the renderer actually produced. */
|
||||
function toc(headings: readonly Heading[]): string {
|
||||
// h2 and h3 only: h1 is the page title, and h4 is a label inside a section rather than a place.
|
||||
const items = headings.filter((h) => h.level === 2 || h.level === 3);
|
||||
if (items.length < 3) return '';
|
||||
const lis = items
|
||||
.map((h) => `<li class="l${h.level}"><a href="#${h.id}">${h.text.replace(/&/g, '&').replace(/</g, '<')}</a></li>`)
|
||||
.join('');
|
||||
return `<details class="toc" open><summary>On this page</summary><ol>${lis}</ol></details>`;
|
||||
}
|
||||
|
||||
export function docPage(opts: {
|
||||
slug: string;
|
||||
title: string;
|
||||
version: string;
|
||||
body: string;
|
||||
headings: readonly Heading[];
|
||||
}): string {
|
||||
const tabs = DOC_PAGES.map(
|
||||
(d) =>
|
||||
`<a class="tab" href="./${d.slug}.html"${d.slug === opts.slug ? ' aria-current="page"' : ''}>${d.nav}</a>`,
|
||||
).join('');
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${opts.title} — Station Master</title>
|
||||
<style>${DOCS_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="docnav"><a class="home" href="./index.html">Station Master</a>${tabs}</nav>
|
||||
<div class="wrap">
|
||||
<header class="dochead">
|
||||
<h1>${opts.title}</h1>
|
||||
<div class="ver">${opts.version}</div>
|
||||
</header>
|
||||
${toc(opts.headings)}
|
||||
${opts.body}
|
||||
<footer>
|
||||
Station Master — <a href="./index.html">back to the game</a> ·
|
||||
these references are kept current with every release.
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* A small Markdown renderer, for the player-facing documentation only.
|
||||
*
|
||||
* WHY NOT A LIBRARY. This project has no runtime dependencies at all, and the guide uses a small,
|
||||
* known subset of Markdown — headings, paragraphs, lists, tables, links, code spans, block quotes
|
||||
* and rules. A Markdown library would be the first dependency in the tree, pulled in to render five
|
||||
* files whose whole vocabulary fits below. `docs/` is written by hand, not by users, so this does
|
||||
* not have to survive hostile input; it has to render what those five documents actually contain
|
||||
* and fail loudly on anything else.
|
||||
*
|
||||
* WHY NOT HAND-WRITTEN HTML. The Markdown is the one copy (TODO #15a). An HTML twin drifts from it
|
||||
* on the first edit, which is the failure the whole documentation pass was about.
|
||||
*
|
||||
* ESCAPING IS UNCONDITIONAL. Every scrap of text goes through `esc` before any markup is added, and
|
||||
* the inline pass only ever inserts tags around already-escaped content. A `<` in the prose is a
|
||||
* less-than sign, not the start of an element — there is no raw-HTML passthrough, deliberately.
|
||||
*/
|
||||
|
||||
/** HTML-escape. Ampersand first, or it double-escapes the entities added after it. */
|
||||
export function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** A heading found while rendering, for the contents list the page builds from it. */
|
||||
export type Heading = { level: number; text: string; id: string };
|
||||
|
||||
/**
|
||||
* `## 3. The shape of a Stage` → `the-shape-of-a-stage`.
|
||||
*
|
||||
* The leading number is dropped: it is a position in the document, and a link that carries it
|
||||
* breaks when a section is inserted above. Duplicate slugs get a numeric suffix rather than
|
||||
* silently pointing at the first one.
|
||||
*/
|
||||
function slug(text: string, taken: Set<string>): string {
|
||||
const base =
|
||||
text
|
||||
.toLowerCase()
|
||||
// A whole section number, dotted or not: "4.2 Local Operations" and "7. FAQ" both lose it.
|
||||
.replace(/^\d+(?:\.\d+)*[.)]?\s+/, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'section';
|
||||
let id = base;
|
||||
for (let n = 2; taken.has(id); n++) id = `${base}-${n}`;
|
||||
taken.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline markup, applied to text that is ALREADY escaped.
|
||||
*
|
||||
* Order matters: code spans are taken out first and put back last, so `**` inside backticks stays
|
||||
* literal. That is not a corner case here — the rules reference quotes field names like
|
||||
* `**Version**` when describing the page header.
|
||||
*/
|
||||
function inline(escaped: string, linkHref: (href: string) => string): string {
|
||||
const code: string[] = [];
|
||||
let s = escaped.replace(/`([^`]+)`/g, (_m, body: string) => {
|
||||
code.push(`<code>${body}</code>`);
|
||||
return `\u0000${code.length - 1}\u0000`;
|
||||
});
|
||||
|
||||
// Links: [text](target). The target is rewritten so a link between documents lands on the
|
||||
// rendered page rather than the Markdown source.
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text: string, href: string) => {
|
||||
const target = linkHref(href);
|
||||
const external = /^https?:/.test(target);
|
||||
return `<a href="${target}"${external ? ' target="_blank" rel="noopener"' : ''}>${text}</a>`;
|
||||
});
|
||||
|
||||
// Bold before italic, or `**x**` is read as an empty italic wrapping a bold.
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
s = s.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
||||
|
||||
return s.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => code[Number(i)]!);
|
||||
}
|
||||
|
||||
/** One table row's cells, from `| a | b |`. */
|
||||
function cells(line: string): string[] {
|
||||
return line
|
||||
.replace(/^\s*\|/, '')
|
||||
.replace(/\|\s*$/, '')
|
||||
.split('|')
|
||||
.map((c) => c.trim());
|
||||
}
|
||||
|
||||
/** `---`, `:--`, `--:` and `:-:` → the CSS alignment a column wants. */
|
||||
function alignments(sep: string): (string | null)[] {
|
||||
return cells(sep).map((c) => {
|
||||
const left = c.startsWith(':');
|
||||
const right = c.endsWith(':');
|
||||
if (left && right) return 'center';
|
||||
if (right) return 'right';
|
||||
if (left) return 'left';
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
const isTableSep = (line: string): boolean => /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes('-');
|
||||
|
||||
export type Rendered = { html: string; headings: Heading[] };
|
||||
|
||||
/**
|
||||
* Render a Markdown document to HTML.
|
||||
*
|
||||
* `linkHref` rewrites link targets — the build uses it to send `rules.md` to `rules.html` — and
|
||||
* defaults to leaving them alone so the function is testable on its own.
|
||||
*/
|
||||
/**
|
||||
* One list at one indent level. An item is its first line plus any lines indented under it; the
|
||||
* more-indented BULLETS among those are the item's own nested list and render recursively, while
|
||||
* plain indented lines are wrapped continuations of its text.
|
||||
*/
|
||||
function listHtml(block: string[], text: (s: string) => string): string {
|
||||
const first = /^(\s*)([-*+]|\d+[.)])\s+/.exec(block.find((l) => l.trim() !== '') ?? '');
|
||||
if (!first) return '';
|
||||
const ordered = /\d/.test(first[2]!);
|
||||
const base = first[1]!.length;
|
||||
const items: { text: string[]; sub: string[] }[] = [];
|
||||
for (const l of block) {
|
||||
const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(l);
|
||||
const current = items[items.length - 1];
|
||||
if (m && m[1]!.length <= base) {
|
||||
items.push({ text: [m[3]!], sub: [] });
|
||||
} else if (!current) {
|
||||
continue;
|
||||
} else if (current.sub.length > 0 || (m && m[1]!.length > base)) {
|
||||
// Once a nested list has begun, everything further belongs to it, wrapped lines included.
|
||||
current.sub.push(l);
|
||||
} else if (l.trim() !== '') {
|
||||
current.text.push(l.trim());
|
||||
}
|
||||
}
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
return (
|
||||
`<${tag}>` +
|
||||
items.map((it) => `<li>${text(it.text.join(' '))}${it.sub.length > 0 ? listHtml(it.sub, text) : ''}</li>`).join('') +
|
||||
`</${tag}>`
|
||||
);
|
||||
}
|
||||
|
||||
export function renderMarkdown(src: string, linkHref: (href: string) => string = (h) => h): Rendered {
|
||||
const lines = src.replace(/\r\n/g, '\n').split('\n');
|
||||
const out: string[] = [];
|
||||
const headings: Heading[] = [];
|
||||
const taken = new Set<string>();
|
||||
const ln = (s: string): void => void out.push(s);
|
||||
const text = (s: string): string => inline(esc(s), linkHref);
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!;
|
||||
|
||||
// The generated-card markers, and any other HTML comment: structural, never shown.
|
||||
if (/^\s*<!--/.test(line)) {
|
||||
while (i < lines.length && !lines[i]!.includes('-->')) i++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === '') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) {
|
||||
ln('<hr>');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
||||
if (heading) {
|
||||
const level = heading[1]!.length;
|
||||
const raw = heading[2]!.trim();
|
||||
const id = slug(raw, taken);
|
||||
headings.push({ level, text: raw.replace(/[*`]/g, ''), id });
|
||||
// The anchor is a link to itself, so a section can be pointed at without a separate widget.
|
||||
ln(
|
||||
`<h${level} id="${id}">${text(raw)}` +
|
||||
`<a class="anchor" href="#${id}" aria-label="Link to this section">#</a></h${level}>`,
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fenced code.
|
||||
if (/^\s*```/.test(line)) {
|
||||
i++;
|
||||
const body: string[] = [];
|
||||
while (i < lines.length && !/^\s*```/.test(lines[i]!)) body.push(lines[i++]!);
|
||||
i++;
|
||||
ln(`<pre><code>${esc(body.join('\n'))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tables: a header row, an alignment row, then body rows.
|
||||
if (line.includes('|') && i + 1 < lines.length && isTableSep(lines[i + 1]!)) {
|
||||
const head = cells(line);
|
||||
const align = alignments(lines[i + 1]!);
|
||||
i += 2;
|
||||
const body: string[][] = [];
|
||||
while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim() !== '') body.push(cells(lines[i++]!));
|
||||
const th = head
|
||||
.map((c, n) => `<th${align[n] ? ` class="ta-${align[n]}"` : ''}>${text(c)}</th>`)
|
||||
.join('');
|
||||
const rows = body
|
||||
.map(
|
||||
(r) =>
|
||||
'<tr>' +
|
||||
r.map((c, n) => `<td${align[n] ? ` class="ta-${align[n]}"` : ''}>${text(c)}</td>`).join('') +
|
||||
'</tr>',
|
||||
)
|
||||
.join('');
|
||||
// Wrapped so a wide table scrolls inside the page rather than widening it on a phone.
|
||||
ln(`<div class="tablewrap"><table><thead><tr>${th}</tr></thead><tbody>${rows}</tbody></table></div>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Block quote: consecutive `>` lines, rendered through this same function so a quote may hold
|
||||
// a list or a table — the rules reference puts both inside one.
|
||||
if (/^\s*>/.test(line)) {
|
||||
const body: string[] = [];
|
||||
while (i < lines.length && /^\s*>/.test(lines[i]!)) body.push(lines[i++]!.replace(/^\s*>\s?/, ''));
|
||||
ln(`<blockquote>${renderMarkdown(body.join('\n'), linkHref).html}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lists. A bullet or a number opens one; continuation lines are indented under their item, and
|
||||
// a more-indented bullet under an item is a nested list, rendered by recursion (v0.8.4 — the
|
||||
// comment said so before and the code appended the nested bullet to its parent as text, so the
|
||||
// published home-deck page carried a literal "- " mid-sentence).
|
||||
const bullet = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
||||
if (bullet) {
|
||||
const baseIndent = bullet[1]!.length;
|
||||
const block: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const l = lines[i]!;
|
||||
if (l.trim() === '') {
|
||||
// A blank line ends the list unless the next line is still inside it.
|
||||
const next = lines[i + 1] ?? '';
|
||||
const continues = /^(\s*)([-*+]|\d+[.)])\s+/.test(next) || /^\s{2,}\S/.test(next);
|
||||
if (!continues) break;
|
||||
block.push('');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const m = /^(\s*)([-*+]|\d+[.)])\s+/.exec(l);
|
||||
if ((m && m[1]!.length <= baseIndent) || (m && m[1]!.length > baseIndent) || /^\s{2,}\S/.test(l)) {
|
||||
block.push(l);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
ln(listHtml(block, text));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Anything else is a paragraph, running to the next blank line or block opener.
|
||||
const para: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const l = lines[i]!;
|
||||
if (
|
||||
l.trim() === '' ||
|
||||
/^(#{1,6})\s/.test(l) ||
|
||||
/^\s*>/.test(l) ||
|
||||
/^\s*```/.test(l) ||
|
||||
/^\s*<!--/.test(l) ||
|
||||
/^\s*(---|\*\*\*|___)\s*$/.test(l) ||
|
||||
/^(\s*)([-*+]|\d+[.)])\s+/.test(l) ||
|
||||
(l.includes('|') && isTableSep(lines[i + 1] ?? ''))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
para.push(l.trim());
|
||||
i++;
|
||||
}
|
||||
if (para.length) ln(`<p>${text(para.join(' '))}</p>`);
|
||||
}
|
||||
|
||||
return { html: out.join('\n'), headings };
|
||||
}
|
||||
+1204
-256
File diff suppressed because it is too large
Load Diff
+1687
-293
File diff suppressed because it is too large
Load Diff
+857
-187
File diff suppressed because it is too large
Load Diff
+239
-25
@@ -1,29 +1,55 @@
|
||||
/**
|
||||
* Events — protocol.md §3.
|
||||
*
|
||||
* An event is a FACT. Events are ordered, append-only, and fully determine state:
|
||||
* `state = fold(events)`. That one property gives reconnection, restart recovery and post-game
|
||||
* replay together.
|
||||
* An event is a FACT: ordered, append-only, and standalone. Events NARRATE the game — they drive the
|
||||
* log, the sounds and the replay's captions.
|
||||
*
|
||||
* THEY DO NOT RECONSTRUCT IT. This header claimed `state = fold(events)` until v0.4.0 and it was
|
||||
* never true. `applyIntent` does go through `reduce`, but the phase driver in `advance.ts` mutates
|
||||
* state and THEN emits a descriptive event, so fourteen of the forty-six types below are never
|
||||
* reduced — the clock, and the whole Mainline phase, which is every train movement in the game.
|
||||
*
|
||||
* The canonical record is `{ seed, history: Intent[] }`, replayed by `fromSave`. That is what save,
|
||||
* restore, undo, restart recovery and post-game replay all run on. See
|
||||
* `docs/architecture/protocol.md` §3, and `test/events.test.ts`, which pins the unreduced set so
|
||||
* that closing the gap is a deliberate act rather than a surprise.
|
||||
*
|
||||
* DESIGN RULE (overview.md, post-game replay): events must render STANDALONE. Carry the from/to,
|
||||
* not just an id the renderer has to resolve against live state — otherwise a replay viewer has to
|
||||
* reconstruct the whole board to draw one frame.
|
||||
*/
|
||||
|
||||
import type { CarType, OfficeTier } from './content.ts';
|
||||
import type { LocalOpsOption } from './intents.ts';
|
||||
import type { CardId, GridCoord, PlayerIndex, RollingStock, TrayId } from './state.ts';
|
||||
import type { CarType, Direction, OfficeTier } from './content.ts';
|
||||
import type { ExtraStart, LocalOpsOption } from './intents.ts';
|
||||
import type { CardId, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||
|
||||
export type GameEvent =
|
||||
// -- clock
|
||||
| { type: 'stageBegan'; day: number; stage: number }
|
||||
/** Employee Rotation (Appendix B) — every player has moved one chair left for the new Day. */
|
||||
| { type: 'seatsRotated'; day: number; seating: PlayerIndex[] }
|
||||
/**
|
||||
* §5 — the Fedora passed, at the end of Stage 3, 6, 9 or 12.
|
||||
*
|
||||
* ITS OWN EVENT RATHER THAN THE `actorChanged` THIS USED TO RIDE ON. That one is turn bookkeeping,
|
||||
* fired every time the cursor moves, and `record()` drops it on the floor as noise — so the one
|
||||
* moment it carried that a player actually needed to see went past in silence. Reported from the
|
||||
* table (2026-09-16): the Supervisor Shift appears in the history and the handover never does.
|
||||
*/
|
||||
| { type: 'superintendentChanged'; player: PlayerIndex; stage: number }
|
||||
| { type: 'phaseBegan'; phase: string }
|
||||
| { type: 'actorChanged'; player: PlayerIndex | null }
|
||||
// -- local operations
|
||||
| { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption }
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w' }
|
||||
/**
|
||||
* `via` mirrors the intent's `via` (docs/plans/switching-paths.md) — present only when there was
|
||||
* more than one legal route to `to`, so the history can say which one ran rather than leaving a
|
||||
* choice the player made invisible in their own log.
|
||||
*/
|
||||
| { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; movesAllowed: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||
| {
|
||||
type: 'carsCoupled';
|
||||
player: PlayerIndex;
|
||||
trayId: TrayId;
|
||||
at: GridCoord;
|
||||
stock: RollingStock[];
|
||||
@@ -34,9 +60,88 @@ export type GameEvent =
|
||||
* running forward: the engine meets the cars head-on and takes them on the front.
|
||||
*/
|
||||
toNose: boolean;
|
||||
/**
|
||||
* Cars that STAY on the card the crew pulled out of, when it coupled its own cut.
|
||||
*
|
||||
* A train may set out off both ends on one square, and pulling forward picks up only the cut
|
||||
* at the end it leaves by — the one behind it is still standing there. `from` names the cards
|
||||
* to sweep and every other one is emptied outright, so the start card needs the exception said
|
||||
* explicitly rather than recomputed: the tray has already moved by the time this is reduced,
|
||||
* and `standingWest` went with it.
|
||||
*/
|
||||
leaves?: { at: GridCoord; stock: RollingStock[] };
|
||||
/**
|
||||
* The train's OWN cut, lifted back off the square it set it out on.
|
||||
*
|
||||
* Recoupling your own cars on the square you are standing on is UNDOING the drop, not a fresh
|
||||
* pick-up (Jesse's call, of the two the plan put up). Trains 3/4 spend a per-location freight
|
||||
* budget on setting out, so without this a legal-looking drop became silently one-way: the
|
||||
* train could only ever back away from its own cut, never pull forward through it. The budget
|
||||
* is refunded here and not charged again at the far end.
|
||||
*/
|
||||
recoupled?: { at: GridCoord; stock: RollingStock[] };
|
||||
}
|
||||
| { type: 'carsDropped'; player: PlayerIndex; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||
| {
|
||||
type: 'consistSorted';
|
||||
player: PlayerIndex;
|
||||
trayId: TrayId;
|
||||
at: GridCoord;
|
||||
before: RollingStock[];
|
||||
after: RollingStock[];
|
||||
/**
|
||||
* Where the engine ends up in `after`, counted as an index into it — 0 is the nose.
|
||||
*
|
||||
* The Small Yard used to put the engine back on the front unconditionally, which is what the
|
||||
* v0.4.5 card text says ("reorder its entire consist and put the engine at the nose").
|
||||
* `implications.md` records the design source saying the opposite — "may sort itself into any
|
||||
* order, INCLUDING cars ahead of the engine" — and Jesse settled it that way on 2026-09-17.
|
||||
*/
|
||||
engineAt: number;
|
||||
}
|
||||
/**
|
||||
* A player finished their switching turn: what it cost, and where the crew was left.
|
||||
*
|
||||
* The history panel keeps a switching turn's FIRST move and drops the ones in the middle, so the
|
||||
* closing line is where "and it ended up here" has to come from. It cannot be recovered by
|
||||
* revealing the last `trayMoved` after the fact: the log streams to clients as it is written
|
||||
* (`server/session.ts` § linesSince), and nobody knows a move was the last one until the turn is
|
||||
* already over and that line has been sent.
|
||||
*/
|
||||
| {
|
||||
type: 'switchingEnded';
|
||||
player: PlayerIndex;
|
||||
movesUsed: number;
|
||||
movesAllowed: number;
|
||||
lastMove?: { trayId: TrayId; to: GridCoord };
|
||||
}
|
||||
/**
|
||||
* THE FREIGHT AGENT WAS CHOSEN AND DID NOTHING, which is a decision rather than an absence.
|
||||
*
|
||||
* §6.3 requires no action, and the bot deliberately takes that route — unjamming a healthy box
|
||||
* destroys a load that cost a whole Local Operations action to stock, so an idle Stage is
|
||||
* strictly better. Reported from a table on Day 1 Stage 3 of v0.8.0.16: the log announced
|
||||
* FREIGHT AGENT work and then said nothing at all, so the turn read as a bug.
|
||||
*
|
||||
* Reduces to nothing, like `switchingEnded` above: it reports a choice the state already holds.
|
||||
*/
|
||||
| { type: 'freightAgentIdled'; player: PlayerIndex }
|
||||
/**
|
||||
* A train the Interlocking was holding at the Limits has taken the A/D track that just freed.
|
||||
*
|
||||
* It happens as a side effect of ANOTHER train arriving and clearing the Office, so without this
|
||||
* the held train simply appeared at the Office with nothing said — reported from a table as
|
||||
* "wasn't clear what changed and why train 8 was suddenly released". `freedBy` names the train
|
||||
* whose arrival did it, because "why now" is the whole question.
|
||||
*/
|
||||
| {
|
||||
type: 'trainReleasedFromLimits';
|
||||
trainNumber: number;
|
||||
office: string;
|
||||
owner: SeatIndex;
|
||||
/** The train whose arrival freed the track — or null when a departure freed it (v0.8.3). */
|
||||
freedBy: number | null;
|
||||
}
|
||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
/**
|
||||
* §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
|
||||
@@ -50,8 +155,19 @@ export type GameEvent =
|
||||
| { type: 'deckReshuffled'; order: CardId[]; rngState: number }
|
||||
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
|
||||
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
|
||||
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
|
||||
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; trayId: TrayId; node: number }
|
||||
/**
|
||||
* `from` is the card's kind BEFORE the change, carried so the log can say what was realigned
|
||||
* rather than only what it turned into (playtest, 2026-09-15: "it should state that the mainline
|
||||
* card 3 curves was converted to plains"). Events are derived by replaying a save, never stored,
|
||||
* so widening one strands nothing on disk.
|
||||
*/
|
||||
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; from?: string; became?: string }
|
||||
/** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */
|
||||
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction }
|
||||
/** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */
|
||||
| { type: 'redFlagSpent'; seat: SeatIndex; side: Direction; trainNumber: number }
|
||||
/** §Q (Gitea#19) — the district's owner answered the out-of-phase "flag against this train?". */
|
||||
| { type: 'redFlagRuled'; player: PlayerIndex; trainId: TrayId; flag: boolean }
|
||||
| {
|
||||
type: 'trainsDestroyed';
|
||||
player: PlayerIndex;
|
||||
@@ -66,7 +182,7 @@ export type GameEvent =
|
||||
// -- freight agent
|
||||
| { type: 'stockToOutbound'; player: PlayerIndex; at: GridCoord; stock: RollingStock }
|
||||
| { type: 'inboundCleared'; player: PlayerIndex; at: GridCoord; stock: RollingStock }
|
||||
| { type: 'facilityUnjammed'; player: PlayerIndex; at: GridCoord; from: string; stock: RollingStock }
|
||||
| { type: 'facilityUnjammed'; player: PlayerIndex; at: GridCoord; from: string; index: number; stock: RollingStock }
|
||||
// -- trains
|
||||
/**
|
||||
* §7 — a Timetabled Train card played from hand is scheduled by a 1D12 roll. `rngState` carries
|
||||
@@ -85,35 +201,133 @@ export type GameEvent =
|
||||
*/
|
||||
| { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at?: GridCoord; node?: number }
|
||||
| { type: 'extraQueued'; player: PlayerIndex; trainNumber: number }
|
||||
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number }
|
||||
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number; cardId: CardId }
|
||||
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
|
||||
| { type: 'trainHeld'; trainNumber: number; reason: string }
|
||||
/** A train whose card pays for standing still (X18 Circus) collected on it. */
|
||||
| { type: 'trainStoodStill'; trainNumber: number; where: string }
|
||||
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string }
|
||||
/**
|
||||
* `expedited` because it changes what the player must do RIGHT NOW. Q3 — an expedited train
|
||||
* departs the same Stage it arrives, so there is no Local Operations turn in which to work it;
|
||||
* every other train stands until the next Mainline Phase and can be switched in between.
|
||||
* Q3 — A STATION MASTER FAULT. An expedited train is not to be held anywhere but the station: if
|
||||
* it is still off the Office square — parked on Secondary Track, say, to clear a switching move —
|
||||
* when a Mainline Phase begins, that is a failure to keep it ready to highball, and it costs
|
||||
* Revenue. `where` names the square it was found on. Always followed by a `revenueChanged`.
|
||||
*/
|
||||
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean }
|
||||
| { type: 'expediteFault'; player: PlayerIndex; trainNumber: number; where: string }
|
||||
/**
|
||||
* `why` is the RULE that let it go now, not a restatement of the move.
|
||||
*
|
||||
* Reported from play: "some trains seem to be moving before I can switch or do other operations on
|
||||
* them." Every departure looked identical in the history, so the one that mattered — an Expedited
|
||||
* train leaving at the end of the Stage it arrived, with no Local Operations turn in between —
|
||||
* read exactly like an ordinary train leaving a Stage later.
|
||||
*/
|
||||
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string; why: string }
|
||||
/**
|
||||
* `expedited` flags a train that must not be left standing off the station (Q3) — it may be
|
||||
* switched normally like any other arrival, but it has to be back on the Office square before the
|
||||
* next Mainline Phase begins, or `expediteFault` fires.
|
||||
*/
|
||||
/**
|
||||
* `owner` is WHOSE Office it reached — the district's player, not whoever is acting. The Mainline
|
||||
* Phase has no actor, so nothing else in the line could name the seat, and the narration said only
|
||||
* "ARRIVED at the Whistle Post" — every seat's Office has a tier, and at a four-seat table three of
|
||||
* them are somebody else's (Jesse, playtest 2026-09-16).
|
||||
*/
|
||||
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; owner: PlayerIndex; expedited: boolean }
|
||||
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
|
||||
| { type: 'trainCompleted'; trainNumber: number; consist: RollingStock[] }
|
||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
|
||||
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
|
||||
/**
|
||||
* The train ran the length of the Division and left it. `side` is the Division Point it left by,
|
||||
* which is what the announcement needs — and every player scores 1 for it, so this event is
|
||||
* always followed by one `revenueChanged` per player.
|
||||
*/
|
||||
| { type: 'trainCompleted'; trainNumber: number; isExtra: boolean; side: 'east' | 'west'; consist: RollingStock[] }
|
||||
/** An Extra took a Crew Tray and started its run — at a Division Point, or at a Control Point. */
|
||||
/**
|
||||
* Carries the RESOLVED start and direction (`resolveExtraStart`), not the raw intent fields, so
|
||||
* the reducer never re-answers a question `check` already answered — the same shape as
|
||||
* `passengersBoarded` carrying its tray and coach index.
|
||||
*/
|
||||
| {
|
||||
type: 'extraStarted';
|
||||
player: PlayerIndex;
|
||||
trainNumber: number;
|
||||
at: ExtraStart;
|
||||
direction: Direction;
|
||||
}
|
||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock; trainNumber: number | null; isExtra: boolean }
|
||||
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId; trainNumber: number | null; isExtra: boolean }
|
||||
/**
|
||||
* A train was made up and the round could give it NOTHING THE CARD CALLS FOR — the Division Yard
|
||||
* holds no car of a category it still wants (§7, §8.2 "may depart with fewer").
|
||||
*
|
||||
* ITS OWN EVENT BECAUSE THE SILENCE WAS THE BUG (playtest, 2026-09-16: "train 5, the sparrow, has
|
||||
* no coaches, which seems strange"). `trainNeedingCars` returns null in exactly this case, so the
|
||||
* phase never stops, nobody is asked for a car, and the only trace was a MADE UP line promising
|
||||
* "now taking cars" with nothing after it. The train then ran the whole Division empty.
|
||||
*
|
||||
* CARRIES WHY, not just that. The shortage is a standing condition rather than a moment — §2.2
|
||||
* returns the Classification Yard only when the Division Yard runs bare — so the counts that
|
||||
* explain it have to travel with the event: what is still wanted, how many such cars are waiting
|
||||
* in Classification, and how far the Division Yard is from empty.
|
||||
*/
|
||||
| {
|
||||
type: 'makeUpShort';
|
||||
trainNumber: number;
|
||||
isExtra: boolean;
|
||||
/** How many cars it got, out of what the card calls for. */
|
||||
placed: number;
|
||||
wanted: number;
|
||||
/** The categories the card still wants and the Division Yard cannot supply. */
|
||||
missing: ('freight' | 'coach' | 'caboose')[];
|
||||
/** Cars of those categories sitting in the Classification Yard. */
|
||||
waiting: number;
|
||||
/** §2.2 — Classification comes back only when this reaches zero. */
|
||||
divisionYardHolds: number;
|
||||
}
|
||||
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
|
||||
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
|
||||
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
|
||||
// -- load / unload
|
||||
| { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord }
|
||||
| { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord }
|
||||
/**
|
||||
* `trayId` and `coachIndex` name the TRAIN and the COACH the Porter worked, rather than leaving the
|
||||
* reducer to find them again — the same lesson as `unloadBegan`'s `carIndex` below. Re-deriving
|
||||
* "the first empty coach on the first train at the Office" is how two trains standing at one
|
||||
* station both answered to one roster chip (v0.4.9d playtest), and how a coach the player had not
|
||||
* chosen got filled. Required, not optional: an event is a fact, and a fact that has to be looked
|
||||
* up against live state cannot render standalone in a replay.
|
||||
*/
|
||||
| { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord; trayId: TrayId; coachIndex: number }
|
||||
| { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord; trayId: TrayId; coachIndex: number }
|
||||
| { type: 'loadStarted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||
| { type: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number }
|
||||
| { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||
| { type: 'loadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||
/**
|
||||
* `carIndex` carries the SPECIFIC car the player picked off the industry track — see the comment
|
||||
* on `laborer.beginUnload` in `apply.ts` for why the reducer must not re-derive it.
|
||||
*/
|
||||
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType; carIndex: number }
|
||||
// -- consequences
|
||||
| { type: 'revenueChanged'; player: PlayerIndex; delta: number; total: number; reason: string }
|
||||
| { type: 'phaseEnded'; player: PlayerIndex; phase: string };
|
||||
| { type: 'phaseEnded'; player: PlayerIndex; phase: string }
|
||||
// -- §3.3, extended play (Gitea#11)
|
||||
/**
|
||||
* One seat's answer to "play one more Day?". Every seat votes; the vote is unanimous, and one
|
||||
* refusal ends it. In the log so that a table can see who is still being waited on, and who
|
||||
* called time.
|
||||
*/
|
||||
| { type: 'extensionVoted'; player: PlayerIndex; agree: boolean }
|
||||
/** The table agreed. `day` is the Day the extra one becomes — `config.days + extraDays`. */
|
||||
| { type: 'dayExtended'; day: number }
|
||||
/**
|
||||
* Play is over for good — somebody declined the extension.
|
||||
*
|
||||
* Distinct from the ending itself, which `checkVictory` already announced by way of the result: an
|
||||
* ending that COULD have been played past and was not is a decision the table made, and the log
|
||||
* should say so rather than simply stopping.
|
||||
*/
|
||||
| { type: 'playConcluded'; declinedBy: PlayerIndex }
|
||||
/** §11 (Gitea#5) — the district's owner answered the Yard Office offer. */
|
||||
| { type: 'yardOfficeRuled'; player: PlayerIndex; trainId: TrayId; take: boolean };
|
||||
|
||||
export type EventType = GameEvent['type'];
|
||||
|
||||
+197
-11
@@ -4,8 +4,8 @@
|
||||
* An intent is a PROPOSAL. It may be rejected. Contrast with an event (events.ts), which is a fact.
|
||||
*/
|
||||
|
||||
import type { CarType, Direction, Hand, OfficeTier, TrackGeometry } from './content.ts';
|
||||
import type { CardId, GridCoord, PlayerIndex, TrayId } from './state.ts';
|
||||
import type { CarType, Direction, OfficeTier } from './content.ts';
|
||||
import type { CardId, GridCoord, PlayerIndex, SeatIndex, TrayId } from './state.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local Operations Phase (§6) — a three-way exclusive choice
|
||||
@@ -13,10 +13,28 @@ import type { CardId, GridCoord, PlayerIndex, TrayId } from './state.ts';
|
||||
|
||||
export type LocalOpsOption = 'switch' | 'draw' | 'freightAgent';
|
||||
|
||||
/**
|
||||
* Where an Extra is placed when it is started (§7).
|
||||
*
|
||||
* `mainline` names a node index in `division.nodes` and is only ever an Interchange; `office` names
|
||||
* a SEAT, which is what an Office Area belongs to, and only ever a Control Point.
|
||||
*/
|
||||
export type ExtraStart =
|
||||
| { kind: 'divisionPoint'; side: Direction }
|
||||
| { kind: 'mainline'; node: number }
|
||||
| { kind: 'office'; seat: SeatIndex };
|
||||
|
||||
export type Intent =
|
||||
| { type: 'localOps.choose'; option: LocalOpsOption }
|
||||
// -- switch (§6.1, Appendix A)
|
||||
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean }
|
||||
/**
|
||||
* `via` names one INTERMEDIATE card on the chosen route, for the rare case where two routes to
|
||||
* `to` couple different cars (see docs/plans/switching-paths.md) — never the start, never `to`
|
||||
* itself. Absent, it resolves exactly as it always has: the first route `legal.ts` enumerated to
|
||||
* `to`. An index into the destination list would do the same job worse — intents are the
|
||||
* canonical record `undo` replays against, and enumeration order is not a stable thing to save.
|
||||
*/
|
||||
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean; via?: GridCoord }
|
||||
/**
|
||||
* Set out a cut. `fromNose` takes it off the FRONT of the train rather than the back — Appendix A's
|
||||
* worked example does exactly this: "Back up and drop off everything on the nose of your train
|
||||
@@ -29,7 +47,19 @@ export type Intent =
|
||||
* order, including cars in front of the engine". This is the designed answer to §A.3's
|
||||
* come-off-in-seated-order constraint, which is what makes facing-point work possible.
|
||||
*/
|
||||
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[] }
|
||||
/**
|
||||
* §Enhancements, Small Yard — one Move to re-make a train standing on the yard.
|
||||
*
|
||||
* `order` is a permutation of the current consist, nose first. `engineAt` is where the LOCOMOTIVE
|
||||
* ends up in it: 0 puts it back on the front, which is what the v0.4.5 card text describes and
|
||||
* what this action did unconditionally until 2026-09-17. `implications.md` records the design
|
||||
* source saying a train here "may sort itself into any order, including cars ahead of the engine",
|
||||
* and Jesse ruled that way — so it is a number now, and a train left nose-loaded is one §8.2 will
|
||||
* not let out of the Office until it is sorted again.
|
||||
*
|
||||
* Optional, defaulting to 0, so every save written before this replays exactly as it did.
|
||||
*/
|
||||
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[]; engineAt?: number }
|
||||
| { type: 'switch.end' }
|
||||
// -- draw (§6.2)
|
||||
| { type: 'draw.fromHomeOffice' }
|
||||
@@ -66,6 +96,32 @@ export type Intent =
|
||||
// -- New Train Phase (§7)
|
||||
| { type: 'newTrain.placeCar'; trayId: TrayId; carType: CarType; loaded: boolean }
|
||||
| { type: 'newTrain.passCar'; trayId: TrayId }
|
||||
/**
|
||||
* §7 — "the player who played the card may place the Crew Tray at EITHER Division Point for
|
||||
* immediate departure", plus Jesse's ruling on where else and which way.
|
||||
*
|
||||
* The number does not decide an Extra's direction — the START does. Either Division Point may be
|
||||
* chosen and the train runs away from it (west end runs east, east end runs west, since the other
|
||||
* reading is a train that leaves the Division having crossed nothing). At an Interchange or a
|
||||
* Control Point, which are in the middle of the railroad, both ways are real runs and `direction`
|
||||
* says which; it is required there and ignored at a Division Point.
|
||||
*
|
||||
* WHICH STARTS ARE OFFERED is the `extraStart` house rule (content.ts) — Division Points and the
|
||||
* Interchange always, Offices by setting.
|
||||
*
|
||||
* `atSeat` IS LEGACY AND WRITE-ONCE. Saves written before this choice existed carry only that
|
||||
* field: `null` meant "the Division Point this train's number sends it to" and a seat meant that
|
||||
* Office, both running in the number's direction. `start` absent is exactly what those saves said,
|
||||
* so they replay unchanged; everything written from now on carries `start` and `atSeat` is
|
||||
* omitted. `resolveExtraStart` (apply.ts) is the single place that reads either.
|
||||
*/
|
||||
| {
|
||||
type: 'newTrain.startExtra';
|
||||
trainNumber: number;
|
||||
atSeat?: SeatIndex | null;
|
||||
start?: ExtraStart;
|
||||
direction?: Direction;
|
||||
}
|
||||
/** Q9 — run a second, identical section behind a train that is due out this Stage. */
|
||||
| { type: 'newTrain.secondSection'; trainNumber: number }
|
||||
// -- Mainline Phase (§8.1) — the Superintendent's clearance ruling
|
||||
@@ -76,10 +132,24 @@ export type Intent =
|
||||
*/
|
||||
| { type: 'mainline.modify'; cardId: CardId; node: number }
|
||||
/**
|
||||
* Red Flags — protect a stopped train. The flagged train cannot be hit; an approaching train is
|
||||
* held instead of colliding.
|
||||
* §Q, RED FLAGS (Gitea#19) — plant a flag on one side of your own district.
|
||||
*
|
||||
* "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits from
|
||||
* that direction (i.e. Flag East holds westbound trains). You can do this if you see a problem or
|
||||
* wish to complete switching."
|
||||
*
|
||||
* `side` names the side of the district the flag goes on, so a train arriving from that side is
|
||||
* held. It REPLACES the old rule, which was played on a stopped train out on the Mainline and
|
||||
* protected it from a rear-ender: measured at 4,212 offers and 4 plays across 600 games, a
|
||||
* mechanic nobody used. ABS Signals already protects a train standing on a Mainline card.
|
||||
*/
|
||||
| { type: 'maneuver.redFlags'; cardId: CardId; trayId: TrayId }
|
||||
| { type: 'maneuver.redFlags'; cardId: CardId; side: Direction }
|
||||
/**
|
||||
* The same card, played OUT OF PHASE at the moment of danger (Gitea#19) — "COLLISION RISK! FLAG
|
||||
* AGAINST T2?". Answers a pending `redFlag` decision; `flag: false` declines and lets the
|
||||
* collision happen. The side is not asked for: the train is already coming from one.
|
||||
*/
|
||||
| { type: 'mainline.redFlag'; flag: boolean; cardId?: CardId }
|
||||
/**
|
||||
* Flying Switch — cut cars off behind the engine and roll them into an adjacent industry, without
|
||||
* the engine entering it.
|
||||
@@ -87,13 +157,53 @@ export type Intent =
|
||||
| { type: 'maneuver.flyingSwitch'; cardId: CardId; trayId: TrayId; count: number; to: GridCoord }
|
||||
| { type: 'redFlag.play' }
|
||||
// -- Load/Unload Phase (§9)
|
||||
| { type: 'porter.board'; at: GridCoord }
|
||||
| { type: 'porter.detrain'; at: GridCoord }
|
||||
/**
|
||||
* `trayId` names the train the Porter works — reported from playtesting v0.4.9d as "operating two
|
||||
* trains in a station, the select button does not work: regardless of which you pick, it is always
|
||||
* one train, not the other". It was: neither intent carried a train, so the reducer took the first
|
||||
* one on the A/D tracks and the roster chip the player had clicked changed nothing but the drawing.
|
||||
*
|
||||
* OPTIONAL, like `switch.move`'s `via` and for the same reason: intents are the canonical record
|
||||
* `undo` and every save replay against, and absent means what it has always meant — the first
|
||||
* eligible train at the Office.
|
||||
*/
|
||||
| { type: 'porter.board'; at: GridCoord; trayId?: TrayId }
|
||||
| { type: 'porter.detrain'; at: GridCoord; trayId?: TrayId }
|
||||
/** §9.3 — the first Laborer step: Green Loading Slot -> MEN. */
|
||||
| { type: 'laborer.startLoad'; at: GridCoord }
|
||||
| { type: 'laborer.advanceLoad'; at: GridCoord; box: number }
|
||||
| { type: 'laborer.beginUnload'; at: GridCoord; carIndex: number }
|
||||
| { type: 'loadUnload.end' };
|
||||
| { type: 'loadUnload.end' }
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — one vote on whether to play one more Day.
|
||||
*
|
||||
* ARRIVES OUT OF TURN, like `mainline.clearance`, and unlike it goes to EVERY seat rather than to
|
||||
* the Superintendent: it is a table decision, not a ruling. Unanimous, and one `agree: false`
|
||||
* ends the game immediately — nobody waits on a player who has already refused.
|
||||
*
|
||||
* It is an intent, rather than a button the client handles by itself, because a save is
|
||||
* `{ seed, config, history }` replayed through the engine: a decision that is not in the history
|
||||
* did not happen, and an extended game would evaporate on the next reload, Undo, or server
|
||||
* restart. This is the record of the table agreeing.
|
||||
*
|
||||
* CARRIES ITS VOTER, uniquely among intents, and it has to. A saved history is a flat `Intent[]`
|
||||
* with no seat recorded against each move: the replay DERIVES who acted from the turn order
|
||||
* (`fromMultiplayerSave`). That works for every other intent, including `mainline.clearance`,
|
||||
* because there is exactly one seat it could have been. Here there is not — every seat may vote,
|
||||
* in any order — so a vote whose voter is not written down cannot be replayed at all, and a
|
||||
* resumed server would refuse the save with `NO_ACTOR`. The server checks this against the seat
|
||||
* it authenticated (`NOT_YOUR_TURN`), so it is a record, never a claim.
|
||||
*/
|
||||
| { type: 'game.extend'; player: PlayerIndex; agree: boolean }
|
||||
/**
|
||||
* §11 (Gitea#5) — take the Yard Office, or the standard Office.
|
||||
*
|
||||
* Interrupts the Mainline Phase like `mainline.clearance`, and like it goes to one named player:
|
||||
* whoever sits in the district the train is arriving at. Offered only when a route exists, so
|
||||
* `take: true` always has somewhere to go — though it may still meet cars on the lead and crash,
|
||||
* which is the point of the rule.
|
||||
*/
|
||||
| { type: 'mainline.yardOffice'; take: boolean };
|
||||
|
||||
export type IntentType = Intent['type'];
|
||||
|
||||
@@ -136,6 +246,11 @@ export type RejectionCode =
|
||||
| 'ON_RUNNING_TRACK'
|
||||
/** A card with no east-west road would dead-end the Running Track it was laid in. */
|
||||
| 'BREAKS_RUNNING_TRACK'
|
||||
/**
|
||||
* §2.1 — track, and the Facilities that carry it, stay inside your own Limits at every row. A
|
||||
* Modifier is not track and is exempt (§9's nine spots), which is why this is not shared with it.
|
||||
*/
|
||||
| 'OUTSIDE_LIMITS'
|
||||
/**
|
||||
* A turnout may be laid on top of a straight, or of a curve on the same arc as its diverging leg
|
||||
* — but only those, and only while the square is idle. See `checkTurnoutUpgrade`.
|
||||
@@ -161,7 +276,78 @@ export type RejectionCode =
|
||||
| 'NOT_A_GRADE'
|
||||
| 'TRAIN_ON_CARD'
|
||||
| 'CONSIST_ORDER'
|
||||
| 'NO_TRAIN_AT_OFFICE';
|
||||
| 'NO_TRAIN_AT_OFFICE'
|
||||
/**
|
||||
* WHY PASSENGER WORK WAS REFUSED, told apart.
|
||||
*
|
||||
* All three used to come back as `NO_TRAIN_AT_OFFICE` — said while a train was standing at the
|
||||
* Office, which reads as a broken game rather than a rule. Measured over 60 solitaire games, the
|
||||
* fallback fired 51 times with a coach train in front of the player: 27 with nobody waiting to
|
||||
* travel, 24 with passengers waiting and every coach on the train already full.
|
||||
*/
|
||||
/**
|
||||
* §9.3 — a load may only start down the sign when an empty car of THAT type is standing on the
|
||||
* industry's track and is not already promised to another load. Walking cargo onto WORK with
|
||||
* nothing to put it in is "just dropping it onto the tracks".
|
||||
*
|
||||
* STOCKING THE GREEN BOX IS NOT GATED ON IT. §6.3 lets the Freight Agent stage a load whenever
|
||||
* the Division Yard has the car and the box has room; the empty car only has to be there before
|
||||
* the Laborers can move it out of the box.
|
||||
*/
|
||||
| 'NO_EMPTY_CAR_SPOTTED'
|
||||
/**
|
||||
* §9 (Jesse's ruling, v0.4.9e) — freight or passengers loaded anywhere in an Office Area may not
|
||||
* be unloaded anywhere in that same Office Area. The load has to be carried out of the district by
|
||||
* a train first; a Freight House may not break the load it just made, and passengers may not
|
||||
* detrain at the platform they boarded from.
|
||||
*
|
||||
* Distinct from the other refusals because the car IS loaded, the Laborer IS free and the boxes
|
||||
* ARE clear: the only thing wrong with it is where it came from, and a player looking at a loaded
|
||||
* boxcar standing on their own industry track deserves to be told that rather than "wrong car".
|
||||
*/
|
||||
| 'LOADED_IN_THIS_DISTRICT'
|
||||
| 'NO_PORTERS_HERE'
|
||||
| 'NO_PASSENGERS_WAITING'
|
||||
| 'NO_EMPTY_COACH'
|
||||
| 'NO_LOADED_COACH'
|
||||
| 'INBOUND_BOX_FULL'
|
||||
| 'NO_EMPTY_COACH_IN_YARD'
|
||||
| 'NOT_A_CONTROL_POINT'
|
||||
/**
|
||||
* A passenger Modifier — Waiting Area, Restaurant, Hotel — played at an Office that is still a
|
||||
* Whistle Post. A Whistle Post is not a Passenger Facility (§9), so it has nothing to add to.
|
||||
*/
|
||||
| 'OFFICE_NOT_PASSENGER'
|
||||
| 'NO_EXTRA_PENDING'
|
||||
/** §7 gives an Extra to the player who played the card; another seat may not place it for them. */
|
||||
| 'NOT_YOUR_EXTRA'
|
||||
| 'NO_FREE_TRAY'
|
||||
| 'NO_FREE_AD_TRACK'
|
||||
// -- §7, where an Extra may be started (`resolveExtraStart`)
|
||||
| 'NO_SUCH_DIVISION_POINT'
|
||||
/** Only the Interchange has a yard an Extra can be made up in. */
|
||||
| 'NOT_AN_INTERCHANGE'
|
||||
/** In the middle of the railroad both ways are real runs, so the intent has to say which. */
|
||||
| 'NO_DIRECTION_CHOSEN'
|
||||
/** The `extraStart` house rule is `divisionPointsOnly`. */
|
||||
| 'OFFICE_STARTS_NOT_ALLOWED'
|
||||
/** The `extraStart` house rule is `ownOffice` and this is somebody else's district. */
|
||||
| 'NOT_YOUR_OFFICE'
|
||||
/**
|
||||
* §6.2, Jesse's ruling (Gitea#6) — a train card is never discarded. Hold it as long as you like;
|
||||
* the only way it leaves your hand is onto the timetable.
|
||||
*/
|
||||
| 'TRAINS_ARE_NEVER_DISCARDED'
|
||||
/** §3.3 (Gitea#11) — `game.extend` when the game is not waiting on an extension vote. */
|
||||
| 'NOT_AWAITING_EXTENSION'
|
||||
/** §3.3 (Gitea#11) — this seat has already voted on this extension. */
|
||||
| 'ALREADY_VOTED'
|
||||
/** §11 (Gitea#5) — answering a Yard Office offer that is not open. */
|
||||
| 'NO_YARD_OFFICE_OFFER'
|
||||
/** §Q (Gitea#19) — answering a Red Flag prompt that is not open. */
|
||||
| 'NO_RED_FLAG_PROMPT'
|
||||
/** §Q (Gitea#19) — this district already has a flag on that side. */
|
||||
| 'ALREADY_FLAGGED';
|
||||
|
||||
export type Rejection = { code: RejectionCode; message: string };
|
||||
|
||||
|
||||
+285
-59
@@ -12,24 +12,186 @@
|
||||
* If you find yourself writing a rule here, it belongs in apply.ts.
|
||||
*/
|
||||
|
||||
import type { CarType, Hand, TrackGeometry } from './content.ts';
|
||||
import { enhancementRule } from './content.ts';
|
||||
import { check, areaOf, destinationsFor } from './apply.ts';
|
||||
import type { CarType } from './content.ts';
|
||||
import { enhancementRule, mainlineProfile } from './content.ts';
|
||||
import { check, areaOf, destinationsFor, withRouteCache } from './apply.ts';
|
||||
import type { Intent } from './intents.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
|
||||
import { coordKey, seatOf } from './state.ts';
|
||||
import type { MoveDestination } from './track.ts';
|
||||
import { variantsFor } from './track.ts';
|
||||
|
||||
/**
|
||||
* A square GridCoord this route passes through that no OTHER route to the same destination does —
|
||||
* the discriminator `switch.move.via` needs so `selectDestination` (apply.ts) never has to guess
|
||||
* which of two routes an intent meant. `siblings` is every route the same `destinationsFor` call
|
||||
* returned at this same square; `route` must be one of them.
|
||||
*
|
||||
* Absent when `route` is the only route there (the overwhelmingly common case — `via` need not be
|
||||
* carried at all), or when the destination is one hop away, which is never ambiguous: the square
|
||||
* immediately before a given entry port is fixed by geometry, so a zero-length path cannot have a
|
||||
* sibling.
|
||||
*/
|
||||
function distinguishingVia(route: MoveDestination, siblings: readonly MoveDestination[]): GridCoord | undefined {
|
||||
if (route.path.length === 0) return undefined;
|
||||
const otherSquares = new Set<string>();
|
||||
for (const sibling of siblings) {
|
||||
if (sibling === route) continue;
|
||||
for (const step of sibling.path) otherSquares.add(coordKey(step.coord));
|
||||
}
|
||||
for (const step of route.path) {
|
||||
if (!otherSquares.has(coordKey(step.coord))) return step.coord;
|
||||
}
|
||||
// Every square on this route is shared with some sibling — geometrically possible only if the
|
||||
// routes fork and rejoin more than once, which `track.ts`'s doc comment says not to rely on not
|
||||
// happening. Naming the last square before the destination at least matches today's behaviour:
|
||||
// wrong in the same way a route with no `via` at all would be.
|
||||
return route.path[route.path.length - 1]!.coord;
|
||||
}
|
||||
|
||||
const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose'];
|
||||
|
||||
/** Every intent `player` may legally submit right now. */
|
||||
export function legalActions(s: GameState, player: PlayerIndex): Intent[] {
|
||||
return candidates(s, player).filter((i) => check(s, player, i) === null);
|
||||
// One position, examined many times over: its routes are walked once (`withRouteCache`).
|
||||
return withRouteCache(s, () => candidates(s, player).filter((i) => check(s, player, i) === null));
|
||||
}
|
||||
|
||||
export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean {
|
||||
return check(s, player, i) === null;
|
||||
/**
|
||||
* §6.1 — the switching half of the Local Operations candidates, in the order `legalActions` offers
|
||||
* them. Split out so the switching planner (`sim/switch-planner.ts`) can ask for just these without
|
||||
* `check` running over every draw and Freight Agent candidate at each of the thousands of positions it
|
||||
* tries — that was about a quarter of all planning time. Still no rules here: `check` decides.
|
||||
*/
|
||||
function switchCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const out: Intent[] = [];
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
const dests = destinationsFor(s, player, trayId, from, reverse);
|
||||
// Grouped by destination square so `distinguishingVia` only ever compares routes that are
|
||||
// actually racing for the same button — two routes to DIFFERENT squares need no `via` to
|
||||
// tell apart, `to` already does that.
|
||||
const byCoord = new Map<string, MoveDestination[]>();
|
||||
for (const d of dests) {
|
||||
const k = coordKey(d.coord);
|
||||
(byCoord.get(k) ?? byCoord.set(k, []).get(k)!).push(d);
|
||||
}
|
||||
for (const group of byCoord.values()) {
|
||||
for (const d of group) {
|
||||
const via = group.length > 1 ? distinguishingVia(d, group) : undefined;
|
||||
out.push({ type: 'switch.move', trayId, to: d.coord, reverse, ...(via ? { via } : {}) });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let n = 1; n <= tray.consist.length; n++) {
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n });
|
||||
// Off the nose as well as the tail — the only way to get cars back off the front of a train
|
||||
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
|
||||
}
|
||||
/**
|
||||
* Small Yard: enumerating every permutation would explode, so offer the useful ones — bringing
|
||||
* each car to the droppable end, plus a full reversal. `check` validates any order, so a UI may
|
||||
* submit an arbitrary permutation.
|
||||
*
|
||||
* NOTHING THAT RE-ORDERS NOTHING. Bringing the LAST car to the end is the identity, and a
|
||||
* two-car train's reversal repeats its only real option — so the menu carried a move that spent
|
||||
* one of six Moves to leave the train exactly as it was, beside a duplicate of the move next to
|
||||
* it. Both were invisible while the labels were index lists (playtest, 2026-09-17); both are
|
||||
* plainly wrong once the label reads as a train. Filtered by the ORDER rather than by the case
|
||||
* that produced it, so a new generator cannot reintroduce either.
|
||||
*/
|
||||
const n = tray.consist.length;
|
||||
const identity = [...Array(n).keys()];
|
||||
if (n > 1) {
|
||||
const orders: number[][] = [];
|
||||
for (let k = 0; k < n; k++) {
|
||||
const order = identity.filter((x) => x !== k);
|
||||
order.push(k);
|
||||
orders.push(order);
|
||||
}
|
||||
orders.push([...identity].reverse());
|
||||
// Nothing that re-orders nothing: the current train is the one thing on offer that costs a
|
||||
// Move and changes the board not at all. Keyed by (order, engine position) together, since
|
||||
// since 2026-09-17 the same car order at a different engine position is a different train.
|
||||
const seen = new Set<string>([`${identity.join(',')}|${tray.engineAt}`]);
|
||||
const offer = (order: number[], engineAt: number): void => {
|
||||
const key = `${order.join(',')}|${engineAt}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order, engineAt });
|
||||
};
|
||||
// The car orders, each leaving the engine on the nose — the Small Yard's ordinary use.
|
||||
for (const order of orders) offer(order, 0);
|
||||
/**
|
||||
* A MADE-UP ORDER IS ALWAYS AMONG THESE, which is worth saying because it looks as though it
|
||||
* might not be (Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the
|
||||
* back").
|
||||
*
|
||||
* A yard sort serves two errands — pulling one car out to an end so it can be spotted, and
|
||||
* putting the train back together to leave — and the orders above are written for the first.
|
||||
* They cover the second as a by-product: "bring car k to the tail" is enumerated for EVERY car,
|
||||
* so bringing the CABOOSE to the tail is always one of them, and with the engine on the nose
|
||||
* that is a train §8.2 will let out of the Office.
|
||||
*
|
||||
* An explicit "make it up to leave" option was written here and deleted: it produced exactly
|
||||
* the k-is-the-caboose order and was dropped by the dedupe every time. The one case where no
|
||||
* made-up order appears is a train that is ALREADY made up, where such an option would be the
|
||||
* identity — and the labels say which is which, so a player can see that every offer would
|
||||
* break a train that is currently fit to run.
|
||||
*/
|
||||
}
|
||||
/**
|
||||
* WHERE THE ENGINE GOES, as its own short list rather than multiplied through the one above
|
||||
* (Jesse's call, 2026-09-17: "a separate engine control").
|
||||
*
|
||||
* Offering every car order at every engine position is the honest enumeration and it is
|
||||
* unreadable: a four-car consist would go from four options to twenty, which is the labelling
|
||||
* problem that prompted all of this. So the engine positions are offered against the consist AS
|
||||
* IT STANDS — pick an order, or pick where the engine sits, each one Move. A player who wants
|
||||
* both spends two, which is the same price the yard charges for any second sort.
|
||||
*
|
||||
* OFFERED FOR A ONE-CAR TRAIN TOO, unlike the car orders: a single car ahead of the engine or
|
||||
* behind it is exactly the difference between shoving it into a facing industry and pulling it.
|
||||
*/
|
||||
if (n >= 1) {
|
||||
const seenEngine = new Set<string>([`${identity.join(',')}|${tray.engineAt}`]);
|
||||
for (let k = 0; k <= n; k++) {
|
||||
const key = `${identity.join(',')}|${k}`;
|
||||
if (seenEngine.has(key)) continue;
|
||||
seenEngine.add(key);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order: identity, engineAt: k });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
const k = s.cards.get(cardId)?.kind;
|
||||
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
|
||||
for (let count = 1; count <= tray.consist.length; count++) {
|
||||
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to: d.coord });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push({ type: 'switch.end' });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The switching intents `player` may legally submit right now — exactly `legalActions`' switching subset. */
|
||||
export function legalSwitchingActions(s: GameState, player: PlayerIndex): Intent[] {
|
||||
return withRouteCache(s, () => switchCandidates(s, player).filter((i) => check(s, player, i) === null));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Candidate generation. Over-generates freely — `check` is the authority, so a candidate that
|
||||
* turns out to be illegal simply gets filtered. Being generous here is what stops this module
|
||||
@@ -38,18 +200,44 @@ export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean {
|
||||
function candidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const out: Intent[] = [];
|
||||
|
||||
// The clearance ruling arrives out of turn order and goes to the Superintendent (§8.1).
|
||||
if (s.clock.pendingDecision !== null) {
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — the only thing on offer when the timetable has run out and the
|
||||
* table is being asked whether to play on.
|
||||
*
|
||||
* Returned EARLY rather than added to the list, because nothing else is legal in this state and
|
||||
* the phase switch below would otherwise generate a boardful of candidates for `check` to reject
|
||||
* one at a time. It also puts the vote in front of the bot driver through the ordinary path, which
|
||||
* is what lets a bot seat answer without the engine having to know which seats are bots.
|
||||
*/
|
||||
if (s.status === 'awaitingExtension') {
|
||||
if (s.extensionVotes[player] === null) {
|
||||
out.push({ type: 'game.extend', player, agree: true });
|
||||
out.push({ type: 'game.extend', player, agree: false });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The two interruptions of the Mainline Phase. Each goes to one named player — `check` is the
|
||||
// authority on which — so both are generated here and filtered there.
|
||||
if (s.clock.pendingDecision?.kind === 'clearance') {
|
||||
out.push({ type: 'mainline.clearance', allow: true });
|
||||
out.push({ type: 'mainline.clearance', allow: false });
|
||||
}
|
||||
if (s.clock.pendingDecision?.kind === 'yardOffice') {
|
||||
out.push({ type: 'mainline.yardOffice', take: true });
|
||||
out.push({ type: 'mainline.yardOffice', take: false });
|
||||
}
|
||||
if (s.clock.pendingDecision?.kind === 'redFlag') {
|
||||
out.push({ type: 'mainline.redFlag', flag: true });
|
||||
out.push({ type: 'mainline.redFlag', flag: false });
|
||||
}
|
||||
|
||||
switch (s.clock.phase) {
|
||||
case 'localOps':
|
||||
out.push(...localOpsCandidates(s, player));
|
||||
break;
|
||||
case 'newTrain':
|
||||
out.push(...newTrainCandidates(s));
|
||||
out.push(...newTrainCandidates(s, player));
|
||||
break;
|
||||
case 'loadUnload':
|
||||
out.push(...loadUnloadCandidates(s, player));
|
||||
@@ -63,7 +251,8 @@ function candidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
const k = s.cards.get(cardId)?.kind;
|
||||
if (k?.kind !== 'maneuver' || k.key !== 'redFlags') continue;
|
||||
for (const [trayId] of s.trays) out.push({ type: 'maneuver.redFlags', cardId, trayId });
|
||||
// §Q (Gitea#19) — a flag goes on one side of your own district, so the only choice is which.
|
||||
for (const side of ['east', 'west'] as const) out.push({ type: 'maneuver.redFlags', cardId, side });
|
||||
}
|
||||
|
||||
out.push({ type: 'redFlag.play' });
|
||||
@@ -81,50 +270,7 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const area = areaOf(s, player);
|
||||
|
||||
// -- switch (§6.1)
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
|
||||
out.push({ type: 'switch.move', trayId, to: d.coord, reverse });
|
||||
}
|
||||
}
|
||||
for (let n = 1; n <= tray.consist.length; n++) {
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n });
|
||||
// Off the nose as well as the tail — the only way to get cars back off the front of a train
|
||||
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
|
||||
}
|
||||
// Small Yard: enumerating every permutation would explode, so offer the useful ones —
|
||||
// bringing each car to the droppable end, plus a full reversal. `check` validates any order,
|
||||
// so a UI may submit an arbitrary permutation.
|
||||
const n = tray.consist.length;
|
||||
if (n > 1) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
const order = [...Array(n).keys()].filter((x) => x !== k);
|
||||
order.push(k);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order });
|
||||
}
|
||||
out.push({ type: 'switch.sortConsist', trayId, order: [...Array(n).keys()].reverse() });
|
||||
}
|
||||
}
|
||||
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
const k = s.cards.get(cardId)?.kind;
|
||||
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
|
||||
for (let count = 1; count <= tray.consist.length; count++) {
|
||||
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to: d.coord });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push({ type: 'switch.end' });
|
||||
out.push(...switchCandidates(s, player));
|
||||
|
||||
// -- draw (§6.2)
|
||||
out.push({ type: 'draw.fromHomeOffice' });
|
||||
@@ -164,12 +310,19 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
*
|
||||
* Deduplicated because the two lists overlap: `placements` already contains the Limits signs.
|
||||
*/
|
||||
/**
|
||||
* A TURNOUT AND AN INDUSTRY MAY BOTH BUILD OVER TRACK ALREADY DOWN, so both are offered the
|
||||
* occupied cells as well as the empty ones and `check` decides which it will take. Without this
|
||||
* the rule exists in the engine and is never once presented — which is how the 18 Enhancement
|
||||
* cards came to be permanently dead.
|
||||
*/
|
||||
const isTurnout = kind?.kind === 'track' && kind.geometry === 'turnout';
|
||||
const isFacility = kind?.kind === 'freightFacility';
|
||||
const targets = onMainline
|
||||
? []
|
||||
: kind?.kind === 'enhancement'
|
||||
? attachments
|
||||
: isTurnout
|
||||
: isTurnout || isFacility
|
||||
? dedupe([...placements, ...attachments])
|
||||
: placements;
|
||||
// Orientation is chosen on placement, and a printed card turns but never flips, so the widest
|
||||
@@ -215,7 +368,7 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function newTrainCandidates(s: GameState): Intent[] {
|
||||
function newTrainCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const out: Intent[] = [];
|
||||
for (const [trayId] of s.trays) {
|
||||
for (const carType of CAR_TYPES) {
|
||||
@@ -226,7 +379,38 @@ function newTrainCandidates(s: GameState): Intent[] {
|
||||
}
|
||||
const due = s.timetable[s.clock.stage - 1];
|
||||
if (due !== null && due !== undefined) {
|
||||
out.push({ type: 'newTrain.secondSection', trainNumber: due });
|
||||
// Q9 — the order costs the card, so it is only a candidate while the card is in hand.
|
||||
// `check` is still the authority; this keeps the menu from offering what it will refuse.
|
||||
if ((s.decks.hands.get(player) ?? []).some((id) => s.cards.get(id)?.kind.kind === 'secondSection')) {
|
||||
out.push({ type: 'newTrain.secondSection', trainNumber: due });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* WHERE A PENDING EXTRA MAY START (§7, Jesse's ruling) — every candidate offered, with `check`
|
||||
* doing the filtering, so "is this a Control Point" and "does the house rule allow it" have one
|
||||
* implementation each rather than two.
|
||||
*
|
||||
* BOTH Division Points, not the one the number dictates: an Extra's direction comes from where it
|
||||
* is placed. In the middle of the railroad — an Interchange, a Control Point — both ways are real
|
||||
* runs, so those are offered twice, once per direction.
|
||||
*/
|
||||
// Only the player who played it is offered anywhere to put it (§7) — `check` refuses anyone else
|
||||
// with NOT_YOUR_EXTRA, and offering options that are certain to be refused is how a menu lies.
|
||||
for (const { trainNumber } of s.pendingExtras.filter((x) => x.player === player)) {
|
||||
for (const side of ['west', 'east'] as const) {
|
||||
out.push({ type: 'newTrain.startExtra', trainNumber, start: { kind: 'divisionPoint', side } });
|
||||
}
|
||||
for (const [node, n] of s.division.nodes.entries()) {
|
||||
if (n.kind !== 'mainline' || !mainlineProfile(n.card).sortsCars) continue;
|
||||
for (const direction of ['west', 'east'] as const) {
|
||||
out.push({ type: 'newTrain.startExtra', trainNumber, start: { kind: 'mainline', node }, direction });
|
||||
}
|
||||
}
|
||||
for (const seat of s.officeAreas.keys()) {
|
||||
for (const direction of ['west', 'east'] as const) {
|
||||
out.push({ type: 'newTrain.startExtra', trainNumber, start: { kind: 'office', seat }, direction });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -235,9 +419,21 @@ function loadUnloadCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const out: Intent[] = [];
|
||||
const area = areaOf(s, player);
|
||||
|
||||
/**
|
||||
* ONE OPTION PER TRAIN STANDING AT THE OFFICE, not one per square.
|
||||
*
|
||||
* Reported from playtesting v0.4.9d: "operating two trains in a station, the select button does
|
||||
* not work — regardless of which you pick, it is always one train, not the other". There was only
|
||||
* ever ONE `board passengers` button, because the intent carried no train; the roster chip chose
|
||||
* what the board drew and nothing else. Now each eligible train is its own candidate, and `check`
|
||||
* filters the ones whose card, consist or passengers rule them out.
|
||||
*/
|
||||
const traysHere = area.adOccupancy.filter((id) => s.trays.has(id));
|
||||
for (const coord of facilityCoords(s, player)) {
|
||||
out.push({ type: 'porter.board', at: coord });
|
||||
out.push({ type: 'porter.detrain', at: coord });
|
||||
for (const trayId of traysHere) {
|
||||
out.push({ type: 'porter.board', at: coord, trayId });
|
||||
out.push({ type: 'porter.detrain', at: coord, trayId });
|
||||
}
|
||||
const f = area.grid.get(`${coord.row},${coord.col}`)?.facility;
|
||||
if (f) {
|
||||
out.push({ type: 'laborer.startLoad', at: coord });
|
||||
@@ -301,5 +497,35 @@ function placementCandidates(s: GameState, player: PlayerIndex): GridCoord[] {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* THE NINE-SPOT MODIFIER NEIGHBOURHOOD (§9), which the four-square walk above cannot reach.
|
||||
*
|
||||
* A Modifier goes "adjacent to a Facility, on any of the nine nearby spots" — diagonals included —
|
||||
* and `check` has always accepted all eight. It was the CANDIDATES that were orthogonal-only, so a
|
||||
* diagonal square with no orthogonal neighbour was legal and never offered: reported by Jesse as
|
||||
* being unable to place a Modifier to the south-east of his industry.
|
||||
*
|
||||
* Only around a Facility, and only in a second pass. Track has to JOIN, and a diagonal shares no
|
||||
* edge, so a Modifier is the only card these squares can ever take — generating diagonals around
|
||||
* every card instead costs 54% more simulation time for candidates `check` then rejects. Appending
|
||||
* rather than interleaving leaves the existing order untouched, which matters because the bot
|
||||
* breaks ties by first-best: measured over 200 seeded games, 0 played differently.
|
||||
*/
|
||||
for (const [key, card] of area.grid) {
|
||||
if (!card.facility) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
for (const c of [
|
||||
{ row: row! + 1, col: col! + 1 },
|
||||
{ row: row! + 1, col: col! - 1 },
|
||||
{ row: row! - 1, col: col! + 1 },
|
||||
{ row: row! - 1, col: col! - 1 },
|
||||
]) {
|
||||
const k = `${c.row},${c.col}`;
|
||||
if (seen.has(k) || area.grid.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -67,9 +67,3 @@ export function createRng(seed: number): Rng {
|
||||
};
|
||||
}
|
||||
|
||||
/** Restore an RNG mid-stream, for rebuilding a game from a snapshot. */
|
||||
export function restoreRng(state: number): Rng {
|
||||
// mulberry32 advances from its state before drawing, and createRng seeds state directly,
|
||||
// so restoring is just seeding with the saved state.
|
||||
return createRng(state);
|
||||
}
|
||||
|
||||
+178
-67
@@ -16,24 +16,26 @@ import {
|
||||
MOVES_PER_LOCAL_OPS,
|
||||
MODIFIER_PROFILES,
|
||||
OFFICE_PROFILES,
|
||||
OPENING_OTHER,
|
||||
OPENING_TRACK,
|
||||
MAINLINE_PROFILES,
|
||||
mainlineProfile,
|
||||
OPENING_DEALS,
|
||||
MAINLINE_DECK,
|
||||
houseRules,
|
||||
TRACK_CARDS,
|
||||
ROLLING_STOCK_SUPPLY,
|
||||
STAGES_PER_DAY,
|
||||
SECOND_SECTION,
|
||||
TIMETABLED_TRAINS,
|
||||
crewTrayCount,
|
||||
mainlineCardCount,
|
||||
officeProfile,
|
||||
} from './content.ts';
|
||||
import type { StartingOffice } from './content.ts';
|
||||
import type { Rng } from './rng.ts';
|
||||
import { createRng } from './rng.ts';
|
||||
import type {
|
||||
Card,
|
||||
CardId,
|
||||
DivisionNode,
|
||||
SeatIndex,
|
||||
GameConfig,
|
||||
GameState,
|
||||
OfficeArea,
|
||||
@@ -42,7 +44,7 @@ import type {
|
||||
TrackCard,
|
||||
TrayId,
|
||||
} from './state.ts';
|
||||
import { coordKey, freshTurn } from './state.ts';
|
||||
import { coordKey, emptyTally, freshTurns } from './state.ts';
|
||||
|
||||
export type SetupOptions = {
|
||||
id: string;
|
||||
@@ -52,21 +54,32 @@ export type SetupOptions = {
|
||||
};
|
||||
|
||||
/** Builds the 52-card Home Office deck (§12.1). Unshuffled; caller shuffles with the seeded RNG. */
|
||||
export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
|
||||
export function buildDeck(
|
||||
mode: GameConfig['mode'] = 'competitive',
|
||||
pvpCardsAllowed = false,
|
||||
startingOffice: StartingOffice = 'whistlePost',
|
||||
/** False only for a save that predates the card — see `HouseRules.secondSectionCard`. */
|
||||
secondSectionCard = true,
|
||||
): Card[] {
|
||||
/**
|
||||
* THE 22 OPPONENT-DIRECTED CARDS ARE OUT OF EVERY DECK FOR NOW, not just the solitaire one.
|
||||
* THE 22 OPPONENT-DIRECTED CARDS ARE OUT OF EVERY DECK REGARDLESS OF `pvpCardsAllowed`, for now.
|
||||
*
|
||||
* Q6 took them out of solitaire because they have no legal target in a one-player game. They are
|
||||
* out of the competitive deck too because `checkPlay` answers both categories `NOT_IMPLEMENTED`:
|
||||
* leaving them in would make ~9% of draws reject outright, which is worse than not dealing them.
|
||||
* Three Enhancements — Facing Point Locks, Water Column and Overpass — exist only to answer these,
|
||||
* and stay dormant until they come back. Recorded in TODO.md as multiplayer work.
|
||||
* `pvpCardsAllowed` (`GameConfig`, wired 2026-08-20) is the player-facing toggle, but ANDing it
|
||||
* with `cardsImplemented` keeps it inert until Phase 5 actually builds the cards — flipping
|
||||
* `pvpCardsAllowed` on today must not turn a working game into one where ~9% of draws reject.
|
||||
*
|
||||
* AND SO ARE THE SEVEN CARDS THAT ANSWER THEM — see below. They used to be dealt and sit dormant,
|
||||
* which is the same dead draw by another name. Recorded in TODO.md as multiplayer work.
|
||||
*
|
||||
* `mode` is still taken so the signature does not move when they return; it is deliberately unused
|
||||
* for these two categories today.
|
||||
*/
|
||||
void mode;
|
||||
const opponentCardsInDeck = false;
|
||||
const cardsImplemented = false;
|
||||
const opponentCardsInDeck = pvpCardsAllowed && cardsImplemented;
|
||||
const cards: Card[] = [];
|
||||
let n = 0;
|
||||
const push = (kind: Card['kind']): void => {
|
||||
@@ -75,7 +88,16 @@ export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
|
||||
|
||||
for (const t of TIMETABLED_TRAINS) push({ kind: 'timetabledTrain', number: t.number });
|
||||
for (const t of EXTRA_TRAINS) push({ kind: 'extraTrain', number: t.number });
|
||||
/**
|
||||
* OFFICE UPGRADES, MINUS THE TIER EVERYBODY ALREADY HAS.
|
||||
*
|
||||
* A table that starts on Depots has no use for a Depot card: `check` refuses it, because an
|
||||
* upgrade must be to the NEXT tier and a Depot is not an upgrade on a Depot. Leaving them in
|
||||
* would deal four dead cards into a 90-odd card deck — the same dead draw the opponent-directed
|
||||
* cards are held out for. Station and Terminal are still upgrades and stay.
|
||||
*/
|
||||
for (const o of OFFICE_PROFILES) {
|
||||
if (o.tier === startingOffice) continue;
|
||||
for (let i = 0; i < o.copiesInDeck; i++) push({ kind: 'office', tier: o.tier });
|
||||
}
|
||||
for (const f of FREIGHT_PROFILES) {
|
||||
@@ -84,15 +106,39 @@ export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
|
||||
for (const m of MODIFIER_PROFILES) {
|
||||
for (let i = 0; i < m.copies; i++) push({ kind: 'modifier', modifier: m.kind });
|
||||
}
|
||||
/**
|
||||
* Q9 — THE SECOND SECTION CARD, WHICH WAS DECLARED AND NEVER DEALT.
|
||||
*
|
||||
* `content.ts` has carried `SECOND_SECTION` (1 copy) all along and `setup.ts` never built it into
|
||||
* the deck, while `newTrain.secondSection` was offered free on every train due out — so the one
|
||||
* card that is supposed to gate the action did not exist and the action cost nothing. The bot ran
|
||||
* 26 accidental Second Sections in one measured round because of it.
|
||||
*
|
||||
* Jesse's ruling, 2026-09-23: the action requires the card. Dealing it is the other half — gating
|
||||
* on a card the deck never holds would delete the mechanic rather than fix it.
|
||||
*/
|
||||
if (secondSectionCard) for (let i = 0; i < SECOND_SECTION.copies; i++) push({ kind: 'secondSection' });
|
||||
if (opponentCardsInDeck) {
|
||||
for (const c of SPACE_USE_CARDS) {
|
||||
for (let i = 0; i < c.copies; i++) push({ kind: 'spaceUse', key: c.key });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* A DEFENCE GOES OUT WITH THE ATTACK IT ANSWERS.
|
||||
*
|
||||
* Facing Point Locks (both kinds), the Water Column and the Overpass exist only to answer Derail,
|
||||
* a Watertower and a Railroad Crossing — all of which are among the 22 opponent-directed cards
|
||||
* held out above. Left in, they are dead draws for exactly the same reason the attacks would be,
|
||||
* and there are 7 of them. `SimpleCard.answers` names the pairing on the card itself, so they come
|
||||
* back together the moment `opponentCardsInDeck` does.
|
||||
*/
|
||||
const dealt = (c: { answers?: string }): boolean => opponentCardsInDeck || !c.answers;
|
||||
for (const c of ENHANCEMENT_CARDS) {
|
||||
if (!dealt(c)) continue;
|
||||
for (let i = 0; i < c.copies; i++) push({ kind: 'enhancement', key: c.key });
|
||||
}
|
||||
for (const c of MAINLINE_MODIFIER_CARDS) {
|
||||
if (!dealt(c)) continue;
|
||||
for (let i = 0; i < c.copies; i++) push({ kind: 'mainlineModifier', key: c.key });
|
||||
}
|
||||
for (const c of MANEUVER_CARDS) {
|
||||
@@ -129,7 +175,7 @@ export function buildRollingStock(): RollingStock[] {
|
||||
* branch from the opening Stage. The stubs are NOT turnouts: §A.1's directional rule governs
|
||||
* drawn turnout cards only.
|
||||
*/
|
||||
function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
||||
function buildOfficeArea(seat: SeatIndex, tier: StartingOffice): OfficeArea {
|
||||
const row = 0;
|
||||
const officeCoord = { row, col: 0 };
|
||||
const limitsWest = { row, col: -1 };
|
||||
@@ -139,7 +185,8 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
||||
geometry: { kind: 'office' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: buildPassengerFacility('whistlePost'),
|
||||
standingWest: 0,
|
||||
facility: buildPassengerFacility(tier),
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
@@ -148,6 +195,7 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -159,8 +207,8 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
||||
grid.set(coordKey(limitsEast), limitsCard());
|
||||
|
||||
return {
|
||||
owner,
|
||||
tier: 'whistlePost',
|
||||
seat,
|
||||
tier,
|
||||
grid,
|
||||
officeCoord,
|
||||
runningRow: row,
|
||||
@@ -190,7 +238,7 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
|
||||
// WORK sign is printed "For Freight Facilities" (rules-v0.2.md:452). This was a three-slot array
|
||||
// of nulls, which read as "three empty boxes" to every renderer that looked.
|
||||
menAtWork: null,
|
||||
industryTrack: { length: 0, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 0,
|
||||
porters: p.porters,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
@@ -204,23 +252,41 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
|
||||
* placed between each player"), which is what gives the Division its terrain and therefore its
|
||||
* crossing times.
|
||||
*/
|
||||
/**
|
||||
* THE MAINLINE CARDS ARE DEALT FROM A DECK, NOT ROLLED.
|
||||
*
|
||||
* `MAINLINE_PROFILES` is a list of card TYPES and this drew from it uniformly WITH replacement, so
|
||||
* a Division could be handed two Interchanges or two Tunnels, and Plains — printed twice in the
|
||||
* deck — carried the same weight as cards printed once. `MAINLINE_DECK` is the printed inventory
|
||||
* (`docs/mainline-deck.md`, which flagged this as needing correction), and the
|
||||
* deal is now a deal: take cards out of it and do not put them back.
|
||||
*
|
||||
* The Extra-start rules are what forced the issue. "An Extra may start at the Interchange if one is
|
||||
* on the board" only reads as a rule if the board can hold at most one.
|
||||
*
|
||||
* A Division needs `players + 1` cards, so four players draw five from ten and the deck is never
|
||||
* close to exhausted; the throw is there because a silent short Division would be very hard to see.
|
||||
*/
|
||||
function buildDivision(players: number, rng: Rng): DivisionNode[] {
|
||||
const nodes: DivisionNode[] = [];
|
||||
const kinds = MAINLINE_PROFILES.map((m) => m.kind);
|
||||
const deck = [...MAINLINE_DECK];
|
||||
const mainline = (): DivisionNode => {
|
||||
const card = kinds[rng.nextInt(kinds.length)]!;
|
||||
if (deck.length === 0) throw new Error('the Mainline deck ran out — too many players for it');
|
||||
const card = deck.splice(rng.nextInt(deck.length), 1)[0]!;
|
||||
const node: DivisionNode = { kind: 'mainline', card, transits: [] };
|
||||
// The Heavy Grade card says "Player sets orientation", but setup has no decision point yet —
|
||||
// createGame is synchronous and returns a ready state. Rolled for now so the orientation is at
|
||||
// least deterministic and varies between games; it should become a real player choice when
|
||||
// setup gains an interactive phase. See implications.md §10 Q11.
|
||||
if (mainlineProfile(card).speed.kind === 'grade') {
|
||||
if (card === 'heavyGrade') {
|
||||
/**
|
||||
* TEMPORARY, and flagged in TODO. The card prints "(Up)" and "Player sets orientation", so
|
||||
* which way a Heavy Grade climbs is the player's decision — but it is dealt during setup, and
|
||||
* setup has no decision point at all. Rolled from the seed until it gains one, because the
|
||||
* orientation MATTERS: it decides which direction climbs, and therefore what a Brakeman or
|
||||
* Helpers card is worth.
|
||||
* SETTLED, not provisional (v0.5.0, Jesse's call) — this overrides the card's own printed
|
||||
* "Player sets orientation". A Heavy Grade sits on the shared west-to-east chain BETWEEN two
|
||||
* players (§4.2), or beyond an end Division Point next to one — never inside one player's own
|
||||
* district — so whichever direction climbs advantages one neighbour over the other, and there
|
||||
* is no single player who owns that call fairly. Orientation is rolled from the seed instead,
|
||||
* identically for solitaire and multiplayer, and this is not expected to change when setup
|
||||
* eventually gains an interactive phase for other decisions.
|
||||
*
|
||||
* RE-CONFIRMED 2026-08-23, when the question was raised again and giving the choice to the
|
||||
* Superintendent was considered and rejected — the office rotates every three Stages, the
|
||||
* advantage it would hand out does not. See implications.md §10 Q11.
|
||||
*/
|
||||
node.gradeUp = rng.nextInt(2) === 0 ? 'east' : 'west';
|
||||
}
|
||||
@@ -230,7 +296,7 @@ function buildDivision(players: number, rng: Rng): DivisionNode[] {
|
||||
nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] });
|
||||
for (let p = 0; p < players; p++) {
|
||||
nodes.push(mainline());
|
||||
nodes.push({ kind: 'office', owner: p });
|
||||
nodes.push({ kind: 'office', seat: p });
|
||||
}
|
||||
nodes.push(mainline());
|
||||
nodes.push({ kind: 'divisionPoint', side: 'east', holding: [] });
|
||||
@@ -247,64 +313,101 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
|
||||
const rng = createRng(seed);
|
||||
const playerCount = playerNames.length;
|
||||
// Resolved once, here, because it decides BOTH the Office every area is built on and which office
|
||||
// cards the deck holds — and the two must not be able to disagree.
|
||||
const startingOffice = houseRules(config).startingOffice;
|
||||
|
||||
const players = playerNames.map((name, index) => ({ index, name, revenue: 0 }));
|
||||
|
||||
const officeAreas = new Map<PlayerIndex, OfficeArea>();
|
||||
for (let p = 0; p < playerCount; p++) officeAreas.set(p, buildOfficeArea(p));
|
||||
|
||||
// Offices are keyed by SEAT — a fixed position in the west-to-east chain. `seating` maps seats to
|
||||
// the players occupying them, and starts as the identity mapping, which is what makes the
|
||||
// seat/player split behaviour-neutral. Employee Rotation would rotate this array and nothing else.
|
||||
const officeAreas = new Map<SeatIndex, OfficeArea>();
|
||||
for (let seat = 0; seat < playerCount; seat++) officeAreas.set(seat, buildOfficeArea(seat, startingOffice));
|
||||
// §4.4 - highest D12 takes the Eastern Division Point; §4.5 - highest begins as Superintendent.
|
||||
// Both rolls are drawn even in solitaire so the RNG stream stays identical across player counts.
|
||||
const divisionRolls = players.map(() => rng.d12());
|
||||
const superRolls = players.map(() => rng.d12());
|
||||
const superintendent = argmax(superRolls);
|
||||
void divisionRolls; // seating is fixed by array order; the roll is recorded for the event log
|
||||
|
||||
/**
|
||||
* §4.6-4.7 — THE OPENING DEAL, dealt from two piles rather than one.
|
||||
* §4.4 — THE OPENING D12 DECIDES WHO SITS WHERE.
|
||||
*
|
||||
* Track is shuffled SEPARATELY from everything else and each player is dealt **3 track cards and 3
|
||||
* other cards**; the track left over is then shuffled back in and the game runs off one deck as
|
||||
* before. The player opens holding six against a limit of three, so the first turn is spent
|
||||
* choosing what to keep — draw as usual, then play or discard down to three (§6.2, enforced on
|
||||
* `draw.end`, which needs no special case for this).
|
||||
* `seating[seat] = player`, and seat 0 is the WESTERN end of the chain (`buildDivision` lays the
|
||||
* Western Division Point, then office 0, and finishes at the Eastern one). So the highest roll
|
||||
* takes the last seat — "highest is the Eastern Division Point" — and the lowest ends up beside
|
||||
* the Western Division Point, which is the rule's other named position.
|
||||
*
|
||||
* WHY. A run-around needs five specific pieces in a usable order, and the bot held a turnout and a
|
||||
* matching-hand curve together on **0.2% of turns** — about once every eight games. Measured five
|
||||
* ways, that is a SUPPLY problem and not a bot weakness: 91 run-arounds per 100 games when track
|
||||
* was a private 26-piece supply the player chose from, 29 per 100 once it was drawn, 4 per 60 with
|
||||
* track in the deck. Dealing the opening district as track restores something close to the
|
||||
* prototype's private supply without reintroducing an unbounded one, and makes the opening a
|
||||
* decision rather than a wait.
|
||||
* The rule names only those two ends, because at a table the players are already sitting in a
|
||||
* chain and the roll only says which way round it is. There is no physical table here, so the
|
||||
* roll orders everybody: ascending by roll, west to east. It uses a number every player is
|
||||
* already told to roll, and it makes the roll matter to more than the winner.
|
||||
*
|
||||
* PROVISIONAL — flagged for review once it has been played. See `TODO.md`.
|
||||
* Ties break toward the LOWER player index sitting further east, which is the same convention
|
||||
* `argmax` uses for the Superintendent roll on the line above — first max wins.
|
||||
*
|
||||
* This was `void divisionRolls` until v0.4.1: the roll was drawn and discarded, and seating was
|
||||
* the identity mapping. Turning it on is what makes seat and player genuinely different at
|
||||
* runtime rather than only in the type names.
|
||||
*/
|
||||
const deck = buildDeck(config.mode);
|
||||
const seating: PlayerIndex[] = players
|
||||
.map((_, p) => p)
|
||||
.sort((a, b) => divisionRolls[a]! - divisionRolls[b]! || b - a);
|
||||
|
||||
/**
|
||||
* §4.6-4.7 — THE OPENING DEAL, in whichever of the three shapes this game was dealt with.
|
||||
*
|
||||
* `threeRandom` and `sixRandom` deal off ONE shuffled deck; `threeTrackThreeOther` shuffles track
|
||||
* separately and deals three of each, then shuffles the remainder back together so the game runs
|
||||
* off one deck from the first draw onward either way. `content.ts` says what each option is for.
|
||||
*
|
||||
* A player dealt six holds six against a limit of three, on purpose: the first turn is spent
|
||||
* choosing what to keep — draw as usual, then play or discard down to three (§6.2, enforced on
|
||||
* `draw.end`, which needs no special case for this). A player dealt three is already at the limit.
|
||||
*
|
||||
* §4.7 — "starting from the Superintendent, deal each player…", which proceeds round the table and
|
||||
* is therefore SEAT order, not player order. That is the same in all three shapes.
|
||||
*/
|
||||
const rules = houseRules(config);
|
||||
const deal = OPENING_DEALS[rules.startingHand];
|
||||
const deck = buildDeck(config.mode, config.pvpCardsAllowed, rules.startingOffice, rules.secondSectionCard);
|
||||
const cards = new Map<CardId, Card>();
|
||||
for (const c of deck) cards.set(c.id, c);
|
||||
|
||||
const isTrack = (id: CardId): boolean => cards.get(id)?.kind.kind === 'track';
|
||||
const ids = deck.map((c) => c.id);
|
||||
// Shuffled in a fixed order — track first — so the RNG stream is deterministic for a given seed.
|
||||
const trackPile = rng.shuffle(ids.filter(isTrack));
|
||||
const otherPile = rng.shuffle(ids.filter((id) => !isTrack(id)));
|
||||
|
||||
const superSeat = seating.indexOf(superintendent);
|
||||
const hands = new Map<PlayerIndex, CardId[]>();
|
||||
let trackCursor = 0;
|
||||
let otherCursor = 0;
|
||||
for (let i = 0; i < playerCount; i++) {
|
||||
const p = (superintendent + i) % playerCount;
|
||||
hands.set(p, [
|
||||
...trackPile.slice(trackCursor, trackCursor + OPENING_TRACK),
|
||||
...otherPile.slice(otherCursor, otherCursor + OPENING_OTHER),
|
||||
]);
|
||||
trackCursor += OPENING_TRACK;
|
||||
otherCursor += OPENING_OTHER;
|
||||
}
|
||||
const dealtTo = (i: number): PlayerIndex => seating[(superSeat + i) % playerCount]!;
|
||||
|
||||
// The remainder goes back into ONE deck for the rest of the game — the split is an opening-deal
|
||||
// device only, so track competes for the draw exactly as before from the first draw onward.
|
||||
const shuffled = rng.shuffle([...trackPile.slice(trackCursor), ...otherPile.slice(otherCursor)]);
|
||||
let shuffled: CardId[];
|
||||
if (deal.any > 0) {
|
||||
// One deck, one shuffle, the top cards off it — the prototype's own deal.
|
||||
const single = rng.shuffle(ids);
|
||||
let cut = 0;
|
||||
for (let i = 0; i < playerCount; i++) {
|
||||
hands.set(dealtTo(i), single.slice(cut, cut + deal.any));
|
||||
cut += deal.any;
|
||||
}
|
||||
shuffled = single.slice(cut);
|
||||
} else {
|
||||
// Shuffled in a fixed order — track first — so the RNG stream is deterministic for a given seed.
|
||||
const trackPile = rng.shuffle(ids.filter(isTrack));
|
||||
const otherPile = rng.shuffle(ids.filter((id) => !isTrack(id)));
|
||||
let trackCursor = 0;
|
||||
let otherCursor = 0;
|
||||
for (let i = 0; i < playerCount; i++) {
|
||||
hands.set(dealtTo(i), [
|
||||
...trackPile.slice(trackCursor, trackCursor + deal.track),
|
||||
...otherPile.slice(otherCursor, otherCursor + deal.other),
|
||||
]);
|
||||
trackCursor += deal.track;
|
||||
otherCursor += deal.other;
|
||||
}
|
||||
// The remainder goes back into ONE deck for the rest of the game — the split is an opening-deal
|
||||
// device only, so track competes for the draw exactly as before from the first draw onward.
|
||||
shuffled = rng.shuffle([...trackPile.slice(trackCursor), ...otherPile.slice(otherCursor)]);
|
||||
}
|
||||
|
||||
// §4.6-4.7 — three cards turned face up beside the deck. Each is the bottom of a Department pile
|
||||
// that grows as players discard onto it. Taken after the recombination, so a Department slot can
|
||||
@@ -334,6 +437,8 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
seed,
|
||||
rngState: rng.getState(),
|
||||
players,
|
||||
seating,
|
||||
openingRolls: { division: divisionRolls, superintendent: superRolls },
|
||||
division: { nodes: buildDivision(playerCount, rng) },
|
||||
officeAreas,
|
||||
trays: new Map(),
|
||||
@@ -350,15 +455,21 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
phase: 'localOps',
|
||||
currentActor: superintendent,
|
||||
pendingDecision: null,
|
||||
clearanceRuling: null,
|
||||
decisionAnswer: null,
|
||||
superintendent,
|
||||
actorOffset: 0,
|
||||
},
|
||||
turn: freshTurn(MOVES_PER_LOCAL_OPS),
|
||||
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
|
||||
movedThisPhase: new Set(),
|
||||
collisionsToday: 0,
|
||||
collisionsPrevDay: 0,
|
||||
collisionsTotal: 0,
|
||||
status: 'active',
|
||||
outcome: null,
|
||||
extraDays: 0,
|
||||
extensionVotes: players.map(() => null),
|
||||
official: null,
|
||||
tally: emptyTally(playerCount),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+780
-59
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* The event tally — Gitea#16's statistics, folded from the event stream into `GameState.tally`.
|
||||
*
|
||||
* WHERE IT IS HOOKED, and why it is not in `reduce`. `apply.ts`'s `reduce` sees only the events an
|
||||
* INTENT produced; `advance.ts` mutates state directly and pushes its events without reducing them
|
||||
* at all — and `advance` is where `trainCompleted`, `trainsDestroyed` and `trainStoodStill` come
|
||||
* from, which are exactly the numbers this issue asks for. So the fold is hooked at the two places
|
||||
* every event in the game passes through exactly once on its way to a caller:
|
||||
*
|
||||
* - `applyIntent` (`apply.ts`), beside its `reduce` loop;
|
||||
* - `advance` (`advance.ts`), which now wraps the phase driver and folds what it returns.
|
||||
*
|
||||
* Exactly once matters in both directions: an event folded twice inflates a count, and an event
|
||||
* folded nowhere is a statistic that silently reads zero. `test/tally.test.ts` pins both by playing
|
||||
* real games and checking the tally against an independent count over the same event array.
|
||||
*
|
||||
* NOTHING HERE IS A RULE. The tally is read by the results screen and by the badge work Gitea#16
|
||||
* leaves to a second pass; no engine decision consults it. That is what makes adding a counter
|
||||
* always safe.
|
||||
*
|
||||
* WHAT IS NOT COUNTED PER PLAYER, and why. `carsCoupled` and `carsDropped` carry a `trayId` and no
|
||||
* `player` — switching is done BY a crew, and the event says which crew rather than which person.
|
||||
* Rather than guess an owner from whose turn it happened to be, those two are table totals only.
|
||||
* The events that do name a player (`loadCompleted`, `passengersBoarded`, `cardPlayed`,
|
||||
* `revenueChanged`, `trainsDestroyed`) are the ones `byPlayer` reports.
|
||||
*/
|
||||
|
||||
import type { GameEvent } from './events.ts';
|
||||
import type { GameState } from './state.ts';
|
||||
|
||||
/**
|
||||
* Fold one event into `s.tally`.
|
||||
*
|
||||
* The switch is deliberately not exhaustive — most of the 49 event types say nothing a player would
|
||||
* want counted, and listing them all to `break` would bury the ones that do. A `default` that does
|
||||
* nothing is the honest shape.
|
||||
*/
|
||||
export function tallyEvent(s: GameState, e: GameEvent): void {
|
||||
const t = s.tally;
|
||||
const mine = 'player' in e && typeof e.player === 'number' ? t.byPlayer[e.player] : undefined;
|
||||
|
||||
switch (e.type) {
|
||||
/**
|
||||
* §X18 — the Circus train set up and was paid for the Stage it spent standing.
|
||||
*
|
||||
* NOT a "longest stand" streak, which is what Gitea#16 wants and what its comment assumed this
|
||||
* event was. It fires once in a game per such train: only trains whose profile sets
|
||||
* `stopEarnsPoint` emit it at all, and `advance.ts` claims it once with `stopPointClaimed`. So
|
||||
* there is nothing to count a run of, and the honest thing to report is the event itself.
|
||||
*/
|
||||
case 'trainStoodStill':
|
||||
t.circusStops.push({ trainNumber: e.trainNumber, where: e.where });
|
||||
break;
|
||||
|
||||
/**
|
||||
* DID THIS TRAIN DO ANY SWITCHING — Gitea#16's "switching master" join, kept as it happens
|
||||
* rather than reconstructed afterwards.
|
||||
*
|
||||
* The two halves of the join are in different currencies: switching events name a `trayId` and
|
||||
* completion names a `trainNumber`, and no event carries both. The tray is looked up in LIVE
|
||||
* state, which is sound precisely here — a crew that has just coupled or dropped is still on the
|
||||
* board — where re-deriving it at completion time would not be, the tray having been released by
|
||||
* then. A lookup that misses costs one train its mark on a statistic; it cannot affect a rule.
|
||||
*/
|
||||
case 'carsCoupled':
|
||||
t.carsCoupled += e.stock.length;
|
||||
markSwitched(s, e.trayId);
|
||||
break;
|
||||
|
||||
case 'carsDropped':
|
||||
t.carsDropped += e.stock.length;
|
||||
markSwitched(s, e.trayId);
|
||||
break;
|
||||
|
||||
case 'flyingSwitch':
|
||||
t.flyingSwitches += 1;
|
||||
markSwitched(s, e.trayId);
|
||||
break;
|
||||
|
||||
// A tray is reused run after run, so a fresh train starts with a clean sheet.
|
||||
case 'trainMadeUp':
|
||||
t.switchedSince = t.switchedSince.filter((n) => n !== e.trainNumber);
|
||||
break;
|
||||
|
||||
case 'trainCompleted':
|
||||
t.trainsCompleted += 1;
|
||||
if (t.switchedSince.includes(e.trainNumber)) t.trainsCompletedWithWork += 1;
|
||||
t.switchedSince = t.switchedSince.filter((n) => n !== e.trainNumber);
|
||||
break;
|
||||
|
||||
case 'trainsDestroyed':
|
||||
t.trainsDestroyed += e.trains.length;
|
||||
for (const train of e.trains) t.carsDestroyed += train.consist.length;
|
||||
if (mine) mine.collisions += 1;
|
||||
break;
|
||||
|
||||
case 'officeUpgraded':
|
||||
t.officeUpgrades += 1;
|
||||
break;
|
||||
|
||||
case 'dispatchBonusUsed':
|
||||
t.dispatchBonusesUsed += 1;
|
||||
break;
|
||||
|
||||
case 'facilityUnjammed':
|
||||
t.facilitiesUnjammed += 1;
|
||||
break;
|
||||
|
||||
case 'expediteFault':
|
||||
t.expediteFaults += 1;
|
||||
break;
|
||||
|
||||
case 'trainHeld':
|
||||
t.trainsHeld += 1;
|
||||
break;
|
||||
|
||||
case 'trainDiverted':
|
||||
t.trainsDiverted += 1;
|
||||
break;
|
||||
|
||||
case 'secondSectionOrdered':
|
||||
t.secondSections += 1;
|
||||
break;
|
||||
|
||||
case 'extraStarted':
|
||||
t.extrasStarted += 1;
|
||||
break;
|
||||
|
||||
case 'cardDrawn':
|
||||
t.cardsDrawn += 1;
|
||||
break;
|
||||
|
||||
case 'cardPlayed':
|
||||
t.cardsPlayed += 1;
|
||||
if (mine) mine.cardsPlayed += 1;
|
||||
break;
|
||||
|
||||
case 'cardDiscarded':
|
||||
t.cardsDiscarded += 1;
|
||||
break;
|
||||
|
||||
case 'clearanceRequested':
|
||||
t.clearancesRequested += 1;
|
||||
break;
|
||||
|
||||
// §8.1 — a ruling is given either way; only a YES let the other train through.
|
||||
case 'clearanceGiven':
|
||||
if (e.allow) t.clearancesAllowed += 1;
|
||||
break;
|
||||
|
||||
case 'loadStarted':
|
||||
t.loadsStarted += 1;
|
||||
break;
|
||||
|
||||
case 'loadCompleted':
|
||||
t.loadsCompleted += 1;
|
||||
if (mine) mine.loads += 1;
|
||||
break;
|
||||
|
||||
case 'unloadBegan':
|
||||
t.unloadsBegun += 1;
|
||||
break;
|
||||
|
||||
case 'unloadCompleted':
|
||||
t.unloadsCompleted += 1;
|
||||
if (mine) mine.unloads += 1;
|
||||
break;
|
||||
|
||||
case 'passengersBoarded':
|
||||
t.passengersBoarded += 1;
|
||||
if (mine) mine.passengersBoarded += 1;
|
||||
break;
|
||||
|
||||
case 'passengersDetrained':
|
||||
t.passengersDetrained += 1;
|
||||
if (mine) mine.passengersDetrained += 1;
|
||||
break;
|
||||
|
||||
/**
|
||||
* Gained and lost are kept APART because the net is already on `players[i].revenue`. What the
|
||||
* results screen cannot otherwise say is how much of a modest final score was earned and then
|
||||
* handed back at a grade crossing — which is the whole difference between a quiet game and an
|
||||
* eventful one.
|
||||
*/
|
||||
case 'revenueChanged':
|
||||
if (mine) {
|
||||
if (e.delta >= 0) mine.revenueGained += e.delta;
|
||||
else mine.revenueLost += -e.delta;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function markSwitched(s: GameState, trayId: string): void {
|
||||
const n = s.trays.get(trayId)?.trainNumber;
|
||||
if (n === undefined || n === null) return;
|
||||
if (!s.tally.switchedSince.includes(n)) s.tally.switchedSince.push(n);
|
||||
}
|
||||
+243
-29
@@ -34,7 +34,7 @@ import type {
|
||||
TrackArc,
|
||||
TurnoutOrientation,
|
||||
} from './state.ts';
|
||||
import { carsOn, coordKey, isLockedByWork, isOperationalRail, spaceOn } from './state.ts';
|
||||
import { carsOn, coordKey, cutTowards, isLockedByWork, isOperationalRail, spaceOn } from './state.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ports and geometry
|
||||
@@ -188,6 +188,40 @@ export function joins(a: TrackCard, p: Port, b: TrackCard): boolean {
|
||||
return slopeAt(a, p) === slopeAt(b, opposite(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH END OF THE WEST-TO-EAST ROW A PORT SITS AT.
|
||||
*
|
||||
* `TrackCard.standing` is ordered west to east (§A.3), so whether a train meets the row front to
|
||||
* back or back to front depends on which end it enters by — and a port is not always at one of
|
||||
* those two extremes. Every 45° leg leaves through the MIDDLE of its north or south edge, so its
|
||||
* end of the run is whichever end the arc does NOT reach: a `sw` curve's south leg is the EAST end
|
||||
* of the row, and an `se` curve's south leg is the WEST end. Same port, opposite answers, which is
|
||||
* why this has to ask the card rather than read the port.
|
||||
*
|
||||
* Gitea#17 is what both callers looked like without it. `exploreMoves` reversed the row for an 'e'
|
||||
* entry and for nothing else, so backing into a cut through a `sw` curve's south leg coupled it up
|
||||
* back to front — the caboose came out next to the engine, which §8.2 then calls badly made up.
|
||||
* `cutTowards` answered "you meet nothing" for a north or south exit, so a crew standing on a curve
|
||||
* pulled out through the leg and left the cars beside it standing, which §A.4 forbids.
|
||||
*
|
||||
* There is no north-south straight anywhere on the printed sheet (see the module comment), so a run
|
||||
* touching a 45° leg always has an east or west port at its other end and the answer is never
|
||||
* undefined. A TURNOUT is the one card whose row has three ends rather than two — and it is also
|
||||
* the one card no cut can ever stand on, since a train may not stop there (§A.1) and so never sets
|
||||
* anything out there. Its stem answers for it.
|
||||
*/
|
||||
export function rowEndAt(card: TrackCard, p: Port): 'e' | 'w' {
|
||||
if (p === 'e' || p === 'w') return p;
|
||||
for (const [a, b] of connectionsFor(card)) {
|
||||
const other = a === p ? b : b === p ? a : null;
|
||||
if (other === 'e') return 'w';
|
||||
if (other === 'w') return 'e';
|
||||
}
|
||||
// Not a card the printed sheet can produce. Reading the leg as the west end leaves the row in the
|
||||
// order it is stored rather than inventing a reversal on a card nothing knows the shape of.
|
||||
return 'w';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orientation (Gap 11)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -343,7 +377,7 @@ export function reachableDestinations(
|
||||
start: GridCoord,
|
||||
initialExit: Port,
|
||||
): MoveDestination[] {
|
||||
return exploreMoves(ctx, start, initialExit).destinations;
|
||||
return exploreMoves(ctx, start, initialExit, false).destinations;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -355,7 +389,19 @@ export function reachableDestinations(
|
||||
* and only the obstructions are worth listing in the "why nothing is moving" panel, where every
|
||||
* turnout in the district would otherwise appear.
|
||||
*/
|
||||
export type MoveBlockKind = 'noJoin' | 'occupied' | 'locked' | 'tooManyCars' | 'noStopping';
|
||||
export type MoveBlockKind =
|
||||
| 'noJoin'
|
||||
| 'occupied'
|
||||
| 'locked'
|
||||
| 'tooManyCars'
|
||||
| 'noStopping'
|
||||
/**
|
||||
* The rails go there and the TRAIN'S OWN CARD does not allow it — a no-switching or drop-only
|
||||
* train that would have to couple something, an empties-only train facing a loaded car, or a
|
||||
* freight allowance already spent at that location. Decided by `check` rather than by the walk,
|
||||
* so it is added in `movesFor` rather than emitted by `exploreMoves`.
|
||||
*/
|
||||
| 'cardRule';
|
||||
export type MoveBlock = { coord: GridCoord; kind: MoveBlockKind; why: string };
|
||||
|
||||
/**
|
||||
@@ -374,16 +420,45 @@ export type MoveBlock = { coord: GridCoord; kind: MoveBlockKind; why: string };
|
||||
* beyond a reachable one whose rails do not meet. Squares the walk never came near are not listed:
|
||||
* "there is no track between here and there" is not news.
|
||||
*/
|
||||
/**
|
||||
* How many frontier nodes a single `exploreMoves` walk may enqueue before it stops finding new
|
||||
* routes. A per-path visited set (below) guarantees every individual route terminates, but the
|
||||
* NUMBER of simple paths through a dense district is worst-case exponential — a real board never
|
||||
* gets close, but nothing stops a pathological one from being built. BFS order means the queue
|
||||
* fills shortest-route-first, so hitting the cap loses only the longest, least-likely-to-matter
|
||||
* routes; it never loses a shorter one in favour of a longer one.
|
||||
*/
|
||||
const MAX_ENUMERATED_FRONTIER = 4000;
|
||||
|
||||
/**
|
||||
* The identity of a route's OUTCOME, for dedupe: which square it ends at, which side it entered
|
||||
* by, and exactly which cars it couples, each tagged with the card it came off. Two routes that
|
||||
* couple the same car TYPES from DIFFERENT cards are not the same outcome — sweeping the wrong
|
||||
* card clean is exactly the bug this key exists to avoid — so the origin card rides along with
|
||||
* every car, not just its type.
|
||||
*/
|
||||
function routeOutcomeKey(coord: GridCoord, entry: Port, couples: RollingStock[], origins: string[]): string {
|
||||
const cars = couples.map((c, idx) => `${origins[idx]}:${c.type}:${c.loaded ? 1 : 0}`).join(',');
|
||||
return `${coordKey(coord)}|${entry}|${cars}`;
|
||||
}
|
||||
|
||||
export function exploreMoves(
|
||||
ctx: MoveContext,
|
||||
start: GridCoord,
|
||||
initialExit: Port,
|
||||
/**
|
||||
* False when only the destinations are wanted (`reachableDestinations`, every legality check): the
|
||||
* rejections are then not recorded at all. They never change a destination, and building them was
|
||||
* pure allocation on the hottest path in the engine.
|
||||
*/
|
||||
collectBlocks = true,
|
||||
): { destinations: MoveDestination[]; blocked: MoveBlock[] } {
|
||||
const { area, occupancy } = ctx;
|
||||
const results: MoveDestination[] = [];
|
||||
const blocked: MoveBlock[] = [];
|
||||
const noted = new Set<string>();
|
||||
const block = (coord: GridCoord, kind: MoveBlockKind, why: string): void => {
|
||||
if (!collectBlocks) return;
|
||||
const k = coordKey(coord);
|
||||
if (noted.has(k)) return;
|
||||
noted.add(k);
|
||||
@@ -394,9 +469,35 @@ export function exploreMoves(
|
||||
if (!startCard) return { destinations: results, blocked };
|
||||
if (!hasPort(startCard, initialExit)) return { destinations: results, blocked };
|
||||
|
||||
const seen = new Set<string>();
|
||||
const resultKeys = new Set<string>();
|
||||
|
||||
type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[] };
|
||||
type Frontier = {
|
||||
coord: GridCoord;
|
||||
entry: Port;
|
||||
path: MoveStep[];
|
||||
couples: RollingStock[];
|
||||
/** Coord key each entry in `couples` came off, aligned by index — see `routeOutcomeKey`. */
|
||||
origins: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Has THIS route already used `to`? Per-path, not global — see the doc comment on
|
||||
* `MAX_ENUMERATED_FRONTIER` for why a global set would forbid the very routes this walk exists to
|
||||
* find.
|
||||
*
|
||||
* Read off the route's own `path` instead of a Set copied at every step, which was a large share of
|
||||
* the walk's garbage. It answers exactly as that Set did: the start square, then every square
|
||||
* enqueued along the route AFTER the first hop, including this node's own — the first hop's square
|
||||
* was never added, and `path[0]` is that square, so the scan begins at 1.
|
||||
*/
|
||||
const onRoute = (node: Frontier, to: GridCoord): boolean => {
|
||||
if (sameCoord(to, start)) return true;
|
||||
if (node.path.length > 0 && sameCoord(to, node.coord)) return true;
|
||||
for (let k = 1; k < node.path.length; k++) {
|
||||
if (sameCoord(node.path[k]!.coord, to)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// The very first hop is checked here because `start`'s card is not itself enqueued; every later
|
||||
// hop is checked at the push site below, where both sides of the edge are in hand.
|
||||
@@ -406,12 +507,34 @@ export function exploreMoves(
|
||||
if (firstCard) block(first, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal');
|
||||
return { destinations: results, blocked };
|
||||
}
|
||||
/**
|
||||
* THE CUT ON YOUR OWN CARD, which the walk used to ignore entirely.
|
||||
*
|
||||
* Reported from play: "if I put cars off the nose on a given track, and my next move is go
|
||||
* forward, I need to couple those cars right back on". Exactly so — and the engine let the train
|
||||
* drive away and leave them. `cutTowards` takes only the cars between the engine and the end it is
|
||||
* leaving by, so setting out off the nose and then BACKING away is still free, which is the whole
|
||||
* point of setting out off one particular end.
|
||||
*
|
||||
* Ordered nearest-first like every other card's, so it simply seeds the accumulator.
|
||||
*/
|
||||
const ownCut = cutTowards(startCard, carsOn(startCard), rowEndAt(startCard, initialExit));
|
||||
const startKey = coordKey(start);
|
||||
const queue: Frontier[] = [
|
||||
{ coord: first, entry: opposite(initialExit), path: [], couples: [] },
|
||||
{
|
||||
coord: first,
|
||||
entry: opposite(initialExit),
|
||||
path: [],
|
||||
couples: ownCut,
|
||||
origins: ownCut.map(() => startKey),
|
||||
},
|
||||
];
|
||||
let enumerated = 1;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift()!;
|
||||
// FIFO by index rather than `shift()`, which re-packs the array on every pop. Same order.
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const node = queue[head++]!;
|
||||
const card = cardAt(area, node.coord);
|
||||
if (!card) continue;
|
||||
|
||||
@@ -443,30 +566,54 @@ export function exploreMoves(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mandatory coupling. Rejecting rather than truncating is deliberate: a move that would
|
||||
// overfill the tray is illegal, not a move that picks up fewer cars.
|
||||
const couples = [...node.couples, ...carsOn(card)];
|
||||
/**
|
||||
* Mandatory coupling. Rejecting rather than truncating is deliberate: a move that would
|
||||
* overfill the tray is illegal, not a move that picks up fewer cars.
|
||||
*
|
||||
* NEAREST FIRST ALONG THE DIRECTION OF TRAVEL. `carsOn` runs west to east, so a train entering
|
||||
* at the row's EAST end meets them back to front and the row has to be reversed. Without this
|
||||
* the same parked cut produced an identical consist whichever way it was approached, when the
|
||||
* two must mirror — which is the difference between a run-around being worth a Move and being
|
||||
* pointless.
|
||||
*
|
||||
* `rowEndAt` rather than `node.entry === 'e'`: a 45° leg is an end of the row too, and which
|
||||
* end it is depends on the card's arc (Gitea#17).
|
||||
*/
|
||||
const met = rowEndAt(card, node.entry) === 'e' ? [...carsOn(card)].reverse() : carsOn(card);
|
||||
const couples = [...node.couples, ...met];
|
||||
const nodeKey = coordKey(node.coord);
|
||||
const origins = [...node.origins, ...met.map(() => nodeKey)];
|
||||
if (ctx.consistSize + couples.length > MAX_CONSIST) {
|
||||
const own =
|
||||
ownCut.length > 0
|
||||
? `, ${ownCut.length} of them the cut standing at the end of your OWN card that this move pulls out through`
|
||||
: '';
|
||||
block(
|
||||
node.coord,
|
||||
'tooManyCars',
|
||||
`too many cars — running here couples ${couples.length} standing car(s) onto a train already holding ${ctx.consistSize}, and ${MAX_CONSIST} is the limit. Coupling is mandatory: you may not run past a car and leave it (§A.4)`,
|
||||
`too many cars — running here couples ${couples.length} standing car(s)${own} onto a train already holding ${ctx.consistSize}, and ${MAX_CONSIST} is the limit. Coupling is mandatory: you may not run past a car and leave it (§A.4)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${coordKey(node.coord)}|${node.entry}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
if (isOperationalRail(card) && !sameCoord(node.coord, start)) {
|
||||
results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
|
||||
// Dedupe on OUTCOME, not on reaching the square: two routes that end here having coupled
|
||||
// different cars (or the same cars off different cards) are a real choice, and both are
|
||||
// offered. Two routes that end here having coupled identically are not a choice — one is
|
||||
// noise doubling the button list and the bot's branching factor for nothing.
|
||||
const rkey = routeOutcomeKey(node.coord, node.entry, couples, origins);
|
||||
if (!resultKeys.has(rkey)) {
|
||||
resultKeys.add(rkey);
|
||||
results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
|
||||
}
|
||||
} else if (!sameCoord(node.coord, start)) {
|
||||
// Reached, crossable, but not somewhere a train may STOP — a turnout carries no wheel icon
|
||||
// (§A.1). Trains run through one all day; they just cannot finish a Move on it.
|
||||
block(node.coord, 'noStopping', 'a train may run through here but not stop — this is not Operational Rail (§A.1), so it cannot be the end of a Move');
|
||||
}
|
||||
|
||||
if (enumerated >= MAX_ENUMERATED_FRONTIER) continue;
|
||||
|
||||
for (const exit of exitsFrom(card, node.entry)) {
|
||||
// Slope is a property of the EDGE, not of either card, so it can only be tested with both in
|
||||
// hand. Enqueueing on `hasPort` alone routed trains across a 45° leg that bent back on itself.
|
||||
@@ -476,11 +623,19 @@ export function exploreMoves(
|
||||
if (next) block(to, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal');
|
||||
continue;
|
||||
}
|
||||
// A card twice on ONE route is a loop, not a longer route (§2.4 travels without changing
|
||||
// direction; it never says without repeating ground, but a train cannot occupy the same
|
||||
// track twice at once either). Per-path, not global — a DIFFERENT route may legitimately
|
||||
// pass through a card this one already used.
|
||||
if (onRoute(node, to)) continue;
|
||||
if (enumerated >= MAX_ENUMERATED_FRONTIER) break;
|
||||
enumerated++;
|
||||
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
|
||||
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples });
|
||||
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins });
|
||||
}
|
||||
}
|
||||
|
||||
if (!collectBlocks) return { destinations: results, blocked };
|
||||
// A card that turned out to be reachable after all is not a blocker: the walk may meet a square
|
||||
// from a bad angle first and a good one later.
|
||||
const reached = new Set(results.map((r) => coordKey(r.coord)));
|
||||
@@ -525,6 +680,40 @@ export function carriesThroughTrack(card: TrackCard): boolean {
|
||||
* a TURNOUT laid on the Running Track — the Office no longer carries a stub of its own, because no
|
||||
* office or industry card on the printed sheet does.
|
||||
*/
|
||||
/**
|
||||
* §2.1 — IS THIS SQUARE INSIDE THE DISTRICT AT ALL?
|
||||
*
|
||||
* The Limits sign denotes "the limit of your control area", and §3 defines Secondary Track as "all
|
||||
* tracks IN YOUR LIMITS that are not the Running Track" — so the bound belongs to the district, not
|
||||
* to the one row the sign happens to stand in. This used to guard the Running Track alone, on the
|
||||
* reasoning that the rows above and below were unbounded; the consequence was a siding snaking east
|
||||
* past your own sign, taking industries with it, and a player switching cars on track outside the
|
||||
* territory §8.1 and §10 reason about. Reported by Jesse: sidings must not be built outside the
|
||||
* Limits.
|
||||
*
|
||||
* INCLUSIVE OF THE SIGN'S OWN COLUMN. The sign stands ON the boundary rather than beyond it, and the
|
||||
* opening district is signs at ±1 around the Office — so the strict reading would leave exactly one
|
||||
* buildable column and break §11.3's promise that both Secondary rows, and the nine-spot Modifier
|
||||
* neighbourhood, are usable from the first Stage.
|
||||
*
|
||||
* MODIFIERS ARE SUBJECT TO THIS TOO, since 2026-09-17 — REVERSING an earlier call of Jesse's that
|
||||
* exempted them. The exemption reasoned that §9 places a Modifier on any of the nine spots around a
|
||||
* Facility, so a Facility standing at the limit has three of its nine outside them and bounding the
|
||||
* card would make it unplayable exactly where a district ends. Play showed the cost of that the
|
||||
* other way round: a Transmission Lines card went down at (-2,4) with the sign at column 3, which
|
||||
* reads at the table as building outside your own territory, and §8.1 and §10 both reason about
|
||||
* what is inside a player's Limits.
|
||||
*
|
||||
* THE FEARED CASE DID NOT ARISE, and was measured on the move that prompted the change rather than
|
||||
* argued: the Power Plant sat at (-1,3) against a sign at 3, and (-2,2) and (-2,3) were both free,
|
||||
* legal and inside. A Facility at the limit keeps six of its nine spots, and the Limits move outward
|
||||
* as the Running Track grows (§2.1, Gap 4a), so the ground for a Modifier arrives with the district.
|
||||
* `check` bars them from the Running Track ROW as well, which is the ground the main grows onto.
|
||||
*/
|
||||
export function withinLimits(area: OfficeArea, coord: GridCoord): boolean {
|
||||
return coord.col >= area.limitsWest.col && coord.col <= area.limitsEast.col;
|
||||
}
|
||||
|
||||
export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): boolean {
|
||||
const existing = cardAt(area, coord);
|
||||
// A Limits sign on the Running Track is the GROWTH POINT, not an obstacle. Extending means laying
|
||||
@@ -541,15 +730,33 @@ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard):
|
||||
// the sign itself is the only growth point. Allowing a placement beyond it built track on the far
|
||||
// side of the sign and then planted a SECOND sign further out, leaving the board reading
|
||||
// limits · Whistle Post · limits · straight · limits
|
||||
// with a Limits card stranded mid-track. The district below the Running Track is unbounded.
|
||||
if (coord.row === area.runningRow) {
|
||||
if (coord.col < area.limitsWest.col || coord.col > area.limitsEast.col) return false;
|
||||
// And it must not BREAK the Running Track. A curve laid here has no east-west road, so the main
|
||||
// stops dead at it — the bot did exactly this, turning the west end of its own Running Track
|
||||
// into a stub and cutting the Office off from the Limits.
|
||||
if (!carriesThroughTrack(card)) return false;
|
||||
}
|
||||
// with a Limits card stranded mid-track.
|
||||
if (!withinLimits(area, coord)) return false;
|
||||
// And it must not BREAK the Running Track. A curve laid here has no east-west road, so the main
|
||||
// stops dead at it — the bot did exactly this, turning the west end of its own Running Track
|
||||
// into a stub and cutting the Office off from the Limits.
|
||||
if (coord.row === area.runningRow && !carriesThroughTrack(card)) return false;
|
||||
|
||||
/**
|
||||
* ONE NEIGHBOUR MUST JOIN. THE OTHERS NEED NOT — AND THIS RULE HAS BEEN BOTH WAYS (Gitea#15).
|
||||
*
|
||||
* A card may be laid with an exit facing a card that has nothing to meet it. The rail stops dead
|
||||
* at that edge, and that is legal.
|
||||
*
|
||||
* The issue was filed the other way round — "if a card is placed in that space, it MUST connect" —
|
||||
* against a right-hand curve laid with its north leg against an Ice House and the turnout below it
|
||||
* pointing at its portless south edge. **RAR reversed it on review (2026-08-26): placing it is
|
||||
* fine, and a stub like that is useful — a siding to park cars on.**
|
||||
*
|
||||
* WHAT MATTERS INSTEAD IS THAT NOTHING CAN DRIVE ACROSS THE GAP, so the real requirement is on
|
||||
* MOVEMENT rather than on placement: two cards touching are not connected, and `exploreMoves` must
|
||||
* refuse the hop. It does — every step is gated on `joins`, never on a bare pair of `hasPort`
|
||||
* calls — and `track.test.ts` pins the reported geometry against exactly that.
|
||||
*
|
||||
* SO DO NOT ADD A PER-EDGE CHECK HERE. One was written and taken out again when the ruling
|
||||
* arrived. What survives is the weaker rule that was always here: the piece must touch the network
|
||||
* SOMEWHERE, which is what stops orphaned track being laid in an empty corner of the board.
|
||||
*/
|
||||
const ports: Port[] = ['n', 's', 'e', 'w'];
|
||||
for (const p of ports) {
|
||||
const neighbourCard = cardAt(area, neighbour(coord, p));
|
||||
@@ -559,12 +766,19 @@ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard):
|
||||
}
|
||||
|
||||
/**
|
||||
* §A.4 — the Office track is Operational Rail, but Rolling Stock may not be left there.
|
||||
* §A.4 — the Office track is Operational Rail, but Rolling Stock may not be left there — WITH ONE
|
||||
* EXCEPTION (Jesse's call, v0.5.0): any train may set out one or more coaches at the Office. It is
|
||||
* the one car type a passenger platform is meant to hold, and it is what makes the ENGINE-boxcar-
|
||||
* coach Local arrangement workable — the coach drops here while the engine and freight car switch
|
||||
* freely. Freight and cabooses stay banned at the Office for every train, no exception.
|
||||
*
|
||||
* An industry track also has a finite length (§9.3), so it can be full.
|
||||
*/
|
||||
export function canDropCarsAt(area: OfficeArea, coord: GridCoord, count = 1): boolean {
|
||||
if (sameCoord(coord, area.officeCoord)) return false;
|
||||
export function canDropCarsAt(area: OfficeArea, coord: GridCoord, count = 1, coachesOnly = false): boolean {
|
||||
const card = cardAt(area, coord);
|
||||
if (sameCoord(coord, area.officeCoord)) {
|
||||
return coachesOnly && !!card && isOperationalRail(card) && spaceOn(card) >= count;
|
||||
}
|
||||
if (!card || !isOperationalRail(card)) return false;
|
||||
return spaceOn(card) >= count;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* SEAT RECOVERY CODES — Gitea#33.
|
||||
*
|
||||
* A session token is the only identity the game has (`lobby-and-sessions.md` §1) and it lives in
|
||||
* exactly one place the player controls: their browser's `localStorage`, scoped to the origin they
|
||||
* joined at. Lose that — a different browser, a cleared profile, a private window — and the seat is
|
||||
* unreachable, because there is nothing else on the server that will accept a claim to it. Seen at a
|
||||
* real table on 2026-09-16: the joining player came back to an empty lobby while their token sat
|
||||
* intact in `sessions.json`, and the only way in was an administrator reading the file off the data
|
||||
* volume and the player pasting it into a devtools console.
|
||||
*
|
||||
* THE CODE IS NOT THE TOKEN, AND THAT IS THE WHOLE POINT. §1 says to keep the token out of URLs so it
|
||||
* is not shoulder-surfed or pasted into a chat — and a recovery link is exactly the kind of thing
|
||||
* that gets pasted into a chat. So an administrator mints a SHORT-LIVED, SINGLE-USE code, the player
|
||||
* opens a link carrying that, and the page trades it for the real token over the same connection it
|
||||
* would have used anyway. A code that leaks after it is spent is worth nothing; a token that leaks is
|
||||
* worth the seat for the rest of the game.
|
||||
*
|
||||
* PURE ON PURPOSE, like `lobby.ts` beside it: no sockets, no filesystem, no clock of its own. `now`
|
||||
* is passed in so expiry is testable without faking timers, which is the only reason this file can be
|
||||
* tested at all — nothing in this repo stands an HTTP server up to make requests against it.
|
||||
*
|
||||
* IN MEMORY, NOT ON DISK, which is a deliberate limit rather than an oversight. A restart drops every
|
||||
* outstanding code, and that is the right failure: the codes are minted on demand and spent within
|
||||
* minutes, the administrator is by definition present, and persisting them would put a credential-
|
||||
* equivalent on the volume to solve a problem measured in seconds.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Long enough to walk to the other room and read it out; short enough that a link left in a chat
|
||||
* window is useless by the time anyone scrolls back to it.
|
||||
*/
|
||||
export const CLAIM_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
export type ClaimStore = {
|
||||
/** Mint a code for one seat's token. Returns the code and when it stops working. */
|
||||
mint(token: string, gameId: string, now: number, ttlMs?: number): { code: string; expiresAt: number };
|
||||
/**
|
||||
* Spend a code. Returns the seat it names, or null when the code is unknown, already spent or
|
||||
* expired — deliberately one answer for all three, so a caller cannot probe which it was.
|
||||
*/
|
||||
redeem(code: string, now: number): { token: string; gameId: string } | null;
|
||||
/** Outstanding, unexpired codes. For tests and for anything that wants to report the store's size. */
|
||||
outstanding(now: number): number;
|
||||
};
|
||||
|
||||
export function createClaimStore(): ClaimStore {
|
||||
const claims = new Map<string, { token: string; gameId: string; expiresAt: number }>();
|
||||
|
||||
/** Expiry is lazy: there is no timer to own, start, stop or leak across a server's lifetime. */
|
||||
const prune = (now: number): void => {
|
||||
for (const [code, claim] of claims) if (claim.expiresAt <= now) claims.delete(code);
|
||||
};
|
||||
|
||||
return {
|
||||
mint(token, gameId, now, ttlMs = CLAIM_TTL_MS) {
|
||||
prune(now);
|
||||
// The same primitive the session tokens themselves use (`lobby.ts`), for the same reason: it
|
||||
// has to be unguessable, and inventing a second scheme here would be inventing a weaker one.
|
||||
const code = randomUUID();
|
||||
const expiresAt = now + ttlMs;
|
||||
claims.set(code, { token, gameId, expiresAt });
|
||||
return { code, expiresAt };
|
||||
},
|
||||
|
||||
redeem(code, now) {
|
||||
prune(now);
|
||||
const claim = claims.get(code);
|
||||
if (!claim) return null;
|
||||
// SINGLE USE. Deleted before the caller can do anything with it, so two browsers racing on the
|
||||
// same link cannot both be seated — and a link that stays in someone's history is spent.
|
||||
claims.delete(code);
|
||||
return { token: claim.token, gameId: claim.gameId };
|
||||
},
|
||||
|
||||
outstanding(now) {
|
||||
prune(now);
|
||||
return claims.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,970 @@
|
||||
/**
|
||||
* The HTTP/SSE wiring — Phase 2 of `docs/architecture/multiplayer.md` (§8-9, §12 steps 8 and 12),
|
||||
* extended for Phase 4 (§12 steps 17-20) to a lobby and more than one game.
|
||||
*
|
||||
* Plain `node:http`, no framework: the project has zero runtime dependencies
|
||||
* (`package.json`), and `scripts/build-web.ts` already shells out to `tsc` directly rather than
|
||||
* reaching for a bundler — this matches that everywhere-else choice rather than introducing the
|
||||
* first framework dependency for one route table.
|
||||
*
|
||||
* All the game logic lives in `session.ts` and `lobby.ts`; this file is deliberately thin — routing,
|
||||
* the join-secret gate on the DOOR (create/join), token resolution once a player is through it, SSE
|
||||
* mechanics, and static file serving for the built client (`dist/`, D16: the server serves the
|
||||
* client, which is what makes same-origin work with no CORS).
|
||||
*
|
||||
* TOKENS REPLACE `?seat=&secret=` ON THE RUNNING-GAME ROUTES. `lobby-and-sessions.md` §1: the token
|
||||
* already proves "I am the player who was in this game," which is the only identity claim `/api/stream`
|
||||
* and `/api/intent` need — the join secret's job ends at the lobby door.
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { extname, join, normalize } from 'node:path';
|
||||
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
|
||||
import {
|
||||
appendTiming,
|
||||
deleteGame,
|
||||
deleteLobby,
|
||||
gameDir,
|
||||
readIndex,
|
||||
removeIndexEntry,
|
||||
upsertIndexEntry,
|
||||
writeGame,
|
||||
writeLobby,
|
||||
writeSessions,
|
||||
} from './persistence.ts';
|
||||
import { createClaimStore } from './claims.ts';
|
||||
import { createSession } from './session.ts';
|
||||
import type { GameSession, Push } from './session.ts';
|
||||
import {
|
||||
createLobby,
|
||||
leaveLobby,
|
||||
freshGameCode,
|
||||
playerCountAllowed,
|
||||
joinLobby,
|
||||
reassignHost,
|
||||
setBotSeat,
|
||||
startLobby,
|
||||
} from './lobby.ts';
|
||||
import type { Lobby, PlayerSession } from './lobby.ts';
|
||||
|
||||
export type ServerOptions = {
|
||||
port: number;
|
||||
bindAddress: string;
|
||||
/** D14 — a server-wide secret, passed out of band. Gates lobby creation and joining — the door;
|
||||
* once a player is through it and holds a token, the token alone authenticates them. */
|
||||
joinSecret: string;
|
||||
/** The built client (`npm run build:web`'s `dist/`), served at `/` (D16). */
|
||||
distDir: string;
|
||||
/** Where every game's files live, one subdirectory per `gameId` (`persistence.ts`'s `gameDir`). */
|
||||
dataDir: string;
|
||||
/** `package.json`'s version — stamped onto every write, checked on every load (§12 step 15). */
|
||||
engineVersion: string;
|
||||
/**
|
||||
* Gates the administrative routes — listing, exporting and deleting games — and is DELIBERATELY
|
||||
* not the join secret. Every player holds that one, so gating a delete with it would let anyone
|
||||
* at the table destroy anyone else's game. This is held by whoever runs the server and nobody
|
||||
* else. When it is unset the admin routes do not exist at all (404, the same answer as any other
|
||||
* unknown path), so a server that was never given one cannot be administered by guessing.
|
||||
*/
|
||||
adminSecret?: string | undefined;
|
||||
/** Reconstructed by `index.ts`'s load-on-start. Empty maps for a fresh server. */
|
||||
initialGames: Map<string, GameSession>;
|
||||
initialLobbies: Map<string, Lobby>;
|
||||
initialSessions: Map<string, PlayerSession>;
|
||||
};
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
/**
|
||||
* The Quickstart guide, published by `build-web.ts` as `quickstart.md`.
|
||||
*
|
||||
* text/plain ON PURPOSE. The fallback below is `application/octet-stream`, which makes a browser
|
||||
* DOWNLOAD the file instead of showing it — so without this line the splash page's "read the
|
||||
* guide" link hands a tester a file to save rather than a page to read.
|
||||
*/
|
||||
'.md': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
const HEARTBEAT_MS = 20_000;
|
||||
|
||||
/**
|
||||
* THE LARGEST BODY ANY ROUTE HERE HAS A USE FOR, with room to spare (v0.8.4). The biggest thing a
|
||||
* client sends is a `GameConfig` on `/api/lobby/create` — a few hundred bytes. `readJson` used to
|
||||
* buffer whatever arrived, before any secret was checked, so anyone who could reach the port could
|
||||
* exhaust the process's memory with one POST. D14 expects this box to be reachable.
|
||||
*/
|
||||
const MAX_BODY_BYTES = 64 * 1024;
|
||||
|
||||
/** A refusal with a status — thrown from anywhere in a handler and answered by the one catch. */
|
||||
class HttpError extends Error {
|
||||
readonly status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
const declared = Number(req.headers['content-length'] ?? 0);
|
||||
if (declared > MAX_BODY_BYTES) throw new HttpError(413, 'body too large');
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += (chunk as Buffer).length;
|
||||
if (size > MAX_BODY_BYTES) throw new HttpError(413, 'body too large');
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
if (text.trim() === '') return {};
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
// A malformed body is the caller's mistake, answered as one — it used to surface as a 500.
|
||||
throw new HttpError(400, 'body is not JSON');
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new HttpError(400, 'expected a JSON object');
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
/**
|
||||
* NEVER A SECOND HEAD (v0.8.4). The SSE routes and `serveStatic` write their head and go on; a
|
||||
* throw after that reached the handler's catch, which called this, and `writeHead` on a response
|
||||
* whose head was sent throws `ERR_HTTP_HEADERS_SENT` — inside a `.catch`, with nothing above it,
|
||||
* so Node exited on the unhandled rejection. One bad stream write was a whole-server crash.
|
||||
*/
|
||||
if (res.headersSent) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text) });
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
async function serveStatic(
|
||||
distDir: string,
|
||||
urlPath: string,
|
||||
res: ServerResponse,
|
||||
/** The request's `?v=` build tag, when it has one — see the `Cache-Control` note below. */
|
||||
buildTagged = false,
|
||||
): Promise<void> {
|
||||
const rel = urlPath === '/' ? '/index.html' : urlPath;
|
||||
// `normalize` collapses `..`, and the join is then checked to still be inside `distDir` — a request
|
||||
// for `/../../etc/passwd` must not escape the one directory this is allowed to read from.
|
||||
const full = join(distDir, normalize(rel));
|
||||
if (!full.startsWith(distDir)) {
|
||||
sendJson(res, 400, { error: 'bad path' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const info = await stat(full);
|
||||
if (!info.isFile()) throw new Error('not a file');
|
||||
/**
|
||||
* ONLY A URL CARRYING A BUILD TAG MAY BE CACHED, AND NOTHING ELSE MAY BE.
|
||||
*
|
||||
* Nothing here sent a `Cache-Control` at all before, so a browser applied its own heuristic to
|
||||
* the pages as much as the modules. The pages are the one thing that CANNOT be versioned in
|
||||
* their own URL — a player types the address or follows a bookmark — so a cached `play.html`
|
||||
* pins that player to the entire build it names, including every `?v=` tag inside it. That is
|
||||
* half of why v0.7.5 and v0.7.6 did not reach the browser that asked for them; `build-web.ts`
|
||||
* publishing `?v=nogit` on every packaged release was the other half, and neither is enough on
|
||||
* its own.
|
||||
*
|
||||
* `?v=` is the exact condition rather than "not HTML": `build-web.ts` tags the modules and the
|
||||
* script tags that load them, and tags NOTHING else. An untagged URL — an image, the replay
|
||||
* manifest — has no way to announce a change, so a year of `immutable` on one would outlive
|
||||
* several releases of whatever it holds.
|
||||
*/
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[extname(full)] ?? 'application/octet-stream',
|
||||
'Content-Length': info.size,
|
||||
'Cache-Control': buildTagged ? 'public, max-age=31536000, immutable' : 'no-cache',
|
||||
});
|
||||
// A read that fails mid-stream (file replaced by a deploy, disk error) closes the response rather
|
||||
// than raising an error nothing listens for.
|
||||
createReadStream(full)
|
||||
.on('error', () => res.destroy())
|
||||
.pipe(res);
|
||||
} catch {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('not found');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a lobby SSE push carries — the whole `Lobby`, since the seat list is small and a delta
|
||||
* mechanism buys nothing at this size (`session.ts`'s `Push` deltas the BOARD, which is not this).
|
||||
*
|
||||
* `started` rides on the FINAL push of a lobby's life, sent the instant before the connection is
|
||||
* closed at `Lobby.Start` — without it, the client's only signal that the game began is the stream
|
||||
* simply ending, indistinguishable from a network hiccup that `EventSource` would otherwise retry.
|
||||
*/
|
||||
type LobbyPush = { lobby: Lobby; you: PlayerIndex; started: boolean };
|
||||
/** What `/api/lobby/preview` answers with — everything a player weighing a join needs, and nothing
|
||||
* that would spoil the game. THE SEED IS NOT IN IT: it decides every shuffle and every roll. */
|
||||
type LobbyPreview = {
|
||||
gameCode: string;
|
||||
hostName: string;
|
||||
config: GameConfig;
|
||||
players: number;
|
||||
seated: { seat: number; who: string | null; bot: boolean }[];
|
||||
};
|
||||
|
||||
/** Returns the listening server — a test binds port 0 and reads the port back off it. */
|
||||
export function startServer(opts: ServerOptions): Server {
|
||||
const games = opts.initialGames;
|
||||
const lobbies = opts.initialLobbies;
|
||||
const sessions = opts.initialSessions;
|
||||
/**
|
||||
* Outstanding seat recovery codes — Gitea#33, `claims.ts`.
|
||||
*
|
||||
* In memory and not on the volume, deliberately: a code is minted on demand and spent within
|
||||
* minutes with the administrator standing right there, so a restart dropping them all is the right
|
||||
* failure. Persisting them would put a credential-equivalent on disk to solve a problem measured
|
||||
* in seconds.
|
||||
*/
|
||||
const claims = createClaimStore();
|
||||
const gameCodes = new Map<string, string>(); // gameCode -> gameId, for /api/lobby/join
|
||||
for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId);
|
||||
|
||||
// One open SSE response per (gameId, seat) for a running game, and per (gameId, token) for a
|
||||
// lobby still being seated — a second connection from the same seat/token replaces the first
|
||||
// rather than fanning out to both (no concept yet of "the same seat from two tabs").
|
||||
const gameConnections = new Map<string, Map<PlayerIndex, ServerResponse>>();
|
||||
const gameEventIds = new Map<string, Map<PlayerIndex, number>>();
|
||||
const lobbyConnections = new Map<string, Map<string, ServerResponse>>();
|
||||
/** Which seats of a game have ever held a connection in THIS process — see `presenceOfOthers`. */
|
||||
const everConnected = new Map<string, Set<PlayerIndex>>();
|
||||
|
||||
function writeSse(res: ServerResponse, id: number, data: unknown): void {
|
||||
res.write(`id: ${id}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
function nextEventId(gameId: string, seat: PlayerIndex): number {
|
||||
const ids = gameEventIds.get(gameId) ?? new Map<PlayerIndex, number>();
|
||||
const id = (ids.get(seat) ?? 0) + 1;
|
||||
ids.set(seat, id);
|
||||
gameEventIds.set(gameId, ids);
|
||||
return id;
|
||||
}
|
||||
|
||||
function broadcastGame(gameId: string, pushes: Map<PlayerIndex, Push>): void {
|
||||
const conns = gameConnections.get(gameId);
|
||||
if (!conns) return;
|
||||
for (const [seat, push] of pushes) {
|
||||
const res = conns.get(seat);
|
||||
if (res) writeSse(res, nextEventId(gameId, seat), push);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Presence is transport-layer news about a CONNECTION, never a `GameEvent` — it does not go
|
||||
* through `session.ts` at all (`lobby-and-sessions.md` §5). Sent to every OTHER currently
|
||||
* connected seat of the same game as a presence-only push (an empty board delta, no menu, no new
|
||||
* lines) rather than inventing a second SSE event type — one message shape for the client to parse.
|
||||
*/
|
||||
function broadcastPresence(gameId: string, seat: PlayerIndex, connected: boolean): void {
|
||||
const conns = gameConnections.get(gameId);
|
||||
if (!conns) return;
|
||||
for (const [other, res] of conns) {
|
||||
if (other === seat) continue;
|
||||
const push: Push = { menu: null, lines: [], presence: [{ seat, connected, seen: true }] };
|
||||
writeSse(res, nextEventId(gameId, other), push);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EVERY OTHER SEAT'S STATE, for a client that has just connected.
|
||||
*
|
||||
* `broadcastPresence` only ever reports a CHANGE, so a player arriving at a table where two people
|
||||
* had not opened the game yet was told nothing about them at all — and "is everyone here?" is the
|
||||
* question at the moment a game starts. `seen` separates a seat that was here and dropped from one
|
||||
* that has never connected; it is remembered only for as long as this process runs, so after a
|
||||
* restart every absent seat reads as "not here yet", which is the more cautious of the two.
|
||||
*/
|
||||
function presenceOfOthers(
|
||||
gameId: string,
|
||||
seat: PlayerIndex,
|
||||
session: GameSession,
|
||||
): NonNullable<Push['presence']> {
|
||||
const conns = gameConnections.get(gameId);
|
||||
const ever = everConnected.get(gameId) ?? new Set<PlayerIndex>();
|
||||
const out: NonNullable<Push['presence']> = [];
|
||||
for (let other = 0 as PlayerIndex; other < session.playerCount; other++) {
|
||||
// A bot holds no connection and never will, so reporting it would put "waiting on Bot 1 — not
|
||||
// here yet" on every screen for the whole game. Found by playing a real 3-seat game.
|
||||
if (other === seat || session.isBot(other)) continue;
|
||||
out.push({ seat: other, connected: conns?.has(other) === true, seen: ever.has(other) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function broadcastLobby(gameId: string): void {
|
||||
const lobby = lobbies.get(gameId);
|
||||
const conns = lobbyConnections.get(gameId);
|
||||
if (!lobby || !conns) return;
|
||||
for (const [token, res] of conns) {
|
||||
const ps = sessions.get(token);
|
||||
if (!ps) continue;
|
||||
writeSse(res, 0, { lobby, you: ps.player, started: false } satisfies LobbyPush);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistLobby(lobby: Lobby): Promise<void> {
|
||||
lobbies.set(lobby.gameId, lobby);
|
||||
gameCodes.set(lobby.gameCode, lobby.gameId);
|
||||
await writeLobby(opts.dataDir, lobby);
|
||||
await upsertIndexEntry(opts.dataDir, { gameId: lobby.gameId, gameCode: lobby.gameCode, status: 'lobby' });
|
||||
}
|
||||
|
||||
async function persistSession(ps: PlayerSession): Promise<void> {
|
||||
sessions.set(ps.token, ps);
|
||||
await persistSessionsOf(ps.gameId);
|
||||
}
|
||||
|
||||
/** Rewrites one game's `sessions.json` from what is in memory — after a token is revoked as much
|
||||
* as after one is issued, or a restart would hand the seat back to a browser that left it. */
|
||||
async function persistSessionsOf(gameId: string): Promise<void> {
|
||||
const all = [...sessions.values()].filter((s) => s.gameId === gameId);
|
||||
await writeSessions(opts.dataDir, gameId, all);
|
||||
}
|
||||
|
||||
/**
|
||||
* ONE INTENT AT A TIME PER GAME — `protocol.md` §5's serialisation rule, which the intent handler
|
||||
* relied on Node's single thread to keep and did not (v0.8.4): the `await` on the disk write is
|
||||
* an interleaving point, so two moves arriving together could both apply in memory, race their
|
||||
* writes to one `.tmp`, and hand their broadcasts to the clients in the wrong order. Everything a
|
||||
* move does — apply, persist, answer, broadcast — now runs as one unit behind the move before it.
|
||||
*/
|
||||
const gameQueues = new Map<string, Promise<unknown>>();
|
||||
function inTurn<T>(gameId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = gameQueues.get(gameId) ?? Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
gameQueues.set(gameId, next.catch(() => undefined));
|
||||
return next;
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
void (async () => {
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
||||
|
||||
// -- Is anyone home? --------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* THE ONE ROUTE THAT EXISTS TO BE FAILED.
|
||||
*
|
||||
* The same `dist/` is served two ways: by this server, and as a plain static upload with no
|
||||
* server behind it at all (`scripts/deploy-web.ts`). The bundle is byte-identical either way
|
||||
* — one client, mode decided at runtime (D4) — so the page cannot know from its own build
|
||||
* which it is, and every other route here answers a 404 for a path it does not have, exactly
|
||||
* as a static host would. Nothing distinguished them until this did.
|
||||
*
|
||||
* Unauthenticated on purpose: it says only that a Station Master server is answering, which
|
||||
* is what the client is about to offer the player anyway. It reveals no game and no seat.
|
||||
*/
|
||||
if (url.pathname === '/api/health' && req.method === 'GET') {
|
||||
// `summary()` rather than `exportSave()`: this is polled on a timer, and the save copies
|
||||
// every intent of every game to answer a question about none of them.
|
||||
let active = 0;
|
||||
for (const g of games.values()) if (g.summary().status === 'active') active++;
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
service: 'station-master',
|
||||
engineVersion: opts.engineVersion,
|
||||
games: { active, lobby: lobbies.size },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// -- Administration: listing, exporting and deleting games ------------------------------
|
||||
|
||||
if (url.pathname === '/api/games' || url.pathname.startsWith('/api/games/')) {
|
||||
// Unset means the routes are not here — indistinguishable from any other unknown path, so
|
||||
// nothing advertises an administrative surface to someone probing for one.
|
||||
if (!opts.adminSecret) {
|
||||
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||
return;
|
||||
}
|
||||
if (req.headers['x-admin-secret'] !== opts.adminSecret) {
|
||||
sendJson(res, 403, { error: 'bad or missing admin secret' });
|
||||
return;
|
||||
}
|
||||
|
||||
const codes = new Map((await readIndex(opts.dataDir)).map((e) => [e.gameId, e.gameCode]));
|
||||
|
||||
if (url.pathname === '/api/games' && req.method === 'GET') {
|
||||
const running = [...games.entries()].map(([gameId, g]) => ({
|
||||
gameId,
|
||||
gameCode: codes.get(gameId) ?? null,
|
||||
state: 'running' as const,
|
||||
/**
|
||||
* WHICH SEATS A PERSON IS SITTING IN — Gitea#33.
|
||||
*
|
||||
* `playerNames` cannot answer it: a bot's name is just a name, and telling the two apart
|
||||
* by matching "Bot 1" would be guessing at a label. `sessions` holds humans and only
|
||||
* humans, so this is the fact rather than an inference — and it is what lets the seat
|
||||
* recovery action offer real players instead of chairs no token was ever issued for.
|
||||
*/
|
||||
seatedPlayers: [...sessions.values()]
|
||||
.filter((s) => s.gameId === gameId)
|
||||
.map((s) => s.player),
|
||||
...g.summary(),
|
||||
}));
|
||||
// A lobby has no game to summarize yet — it is reported as what it is, so an
|
||||
// administrator sees a table that never started rather than nothing at all.
|
||||
const waiting = [...lobbies.values()].map((l) => ({
|
||||
gameId: l.gameId,
|
||||
gameCode: l.gameCode,
|
||||
state: 'lobby' as const,
|
||||
playerCount: l.seats.length,
|
||||
playerNames: l.seats.map((seat) =>
|
||||
seat === null ? '(empty)' : seat.kind === 'bot' ? 'Bot' : seat.displayName,
|
||||
),
|
||||
createdAt: l.createdAt,
|
||||
}));
|
||||
sendJson(res, 200, { games: [...running, ...waiting] });
|
||||
return;
|
||||
}
|
||||
|
||||
const match = /^\/api\/games\/([^/]+)(\/save|\/claim)?$/.exec(url.pathname);
|
||||
const gameId = match?.[1];
|
||||
// Compared explicitly rather than tested for truthiness: with two suffixes in the group, a
|
||||
// bare `match?.[2]` would let a GET on `/claim` fall into the `/save` branch below.
|
||||
const suffix = match?.[2];
|
||||
if (!gameId) {
|
||||
sendJson(res, 404, { error: 'no such route' });
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* MINT A SEAT RECOVERY CODE FOR ONE PLAYER — Gitea#33.
|
||||
*
|
||||
* The token is the only identity this game has and it lives in one browser's `localStorage`;
|
||||
* lose it and the seat is unreachable, because nothing else here will accept a claim to it.
|
||||
* This is the supported way back, and it is administrative on purpose: whoever runs the
|
||||
* server decides that a particular player has lost their seat, which is a judgement no
|
||||
* automated route can make safely.
|
||||
*
|
||||
* IT HANDS BACK A CODE, NOT THE TOKEN. §1 says keep the token out of URLs, and the code is
|
||||
* going into one. Short-lived and single-use (`claims.ts`), so a link left in a chat window
|
||||
* is worth nothing by the time anyone finds it.
|
||||
*/
|
||||
if (suffix === '/claim' && req.method === 'POST') {
|
||||
const body = (await readJson(req)) as { player?: number };
|
||||
const ps = [...sessions.values()].find((s) => s.gameId === gameId && s.player === body.player);
|
||||
if (!ps) {
|
||||
sendJson(res, 404, { error: 'no such seat' });
|
||||
return;
|
||||
}
|
||||
const { code, expiresAt } = claims.mint(ps.token, gameId, Date.now());
|
||||
sendJson(res, 200, {
|
||||
code,
|
||||
expiresAt,
|
||||
player: ps.player,
|
||||
displayName: ps.displayName,
|
||||
gameCode: codes.get(gameId) ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (suffix === '/save' && req.method === 'GET') {
|
||||
const session = games.get(gameId);
|
||||
if (!session) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { gameCode: codes.get(gameId) ?? null, save: session.exportSave() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
const session = games.get(gameId);
|
||||
const lobby = lobbies.get(gameId);
|
||||
if (!session && !lobby) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
return;
|
||||
}
|
||||
// The save goes back with the deletion, so a game can never be destroyed without its
|
||||
// record being handed to whoever destroyed it — the intents ARE the game (D5), so this
|
||||
// is the whole thing, replayable later, not a summary of it.
|
||||
const save = session?.exportSave() ?? null;
|
||||
|
||||
// Everyone watching is told the game is gone before its files are, rather than being
|
||||
// left on a stream that will never push again.
|
||||
for (const [, watcher] of gameConnections.get(gameId) ?? []) watcher.end();
|
||||
gameConnections.delete(gameId);
|
||||
for (const [, watcher] of lobbyConnections.get(gameId) ?? []) watcher.end();
|
||||
lobbyConnections.delete(gameId);
|
||||
|
||||
games.delete(gameId);
|
||||
lobbies.delete(gameId);
|
||||
gameEventIds.delete(gameId);
|
||||
const code = lobby?.gameCode ?? codes.get(gameId);
|
||||
if (code) gameCodes.delete(code);
|
||||
for (const [token, ps] of [...sessions]) if (ps.gameId === gameId) sessions.delete(token);
|
||||
|
||||
await removeIndexEntry(opts.dataDir, gameId);
|
||||
await deleteGame(opts.dataDir, gameId);
|
||||
sendJson(res, 200, { ok: true, gameCode: code ?? null, save });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 405, { error: 'method not allowed' });
|
||||
return;
|
||||
}
|
||||
|
||||
// -- Lobby: creating and joining (the door — join-secret gated) --------------------------
|
||||
|
||||
if (url.pathname === '/api/lobby/create' && req.method === 'POST') {
|
||||
const body = (await readJson(req)) as {
|
||||
secret?: string;
|
||||
config?: GameConfig;
|
||||
displayName?: string;
|
||||
players?: number;
|
||||
seed?: number | null;
|
||||
};
|
||||
if (body.secret !== opts.joinSecret) {
|
||||
sendJson(res, 403, { error: 'bad or missing secret' });
|
||||
return;
|
||||
}
|
||||
if (!body.config || typeof body.displayName !== 'string' || body.displayName.trim() === '') {
|
||||
sendJson(res, 400, { error: 'expected { secret, config, displayName, players }' });
|
||||
return;
|
||||
}
|
||||
// The table size is the host's to choose and is fixed from here on, so it is validated at
|
||||
// the door rather than at Start — `createLobby` builds the seats array from it.
|
||||
const players = body.players ?? 0;
|
||||
if (!Number.isInteger(players) || !playerCountAllowed(body.config.mode, players)) {
|
||||
sendJson(res, 400, { error: 'BAD_PLAYER_COUNT' });
|
||||
return;
|
||||
}
|
||||
const gameCode = freshGameCode((code) => gameCodes.has(code));
|
||||
const seed = typeof body.seed === 'number' && Number.isFinite(body.seed) ? Math.trunc(body.seed) : null;
|
||||
const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode, players, seed);
|
||||
await persistLobby(lobby);
|
||||
await persistSession(session);
|
||||
sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/lobby/join' && req.method === 'POST') {
|
||||
const body = (await readJson(req)) as { secret?: string; gameCode?: string; displayName?: string };
|
||||
if (body.secret !== opts.joinSecret) {
|
||||
sendJson(res, 403, { error: 'bad or missing secret' });
|
||||
return;
|
||||
}
|
||||
if (typeof body.gameCode !== 'string' || typeof body.displayName !== 'string' || body.displayName.trim() === '') {
|
||||
sendJson(res, 400, { error: 'expected { secret, gameCode, displayName }' });
|
||||
return;
|
||||
}
|
||||
const gameId = gameCodes.get(body.gameCode.trim().toUpperCase());
|
||||
const lobby = gameId ? lobbies.get(gameId) : undefined;
|
||||
if (!lobby) {
|
||||
// A game code that already started is no longer in `lobbies` at all — same NOT_FOUND a
|
||||
// typo gets, which tells a latecomer "that game is gone" without leaking which case it was.
|
||||
sendJson(res, 404, { error: 'no open lobby with that code' });
|
||||
return;
|
||||
}
|
||||
const result = joinLobby(lobby, body.displayName.trim());
|
||||
if (!result.ok) {
|
||||
sendJson(res, 409, { error: result.code });
|
||||
return;
|
||||
}
|
||||
await persistLobby(result.lobby);
|
||||
await persistSession(result.session);
|
||||
broadcastLobby(lobby.gameId);
|
||||
sendJson(res, 200, {
|
||||
gameId: lobby.gameId,
|
||||
gameCode: lobby.gameCode,
|
||||
token: result.session.token,
|
||||
player: result.session.player,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// -- Lobby: seating, once inside (token-authenticated) ------------------------------------
|
||||
|
||||
if (url.pathname === '/api/lobby/bot' && req.method === 'POST') {
|
||||
const body = (await readJson(req)) as { token?: string; seat?: number; filled?: boolean };
|
||||
const ps = typeof body.token === 'string' ? sessions.get(body.token) : undefined;
|
||||
const lobby = ps ? lobbies.get(ps.gameId) : undefined;
|
||||
if (!ps || !lobby) {
|
||||
sendJson(res, 404, { error: 'no such lobby' });
|
||||
return;
|
||||
}
|
||||
if (lobby.hostToken !== ps.token) {
|
||||
sendJson(res, 403, { error: 'NOT_HOST' });
|
||||
return;
|
||||
}
|
||||
if (typeof body.seat !== 'number' || typeof body.filled !== 'boolean') {
|
||||
sendJson(res, 400, { error: 'expected { token, seat, filled }' });
|
||||
return;
|
||||
}
|
||||
const updated = setBotSeat(lobby, body.seat as PlayerIndex, body.filled);
|
||||
await persistLobby(updated);
|
||||
broadcastLobby(lobby.gameId);
|
||||
sendJson(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* GIVING UP A SEAT — the player's own, or (host only) somebody else's.
|
||||
*
|
||||
* There was no door out of a lobby before this: a mis-join or a player who wandered off left a
|
||||
* chair that could not be freed, and a table that cannot start until every chair is taken.
|
||||
* The host's "remove" and a player's "Leave" are the same act from opposite ends, so they are
|
||||
* one route — `seat` names somebody else's chair and is refused to anyone but the host.
|
||||
*/
|
||||
if (url.pathname === '/api/lobby/leave' && req.method === 'POST') {
|
||||
const body = (await readJson(req)) as { token?: string; seat?: number };
|
||||
const ps = typeof body.token === 'string' ? sessions.get(body.token) : undefined;
|
||||
const lobby = ps ? lobbies.get(ps.gameId) : undefined;
|
||||
if (!ps || !lobby) {
|
||||
sendJson(res, 404, { error: 'no such lobby' });
|
||||
return;
|
||||
}
|
||||
const seat = typeof body.seat === 'number' ? (body.seat as PlayerIndex) : undefined;
|
||||
if (seat !== undefined && seat !== ps.player && lobby.hostToken !== ps.token) {
|
||||
sendJson(res, 403, { error: 'NOT_HOST' });
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* THE SEAT GOES, AND SO DOES THE TOKEN THAT HELD IT (v0.8.4).
|
||||
*
|
||||
* Leaving used to free the chair and keep the session: the token stayed in `sessions` and
|
||||
* on disk, and `joinLobby` hands a vacated chair to the next arrival. So a player who left
|
||||
* (or was removed) still held a token for seat N, and once somebody else sat in seat N and
|
||||
* the game began, `/api/stream` served that token the new occupant's hand and `/api/intent`
|
||||
* let it move for them — and its stream connection displaced theirs. Revoked here, before
|
||||
* the chair is offered to anyone.
|
||||
*/
|
||||
const occupant = seat === undefined ? ps : [...sessions.values()].find((s) => s.gameId === lobby.gameId && s.player === seat);
|
||||
const result = leaveLobby(lobby, ps.token, seat);
|
||||
if (result.empty) {
|
||||
// Nobody human is left to start it. Everything about this lobby goes, including the code,
|
||||
// so it cannot be joined into a game that will never begin.
|
||||
lobbies.delete(lobby.gameId);
|
||||
gameCodes.delete(lobby.gameCode);
|
||||
for (const [, watcher] of lobbyConnections.get(lobby.gameId) ?? []) watcher.end();
|
||||
lobbyConnections.delete(lobby.gameId);
|
||||
for (const [token, s] of [...sessions]) if (s.gameId === lobby.gameId) sessions.delete(token);
|
||||
await deleteLobby(opts.dataDir, lobby.gameId);
|
||||
// The row goes with the lobby rather than being marked: a game that never started is not a
|
||||
// game an administrator has any use for a record of.
|
||||
await removeIndexEntry(opts.dataDir, lobby.gameId);
|
||||
sendJson(res, 200, { ok: true, closed: true });
|
||||
return;
|
||||
}
|
||||
if (occupant && result.lobby !== lobby) {
|
||||
sessions.delete(occupant.token);
|
||||
lobbyConnections.get(lobby.gameId)?.get(occupant.token)?.end();
|
||||
lobbyConnections.get(lobby.gameId)?.delete(occupant.token);
|
||||
await persistSessionsOf(lobby.gameId);
|
||||
}
|
||||
await persistLobby(result.lobby);
|
||||
broadcastLobby(lobby.gameId);
|
||||
sendJson(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHAT AM I ABOUT TO JOIN? Read-only, takes no seat, and gated by the same join secret the
|
||||
* door itself is.
|
||||
*
|
||||
* A player used to have to take a chair before they could see a single rule of the game they
|
||||
* were sitting down to — and until 2026-08-23 there was then no way back out of it.
|
||||
*/
|
||||
if (url.pathname === '/api/lobby/preview' && req.method === 'GET') {
|
||||
if (url.searchParams.get('secret') !== opts.joinSecret) {
|
||||
sendJson(res, 403, { error: 'bad or missing secret' });
|
||||
return;
|
||||
}
|
||||
const code = (url.searchParams.get('gameCode') ?? '').trim().toUpperCase();
|
||||
const gameId = gameCodes.get(code);
|
||||
const lobby = gameId ? lobbies.get(gameId) : undefined;
|
||||
if (!lobby) {
|
||||
sendJson(res, 404, { error: 'no open lobby with that code' });
|
||||
return;
|
||||
}
|
||||
const hostSeat = lobby.seats.find((seat) => seat?.kind === 'human' && seat.token === lobby.hostToken);
|
||||
const preview: LobbyPreview = {
|
||||
gameCode: lobby.gameCode,
|
||||
hostName: hostSeat?.kind === 'human' ? hostSeat.displayName : 'unknown',
|
||||
config: lobby.config,
|
||||
players: lobby.seats.length,
|
||||
seated: lobby.seats.map((seat, i) => ({
|
||||
seat: i,
|
||||
who: seat?.kind === 'human' ? seat.displayName : null,
|
||||
bot: seat?.kind === 'bot',
|
||||
})),
|
||||
};
|
||||
sendJson(res, 200, preview);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/lobby/start' && req.method === 'POST') {
|
||||
const body = (await readJson(req)) as { token?: string };
|
||||
const ps = typeof body.token === 'string' ? sessions.get(body.token) : undefined;
|
||||
const lobby = ps ? lobbies.get(ps.gameId) : undefined;
|
||||
if (!ps || !lobby) {
|
||||
sendJson(res, 404, { error: 'no such lobby' });
|
||||
return;
|
||||
}
|
||||
const result = startLobby(lobby, ps.token);
|
||||
if (!result.ok) {
|
||||
sendJson(res, 409, { error: result.code });
|
||||
return;
|
||||
}
|
||||
// The host's seed if they named one; otherwise a fresh random deal.
|
||||
const session = createSession(
|
||||
lobby.seed ?? Math.floor(Math.random() * 1e9),
|
||||
lobby.config,
|
||||
result.playerNames,
|
||||
result.botSeats,
|
||||
);
|
||||
games.set(lobby.gameId, session);
|
||||
lobbies.delete(lobby.gameId);
|
||||
// Every SSE watcher on the LOBBY stream is done — the game stream is what carries the game
|
||||
// forward from here. `started: true` on one last message, THEN close, is what lets a
|
||||
// still-open lobby tab tell "the game began" apart from a network hiccup `EventSource`
|
||||
// would otherwise silently retry through.
|
||||
for (const [watcherToken, watcherRes] of lobbyConnections.get(lobby.gameId) ?? []) {
|
||||
const watcherPs = sessions.get(watcherToken);
|
||||
if (watcherPs) writeSse(watcherRes, 0, { lobby, you: watcherPs.player, started: true } satisfies LobbyPush);
|
||||
watcherRes.end();
|
||||
}
|
||||
lobbyConnections.delete(lobby.gameId);
|
||||
|
||||
await writeGame(gameDir(opts.dataDir, lobby.gameId), session.exportSave(), opts.engineVersion);
|
||||
await upsertIndexEntry(opts.dataDir, { gameId: lobby.gameId, gameCode: lobby.gameCode, status: 'active' });
|
||||
await deleteLobby(opts.dataDir, lobby.gameId);
|
||||
sendJson(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/lobby/stream' && req.method === 'GET') {
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
const ps = sessions.get(token);
|
||||
const lobby = ps ? lobbies.get(ps.gameId) : undefined;
|
||||
if (!ps || !lobby) {
|
||||
sendJson(res, 404, { error: 'no such lobby' });
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
||||
const conns = lobbyConnections.get(lobby.gameId) ?? new Map<string, ServerResponse>();
|
||||
conns.set(token, res);
|
||||
lobbyConnections.set(lobby.gameId, conns);
|
||||
writeSse(res, 0, { lobby, you: ps.player, started: false } satisfies LobbyPush);
|
||||
const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS);
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
const live = lobbyConnections.get(lobby.gameId);
|
||||
if (live?.get(token) === res) live.delete(token);
|
||||
// `lobby-and-sessions.md` §2 — host rights pass to the earliest-joined remaining player
|
||||
// if the host's connection closes before start. `lobbies.get` again, not the captured
|
||||
// `lobby`, because it may have changed (another join, another bot toggle) since connect.
|
||||
const current = lobbies.get(lobby.gameId);
|
||||
if (current && current.hostToken === token) {
|
||||
persistLobby(reassignHost(current, token))
|
||||
.then(() => broadcastLobby(lobby.gameId))
|
||||
.catch((err: unknown) => console.error(`reassigning the host of ${lobby.gameId} failed:`, err));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// -- The running game (token-authenticated) ------------------------------------------------
|
||||
|
||||
/**
|
||||
* IS THIS TOKEN STILL GOOD FOR ANYTHING?
|
||||
*
|
||||
* A browser remembers its session in `localStorage` and re-enters the game on the next load
|
||||
* without asking, which is what makes reconnection seamless — and what leaves it stranded
|
||||
* when the game is gone. `EventSource` cannot report a status code and retries a 404
|
||||
* silently forever, so the client needs somewhere cheap to ask a yes/no question. Two ways a
|
||||
* game legitimately disappears under a player: an engine-version bump refuses to resume it
|
||||
* (D7), and an administrator ends it (`DELETE /api/games/<id>`).
|
||||
*/
|
||||
if (url.pathname === '/api/session' && req.method === 'GET') {
|
||||
const ps = sessions.get(url.searchParams.get('token') ?? '');
|
||||
const live = ps ? games.get(ps.gameId) : undefined;
|
||||
if (!ps || !live) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { gameId: ps.gameId, player: ps.player });
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* SPEND A SEAT RECOVERY CODE — Gitea#33, the other half of `/api/games/<id>/claim`.
|
||||
*
|
||||
* NOT GATED BY THE ADMIN SECRET, and it must not be: the player following the link is the one
|
||||
* person in this story who holds no secret at all. The code IS the authorisation — unguessable,
|
||||
* single-use and short-lived — which is the same shape as the session token it hands back, and
|
||||
* why minting one is the administrative act rather than spending one.
|
||||
*
|
||||
* The token travels in the response BODY of a POST, never in a URL (`lobby-and-sessions.md`
|
||||
* §1). One answer for unknown, spent and expired codes, so this cannot be used to probe which.
|
||||
*/
|
||||
if (url.pathname === '/api/claim' && req.method === 'POST') {
|
||||
const body = (await readJson(req)) as { code?: string };
|
||||
const claimed = typeof body.code === 'string' ? claims.redeem(body.code, Date.now()) : null;
|
||||
const ps = claimed ? sessions.get(claimed.token) : undefined;
|
||||
const live = ps ? games.get(ps.gameId) : undefined;
|
||||
if (!claimed || !ps || !live) {
|
||||
sendJson(res, 404, { error: 'no such claim' });
|
||||
return;
|
||||
}
|
||||
const codes = new Map((await readIndex(opts.dataDir)).map((e) => [e.gameId, e.gameCode]));
|
||||
sendJson(res, 200, {
|
||||
token: ps.token,
|
||||
gameId: ps.gameId,
|
||||
player: ps.player,
|
||||
gameCode: codes.get(ps.gameId) ?? '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* THIS SEAT'S OWN GAME, AS A SAVE (playtest, 2026-09-15: "most of the time, I want to go ahead and
|
||||
* just save it as a JSON file in my Downloads folder").
|
||||
*
|
||||
* The administrative export at `/api/games/<id>/save` is gated on the admin secret, which a player
|
||||
* does not have and should not need: a save is the seed and the moves, and every one of those moves
|
||||
* is already on this player's screen. So the seat's own session token is the gate, exactly as it is
|
||||
* for `/api/stream` and `/api/intent` — it proves which game and which chair. The page turns the
|
||||
* JSON into a file (`main.ts`'s `downloadSave`).
|
||||
*
|
||||
* NOT WHILE A COMPETITIVE GAME IS RUNNING (Jesse's ruling, 2026-09-29, TODO #117). "Every one of
|
||||
* those moves is already on this player's screen" was true of the moves and false of the SEED,
|
||||
* which a save has to carry to be a save — and the seed is every rival's hand and the deck order
|
||||
* for the rest of the game, the exact thing `game.ts` strips from the shared log. So a
|
||||
* Competitive seat gets its save once the game is over; a Co-op table has nothing to hide from
|
||||
* itself and gets it at any time, as does a one-seat game.
|
||||
*/
|
||||
if (url.pathname === '/api/save' && req.method === 'GET') {
|
||||
const ps = sessions.get(url.searchParams.get('token') ?? '');
|
||||
const session = ps ? games.get(ps.gameId) : undefined;
|
||||
if (!ps || !session) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
return;
|
||||
}
|
||||
const save = session.exportSave();
|
||||
const competitive = save.config.mode === 'competitive' && save.playerNames.length > 1;
|
||||
if (competitive && save.status !== 'finished') {
|
||||
sendJson(res, 403, { error: 'SAVE_AFTER_FINISH' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { gameId: ps.gameId, save });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/stream' && req.method === 'GET') {
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
const ps = sessions.get(token);
|
||||
const session = ps ? games.get(ps.gameId) : undefined;
|
||||
if (!ps || !session) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
return;
|
||||
}
|
||||
const { gameId, player: seat } = ps;
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
||||
const conns = gameConnections.get(gameId) ?? new Map<PlayerIndex, ServerResponse>();
|
||||
conns.set(seat, res);
|
||||
gameConnections.set(gameId, conns);
|
||||
const ever = everConnected.get(gameId) ?? new Set<PlayerIndex>();
|
||||
ever.add(seat);
|
||||
everConnected.set(gameId, ever);
|
||||
// The board, and who else is at the table — the second half used to be missing entirely.
|
||||
const first = session.connect(seat);
|
||||
first.presence = presenceOfOthers(gameId, seat, session);
|
||||
writeSse(res, nextEventId(gameId, seat), first);
|
||||
broadcastPresence(gameId, seat, true);
|
||||
// Idle for minutes at a time is the expected shape of this game (multiplayer.md §9) — a
|
||||
// silent SSE connection is exactly what a proxy in the path may reap. A comment line is not a
|
||||
// real event (EventSource ignores lines starting with `:`), so it costs the client nothing.
|
||||
const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS);
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
const live = gameConnections.get(gameId);
|
||||
if (live?.get(seat) === res) live.delete(seat);
|
||||
broadcastPresence(gameId, seat, false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/intent' && req.method === 'POST') {
|
||||
// Token comes from the QUERY STRING, matching `/api/stream` and matching what
|
||||
// `web/session.ts`'s `RemoteSession.submit` actually sends (`fetch('/api/intent?token=…')`)
|
||||
// — the body carries only what changes per call, `{ seq, intent }`.
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
const ps = sessions.get(token);
|
||||
const session = ps ? games.get(ps.gameId) : undefined;
|
||||
if (!ps || !session) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
return;
|
||||
}
|
||||
const body = (await readJson(req)) as { seq?: number; intent?: Intent };
|
||||
if (typeof body.seq !== 'number' || !body.intent) {
|
||||
sendJson(res, 400, { error: 'expected { seq, intent }' });
|
||||
return;
|
||||
}
|
||||
const { seq, intent } = body;
|
||||
await inTurn(ps.gameId, async () => {
|
||||
const result = session.intent(ps.player, seq, intent);
|
||||
if (result.accepted) {
|
||||
// Persisted BEFORE the response goes out — "accepted" should mean "durably on disk" at
|
||||
// this scale, not just "applied in memory" (§12 step 14).
|
||||
const dir = gameDir(opts.dataDir, ps.gameId);
|
||||
try {
|
||||
await writeGame(dir, session.exportSave(), opts.engineVersion);
|
||||
if (result.timing) await appendTiming(dir, result.timing);
|
||||
if (session.exportSave().status === 'finished') {
|
||||
// `upsertIndexEntry` replaces the WHOLE row for this `gameId`, so the code has to be
|
||||
// carried forward here rather than left blank — `gameCodes` is the only place still
|
||||
// holding it once a lobby's own record is gone.
|
||||
const gameCode = [...gameCodes.entries()].find(([, id]) => id === ps.gameId)?.[0] ?? '';
|
||||
await upsertIndexEntry(opts.dataDir, { gameId: ps.gameId, gameCode, status: 'finished' });
|
||||
}
|
||||
} catch (err) {
|
||||
// The move IS applied — every seat's game has moved on — so the table is told and the
|
||||
// disk's failure is the operator's to see in the log. Answering 500 here told the one
|
||||
// player who moved that their move was refused, while everyone else watched it happen.
|
||||
console.error(`persisting ${ps.gameId} after a move failed:`, err);
|
||||
}
|
||||
}
|
||||
sendJson(res, 200, result.accepted ? { ok: true } : { ok: false, code: result.code });
|
||||
if (result.accepted) broadcastGame(ps.gameId, result.pushes);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||
})().catch((err: unknown) => {
|
||||
if (err instanceof HttpError) {
|
||||
sendJson(res, err.status, { error: err.message });
|
||||
return;
|
||||
}
|
||||
// Logged here, not echoed: an fs error's message carries the absolute path of the data
|
||||
// directory, which is the operator's to know and not the caller's.
|
||||
console.error(`${req.method ?? ''} ${req.url ?? ''} failed:`, err);
|
||||
sendJson(res, 500, { error: 'internal error' });
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(opts.port, opts.bindAddress);
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Process bootstrap — Phase 2 §12 step 8, extended for Phase 3 (§12 steps 14-15) load-on-start and
|
||||
* Phase 4 (§12 steps 17-20) to resume every saved game and lobby, not just one.
|
||||
*
|
||||
* Run with: node src/server/index.ts
|
||||
*
|
||||
* Env-configured, no config file — matches how the rest of this project's dev-side tooling reads
|
||||
* `process.env` directly (`scripts/build-web.ts`'s `BUILD_DIST_DIR`).
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { startServer } from './http.ts';
|
||||
import { gameDir, loadGame, readIndex, readLobby, readSessions } from './persistence.ts';
|
||||
import { tryResumeSession } from './session.ts';
|
||||
import type { GameSession } from './session.ts';
|
||||
import type { Lobby, PlayerSession } from './lobby.ts';
|
||||
|
||||
const port = Number(process.env['PORT'] ?? 8081);
|
||||
const bindAddress = process.env['BIND_ADDRESS'] ?? '0.0.0.0';
|
||||
const joinSecret = process.env['JOIN_SECRET'];
|
||||
/**
|
||||
* Optional, unlike `JOIN_SECRET`: a server with no administrator is a perfectly good server, and
|
||||
* refusing to boot without one would break every existing deployment and every dev run. Unset
|
||||
* simply means the admin routes are not there (`http.ts`), which is the safe default — the
|
||||
* capability has to be granted, never merely left ungated.
|
||||
*/
|
||||
const adminSecret = process.env['ADMIN_SECRET'];
|
||||
const distDir = resolve(process.env['DIST_DIR'] ?? 'dist');
|
||||
const dataDir = resolve(process.env['DATA_DIR'] ?? 'data');
|
||||
|
||||
if (!joinSecret) {
|
||||
console.error('JOIN_SECRET must be set — a server-wide secret, passed out of band (D14).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// `package.json`'s version IS `engineVersion` (§12 step 15) — the same reading `scripts/build-web.ts`'s
|
||||
// `buildStamp()` already does, just from `src/server/` rather than the repo root script directory.
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const engineVersion = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }).version;
|
||||
|
||||
const initialGames = new Map<string, GameSession>();
|
||||
const initialLobbies = new Map<string, Lobby>();
|
||||
const initialSessions = new Map<string, PlayerSession>();
|
||||
|
||||
const index = await readIndex(dataDir);
|
||||
// Said before the loop, not after it: replaying is the reason a restart pauses before the port
|
||||
// opens, and a log that only reports each game once it is done gives no warning of how much is
|
||||
// still to come.
|
||||
const resumable = index.filter((e) => e.status !== 'finished').length;
|
||||
if (resumable > 0) console.log(`Resuming ${resumable} saved game(s)…`);
|
||||
for (const entry of index) {
|
||||
const sessions = await readSessions(dataDir, entry.gameId);
|
||||
for (const s of sessions) initialSessions.set(s.token, s);
|
||||
|
||||
if (entry.status === 'lobby') {
|
||||
const lobby = await readLobby(dataDir, entry.gameId);
|
||||
if (lobby) initialLobbies.set(entry.gameId, lobby);
|
||||
continue;
|
||||
}
|
||||
|
||||
const loaded = await loadGame(gameDir(dataDir, entry.gameId));
|
||||
if (!loaded.found && loaded.corrupt) {
|
||||
// One unreadable file is one game lost, not every game (v0.8.4) — see `loadGame`.
|
||||
console.error(`Skipping ${entry.gameId} (${entry.gameCode}): game.json is unreadable — ${loaded.corrupt}. The file is left untouched.`);
|
||||
continue;
|
||||
}
|
||||
if (loaded.found) {
|
||||
let resumed: ReturnType<typeof tryResumeSession>;
|
||||
try {
|
||||
resumed = tryResumeSession(loaded.saved);
|
||||
} catch (err) {
|
||||
// A save that parses but is not the shape the replay expects (no config, a history entry
|
||||
// that is not an intent) throws inside the engine rather than being refused. Same answer:
|
||||
// this game, not the server.
|
||||
console.error(`Skipping ${entry.gameId} (${entry.gameCode}): the save could not be replayed — ${err instanceof Error ? err.message : String(err)}. The file is left untouched.`);
|
||||
continue;
|
||||
}
|
||||
if (resumed.ok) {
|
||||
initialGames.set(entry.gameId, resumed.session);
|
||||
console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`);
|
||||
} else {
|
||||
/**
|
||||
* The save does not replay under these rules, which is the only thing that has ever actually
|
||||
* mattered — and now the only thing asked. Says which move it choked on, because "some
|
||||
* version differs" was never enough to act on: the file is left untouched, so an operator who
|
||||
* wants the game back can put the previous version on and finish it.
|
||||
*/
|
||||
const f = resumed.failure;
|
||||
console.error(
|
||||
`Refusing to resume ${entry.gameId} (${entry.gameCode}): move ${f.stoppedAt + 1} of ${f.of} ` +
|
||||
`(${f.intent}) is rejected by the current rules with ${f.code}. Saved under engine ` +
|
||||
`version ${loaded.storedVersion}, this server is running ${engineVersion}. The file is ` +
|
||||
`left untouched.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// `entry.status === 'finished'` games are not resumed into memory at all — nothing plays them
|
||||
// forward, and their files stay on disk for post-game replay (`lobby-and-sessions.md` §6).
|
||||
}
|
||||
|
||||
startServer({
|
||||
port,
|
||||
bindAddress,
|
||||
joinSecret,
|
||||
adminSecret,
|
||||
distDir,
|
||||
dataDir,
|
||||
engineVersion,
|
||||
initialGames,
|
||||
initialLobbies,
|
||||
initialSessions,
|
||||
});
|
||||
console.log(
|
||||
`Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir} — ` +
|
||||
`${initialGames.size} game(s) and ${initialLobbies.size} lobby(ies) resumed.`,
|
||||
);
|
||||
if (!adminSecret) {
|
||||
console.log('ADMIN_SECRET is unset — the /api/games administration routes are disabled.');
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* The lobby — Phase 4 of `docs/architecture/multiplayer.md` (§12 steps 17-20), fully specified in
|
||||
* `docs/architecture/lobby-and-sessions.md`.
|
||||
*
|
||||
* Pure logic, no sockets, no filesystem, no in-memory registry — same split `session.ts` already
|
||||
* draws (`http.ts` wires connections and persistence to this; `persistence.ts` is the only thing
|
||||
* that touches disk). A `Lobby` exists only BEFORE `Lobby.Start`: once the config locks and the
|
||||
* `GameSession` is built, this module is done with that game — everything after is `session.ts`.
|
||||
*
|
||||
* WHY A SEPARATE FILE FROM `session.ts`. `session.ts` is already the "running game" module and is
|
||||
* large; a lobby's concerns — seating, host rights, the join secret's door versus a game code's
|
||||
* discovery, config locking — share almost no code with running a Stage clock, and mixing them would
|
||||
* make both harder to read for no reuse gained.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
|
||||
|
||||
/** `lobby-and-sessions.md` §1 — names the PLAYER, not the seat. Issued once, at join, and never
|
||||
* reissued: it is what makes reconnection work, so it must outlive the connection that first used
|
||||
* it. */
|
||||
export type PlayerSession = {
|
||||
token: string;
|
||||
gameId: string;
|
||||
player: PlayerIndex;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type LobbySeat = { kind: 'human'; token: string; displayName: string } | { kind: 'bot' } | null;
|
||||
|
||||
/** A game that has not started. `hostToken` ends its meaning at `Lobby.Start` — nothing here
|
||||
* survives into the running game except the seat list and the locked `config`. */
|
||||
export type Lobby = {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
hostToken: string;
|
||||
config: GameConfig;
|
||||
seats: LobbySeat[];
|
||||
/** Tokens in the order they joined — human seats only, host first — so a departed host's
|
||||
* replacement is unambiguous (`lobby-and-sessions.md` §2: "earliest-joined remaining player"). */
|
||||
joinOrder: string[];
|
||||
createdAt: number;
|
||||
/**
|
||||
* The seed the host asked for, or null for one picked at `Lobby.Start`. Chosen here rather than
|
||||
* at start because the same seed and the same settings deal the same railroad — which is only
|
||||
* useful if the person setting the game up can name it.
|
||||
*/
|
||||
seed: number | null;
|
||||
};
|
||||
|
||||
export type CreateResult = { lobby: Lobby; session: PlayerSession };
|
||||
export type JoinResult =
|
||||
| { ok: true; lobby: Lobby; session: PlayerSession }
|
||||
| { ok: false; code: 'LOBBY_FULL' | 'ALREADY_STARTED' | 'NAME_TAKEN' };
|
||||
/** `empty` when the last human has gone — the caller drops the lobby rather than leaving a table of
|
||||
* bots waiting for a host who no longer exists. */
|
||||
export type LeaveResult = { lobby: Lobby; empty: boolean };
|
||||
export type StartResult = { ok: true; playerNames: string[]; botSeats: PlayerIndex[] } | { ok: false; code: 'NOT_HOST' | 'BAD_PLAYER_COUNT' };
|
||||
|
||||
/**
|
||||
* `lobby-and-sessions.md` §2 — short and speakable, not an account name and not a UUID. A small
|
||||
* fixed word list rather than a dictionary: this is read aloud across a table or a call, not typed
|
||||
* from memory, so a handful of unambiguous railroad words serve better than a large random one.
|
||||
*/
|
||||
const CODE_WORDS = [
|
||||
'RAIL', 'YARD', 'DEPOT', 'COAL', 'MAIL', 'CABOOSE', 'SIGNAL', 'FREIGHT',
|
||||
'TRESTLE', 'HOPPER', 'TENDER', 'SIDING', 'GRADE', 'SPUR', 'WHISTLE', 'TROLLEY',
|
||||
];
|
||||
|
||||
function randomCode(): string {
|
||||
const word = CODE_WORDS[Math.floor(Math.random() * CODE_WORDS.length)]!;
|
||||
const digits = Math.floor(Math.random() * 10_000)
|
||||
.toString()
|
||||
.padStart(4, '0');
|
||||
return `${word}-${digits}`;
|
||||
}
|
||||
|
||||
/** Collision-checked against whatever codes the caller already knows about — `http.ts` holds the
|
||||
* live registry, so the check lives there rather than this module owning a set of its own. */
|
||||
export function freshGameCode(taken: (code: string) => boolean): string {
|
||||
let code = randomCode();
|
||||
while (taken(code)) code = randomCode();
|
||||
return code;
|
||||
}
|
||||
|
||||
/** `lobby-and-sessions.md` §2 — solitaire is exactly 1, competitive/coop are 2-4. Not a rules
|
||||
* limit: the engine will build a Division for any seat count. This is the lobby judgment, sized to
|
||||
* what has actually been played and tested (`test/multiplayer.test.ts` exercises 2, 3 and 4). */
|
||||
export function playerCountAllowed(mode: GameConfig['mode'], count: number): boolean {
|
||||
return mode === 'solitaire' ? count === 1 : count >= 2 && count <= 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* The creating player is the host and takes seat 0 (`lobby-and-sessions.md` §2).
|
||||
*
|
||||
* THE TABLE SIZE IS FIXED WHEN THE GAME IS CREATED, and `seats.length` is it.
|
||||
*
|
||||
* The host says how many are playing, so the seats array is built at full length with the host in
|
||||
* chair 0 and the rest empty. Nothing ever grows or shrinks it, which is what makes a gap
|
||||
* impossible to express rather than merely illegal — and that matters more than it looks: seats
|
||||
* used to be appended as people joined, so a bot dropped into a later chair padded the array with
|
||||
* a hole that silently blocked Start. It also removes any need to compact the seats at
|
||||
* `Lobby.Start`, and compaction would have shifted the `player` index every `PlayerSession`
|
||||
* already carries (`joinLobby` stamps it at join time, and `/api/stream` and `/api/intent` route
|
||||
* by it) — quietly handing a player somebody else's railroad.
|
||||
*
|
||||
* Knowing the count this early has one more consequence, and it is a bug fix: the config's
|
||||
* `minCombinedRevenue` is derived from the player count, and the lobby previously had to guess it
|
||||
* as 4 before anyone had sat down.
|
||||
*/
|
||||
export function createLobby(
|
||||
config: GameConfig,
|
||||
hostDisplayName: string,
|
||||
gameCode: string,
|
||||
players: number,
|
||||
seed: number | null = null,
|
||||
): CreateResult {
|
||||
const gameId = randomUUID();
|
||||
const token = randomUUID();
|
||||
const session: PlayerSession = { token, gameId, player: 0, displayName: hostDisplayName };
|
||||
const seats: LobbySeat[] = Array.from({ length: players }, (_, i) =>
|
||||
i === 0 ? { kind: 'human', token, displayName: hostDisplayName } : null,
|
||||
);
|
||||
const lobby: Lobby = {
|
||||
gameId,
|
||||
gameCode,
|
||||
hostToken: token,
|
||||
config,
|
||||
seats,
|
||||
joinOrder: [token],
|
||||
createdAt: Date.now(),
|
||||
seed,
|
||||
};
|
||||
return { lobby, session };
|
||||
}
|
||||
|
||||
/** Joins the first empty seat, or extends the seat list if every seat so far is filled and the
|
||||
* mode's cap allows one more (`lobby-and-sessions.md` §2's 2-4 is enforced at `Lobby.Start`, not
|
||||
* here — a 5th join before anyone starts is refused outright since there is no seat it could ever
|
||||
* play in, but the running count is checked against `playerCountAllowed` at every join too, so a
|
||||
* lobby can never grow the seats array past what could legally start). */
|
||||
export function joinLobby(lobby: Lobby, displayName: string): JoinResult {
|
||||
// The table was sized at creation, so joining takes an empty chair or none at all — there is no
|
||||
// longer an "append another seat" path for a late arrival to grow the game through.
|
||||
const seatIndex = lobby.seats.findIndex((s) => s === null);
|
||||
if (seatIndex < 0) return { ok: false, code: 'LOBBY_FULL' };
|
||||
|
||||
/**
|
||||
* TWO PLAYERS CANNOT SHARE A NAME (2026-08-23).
|
||||
*
|
||||
* The name is not decoration: it labels the district on the Division map, it is what the turn
|
||||
* chart means by "waiting on Jesse", and `record()` puts it in front of every line that player
|
||||
* causes. Two identical names make all three ambiguous, and there is no way to fix it once the
|
||||
* game starts — the names are locked into the session at `Lobby.Start`. Refused rather than
|
||||
* silently suffixed: a player should play under the name they chose, or be told to choose again.
|
||||
*/
|
||||
const wanted = displayName.trim().toLowerCase();
|
||||
const clash = lobby.seats.some((s) => s?.kind === 'human' && s.displayName.trim().toLowerCase() === wanted);
|
||||
if (clash) return { ok: false, code: 'NAME_TAKEN' };
|
||||
|
||||
const token = randomUUID();
|
||||
const session: PlayerSession = { token, gameId: lobby.gameId, player: seatIndex, displayName };
|
||||
const seats = [...lobby.seats];
|
||||
seats[seatIndex] = { kind: 'human', token, displayName };
|
||||
return {
|
||||
ok: true,
|
||||
lobby: { ...lobby, seats, joinOrder: [...lobby.joinOrder, token] },
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GIVING UP A SEAT — a player leaving, or the host clearing somebody out of a chair.
|
||||
*
|
||||
* There was no way out of a lobby at all before 2026-08-23: a mis-join or a player who wandered off
|
||||
* wedged the table, because Start needs every chair filled and `setBotSeat` refuses to touch an
|
||||
* occupied human seat. One function serves both, since they differ only in whose seat is named, and
|
||||
* `http.ts` is what checks that a caller naming somebody else's seat is the host.
|
||||
*
|
||||
* Host rights move exactly as they do on a dropped connection (`reassignHost`), and the caller is
|
||||
* told when the last human has gone so the lobby can be dropped rather than left orphaned.
|
||||
*/
|
||||
export function leaveLobby(lobby: Lobby, token: string, seat?: PlayerIndex): LeaveResult {
|
||||
const index =
|
||||
seat === undefined ? lobby.seats.findIndex((s) => s?.kind === 'human' && s.token === token) : seat;
|
||||
const occupant = index >= 0 ? (lobby.seats[index] ?? null) : null;
|
||||
if (index < 0 || occupant?.kind !== 'human') return { lobby, empty: false };
|
||||
|
||||
const seats = [...lobby.seats];
|
||||
seats[index] = null;
|
||||
const departing = occupant.token;
|
||||
const withoutThem: Lobby = {
|
||||
...lobby,
|
||||
seats,
|
||||
joinOrder: lobby.joinOrder.filter((t) => t !== departing),
|
||||
};
|
||||
const empty = !seats.some((s) => s?.kind === 'human');
|
||||
return { lobby: reassignHost(withoutThem, departing), empty };
|
||||
}
|
||||
|
||||
/** Host-only in effect (`http.ts` checks the caller's token against `hostToken` before calling
|
||||
* this) — marks an empty seat as bot-filled, or clears one back to empty. Never touches an occupied
|
||||
* human seat; the host removes a person by them leaving, not by overwriting their seat. */
|
||||
export function setBotSeat(lobby: Lobby, seat: PlayerIndex, filled: boolean): Lobby {
|
||||
const seats = [...lobby.seats];
|
||||
// No padding: a seat outside the table the host chose is not a seat, and inventing one is how
|
||||
// the old array grew holes in it.
|
||||
if (seat < 0 || seat >= seats.length) return lobby;
|
||||
if (filled) {
|
||||
if (seats[seat] !== null) return lobby;
|
||||
seats[seat] = { kind: 'bot' };
|
||||
} else {
|
||||
if (seats[seat]?.kind !== 'bot') return lobby;
|
||||
seats[seat] = null;
|
||||
}
|
||||
return { ...lobby, seats };
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's connection closed before `Lobby.Start`. `lobby-and-sessions.md` §2: rights pass to the
|
||||
* earliest-joined remaining player — remaining means still occupying a seat, not necessarily still
|
||||
* connected, since a momentary drop should not also cost the NEXT person the host chair. Returns
|
||||
* the lobby unchanged if the departing token was not the host, or if no other human seat exists.
|
||||
*/
|
||||
export function reassignHost(lobby: Lobby, departingToken: string): Lobby {
|
||||
if (lobby.hostToken !== departingToken) return lobby;
|
||||
const stillSeated = new Set(
|
||||
lobby.seats.flatMap((s) => (s?.kind === 'human' ? [s.token] : [])),
|
||||
);
|
||||
const next = lobby.joinOrder.find((t) => t !== departingToken && stillSeated.has(t));
|
||||
return next ? { ...lobby, hostToken: next } : lobby;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks the config, checks the player count, and hands back exactly what `session.ts`'s
|
||||
* `createSession` needs — this function does not call it, so `lobby.ts` never depends on
|
||||
* `session.ts` (the dependency runs the other way: `http.ts` calls both).
|
||||
*/
|
||||
export function startLobby(lobby: Lobby, callerToken: string): StartResult {
|
||||
if (callerToken !== lobby.hostToken) return { ok: false, code: 'NOT_HOST' };
|
||||
// Every chair at the table must be taken. The size itself was validated at creation and cannot
|
||||
// have moved since, so this is only ever waiting on the last empty seat to fill.
|
||||
if (lobby.seats.some((s) => s === null) || !playerCountAllowed(lobby.config.mode, lobby.seats.length)) {
|
||||
return { ok: false, code: 'BAD_PLAYER_COUNT' };
|
||||
}
|
||||
// Seat index IS player index — no compaction, because there is nothing to compact past.
|
||||
const taken = lobby.seats as Exclude<LobbySeat, null>[];
|
||||
// Bots are numbered rather than all being called "Bot": two of them at one table are two
|
||||
// different railroads, and a map labelling both the same cannot say which is which.
|
||||
let botNumber = 0;
|
||||
const playerNames = taken.map((s) => (s.kind === 'human' ? s.displayName : `Bot ${++botNumber}`));
|
||||
const botSeats = taken.flatMap((s, i) => (s.kind === 'bot' ? [i as PlayerIndex] : []));
|
||||
return { ok: true, playerNames, botSeats };
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Persistence — Phase 3 of `docs/architecture/multiplayer.md` (§12 steps 14-15), extended for
|
||||
* Phase 4 (§12 steps 17-20) to more than one game (`lobby-and-sessions.md` §6 specifies both
|
||||
* shapes and the reasoning behind them).
|
||||
*
|
||||
* ONE DIRECTORY PER GAME (`games/<gameId>/`), plus one top-level `index.json` naming every game so
|
||||
* `index.ts` can find and resume them all on boot without scanning the filesystem. Every write is
|
||||
* still a full-file atomic rewrite (write to `.tmp`, `rename` over the real path) rather than true
|
||||
* on-disk appending — §6 says the storage mechanism is genuinely open as long as the LOGICAL history
|
||||
* is never rewritten or reordered, which a full rewrite of an always-growing array satisfies, and at
|
||||
* the measured scale (~350 intents, a few hundred bytes per game) there is nothing to optimize yet.
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { SavedGame, TurnTiming } from './session.ts';
|
||||
import type { Lobby, PlayerSession } from './lobby.ts';
|
||||
|
||||
const GAME_FILE = 'game.json';
|
||||
const TIMINGS_FILE = 'turn-timings.json';
|
||||
const LOBBY_FILE = 'lobby.json';
|
||||
const SESSIONS_FILE = 'sessions.json';
|
||||
const INDEX_FILE = 'index.json';
|
||||
|
||||
type PersistedGame = SavedGame & { engineVersion: string };
|
||||
|
||||
/**
|
||||
* ONE WRITER AT A TIME PER FILE (v0.8.4).
|
||||
*
|
||||
* Every write here is read-modify-write or write-then-rename with an `await` in the middle, and
|
||||
* Node's single thread is no protection across an `await`: two requests for the same game could
|
||||
* both be inside `atomicWrite` at once. With ONE fixed `.tmp` name per path that tore the file —
|
||||
* measured at 200 rounds of two concurrent writes: every round lost one write to `rename` ENOENT,
|
||||
* and six left `game.json` as invalid JSON, which the boot then died on. So the temp name is unique
|
||||
* per write, and every write to a given path queues behind the one before it, which is also what
|
||||
* makes `upsertIndexEntry`'s "adds or updates exactly one row" claim true under two callers.
|
||||
*/
|
||||
const queues = new Map<string, Promise<unknown>>();
|
||||
let writeSerial = 0;
|
||||
|
||||
function serial<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = queues.get(key) ?? Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
queues.set(key, next.catch(() => undefined));
|
||||
return next;
|
||||
}
|
||||
|
||||
async function atomicWrite(path: string, text: string): Promise<void> {
|
||||
await serial(path, async () => {
|
||||
const tmp = `${path}.${process.pid}.${++writeSerial}.tmp`;
|
||||
await writeFile(tmp, text);
|
||||
await rename(tmp, path);
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeGame(dataDir: string, saved: SavedGame, engineVersion: string): Promise<void> {
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
const payload: PersistedGame = { engineVersion, ...saved };
|
||||
await atomicWrite(join(dataDir, GAME_FILE), JSON.stringify(payload, null, 1));
|
||||
}
|
||||
|
||||
export type LoadResult =
|
||||
/** `corrupt` names the parse error when the file is there and is not JSON — see `loadGame`. */
|
||||
| { found: false; corrupt?: string }
|
||||
/** The version that wrote the file, for diagnostics — it is no longer what decides. */
|
||||
| { found: true; saved: SavedGame; storedVersion: string };
|
||||
|
||||
/**
|
||||
* READS THE SAVE. DOES NOT JUDGE IT.
|
||||
*
|
||||
* This used to refuse any save whose `engineVersion` was not an exact match for the running one,
|
||||
* on the reasoning that a move legal under old rules may not be legal under new ones (D7). The
|
||||
* reasoning is sound and the test was not: the stamp is the PACKAGE version, which moves for
|
||||
* reasons that have nothing to do with the rules, so four consecutive releases destroyed every
|
||||
* game in progress — one of them a release that changed only how the board is drawn.
|
||||
*
|
||||
* Whether a save still replays is a question with an exact answer, so it is now asked directly:
|
||||
* `tryResumeSession` replays the intents and reports the first one the engine refuses, if any.
|
||||
* The version is kept and reported because it is useful in a failure, but it decides nothing.
|
||||
*/
|
||||
export async function loadGame(dataDir: string): Promise<LoadResult> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(join(dataDir, GAME_FILE), 'utf8');
|
||||
} catch {
|
||||
return { found: false };
|
||||
}
|
||||
/**
|
||||
* A FILE THAT IS NOT JSON IS REPORTED, NOT THROWN (v0.8.4). This parse was bare, and `index.ts`
|
||||
* awaited it at the top level — so one torn or half-edited `game.json` took the whole process
|
||||
* down before the port opened, and StartOS restarted it into the same file: every game on the
|
||||
* server unreachable because of one. The caller gets a reason to log and moves on.
|
||||
*/
|
||||
let payload: PersistedGame;
|
||||
try {
|
||||
payload = JSON.parse(text) as PersistedGame;
|
||||
} catch (err) {
|
||||
return { found: false, corrupt: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
if (!payload || typeof payload !== 'object' || !Array.isArray(payload.history)) {
|
||||
return { found: false, corrupt: 'not a saved game (no history array)' };
|
||||
}
|
||||
const { engineVersion, ...saved } = payload;
|
||||
return { found: true, saved, storedVersion: engineVersion };
|
||||
}
|
||||
|
||||
/** Appended once per closed turn span (`GameSession.intent`'s `timing` result) — read-modify-write at
|
||||
* this scale rather than real appending, same reasoning as `writeGame`. */
|
||||
export async function appendTiming(dataDir: string, timing: TurnTiming): Promise<void> {
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
const path = join(dataDir, TIMINGS_FILE);
|
||||
// The read and the write are one unit under the file's queue, or two appends could each read the
|
||||
// same list and one of them would be lost.
|
||||
await serial(`rmw:${path}`, async () => {
|
||||
let existing: TurnTiming[];
|
||||
try {
|
||||
existing = JSON.parse(await readFile(path, 'utf8')) as TurnTiming[];
|
||||
} catch {
|
||||
existing = [];
|
||||
}
|
||||
existing.push(timing);
|
||||
await atomicWrite(path, JSON.stringify(existing, null, 1));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 4 — more than one game
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GameIndexEntry = { gameId: string; gameCode: string; status: 'lobby' | 'active' | 'finished' };
|
||||
|
||||
/** Where one game's own files live. Every function below takes a `gameId` and joins it under here
|
||||
* itself; `writeGame`/`loadGame`/`appendTiming` above do not, and take a directory directly, so a
|
||||
* caller wanting one specific game's `game.json` passes `gameDir(dataDir, gameId)` to those. */
|
||||
export function gameDir(dataDir: string, gameId: string): string {
|
||||
return join(dataDir, 'games', gameId);
|
||||
}
|
||||
|
||||
export async function readIndex(dataDir: string): Promise<GameIndexEntry[]> {
|
||||
try {
|
||||
return JSON.parse(await readFile(join(dataDir, INDEX_FILE), 'utf8')) as GameIndexEntry[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIndex(dataDir: string, entries: GameIndexEntry[]): Promise<void> {
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
await atomicWrite(join(dataDir, INDEX_FILE), JSON.stringify(entries, null, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or updates exactly one game's row, by `gameId` — every caller that changes one game's status
|
||||
* (created, started, finished) wants precisely this, not "replace the whole index", which would
|
||||
* silently drop every other game's row the moment two writes happened close together.
|
||||
*/
|
||||
export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry): Promise<void> {
|
||||
await serial(`rmw:${join(dataDir, INDEX_FILE)}`, async () => {
|
||||
const entries = await readIndex(dataDir);
|
||||
const i = entries.findIndex((e) => e.gameId === entry.gameId);
|
||||
if (i >= 0) entries[i] = entry;
|
||||
else entries.push(entry);
|
||||
await writeIndex(dataDir, entries);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a game from the index. Paired with `deleteGame` — the directory holds the game, the
|
||||
* index says the game exists, and a delete that did one without the other would either resurrect
|
||||
* it on the next boot or leave `index.json` pointing at nothing.
|
||||
*/
|
||||
export async function removeIndexEntry(dataDir: string, gameId: string): Promise<void> {
|
||||
await serial(`rmw:${join(dataDir, INDEX_FILE)}`, async () => {
|
||||
const entries = await readIndex(dataDir);
|
||||
await writeIndex(
|
||||
dataDir,
|
||||
entries.filter((e) => e.gameId !== gameId),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes a game's whole directory — its save, its turn timings, its sessions, its lobby file. */
|
||||
export async function deleteGame(dataDir: string, gameId: string): Promise<void> {
|
||||
await rm(gameDir(dataDir, gameId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function writeLobby(dataDir: string, lobby: Lobby): Promise<void> {
|
||||
const dir = gameDir(dataDir, lobby.gameId);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await atomicWrite(join(dir, LOBBY_FILE), JSON.stringify(lobby, null, 1));
|
||||
}
|
||||
|
||||
export async function readLobby(dataDir: string, gameId: string): Promise<Lobby | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(join(gameDir(dataDir, gameId), LOBBY_FILE), 'utf8')) as Lobby;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removed once a game starts — a `Lobby` and a running `SavedGame` are mutually exclusive for one
|
||||
* `gameId`, and leaving the file behind would let a restart resurrect a lobby for a game already
|
||||
* under way. Missing already is not an error; `Lobby.Start` calls this exactly once, in the same
|
||||
* request that writes `game.json`.
|
||||
*/
|
||||
export async function deleteLobby(dataDir: string, gameId: string): Promise<void> {
|
||||
try {
|
||||
await unlink(join(gameDir(dataDir, gameId), LOBBY_FILE));
|
||||
} catch {
|
||||
// Already gone — nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
/** Session tokens for one game — `lobby-and-sessions.md` §6: persisted so a token still works after
|
||||
* a server restart, the same reason `game.json` itself is persisted. */
|
||||
export async function writeSessions(dataDir: string, gameId: string, sessions: PlayerSession[]): Promise<void> {
|
||||
const dir = gameDir(dataDir, gameId);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await atomicWrite(join(dir, SESSIONS_FILE), JSON.stringify(sessions, null, 1));
|
||||
}
|
||||
|
||||
export async function readSessions(dataDir: string, gameId: string): Promise<PlayerSession[]> {
|
||||
try {
|
||||
return JSON.parse(await readFile(join(gameDir(dataDir, gameId), SESSIONS_FILE), 'utf8')) as PlayerSession[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
/**
|
||||
* The game session host — Phases 2 and 3 of `docs/architecture/multiplayer.md` (§8, §12 steps 9,
|
||||
* 14-16).
|
||||
*
|
||||
* Pure logic, no sockets, no filesystem — `src/server/http.ts` is the thin wiring that calls into
|
||||
* this, and `src/server/persistence.ts` is what actually reads/writes disk. One `GameSession` per
|
||||
* in-memory game (Phase 2 is one game per process; Phase 4 generalizes to many).
|
||||
*
|
||||
* REUSES `src/web/game.ts`'s `Game`/`submit`/`drain`/`currentActor`/`actionMenu` wholesale — all pure
|
||||
* logic already, with no DOM dependency, exactly as `LocalSession` uses them client-side. Building a
|
||||
* second copy of "apply an intent, narrate it, compute the Menu" here would drift from what solitaire
|
||||
* already does and is tested against.
|
||||
*
|
||||
* ONE THING `game.ts`'s `submit` DOES NOT DO: verify who is calling it. `submit(game, intent)` reads
|
||||
* `currentActor(game)` itself and applies the intent AS that player, regardless of who asked — safe
|
||||
* for `LocalSession` (there is only ever one possible caller: the one browser). A server has more than
|
||||
* one seat submitting, so THIS file is where "is `seat` actually allowed to act right now?" has to be
|
||||
* checked, before `submit` is ever called — see `intent()` below.
|
||||
*/
|
||||
|
||||
import { withSavedDeal } from '../engine/content.ts';
|
||||
import { check } from '../engine/apply.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
|
||||
import { actionMenu, currentActor, fromMultiplayerSave, isOutOfTurn, newMultiplayerGame, submit } from '../web/game.ts';
|
||||
import type { Game, Menu } from '../web/game.ts';
|
||||
import { deltaFrame } from '../sim/frame-delta.ts';
|
||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||
import { publicSnapshot, snapshot, seatLabel } from '../sim/view.ts';
|
||||
import type { Frame, PublicFrame } from '../sim/view.ts';
|
||||
import { takeSteps } from '../sim/display-step.ts';
|
||||
import type { DisplayStep } from '../sim/display-step.ts';
|
||||
import { developerBot } from '../sim/bot.ts';
|
||||
|
||||
export type Push = {
|
||||
/**
|
||||
* Absent on a presence-only push (below) — there is no board delta to send when nothing about the
|
||||
* GAME changed, and computing one just to say "unchanged" would mean tracking a last-sent frame
|
||||
* for a seat that may not even be in this game's connection table (a lobby-stage notice has none).
|
||||
*/
|
||||
frame?: FrameDelta;
|
||||
/** Non-null only for the seat that may currently act — never guess otherwise (`game.ts`'s `actionMenu` guards this too, but the host must still only compute/send it for the actor). */
|
||||
menu: Menu | null;
|
||||
/** Narration since the LAST push to this specific seat, not the whole game's log. */
|
||||
lines: { text: string; tone: string }[];
|
||||
/**
|
||||
* Connection news about OTHER seats — never this push's own recipient. `lobby-and-sessions.md`
|
||||
* §5: a disconnect is server-layer news about a connection, not a `GameEvent`, so it must not go
|
||||
* through the engine or the shared narration log (which must stay replayable from a seed). Built
|
||||
* and broadcast entirely by `http.ts`, which already owns the connection table; `session.ts` never
|
||||
* sets this field itself — every `Push` `session.ts` builds carries a real `frame` and no
|
||||
* `presence`, and `http.ts`'s presence notices carry no `frame` and no `menu`.
|
||||
*
|
||||
* A LIST, since 2026-08-23: a connecting client is told about every other seat at once. It used to
|
||||
* learn of a seat only when that seat disconnected AFTER it connected, so a player arriving at a
|
||||
* table where two people had not shown up yet was told nothing at all. `seen` distinguishes "was
|
||||
* here and dropped" from "has never opened the game".
|
||||
*/
|
||||
presence?: { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
||||
/**
|
||||
* THE FOUR TRANSIENT SIGNALS (2026-08-23) — what solitaire has always drawn and multiplayer never
|
||||
* did: sound cues, the timetable slot a D12 just filled, a one-line announcement, and the card
|
||||
* that just came into this seat's hand.
|
||||
*
|
||||
* The first three are SHARED — a collision anywhere on the Division, the Stage bell, a train
|
||||
* running off the end pays everyone — so they are identical in every seat's push and drained once
|
||||
* per broadcast. `justDrawn` is not: it goes ONLY to the seat that drew it (`game.justDrawn` is
|
||||
* one field for the whole game and does not say whose). `test/redaction.test.ts` is the guard.
|
||||
*/
|
||||
cues?: string[];
|
||||
scheduled?: number | null;
|
||||
announcement?: string | null;
|
||||
justDrawn?: string | null;
|
||||
/**
|
||||
* THE LAST INTENT `seq` THIS SEAT HAD ACCEPTED — on the connect push only (v0.8.4).
|
||||
*
|
||||
* The client numbers its intents from 1 per page load, and this host remembers the seat's last
|
||||
* accepted number for the life of the game and answers a repeat with "already applied" (§5). So
|
||||
* a seat that had made one move, reloaded, and clicked again sent `seq: 1` a second time: the
|
||||
* server said ok and did nothing, the page redrew nothing, and the click looked dead. Telling the
|
||||
* client where the count stands lets it continue from there instead of starting over.
|
||||
*/
|
||||
lastSeq?: number;
|
||||
/**
|
||||
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13.
|
||||
*
|
||||
* A field on `Push` rather than a second SSE event type, following `presence`'s precedent and for
|
||||
* its stated reason (`http.ts`): one message shape for the client to parse. `http.ts` therefore
|
||||
* needs no change at all — `broadcastGame` forwards whatever this file builds.
|
||||
*
|
||||
* IDENTICAL IN EVERY SEAT'S PUSH, because a step carries the PUBLIC board and nothing else. A
|
||||
* player's own hand, menu and objective are not animated: they arrive on the same push, already
|
||||
* coalesced, exactly as they always have. That is what keeps the redaction surface at zero new
|
||||
* area — `test/redaction.test.ts` guards the projection these are built from.
|
||||
*/
|
||||
steps?: DisplayStep[];
|
||||
/**
|
||||
* The public board to start a step queue from — sent on a CONNECT, never on an update.
|
||||
*
|
||||
* Steps carry deltas against one chain shared by the whole table, so a client that has just
|
||||
* arrived (or come back) has nothing to merge the next delta onto and `applyPublicDelta` would
|
||||
* rightly throw. This is that baseline: the exact frame the chain has reached, so the next step
|
||||
* lands on it. A reconnecting client resets rather than replaying what it missed — the history
|
||||
* panel is what carries the words, and it is already sent whole on connect (#97).
|
||||
*/
|
||||
publicReset?: PublicFrame;
|
||||
};
|
||||
|
||||
/**
|
||||
* `lobby-and-sessions.md` §5 — "wall-clock at the start of a turn, wall-clock at the intent that
|
||||
* ends it, per player per phase." Computed entirely here, never touching the engine (which has no
|
||||
* clock and must stay deterministic) and never stored inside `history` (a replay must reproduce a
|
||||
* game from decisions alone).
|
||||
*/
|
||||
export type TurnTiming = {
|
||||
player: PlayerIndex;
|
||||
phase: string;
|
||||
day: number;
|
||||
stage: number;
|
||||
startedAt: number;
|
||||
endedAt: number;
|
||||
};
|
||||
|
||||
/** Everything `persistence.ts` needs to write `game.json` and rebuild a session from it later. */
|
||||
export type SavedGame = {
|
||||
seed: number;
|
||||
config: GameConfig;
|
||||
playerNames: string[];
|
||||
history: Intent[];
|
||||
status: 'active' | 'finished';
|
||||
createdAt: number;
|
||||
/**
|
||||
* Seats `developerBot` plays for, filled at `Lobby.Start` (D8 — bots fill EMPTY seats at lobby
|
||||
* time only, never mid-game). Persisted so a bot seat is still a bot after a server restart —
|
||||
* `resumeSession` has no other way to know, and re-deriving it from `playerNames` would mean
|
||||
* guessing from a display name rather than reading a fact.
|
||||
*/
|
||||
botSeats: PlayerIndex[];
|
||||
/**
|
||||
* Wall-clock of the last accepted intent, so "has this game stalled?" survives a restart.
|
||||
*
|
||||
* Optional because it postdates the format, and defaulted to `createdAt` when absent — a game
|
||||
* whose last move is unrecorded reads as untouched since it began, which is the honest answer
|
||||
* rather than a fabricated one. Kept OUT of `history`, like the turn timings and for the same
|
||||
* reason: a replay must reproduce a game from decisions alone, and wall-clock is not a decision.
|
||||
*/
|
||||
lastMoveAt?: number;
|
||||
};
|
||||
|
||||
/** What an administrator needs to see about a game without replaying it themselves. */
|
||||
export type GameSummary = {
|
||||
playerCount: number;
|
||||
playerNames: string[];
|
||||
botSeats: PlayerIndex[];
|
||||
status: 'active' | 'finished';
|
||||
createdAt: number;
|
||||
lastMoveAt: number;
|
||||
day: number;
|
||||
stage: number;
|
||||
phase: string;
|
||||
/**
|
||||
* Whose move it is, or `null` — which is not an error state: the Mainline Phase runs itself, and
|
||||
* a finished game waits on nobody.
|
||||
*/
|
||||
waitingOn: { seat: PlayerIndex; name: string } | null;
|
||||
};
|
||||
|
||||
export type IntentResult =
|
||||
| { accepted: true; pushes: Map<PlayerIndex, Push>; timing: TurnTiming | null }
|
||||
| { accepted: false; code: string };
|
||||
|
||||
export type GameSession = {
|
||||
readonly playerCount: number;
|
||||
isBot(seat: PlayerIndex): boolean;
|
||||
/** A new SSE connection (or a reconnect) for `seat` — always a full Frame, never a delta. */
|
||||
connect(seat: PlayerIndex): Push;
|
||||
intent(seat: PlayerIndex, seq: number, i: Intent): IntentResult;
|
||||
/** Everything needed to persist this game and, later, rebuild it via `resumeSession`. */
|
||||
exportSave(): SavedGame;
|
||||
/**
|
||||
* A cheap description of where this game has got to. Deliberately does not copy `history` the
|
||||
* way `exportSave` must — the health check polls this on a timer, and an administrator listing
|
||||
* games wants the state of each, not a copy of every intent in all of them.
|
||||
*/
|
||||
summary(): GameSummary;
|
||||
};
|
||||
|
||||
type OpenSpan = { player: PlayerIndex; phase: string; day: number; stage: number; startedAt: number };
|
||||
|
||||
function buildSession(
|
||||
game: Game,
|
||||
playerNames: string[],
|
||||
createdAt: number,
|
||||
botSeats: Set<PlayerIndex>,
|
||||
lastMoveAtInit: number,
|
||||
): GameSession {
|
||||
let lastMoveAt = lastMoveAtInit;
|
||||
/** Who drew the card `game.justDrawn` names. The engine records WHICH card came into hand but not
|
||||
* whose hand it went into, and that is the whole difference between a badge and a leak. */
|
||||
let lastDraw: { seat: PlayerIndex; cardId: string } | null = null;
|
||||
const lastSeq = new Map<PlayerIndex, number>();
|
||||
const lastFrame = new Map<PlayerIndex, Frame>();
|
||||
const sentLines = new Map<PlayerIndex, number>();
|
||||
|
||||
const openSpanFor = (now: number): OpenSpan | null => {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) return null;
|
||||
return { player: actor, phase: game.state.clock.phase, day: game.state.clock.day, stage: game.state.clock.stage, startedAt: now };
|
||||
};
|
||||
let open: OpenSpan | null = openSpanFor(Date.now());
|
||||
|
||||
/**
|
||||
* A seat's full Frame, with `lines` deliberately EMPTY (#97).
|
||||
*
|
||||
* Narration reaches a client by ONE path — `Push.lines` — because `RemoteSession`
|
||||
* (`web/session.ts`) accumulates from that field alone and its `lines()` returns the accumulator.
|
||||
* Passing `game.log` here serialised the entire log into every frame for every seat, where it grew
|
||||
* all game and was thrown away on arrival, while `linesSince` correctly sent the same text beside
|
||||
* it. The duplicate was not merely waste: it masked the reconnect bug `connect` fixes below.
|
||||
*/
|
||||
function frameFor(seat: PlayerIndex): Frame {
|
||||
return snapshot(game.state, [], null, null, null, false, seat);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHAT THIS SEAT HAS NOT BEEN SENT YET, bookmarked by SEQUENCE rather than by position.
|
||||
*
|
||||
* This was `game.log.slice(sentLines.get(seat))` against an array the game trims to `LOG_LIMIT`.
|
||||
* Once a seat's bookmark reached that limit the array stopped growing past it, so the slice
|
||||
* returned an empty list on every push from then on and that seat's history froze permanently —
|
||||
* at a different moment for each seat, because each holds its own bookmark. Reported from a
|
||||
* two-player game that did not reach Day 5.
|
||||
*
|
||||
* A sequence number cannot run past the end: lines that have been trimmed are simply gone, and
|
||||
* everything still held with a higher `seq` is sent. A seat that has missed more than the log
|
||||
* keeps gets what remains rather than nothing.
|
||||
*/
|
||||
function linesSince(seat: PlayerIndex): { text: string; tone: string; seq: number }[] {
|
||||
const already = sentLines.get(seat) ?? -1;
|
||||
const fresh = game.log.filter((l) => l.seq > already);
|
||||
const last = game.log[game.log.length - 1];
|
||||
if (last) sentLines.set(seat, last.seq);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
function menuFor(seat: PlayerIndex): Menu | null {
|
||||
return seat === currentActor(game) ? actionMenu(game, seat) : null;
|
||||
}
|
||||
|
||||
/** What the last batch of events earned, drained from the game exactly once and then handed to
|
||||
* every seat. `null` on a (re)connect: a fresh connection is drawing a STATE, and replaying the
|
||||
* sounds of everything it missed would be a burst of noise about the past. */
|
||||
type Moment = { cues: string[]; scheduled: number | null; announcement: string | null };
|
||||
|
||||
function takeMoment(): Moment {
|
||||
const cues = game.cues.splice(0, game.cues.length);
|
||||
const scheduled = game.scheduled;
|
||||
game.scheduled = null;
|
||||
const announcement = game.announced;
|
||||
game.announced = null;
|
||||
return { cues, scheduled, announcement };
|
||||
}
|
||||
|
||||
function pushFor(seat: PlayerIndex, moment: Moment | null, steps: DisplayStep[] = []): Push {
|
||||
const frame = frameFor(seat);
|
||||
const delta = deltaFrame(lastFrame.get(seat) ?? null, frame);
|
||||
lastFrame.set(seat, frame);
|
||||
const push: Push = { frame: delta, menu: menuFor(seat), lines: linesSince(seat) };
|
||||
if (steps.length > 0) push.steps = steps;
|
||||
if (moment) {
|
||||
if (moment.cues.length > 0) push.cues = moment.cues;
|
||||
if (moment.scheduled !== null) push.scheduled = moment.scheduled;
|
||||
if (moment.announcement !== null) push.announcement = moment.announcement;
|
||||
}
|
||||
// The drawer's own card, and nobody else's — on a reconnect too, so the badge survives a refresh.
|
||||
if (lastDraw !== null && lastDraw.seat === seat) push.justDrawn = lastDraw.cardId;
|
||||
return push;
|
||||
}
|
||||
|
||||
function pushesForAll(): Map<PlayerIndex, Push> {
|
||||
const moment = takeMoment();
|
||||
// Drained ONCE for the whole broadcast, not per seat: the steps are public and identical, and
|
||||
// `takeSteps` empties the collector, so draining inside the loop would give them to seat 0 and
|
||||
// an empty list to everybody else.
|
||||
const steps = takeSteps(game.display);
|
||||
const out = new Map<PlayerIndex, Push>();
|
||||
for (let seat = 0; seat < playerNames.length; seat++) {
|
||||
out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex, moment, steps));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the open span if the acting player, phase, Day or Stage moved since it opened, and opens
|
||||
* the next one (or none, in the automatic Mainline Phase). Returns the just-closed span, if any,
|
||||
* for the caller to persist — a "turn" here is exactly the contiguous stretch where none of those
|
||||
* four things changed, which can span several intents (draw, then play, then end) as one span.
|
||||
*/
|
||||
function settleTiming(): TurnTiming | null {
|
||||
const now = Date.now();
|
||||
const next = openSpanFor(now);
|
||||
const same =
|
||||
open !== null &&
|
||||
next !== null &&
|
||||
open.player === next.player &&
|
||||
open.phase === next.phase &&
|
||||
open.day === next.day &&
|
||||
open.stage === next.stage;
|
||||
if (same) return null;
|
||||
const closed: TurnTiming | null = open === null ? null : { ...open, endedAt: now };
|
||||
open = next;
|
||||
return closed;
|
||||
}
|
||||
|
||||
/**
|
||||
* PLAY EVERY BOT SEAT FORWARD until a human is due, or the game ends.
|
||||
*
|
||||
* `developerBot` (D8, `lobby-and-sessions.md` §2) fills EMPTY seats at lobby time only — it never
|
||||
* takes over for a disconnected human, so this loop only ever touches seats `botSeats` named at
|
||||
* `Lobby.Start`. Uses `submit` directly rather than the public `intent()` path: a bot is not a
|
||||
* client with a `seq` to dedupe against, and its choice is by construction always legal (`check`
|
||||
* would only ever confirm what `legalActions` already promised).
|
||||
*
|
||||
* Intermediate bot-turn spans are swept through `settleTiming()` but their result is discarded —
|
||||
* `lobby-and-sessions.md` §5's turn clock exists to learn how long HUMANS take, and a bot decides
|
||||
* in zero wall-clock time by definition. Called once at construction (a resume could land exactly
|
||||
* on a bot's turn) and once after every accepted human intent.
|
||||
*
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — the bots' half of a unanimous vote.
|
||||
*
|
||||
* "Bots will not disagree with the human. Humans get to vote first. If all humans vote yes, then
|
||||
* bots vote yes too. If a human votes no, it's not unanimous, it ends right then. If only bots are
|
||||
* playing, they never vote to extend" (Jesse, 2026-08-28).
|
||||
*
|
||||
* Which makes a bot's vote a formality rather than a policy decision, performed once the humans
|
||||
* have already settled it, so that the unanimity the engine checks is a real unanimity rather than
|
||||
* a special case carved into the rules for absent players.
|
||||
*
|
||||
* THE ALL-BOT TABLE IS THE CASE TO GET RIGHT, and getting it wrong hung the game. `driveBots`
|
||||
* cannot reach the vote — it loops on `currentActor`, which is null the moment the game stops —
|
||||
* so if this returns early with no humans to follow, nobody votes at all and a bot-only game sits
|
||||
* on the question for ever. It happened: an all-bot session never reached `finished`. With nobody
|
||||
* to follow, the bots' own answer stands, and it is no.
|
||||
*/
|
||||
function driveBotVotes(): void {
|
||||
if (game.state.status !== 'awaitingExtension') return;
|
||||
const humans = [...Array(playerNames.length).keys()].filter((p) => !botSeats.has(p));
|
||||
// A human who has voted `false` has already ended the game, so reaching here with humans still
|
||||
// outstanding means the table is genuinely waiting on a person. Bots wait with it.
|
||||
if (humans.length > 0 && !humans.every((p) => game.state.extensionVotes[p] === true)) return;
|
||||
const agree = humans.length > 0;
|
||||
for (const seat of botSeats) {
|
||||
if (game.state.extensionVotes[seat] === null) {
|
||||
submit(game, { type: 'game.extend', player: seat, agree }, seat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bots play, bots vote, and an agreed extension puts them back to playing — so the two drivers
|
||||
* alternate rather than running once each. Bounded because every pass must consume something: a
|
||||
* turn, or a vote that cannot be cast twice.
|
||||
*/
|
||||
function driveBotTurns(): void {
|
||||
for (let pass = 0; pass < 1_000; pass++) {
|
||||
const before = game.history.length;
|
||||
driveBots();
|
||||
driveBotVotes();
|
||||
if (game.history.length === before) return;
|
||||
}
|
||||
throw new Error('driveBotTurns: probable infinite loop');
|
||||
}
|
||||
|
||||
function driveBots(): void {
|
||||
let guard = 0;
|
||||
for (;;) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null || !botSeats.has(actor)) return;
|
||||
if (++guard > 10_000) throw new Error(`driveBots: probable infinite loop at seat ${actor}`);
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) return;
|
||||
const choice = developerBot.choose(game.state, actor, options);
|
||||
const drawnBefore = game.justDrawn;
|
||||
const ok = submit(game, choice);
|
||||
if (game.justDrawn !== drawnBefore && game.justDrawn !== null) {
|
||||
lastDraw = { seat: actor, cardId: game.justDrawn };
|
||||
}
|
||||
/* c8 ignore next -- `options` came from `legalActions`, so `choice` is always legal. */
|
||||
if (!ok) throw new Error(`driveBots: developerBot chose an illegal action for seat ${actor}`);
|
||||
settleTiming();
|
||||
}
|
||||
}
|
||||
driveBotTurns();
|
||||
// Whatever the opening bot turns earned belongs to a game nobody was connected to yet — dropped
|
||||
// here rather than fired at the first client to arrive. (It also stops `game.cues` growing without
|
||||
// bound on a server, which nothing was draining before this.)
|
||||
takeMoment();
|
||||
/**
|
||||
* THE PRESENTATION STEPS THOSE TURNS PRODUCED GO WITH THEM (v0.8.0).
|
||||
*
|
||||
* Left in the collector they would be delivered on the FIRST broadcast after somebody connects —
|
||||
* but that client's `publicReset` is the board as it stands AFTER these very moves, so replaying
|
||||
* them onto it would draw positions the game had already left. The plan says as much: opening bot
|
||||
* moves need no replay, and a later display simply receives the final reset.
|
||||
*
|
||||
* This is the only moment the collector holds anything outside an intent. `pushesForAll()` drains
|
||||
* it synchronously at the end of every `intent()`, so between moves it is always empty — which is
|
||||
* what makes dropping here safe rather than a race with a seat that has not been sent them yet.
|
||||
*/
|
||||
takeSteps(game.display);
|
||||
|
||||
return {
|
||||
playerCount: playerNames.length,
|
||||
isBot: (seat) => botSeats.has(seat),
|
||||
|
||||
connect(seat) {
|
||||
// A (re)connect always starts from a clean slate — no cache to trust across a lost connection
|
||||
// (or a server restart, Phase 3) — so the honest thing is a full Frame, not a delta.
|
||||
lastFrame.delete(seat);
|
||||
// ...and the NARRATION watermark with it (#97). The browser this answers has just reloaded
|
||||
// from an empty accumulator, so a seat told "nothing new since your last push" came back to a
|
||||
// blank history panel mid-game, with the server holding the whole log. `Push.lines` on a
|
||||
// connect IS the history, which is what lets the Frame stop carrying a second copy.
|
||||
sentLines.delete(seat);
|
||||
const push = pushFor(seat, null);
|
||||
push.lastSeq = lastSeq.get(seat) ?? 0;
|
||||
/**
|
||||
* The baseline for this client's step queue (v0.8.0). `game.display.last` is the exact frame
|
||||
* the shared delta chain has reached, so the next step merges onto it; before any step has
|
||||
* been collected there is no chain yet and a fresh projection is the same thing.
|
||||
*/
|
||||
push.publicReset = game.display.last ?? publicSnapshot(game.state);
|
||||
return push;
|
||||
},
|
||||
|
||||
intent(seat, seq, i) {
|
||||
// Idempotent resend (protocol.md §5): a repeat of an ALREADY-APPLIED seq is a no-op success,
|
||||
// with no pushes to re-broadcast. A repeat after a REJECTION is not remembered here — it was
|
||||
// never applied, so it is worth trying again (see the `lastSeq.set` below: only on success).
|
||||
if (lastSeq.get(seat) === seq) return { accepted: true, pushes: new Map(), timing: null };
|
||||
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — the vote is the one intent with no actor to be.
|
||||
*
|
||||
* `currentActor` is null once the timetable has run out, so this guard would refuse every
|
||||
* vote with NOT_YOUR_TURN. Every seat may vote, and `check` is still the authority on whether
|
||||
* this particular seat may vote right now (it has voted already; the game is not waiting on a
|
||||
* vote at all), so skipping the turn test here gives nothing away.
|
||||
*/
|
||||
if (!isOutOfTurn(i) && seat !== currentActor(game)) {
|
||||
return { accepted: false, code: 'NOT_YOUR_TURN' };
|
||||
}
|
||||
|
||||
// Checked directly, rather than via `submit`'s boolean, for two reasons: `submit` writes a
|
||||
// "that is not allowed" line into the SHARED `game.log` on rejection, which would otherwise
|
||||
// broadcast one seat's illegal attempt to the whole table on their next push (rejection
|
||||
// feedback is private, to the submitter only); and calling `check` first means an intent
|
||||
// already known to be illegal never touches the engine at all.
|
||||
const code = check(game.state, seat, i);
|
||||
if (code) return { accepted: false, code };
|
||||
|
||||
const drawnBefore = game.justDrawn;
|
||||
const applied = submit(game, i, isOutOfTurn(i) ? seat : null);
|
||||
if (game.justDrawn !== drawnBefore && game.justDrawn !== null) {
|
||||
lastDraw = { seat, cardId: game.justDrawn };
|
||||
}
|
||||
/* c8 ignore next -- `check` above already proved this intent is legal; `submit` cannot then refuse it. */
|
||||
if (!applied) return { accepted: false, code: 'REJECTED' };
|
||||
|
||||
lastSeq.set(seat, seq);
|
||||
lastMoveAt = Date.now();
|
||||
const timing = settleTiming();
|
||||
// Any bot due to act now plays out entirely before this push goes back — the delta mechanism
|
||||
// diffs against whatever was last sent, so it captures the bots' moves along with the human's
|
||||
// in one push regardless of how many turns that took. Votes included, since Gitea#11: a human
|
||||
// agreeing to another Day is exactly the move the bots are waiting on to agree themselves.
|
||||
driveBotTurns();
|
||||
return { accepted: true, pushes: pushesForAll(), timing };
|
||||
},
|
||||
|
||||
exportSave() {
|
||||
return {
|
||||
seed: game.seed,
|
||||
config: game.state.config,
|
||||
playerNames: [...playerNames],
|
||||
history: [...game.history],
|
||||
// `awaitingExtension` is a game waiting on its table, not a game that is over — so it maps
|
||||
// to 'active' and `server/index.ts` resumes it on a restart like any other (Gitea#11).
|
||||
status: game.state.status === 'finished' ? 'finished' : 'active',
|
||||
createdAt,
|
||||
botSeats: [...botSeats],
|
||||
lastMoveAt,
|
||||
};
|
||||
},
|
||||
|
||||
summary() {
|
||||
const actor = currentActor(game);
|
||||
return {
|
||||
playerCount: playerNames.length,
|
||||
playerNames: [...playerNames],
|
||||
botSeats: [...botSeats],
|
||||
// `awaitingExtension` is a game waiting on its table, not a game that is over — so it maps
|
||||
// to 'active' and `server/index.ts` resumes it on a restart like any other (Gitea#11).
|
||||
status: game.state.status === 'finished' ? 'finished' : 'active',
|
||||
createdAt,
|
||||
lastMoveAt,
|
||||
day: game.state.clock.day,
|
||||
stage: game.state.clock.stage,
|
||||
phase: game.state.clock.phase,
|
||||
waitingOn: actor === null ? null : { seat: actor, name: playerNames[actor] ?? `Seat ${seatLabel(actor)}` },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSession(
|
||||
seed: number,
|
||||
config: GameConfig,
|
||||
playerNames: string[],
|
||||
botSeats: PlayerIndex[] = [],
|
||||
): GameSession {
|
||||
const now = Date.now();
|
||||
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, now, new Set(botSeats), now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3 — rebuild a session from a persisted `SavedGame` (`persistence.ts`). The engine-version
|
||||
* check happens before this is ever called; by the time `saved.history` reaches here it is already
|
||||
* known to have been recorded under the currently-running rules.
|
||||
*/
|
||||
/**
|
||||
* A resume that could not complete, and exactly where it gave up. `index.ts` turns this into the
|
||||
* refusal it logs, so the operator is told which move the current rules will not accept rather
|
||||
* than only that some version string differs.
|
||||
*/
|
||||
export type ResumeFailure = { stoppedAt: number; of: number; intent: string; code: string };
|
||||
|
||||
export function tryResumeSession(saved: SavedGame): { ok: true; session: GameSession } | { ok: false; failure: ResumeFailure } {
|
||||
// A save written before `startingOffice` existed opened on a Whistle Post and cannot say so —
|
||||
// replaying it under today's Depot default would deal a different railroad. See `withSavedDeal`.
|
||||
const { game, stopped } = fromMultiplayerSave(
|
||||
saved.seed, withSavedDeal(saved.config), saved.playerNames, saved.history,
|
||||
);
|
||||
if (stopped) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: { stoppedAt: stopped.index, of: saved.history.length, intent: stopped.intent.type, code: stopped.code },
|
||||
};
|
||||
}
|
||||
return { ok: true, session: build(game, saved) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws on a save the current rules will not replay. Kept for callers that have already
|
||||
* established the save is good — the server boots through `tryResumeSession`, which answers
|
||||
* instead of throwing.
|
||||
*/
|
||||
export function resumeSession(saved: SavedGame): GameSession {
|
||||
const r = tryResumeSession(saved);
|
||||
if (!r.ok) {
|
||||
throw new Error(
|
||||
`save does not replay under the current rules: intent ${r.failure.stoppedAt + 1} of ` +
|
||||
`${r.failure.of} (${r.failure.intent}) was rejected with ${r.failure.code}`,
|
||||
);
|
||||
}
|
||||
return r.session;
|
||||
}
|
||||
|
||||
function build(game: Game, saved: SavedGame): GameSession {
|
||||
return buildSession(
|
||||
game,
|
||||
saved.playerNames,
|
||||
saved.createdAt,
|
||||
new Set(saved.botSeats),
|
||||
saved.lastMoveAt ?? saved.createdAt,
|
||||
);
|
||||
}
|
||||
+844
-225
File diff suppressed because it is too large
Load Diff
+431
-115
@@ -19,16 +19,28 @@
|
||||
* complete at all.
|
||||
*/
|
||||
|
||||
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarTypes, laborersLeft } from '../engine/apply.ts';
|
||||
import {
|
||||
applyIntent,
|
||||
areaOf,
|
||||
extendLimitsIfNeeded,
|
||||
isLockedOut,
|
||||
protoCard,
|
||||
canAdvanceLoad,
|
||||
destinationsFor,
|
||||
facilityCarTypes,
|
||||
laborersLeft,
|
||||
ownCutFor,
|
||||
} from '../engine/apply.ts';
|
||||
import { MAX_CONSIST, nextOfficeTier, officeProfile } from '../engine/content.ts';
|
||||
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
||||
import type { CarType, FreightKind, Hand, TrackGeometry } from '../engine/content.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
||||
import { canPlaceAt, connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { coordKey } from '../engine/state.ts';
|
||||
import { actingPlayer, coordKey, turnOf } from '../engine/state.ts';
|
||||
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||
import { planSwitchingTurn, switchFingerprint } from './switch-planner.ts';
|
||||
|
||||
export type BotPolicy = {
|
||||
name: string;
|
||||
@@ -79,26 +91,14 @@ function because(reason: string, intent: Intent): Intent {
|
||||
return intent;
|
||||
}
|
||||
|
||||
/**
|
||||
* KNOBS FOR A/B MEASUREMENT, and nothing else.
|
||||
*
|
||||
* A heuristic change has to be measured against the bot it replaces, over the SAME deals — and
|
||||
* editing the bot between runs makes that impossible to do honestly, because the two sides of the
|
||||
* comparison never exist at once. Every flag here is off by default, so `makeDeveloperBot({})` is
|
||||
* byte-identical to the bot that came before this existed.
|
||||
*
|
||||
* TEMPORARY BY CONSTRUCTION. When a flag measures well it becomes the default and the flag is
|
||||
* deleted in the same commit; when it measures badly it is deleted with its finding recorded in the
|
||||
* changelog. What must not happen is a bot that accumulates switches nobody can account for — a
|
||||
* heuristic with no measurement attached is exactly what this machinery exists to prevent.
|
||||
*/
|
||||
/**
|
||||
* ABLATIONS, for re-measuring the heuristics that are now the bot's default play.
|
||||
*
|
||||
* Every flag here turns something OFF. That is the opposite of how this started — the tweaks were
|
||||
* candidates to switch on — and it is the right shape once a candidate has been adopted: what a
|
||||
* measured heuristic needs afterwards is a way to ask "is this still worth it?" when the deck or
|
||||
* the rules move under it. Both of these were worth about +1.5 revenue together when adopted; if a
|
||||
* the rules move under it. The first two were worth about +1.5 revenue together when adopted, and
|
||||
* planning the switching turn (`noPlanSwitching`) +2.89 on its own; if a
|
||||
* rebalance changes the economy, that is a claim to re-test rather than to assume.
|
||||
*
|
||||
* The candidates that did NOT survive are gone rather than left switched off: preferring coaches at
|
||||
@@ -113,9 +113,78 @@ export type BotTweaks = {
|
||||
noTrainCap?: boolean;
|
||||
/** Draw whenever nothing is urgent, as the bot did before it preferred operating. */
|
||||
noOperateFirst?: boolean;
|
||||
|
||||
/**
|
||||
* Choose switching Moves one at a time from the rule ladder, as the bot did before it planned the
|
||||
* whole turn (`switch-planner.ts`). Measured at adoption, 2026-09-14: planning was worth
|
||||
* +2.89 ± 0.18 revenue a game (t = 15.79) over 1600 paired seeds, 733 better against 21 worse.
|
||||
*/
|
||||
noPlanSwitching?: boolean;
|
||||
/**
|
||||
* Take a face-up train or industry card whether or not it could be played, as the bot did before
|
||||
* 2026-09-14. It then took 20.1 trains and 11.9 industries a game off the Departments and discarded
|
||||
* 20.2 and 11.8, retaking the same card 28.8 times a game. Asking first measured +1.52 ± 0.10
|
||||
* (t = 15.59) over 1600 paired seeds — this ablation was worse on 880 of them and better on 211.
|
||||
*/
|
||||
noPlayableTakes?: boolean;
|
||||
/**
|
||||
* Choose where track goes by `bestTrackLay`'s piece rules and the fallback's first legal square, as the
|
||||
* bot did before 2026-09-15, instead of by what the district can DO afterwards (`bestValuedLay`).
|
||||
* Scoring the layout measured +0.118 ± 0.029 (t = 4.14) over 6400 paired seeds, and closed run-arounds
|
||||
* in 22 of 60 districts against 9.
|
||||
*/
|
||||
noValueLays?: boolean;
|
||||
/**
|
||||
* Let the New Train phase's fallback take `options[0]`, as the bot did before 2026-09-15. Because
|
||||
* `legalActions` lists `newTrain.secondSection` ahead of the Extra starts, all 26 Second Sections the
|
||||
* bot ran in 60 games were that accident, and 6 of the 20 collisions followed one. Taking a car, a pass
|
||||
* or the Extra's start instead measured +0.32 ± 0.09 (t = 3.64) over 400 paired seeds.
|
||||
*/
|
||||
noDeliberateNewTrain?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A switching turn planned once and then played a step per decision.
|
||||
*
|
||||
* Keyed by the tweaks object, because that is what one policy owns — the server shares a single
|
||||
* `developerBot` across every bot seat, so the plan inside it is kept per player. Each step is
|
||||
* submitted only while the position still matches the fingerprint the plan expected there; anything
|
||||
* else replans. A switching turn has no randomness, so in practice a plan is made once a turn.
|
||||
*/
|
||||
type ActivePlan = { steps: Intent[]; keys: string[]; next: number; summary: string };
|
||||
const activePlans = new WeakMap<BotTweaks, Map<PlayerIndex, ActivePlan>>();
|
||||
|
||||
function plannedSwitch(s: GameState, player: PlayerIndex, options: Intent[], tweaks: BotTweaks): Intent | null {
|
||||
let mine = activePlans.get(tweaks);
|
||||
if (!mine) activePlans.set(tweaks, (mine = new Map()));
|
||||
const here = switchFingerprint(s, player);
|
||||
let active = mine.get(player);
|
||||
if (!active || active.keys[active.next] !== here) {
|
||||
const p = planSwitchingTurn(s, player);
|
||||
active = {
|
||||
steps: p.steps,
|
||||
keys: p.keys,
|
||||
next: 0,
|
||||
summary:
|
||||
`position ${p.rootScore.toFixed(2)} → ${p.score.toFixed(2)} over ${p.expanded} positions` +
|
||||
(p.complete ? '' : ', search budget reached'),
|
||||
};
|
||||
mine.set(player, active);
|
||||
}
|
||||
if (active.next >= active.steps.length) {
|
||||
mine.delete(player);
|
||||
const end = options.find((i) => i.type === 'switch.end');
|
||||
return end ? because(`planned switching turn complete — ${active.summary}`, end) : null;
|
||||
}
|
||||
const want = JSON.stringify(active.steps[active.next]);
|
||||
const match = options.find((i) => JSON.stringify(i) === want);
|
||||
if (!match) {
|
||||
mine.delete(player);
|
||||
return null;
|
||||
}
|
||||
active.next++;
|
||||
return because(`step ${active.next} of ${active.steps.length} of a planned switching turn — ${active.summary}`, match);
|
||||
}
|
||||
|
||||
/** The bot as it plays today. Every knob off. */
|
||||
export const developerBot: BotPolicy = makeDeveloperBot({});
|
||||
|
||||
@@ -130,13 +199,39 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
|
||||
|
||||
choose(s, player, options) {
|
||||
lastReason = 'no specific reason — first legal option';
|
||||
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — a bot never asks for another Day.
|
||||
*
|
||||
* "If only bots are playing, they never vote to extend" (Jesse, 2026-08-28), which is what keeps
|
||||
* the balance harness and every bot-only game ending at the timetable it was dealt with. It also
|
||||
* makes this the SAFE DEFAULT everywhere else: a bot's agreement in a game with humans in it is
|
||||
* decided by `server/session.ts`, which votes on the bots' behalf only once every human has
|
||||
* already said yes, and never reaches this policy at all.
|
||||
*/
|
||||
const extend = options.find((i) => i.type === 'game.extend' && i.agree === false);
|
||||
if (extend) return because('a bot plays the timetable it was dealt and no more', extend);
|
||||
|
||||
const clearance = ruleOnClearance(options);
|
||||
if (clearance) return because('the Superintendent must rule on a following train (§8.1)', clearance);
|
||||
if (clearance) return because('the Superintendent must rule on a following train', clearance);
|
||||
|
||||
/**
|
||||
* §11 (Gitea#5) — the bot keeps its trains at the Train Order Office.
|
||||
*
|
||||
* A deliberate policy, not an oversight, and the cautious half of a real choice: the Yard Office
|
||||
* frees an A/D track, which is worth something on a busy district, but the lead into it may be
|
||||
* fouled and the bot does not read its own yard well enough to tell (`TODO.md`, Bot
|
||||
* Performance — it cannot spot a car at a stub industry either). Declining is always safe, and
|
||||
* it keeps the balance harness comparable with every measurement taken before this rule existed.
|
||||
* Worth revisiting when the bot can judge the lead.
|
||||
*/
|
||||
const yardOffice = options.find((i) => i.type === 'mainline.yardOffice' && i.take === false);
|
||||
if (yardOffice) return because('the bot does not judge the lead into a yard, so it stays at the Office', yardOffice);
|
||||
|
||||
// Red Flags come before anything else — protection is only worth playing at the moment the
|
||||
// collision is actually pending, and that moment passes.
|
||||
const flags = worthFlagging(s, options);
|
||||
if (flags) return because('a train of ours is stopped on a Mainline card with another train on it — Red Flags now or not at all', flags);
|
||||
if (flags) return because('the engine says this arrival collides, and we hold a Red Flag — now or never', flags);
|
||||
|
||||
// --- Load/Unload: spend every worker, then end. Each is a point, or a step toward one.
|
||||
//
|
||||
@@ -175,6 +270,13 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
|
||||
);
|
||||
if (match) return because(`the ${w.loaded ? 'loaded' : 'empty'} ${w.type} is what a facility is short of`, match);
|
||||
}
|
||||
if (!tweaks.noDeliberateNewTrain) {
|
||||
const move =
|
||||
pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar', 'newTrain.startExtra') ??
|
||||
options.find((i) => i.type !== 'newTrain.secondSection' && i.type !== 'maneuver.redFlags') ??
|
||||
options[0]!;
|
||||
return because('no car on offer is one our facilities need', move);
|
||||
}
|
||||
return because('no car on offer is one our facilities need', pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!);
|
||||
}
|
||||
|
||||
@@ -206,7 +308,7 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
|
||||
? options.filter((i) => !(i.type === 'card.play' && isTrainCard(s, i.cardId)))
|
||||
: options;
|
||||
const usable = held.length > 0 ? held : options;
|
||||
if (s.turn.option === null) return chooseLocalOption(s, player, usable, tweaks);
|
||||
if (turnOf(s, player).option === null) return chooseLocalOption(s, player, usable, tweaks);
|
||||
return followThrough(s, player, usable, tweaks);
|
||||
}
|
||||
|
||||
@@ -241,40 +343,13 @@ function committedTrains(s: GameState): number {
|
||||
*
|
||||
* §7 lets you play as many train cards as you draw, and a train that arrives with nowhere to stand
|
||||
* is an automatic collision (Gap 2d) — so the two rules together make a train card actively harmful
|
||||
* once the A/D tracks are spoken for. Off unless `trainCapSlack` is set.
|
||||
* once the A/D tracks are spoken for. `noTrainCap` switches the cap off for re-measurement.
|
||||
*/
|
||||
function trainWouldOverfillTheOffice(s: GameState, player: PlayerIndex, tweaks: BotTweaks): boolean {
|
||||
if (tweaks.noTrainCap) return false;
|
||||
return committedTrains(s) >= officeProfile(areaOf(s, player).tier).adTracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Would running here leave the engine buried among its own cars?
|
||||
*
|
||||
* Cars met on a FORWARD move couple onto the nose (§A.3), which pushes the engine back through its
|
||||
* own train — `carsCoupled` moves `engineAt` by the number taken. So the engine ends up buried
|
||||
* whenever it had cars behind it already and picks up more in front, and §8.2 then refuses to let
|
||||
* the train leave the Office. Only trains care: a local crew has nowhere it must depart from.
|
||||
*
|
||||
* Asked of the engine's own destination list, so the count is the count that will really couple.
|
||||
*/
|
||||
function wouldBuryTheEngine(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
move: Extract<Intent, { type: 'switch.move' }>,
|
||||
): boolean {
|
||||
const tray = s.trays.get(move.trayId);
|
||||
if (!tray || tray.trainNumber === null) return false;
|
||||
if (tray.position.at !== 'grid') return false;
|
||||
if (move.reverse) return false; // cars taken while backing up couple BEHIND the engine
|
||||
const len = tray.consist.length;
|
||||
if (len === 0 || tray.engineAt >= len) return false; // nothing behind the engine to bury it against
|
||||
|
||||
const dest = destinationsFor(s, player, move.trayId, tray.position.coord, false).find(
|
||||
(d) => d.coord.row === move.to.row && d.coord.col === move.to.col,
|
||||
);
|
||||
return (dest?.couples.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
function chooseLocalOption(
|
||||
s: GameState,
|
||||
@@ -339,7 +414,7 @@ function chooseLocalOption(
|
||||
* NO BRANCH FOR MAINLINE MODIFIERS, and that is the measured answer rather than an oversight.
|
||||
*
|
||||
* There was one. It read `options.some((i) => i.type === 'mainline.modify')`, but `mainline.modify`
|
||||
* requires `s.turn.option === 'draw'` and this runs while the option is still null, so
|
||||
* requires `turnOf(s, player).option === 'draw'` and this runs while the option is still null, so
|
||||
* `legalActions` had already filtered it out — the branch could never fire and never had.
|
||||
*
|
||||
* Rewriting it to check the HAND made it work, and made the bot WORSE: -0.64 revenue a game,
|
||||
@@ -364,8 +439,10 @@ function chooseLocalOption(
|
||||
return because('the crew is away from the Office and can never depart from where it stands', can('switch')!);
|
||||
}
|
||||
|
||||
// 3. Stock a green box only when the load can actually finish — an empty car of the right type
|
||||
// is already spotted. Stocking without one just fills the box.
|
||||
// 3. Stock a green box when the load can actually be worked — an empty car of the right type is
|
||||
// already spotted. Staging ahead of the car is legal (§6.3) and cannot jam anything, but it
|
||||
// spends the Stage's one Freight Agent action on a load no Laborer can touch yet, so it is not
|
||||
// worth CHOOSING the option for; it is taken only as a fallback once the option is chosen.
|
||||
if (can('freightAgent') && canStockProductively(s, player)) {
|
||||
return because('a green box can be stocked with a load that can actually finish', can('freightAgent')!);
|
||||
}
|
||||
@@ -402,7 +479,7 @@ function topOfDepartment(s: GameState, slot: number): string | undefined {
|
||||
* In a competitive game the same call reads the other way round — burying a card a rival wants is an
|
||||
* attack — which is why the choice belongs to the discarding player and not to the rules.
|
||||
*/
|
||||
function bestDiscard(s: GameState, options: Intent[]): Intent | null {
|
||||
function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[], tweaks: BotTweaks): Intent | null {
|
||||
let best: Intent | null = null;
|
||||
let bestScore = -Infinity;
|
||||
for (const i of options) {
|
||||
@@ -412,7 +489,7 @@ function bestDiscard(s: GameState, options: Intent[]): Intent | null {
|
||||
// two showing whatever they happened to start with. Measured over 100 games — spreading 2.87
|
||||
// revenue, concentrating on the deepest 2.67, indifferent 2.67.
|
||||
const top = topOfDepartment(s, i.toSlot);
|
||||
const wanted = isWorthTaking(s, i.toSlot);
|
||||
const wanted = isWorthTaking(s, player, i.toSlot, tweaks);
|
||||
const depth = s.decks.departments[i.toSlot]?.length ?? 0;
|
||||
const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5;
|
||||
if (score > bestScore) {
|
||||
@@ -424,10 +501,39 @@ function bestDiscard(s: GameState, options: Intent[]): Intent | null {
|
||||
}
|
||||
|
||||
/** A face-up card worth spending the draw on rather than gambling on the deck. */
|
||||
function isWorthTaking(s: GameState, slot: number): boolean {
|
||||
return takingRank(s, slot) > 0;
|
||||
function isWorthTaking(s: GameState, player: PlayerIndex, slot: number, tweaks: BotTweaks): boolean {
|
||||
return takingRank(s, player, slot, tweaks) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Could an industry of this kind be laid anywhere right now? Asked of the engine's own placement
|
||||
* rule (`canPlaceAt`) and lockout (`isLockedOut`) rather than a copy: an industry is plain east-west
|
||||
* track, so the only squares worth asking about are empty ones east or west of a card already down.
|
||||
*/
|
||||
function industrySiteExists(s: GameState, player: PlayerIndex, kind: FreightKind): boolean {
|
||||
const area = areaOf(s, player);
|
||||
if (isLockedOut(area, kind)) return false;
|
||||
const probe = {
|
||||
geometry: { kind: 'facility', facility: kind },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
} as unknown as TrackCard;
|
||||
for (const key of area.grid.keys()) {
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
for (const dc of [1, -1]) {
|
||||
const at = { row: row!, col: col! + dc };
|
||||
if (at.row === area.runningRow || area.grid.has(coordKey(at))) continue;
|
||||
if (canPlaceAt(area, at, probe)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* HOW BADLY the face-up card is wanted. 0 means not worth the draw.
|
||||
*
|
||||
@@ -435,14 +541,18 @@ function isWorthTaking(s: GameState, slot: number): boolean {
|
||||
* happened to be scanned first — a coin flip on the card that decides whether the district ever
|
||||
* becomes a Passenger Facility at all.
|
||||
*/
|
||||
function takingRank(s: GameState, slot: number): number {
|
||||
function takingRank(s: GameState, player: PlayerIndex, slot: number, tweaks: BotTweaks): number {
|
||||
const id = topOfDepartment(s, slot);
|
||||
if (!id) return 0;
|
||||
const k = s.cards.get(id)?.kind;
|
||||
if (!k) return 0;
|
||||
if (k.kind === 'office') return nextOfficeTier(areaOf(s, 0).tier) === k.tier ? 3 : 0;
|
||||
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return 2;
|
||||
if (k.kind === 'freightFacility') return 1;
|
||||
if (k.kind === 'office') return nextOfficeTier(areaOf(s, player).tier) === k.tier ? 3 : 0;
|
||||
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') {
|
||||
return !tweaks.noPlayableTakes && trainWouldOverfillTheOffice(s, player, tweaks) ? 0 : 2;
|
||||
}
|
||||
if (k.kind === 'freightFacility') {
|
||||
return !tweaks.noPlayableTakes && !industrySiteExists(s, player, k.facility) ? 0 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -463,9 +573,13 @@ function isTrainCard(s: GameState, cardId: string): boolean {
|
||||
/**
|
||||
* A facility with room in its green box AND an empty car OF ITS OWN TYPE already spotted.
|
||||
*
|
||||
* The type match matters: a load that reaches WORK with no matching car to go onto is stuck, and a
|
||||
* stuck load strips the industry track of Operational Rail status (§9.3), so no car can be brought
|
||||
* in to rescue it. Stocking speculatively jams the facility.
|
||||
* A PRIORITY TEST, NOT A LEGALITY TEST — the engine stocks either way (§6.3). What it decides is
|
||||
* whether stocking is worth the Local Operations action THIS Stage: with a car spotted the load
|
||||
* moves next Stage, without one it sits.
|
||||
*
|
||||
* The type match matters when it does move: a load that reaches WORK with no matching car to go
|
||||
* onto is stuck, and a stuck load strips the industry track of Operational Rail status (§9.3), so
|
||||
* no car can be brought in to rescue it. `loadCanFinish` keeps the Laborers off such a load.
|
||||
*/
|
||||
function canStockProductively(s: GameState, player: PlayerIndex): boolean {
|
||||
for (const card of areaOf(s, player).grid.values()) {
|
||||
@@ -646,8 +760,7 @@ function runAroundCells(area: OfficeArea): Set<string> {
|
||||
* there is one the crew dare not serve.
|
||||
*/
|
||||
function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
|
||||
const area = s.officeAreas.get(player);
|
||||
if (!area) return null;
|
||||
const area = areaOf(s, player);
|
||||
|
||||
const onLoop = runAroundCells(area);
|
||||
const reachable = reachableOffMain(area);
|
||||
@@ -702,9 +815,106 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]):
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHAT A DISTRICT'S TRACK IS WORTH FOR WHAT IT LETS HAPPEN NEXT — the default since 2026-09-15;
|
||||
* `noValueLays` turns it off.
|
||||
*
|
||||
* `bestTrackLay` scores the PIECE — its shape and where it sits — and cannot tell one that opens an
|
||||
* industry site or closes a run-around from one that merely fills a square. This scores the LAYOUT the
|
||||
* piece would leave, so a lay is worth the difference it makes. Every term is something the rules turn
|
||||
* into play: a site is somewhere a held industry can go; a run-around lets a crew pass its own cars
|
||||
* (§A.5); a way off the main is the only road to either; a Running Track straight is what Interlocking
|
||||
* needs. The weights are a starting point to measure, not a result.
|
||||
*/
|
||||
function layoutValue(area: OfficeArea): number {
|
||||
let v = 0;
|
||||
const reachable = reachableOffMain(area);
|
||||
v += Math.min(reachable.size, 12) * 0.2;
|
||||
|
||||
let ways = 0;
|
||||
let loops = 0;
|
||||
for (const side of SIDES) {
|
||||
for (const col of waysOff(area, side)) {
|
||||
ways++;
|
||||
if (descendFrom(area, col, side).rejoins.size > 0) loops++;
|
||||
}
|
||||
}
|
||||
v += [0, 1.5, 2, 2.5][Math.min(ways, 3)]!;
|
||||
if (loops > 0) v += 6 + Math.min(loops - 1, 1) * 2;
|
||||
|
||||
// Squares an industry could legally be laid on, joined to track a crew can reach.
|
||||
const probe = protoCard({ kind: 'freightFacility', facility: 'mineTipple' }, 0)!;
|
||||
const tried = new Set<string>();
|
||||
let sites = 0;
|
||||
for (const key of reachable) {
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
for (const dc of [1, -1]) {
|
||||
const at = { row: row!, col: col! + dc };
|
||||
const k = coordKey(at);
|
||||
if (tried.has(k) || area.grid.has(k) || at.row === area.runningRow) continue;
|
||||
tried.add(k);
|
||||
if (canPlaceAt(area, at, probe)) sites++;
|
||||
}
|
||||
}
|
||||
v += [0, 2, 3, 3.5][Math.min(sites, 3)]!;
|
||||
|
||||
let mainStraight = false;
|
||||
for (const [key, card] of area.grid) {
|
||||
if (Number(key.split(',')[0]) !== area.runningRow || card.geometry.kind !== 'track') continue;
|
||||
if (card.geometry.geometry === 'straight') mainStraight = true;
|
||||
// A turnout on the main whose leg joins nothing is a hole in the Running Track with no road behind it.
|
||||
if (card.geometry.geometry === 'turnout') {
|
||||
const col = Number(key.split(',')[1]);
|
||||
for (const side of SIDES) {
|
||||
if (!hasPort(card, legPort(side))) continue;
|
||||
const beyond = area.grid.get(`${area.runningRow + side},${col}`);
|
||||
if (!beyond || !joins(card, legPort(side), beyond)) v -= 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mainStraight) v += 1.5;
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* The track lay worth most by `layoutValue`, placed on a copy of the district exactly as the reducer
|
||||
* places it (`protoCard`, `extendLimitsIfNeeded`). With `mustBuild`, only a lay that gains something is
|
||||
* offered, which is the slot `bestTrackLay` fills; without it, the best of whatever is legal, which is
|
||||
* the slot the "play what is in hand" fallback fills. Ties go to the square nearer the Office.
|
||||
*/
|
||||
function bestValuedLay(s: GameState, player: PlayerIndex, options: Intent[], mustBuild: boolean): Intent | null {
|
||||
const area = areaOf(s, player);
|
||||
const base = layoutValue(area);
|
||||
let best: Intent | null = null;
|
||||
let bestScore = -Infinity;
|
||||
for (const i of options) {
|
||||
if (i.type !== 'card.play' || i.placement === undefined) continue;
|
||||
const kind = s.cards.get(i.cardId)?.kind;
|
||||
if (kind?.kind !== 'track') continue;
|
||||
const built = protoCard(kind, i.variant);
|
||||
if (!built) continue;
|
||||
const after: OfficeArea = {
|
||||
...area,
|
||||
grid: new Map(area.grid),
|
||||
limitsWest: { ...area.limitsWest },
|
||||
limitsEast: { ...area.limitsEast },
|
||||
};
|
||||
after.grid.set(coordKey(i.placement), built);
|
||||
extendLimitsIfNeeded(after, i.placement);
|
||||
const gain = layoutValue(after) - base;
|
||||
if (mustBuild && gain <= 0.1) continue;
|
||||
const distance = Math.abs(i.placement.row - area.officeCoord.row) * 2 + Math.abs(i.placement.col - area.officeCoord.col);
|
||||
const score = gain - distance * 0.01;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
|
||||
const area = s.officeAreas.get(player);
|
||||
if (!area) return null;
|
||||
const area = areaOf(s, player);
|
||||
|
||||
const at = (row: number, col: number): TrackCard | undefined => area.grid.get(`${row},${col}`);
|
||||
|
||||
@@ -1005,19 +1215,20 @@ function facilityWantsAt(
|
||||
}
|
||||
|
||||
/**
|
||||
* Red Flags — "any time". Worth spending only when a train of ours is stopped out on the Mainline
|
||||
* with another train on the same card, which is the situation that becomes a rear-ender.
|
||||
* §Q, RED FLAGS (Gitea#19) — spent only at the moment of danger.
|
||||
*
|
||||
* The card was redefined: it plants a directional flag on your own Limits rather than protecting a
|
||||
* stopped train out on the Mainline, so the old heuristic ("is a train of ours sharing a Mainline
|
||||
* card") no longer describes anything the card does.
|
||||
*
|
||||
* The bot now flags ONLY through the out-of-phase prompt, which the engine raises exactly when an
|
||||
* arrival would collide (`redFlagStop`). That is a better policy than the old one and a much
|
||||
* simpler one: the engine has already established the danger, so there is nothing for the bot to
|
||||
* judge. It never plants a flag speculatively — it cannot tell whether it wants time to switch, and
|
||||
* a flag spent early is a flag not there when a train is actually bearing down.
|
||||
*/
|
||||
function worthFlagging(s: GameState, options: Intent[]): Intent | null {
|
||||
for (const i of options) {
|
||||
if (i.type !== 'maneuver.redFlags') continue;
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray || tray.position.at !== 'mainline') continue;
|
||||
const node = s.division.nodes[tray.position.index];
|
||||
if (node?.kind !== 'mainline') continue;
|
||||
if (node.transits.length > 1) return i;
|
||||
}
|
||||
return null;
|
||||
function worthFlagging(_s: GameState, options: Intent[]): Intent | null {
|
||||
return options.find((i) => i.type === 'mainline.redFlag' && i.flag === true) ?? null;
|
||||
}
|
||||
|
||||
/** A one-line account of which Load/Unload action was taken, and why it ranked first. */
|
||||
@@ -1038,6 +1249,25 @@ function loadReason(i: Intent): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Would a load stocked here be workable straight away — is there a spotted empty car of that type
|
||||
* not already promised to a load in the box or on the sign?
|
||||
*
|
||||
* A PREFERENCE, NOT A RULE. §6.3 stocking has no car requirement; this only ranks one legal stock
|
||||
* above another, so the Stage's single Freight Agent action goes where a Laborer can use it.
|
||||
*/
|
||||
function stockCanBeWorked(s: GameState, player: PlayerIndex, at: GridCoord, carType: CarType): boolean {
|
||||
const f = areaOf(s, player).grid.get(coordKey(at))?.facility;
|
||||
if (!f) return false;
|
||||
// Passengers board off the platform with no car spotted at all (§9.2), so a stocked coach is
|
||||
// always immediately workable.
|
||||
if (f.kind !== 'freight') return true;
|
||||
const spotted = f.industryTrack.cars.filter((c) => !c.loaded && c.type === carType).length;
|
||||
const staged = f.outboundBox.filter((c) => c.type === carType).length;
|
||||
const working = (f.menAtWork ?? []).filter((l) => l?.dir === 'out' && l.type === carType).length;
|
||||
return spotted - staged - working > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is there already an empty car of the right type spotted to receive this load (§9.3)? Without one
|
||||
* the load can be started and walked across MEN|AT|WORK but can never come off.
|
||||
@@ -1079,10 +1309,30 @@ function consistAfterMove(
|
||||
(d) => d.coord.row === move.to.row && d.coord.col === move.to.col,
|
||||
);
|
||||
if (!dest) return null;
|
||||
// Forward means the engine leads and meets the cars head-on (§A.3).
|
||||
return move.reverse ? [...tray.consist, ...dest.couples] : [...dest.couples, ...tray.consist];
|
||||
/**
|
||||
* Forward means the engine leads and meets the cars head-on (§A.3), and `couples` is ordered
|
||||
* NEAREST FIRST — so the last car met ends up nose-most and the cut reverses on its way into the
|
||||
* tray. Predicting it unreversed made the bot's run-around test read the wrong car as the tail
|
||||
* whenever it approached a cut of two or more forwards.
|
||||
*/
|
||||
return move.reverse
|
||||
? [...tray.consist, ...dest.couples]
|
||||
: [...[...dest.couples].reverse(), ...tray.consist];
|
||||
}
|
||||
|
||||
/**
|
||||
* Would this move drag the cars the crew has just set out back onto the train?
|
||||
*
|
||||
* Coupling is mandatory, so pulling out through the end a cut is standing at picks it up again —
|
||||
* which usually undoes the set-out the crew made this turn. The bot cannot make an ILLEGAL move
|
||||
* (legality is enforced upstream), so this costs it nothing to ignore; it simply wastes the Move,
|
||||
* and the crew arrives with the car it was trying to leave behind.
|
||||
*/
|
||||
function recouplesOwnCut(s: GameState, player: PlayerIndex, i: Intent): boolean {
|
||||
return i.type === 'switch.move' && ownCutFor(s, player, i.trayId, i.reverse).length > 0;
|
||||
}
|
||||
|
||||
|
||||
/** Is this player's crew sitting somewhere it can never depart from? */
|
||||
function strandedFromOffice(s: GameState, player: PlayerIndex): boolean {
|
||||
const area = areaOf(s, player);
|
||||
@@ -1125,25 +1375,23 @@ function followThrough(
|
||||
options: Intent[],
|
||||
tweaks: BotTweaks,
|
||||
): Intent {
|
||||
switch (s.turn.option) {
|
||||
switch (turnOf(s, player).option) {
|
||||
case 'draw': {
|
||||
// Draw before playing — otherwise the hand empties and never refills.
|
||||
//
|
||||
// Prefer a face-up Department card only when it is actually worth having. Taking the visible
|
||||
// card unconditionally meant the bot never once drew blind from the deck across 60 games,
|
||||
// which the anomaly detector correctly flagged: a whole branch of §6.2 going unexercised.
|
||||
if (!s.turn.drawnThisTurn) {
|
||||
if (!turnOf(s, player).drawnThisTurn) {
|
||||
const piles = options.filter(
|
||||
(i): i is Extract<Intent, { type: 'draw.fromDepartment' }> =>
|
||||
i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot),
|
||||
i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot, tweaks),
|
||||
);
|
||||
// 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.
|
||||
// Best-ranked pile, not the first that qualifies. Measured as a near no-op — an Office card
|
||||
// and a train card are face up together 1.6 decisions a game — but ranking them is what the
|
||||
// ranking function is for, and a coin flip on the card that decides whether the district
|
||||
// ever becomes a Passenger Facility is not worth keeping for its own sake.
|
||||
const useful = piles.sort((a, b) => takingRank(s, b.slot) - takingRank(s, a.slot))[0];
|
||||
const useful = piles.sort((a, b) => takingRank(s, player, b.slot, tweaks) - takingRank(s, player, a.slot, tweaks))[0];
|
||||
if (useful) return because('a face-up card is worth more than a blind draw right now', useful);
|
||||
const blind = options.find((i) => i.type === 'draw.fromHomeOffice');
|
||||
if (blind) return because('no face-up card is worth taking — gamble on the deck', blind);
|
||||
@@ -1204,7 +1452,7 @@ function followThrough(
|
||||
// STRAIGHTS that Enhancements require, and no Freight Facility has anywhere to go until a
|
||||
// district exists. Measured with track absent, the hand held a playable Enhancement on 4,778
|
||||
// turns and could legally place one on 33.
|
||||
const track = bestTrackLay(s, player, options);
|
||||
const track = !tweaks.noValueLays ? bestValuedLay(s, player, options, true) : bestTrackLay(s, player, options);
|
||||
if (track) return because('lay track — nothing else creates the straights Enhancements need or the spurs freight needs', track);
|
||||
|
||||
// Then real development: a card actually laid into the grid. Freight facilities are scored —
|
||||
@@ -1229,17 +1477,47 @@ function followThrough(
|
||||
s.cards.get(i.cardId)?.kind.kind !== 'track',
|
||||
);
|
||||
if (placed) return because('develop the district with a card that goes on the board', placed);
|
||||
const play = options.find((i) => i.type === 'card.play');
|
||||
const play = !tweaks.noValueLays
|
||||
? options.find((i) => i.type === 'card.play' && s.cards.get(i.cardId)?.kind.kind !== 'track') ??
|
||||
bestValuedLay(s, player, options, false)
|
||||
: options.find((i) => i.type === 'card.play');
|
||||
if (play) return because('play what is in hand', play);
|
||||
const end = options.find((i) => i.type === 'draw.end');
|
||||
if (end) return because('nothing in hand can be played anywhere legal', end);
|
||||
return because(
|
||||
'nothing playable — discard onto the Department whose face-up card is least worth keeping reachable',
|
||||
bestDiscard(s, options) ?? pickFirst(options, 'card.discard') ?? options[0]!,
|
||||
bestDiscard(s, player, options, tweaks) ?? pickFirst(options, 'card.discard') ?? options[0]!,
|
||||
);
|
||||
}
|
||||
|
||||
case 'switch': {
|
||||
// The planner decides the whole turn; the rules below are its fallback if the position is ever
|
||||
// not the one it planned for, and the whole of switching under `noPlanSwitching`.
|
||||
if (!tweaks.noPlanSwitching) {
|
||||
const planned = plannedSwitch(s, player, options, tweaks);
|
||||
if (planned) return planned;
|
||||
}
|
||||
|
||||
/**
|
||||
* A MOVE THAT DRAGS THE CREW'S OWN CUT BACK ON IS A WASTED MOVE, so take those off the table
|
||||
* before any heuristic gets to choose one.
|
||||
*
|
||||
* Coupling is mandatory (§A.4), so pulling out through the end a cut is standing at picks it
|
||||
* straight back up — which undoes the set-out the crew just made. Measured before this filter:
|
||||
* **625 of 1,029 set-outs across 60 games were recoupled by the very next move**, 219 of them
|
||||
* cars that had just been spotted at an industry. The bot was not doing anything illegal; it
|
||||
* was spending two Moves to end up where it started, over and over.
|
||||
*
|
||||
* Filtered here rather than at each `find` below because there are six of them and a rule that
|
||||
* has to be remembered at six call sites is a rule that will be forgotten at the seventh. The
|
||||
* originals come back if the filter would leave the crew with no move at all — backed into a
|
||||
* stub behind its own cars, the only way out IS through them, and standing still is worse.
|
||||
*/
|
||||
const clear = options.filter((i) => !recouplesOwnCut(s, player, i));
|
||||
if (options.some((i) => i.type === 'switch.move') && clear.some((i) => i.type === 'switch.move')) {
|
||||
options = clear;
|
||||
}
|
||||
|
||||
// Re-check every Move, not just when choosing the option. Conditions change mid-turn — a car
|
||||
// gets coupled, a siding fills — and once there is nothing left to do the fall-through would
|
||||
// pick an arbitrary legal move and burn the remaining Moves shuttling.
|
||||
@@ -1459,8 +1737,30 @@ function followThrough(
|
||||
}
|
||||
const clear = options.find((i) => i.type === 'freightAgent.clearInbound');
|
||||
if (clear) return because('the red Inbound box is full and blocking further unloading', clear);
|
||||
const stock = options.find((i) => i.type === 'freightAgent.stockOutbound');
|
||||
if (stock) return because('stock a green box so a Laborer has work next Stage', stock);
|
||||
/**
|
||||
* PREFER THE BOX WHOSE LOAD CAN ACTUALLY BE WORKED.
|
||||
*
|
||||
* §6.3 lets the Freight Agent stage a load with no car spotted — cargo may wait on the dock —
|
||||
* so every facility with room and a matching car in the Division Yard is now offered, not just
|
||||
* the ready ones. `canStockProductively` chose the option believing a ready facility existed;
|
||||
* taking the first offer regardless would spend the Stage's one Freight Agent action on some
|
||||
* other industry that cannot move its load for want of a car. Staging ahead is still taken as
|
||||
* a fallback: it is legal, it cannot jam anything, and it saves an action later.
|
||||
*/
|
||||
const stockable = options.filter(
|
||||
(i): i is Extract<Intent, { type: 'freightAgent.stockOutbound' }> =>
|
||||
i.type === 'freightAgent.stockOutbound',
|
||||
);
|
||||
const workable = stockable.find((i) => stockCanBeWorked(s, player, i.at, i.carType));
|
||||
const stock = workable ?? stockable[0];
|
||||
if (stock) {
|
||||
return because(
|
||||
workable
|
||||
? 'stock a green box whose load a Laborer can start next Stage'
|
||||
: 'stage a load ahead of the car — it waits in the green box until one is spotted',
|
||||
stock,
|
||||
);
|
||||
}
|
||||
/**
|
||||
* NEVER UNJAM A HEALTHY BOX.
|
||||
*
|
||||
@@ -1510,7 +1810,7 @@ function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
|
||||
const f = card.facility;
|
||||
if (!f || f.kind !== 'freight') continue;
|
||||
const spotted = f.industryTrack.cars.length;
|
||||
if (spotted >= f.industryTrack.length) continue;
|
||||
if (spotted >= MAX_CONSIST) continue;
|
||||
// Every commodity the industry handles, asked of its profile. A hand-written switch here had
|
||||
// drifted from the sheet: it named two industries that do not exist (`produceShed`,
|
||||
// `oilRefinery`) and omitted three that do (`freightHouse`, `refinery`, `packingSheds`), so
|
||||
@@ -1539,12 +1839,12 @@ function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
|
||||
* Would spotting this car here do any work?
|
||||
*
|
||||
* An OUTBOUND facility needs an EMPTY car of its own commodity to load onto; an INBOUND one needs
|
||||
* a LOADED car of its commodity to unload. Anything else merely consumes a slot on a finite
|
||||
* industry track (§9.3).
|
||||
* a LOADED car of its commodity to unload. Anything else merely consumes room on the industry's
|
||||
* track, which holds four cars like any other card (§9.3).
|
||||
*/
|
||||
function facilityWants(f: Facility, car: RollingStock): boolean {
|
||||
if (f.kind !== 'freight') return false;
|
||||
if (f.industryTrack.cars.length >= f.industryTrack.length) return false;
|
||||
if (f.industryTrack.cars.length >= MAX_CONSIST) return false;
|
||||
if (!facilityCarTypes(f).includes(car.type)) return false;
|
||||
if (!car.loaded && f.allows.outbound) return true;
|
||||
if (car.loaded && f.allows.inbound) return true;
|
||||
@@ -1638,30 +1938,17 @@ function sidingsWorthCollecting(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Cars standing on ordinary track that some facility would actually take. */
|
||||
function strandedWantedCars(s: GameState, player: PlayerIndex): { row: number; col: number }[] {
|
||||
const out: { row: number; col: number }[] = [];
|
||||
const tray = trayOf(s, player);
|
||||
if (!tray || tray.consist.length >= MAX_CONSIST) return out;
|
||||
for (const [key, card] of areaOf(s, player).grid) {
|
||||
if (card.facility || card.standing.length === 0) continue;
|
||||
if (!card.standing.some((c) => facilitiesWanting(s, player, c).length > 0)) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
out.push({ row: row!, col: col! });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function trayOf(s: GameState, player: PlayerIndex) {
|
||||
for (const tray of s.trays.values()) {
|
||||
if (tray.position.at === 'grid' && tray.position.owner === player) return tray;
|
||||
if (tray.position.at === 'grid' && tray.position.seat === player) return tray;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function trayLocation(s: GameState, player: PlayerIndex): { row: number; col: number } | null {
|
||||
for (const tray of s.trays.values()) {
|
||||
if (tray.position.at === 'grid' && tray.position.owner === player) {
|
||||
if (tray.position.at === 'grid' && tray.position.seat === player) {
|
||||
return tray.position.coord;
|
||||
}
|
||||
}
|
||||
@@ -1693,7 +1980,7 @@ export type PlayOutcome = {
|
||||
trainsScheduled: number;
|
||||
cardsPlayed: number;
|
||||
outcome: GameState['outcome'];
|
||||
/** The full ordered log. `state = fold(events)`, so this is the complete record of the game. */
|
||||
/** The full ordered log — everything the game emitted, in order. Narration, not a reducible record. */
|
||||
events: GameEvent[];
|
||||
/** Every intent the bot actually submitted, for action-mix analysis. */
|
||||
intents: Intent['type'][];
|
||||
@@ -1768,8 +2055,37 @@ export function playGame(
|
||||
tally(pumpFn(s));
|
||||
if (s.status === 'finished') break;
|
||||
|
||||
const actor =
|
||||
s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — a simulated game plays the timetable it was dealt.
|
||||
*
|
||||
* DECIDED BY THE DRIVER, not by the policy, and that distinction is the whole point. A bot that
|
||||
* is merely handed the two votes among its legal options will sometimes take another Day —
|
||||
* `randomBot` does so half the time — and since the table can go on granting Days for ever, the
|
||||
* game then runs until `maxTurns`. That is not a hypothetical: it turned `test/sim.test.ts` from
|
||||
* under a second into an unbounded hang, because every seeded game in the harness suddenly played
|
||||
* fifty thousand turns instead of two hundred.
|
||||
*
|
||||
* The harness exists to measure games of a configured length against a configured floor, so
|
||||
* "would you like more Days?" has one answer here whatever the policy. `developerBot` declines on
|
||||
* its own account too, which is what the server relies on when a table is all bots; this is the
|
||||
* guarantee that holds for every OTHER policy, including ones not written yet.
|
||||
*/
|
||||
if (s.status === 'awaitingExtension') {
|
||||
const voter = s.extensionVotes.findIndex((v) => v === null);
|
||||
if (voter < 0) break;
|
||||
const decline: Intent = { type: 'game.extend', player: voter, agree: false };
|
||||
intents.push(decline.type);
|
||||
history.push(decline);
|
||||
const declined = applyIntent(s, voter, decline);
|
||||
// A broken invariant, not a game ending early: the status says a vote is pending and `voter` is
|
||||
// a seat that has not cast one. Thrown rather than broken out of, matching the illegal-action
|
||||
// check below — silently returning a short game is how a dead replay looks like a real one.
|
||||
if (!declined.ok) throw new Error(`the extension vote was refused with ${declined.code}`);
|
||||
tally(declined.events);
|
||||
continue;
|
||||
}
|
||||
|
||||
const actor = actingPlayer(s);
|
||||
if (actor === null) break;
|
||||
|
||||
const options = legalActions(s, actor);
|
||||
|
||||
+26
-13
@@ -23,6 +23,7 @@
|
||||
* Run with:
|
||||
* node src/sim/compare.ts 1600 noTrainCap=1 — what the A/D cap is worth today
|
||||
* node src/sim/compare.ts 1600 noOperateFirst=1 — what operating before drawing is worth
|
||||
* node src/sim/compare.ts 1600 noPlanSwitching=1 — what planning the switching turn is worth
|
||||
*
|
||||
* The flags are ABLATIONS: they turn off heuristics the bot already plays, so a negative delta is
|
||||
* the heuristic earning its place. That is what a measured bot needs going forward — the question
|
||||
@@ -31,6 +32,12 @@
|
||||
|
||||
import { pump } from '../engine/advance.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
collectiveRevenueFloor,
|
||||
lengthProfile,
|
||||
} from '../engine/content.ts';
|
||||
import type { GameLength } from '../engine/content.ts';
|
||||
import type { GameConfig, GameMode } from '../engine/state.ts';
|
||||
import type { BotPolicy, BotTweaks } from './bot.ts';
|
||||
@@ -56,17 +63,23 @@ export type PairedResult = {
|
||||
variantStats: GameStats[];
|
||||
};
|
||||
|
||||
const SOLO = (length: GameLength, mode: GameMode): GameConfig => ({
|
||||
mode,
|
||||
victory: 'highestAfterDays',
|
||||
length,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
});
|
||||
// Always one bot (`runOne` below), so the revenue floor is `collectiveRevenueFloor(1, days)`.
|
||||
const SOLO = (length: GameLength, mode: GameMode): GameConfig => {
|
||||
const days = lengthProfile(length).days;
|
||||
return {
|
||||
mode,
|
||||
days,
|
||||
minCombinedRevenue: collectiveRevenueFloor(1, days),
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* One game, one seed, one policy.
|
||||
@@ -209,7 +222,7 @@ export function formatPaired(r: PairedResult): string {
|
||||
* against itself and report a confident zero, which is the most expensive way this tool could fail.
|
||||
*/
|
||||
export const NUMERIC_TWEAKS = new Set<string>([]);
|
||||
export const BOOLEAN_TWEAKS = new Set(['noTrainCap', 'noOperateFirst']);
|
||||
export const BOOLEAN_TWEAKS = new Set(['noTrainCap', 'noOperateFirst', 'noPlanSwitching', 'noPlayableTakes', 'noValueLays', 'noDeliberateNewTrain']);
|
||||
|
||||
export function parseTweaks(args: string[]): BotTweaks {
|
||||
const tweaks: Record<string, number | boolean> = {};
|
||||
@@ -238,7 +251,7 @@ if (isMain) {
|
||||
const tweaks = parseTweaks(args);
|
||||
|
||||
if (Object.keys(tweaks).length === 0) {
|
||||
console.error('nothing to compare — pass at least one tweak, e.g. trainCapSlack=1');
|
||||
console.error(`nothing to compare — pass at least one ablation, e.g. noValueLays=1 (one of: ${[...BOOLEAN_TWEAKS].join(', ')})`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(formatPaired(compare(tweaks, games, length)));
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* THE DISPLAY-STEP COLLECTOR — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 1-3.
|
||||
*
|
||||
* One ordered, watchable presentation step per accepted intent, so a player can see what everyone
|
||||
* else did instead of finding the board already rearranged. TODO #13: *"It's not fun to do my turn
|
||||
* and have magic happen in the background and then have to figure out what others did."*
|
||||
*
|
||||
* ONE HOOK, NOT TWO. The design anticipated wiring this into `GameSession.intent()` and
|
||||
* `GameSession.driveBots()` separately, with `LocalSession` doing its own thing for solitaire. It
|
||||
* does not need to: `src/server/session.ts` imports `submit` from `src/web/game.ts`, so solitaire,
|
||||
* live multiplayer and every bot turn already funnel through ONE function. Collecting there is what
|
||||
* makes solitaire a special case of multiplayer rather than a second implementation, which is the
|
||||
* standing design direction for this codebase.
|
||||
*
|
||||
* AND REPLAY IS INERT FOR FREE. `fromSave` and `fromMultiplayerSave` rebuild a game by calling
|
||||
* `applyIntent` + `record` + `drain` directly rather than `submit`, so a resumed server or a rebuilt
|
||||
* undo does NOT re-emit the whole game as steps. That was expected to need an explicit guard — the
|
||||
* plan calls it out as the same class of bug as #97, a mechanism firing on a path nobody pictured
|
||||
* it running on. It needs none, but the property is load-bearing: **if a replay path is ever moved
|
||||
* onto `submit()`, this becomes a real bug**, and `test/watchable.test.ts` pins it.
|
||||
*
|
||||
* WHAT A STEP IS. One accepted intent, or ONE AUTOMATIC PHASE — never one per `GameEvent`, because
|
||||
* the event list is not a complete reducer and a receiver could not rebuild state from it. It gets a
|
||||
* projected frame instead.
|
||||
*
|
||||
* PHASES EARN THEIR OWN STEPS, and that is TODO #18. `pump()` runs every automatic phase between one
|
||||
* click and the next and `drain()` records the whole batch at once, so New Train, the Mainline and
|
||||
* the shift change "look like they are being skipped entirely" — trains cross the Division in one
|
||||
* jump. Folding them into the triggering intent's step reproduces exactly that. So `submit()` steps
|
||||
* `advance()` one call at a time instead, and collects a step for each phase that actually DID
|
||||
* something. A phase that did nothing adds no narration and therefore produces no step at all, which
|
||||
* is Jesse's own rule (2026-09-09): "if nothing happens during a phase then we shouldn't lose time
|
||||
* to it."
|
||||
*
|
||||
* `drain()` is deliberately NOT changed. Replay, undo and `fromSave` all use it, and the
|
||||
* replay-inertness property below depends on their staying off this path. The stepped version lives
|
||||
* in `submit()` and makes the same `advance()` calls in the same order, so the resulting state is
|
||||
* identical — only the collection differs.
|
||||
*/
|
||||
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { GameState, PlayerIndex, SeatIndex } from '../engine/state.ts';
|
||||
import { seatOf } from '../engine/state.ts';
|
||||
import { publicSnapshot } from './view.ts';
|
||||
import type { PublicFrame } from './view.ts';
|
||||
import { deltaPublicFrame } from './public-delta.ts';
|
||||
import type { PublicFrameDelta } from './public-delta.ts';
|
||||
|
||||
/**
|
||||
* The wire format's version, on the ENVELOPE rather than on the projection.
|
||||
*
|
||||
* The plan's original sketch put `protocolVersion` inside `PublicFrame`. It does not belong there:
|
||||
* `PublicFrame` is a projection of the game and its property list is an allow-list that
|
||||
* `test/redaction.test.ts` enumerates, so a transport concern living in it would have to be
|
||||
* allow-listed as public game state, which it is not. The step is the message; the message carries
|
||||
* the version.
|
||||
*/
|
||||
export const DISPLAY_PROTOCOL_VERSION = 1;
|
||||
|
||||
/** What produced a step: somebody's intent, or the Division advancing a phase by itself. */
|
||||
export type StepCause = Intent['type'] | 'phase';
|
||||
|
||||
/** One watchable thing that happened, in order. */
|
||||
export type DisplayStep = {
|
||||
protocolVersion: typeof DISPLAY_PROTOCOL_VERSION;
|
||||
/** Monotonic per game. 0.8.1's reconnecting display stream needs it to detect a gap; a queue only needs the order. */
|
||||
seq: number;
|
||||
/**
|
||||
* Who acted — NULL for an automatic phase, which nobody did.
|
||||
*
|
||||
* Both are carried because Employee Rotation makes "which seat" and "which player" different
|
||||
* questions.
|
||||
*/
|
||||
player: PlayerIndex | null;
|
||||
seat: SeatIndex | null;
|
||||
/** What caused it — the input to pacing's kind classification. */
|
||||
cause: StepCause;
|
||||
/** The public board after this intent and everything it drained, against the previous step. */
|
||||
frame: PublicFrameDelta;
|
||||
/** The narration this intent added, in order, including any phase lines drained behind it. */
|
||||
lines: { text: string; tone: string }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-game collector state.
|
||||
*
|
||||
* Held on `Game` beside `log`, `cues` and `announced` and drained the same way, which is the
|
||||
* established convention in this codebase for "the model accumulated something, the view takes it".
|
||||
*/
|
||||
export type DisplayCollector = {
|
||||
/** Undrained steps, oldest first. */
|
||||
steps: DisplayStep[];
|
||||
/** The last public frame a step was built against, so the next delta has something to diff. */
|
||||
last: PublicFrame | null;
|
||||
/** Next sequence number to assign. */
|
||||
seq: number;
|
||||
};
|
||||
|
||||
export function newCollector(): DisplayCollector {
|
||||
return { steps: [], last: null, seq: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one accepted intent as a step.
|
||||
*
|
||||
* Called from `submit()` AFTER `record()` and `drain()`, so `state` is the position the intent
|
||||
* finally produced and `lines` is everything it caused to be said. The frame is projected
|
||||
* immediately and never from a retained `GameState` reference — a retained reference would resolve
|
||||
* to the FINAL state of a whole bot run, which is exactly the teleporting this exists to prevent.
|
||||
*/
|
||||
export function collectStep(
|
||||
collector: DisplayCollector,
|
||||
state: GameState,
|
||||
player: PlayerIndex | null,
|
||||
cause: StepCause,
|
||||
lines: { text: string; tone: string }[],
|
||||
): void {
|
||||
const next = publicSnapshot(state);
|
||||
collector.steps.push({
|
||||
protocolVersion: DISPLAY_PROTOCOL_VERSION,
|
||||
seq: collector.seq++,
|
||||
player,
|
||||
seat: player === null ? null : seatOf(state, player),
|
||||
cause,
|
||||
frame: deltaPublicFrame(collector.last, next),
|
||||
lines,
|
||||
});
|
||||
collector.last = next;
|
||||
}
|
||||
|
||||
/** Take everything collected so far, leaving the collector empty — `takeMoment()`'s pattern. */
|
||||
export function takeSteps(collector: DisplayCollector): DisplayStep[] {
|
||||
return collector.steps.splice(0, collector.steps.length);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Live per-seat Frame delta — Phase 2 of `docs/architecture/multiplayer.md`.
|
||||
*
|
||||
* `src/sim/replay.ts`'s `compress()` looked like the thing to reuse here (D2/D3's "2.9 KB per push
|
||||
* with the board omitted when unchanged" cites it) but it solves a different problem: it interns
|
||||
* card/description strings across a WHOLE recorded array of frames, which only pays off when
|
||||
* bundling many frames into one replay file. A live server pushes one frame at a time and has
|
||||
* nothing to intern against. The part that genuinely carries over is much smaller — a one-step-back
|
||||
* "null if unchanged since the last thing sent to THIS seat" check on the three fields that make up
|
||||
* almost all of a Frame's size: `cells`, `facilities`, `division` (`replay.ts`'s own `keys` array).
|
||||
*
|
||||
* Node-free by design, unlike `replay.ts` (which imports `node:fs`) — both the server and a browser
|
||||
* `RemoteSession` import this file directly.
|
||||
*/
|
||||
|
||||
import type { CellView, DivisionView, FacilityView, Frame } from './view.ts';
|
||||
|
||||
/** A `Frame` with the three board-shaped fields replaced by `null` where unchanged since `previous`. */
|
||||
export type FrameDelta = Omit<Frame, 'cells' | 'facilities' | 'division'> & {
|
||||
cells: CellView[] | null;
|
||||
facilities: FacilityView[] | null;
|
||||
division: DivisionView[] | null;
|
||||
};
|
||||
|
||||
const BOARD_KEYS = ['cells', 'facilities', 'division'] as const;
|
||||
|
||||
/**
|
||||
* `previous` is the last Frame actually sent to THIS seat, or `null` for a first connect / a
|
||||
* reconnect after a gap — Phase 2 has no persistence to replay a gap against (Phase 3), so a
|
||||
* reconnect always gets a full Frame here rather than a delta.
|
||||
*/
|
||||
export function deltaFrame(previous: Frame | null, next: Frame): FrameDelta {
|
||||
const out = { ...next } as unknown as FrameDelta;
|
||||
for (const key of BOARD_KEYS) {
|
||||
const unchanged = previous !== null && JSON.stringify(previous[key]) === JSON.stringify(next[key]);
|
||||
(out as Record<string, unknown>)[key] = unchanged ? null : next[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The receiving side: merges a delta back onto the last full Frame this seat actually has. */
|
||||
export function applyDelta(previous: Frame | null, delta: FrameDelta): Frame {
|
||||
const out = { ...delta } as unknown as Frame;
|
||||
for (const key of BOARD_KEYS) {
|
||||
if (delta[key] === null) {
|
||||
if (previous === null) {
|
||||
throw new Error(`deltaFrame said "${key}" is unchanged, but there is no previous Frame to merge onto`);
|
||||
}
|
||||
(out as Record<string, unknown>)[key] = previous[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+15
-7
@@ -12,7 +12,12 @@
|
||||
*/
|
||||
|
||||
import { pump } from '../engine/advance.ts';
|
||||
import { collectiveRevenueFloor, lengthProfile } from '../engine/content.ts';
|
||||
import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
collectiveRevenueFloor,
|
||||
lengthProfile,
|
||||
} from '../engine/content.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { GameLength } from '../engine/content.ts';
|
||||
import type { GameConfig, GameMode } from '../engine/state.ts';
|
||||
@@ -60,14 +65,17 @@ function statsOf(xs: number[]): Stats {
|
||||
};
|
||||
}
|
||||
|
||||
function configFor(mode: GameMode, length: GameLength): GameConfig {
|
||||
function configFor(mode: GameMode, length: GameLength, players: number): GameConfig {
|
||||
const days = lengthProfile(length).days;
|
||||
return {
|
||||
mode,
|
||||
victory: 'highestAfterDays',
|
||||
length,
|
||||
days,
|
||||
minCombinedRevenue: collectiveRevenueFloor(players, days),
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
@@ -83,7 +91,7 @@ export function simulate(opts: SimOptions): SimReport {
|
||||
const s = createGame({
|
||||
id: `sim-${i}`,
|
||||
seed,
|
||||
config: configFor(opts.mode, opts.length),
|
||||
config: configFor(opts.mode, opts.length, opts.players.length),
|
||||
playerNames: opts.players,
|
||||
});
|
||||
// The probe watches the game as it is played: the funnel gates are conditions at a moment, not
|
||||
@@ -154,7 +162,7 @@ export function formatReport(r: SimReport, length: GameLength, players: number):
|
||||
|
||||
// The provisional numbers this exists to test.
|
||||
out.push('\n against the provisional targets:');
|
||||
out.push(` target ${profile.target} over ${profile.days} Days`);
|
||||
out.push(` ${profile.days} Days`);
|
||||
out.push(
|
||||
` predicted ~5-6 revenue/player/Day steady state; observed mean ` +
|
||||
`${r.revenuePerPlayerPerDay.mean.toFixed(1)}`,
|
||||
|
||||
+744
-100
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* HOW LONG EACH STEP IS SHOWN — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 5.
|
||||
*
|
||||
* Shared rather than living in `src/web/`, so the 0.8.1 seatless board paces identically to a
|
||||
* player's own screen. Two views of one game that disagreed about how fast it looks would be worse
|
||||
* than either alone.
|
||||
*
|
||||
* WHY BY KIND RATHER THAN BY BUDGET. The obvious scheme is to give the whole backlog a time budget
|
||||
* and divide it by the queue length. Measured against real games, that does exactly the wrong
|
||||
* thing. From `public/replays/`: ~60 stages per game and ~5 intents per player per stage, so a
|
||||
* four-player table produces ~15 other-player steps per stage — but 44 of a 307-intent game are
|
||||
* `draw.end` and 60 are `loadUnload.end`, bookkeeping nobody wants to watch, while the thing that
|
||||
* is worth watching is rare and clustered. Two of the three published replays contain no
|
||||
* `switch.move` at all; the third has bursts of 14, 6, 6 and 6, and `trayMoved`'s own narration says
|
||||
* "N of 6 Moves left" because six is the engine's cap per crew. So a uniform budget spends the
|
||||
* player's attention on `draw.end` and rushes the switching.
|
||||
*
|
||||
* Assigning dwell by kind and letting the total fall out costs ~40s of animation across a whole
|
||||
* 60-stage game, against ~3.6 minutes for a flat 700ms — better switching visibility for a fifth of
|
||||
* the time. Jesse, 2026-09-09, on what matters: *"I definitely want to watch other players struggle
|
||||
* with the switching exercises … I don't think reading the switching in the log will be anywhere
|
||||
* nearly as interesting as watching the trains actually move on the board."*
|
||||
*
|
||||
* PACING IS CLIENT-SIDE ONLY. The server emits steps as fast as it likes and the client decides how
|
||||
* to show them, which is what keeps Gitea#20's "do not slow the authoritative game" true.
|
||||
*/
|
||||
|
||||
import type { StepCause } from './display-step.ts';
|
||||
|
||||
export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
|
||||
|
||||
/**
|
||||
* THE TUNING TABLE — dwell in milliseconds per kind.
|
||||
*
|
||||
* Start generous and tune down by playing; Jesse, 2026-09-09: *"start at 1s and tune down."* This is
|
||||
* the committed default and changing it needs a web rebuild, which in the `.s9pk` is a release — so
|
||||
* it is deliberately not the only way to change the pacing. A viewer's own `pace` multiplier
|
||||
* (`Settings`, `localStorage`) and a `?pace=` URL parameter both scale these without one, and
|
||||
* `pace = 0` turns the animation off entirely, which is also TODO #18's "a player who has seen it a
|
||||
* hundred times will want it off". **Multipliers above 1 are supported and expected** — Jesse asked
|
||||
* for 2 and 3 explicitly after the first play — up to `MAX_PACE`, and every tier scales together so
|
||||
* their relative weighting survives.
|
||||
*
|
||||
* NOT IN GAME-CREATION SETTINGS, on Jesse's call 2026-09-09: dwell is presentation, not a rule, and
|
||||
* `config` rides along in saves and replays. If it ever moves there, the config field supplies this
|
||||
* table's multiplier — the table, the classification and the queue do not change.
|
||||
*/
|
||||
export const DWELL: Record<StepKind, number> = {
|
||||
/** A train physically moving on the board. The thing worth watching, and protected accordingly. */
|
||||
switching: 1000,
|
||||
/**
|
||||
* A card, a car or a load changing hands somewhere visible — and the announcement of what a
|
||||
* player is about to do.
|
||||
*
|
||||
* WAS 250ms, WHICH WAS WRONG, and wrong in the way that mattered most: an early-game bot turn has
|
||||
* no switching in it at all, so it was six steps of 250ms and 0ms — **750ms for a whole turn**.
|
||||
* Jesse, from the first real play on the test server: *"bot play was way too fast. I briefly saw
|
||||
* that it was the bot's office area then their turn was done."* His instruction had been "start at
|
||||
* 1s and tune down", and that was applied only to switching while this number was invented.
|
||||
*/
|
||||
action: 700,
|
||||
/**
|
||||
* An automatic phase that DID something — TODO #18.
|
||||
*
|
||||
* Only reached when the phase actually narrated: `submit()` collects no step for a phase that
|
||||
* changed nothing, so this is never spent on the empty ones Jesse is content to guess at. Between
|
||||
* an ordinary action and a switching move, because the Mainline phase moves trains the length of
|
||||
* the Division and is the clearest case of "stuff just happened without being able to see how".
|
||||
*/
|
||||
phase: 600,
|
||||
/** Turn and phase bookkeeping. Nothing moved; do not spend the player's attention on it. */
|
||||
bookkeeping: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Which kind an intent is.
|
||||
*
|
||||
* Exhaustive over `Intent['type']` on purpose — a `default` would silently drop a newly added intent
|
||||
* into whatever tier the fallback names, and the failure mode is invisible (a move that never gets
|
||||
* a beat, or bookkeeping that stalls the queue for a second). `test/pacing.test.ts` walks every
|
||||
* member of the union so a new intent cannot land here unclassified.
|
||||
*/
|
||||
export function kindOf(cause: StepCause): StepKind {
|
||||
switch (cause) {
|
||||
// The Division advancing itself — New Train, the Mainline, the shift change (TODO #18).
|
||||
case 'phase':
|
||||
return 'phase';
|
||||
|
||||
// The crew and its train moving, coupling, setting out and re-ordering — §6.1 and Appendix A.
|
||||
case 'switch.move':
|
||||
case 'switch.dropCars':
|
||||
case 'switch.sortConsist':
|
||||
case 'maneuver.flyingSwitch':
|
||||
case 'maneuver.redFlags':
|
||||
return 'switching';
|
||||
|
||||
// Something visible changed hands or position, but no train drove anywhere.
|
||||
case 'card.play':
|
||||
case 'card.discard':
|
||||
case 'draw.fromHomeOffice':
|
||||
case 'draw.fromDepartment':
|
||||
case 'newTrain.placeCar':
|
||||
case 'newTrain.passCar':
|
||||
case 'newTrain.secondSection':
|
||||
case 'newTrain.startExtra':
|
||||
case 'porter.board':
|
||||
case 'porter.detrain':
|
||||
case 'laborer.startLoad':
|
||||
case 'laborer.advanceLoad':
|
||||
case 'laborer.beginUnload':
|
||||
case 'freightAgent.stockOutbound':
|
||||
case 'freightAgent.clearInbound':
|
||||
case 'freightAgent.unjam':
|
||||
case 'mainline.clearance':
|
||||
case 'mainline.modify':
|
||||
case 'mainline.redFlag':
|
||||
case 'mainline.yardOffice':
|
||||
case 'redFlag.play':
|
||||
return 'action';
|
||||
|
||||
/**
|
||||
* `localOps.choose` IS AN ANNOUNCEMENT, NOT BOOKKEEPING — moved out 2026-09-09 after the first
|
||||
* real play. It is the line that reads "Player Bot 1 chose to SWITCH — six Moves to shunt cars
|
||||
* around the yard": the heading for everything that follows, and at zero dwell nobody ever saw
|
||||
* it, so a bot's turn began with no indication of what it was about to do.
|
||||
*/
|
||||
case 'localOps.choose':
|
||||
return 'action';
|
||||
|
||||
// Ending a phase or a turn, and voting. Nothing to see: the consequences were the thing, and
|
||||
// there are more of these than of anything else.
|
||||
case 'loadUnload.end':
|
||||
case 'draw.end':
|
||||
case 'switch.end':
|
||||
case 'freightAgent.end':
|
||||
case 'game.extend':
|
||||
return 'bookkeeping';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The widest multiplier that is a speed rather than a mistake.
|
||||
*
|
||||
* `pace` has no lower surprise — 0 means off — but an unbounded upper one does: `?pace=300` from
|
||||
* somebody typing 3.00, or a corrupt `localStorage` value, would give a switching move a five-minute
|
||||
* dwell and look exactly like a frozen board. Twenty is far past any speed anyone would choose and
|
||||
* well short of unusable.
|
||||
*
|
||||
* RAISED FROM TEN 2026-09-10, because the ceiling turned out not to be theoretical: Jesse played at
|
||||
* 10× — the top of the ladder — and reported it *"still a bit fast, but followable"*. A control whose
|
||||
* slowest setting is not slow enough for the person using it has the wrong ceiling, not the right one
|
||||
* held firmly.
|
||||
*/
|
||||
export const MAX_PACE = 20;
|
||||
|
||||
/**
|
||||
* The speeds the on-screen control offers, slowest last.
|
||||
*
|
||||
* `0` is off: every move is drawn at once, as it was before v0.8.0 — TODO #18's "a player who has
|
||||
* seen it a hundred times will want it off". The ladder runs well past 1 because that is what the
|
||||
* first real play asked for: Jesse reached for 7×, and although the `?pace=` he used never took
|
||||
* effect (the splash replaces the query string, so the play page only ever saw `?lobby`), the wish
|
||||
* was real. Watching a bot shunt cars is the point of this feature, and it is worth as long as it
|
||||
* takes.
|
||||
*/
|
||||
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10, 15, 20] as const;
|
||||
|
||||
/**
|
||||
* How long to show one step, in ms, at a given speed.
|
||||
*
|
||||
* `pace` scales every tier by the same factor, so **the tiers stay in proportion at any speed** — a
|
||||
* switching move outlasts an ordinary action at 0.5× and at 3× alike. That is deliberate: the
|
||||
* relative weighting is the design (a train moving is worth more attention than a card changing
|
||||
* hands), and the multiplier is only how fast the whole thing runs. `0` means do not animate at all.
|
||||
*/
|
||||
export function dwellFor(cause: StepCause, pace = 1): number {
|
||||
return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace)));
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the speed control a PHASE gets — damped, not the full multiplier.
|
||||
*
|
||||
* Phases were pinned at their tabled beat in v0.8.0.3, because scaling them with everything else put
|
||||
* a wall of clock-ticking after a player's own move. That was right about the cost and wrong about
|
||||
* the need: at 10× the caption row goes past faster than the sentence on it can be read. Jesse,
|
||||
* 2026-09-10: *"phases displayed on the upper line go by too quickly still. Should be 4 times as
|
||||
* long — at a guess. Maybe use the speed multiplier for that too?"*
|
||||
*
|
||||
* So they scale, at a third of the rate. That lands exactly on his guess — 10× gives a phase four
|
||||
* times its tabled beat — while leaving 1× untouched, and it stays affordable because phase beats
|
||||
* cluster rather than accumulate: measured over 60 pushes, a push carries **1.0 phase beat on
|
||||
* average and 4 at worst**, so the wait after a move goes to ~2.4s typical and ~10s at its very
|
||||
* worst rather than the minutes a full multiplier would have cost.
|
||||
*
|
||||
* Below 1× it simply follows the multiplier: somebody asking for everything faster means the phases
|
||||
* too.
|
||||
*/
|
||||
function phaseSpeed(pace: number): number {
|
||||
return pace <= 1 ? pace : 1 + (pace - 1) / 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to show one STEP — the form the queue actually uses.
|
||||
*
|
||||
* A step that said nothing gets no dwell, whatever caused it. That is one rule covering two cases
|
||||
* arrived at separately: a phase where nothing happened (Jesse, 2026-09-09 — *"if nothing happens
|
||||
* during a phase then we shouldn't lose time to it"*), and a phase that only handed the turn on,
|
||||
* which changes the board but has nothing on it to look at. Structurally typed so this file does not
|
||||
* have to import `DisplayStep` back from the module that imports `StepCause` from it.
|
||||
*/
|
||||
export function dwellForStep(
|
||||
step: { cause: StepCause; player: number | null; lines: readonly unknown[]; frame: { table: object } },
|
||||
pace = 1,
|
||||
): number {
|
||||
// Off means off, for the clock as much as for anybody's move.
|
||||
if (pace <= 0) return 0;
|
||||
/**
|
||||
* THE SPEED CONTROL IS ABOUT OTHER PEOPLE, NOT ABOUT THE CLOCK.
|
||||
*
|
||||
* A phase keeps its tabled beat at every speed. Measured over 40 turns of a real 3-seat game, the
|
||||
* waiting split almost evenly — 21.0s of other players against 21.0s of phases turning over — so
|
||||
* scaling both put 105 seconds of clock-ticking into a 5× game, all of it after the player's own
|
||||
* move and none of it anything to watch. Jesse, from that game: *"after my turn, when I actually
|
||||
* execute my turn, I'm still subject to that same delay before it moves on. That makes no sense."*
|
||||
*
|
||||
* The phase still gets its beat (TODO #18) — it just does not get longer because somebody wanted
|
||||
* to watch a bot shunt cars.
|
||||
*/
|
||||
const speed = step.player === null ? phaseSpeed(pace) : pace;
|
||||
if (step.lines.length > 0) return dwellFor(step.cause, speed);
|
||||
/**
|
||||
* A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give
|
||||
* every phase a visible beat", for New Train, the Mainline and the shift change.
|
||||
*
|
||||
* Measured, because the obvious rule was wrong twice. "No narration, no dwell" looked right and
|
||||
* silently killed #18: a phase can move trains without saying anything, and those steps were being
|
||||
* flashed past. "Anything that changed the board" is wrong the other way: `submit()` steps
|
||||
* `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on
|
||||
* all of them would cost a quarter of an hour a game.
|
||||
*/
|
||||
const table = step.frame.table as Record<string, unknown>;
|
||||
const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table;
|
||||
return turned ? dwellFor(step.cause, speed) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many steps in a queue are actually going to be WATCHED.
|
||||
*
|
||||
* This is the number the "N behind" counter shows, and it is deliberately not `queue.length`. With
|
||||
* bookkeeping dwelling at zero, a backlog of 17 where 12 are `*.end` would read "17", plummet to 5
|
||||
* the instant it started, and then crawl — which is not the steady countdown the counter is for.
|
||||
* Thirteen dwelling steps means thirteen things you are going to see.
|
||||
*/
|
||||
export function watchableCount(causes: readonly StepCause[], pace = 1): number {
|
||||
return causes.filter((c) => dwellFor(c, pace) > 0).length;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Delta for the SEATLESS public frame — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 3.
|
||||
*
|
||||
* `frame-delta.ts` solves the same-shaped problem for a seated player's `Frame` and does NOT carry
|
||||
* over, which is worth saying plainly because reusing it looks obvious and is wrong. It nulls three
|
||||
* TOP-LEVEL keys — `cells`, `facilities`, `division` — and a `PublicFrame` has only the last of
|
||||
* those. Its `cells` and `facilities` live one level down, inside `districts[]`, one entry per seat,
|
||||
* and that is where nearly all of the bytes are.
|
||||
*
|
||||
* **So the districts are deltaed PER SEAT rather than as one array.** One accepted intent changes
|
||||
* one district; comparing the whole array as a unit would resend every other player's board on
|
||||
* every step, which is exactly the cost this exists to avoid. On a four-player table that is three
|
||||
* boards of waste per step, and a step is emitted for every bot move as well as every human one.
|
||||
*
|
||||
* It is also a TRUE PARTIAL rather than a full frame with holes in it, which is the other place
|
||||
* `frame-delta.ts` does not carry over. See `PublicFrameDelta` below for the measurement that forced
|
||||
* that; in short, most steps change one field and shipping the other thirty-four cost 16.7 MB a game.
|
||||
*
|
||||
* The convention that does carry over, kept identical so a reader of one file can read the other:
|
||||
* an absent or `null` field means "unchanged since the last thing sent to this receiver", and the
|
||||
* receiving side merges against the last full frame it actually holds. A first connect or a
|
||||
* reconnect after a gap sends a full frame instead — the display stream resets rather than replaying
|
||||
* (§ v0.8.0).
|
||||
*
|
||||
* Node-free by design, like `frame-delta.ts`: the server and the browser both import this directly.
|
||||
*/
|
||||
|
||||
import type { CellView, DivisionView, FacilityView, PublicDistrict, PublicFrame } from './view.ts';
|
||||
|
||||
/**
|
||||
* One district with its two heavy fields nulled when unchanged.
|
||||
*
|
||||
* `seat` is the identity and is always present — it is what the receiver matches on. `player` and
|
||||
* `name` are always sent too, and deliberately: Employee Rotation moves players between districts,
|
||||
* so the pairing of seat to player is itself news, and it costs two small fields to never have to
|
||||
* reason about whether a relabelling was missed.
|
||||
*/
|
||||
export type PublicDistrictDelta = Omit<PublicDistrict, 'cells' | 'facilities'> & {
|
||||
cells: CellView[] | null;
|
||||
facilities: FacilityView[] | null;
|
||||
};
|
||||
|
||||
/** The shared-table half of a `PublicFrame` — everything that is not the Division or a district. */
|
||||
type PublicTable = Omit<PublicFrame, 'division' | 'districts'>;
|
||||
|
||||
/**
|
||||
* A `PublicFrame` reduced to WHAT CHANGED.
|
||||
*
|
||||
* **Partial, not a full frame with holes**, and that distinction was measured rather than assumed.
|
||||
* The first version of this spread `...next` and nulled only the board fields, so every step shipped
|
||||
* all 35 top-level properties even when the sole change was whose turn it was. Once TODO #18 gave
|
||||
* automatic phases their own steps, most steps became exactly that — a turn handed on, nothing to
|
||||
* look at — and a full 6-day game cost **19.4 MB**, of which **16.7 MB was those silent steps at
|
||||
* ~11 KB each**. As a partial they are a few dozen bytes.
|
||||
*/
|
||||
export type PublicFrameDelta = {
|
||||
/** Only the shared-table fields whose value differs from the previous frame. */
|
||||
table: Partial<PublicTable>;
|
||||
/** The Division, only when it changed. */
|
||||
division: DivisionView[] | null;
|
||||
/** Only the districts that changed, each carrying only the board fields that changed. */
|
||||
districts: PublicDistrictDelta[];
|
||||
};
|
||||
|
||||
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
const TABLE_KEYS = (frame: PublicFrame): (keyof PublicTable)[] =>
|
||||
(Object.keys(frame) as (keyof PublicFrame)[]).filter(
|
||||
(k): k is keyof PublicTable => k !== 'division' && k !== 'districts',
|
||||
);
|
||||
|
||||
/**
|
||||
* `previous` is the last public frame actually sent to THIS receiver, or `null` for a first connect
|
||||
* or a reset — in which case everything is sent in full.
|
||||
*/
|
||||
export function deltaPublicFrame(previous: PublicFrame | null, next: PublicFrame): PublicFrameDelta {
|
||||
const before = new Map(previous?.districts.map((d) => [d.seat, d]) ?? []);
|
||||
const table: Partial<PublicTable> = {};
|
||||
for (const key of TABLE_KEYS(next)) {
|
||||
if (previous === null || !same(previous[key], next[key])) {
|
||||
(table as Record<string, unknown>)[key] = next[key];
|
||||
}
|
||||
}
|
||||
const districts: PublicDistrictDelta[] = [];
|
||||
for (const d of next.districts) {
|
||||
const was = before.get(d.seat);
|
||||
const cells = was && same(was.cells, d.cells) ? null : d.cells;
|
||||
const facilities = was && same(was.facilities, d.facilities) ? null : d.facilities;
|
||||
// A district with nothing new is left out entirely rather than sent as a row of nulls: on a
|
||||
// four-player table three of them are unchanged on every single step.
|
||||
if (was && cells === null && facilities === null && same(was, d)) continue;
|
||||
districts.push({ ...d, cells, facilities });
|
||||
}
|
||||
return {
|
||||
table,
|
||||
division: previous !== null && same(previous.division, next.division) ? null : next.division,
|
||||
districts,
|
||||
};
|
||||
}
|
||||
|
||||
/** The receiving side: merges a delta back onto the last full public frame this receiver holds. */
|
||||
export function applyPublicDelta(previous: PublicFrame | null, delta: PublicFrameDelta): PublicFrame {
|
||||
const base = previous ?? (delta.table as PublicTable);
|
||||
const merged = { ...base, ...delta.table } as PublicTable;
|
||||
const bySeat = new Map((previous?.districts ?? []).map((d) => [d.seat, d]));
|
||||
for (const d of delta.districts) {
|
||||
const was = bySeat.get(d.seat);
|
||||
bySeat.set(d.seat, {
|
||||
...d,
|
||||
cells: d.cells ?? need(was?.cells, `districts[seat ${d.seat}].cells`),
|
||||
facilities: d.facilities ?? need(was?.facilities, `districts[seat ${d.seat}].facilities`),
|
||||
});
|
||||
}
|
||||
return {
|
||||
...merged,
|
||||
division: delta.division ?? need(previous?.division, 'division'),
|
||||
districts: [...bySeat.values()].sort((a, b) => a.seat - b.seat),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A delta that says "unchanged" against a receiver that has nothing to merge onto is a bug in the
|
||||
* SENDER's bookkeeping, not a recoverable state — it means the two sides disagree about what has
|
||||
* been delivered, and quietly producing a frame with a missing board would put a blank district in
|
||||
* front of a player. `frame-delta.ts` throws in the same situation and for the same reason.
|
||||
*/
|
||||
function need<T>(value: T | undefined, what: string): T {
|
||||
if (value === undefined) {
|
||||
throw new Error(`deltaPublicFrame said "${what}" is unchanged, but there is no previous frame to merge onto`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A face-up or face-down pile a card can move to or from, as the display addresses it. */
|
||||
export type PileKey = 'home' | 'salvage' | `dept${number}`;
|
||||
|
||||
/**
|
||||
* WHICH PILES A STEP MOVED — derived, never sent.
|
||||
*
|
||||
* The receiver already holds the frame before a step and the frame after it, so which pile changed
|
||||
* is a diff rather than something the wire has to carry. That matters twice over: nothing is added
|
||||
* to the protocol, and it cannot drift out of step with the projection the way a hand-maintained
|
||||
* hint would.
|
||||
*
|
||||
* WHY IT IS NEEDED AT ALL. A player watching somebody else draw a card sees seven seconds of an
|
||||
* unchanged board — the step holds the screen, and the only thing that moved is a number in a panel
|
||||
* they were not looking at. Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast
|
||||
* for me to see"*, which was never about duration. Lighting the pile is what tells the eye where.
|
||||
*
|
||||
* WHAT EACH ACTION MOVES, measured across four seeds rather than reasoned about:
|
||||
*
|
||||
* | intent | piles |
|
||||
* | ----------------------- | -------------------------------------------------------- |
|
||||
* | `draw.fromHomeOffice` | `home` — the COUNT only; the card itself stays private |
|
||||
* | `draw.fromDepartment` | that `dept`, and `home` too when the pile refills from it |
|
||||
* | `card.discard` | that `dept` |
|
||||
* | `card.play` | `salvage`, or nothing here when it lands on the board |
|
||||
* | switching, new trains | nothing here — those show on the board itself |
|
||||
*/
|
||||
/**
|
||||
* Mainline cards that became a different card between two public boards — a Realignment, which is the
|
||||
* one play that changes the Division itself.
|
||||
*
|
||||
* Playtest, 2026-09-15: *"is it possible to flash the mainline card when it gets changed by realignment?
|
||||
* This would be more obvious to see what's happening on the map."* Detected the same way `changedPiles`
|
||||
* detects a pile moving — by comparing the two boards the queue already holds — rather than by reading
|
||||
* the event, so the flash lands with the step that shows it and not when the intent arrived.
|
||||
*/
|
||||
export function changedDivisionCards(before: PublicFrame | null, after: PublicFrame): number[] {
|
||||
if (before === null) return [];
|
||||
const out: number[] = [];
|
||||
after.division.forEach((node, i) => {
|
||||
const was = before.division[i];
|
||||
if (was && was.kind === 'ml' && node.kind === 'ml' && was.label !== node.label) out.push(i);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function changedPiles(before: PublicFrame | null, after: PublicFrame): PileKey[] {
|
||||
if (before === null) return [];
|
||||
const out: PileKey[] = [];
|
||||
if (before.deck !== after.deck) out.push('home');
|
||||
after.departmentDepth.forEach((depth, i) => {
|
||||
// The TOP as well as the depth: taking the face-up card and replacing it leaves the count alone
|
||||
// and changes the card everybody can see, which is the half that matters to a watcher.
|
||||
if (before.departmentDepth[i] !== depth || before.departments[i] !== after.departments[i]) {
|
||||
out.push(`dept${i}`);
|
||||
}
|
||||
});
|
||||
if (before.salvage.depth !== after.salvage.depth || before.salvage.top !== after.salvage.top) {
|
||||
out.push('salvage');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+80
-30
@@ -19,16 +19,22 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
import { advance } from '../engine/advance.ts';
|
||||
import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
|
||||
import { applyIntent } from '../engine/apply.ts';
|
||||
import type { GameLength } from '../engine/content.ts';
|
||||
import { MAINLINE_PROFILES, lengthProfile, officeProfile } from '../engine/content.ts';
|
||||
import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
collectiveRevenueFloor,
|
||||
lengthProfile,
|
||||
} from '../engine/content.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
|
||||
import type { GameConfig } from '../engine/state.ts';
|
||||
import { actingPlayer } from '../engine/state.ts';
|
||||
import { reasonSentence } from '../web/panels.ts';
|
||||
import { developerBot, lastChoiceReason } from './bot.ts';
|
||||
import { carLabel, cuesFor, idleNote, isVisible, narrate } from './narrate.ts';
|
||||
import { cuesFor, idleNote, isVisible, narrate } from './narrate.ts';
|
||||
// The view-model lives in its own module so the browser build can import it without dragging in
|
||||
// this file's Node dependencies. Re-exported because tests and the web app import it from here.
|
||||
export type { CellView, DivisionView, FacilityView, Frame, Decision, TrainChip } from './view.ts';
|
||||
@@ -51,13 +57,16 @@ import { playCue } from '../web/sound.ts';
|
||||
export type Recording = { seed: number; length: GameLength; frames: Frame[]; outcome: string };
|
||||
|
||||
export function record(seed: number, length: GameLength, maxSteps = 100_000): Recording {
|
||||
const days = lengthProfile(length).days;
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length,
|
||||
days,
|
||||
minCombinedRevenue: collectiveRevenueFloor(1, days),
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
@@ -67,6 +76,8 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
const narrateCtx = {
|
||||
cardName: (id: string) => cardName(s, id),
|
||||
trainName: (id: string) => trainName(s, id),
|
||||
// A player index is not a seat index, so the fallback names no number at all — see `web/game.ts`.
|
||||
playerName: (p: number) => s.players[p]?.name ?? 'another player',
|
||||
};
|
||||
|
||||
// Tracks whether a phase did anything, so an empty one can say so rather than ending silently.
|
||||
@@ -125,7 +136,7 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
if (s.status === 'finished') break;
|
||||
if (!r.needsInput) continue;
|
||||
|
||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
const actor = actingPlayer(s);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(s, actor);
|
||||
if (options.length === 0) break;
|
||||
@@ -137,12 +148,26 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
push(applied.events);
|
||||
}
|
||||
|
||||
/**
|
||||
* IN WORDS, NOT AS AN ENUM (`TODO.md` #34). This heading read `loss — revenueFloor`, which is
|
||||
* exactly the defect Gitea#16 was filed about on the playable page — it just outlived the fix
|
||||
* here, because nothing player-facing pointed at it. `reasonSentence` is shared rather than
|
||||
* reimplemented, so the replay and the results screen cannot end up explaining the same ending
|
||||
* two different ways.
|
||||
*
|
||||
* Fed the LAST frame, which is the state the outcome was decided in and is already recorded.
|
||||
* Tags are stripped: this lands in an `<h1>` and in a console line, neither of which wants markup.
|
||||
*/
|
||||
const o = s.outcome;
|
||||
const last = frames[frames.length - 1];
|
||||
const why = o && last ? reasonSentence(last, o, last.day).replace(/<[^>]+>/g, '') : '';
|
||||
return {
|
||||
seed,
|
||||
length,
|
||||
frames,
|
||||
outcome: o ? `${o.result} — ${o.reason} · final Revenue ${s.players[0]?.revenue ?? 0}` : 'unfinished',
|
||||
outcome: o
|
||||
? `${o.result === 'win' ? 'won' : 'lost'} — ${why} Final Revenue ${s.players[0]?.revenue ?? 0}.`
|
||||
: 'unfinished',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,9 +227,9 @@ export function compress(frames: Frame[]): Packed {
|
||||
// Identity, not equality: `snapshot` pushes the very same FacilityView object into both the
|
||||
// cell and the frame's `facilities`, so this always resolves.
|
||||
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
|
||||
// `train` rides whole rather than being interned: it changes almost every frame, so a table
|
||||
// `trains` rides whole rather than being interned: it changes almost every frame, so a table
|
||||
// of them would be as long as the frames are and buy nothing.
|
||||
return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad, c.enhancementsWhat];
|
||||
return [ci, wi, c.enhancements, c.cars, fi, c.trains, c.adTracks, c.enhancementsWhat, c.standingWest, c.enhancementsSpent];
|
||||
});
|
||||
return { ...f, cells } as unknown as Frame;
|
||||
});
|
||||
@@ -238,23 +263,36 @@ export function rehydrateCells(
|
||||
facs: unknown[],
|
||||
): unknown[] {
|
||||
return packed.map((row) => {
|
||||
const p = row as [number, number, string[], string | null, string[], number, unknown, unknown, string[]];
|
||||
const p = row as [number, number, string[], string[], number, unknown, unknown, string[], number, boolean[]];
|
||||
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
|
||||
// Tolerant the same way `standingWest` below is: an array rides through as-is, a lone object
|
||||
// (an older recording's singular `train`) is wrapped into a one-train roster, and null or
|
||||
// undefined reads as no train at all.
|
||||
const rawTrains = p[5];
|
||||
const trains = Array.isArray(rawTrains) ? rawTrains : rawTrains ? [rawTrains] : [];
|
||||
return {
|
||||
row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5],
|
||||
what: whats[p[1]], enhancements: p[2], tray: p[3], cars: p[4],
|
||||
facility: p[5] < 0 ? null : facs[p[5]],
|
||||
train: p[6] ?? null,
|
||||
ad: p[7] ?? null,
|
||||
what: whats[p[1]], enhancements: p[2], cars: p[3],
|
||||
facility: p[4] < 0 ? null : facs[p[4]],
|
||||
trains,
|
||||
adTracks: (p[6] as number | undefined) ?? null,
|
||||
// Carried rather than recomputed: this function is emitted into the page by toString() and so
|
||||
// cannot reach the card catalogue that produced the text.
|
||||
enhancementsWhat: p[8] ?? [],
|
||||
enhancementsWhat: p[7] ?? [],
|
||||
// Where the train(s) on this card stand among the cars standing on it, so the replay draws a
|
||||
// cut ahead of or behind the engine exactly as the live board does. Absent in older recordings,
|
||||
// which read as 0 — the whole cut east of the engine, which is what they used to draw anyway.
|
||||
standingWest: p[8] ?? 0,
|
||||
// Which dispatch devices were spent (#101), so a replay strikes a used Radio through exactly
|
||||
// as the live board does. Absent in recordings made before it existed, which read as no
|
||||
// device spent — the same thing they drew at the time, so an old replay is unchanged.
|
||||
enhancementsSpent: p[9] ?? [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function renderHtml(rec: Recording): string {
|
||||
const target = lengthProfile(rec.length);
|
||||
const profile = lengthProfile(rec.length);
|
||||
return `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
@@ -351,7 +389,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
||||
</style></head><body>
|
||||
|
||||
<header>
|
||||
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (target ${target.target} over ${target.days} Days) · ${esc(rec.outcome)}</h1>
|
||||
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (${profile.days} Days) · ${esc(rec.outcome)}</h1>
|
||||
<div class="bar">
|
||||
<span class="dim">fedora <span id="super">—</span></span>
|
||||
<span>revenue <b class="big" id="rev">0</b></span>
|
||||
@@ -453,7 +491,10 @@ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','
|
||||
function miniFacility(x) {
|
||||
let h = '<div class="mini">';
|
||||
if (x.greenCap > 0) h += '<span class="dim">grn </span>' + boxes(x.green, x.greenCap, 'g');
|
||||
if (x.trackCap > 0) h += '<br><span class="dim">work </span>' +
|
||||
// Gated on the sign itself. This used to gate on trackCap, which was a stand-in for "freight
|
||||
// facility" back when only a freight card had an industry track length; the length is gone and
|
||||
// maw is the honest test — a Passenger Facility has no MEN | AT | WORK sign at all.
|
||||
if (x.maw.length > 0) h += '<br><span class="dim">work </span>' +
|
||||
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('');
|
||||
if (x.redCap > 0) h += '<br><span class="dim">red </span>' + boxes(x.red, x.redCap, 'r');
|
||||
return h + '</div>';
|
||||
@@ -470,7 +511,11 @@ function boxes(items, cap, cls) {
|
||||
|
||||
function render() {
|
||||
const f = FRAMES[i];
|
||||
$('turnchart').innerHTML = turnChartHtml(f, f.actor === null ? null : 'Player ' + (f.actor + 1));
|
||||
$('turnchart').innerHTML = turnChartHtml(
|
||||
f,
|
||||
f.actor === null ? null : 'Player ' + (f.actor + 1),
|
||||
f.players.length > 1 ? 'Player ' + (f.superintendent + 1) : null,
|
||||
);
|
||||
$('super').textContent = 'P' + f.superintendent;
|
||||
$('rev').textContent = f.revenue;
|
||||
$('rev').className = 'big ' + (f.revenue < 0 ? 't-bad' : f.revenue > 0 ? 't-good' : '');
|
||||
@@ -490,9 +535,12 @@ function render() {
|
||||
|
||||
const CELLS = cellsAt(i), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
|
||||
|
||||
$('division').innerHTML = divisionSvg(DIV);
|
||||
// players, actor and viewer are not among the delta'd keys (see compress), so they ride whole on
|
||||
// every frame and the replay names the districts exactly as the live page does. No backticks in
|
||||
// this comment: it is inside the generated-page template literal, which they would terminate.
|
||||
$('division').innerHTML = divisionSvg(DIV, { players: f.players, actor: f.actor, viewer: f.viewer });
|
||||
// The same office renderer the playable app uses, so replay and game draw one board.
|
||||
$('grid').innerHTML = officeSvg(CELLS, f.runningRow);
|
||||
$('grid').innerHTML = officeSvg(CELLS, f.runningRow, [], [], f.limits);
|
||||
|
||||
/* Auto-hide: the district is worth its space during the phases that change it, and folded
|
||||
otherwise. A summary stays, because a panel that vanishes reads as broken. */
|
||||
@@ -522,18 +570,20 @@ function render() {
|
||||
'<div class="fac"><b>' + esc(x.name) + '</b> <span class="dim">' + esc(x.commodity) + ' · ' + esc(x.flow) +
|
||||
' · laborers ' + esc(x.laborers) + ' · porters ' + esc(x.porters) + '</span>' +
|
||||
'<div class="boxes"><span class="dim">green</span>' + boxes(x.green, x.greenCap, 'g') + '</div>' +
|
||||
// No sign and no siding on a Passenger Facility: maw is empty for one, but the LABEL was
|
||||
// printed before the loop, so a Depot showed the caption over nothing. The mini card above
|
||||
// gates on trackCap and was already right; this copy was not.
|
||||
// No sign on a Passenger Facility: maw is empty for one, but the LABEL was printed before
|
||||
// the loop, so a Depot showed the caption over nothing.
|
||||
// (No backticks in here — this whole block is inside a template literal.)
|
||||
(x.maw.length > 0
|
||||
? '<div class="boxes"><span class="dim">MEN|AT|WORK</span>' +
|
||||
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('') + '</div>'
|
||||
: '') +
|
||||
'<div class="boxes"><span class="dim">red</span>' + boxes(x.red, x.redCap, 'r') + '</div>' +
|
||||
// 's', not 'g': the siding is a place, not a direction, and green is reserved for outbound.
|
||||
(x.trackCap > 0 || x.track.length > 0
|
||||
? '<div class="boxes"><span class="dim">siding</span>' + boxes(x.track, x.trackCap, 's') + '</div>'
|
||||
// 's', not 'g': standing here is a place, not a direction, and green is reserved for outbound.
|
||||
// Cap 0 so only the cars actually spotted are drawn — an industry card prints no siding, and
|
||||
// the row of empty squares this used to draw claimed both that it did and that the siding was
|
||||
// as long as the industry's box count. It holds four cars, the same as any track card.
|
||||
(x.track.length > 0
|
||||
? '<div class="boxes"><span class="dim">spotted</span>' + boxes(x.track, 0, 's') + '</div>'
|
||||
: '') +
|
||||
'<div class="fstat ' + (x.jammed ? 'bad' : (x.canFinish ? 'good' : 'idle')) + '">' +
|
||||
(x.jammed
|
||||
|
||||
+48
-9
@@ -18,13 +18,14 @@
|
||||
*
|
||||
* Run with:
|
||||
* node src/sim/save-replay.ts 400 --top 3
|
||||
* node src/sim/save-replay.ts 400 --top 3 trainCapSlack=0
|
||||
* node src/sim/save-replay.ts 400 --top 3 noValueLays=1
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { areaAtSeat } from '../engine/apply.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import type { BotPolicy } from './bot.ts';
|
||||
import { developerBot, makeDeveloperBot } from './bot.ts';
|
||||
@@ -51,6 +52,21 @@ export type PlayedGame = {
|
||||
export function playForReplay(seed: number, policy: BotPolicy, maxTurns = 50_000): PlayedGame {
|
||||
const game = newGame(seed);
|
||||
for (let t = 0; t < maxTurns; t++) {
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — a recorded replay is a game played to its end.
|
||||
*
|
||||
* The timetable running out leaves the game on "play one more Day?", where `currentActor` is
|
||||
* null and this loop would otherwise stop — recording a file that replays to a question nobody
|
||||
* answered rather than to a finished game. A recording bot plays the timetable it was dealt, the
|
||||
* same rule `playGame` follows, so it declines and the file ends where a real game would.
|
||||
*/
|
||||
if (game.state.status === 'awaitingExtension') {
|
||||
const voter = game.state.extensionVotes.findIndex((v) => v === null);
|
||||
if (voter < 0) break;
|
||||
if (!submit(game, { type: 'game.extend', player: voter, agree: false }, voter)) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
@@ -71,7 +87,7 @@ export function playForReplay(seed: number, policy: BotPolicy, maxTurns = 50_000
|
||||
save: toSave(game),
|
||||
note:
|
||||
`${revenue} Revenue over ${game.state.clock.day - 1} Days · ${trains} train(s) on the timetable · ` +
|
||||
`${game.state.officeAreas.get(0)?.grid.size ?? 0} cards down` +
|
||||
`${areaAtSeat(game.state, 0).grid.size} cards down` +
|
||||
(collisions > 0 ? ` · ${collisions} collision(s)` : ' · no collisions'),
|
||||
};
|
||||
}
|
||||
@@ -130,13 +146,33 @@ if (isMain) {
|
||||
` best ${revenues.slice(0, 5).join(', ')} · median ${revenues[Math.floor(revenues.length / 2)]}`,
|
||||
);
|
||||
|
||||
let written = 0;
|
||||
for (const p of best) {
|
||||
const verified = best.filter((p) => {
|
||||
const check = verifyReplays(p);
|
||||
if (!check.ok) {
|
||||
console.warn(` REFUSED seed ${p.seed} — ${check.why}`);
|
||||
continue;
|
||||
if (!check.ok) console.warn(` REFUSED seed ${p.seed} — ${check.why}`);
|
||||
return check.ok;
|
||||
});
|
||||
|
||||
/**
|
||||
* THE PUBLISHED SET IS REPLACED, NOT ADDED TO.
|
||||
*
|
||||
* Every file here is named for its seed, so re-recording used to leave the previous set sitting
|
||||
* beside the new one — and the previous set is precisely the one whose rules have just moved. The
|
||||
* point of re-recording is that those files are dead; keeping them means the site serves dead
|
||||
* replays and `harness.test.ts` fails on them forever, which is how the last two went unnoticed.
|
||||
*
|
||||
* Only ever run after at least one replacement verifies, so a run that produces nothing publishable
|
||||
* leaves what is already published alone.
|
||||
*/
|
||||
if (verified.length > 0) {
|
||||
for (const old of readdirSync(dest).filter((f) => f.endsWith('.json') && f !== 'manifest.json')) {
|
||||
if (verified.some((p) => `seed-${p.seed}.json` === old)) continue;
|
||||
unlinkSync(join(dest, old));
|
||||
console.log(` retired ${old} — recorded under rules that have since moved`);
|
||||
}
|
||||
}
|
||||
|
||||
let written = 0;
|
||||
for (const p of verified) {
|
||||
const file = join(dest, `seed-${p.seed}.json`);
|
||||
writeFileSync(
|
||||
file,
|
||||
@@ -147,13 +183,16 @@ if (isMain) {
|
||||
// there is room — a title carrying seven tweak names is a title nobody reads.
|
||||
title: `${p.revenue} Revenue · seed ${p.seed}`,
|
||||
note: `${p.note} · played by ${policy.name}`,
|
||||
// The house rules it was DEALT under, without which the seed does not name this game and
|
||||
// the file replays as something else — see `Save.rules` in `web/game.ts`.
|
||||
...(p.save.rules ? { rules: p.save.rules } : {}),
|
||||
history: p.save.history,
|
||||
},
|
||||
null,
|
||||
1,
|
||||
),
|
||||
);
|
||||
console.log(` wrote ${file} (${p.note}, ${check.why})`);
|
||||
console.log(` wrote ${file} (${p.note})`);
|
||||
written += 1;
|
||||
}
|
||||
console.log(`${written} replay(s) saved — run \`npm run build:web\` to publish them`);
|
||||
|
||||
+10
-6
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* End-of-game statistics.
|
||||
*
|
||||
* Dev-side. Compiles a readable account of how a game actually went, from the event log. Because
|
||||
* `state = fold(events)`, the log is the complete record — nothing needs instrumenting in the
|
||||
* engine to produce any of this.
|
||||
* Dev-side. Compiles a readable account of how a game actually went, from the event log. Every event
|
||||
* the game emits is in there, so nothing needs instrumenting in the engine to produce any of this —
|
||||
* which is a claim about COVERAGE, not about reducibility: the log narrates the game completely and
|
||||
* reconstructs it not at all (`protocol.md` §3).
|
||||
*
|
||||
* Three purposes, in ascending order of usefulness:
|
||||
*
|
||||
@@ -18,7 +19,7 @@
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { GameState } from '../engine/state.ts';
|
||||
import { areaOf, facilityCarTypes } from '../engine/apply.ts';
|
||||
import { areaAtSeat, areaOf, facilityCarTypes } from '../engine/apply.ts';
|
||||
import { officeProfile } from '../engine/content.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -121,7 +122,7 @@ export function makeFunnelProbe(player = 0): {
|
||||
// A buried engine is a per-decision condition: every turn it persists is a turn the train is
|
||||
// stuck, so this counts turns rather than trains.
|
||||
for (const tray of s.trays.values()) {
|
||||
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== player) continue;
|
||||
if (tray.engineAt <= 0 || tray.engineAt >= tray.consist.length) continue;
|
||||
funnel.buriedTurns += 1;
|
||||
// Cars may never be set out at the Office (§A.4), which is where this almost always happens.
|
||||
@@ -295,7 +296,10 @@ export function summarize(
|
||||
const grossPassenger = rev.passengerBoard + rev.passengerDetrain;
|
||||
const gross = grossFreight + grossPassenger;
|
||||
|
||||
const area = final.officeAreas.get(0);
|
||||
// Seat 0 explicitly: these are SOLITAIRE summaries, where there is exactly one Office Area and
|
||||
// seat 0 is the only player. `areaAtSeat` rather than `officeAreas.get` so the key's meaning is
|
||||
// stated — a multi-player report would have to sum over seats, and would fail loudly here first.
|
||||
const area = areaAtSeat(final, 0);
|
||||
let freightFacilities = 0;
|
||||
if (area) {
|
||||
for (const card of area.grid.values()) {
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Component 17b — planning a whole switching turn before making the first Move.
|
||||
*
|
||||
* Dev-side, like the rest of the bot. The developer bot's switching branch chooses ONE move at a time
|
||||
* from a ladder of rules, and its own comment names what that cannot do: "a strong player would use
|
||||
* the six Moves to re-order the consist — that is the game's central switching puzzle, and this bot
|
||||
* does not attempt it." This attempts it, for one turn at a time.
|
||||
*
|
||||
* WHY SEARCH IS FAIR HERE. A switching turn draws no card and rolls no die, so trying sequences on a
|
||||
* copy of the game is exactly what a player does by looking at the board. The score below reads only
|
||||
* what a player can see — the district, the cars on the trains, the facilities — and never the deck.
|
||||
*
|
||||
* WHY NOT EVERY SEQUENCE. Measured 2026-09-14 over 30 switching turns from bot games: a median turn
|
||||
* reaches 229 distinct positions, but 11 of 30 passed 20,000, because setting cars out is free and a
|
||||
* crew can leave them in a great many places. So the search keeps the best `beam` positions at each
|
||||
* step and stops at `budget` positions tried. Small turns are searched completely inside that.
|
||||
*
|
||||
* THE SCORE IS OF WHERE THE TURN ENDS, not of what it did, and it starts from Jesse's ruling
|
||||
* (2026-09-14): "players will attempt to deliver / pick up cars even if it delays trains." So a car
|
||||
* put where it can be worked is worth a point, and a train left away from the Office costs a quarter
|
||||
* of one. The weights are a starting point to measure, not a result.
|
||||
*/
|
||||
|
||||
import { areaAtSeat, areaOf, commitEvents, facilityCarTypes, prepareIntent, withRouteCache } from '../engine/apply.ts';
|
||||
import { badlyMadeUp, isExpedited } from '../engine/advance.ts';
|
||||
import { MAX_CONSIST } from '../engine/content.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalSwitchingActions } from '../engine/legal.ts';
|
||||
import { cloneTally, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||
|
||||
export const SWITCH_WEIGHTS = {
|
||||
/** A car standing where its industry can load or unload it — the point of switching. */
|
||||
spot: 1.0,
|
||||
/** The same, past what the industry's boxes can work at once. */
|
||||
spotBeyondCapacity: 0.25,
|
||||
/** A car the industry cannot work, taking room on its track. */
|
||||
junkOnIndustry: -0.5,
|
||||
/** A finished car — loaded at a shipper, emptied at a receiver — still waiting to be lifted. */
|
||||
finishedLeft: -0.15,
|
||||
/** A car on one of this district's trains that some industry here would work. */
|
||||
carriedWanted: 0.35,
|
||||
/** The same car left on ordinary track, where a later turn can fetch it. */
|
||||
stagedWanted: 0.2,
|
||||
/** A coach kept with its train, or parked at the Office where §A.4 allows it. */
|
||||
coachWithTrain: 0.3,
|
||||
/** A coach left anywhere else, where no Porter can work it. */
|
||||
coachStranded: -0.3,
|
||||
/** Anything but a coach standing on the Office square — the next arrival collides (§8.3). */
|
||||
fouling: -3,
|
||||
/** A train that ends the turn away from the Office and so cannot highball next Mainline Phase. */
|
||||
trainAway: -0.25,
|
||||
/** On top of that, an expedited train — Q3 charges a Revenue point every Phase it is away. */
|
||||
expeditedAway: -1.0,
|
||||
/** A train that could not leave even from the Office — engine buried, caboose mid-train (§8.2). */
|
||||
notMadeUp: -0.6,
|
||||
/** Tie-breaks, so equal outcomes prefer the plan that does less. */
|
||||
perMove: -0.02,
|
||||
perSetOut: -0.005,
|
||||
/** A maneuver card spent — Flying Switch — so the planner plays one only when it buys something. */
|
||||
cardSpent: -0.1,
|
||||
} as const;
|
||||
|
||||
export type PlanOptions = {
|
||||
budget: number;
|
||||
beam: number;
|
||||
/**
|
||||
* Search Flying Switch alongside Moves, set-outs and sorts. On by default but UNMEASURED: the card
|
||||
* is dealt 0 copies (Jesse, 2026-08-26), so over 400 paired seeds turning it on changed nothing —
|
||||
* it is here so the planner can use the card the day it is dealt again.
|
||||
*/
|
||||
flyingSwitch?: boolean;
|
||||
};
|
||||
/**
|
||||
* Measured 2026-09-14, paired over 400 seeds against 3000/48: 2000/32 cost −0.02 ± 0.01 (t = −1.68,
|
||||
* inside the noise) at half the time per turn; 1000/24 cost −0.06 ± 0.02 (t = −2.65) for little more.
|
||||
*/
|
||||
export const DEFAULT_PLAN: PlanOptions = { budget: 2000, beam: 32, flyingSwitch: true };
|
||||
|
||||
export type SwitchPlan = {
|
||||
/** The intents to submit, in order. Empty when nothing beats stopping where the crew stands. */
|
||||
steps: Intent[];
|
||||
/** `switchFingerprint` before each step, and after the last — so a caller can tell it is on plan. */
|
||||
keys: string[];
|
||||
rootScore: number;
|
||||
score: number;
|
||||
/** Positions tried. */
|
||||
expanded: number;
|
||||
/** False when the budget ran out before the search did. */
|
||||
complete: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A copy of the game that a switching intent can be applied to without touching the original.
|
||||
*
|
||||
* NOT `structuredClone`, of the state or even of the district. A switching intent writes only the
|
||||
* cars standing on cards, the industry tracks, the district's A/D and held lists, the consist and
|
||||
* position of the trays standing in it, this player's turn and the tally — so exactly those arrays are
|
||||
* copied and everything else is shared by reference. Measured 2026-09-14, deep-cloning the district
|
||||
* was 44% of all planning time.
|
||||
*
|
||||
* `test/switch-planner.test.ts` proves across real games that planning leaves the original
|
||||
* byte-identical — which is what fails first if a reducer ever starts writing somewhere new, or
|
||||
* starts mutating a car or a card in place instead of replacing it.
|
||||
*/
|
||||
export function forkForSwitching(s: GameState, player: PlayerIndex): GameState {
|
||||
const seat = seatOf(s, player);
|
||||
const area = areaAtSeat(s, seat);
|
||||
const grid = new Map<string, TrackCard>();
|
||||
for (const [key, card] of area.grid) {
|
||||
const f = card.facility;
|
||||
grid.set(key, {
|
||||
...card,
|
||||
standing: [...card.standing],
|
||||
facility: f ? { ...f, industryTrack: { cars: [...f.industryTrack.cars] } } : f,
|
||||
});
|
||||
}
|
||||
const officeAreas = new Map(s.officeAreas);
|
||||
officeAreas.set(seat, {
|
||||
...area,
|
||||
grid,
|
||||
adOccupancy: [...area.adOccupancy],
|
||||
heldAtLimits: [...area.heldAtLimits],
|
||||
dispatchUsedToday: [...area.dispatchUsedToday],
|
||||
});
|
||||
const trays = new Map(s.trays);
|
||||
for (const [id, t] of s.trays) {
|
||||
if (t.position.at === 'grid' && t.position.seat === seat) trays.set(id, { ...t, consist: [...t.consist] });
|
||||
}
|
||||
const turns = new Map(s.turns);
|
||||
const turn = s.turns.get(player)!;
|
||||
turns.set(player, { ...turn, freightWorked: { ...turn.freightWorked } });
|
||||
// Flying Switch spends its card (`spendCard`): the hand map is rewritten and the Salvage Yard grows.
|
||||
const decks = { ...s.decks, hands: new Map(s.decks.hands), salvageYard: [...s.decks.salvageYard] };
|
||||
return { ...s, officeAreas, trays, turns, decks, tally: cloneTally(s.tally) };
|
||||
}
|
||||
|
||||
const carList = (xs: readonly RollingStock[]): string =>
|
||||
xs.map((c) => `${c.type}${c.loaded ? '+' : '-'}${c.origin ?? ''}`).join(',');
|
||||
|
||||
/**
|
||||
* Everything a switching intent can change, as a string — two positions with the same fingerprint
|
||||
* are the same position as far as the rest of the turn is concerned. Identical cars are not told
|
||||
* apart, which is right: no intent names a car.
|
||||
*/
|
||||
export function switchFingerprint(s: GameState, player: PlayerIndex): string {
|
||||
const seat = seatOf(s, player);
|
||||
const parts: string[] = [];
|
||||
for (const [id, t] of s.trays) {
|
||||
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
|
||||
const { row, col } = t.position.coord;
|
||||
parts.push(`${id}@${row},${col}/${t.facing}/${t.railFacing ?? ''}/${t.engineAt}:${carList(t.consist)}`);
|
||||
}
|
||||
for (const [key, card] of areaOf(s, player).grid) {
|
||||
const track = card.facility?.kind === 'freight' ? card.facility.industryTrack.cars : null;
|
||||
if (card.standing.length === 0 && card.standingWest === 0 && (track?.length ?? 0) === 0) continue;
|
||||
parts.push(`${key}=${carList(card.standing)}|${card.standingWest}|${track ? carList(track) : ''}`);
|
||||
}
|
||||
const turn = turnOf(s, player);
|
||||
parts.push(`m${turn.movesRemaining}`, JSON.stringify(turn.freightWorked), `h${(s.decks.hands.get(player) ?? []).join(',')}`);
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* §9.3 — an outbound industry loads EMPTY cars of its commodity, an inbound one unloads LOADED ones —
|
||||
* but never a load that was made in this same district (v0.4.9e, `LOADED_IN_THIS_DISTRICT`).
|
||||
*/
|
||||
function works(f: Facility, c: RollingStock, seat: number): boolean {
|
||||
if (f.kind !== 'freight' || !facilityCarTypes(f).includes(c.type)) return false;
|
||||
return (!c.loaded && f.allows.outbound) || (c.loaded && f.allows.inbound && c.origin !== seat);
|
||||
}
|
||||
|
||||
/**
|
||||
* The car an industry has finished with. Only decidable at a one-way industry: at one that both
|
||||
* ships and receives, a loaded car may be a delivery still waiting to be unloaded.
|
||||
*/
|
||||
function finished(f: Facility, c: RollingStock): boolean {
|
||||
if (f.kind !== 'freight' || !facilityCarTypes(f).includes(c.type)) return false;
|
||||
if (f.allows.outbound && !f.allows.inbound) return c.loaded;
|
||||
if (f.allows.inbound && !f.allows.outbound) return !c.loaded;
|
||||
return false;
|
||||
}
|
||||
|
||||
const same = (a: GridCoord, b: GridCoord): boolean => a.row === b.row && a.col === b.col;
|
||||
|
||||
/** How good this district's position is for the rest of the game, in rough Revenue points. */
|
||||
export function evaluateSwitching(s: GameState, player: PlayerIndex): number {
|
||||
const W = SWITCH_WEIGHTS;
|
||||
const area = areaOf(s, player);
|
||||
const seat = seatOf(s, player);
|
||||
const officeKey = coordKey(area.officeCoord);
|
||||
const passengerOffice = area.grid.get(officeKey)?.facility?.kind === 'passenger';
|
||||
let v = 0;
|
||||
|
||||
const withRoom: Facility[] = [];
|
||||
for (const card of area.grid.values()) {
|
||||
const f = card.facility;
|
||||
if (!f || f.kind !== 'freight') continue;
|
||||
if (f.industryTrack.cars.length < MAX_CONSIST) withRoom.push(f);
|
||||
const cap = Math.max(1, f.capacity.outbound + f.capacity.inbound);
|
||||
let working = 0;
|
||||
for (const c of f.industryTrack.cars) {
|
||||
if (works(f, c, seat)) v += ++working <= cap ? W.spot : W.spotBeyondCapacity;
|
||||
else if (finished(f, c)) v += W.finishedLeft;
|
||||
else v += W.junkOnIndustry;
|
||||
}
|
||||
}
|
||||
const wanted = (c: RollingStock): boolean => withRoom.some((f) => works(f, c, seat));
|
||||
|
||||
for (const [key, card] of area.grid) {
|
||||
if (card.facility?.kind === 'freight') continue;
|
||||
const atOffice = key === officeKey;
|
||||
for (const c of card.standing) {
|
||||
if (c.type === 'coach') v += atOffice && passengerOffice ? W.coachWithTrain : W.coachStranded;
|
||||
else if (atOffice) v += W.fouling;
|
||||
else if (wanted(c)) v += W.stagedWanted;
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of s.trays.values()) {
|
||||
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
|
||||
for (const c of t.consist) {
|
||||
if (c.type === 'coach') v += passengerOffice ? W.coachWithTrain : 0;
|
||||
else if (wanted(c)) v += W.carriedWanted;
|
||||
}
|
||||
if (t.trainNumber === null) continue;
|
||||
if (!same(t.position.coord, area.officeCoord)) {
|
||||
v += W.trainAway;
|
||||
if (isExpedited(t)) v += W.expeditedAway;
|
||||
}
|
||||
if (badlyMadeUp(t)) v += W.notMadeUp;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* For ORDERING the beam only, never for choosing the plan: a Move toward an industry changes nothing
|
||||
* the score can see until the car is set out, so without this the beam would drop the approach in
|
||||
* favour of positions that merely look tidy.
|
||||
*/
|
||||
function approach(s: GameState, player: PlayerIndex): number {
|
||||
const area = areaOf(s, player);
|
||||
const seat = seatOf(s, player);
|
||||
const targets: { at: GridCoord; f: Facility }[] = [];
|
||||
for (const [key, card] of area.grid) {
|
||||
const f = card.facility;
|
||||
if (!f || f.kind !== 'freight' || f.industryTrack.cars.length >= MAX_CONSIST) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
targets.push({ at: { row: row!, col: col! }, f });
|
||||
}
|
||||
let bonus = 0;
|
||||
for (const t of s.trays.values()) {
|
||||
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
|
||||
const here = t.position.coord;
|
||||
for (const c of t.consist) {
|
||||
let nearest = Infinity;
|
||||
for (const { at, f } of targets) {
|
||||
if (works(f, c, seat)) nearest = Math.min(nearest, Math.abs(at.row - here.row) + Math.abs(at.col - here.col));
|
||||
}
|
||||
if (nearest !== Infinity) bonus += 0.1 / (1 + nearest);
|
||||
}
|
||||
}
|
||||
return bonus;
|
||||
}
|
||||
|
||||
const SEARCHED = new Set<Intent['type']>(['switch.move', 'switch.dropCars', 'switch.sortConsist']);
|
||||
|
||||
type Node = {
|
||||
s: GameState;
|
||||
steps: Intent[];
|
||||
keys: string[];
|
||||
moves: number;
|
||||
setOuts: number;
|
||||
cards: number;
|
||||
score: number;
|
||||
rank: number;
|
||||
};
|
||||
|
||||
/** The best way found to spend what is left of this switching turn. Never mutates `s`. */
|
||||
export function planSwitchingTurn(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
opts: PlanOptions = DEFAULT_PLAN,
|
||||
): SwitchPlan {
|
||||
const W = SWITCH_WEIGHTS;
|
||||
const scoreOf = (st: GameState, moves: number, setOuts: number, cards: number): number =>
|
||||
evaluateSwitching(st, player) + moves * W.perMove + setOuts * W.perSetOut + cards * W.cardSpent;
|
||||
const searched = (type: Intent['type']): boolean =>
|
||||
SEARCHED.has(type) || (opts.flyingSwitch === true && type === 'maneuver.flyingSwitch');
|
||||
|
||||
const rootKey = switchFingerprint(s, player);
|
||||
const rootScore = scoreOf(s, 0, 0, 0);
|
||||
const root: Node = { s, steps: [], keys: [rootKey], moves: 0, setOuts: 0, cards: 0, score: rootScore, rank: rootScore };
|
||||
let best = root;
|
||||
const seen = new Set([rootKey]);
|
||||
let frontier: Node[] = [root];
|
||||
let expanded = 0;
|
||||
let complete = true;
|
||||
|
||||
search: while (frontier.length > 0) {
|
||||
const next: Node[] = [];
|
||||
for (const node of frontier) {
|
||||
const movesLeft = turnOf(node.s, player).movesRemaining;
|
||||
// Every candidate is decided against THIS position, inside one route cache, and only then applied
|
||||
// to its own copy: deciding on the copy would re-walk routes the listing had just walked.
|
||||
const decided = withRouteCache(node.s, () =>
|
||||
legalSwitchingActions(node.s, player)
|
||||
.filter((i) => searched(i.type) && (i.type === 'switch.dropCars' || movesLeft >= 1))
|
||||
.map((i) => ({ i, r: prepareIntent(node.s, player, i) })),
|
||||
);
|
||||
for (const { i, r } of decided) {
|
||||
if (expanded >= opts.budget) {
|
||||
complete = false;
|
||||
break search;
|
||||
}
|
||||
expanded++;
|
||||
if (!r.ok) continue;
|
||||
const f = forkForSwitching(node.s, player);
|
||||
commitEvents(f, r.events);
|
||||
const key = switchFingerprint(f, player);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const setOut = i.type === 'switch.dropCars';
|
||||
const moves = node.moves + (setOut ? 0 : 1);
|
||||
const setOuts = node.setOuts + (setOut ? 1 : 0);
|
||||
const cards = node.cards + (i.type === 'maneuver.flyingSwitch' ? 1 : 0);
|
||||
const score = scoreOf(f, moves, setOuts, cards);
|
||||
const child: Node = {
|
||||
s: f,
|
||||
steps: [...node.steps, i],
|
||||
keys: [...node.keys, key],
|
||||
moves,
|
||||
setOuts,
|
||||
cards,
|
||||
score,
|
||||
rank: score + approach(f, player),
|
||||
};
|
||||
if (score > best.score + 1e-9) best = child;
|
||||
next.push(child);
|
||||
}
|
||||
}
|
||||
// A stable sort, so equal ranks keep `legalActions` order and the bot stays deterministic.
|
||||
frontier = next.length > opts.beam ? next.sort((a, b) => b.rank - a.rank).slice(0, opts.beam) : next;
|
||||
}
|
||||
|
||||
return { steps: best.steps, keys: best.keys, rootScore, score: best.score, expanded, complete };
|
||||
}
|
||||
+99
-6
@@ -19,6 +19,8 @@ export type TurnChartFrame = {
|
||||
phase: string;
|
||||
phaseKey: string;
|
||||
actor: number | null;
|
||||
/** What the game has stopped to ask, when it has. Null while a phase is simply running. */
|
||||
awaiting?: { asks: string; train: string } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,7 +29,28 @@ export type TurnChartFrame = {
|
||||
* The first three are the rules text; Cargo and Supervisor Shift are written from what the engine
|
||||
* does, since the recovered sheet does not spell them out.
|
||||
*/
|
||||
export function turnChartHtml(f: TurnChartFrame, actorName: string | null): string {
|
||||
/**
|
||||
* `superName` is the player holding the Fedora, or null for a table where saying so is noise — one
|
||||
* seat, where it is always you.
|
||||
*
|
||||
* REPORTED BY JESSE 2026-08-23, from a two-player game on StartOS: seat 1 played a train card and
|
||||
* seat 2 was asked to build the train, "what's the rule for WHICH player builds a train?" The engine
|
||||
* was right — §7 makes up a consist "starting with the Superintendent and working left" — but the
|
||||
* board did not name the Superintendent ANYWHERE, so the question could not be answered from the
|
||||
* screen. `Frame` has carried `superintendent` all along and the standalone replay printed it; the
|
||||
* live game never did.
|
||||
*/
|
||||
/**
|
||||
* `yours` is true when the player reading this is the one being waited on. It makes the line flash,
|
||||
* because the commonest way a table stalls is somebody not noticing their own turn has come round
|
||||
* (reported from a playtest). The replay viewers pass nothing: a recording waits on nobody.
|
||||
*/
|
||||
export function turnChartHtml(
|
||||
f: TurnChartFrame,
|
||||
actorName: string | null,
|
||||
superName: string | null = null,
|
||||
yours = false,
|
||||
): string {
|
||||
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
|
||||
{
|
||||
key: 'localOps',
|
||||
@@ -39,7 +62,10 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null): stri
|
||||
{
|
||||
key: 'newTrain',
|
||||
label: 'New Train',
|
||||
tip: 'Timetabled trains for this Stage are built. New timetabled trains are randomly placed on the timetable. Held trains are built. Extra trains are built.',
|
||||
// Says WHO builds, which is the question this phase actually raises at a table: the round
|
||||
// starts with the Superintendent and works left, one car each, repeating (§7, Gap 9) — not
|
||||
// with whoever played the card. An Extra is the exception: its player loads it as they choose.
|
||||
tip: 'Timetabled trains for this Stage are built: each player adds ONE car at a time, starting with the Superintendent and working eastward, repeating until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
|
||||
// a locomotive being made up
|
||||
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
|
||||
},
|
||||
@@ -79,15 +105,56 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null): stri
|
||||
);
|
||||
}).join('');
|
||||
|
||||
// An automatic phase is waiting on nobody, and saying so is more use than a blank.
|
||||
const who = actorName ?? 'nobody — the Division is running itself';
|
||||
/**
|
||||
* WAITING ON WHOM, AND FOR WHAT.
|
||||
*
|
||||
* "nobody — the Division is running itself" is true of an automatic phase and was being printed
|
||||
* over the top of three interruptions that are emphatically waiting on a person: §8.1's clearance
|
||||
* ruling, the Yard Office offer and the Red Flag prompt. The Frame carried the phase's actor,
|
||||
* which is null throughout the Mainline Phase, so a game stopped on a named player's decision
|
||||
* reported that nobody was holding it up (Jesse, 2026-08-30). `Frame.actor` is `actingPlayer` now
|
||||
* and answers who; `awaiting` says what, because "waiting on Bob" with no more than that is a
|
||||
* game that looks stuck to everyone except Bob.
|
||||
*/
|
||||
/**
|
||||
* AN AUTOMATIC PHASE WAITS ON NOBODY, so it says what it is DOING instead of apologising.
|
||||
*
|
||||
* "nobody — the Division is running itself" reached the answer by negation, and left a player
|
||||
* reading a line whose subject was an absence (Jesse, playtest 2026-09-16: it should say "waiting
|
||||
* on <player>", or describe what the engine is doing — "the Division is moving trains during the
|
||||
* mainline phase"). The phase's NAME is already printed on the line directly above this one, so
|
||||
* these describe the work rather than repeating the label.
|
||||
*/
|
||||
const DOING: Record<string, string> = {
|
||||
mainline: 'the Division is moving trains',
|
||||
newTrain: "the Division is building this Stage's trains",
|
||||
loadUnload: 'the Division is working cargo',
|
||||
shiftChange: 'the Division is changing shifts',
|
||||
};
|
||||
// Local Operations always has an actor, so its entry is the fallback rather than a case.
|
||||
const who = actorName ?? DOING[f.phaseKey] ?? 'the Division is running itself';
|
||||
// Only a person is WAITED ON. The Division is not waiting; it is working.
|
||||
const waiting = actorName === null ? '' : 'waiting on ';
|
||||
const asked = f.awaiting
|
||||
? ` <span class="tc-asks">${esc(f.awaiting.asks)} · ${esc(f.awaiting.train)}</span>`
|
||||
: '';
|
||||
const fedora =
|
||||
superName === null
|
||||
? ''
|
||||
: `<div class="tc-super" data-tip="The Superintendent holds the Fedora. Every third Stage — 3, 6, 9 and 12 — it passes one seat to the left. The office rules on whether a following train may depart, and every round that goes round the table starts here: the New Train make-up round, and the acting order within a phase." tabindex="0">` +
|
||||
`<span aria-hidden="true">🎩</span> Superintendent <b>${esc(superName)}</b></div>`;
|
||||
|
||||
return (
|
||||
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
|
||||
`<span class="dim">${esc(f.clock)}</span></div>` +
|
||||
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
|
||||
`<div class="tc-who">waiting on <b>${esc(who)}</b></div>` +
|
||||
`<ol class="tc-phases">${chips}</ol>`
|
||||
`<div class="tc-who${yours && actorName !== null ? ' tc-yours' : ''}">` +
|
||||
`${waiting}<b>${esc(who)}</b>${asked}</div>` +
|
||||
// THE FEDORA RIDES AT THE END OF THE PHASE ROW (`TODO.md` #29, Jesse). It sat on its own line
|
||||
// between the phases and everything above them, which put a thing that changes every third
|
||||
// Stage in the middle of the things that change every Stage. The row it belongs beside is the
|
||||
// one whose last chip is Supervisor Shift — the phase that passes it.
|
||||
`<div class="tc-row"><ol class="tc-phases">${chips}</ol>${fedora}</div>`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,8 +177,34 @@ export const TURNCHART_CSS = `
|
||||
says "Player Solitaire", so the chart should agree. In multiplayer this is the thing a table
|
||||
glances at most often, so it gets its own chip rather than hiding in the phase text. */
|
||||
.tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3}
|
||||
.tc-asks{color:#a99ac4;font-style:italic}
|
||||
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
|
||||
border-radius:11px;padding:1px 9px;font-size:12px}
|
||||
/* IT IS YOUR TURN. The commonest way a table stalls is a player not noticing their turn came round,
|
||||
so the chip flashes rather than merely changing colour — motion is what catches an eye that is
|
||||
somewhere else on the board. Amber, because that is what "you can act" means everywhere else on
|
||||
this screen, and it separates "waiting on YOU" from the violet "where we are" news around it.
|
||||
A reduced-motion preference holds it steady and lit instead of dropping the cue. */
|
||||
.tc-who.tc-yours{color:#f0b64a}
|
||||
.tc-who.tc-yours b{color:#1a1f27;background:#f0b64a;border-color:#f0b64a;font-weight:700;
|
||||
animation:tc-flash 1s steps(1,end) infinite}
|
||||
@keyframes tc-flash{
|
||||
0%,49%{background:#f0b64a;border-color:#f0b64a;color:#1a1f27;box-shadow:0 0 0 3px rgba(240,182,74,.25)}
|
||||
50%,100%{background:transparent;border-color:#f0b64a;color:#f0b64a;box-shadow:none}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
.tc-who.tc-yours b{animation:none;box-shadow:0 0 0 3px rgba(240,182,74,.25)}
|
||||
}
|
||||
/* WHO HOLDS THE FEDORA. Violet like the rest of the chart — this is "where you are" news, not
|
||||
something to press — but unfilled, so the eye still lands on "waiting on" first: that is the one
|
||||
that changes every turn, while this changes four times a Day. */
|
||||
/* The phase row and the Fedora on one line, the hat pushed to the far end (TODO.md #29): the row
|
||||
is the Stage, and the Superintendent is who holds it. Wraps under the phases on a narrow screen
|
||||
rather than squeezing the chips. */
|
||||
.tc-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.tc-row ol.tc-phases{flex:1 1 auto}
|
||||
.tc-super{margin-left:auto;display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3;cursor:help}
|
||||
.tc-super b{color:#cbb6f2;border:1px solid #6b5a94;border-radius:11px;padding:1px 9px;font-size:12px}
|
||||
ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap}
|
||||
.tc-phase{display:flex;align-items:center;gap:6px;border:1px solid #2c333d;border-radius:14px;
|
||||
padding:3px 10px 3px 7px;font-size:11px;color:#8b94a3;background:#1a1f26;cursor:help}
|
||||
|
||||
+1181
-171
File diff suppressed because it is too large
Load Diff
+991
-63
File diff suppressed because it is too large
Load Diff
+69
-25
@@ -11,18 +11,35 @@ body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:15px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px}
|
||||
main{width:100%;max-width:760px}
|
||||
.hero{display:flex;gap:24px;align-items:flex-start}
|
||||
.hero-image{width:230px;flex:none;border-radius:9px;overflow:hidden;border:1px solid var(--line);cursor:zoom-in}
|
||||
.hero-image img{display:block;width:100%;height:auto}
|
||||
.hero-content{flex:1;min-width:0}
|
||||
@media(max-width:640px){.hero{flex-direction:column}.hero-image{width:100%;max-width:280px}}
|
||||
h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em}
|
||||
.tag{color:var(--dim);margin:0;font-size:14px}
|
||||
.blurb{color:#c6ccd6;margin:16px 0 26px;max-width:66ch}
|
||||
.blurb{color:#c6ccd6;margin:16px 0 0;max-width:66ch}
|
||||
.rule{height:1px;background:var(--line);margin:22px 0}
|
||||
.doors{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.doors{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-top:26px}
|
||||
@media(max-width:640px){.doors{grid-template-columns:1fr}}
|
||||
a.door{display:block;text-decoration:none;color:inherit;background:var(--panel);
|
||||
.door{display:block;text-decoration:none;color:inherit;background:var(--panel);
|
||||
border:1px solid var(--line);border-radius:9px;padding:18px 18px 16px;transition:.12s}
|
||||
a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
|
||||
a.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
|
||||
a.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
|
||||
a.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
|
||||
.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
|
||||
.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
|
||||
/* Not a fourth door: reading the guide is not a way to play, and giving it equal weight in the
|
||||
grid would say it is. A line under the doors, where somebody who does not know what to click
|
||||
will already be looking. */
|
||||
.newhere{margin:16px 2px 0;color:var(--dim);font-size:13px;line-height:1.55}
|
||||
.newhere a{color:#9fb6d8}
|
||||
.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
|
||||
.door.disabled .go{color:var(--dim)}
|
||||
a.door.disabled{pointer-events:none}
|
||||
a.door.disabled:hover{border-color:var(--line);background:var(--panel);transform:none}
|
||||
.lightbox{position:fixed;inset:0;background:rgba(8,10,13,.92);display:flex;
|
||||
align-items:center;justify-content:center;padding:32px;z-index:10;cursor:zoom-out}
|
||||
.lightbox[hidden]{display:none}
|
||||
.lightbox img{display:block;max-width:90vw;max-height:90vh;border-radius:6px;box-shadow:0 12px 40px rgba(0,0,0,.5)}
|
||||
/* the rule between title and blurb is drawn as rail, not decorated */
|
||||
svg.trackline{width:100%;height:24px;display:block;margin:10px 0 0}
|
||||
.rail{stroke:#4a5361;stroke-width:1.6}
|
||||
@@ -32,31 +49,48 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Station Master</h1>
|
||||
<p class="tag">TimeTable and Train Order (TT&TO) railroading, 1840–1950.</p>
|
||||
<div class="hero">
|
||||
<div class="hero-image" id="hero-image" tabindex="0" role="button" aria-label="View full-size box art">
|
||||
<img src="./images/station-master-splash.jpg"
|
||||
alt="Station Master — TimeTable & Train Order Railroading, 1840–1950. Box art: two trains meet at a junction beside a train order office."
|
||||
width="560" height="700">
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<h1>Station Master</h1>
|
||||
<p class="tag">TimeTable and Train Order (TT&TO) railroading, 1840–1950.</p>
|
||||
|
||||
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
|
||||
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
|
||||
<line class="rail" x1="0" y1="15" x2="700" y2="15"/>
|
||||
<g id="ties"></g>
|
||||
</svg>
|
||||
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
|
||||
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
|
||||
<line class="rail" x1="0" y1="15" x2="700" y2="15"/>
|
||||
<g id="ties"></g>
|
||||
</svg>
|
||||
|
||||
<p class="blurb">
|
||||
As Station Master in your line-side office, you manage the town's railroad connection. You are
|
||||
responsible for all track between your Limits, a district of industries hanging beneath it, and
|
||||
trains that arrive whether or not you are ready for them. Spot the right cars, work the loads,
|
||||
and get every train away again — a train with nowhere to stand is a collision, and a
|
||||
collision costs more than the freight was worth.
|
||||
</p>
|
||||
<p class="blurb">
|
||||
As Station Master in your line-side office, you manage the town's railroad connection. You are
|
||||
responsible for all track between your Limits, a district of industries hanging beneath it, and
|
||||
trains that arrive whether or not you are ready for them. Spot the right cars, work the loads,
|
||||
and get every train away again — a train with nowhere to stand is a collision, and a
|
||||
collision costs more than the freight was worth.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doors">
|
||||
<a class="door" href="./play.html">
|
||||
<a class="door" id="door-multiplayer" href="./play.html?lobby">
|
||||
<h2>Play multiplayer</h2>
|
||||
<p id="door-multiplayer-blurb">Play with your friends across the internet. You each run your
|
||||
own office area within the entire division. You can play a normal game, or choose co-op or
|
||||
cutthroat.</p>
|
||||
<span class="go" id="door-multiplayer-go">Set up a game →</span>
|
||||
</a>
|
||||
|
||||
<a class="door" href="./play.html?solitaire">
|
||||
<h2>Play solitaire</h2>
|
||||
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
|
||||
Your game data is saved in your browser — if you close the tab and reopen this site
|
||||
<p>Play by yourself and run the entire division for five full days. Clear the Revenue floor of
|
||||
15 by the end or the game is a loss. Your game data is saved in your browser — if you close the tab and reopen this site
|
||||
without clearing your cache, your game is preserved and you can continue automatically.
|
||||
During the game you can also explicitly save your progress for later replay.</p>
|
||||
<span class="go">Start a game →</span>
|
||||
<span class="go">Set up a game →</span>
|
||||
</a>
|
||||
|
||||
<a class="door" href="./replays.html">
|
||||
@@ -67,14 +101,24 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="newhere">New to Station Master?
|
||||
<a href="./quickstart.html">Read the Quickstart guide</a> — what the game is, how you win,
|
||||
how a Stage runs, what is on the screen, and a first twenty minutes. About twenty minutes to
|
||||
read, and it will save you an hour of guessing.</p>
|
||||
|
||||
<div class="rule"></div>
|
||||
|
||||
<footer>
|
||||
<span>build <span id="build">__BUILD__</span></span>
|
||||
<span>runs entirely in your browser — no server code required</span>
|
||||
<span>Multiplayer runs on StartOS server. Solitaire runs entirely in your browser.</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<div class="lightbox" id="lightbox" hidden>
|
||||
<img src="./images/station-master-splash.jpg"
|
||||
alt="Station Master — TimeTable & Train Order Railroading, 1840–1950. Box art: two trains meet at a junction beside a train order office.">
|
||||
</div>
|
||||
|
||||
<script type="module" src="./web/splash.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
/**
|
||||
* The lobby screen — Phase 4 of `docs/architecture/multiplayer.md` (§12 steps 17-20), rebuilt
|
||||
* 2026-08-23 (Jesse's cleanup pass).
|
||||
*
|
||||
* Everything in `#lobby` (`play.html`) is owned here: the join-secret gate, the two doors (join a
|
||||
* game, create one), the read-only preview a player reads BEFORE taking a seat, and the seating
|
||||
* screen up to `Lobby.Start`.
|
||||
*
|
||||
* WHAT CHANGED, and why each one was worth changing:
|
||||
* - JOINING CAME FIRST. It used to be a heading below the whole create form — fifteen fields a
|
||||
* player who was handed a code has no use for.
|
||||
* - A SEAT SURVIVES A RELOAD. The token was held in a closure and only written to `localStorage`
|
||||
* at `Lobby.Start`, so a refresh before the host started orphaned the chair: the player could
|
||||
* not get back and the seat could not be freed. `onSeated` hands it to `main.ts` immediately.
|
||||
* - THERE IS A WAY OUT. `/api/lobby/leave` frees a seat, so a mis-join or a player who wanders off
|
||||
* no longer wedges a table that cannot start until every chair is taken.
|
||||
* - THE STREAM CAN FAIL OUT LOUD. `onmessage` was the only handler; a dropped connection left the
|
||||
* seating screen frozen and silent.
|
||||
* - THE RULES ARE VISIBLE TO EVERYONE, not just the host who typed them.
|
||||
*
|
||||
* MIRRORS SERVER TYPES RATHER THAN IMPORTING THEM, same choice `web/session.ts` already made for
|
||||
* `Push`: this file must never depend on anything under `src/server/`, even at the type level, since
|
||||
* it ships to the browser and the server does not.
|
||||
*/
|
||||
|
||||
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
|
||||
import {
|
||||
closestPreset,
|
||||
configFromSettings,
|
||||
gameTypeLabel,
|
||||
preset,
|
||||
presetSettings,
|
||||
} from './presets.ts';
|
||||
import type { GameType, PresetName } from './presets.ts';
|
||||
import { rulesListHtml, settingsForm } from './settings-form.ts';
|
||||
import { seatLabel } from '../sim/view.ts';
|
||||
|
||||
export type LobbyReady = { token: string; gameId: string; seat: PlayerIndex; gameCode: string };
|
||||
|
||||
/** What the page must do with a seat this screen takes or gives up. `main.ts` owns the storage; this
|
||||
* module owns the moments. */
|
||||
export type LobbyHandlers = {
|
||||
/** The game has begun — tear this screen down and build a `RemoteSession`. Called at most once. */
|
||||
onReady: (r: LobbyReady) => void;
|
||||
/** A seat is now held. Called before the game starts, so a reload can come back to it. */
|
||||
onSeated: (s: { token: string; gameId: string; gameCode: string }) => void;
|
||||
/** The seat is gone — left, removed, or the lobby closed under us. Forget the stored record. */
|
||||
onLeft: (gameId?: string) => void;
|
||||
/** Every game this browser still holds a seat in — the page owns the storage, this screen only
|
||||
* draws it. */
|
||||
known?: () => { gameId: string; gameCode: string; stage: 'lobby' | 'game' }[];
|
||||
/** Re-enter one of them. */
|
||||
rejoin?: (gameId: string) => void;
|
||||
/** Give one up for good: the token is the only proof of identity, so this cannot be undone. */
|
||||
forget?: (gameId: string) => void;
|
||||
};
|
||||
|
||||
type LobbySeat = { kind: 'human'; token: string; displayName: string } | { kind: 'bot' } | null;
|
||||
type Lobby = {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
hostToken: string;
|
||||
config: GameConfig;
|
||||
seats: LobbySeat[];
|
||||
joinOrder: string[];
|
||||
createdAt: number;
|
||||
};
|
||||
type LobbyPush = { lobby: Lobby; you: PlayerIndex; started: boolean };
|
||||
type Preview = {
|
||||
gameCode: string;
|
||||
hostName: string;
|
||||
config: GameConfig;
|
||||
players: number;
|
||||
seated: { seat: number; who: string | null; bot: boolean }[];
|
||||
};
|
||||
|
||||
/** Per-origin, same reasoning `lobby-and-sessions.md` §1 gives for the session token itself — a
|
||||
* secret typed at one address means nothing at another. */
|
||||
const SECRET_KEY = 'stationmaster-joinsecret';
|
||||
/** The name is not a credential; it is remembered for the same reason the secret is — nobody should
|
||||
* retype what they typed last time. */
|
||||
const NAME_KEY = 'stationmaster-displayname';
|
||||
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string): T => document.getElementById(id) as T;
|
||||
const has = (id: string): boolean => document.getElementById(id) !== null;
|
||||
|
||||
/**
|
||||
* A server code turned into a sentence.
|
||||
*
|
||||
* `LOBBY_FULL` and `BAD_PLAYER_COUNT` used to be printed at the player exactly as the server said
|
||||
* them. The codes are the server's vocabulary, not the table's.
|
||||
*/
|
||||
function explain(code: unknown, fallback: string): string {
|
||||
const messages: Record<string, string> = {
|
||||
LOBBY_FULL: 'That table is already full — every chair is taken.',
|
||||
ALREADY_STARTED: 'That game has already started.',
|
||||
BAD_PLAYER_COUNT: 'That table size cannot start a game — 2 to 4 players.',
|
||||
NOT_HOST: 'Only the host can do that.',
|
||||
NAME_TAKEN: 'Somebody at that table is already using that name — pick another.',
|
||||
'bad or missing secret': 'That join secret was not accepted by this server.',
|
||||
// What a lobby answers once it has become a GAME — most often seen by a host pressing Start
|
||||
// twice, and by a tab left open on a lobby that started somewhere else.
|
||||
'no such lobby': 'That game is no longer waiting to start — it has either begun or been closed.',
|
||||
'no open lobby with that code': 'No game is waiting under that code. Check it, or ask for a new one — a game that has already started cannot be joined.',
|
||||
};
|
||||
const key = typeof code === 'string' ? code : '';
|
||||
return messages[key] ?? (key !== '' ? key : fallback);
|
||||
}
|
||||
|
||||
/** Exported for `main.ts`'s seat-recovery path (Gitea#33), so there is one JSON POST on this page. */
|
||||
export async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record<string, unknown> }> {
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
async function getJson(path: string): Promise<{ status: number; body: Record<string, unknown> }> {
|
||||
const res = await fetch(path);
|
||||
return { status: res.status, body: (await res.json().catch(() => ({}))) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows `#lobby` and drives it. `resume` re-enters the seating screen for a browser that already
|
||||
* holds a seat (a reload before the host started) rather than starting at the doors.
|
||||
*/
|
||||
export function runLobby(handlers: LobbyHandlers, resume?: { token: string; gameId: string; gameCode: string }): void {
|
||||
// Nothing below exists on a page that is not `play.html` — and `main.ts` is imported by tests that
|
||||
// stub only part of the DOM. Bail rather than throwing through the module's caller.
|
||||
if (!has('lobby') || !has('lb-choice-section')) return;
|
||||
$('lobby').hidden = false;
|
||||
/**
|
||||
* THE TWO DOORS COME BACK EVERY TIME THIS SCREEN DOES.
|
||||
*
|
||||
* `enterSeating` hides this section, and until v0.8.1.0 the only things that put it back were the
|
||||
* lobby's OWN leave paths. Leaving a running game is a different route — `main.ts`'s Leave game
|
||||
* button calls `runLobby` again — so the section stayed hidden from the previous seating and the
|
||||
* screen came back holding nothing but "Games you are in". Reported 2026-09-22: "I see the games
|
||||
* that I'm in, and I have the ability to rejoin or forget. But what if I want to join a different
|
||||
* game or start a new one? How do I get back there?" There was no way: the doors were on the
|
||||
* page, display:none, with no control that could reveal them.
|
||||
*
|
||||
* Reset here rather than in the leave handler, because this function is the one thing every route
|
||||
* onto the lobby goes through — which is exactly why the two that did it themselves missed a third.
|
||||
*/
|
||||
$('lb-choice-section').hidden = false;
|
||||
if (has('lb-seating-section')) $('lb-seating-section').hidden = true;
|
||||
|
||||
const form = settingsForm('lb-');
|
||||
let source: EventSource | null = null;
|
||||
let done = false;
|
||||
|
||||
/**
|
||||
* THE GAMES THIS BROWSER IS ALREADY IN.
|
||||
*
|
||||
* Rejoining is what the stored token is FOR, and until now the only thing that ever used one was a
|
||||
* bare page load — so a player who left a game, or who joined a second, had no way to get back to
|
||||
* the first. Drawn from the page's own storage (`main.ts`), never from this module.
|
||||
*/
|
||||
function renderKnown(): void {
|
||||
if (!has('lb-known')) return;
|
||||
const games = handlers.known?.() ?? [];
|
||||
$('lb-known').hidden = games.length === 0;
|
||||
if (games.length === 0) return;
|
||||
$('lb-known-list').innerHTML = games
|
||||
.map(
|
||||
(g) =>
|
||||
`<div class="lb-known-row"><span class="code">${escapeHtml(g.gameCode || g.gameId.slice(0, 8))}</span>` +
|
||||
`<span class="dim">${g.stage === 'lobby' ? 'waiting to start' : 'in play'}</span>` +
|
||||
`<button class="lb-rejoin" data-game="${escapeHtml(g.gameId)}">Rejoin</button>` +
|
||||
`<button class="lb-forget ghost" data-game="${escapeHtml(g.gameId)}">Forget</button></div>`,
|
||||
)
|
||||
.join('');
|
||||
for (const btn of Array.from($('lb-known-list').querySelectorAll<HTMLButtonElement>('.lb-rejoin'))) {
|
||||
btn.onclick = () => handlers.rejoin?.(btn.dataset['game'] ?? '');
|
||||
}
|
||||
for (const btn of Array.from($('lb-known-list').querySelectorAll<HTMLButtonElement>('.lb-forget'))) {
|
||||
btn.onclick = () => {
|
||||
// Confirmed, because it is not recoverable from this browser: the token IS the identity
|
||||
// (`lobby-and-sessions.md` §1), and nothing else on this server will accept a claim to that
|
||||
// seat.
|
||||
const code = btn.previousElementSibling?.previousElementSibling?.textContent ?? 'that game';
|
||||
if (!confirm(`Forget ${code}? This browser will not be able to rejoin it — your seat stays in the game, and only whoever runs the server could let you back in.`)) return;
|
||||
handlers.forget?.(btn.dataset['game'] ?? '');
|
||||
renderKnown();
|
||||
};
|
||||
}
|
||||
}
|
||||
renderKnown();
|
||||
|
||||
// -- the join secret ------------------------------------------------------------------------
|
||||
|
||||
function showSecret(saved: boolean): void {
|
||||
$('lb-secret-saved').hidden = !saved;
|
||||
$('lb-secret-ask').hidden = saved;
|
||||
}
|
||||
const storedSecret = localStorage.getItem(SECRET_KEY) ?? '';
|
||||
$<HTMLInputElement>('lb-secret').value = storedSecret;
|
||||
showSecret(storedSecret !== '');
|
||||
$<HTMLButtonElement>('lb-secret-change').onclick = () => showSecret(false);
|
||||
|
||||
function secret(): string {
|
||||
const value = $<HTMLInputElement>('lb-secret').value;
|
||||
localStorage.setItem(SECRET_KEY, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A rejected secret re-opens the field it is about — the error used to appear a screen away from
|
||||
* the box that caused it. */
|
||||
function secretRejected(status: number): boolean {
|
||||
if (status !== 403) return false;
|
||||
showSecret(false);
|
||||
$<HTMLInputElement>('lb-secret').focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
// -- display name ---------------------------------------------------------------------------
|
||||
|
||||
const nameField = $<HTMLInputElement>('lb-name');
|
||||
nameField.value = localStorage.getItem(NAME_KEY) ?? '';
|
||||
function displayName(): string {
|
||||
const value = nameField.value.trim();
|
||||
if (value !== '') localStorage.setItem(NAME_KEY, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
// -- the two doors --------------------------------------------------------------------------
|
||||
|
||||
function door(which: 'join' | 'create'): void {
|
||||
$('lb-join-panel').hidden = which !== 'join';
|
||||
$('lb-create-panel').hidden = which !== 'create';
|
||||
$('lb-door-join').classList.toggle('active', which === 'join');
|
||||
$('lb-door-create').classList.toggle('active', which === 'create');
|
||||
}
|
||||
$<HTMLButtonElement>('lb-door-join').onclick = () => door('join');
|
||||
$<HTMLButtonElement>('lb-door-create').onclick = () => door('create');
|
||||
|
||||
// -- the create form ------------------------------------------------------------------------
|
||||
|
||||
/** The named type the form is currently measured against — and, for a Custom game, the type it is
|
||||
* scored as. Custom is only ever reached FROM one of these, so there is always an answer. */
|
||||
let base: PresetName = 'coop';
|
||||
let type: GameType = 'coop';
|
||||
/** Once the host types a Revenue floor it is theirs; players and days stop re-deriving it. */
|
||||
let floorTyped = false;
|
||||
|
||||
const players = (): number => Number($<HTMLSelectElement>('lb-players').value) || 4;
|
||||
const days = (): number => {
|
||||
const raw = Number($<HTMLInputElement>('lb-days').value);
|
||||
return Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : 5;
|
||||
};
|
||||
|
||||
function typeRadios(): HTMLInputElement[] {
|
||||
return Array.from(document.querySelectorAll<HTMLInputElement>('input[name="lb-type"]'));
|
||||
}
|
||||
|
||||
/** Reset every rule to a named type. Seed, players and days are parameters, and are left alone. */
|
||||
function selectPreset(name: PresetName): void {
|
||||
base = name;
|
||||
type = name;
|
||||
floorTyped = false;
|
||||
const values = presetSettings(name, players(), days());
|
||||
form.write(values, values);
|
||||
form.setEmployeeRotationAvailable(true);
|
||||
refresh();
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
const differing = form.mark(base, players(), days());
|
||||
if (differing.length > 0) type = 'custom';
|
||||
else if (type === 'custom') type = base;
|
||||
for (const r of typeRadios()) r.checked = r.value === type;
|
||||
|
||||
/**
|
||||
* NO SENTENCE UNDER THE RADIOS since 2026-08-30 — it restated the type just chosen to the person
|
||||
* who had just chosen it, and the row is already labelled and already carries its own
|
||||
* description (Jesse: "There's no need to repeat it below"). `form.mark` still puts a hint on
|
||||
* each row that actually differs, which is where a Custom game's differences can be acted on.
|
||||
*/
|
||||
// A Custom game is nobody's default: open the block that says how it differs.
|
||||
if (type === 'custom') $<HTMLDetailsElement>('lb-settings').open = true;
|
||||
}
|
||||
|
||||
for (const r of typeRadios()) {
|
||||
// Solitaire is on this screen so the two screens read as one list, but there is nothing here to
|
||||
// deal it with. Dimmed and left to speak for itself: the heading says "Game type
|
||||
// (multi-player)", which is the explanation (Jesse, 2026-08-30).
|
||||
if (r.value === 'solitaire') markUnavailable(r);
|
||||
r.onchange = () => {
|
||||
if (!r.checked) return;
|
||||
if (r.value === 'custom') {
|
||||
// Clicking Custom keeps everything as it stands, and keeps the scoring of the type it came
|
||||
// from (Jesse, 2026-08-23) — it is only ever reached from one of the named types.
|
||||
type = 'custom';
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
selectPreset(r.value as PresetName);
|
||||
};
|
||||
}
|
||||
|
||||
form.onEdit((key) => {
|
||||
// The floor is derived until somebody sets it; ticking the condition off counts as setting it.
|
||||
if (key === 'minCombinedRevenue') floorTyped = true;
|
||||
type = 'custom';
|
||||
refresh();
|
||||
});
|
||||
|
||||
/** Players and days are parameters, not settings: they re-derive the floor and never make a game
|
||||
* Custom by themselves. */
|
||||
function paramsChanged(): void {
|
||||
if (!floorTyped) {
|
||||
const values = form.read();
|
||||
const want = presetSettings(base, players(), days());
|
||||
form.write({ ...values, minCombinedRevenue: want.minCombinedRevenue }, want);
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
$<HTMLSelectElement>('lb-players').onchange = paramsChanged;
|
||||
$<HTMLInputElement>('lb-days').oninput = paramsChanged;
|
||||
|
||||
selectPreset('coop');
|
||||
door('join');
|
||||
|
||||
// -- creating -------------------------------------------------------------------------------
|
||||
|
||||
$<HTMLButtonElement>('lb-create').onclick = () => {
|
||||
const name = displayName();
|
||||
if (name === '') {
|
||||
$('lb-create-err').textContent = 'Enter a display name first.';
|
||||
return;
|
||||
}
|
||||
const asked = $<HTMLInputElement>('lb-seed').value.trim();
|
||||
// Blank or unparseable both mean "surprise me", which is what leaving the box alone asks for.
|
||||
const seed = asked === '' || !Number.isFinite(Number(asked)) ? null : Math.trunc(Number(asked));
|
||||
const config = configFromSettings(form.read(), preset(base).scoring, days(), preset(base).pvpCards);
|
||||
|
||||
$('lb-create-err').textContent = '';
|
||||
void postJson('/api/lobby/create', {
|
||||
secret: secret(),
|
||||
config,
|
||||
displayName: name,
|
||||
players: players(),
|
||||
seed,
|
||||
}).then(({ status, body }) => {
|
||||
if (status !== 200) {
|
||||
secretRejected(status);
|
||||
$('lb-create-err').textContent = explain(body['error'], 'Could not create the game.');
|
||||
return;
|
||||
}
|
||||
enterSeating(body['gameId'] as string, body['token'] as string, body['gameCode'] as string);
|
||||
});
|
||||
};
|
||||
|
||||
// -- joining --------------------------------------------------------------------------------
|
||||
|
||||
let previewed: Preview | null = null;
|
||||
|
||||
function showPreview(p: Preview): void {
|
||||
previewed = p;
|
||||
$('lb-preview').hidden = false;
|
||||
$('lb-preview-code').textContent = p.gameCode;
|
||||
const near = closestPreset(p.config, p.players, p.config.days);
|
||||
const label = gameTypeLabel(
|
||||
near.differing.length === 0 ? near.name : 'custom',
|
||||
p.config.mode,
|
||||
);
|
||||
$('lb-preview-type').textContent =
|
||||
near.differing.length === 0
|
||||
? label
|
||||
: `${label} · ${near.differing.length} ${near.differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(near.name).label}`;
|
||||
const taken = p.seated.filter((s) => s.who !== null || s.bot).length;
|
||||
const names = p.seated
|
||||
.map((s) => (s.bot ? 'a bot' : (s.who ?? 'empty')))
|
||||
.join(', ');
|
||||
$('lb-preview-who').textContent = `Host: ${p.hostName} · ${taken} of ${p.players} seats taken — ${names}`;
|
||||
$('lb-preview-rules').innerHTML = rulesListHtml(p.config, p.players, p.config.days);
|
||||
}
|
||||
|
||||
$<HTMLButtonElement>('lb-look').onclick = () => {
|
||||
const code = $<HTMLInputElement>('lb-code').value.trim().toUpperCase();
|
||||
$('lb-preview').hidden = true;
|
||||
if (code === '') {
|
||||
$('lb-join-err').textContent = 'Enter the game code you were given.';
|
||||
return;
|
||||
}
|
||||
$('lb-join-err').textContent = '';
|
||||
void getJson(
|
||||
`/api/lobby/preview?gameCode=${encodeURIComponent(code)}&secret=${encodeURIComponent(secret())}`,
|
||||
).then(({ status, body }) => {
|
||||
if (status !== 200) {
|
||||
secretRejected(status);
|
||||
$('lb-join-err').textContent = explain(body['error'], 'Could not look up that game.');
|
||||
return;
|
||||
}
|
||||
showPreview(body as unknown as Preview);
|
||||
});
|
||||
};
|
||||
|
||||
$<HTMLButtonElement>('lb-join').onclick = () => {
|
||||
const name = displayName();
|
||||
const code = previewed?.gameCode ?? $<HTMLInputElement>('lb-code').value.trim().toUpperCase();
|
||||
if (name === '') {
|
||||
$('lb-join-err').textContent = 'Enter a display name first.';
|
||||
return;
|
||||
}
|
||||
$('lb-join-err').textContent = '';
|
||||
void postJson('/api/lobby/join', { secret: secret(), gameCode: code, displayName: name }).then(
|
||||
({ status, body }) => {
|
||||
if (status !== 200) {
|
||||
secretRejected(status);
|
||||
$('lb-join-err').textContent = explain(body['error'], 'Could not join that game.');
|
||||
return;
|
||||
}
|
||||
enterSeating(body['gameId'] as string, body['token'] as string, code);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// -- seating --------------------------------------------------------------------------------
|
||||
|
||||
function renderSeating(lobby: Lobby, token: string): void {
|
||||
$('lb-gamecode').textContent = lobby.gameCode;
|
||||
const isHost = lobby.hostToken === token;
|
||||
|
||||
let html = '';
|
||||
// Bots are numbered here exactly as `startLobby` numbers them at the moment the game starts, so
|
||||
// the table you set up is the table that appears on the board.
|
||||
let botNumber = 0;
|
||||
for (let seat = 0; seat < lobby.seats.length; seat++) {
|
||||
const occupant = lobby.seats[seat] ?? null;
|
||||
const isYou = occupant?.kind === 'human' && occupant.token === token;
|
||||
const isSeatHost = occupant?.kind === 'human' && occupant.token === lobby.hostToken;
|
||||
if (occupant?.kind === 'bot') botNumber++;
|
||||
const who =
|
||||
occupant === null
|
||||
? '<span class="dim">— waiting —</span>'
|
||||
: occupant.kind === 'bot'
|
||||
? `Bot ${botNumber}`
|
||||
: `${escapeHtml(occupant.displayName)}${isYou ? ' (you)' : ''}${isSeatHost ? ' — host' : ''}`;
|
||||
let action = '';
|
||||
if (isHost) {
|
||||
if (occupant === null) action = `<button class="lb-bot-add" data-seat="${seat}">+ bot</button>`;
|
||||
else if (occupant.kind === 'bot') action = `<button class="lb-bot-remove" data-seat="${seat}">remove bot</button>`;
|
||||
else if (!isYou) action = `<button class="lb-kick" data-seat="${seat}">remove</button>`;
|
||||
}
|
||||
html += `<div class="lb-seat"><span class="dim">Seat ${seatLabel(seat)}</span><span class="who">${who}</span>${action}</div>`;
|
||||
}
|
||||
$('lb-seats').innerHTML = html;
|
||||
|
||||
/**
|
||||
* The code is the whole invitation, so it has to leave this screen by some route other than
|
||||
* being read off it and retyped. Two buttons because they are two different acts: the CODE is
|
||||
* what you read aloud on a call and works at whatever address each player reaches the box by;
|
||||
* the LINK is what you paste into a chat, and only works for someone who can reach this address.
|
||||
* Neither carries the join secret — that is the door key, and it travels out of band.
|
||||
*
|
||||
* `navigator.clipboard` is unavailable on an insecure origin and can be refused outright, so a
|
||||
* failure says the code is there to be selected rather than silently doing nothing.
|
||||
*/
|
||||
const say = (m: string): void => {
|
||||
$('lb-copied').textContent = m;
|
||||
setTimeout(() => ($('lb-copied').textContent = ''), 4000);
|
||||
};
|
||||
const copy = (text: string, ok: string): void => {
|
||||
void navigator.clipboard
|
||||
?.writeText(text)
|
||||
.then(() => say(ok))
|
||||
.catch(() => say('Could not copy — select the code above instead.'));
|
||||
};
|
||||
$<HTMLButtonElement>('lb-copy').onclick = () => copy(lobby.gameCode, 'Code copied.');
|
||||
$<HTMLButtonElement>('lb-copylink').onclick = () =>
|
||||
copy(
|
||||
`${location.origin}${location.pathname}?lobby&code=${encodeURIComponent(lobby.gameCode)}`,
|
||||
'Invite link copied — the join secret is not in it.',
|
||||
);
|
||||
|
||||
for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.lb-bot-add'))) {
|
||||
btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: true });
|
||||
}
|
||||
for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.lb-bot-remove'))) {
|
||||
btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: false });
|
||||
}
|
||||
for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.lb-kick'))) {
|
||||
btn.onclick = () => {
|
||||
void postJson('/api/lobby/leave', { token, seat: Number(btn.dataset['seat']) }).then(({ status, body }) => {
|
||||
if (status !== 200) $('lb-start-note').textContent = explain(body['error'], 'Could not clear that seat.');
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// What everyone at the table is about to play — the host chose it, and until now nobody else
|
||||
// could see any of it.
|
||||
const near = closestPreset(lobby.config, lobby.seats.length, lobby.config.days);
|
||||
const label = gameTypeLabel(near.differing.length === 0 ? near.name : 'custom', lobby.config.mode);
|
||||
$('lb-seating-type').textContent =
|
||||
near.differing.length === 0
|
||||
? label
|
||||
: `${label} · ${near.differing.length} ${near.differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(near.name).label}`;
|
||||
$('lb-seating-rules').innerHTML = rulesListHtml(lobby.config, lobby.seats.length, lobby.config.days);
|
||||
|
||||
const waiting = lobby.seats.filter((s) => s === null).length;
|
||||
const startBtn = $<HTMLButtonElement>('lb-start');
|
||||
startBtn.hidden = !isHost;
|
||||
startBtn.disabled = waiting > 0;
|
||||
startBtn.textContent = 'Start game';
|
||||
$('lb-start-note').textContent = '';
|
||||
const waitNote = $('lb-stream-note');
|
||||
if (waitNote.dataset['reason'] !== 'stream') {
|
||||
waitNote.hidden = waiting === 0 && isHost;
|
||||
waitNote.textContent = isHost
|
||||
? waiting === 0
|
||||
? ''
|
||||
: `Waiting on ${waiting} more ${waiting === 1 ? 'player' : 'players'} — add a bot to any empty chair to start now.`
|
||||
: 'Waiting for the host to start the game.';
|
||||
waitNote.hidden = waitNote.textContent === '';
|
||||
}
|
||||
startBtn.onclick = () => {
|
||||
// No busy state used to mean a second press posted a second start, whose 409 landed on screen
|
||||
// as a raw code.
|
||||
startBtn.disabled = true;
|
||||
startBtn.textContent = 'Starting…';
|
||||
void postJson('/api/lobby/start', { token }).then(({ status, body }) => {
|
||||
if (status !== 200) {
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = 'Start game';
|
||||
$('lb-start-note').textContent = explain(body['error'], 'Could not start the game.');
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function streamNote(message: string): void {
|
||||
const el = $('lb-stream-note');
|
||||
el.dataset['reason'] = message === '' ? '' : 'stream';
|
||||
el.textContent = message;
|
||||
el.hidden = message === '';
|
||||
}
|
||||
|
||||
function enterSeating(gameId: string, token: string, gameCode: string): void {
|
||||
handlers.onSeated({ token, gameId, gameCode });
|
||||
$('lb-choice-section').hidden = true;
|
||||
$('lb-seating-section').hidden = false;
|
||||
|
||||
$<HTMLButtonElement>('lb-leave').onclick = () => {
|
||||
void postJson('/api/lobby/leave', { token }).then(() => {
|
||||
source?.close();
|
||||
handlers.onLeft(gameId);
|
||||
$('lb-seating-section').hidden = true;
|
||||
$('lb-choice-section').hidden = false;
|
||||
renderKnown();
|
||||
notice('');
|
||||
});
|
||||
};
|
||||
|
||||
source = new EventSource(`/api/lobby/stream?token=${encodeURIComponent(token)}`);
|
||||
source.onmessage = (ev: MessageEvent<string>) => {
|
||||
streamNote('');
|
||||
const push = JSON.parse(ev.data) as LobbyPush;
|
||||
if (push.started) {
|
||||
source?.close();
|
||||
if (done) return;
|
||||
done = true;
|
||||
handlers.onReady({ token, gameId, seat: push.you, gameCode: push.lobby.gameCode });
|
||||
return;
|
||||
}
|
||||
renderSeating(push.lobby, token);
|
||||
};
|
||||
|
||||
/**
|
||||
* A DEAD LOBBY AND A BLIP LOOK IDENTICAL HERE, so ask — the same shape `session.ts` already uses
|
||||
* for the game stream, which the lobby never got.
|
||||
*
|
||||
* Three answers matter. The game may have STARTED while we were disconnected (the `started` push
|
||||
* is sent once and then the connection closes, so a drop at the wrong moment loses it) —
|
||||
* `/api/session` knows, and we go straight in. The lobby may still be there, in which case
|
||||
* `EventSource` is already retrying and the note is all that is needed. Or it is gone, and
|
||||
* sitting on a frozen seating screen is the one thing that must not happen.
|
||||
*/
|
||||
source.onerror = () => {
|
||||
if (done) return;
|
||||
streamNote('Connection lost — retrying. You can leave and rejoin if this does not clear.');
|
||||
void getJson(`/api/session?token=${encodeURIComponent(token)}`).then(({ status, body }) => {
|
||||
if (done) return;
|
||||
if (status === 200) {
|
||||
done = true;
|
||||
source?.close();
|
||||
handlers.onReady({ token, gameId, seat: body['player'] as PlayerIndex, gameCode });
|
||||
return;
|
||||
}
|
||||
void getJson(
|
||||
`/api/lobby/preview?gameCode=${encodeURIComponent(gameCode)}&secret=${encodeURIComponent(secret())}`,
|
||||
).then(({ status: lobbyStatus }) => {
|
||||
if (done || lobbyStatus === 200) return;
|
||||
source?.close();
|
||||
handlers.onLeft(gameId);
|
||||
$('lb-seating-section').hidden = true;
|
||||
$('lb-choice-section').hidden = false;
|
||||
renderKnown();
|
||||
streamNote('');
|
||||
notice('That game is no longer waiting on this server — it was ended, or the last player left.');
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
if (resume) enterSeating(resume.gameId, resume.token, resume.gameCode);
|
||||
}
|
||||
|
||||
/** The lobby-wide message slot: why you are looking at this screen, when it was not your own click. */
|
||||
export function notice(message: string): void {
|
||||
const el = document.getElementById('lb-notice');
|
||||
if (!el) return;
|
||||
el.textContent = message;
|
||||
el.hidden = message === '';
|
||||
}
|
||||
|
||||
/** Prefills the code from an invite link and opens the join door on it. */
|
||||
export function prefillCode(code: string): void {
|
||||
const field = document.getElementById('lb-code') as HTMLInputElement | null;
|
||||
if (field) field.value = code.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* A CHOICE THAT CANNOT BE TAKEN HAS TO SAY SO.
|
||||
*
|
||||
* Reported by Jesse 2026-08-23: "solitaire is disabled, but really hard to tell." A bare `disabled`
|
||||
* on a radio leaves the whole row at full strength — the dot simply refuses the click, which reads
|
||||
* as a broken control rather than an unavailable one.
|
||||
*
|
||||
* The dimming is the whole signal now. It used to append a reason to the row as well, and dropped
|
||||
* that in 2026-08-30 along with the same text on the solitaire screen: one heading naming which
|
||||
* game the screen deals says it once, where three dimmed rows each said it again.
|
||||
*/
|
||||
function markUnavailable(radio: HTMLInputElement): void {
|
||||
radio.disabled = true;
|
||||
radio.closest('label')?.classList.add('disabled');
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
+2570
-209
File diff suppressed because it is too large
Load Diff
+503
-16
@@ -31,8 +31,18 @@ export function cardRow(name: string, why: string, playable: boolean | null): st
|
||||
}
|
||||
|
||||
export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
|
||||
// §6.2 — say so on the card itself. A player who cannot discard a train needs to read that on the
|
||||
// train, not deduce it from a button that is not there. The sentence comes off the Frame
|
||||
// (`handKeepWhy`) rather than being written here, because since Gitea#9 there are two of them and
|
||||
// which one applies depends on the card AND the game's rules.
|
||||
return f.hand.length
|
||||
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
|
||||
? f.hand
|
||||
.map((h, i) => {
|
||||
const what = f.handWhat[i] ?? '';
|
||||
const held = f.handKeepWhy[i];
|
||||
return cardRow(h, held ? [what, held].filter(Boolean).join(' · ') : what, canPlay[i] ?? null);
|
||||
})
|
||||
.join('')
|
||||
: '<span class="dim">empty</span>';
|
||||
}
|
||||
|
||||
@@ -42,25 +52,61 @@ export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
|
||||
* Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it
|
||||
* is out of reach, and choosing where to discard is choosing what to put there.
|
||||
*/
|
||||
export function pilesHtml(f: Frame): string {
|
||||
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => {
|
||||
export function pilesHtml(f: Frame, lit: readonly string[] = []): string {
|
||||
const pile = (
|
||||
key: string,
|
||||
label: string,
|
||||
top: string,
|
||||
depth: number,
|
||||
why: string,
|
||||
extra = '',
|
||||
slot = -1,
|
||||
faceDown = false,
|
||||
): string => {
|
||||
const tip = [why, extra].filter(Boolean).join(' · ');
|
||||
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
|
||||
// page binds a click to it, and only while a card is waiting to be discarded — so the replay
|
||||
// viewer draws exactly the same markup and nothing there is clickable.
|
||||
const target = slot >= 0 ? ` data-dept="${slot}"` : '';
|
||||
// `lit` marks the pile the move being watched just touched — see `changedPiles`.
|
||||
const cls = `handcard${faceDown ? ' facedown' : ''}${lit.includes(key) ? ' pilelit' : ''}`;
|
||||
return (
|
||||
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
||||
`<div class="${cls}"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
||||
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
|
||||
`<b>${esc(top)}</b></div>`
|
||||
);
|
||||
};
|
||||
return (
|
||||
/**
|
||||
* THE HOME OFFICE DECK, which the screen had never drawn.
|
||||
*
|
||||
* `f.deck` has carried the face-down count since the Frame existed and nothing read it — the
|
||||
* exact shape of display gap `test/display-gaps.test.ts` was written to sweep for, surviving in
|
||||
* the panel that draws every OTHER pile. Asked for by Jesse 2026-09-10 for a second reason: a
|
||||
* player drawing from it is the commonest move nobody can see, so it needs somewhere to flash.
|
||||
*
|
||||
* FIRST, because that is the order a card travels: out of here, into a hand, then onto a
|
||||
* Department or the Salvage Yard. Face down, so the card slot says so rather than naming a card
|
||||
* — the whole point of this pile is that nobody knows what is on top.
|
||||
*/
|
||||
pile(
|
||||
'home',
|
||||
'Home Office',
|
||||
'face down',
|
||||
f.deck,
|
||||
'The draw deck. Face down — nobody sees what is on top, and a card drawn from here is private ' +
|
||||
'to whoever drew it. When it runs out, the Salvage Yard and the Departments are swept back ' +
|
||||
'into it.',
|
||||
'',
|
||||
-1,
|
||||
true,
|
||||
) +
|
||||
f.departments
|
||||
.map((d, i) => {
|
||||
const depth = f.departmentDepth[i] ?? 0;
|
||||
const under = depth - 1;
|
||||
return pile(
|
||||
`dept${i}`,
|
||||
`Dept ${i + 1}`,
|
||||
d,
|
||||
depth,
|
||||
@@ -71,6 +117,7 @@ export function pilesHtml(f: Frame): string {
|
||||
})
|
||||
.join('') +
|
||||
pile(
|
||||
'salvage',
|
||||
'Salvage',
|
||||
f.salvage.top,
|
||||
f.salvage.depth,
|
||||
@@ -120,9 +167,18 @@ export function timetableHtml(f: Frame, justSet: number | null): string {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
/**
|
||||
* THE CARD ITSELF, not just the number.
|
||||
*
|
||||
* A train card is played once and then lives only as a number in a Timetable slot — but what
|
||||
* it prints is what decides whether the train can be switched, worked by Porters, or loaded at
|
||||
* all. Reported from play: "once a train card's been played, how would I see that particular
|
||||
* train card again?" This is where the player already looks for that train.
|
||||
*/
|
||||
const tip =
|
||||
t !== null
|
||||
? `Train ${t} departs at Stage ${stage}. A train's NUMBER is its seniority and direction — odd runs west, even runs east — and the Stage it leaves is set by the 1D12 roll made when its card was played, so Train 8 at Stage 6 is normal.`
|
||||
? `Train ${t} departs at Stage ${stage}. A train's NUMBER is its seniority and direction — odd runs west, even runs east — and the Stage it leaves is set by the 1D12 roll made when its card was played, so Train 8 at Stage 6 is normal.` +
|
||||
(f.timetableWhat[k] ? `\n\nITS CARD — ${f.timetableWhat[k]}` : '')
|
||||
: `Stage ${stage} — no train due out.`;
|
||||
return (
|
||||
`<div class="tt-slot ${cls}" data-tip="${esc(tip)}" tabindex="0">` +
|
||||
@@ -134,6 +190,371 @@ export function timetableHtml(f: Frame, justSet: number | null): string {
|
||||
return `<div class="tt">${slots}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE DAY THAT JUST ENDED — the body of the dialog `main.ts` puts up at every Day rollover.
|
||||
*
|
||||
* Reported as Gitea#10: "as the game rolls off the end of the day, you get a dialog saying such.
|
||||
* Hard to keep track of time." The clock was on screen the whole time, but a Day turns over inside
|
||||
* the automatic phases — between one click and the next — and neither the phase banner (2.6s) nor
|
||||
* the announcement flash (4.2s) survives long enough to be noticed by someone reading the board.
|
||||
* A modal is the point: it stops, and it waits to be dismissed.
|
||||
*
|
||||
* It is written from the FRAME AFTER the rollover, so `f.day` is the Day about to start and the one
|
||||
* that ended is the Day before it. Standings are in Revenue order rather than seat order: the
|
||||
* question at the end of a Day is who is ahead.
|
||||
*/
|
||||
export function dayEndHtml(f: Frame): string {
|
||||
const ended = f.day - 1;
|
||||
const left = f.days + f.extraDays - ended;
|
||||
|
||||
const ahead =
|
||||
left <= 0
|
||||
? '<p>That was the last Day on the timetable.</p>'
|
||||
: `<p><b>Day ${f.day} of ${f.days + f.extraDays}</b> begins now — ${left} ${left === 1 ? 'Day' : 'Days'} left to run.</p>`;
|
||||
|
||||
return (
|
||||
`<h3 class="dayend-h">Day ${ended} has ended</h3>` +
|
||||
ahead +
|
||||
standingsHtml(f) +
|
||||
targetHtml(f) +
|
||||
collisionsHtml(f, ended)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared between the Day-end dialog and the end-of-game results screen.
|
||||
//
|
||||
// Gitea#16 asked for the results screen and Gitea#10's dialog had already assembled most of it. The
|
||||
// three blocks below are the overlap, factored out rather than written twice: the two screens report
|
||||
// the same numbers about the same game, and the one thing they must never do is disagree.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every player in Revenue order, the viewer marked.
|
||||
*
|
||||
* `winner` rings the player the OFFICIAL result named, which is not always the player at the top:
|
||||
* in an extended game the standings keep moving after the result is settled, and showing the leader
|
||||
* without saying who actually won would be the screen contradicting itself.
|
||||
*/
|
||||
function standingsHtml(f: Frame, winner: number | null = null): string {
|
||||
const rows = [...f.players]
|
||||
.sort((a, b) => b.revenue - a.revenue || a.seat - b.seat)
|
||||
.map((p) => {
|
||||
const marks =
|
||||
(p.index === f.viewer ? ' <span class="dim">(you)</span>' : '') +
|
||||
(p.index === winner ? ' <span class="wins">— winner</span>' : '');
|
||||
return (
|
||||
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}${marks}</td>` +
|
||||
`<td class="num">${p.revenue}</td></tr>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
return `<table class="dayend-t"><tbody>${rows}</tbody></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The target is a COMBINED floor in every mode that sets one, so it is reported against the whole
|
||||
* table's Revenue rather than the viewer's — showing one player's score against a four-player
|
||||
* target reads as a hopeless position when the table may be comfortably ahead.
|
||||
*/
|
||||
function targetHtml(f: Frame): string {
|
||||
if (f.minCombinedRevenue <= 0) return '';
|
||||
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||
const met = combined >= f.minCombinedRevenue;
|
||||
return (
|
||||
`<p>Combined Revenue <b>${combined}</b> against a target of <b>${f.minCombinedRevenue}</b>` +
|
||||
`${met ? ' — cleared.' : ' — short.'}</p>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only when the game is actually scored on collisions.
|
||||
*
|
||||
* Two conditions, both of them `advance.ts`'s own: `0` on a dial turns that check off, and the
|
||||
* checks run in COMPETITIVE AND CO-OP ONLY (§3.4). A solitaire game carries the default dials on
|
||||
* its config and enforces neither, so reporting a collision budget there would put a rule on
|
||||
* screen that this game does not have.
|
||||
*/
|
||||
function collisionsHtml(f: Frame, endedDay?: number): string {
|
||||
const scoredOnCollisions =
|
||||
(f.mode === 'competitive' || f.mode === 'coop') &&
|
||||
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
||||
if (!scoredOnCollisions) return '';
|
||||
/**
|
||||
* "TODAY" IS THE WRONG WORD IN A DAY-END DIALOG, and it read as a contradiction.
|
||||
*
|
||||
* That dialog is drawn from the frame whose `day` went UP — which is the same frame in which
|
||||
* `collisionsToday` was reset — so it reported 0 however many there had been. Jesse, 2026-09-09,
|
||||
* at the end of a Day 1 with two collisions in it: "it shows a total of two collisions, but zero
|
||||
* today ... that does seem to be a contradiction."
|
||||
*
|
||||
* So when the caller knows which Day just ended it says so by name, and reads the count captured at
|
||||
* the rollover. The end-of-game results screen passes nothing and keeps "today", where the Day has
|
||||
* not turned over and the word is accurate.
|
||||
*/
|
||||
const [count, when] =
|
||||
endedDay === undefined
|
||||
? [f.collisionsToday, 'today']
|
||||
: [f.collisionsPrevDay, `on Day ${endedDay}`];
|
||||
return `<p>Collisions: <b>${count}</b> ${when}, <b>${f.collisionsTotal}</b> in all.</p>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY THE GAME ENDED, as a sentence (Gitea#16).
|
||||
*
|
||||
* The page used to interpolate `outcome.reason` straight into the DOM, so a player who finished a
|
||||
* game read the words `GAME OVER — revenueFloor`: an internal enum value, printed at the one moment
|
||||
* the game has the player's whole attention. Each reason gets a sentence that says what actually
|
||||
* happened, with this game's own numbers in it.
|
||||
*
|
||||
* EXPORTED for the developer replay recorder (`sim/replay.ts`), which was still printing
|
||||
* `loss — revenueFloor` into its own heading a release after this was written — the same defect the
|
||||
* issue was filed about, surviving in the one place nobody had looked (`TODO.md` #34). One
|
||||
* implementation, so the two cannot say the game ended for different reasons.
|
||||
*/
|
||||
export function reasonSentence(f: Frame, o: NonNullable<Frame['outcome']>, day: number): string {
|
||||
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||
switch (o.reason) {
|
||||
case 'daysElapsed':
|
||||
return `Day ${day} was the last on the timetable, and it ran out.`;
|
||||
case 'revenueFloor':
|
||||
return (
|
||||
`The Division closed short: <b>${combined}</b> Revenue between everyone, against a floor of ` +
|
||||
`<b>${f.minCombinedRevenue}</b>. §3.3 — miss the floor and the whole table loses, whoever ` +
|
||||
`earned the most.`
|
||||
);
|
||||
case 'collisionFloor':
|
||||
return (
|
||||
`Too many collisions — <b>${f.collisionsToday}</b> in one Day and <b>${f.collisionsTotal}</b> ` +
|
||||
`in all, against limits of ${f.maxCollisionsPerDay || '—'} and ${f.maxCollisionsTotal || '—'}. ` +
|
||||
`§3.4 — the railroad was declared unsafe and the game was stopped.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The rules this game was actually dealt under — Gitea#16's "what the rules of the game were". */
|
||||
function rulesHtml(f: Frame): string {
|
||||
const mode =
|
||||
f.mode === 'coop' ? 'Co-op — the table scores together' :
|
||||
f.mode === 'competitive' ? 'Competitive — highest Revenue wins' :
|
||||
'Solitaire';
|
||||
const optional = [
|
||||
f.optionalRules.employeeRotation ? 'Employee Rotation' : null,
|
||||
f.optionalRules.reducedVisibility ? 'Reduced Visibility' : null,
|
||||
f.optionalRules.emergencyToolbox ? 'Emergency Toolbox' : null,
|
||||
].filter((x): x is string => x !== null);
|
||||
const r = f.houseRules.revenue;
|
||||
|
||||
const rows: [string, string][] = [
|
||||
['Scoring', mode],
|
||||
['Timetable', f.extraDays > 0
|
||||
? `${f.days} Days, extended by ${f.extraDays} more`
|
||||
: `${f.days} Day${f.days === 1 ? '' : 's'}`],
|
||||
['Revenue floor', f.minCombinedRevenue > 0 ? `${f.minCombinedRevenue} combined` : 'none'],
|
||||
['Collision limits', f.maxCollisionsPerDay > 0 || f.maxCollisionsTotal > 0
|
||||
? `${f.maxCollisionsPerDay || '—'} per Day, ${f.maxCollisionsTotal || '—'} in all`
|
||||
: 'not scored'],
|
||||
['Pay rates', `${r.freightPerLoad} per load, ${r.passengerPerCoach} per coach, ${r.trainPerTransit} per transit`],
|
||||
['Extras start', f.houseRules.extraStart === 'divisionPointsOnly' ? 'Division Points and the Interchange'
|
||||
: f.houseRules.extraStart === 'ownOffice' ? 'those, plus your own Control Point'
|
||||
: 'those, plus any Control Point'],
|
||||
['Timetabled trains', f.houseRules.discardTimetabled ? 'may be discarded' : 'are never discarded'],
|
||||
['Optional rules', optional.length ? optional.join(', ') : 'none'],
|
||||
];
|
||||
|
||||
return `<h4 class="res-h">The rules in play</h4>${factTable(rows)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A two-column table of plain-text facts.
|
||||
*
|
||||
* BOTH HALVES ESCAPED, exactly once, which is the only reason this is a shared helper rather than a
|
||||
* template repeated twice. The rows it is given today are numbers and fixed phrases, but they are
|
||||
* assembled from the Frame — and the day somebody adds a row carrying a player's name, or a facility
|
||||
* label, the escaping has to already be here rather than be remembered.
|
||||
*/
|
||||
function factTable(rows: [string, string][]): string {
|
||||
return (
|
||||
'<table class="res-t"><tbody>' +
|
||||
rows.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`).join('') +
|
||||
'</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE RAILROAD — what actually happened out there, from `GameState.tally` (Gitea#16).
|
||||
*
|
||||
* Rows that would read zero for a reason (no passenger work in a game that had none, no collisions
|
||||
* in a clean one) are dropped rather than printed as `0`: a screen of zeroes reads as a bug, and the
|
||||
* absence of a line is the same information more quietly. A zero that is genuinely interesting —
|
||||
* trains through the Division — stays.
|
||||
*/
|
||||
function tallyHtml(t: Frame['tally']): string {
|
||||
const rows: [string, string][] = [['Trains through the Division', String(t.trainsCompleted)]];
|
||||
|
||||
if (t.trainsCompleted > 0) {
|
||||
rows.push([
|
||||
'Of those, worked en route',
|
||||
`${t.trainsCompletedWithWork} of ${t.trainsCompleted}` +
|
||||
(t.trainsCompletedWithWork === t.trainsCompleted ? ' — every one' : ''),
|
||||
]);
|
||||
}
|
||||
const push = (label: string, n: number, detail = ''): void => {
|
||||
if (n > 0) rows.push([label, `${n}${detail}`]);
|
||||
};
|
||||
push('Loads made up', t.loadsCompleted);
|
||||
push('Loads broken', t.unloadsCompleted);
|
||||
/**
|
||||
* BOTH HALVES OF THE MEN | AT | WORK PIPELINE, not just the loading one.
|
||||
*
|
||||
* §9.1 makes loading and unloading the same shape — begun, then carried through — and the Tally
|
||||
* has counted both since it was written. The screen reported only the loading side, so a player
|
||||
* with three unloads part-finished at the final whistle was told nothing about them while the
|
||||
* equivalent loads were listed. Found 2026-08-30 auditing which Frame fields nothing reads:
|
||||
* `unloadsBegun` was one of four, and the only one whose absence was visible on screen.
|
||||
*/
|
||||
push('Loads still in the pipeline', t.loadsStarted - t.loadsCompleted);
|
||||
push('Unloads still in the pipeline', t.unloadsBegun - t.unloadsCompleted);
|
||||
push('Passengers boarded', t.passengersBoarded);
|
||||
push('Passengers detrained', t.passengersDetrained);
|
||||
push('Cars coupled', t.carsCoupled);
|
||||
push('Cars set out', t.carsDropped);
|
||||
push('Extras run', t.extrasStarted);
|
||||
push('Second sections ordered', t.secondSections);
|
||||
push('Flying switches', t.flyingSwitches);
|
||||
push('Offices upgraded', t.officeUpgrades);
|
||||
push('Facilities unjammed', t.facilitiesUnjammed);
|
||||
push('Trains held', t.trainsHeld);
|
||||
push('Trains diverted', t.trainsDiverted);
|
||||
push('Expedite faults', t.expediteFaults);
|
||||
push('Dispatch bonuses used', t.dispatchBonusesUsed);
|
||||
if (t.clearancesRequested > 0) {
|
||||
rows.push(['Clearances', `${t.clearancesAllowed} allowed of ${t.clearancesRequested} asked`]);
|
||||
}
|
||||
if (t.trainsDestroyed > 0) {
|
||||
rows.push([
|
||||
'Trains destroyed',
|
||||
`${t.trainsDestroyed}, taking ${t.carsDestroyed} car${t.carsDestroyed === 1 ? '' : 's'} with them`,
|
||||
]);
|
||||
}
|
||||
// §X18 only — the one card in the deck that pays a train for standing still. Reported as what it
|
||||
// is rather than as "the longest an engine sat on a siding", which the engine cannot answer (see
|
||||
// `Tally.circusStops`).
|
||||
if (t.circusStops.length > 0) {
|
||||
rows.push([
|
||||
'Circus set-ups',
|
||||
t.circusStops.map((c) => `Train ${c.trainNumber} at ${c.where}`).join(', '),
|
||||
]);
|
||||
}
|
||||
push('Cards drawn', t.cardsDrawn);
|
||||
push('Cards played', t.cardsPlayed);
|
||||
// Gitea#9 made throwing a Timetabled train away a legal and deliberate move, so a discard is a
|
||||
// CHOICE the player made rather than an accident of the hand limit — and the engine has counted
|
||||
// it all along while the screen listed only draws and plays beside it.
|
||||
push('Cards discarded', t.cardsDiscarded);
|
||||
|
||||
return `<h4 class="res-h">The railroad</h4>${factTable(rows)}`;
|
||||
}
|
||||
|
||||
/** Per-player work, for a table that wants to know who did what rather than only who won. */
|
||||
function perPlayerHtml(f: Frame): string {
|
||||
const t = f.tally;
|
||||
if (f.players.length < 2) return '';
|
||||
const head =
|
||||
'<tr><th></th><th class="num">Rev</th><th class="num">Loads</th><th class="num">Unloads</th>' +
|
||||
'<th class="num">Pass.</th><th class="num">Cards</th><th class="num">Crashes</th></tr>';
|
||||
const rows = [...f.players]
|
||||
.sort((a, b) => b.revenue - a.revenue || a.seat - b.seat)
|
||||
.map((p) => {
|
||||
const q = t.byPlayer[p.index];
|
||||
if (!q) return '';
|
||||
return (
|
||||
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}</td>` +
|
||||
`<td class="num">${p.revenue}</td><td class="num">${q.loads}</td>` +
|
||||
`<td class="num">${q.unloads}</td>` +
|
||||
`<td class="num">${q.passengersBoarded + q.passengersDetrained}</td>` +
|
||||
`<td class="num">${q.cardsPlayed}</td><td class="num">${q.collisions}</td></tr>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
return `<h4 class="res-h">Who did what</h4><table class="res-t res-wide"><tbody>${head}${rows}</tbody></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE END-OF-GAME RESULTS SCREEN — Gitea#16, first pass.
|
||||
*
|
||||
* Everything the Frame already knew plus everything the event tally counted, in the order a player
|
||||
* asks for it: what happened, who won, by how much, under what rules, and then what the railroad
|
||||
* actually did all game. Badges and the "what would make this exciting" brainstorm are the second
|
||||
* pass the issue asks for and are deliberately not here.
|
||||
*
|
||||
* THE OFFICIAL RESULT IS THE ONE AT THE TOP, always. In an extended game (Gitea#11) the standings go
|
||||
* on moving after the winner is settled, so this screen reports the frozen result first and puts
|
||||
* everything that happened afterwards in its own section, marked as informational. "In a five-day
|
||||
* game, even if it's extended to eight or nine days, the winner and the official answer is the
|
||||
* winner at the end of five days" (Jesse, 2026-08-28).
|
||||
*/
|
||||
export function resultsHtml(f: Frame): string {
|
||||
// `official` is written by the engine the moment any game ends, so it is present on every finished
|
||||
// game. The fallback keeps this rendering something sane for a Frame that predates it — a replay
|
||||
// of a save recorded before this release, which the replay viewer will happily hand us.
|
||||
const report = f.official;
|
||||
const o = report?.outcome ?? f.outcome;
|
||||
if (!o) return '<p class="dim">This game has not ended.</p>';
|
||||
|
||||
const officialDay = report?.day ?? f.days;
|
||||
const winnerName =
|
||||
o.winner === null ? null : (f.players.find((p) => p.index === o.winner)?.name ?? null);
|
||||
|
||||
const headline =
|
||||
o.result === 'loss'
|
||||
? 'The Division failed'
|
||||
: winnerName === null
|
||||
? 'The Division ran'
|
||||
: `${esc(winnerName)} takes the Division`;
|
||||
|
||||
/**
|
||||
* The result is reported against the standings AS THEY WERE at the official ending, not as they
|
||||
* are now — in an extended game those are different numbers, and the winner has to be shown
|
||||
* winning. `revenues` is frozen alongside the outcome for exactly this.
|
||||
*/
|
||||
const frozen = report
|
||||
? { ...f, players: f.players.map((p) => ({ ...p, revenue: report.revenues[p.index] ?? p.revenue })) }
|
||||
: f;
|
||||
|
||||
const result =
|
||||
o.result === 'loss'
|
||||
? '<p>Nobody wins this one.</p>'
|
||||
: o.winner === null
|
||||
? '<p>The table clears it together — a Co-op game has no individual winner.</p>'
|
||||
: `<p><b>${esc(winnerName ?? '')}</b> finishes ahead on Revenue.</p>`;
|
||||
|
||||
const extended =
|
||||
f.extraDays > 0
|
||||
? '<h4 class="res-h">After the timetable</h4>' +
|
||||
`<p>The table played on for ${f.extraDays} more Day${f.extraDays === 1 ? '' : 's'}, ` +
|
||||
`through Day ${f.days + f.extraDays}. None of it changed the result above — it is recorded ` +
|
||||
'here because it happened.</p>' +
|
||||
standingsHtml(f) +
|
||||
targetHtml(f) +
|
||||
collisionsHtml(f) +
|
||||
tallyHtml(f.tally)
|
||||
: '';
|
||||
|
||||
return (
|
||||
`<h3 class="dayend-h res-${o.result}">${headline}</h3>` +
|
||||
`<p>${reasonSentence(frozen, o, officialDay)}</p>` +
|
||||
result +
|
||||
standingsHtml(frozen, o.winner) +
|
||||
targetHtml(frozen) +
|
||||
collisionsHtml(frozen) +
|
||||
perPlayerHtml(frozen) +
|
||||
rulesHtml(f) +
|
||||
tallyHtml(report?.tally ?? f.tally) +
|
||||
extended
|
||||
);
|
||||
}
|
||||
|
||||
export function blockedHtml(f: Frame): string {
|
||||
return f.blocked.length === 0
|
||||
? '<li class="dim">nothing blocked</li>'
|
||||
@@ -142,7 +563,7 @@ export function blockedHtml(f: Frame): string {
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** The load pipeline at each industry: green → MEN | AT | WORK → red, and the siding. */
|
||||
/** The load pipeline at each industry: green → MEN | AT | WORK → red, and the cars spotted here. */
|
||||
export function facilitiesHtml(f: Frame): string {
|
||||
/**
|
||||
* One number, with what the card printed and what a Modifier added.
|
||||
@@ -239,12 +660,12 @@ export function facilitiesHtml(f: Frame): string {
|
||||
}">${x.kind === 'passenger' ? 'waiting to board' : 'waiting to load'}</span>${boxes(x.green, x.greenCap, 'g')}</div>`
|
||||
: '') +
|
||||
/**
|
||||
* A PASSENGER FACILITY HAS NO SIGN AND NO SIDING, so neither row is drawn for one.
|
||||
* A PASSENGER FACILITY HAS NO SIGN, so the row is not drawn for one.
|
||||
*
|
||||
* `maw` comes back empty for a Depot, Station or Terminal — the pipeline is a Freight
|
||||
* Facility fitting (§9.2 is Porters, with no MEN | AT | WORK step). The row LABEL printed
|
||||
* ahead of the loop regardless, so a Depot showed the caption and three empty boxes it
|
||||
* has no Laborer to work. Same for the siding: an Office has no industry track at all.
|
||||
* has no Laborer to work.
|
||||
*/
|
||||
(x.maw.length > 0
|
||||
? `<div class="boxes"><span class="dim" data-tip="One physical sign, worked in whichever direction this industry runs. Only one load may sit on each of the three boxes.">MEN AT WORK</span>` +
|
||||
@@ -258,14 +679,23 @@ export function facilitiesHtml(f: Frame): string {
|
||||
: 'Loads that have come off an arriving car and been cleared. They travel car → WORK → AT → MEN, then into a red box — the opposite direction to loading.'
|
||||
}">${x.kind === 'passenger' ? 'arrived' : 'cleared inbound'}</span>${boxes(x.red, x.redCap, 'r')}</div>`
|
||||
: '') +
|
||||
(x.trackCap > 0 || x.track.length > 0
|
||||
? `<div class="boxes"><span class="dim" data-tip="Cars spotted on this industry's track. A load leaves the sign onto one of these, or an arriving load starts from one.">siding</span>${boxes(x.track, x.trackCap, 's')}</div>`
|
||||
/**
|
||||
* THE CARS THAT ARE HERE — NOT A SIDING OF A FIXED LENGTH.
|
||||
*
|
||||
* This drew one empty box per unit of the industry's capacity and labelled the row
|
||||
* "siding", which said two untrue things: that the card prints a siding, and that its
|
||||
* length is the box count. It is neither. An industry track is ordinary Operating Rail
|
||||
* and holds four cars like every other card, so there is no printed length worth
|
||||
* drawing — only what is actually standing there. Passing cap 0 renders the cars alone.
|
||||
*/
|
||||
(x.track.length > 0
|
||||
? `<div class="boxes"><span class="dim" data-tip="Cars standing on this industry's track. A load leaves the sign onto one of these, or an arriving load starts from one. Up to four cars fit here, the same as any Operating track card.">spotted</span>${boxes(x.track, 0, 's')}</div>`
|
||||
: '') +
|
||||
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
|
||||
x.jammed
|
||||
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
|
||||
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it.'
|
||||
: x.canFinish
|
||||
? 'A matching empty car is spotted on the siding, so a load worked here can come off onto it.'
|
||||
? 'A matching empty car is spotted on this industry\'s track, so a load worked here can come off onto it.'
|
||||
: 'No matching car is spotted. Starting a load here would park it on WORK and jam the facility.'
|
||||
}">` +
|
||||
(x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') +
|
||||
@@ -283,6 +713,30 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
||||
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
|
||||
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
|
||||
.handcard.unplayable{color:#7d8794;border-color:#39424e}
|
||||
/* THE HOME OFFICE DECK. Face down, so its card slot names no card — it says so instead, in the
|
||||
dimmed voice the rest of the panel uses for "nothing to read here". */
|
||||
.handcard.facedown > b{color:#6f7885;font-style:italic;font-weight:400}
|
||||
/* THE PILE A WATCHED MOVE JUST TOUCHED (v0.8.1).
|
||||
A STATE, NOT A FLASH, and that is the whole point. The .tt-slot.fresh rule above animates for a fixed
|
||||
1.5s, which is right for a die roll nobody is waiting on — but a step can hold the screen for
|
||||
seven seconds at 10x, so a fixed animation would be over long before the pause it belongs to and
|
||||
the player would be back to staring at an unchanged board. The flash-in marks the moment; the lit
|
||||
border and background stay for exactly as long as the step is up, because the class is on the
|
||||
element only while that step is the one being shown. */
|
||||
.handcard.pilelit{border-color:#8fd6a0;background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.55);
|
||||
animation:pilepulse 1.15s ease-in-out infinite}
|
||||
/* A PULSE FOR THE WHOLE DWELL, not one flash at the start. Measured: at 10x a pile stays lit for
|
||||
just under seven seconds, so the highlight was never brief — but a single 0.45s flash-in and a
|
||||
dark green fill were easy to miss entirely while watching the district. Jesse: "caught one flash
|
||||
deck light up for just a very brief moment, but couldn't see that with what bot was doing in
|
||||
office area and history and catch up area all at same time." Something still moving keeps drawing
|
||||
the eye for as long as the move is up; a state that settles stops asking to be looked at. */
|
||||
@keyframes pilepulse{0%,100%{background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.45)}
|
||||
50%{background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0,0 0 22px rgba(143,214,160,.85)}}
|
||||
/* Motion is the point here, so the reduced-motion fallback has to be loud in a different way rather
|
||||
than simply not moving: a solid ring and a brighter fill, held. */
|
||||
@media(prefers-reduced-motion:reduce){
|
||||
.handcard.pilelit{animation:none;background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0}}
|
||||
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
|
||||
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
|
||||
/* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that
|
||||
@@ -323,9 +777,18 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
||||
/* And the piles that are NOT targets step back while a discard is being aimed, so the three that
|
||||
are stand out from the Salvage Yard beside them. */
|
||||
.cardrow.aiming .handcard:not(.target){opacity:.4}
|
||||
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;
|
||||
outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)}
|
||||
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)}
|
||||
/* A CAR YOU MAY ADD IS AN ACTION, SO IT WEARS THE ACTION COLOUR — Jesse, playtest 2026-09-16: the
|
||||
highlight was "just a small bold and basically the same color as everything else", which is the
|
||||
whole difficulty with making the yard chip the button. #c8912f is the border colour that the
|
||||
action buttons themselves use, so a clickable car looks like every other thing inviting a click,
|
||||
rather than like a number that happens to be outlined.
|
||||
|
||||
THE LOADED/EMPTY TEXT COLOURS ARE LEFT ALONE. Green and blue-grey are what say which of the two
|
||||
numbers is which; the amber answers "may I click this", which is a different question, and
|
||||
painting over the first to answer the second would cost real information. */
|
||||
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;font-weight:700;
|
||||
outline:2px solid #c8912f;background:rgba(200,145,47,.20);box-shadow:0 0 0 2px rgba(200,145,47,.16)}
|
||||
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(200,145,47,.38)}
|
||||
/* Twelve Stages across, so a Day is one glance. The current Stage is lit, Stages already gone are
|
||||
dimmed, and a slot the die has just filled flashes once. */
|
||||
.tt{display:flex;gap:3px;flex-wrap:wrap}
|
||||
@@ -369,7 +832,7 @@ ul.blocked{margin:0;padding-left:18px}
|
||||
.box{display:inline-block;min-width:22px;text-align:center;border-radius:3px;padding:1px 4px;font-size:10px}
|
||||
.box.empty{background:#242a32;color:#5a6472}
|
||||
/* Green OUT, red IN, matching the board SVG's palette exactly — the panel and the map are the same
|
||||
card seen twice, so they may not disagree about which colour means which way. Grey for the siding,
|
||||
card seen twice, so they may not disagree about which colour means which way. Grey for spotted cars,
|
||||
which is a place rather than a direction. */
|
||||
.box.g{background:#2f6b3d}
|
||||
.box.r{background:#8a4a4a}
|
||||
@@ -379,4 +842,28 @@ ul.blocked{margin:0;padding-left:18px}
|
||||
.fstat.good{background:rgba(40,140,60,.28)}
|
||||
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
|
||||
.fstat.idle{opacity:.6}
|
||||
/* THE DAY-END DIALOG (Gitea#10). The dialog chrome is play.html's; these are its contents, here
|
||||
because dayEndHtml is here — a panel and its styling stay together. */
|
||||
.dayend-h{font-size:15px;text-transform:none;letter-spacing:0;color:#e6e9ee;margin:0 0 8px}
|
||||
.dayend-t{border-collapse:collapse;margin:9px 0;min-width:210px}
|
||||
.dayend-t td{padding:3px 12px 3px 0;border-top:1px solid #2c333d}
|
||||
.dayend-t tr:first-child td{border-top:0}
|
||||
.dayend-t .num{text-align:right;font-variant-numeric:tabular-nums;font-weight:700;padding-right:0}
|
||||
.dayend-t .you td{color:#8fd6a0}
|
||||
.dayend-t .wins{color:#e8c56a;font-weight:700}
|
||||
|
||||
/* END-OF-GAME RESULTS (Gitea#16). Same family as the Day-end dialog above, which is the point —
|
||||
the two screens share their standings/target/collision blocks and should look like each other. */
|
||||
.res-h{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#8b95a3;
|
||||
margin:16px 0 6px;border-top:1px solid #2c333d;padding-top:10px}
|
||||
.res-win{color:#8fd6a0}
|
||||
.res-loss{color:#d98f8f}
|
||||
.res-t{border-collapse:collapse;margin:4px 0;width:100%}
|
||||
.res-t td,.res-t th{padding:3px 12px 3px 0;border-top:1px solid #232a33;vertical-align:top}
|
||||
.res-t tr:first-child td{border-top:0}
|
||||
.res-t td:first-child{color:#8b95a3;white-space:nowrap}
|
||||
.res-t th{color:#6d7783;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.05em}
|
||||
.res-t .num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.res-wide td:first-child{color:#e6e9ee}
|
||||
.res-t .you td{color:#8fd6a0}
|
||||
`;
|
||||
|
||||
+882
-6
@@ -33,6 +33,16 @@ header button:hover{border-color:#4d6fa8}
|
||||
stops inviting the press. */
|
||||
header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d}
|
||||
header button:disabled:hover{border-color:#2c333d}
|
||||
.zoom{display:inline-flex;align-items:center;gap:4px}
|
||||
.zoom button{padding:3px 9px;line-height:1}
|
||||
.lb-invite{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:0 0 10px;
|
||||
background:#1e242c;border:1px solid var(--line);border-radius:7px;padding:10px 12px}
|
||||
.lb-invite-label{font-size:11px;color:var(--dim)}
|
||||
.lb-invite-code{font-size:22px;font-weight:700;letter-spacing:.08em;color:#f2e6cf}
|
||||
#lb-settings{margin:10px 0;border:1px solid var(--line);border-radius:7px;padding:8px 12px;background:#171c23}
|
||||
#lb-settings summary{cursor:pointer;font-size:13px;color:#9fb6d8}
|
||||
#lb-settings h3{font-size:12px;margin:12px 0 4px;color:#9fb6d8}
|
||||
.zoom #zoomlabel{font-size:11px;color:var(--dim);min-width:32px;text-align:center;display:inline-block}
|
||||
.build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap}
|
||||
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
|
||||
.home:hover{color:#5aa9e6}
|
||||
@@ -40,10 +50,83 @@ header button:disabled:hover{border-color:#2c333d}
|
||||
.pace.good{background:rgba(40,140,60,.32)}
|
||||
.pace.behind{background:rgba(190,120,40,.28)}
|
||||
.yard.bare{outline:1px dashed #e0a060;outline-offset:3px;border-radius:4px;padding:3px}
|
||||
/* NEW GAME DIALOG. Native <dialog>, so Esc closes it and focus is trapped without any of that
|
||||
being written here. Everything below is only colour and spacing: the browser's own white-on-white
|
||||
default is unreadable against this page. */
|
||||
dialog{background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:9px;
|
||||
padding:16px 18px;max-width:520px;width:calc(100% - 32px);font:inherit;max-height:86vh;overflow:auto}
|
||||
dialog::backdrop{background:rgba(0,0,0,.62)}
|
||||
dialog h3{margin-top:15px}
|
||||
/* The rationale under each heading, not beside each control — the reasons are sentences, and a
|
||||
sentence squeezed into a label column wraps into noise. */
|
||||
.ng-note{color:var(--dim);font-size:11.5px;margin:0 0 8px;line-height:1.45}
|
||||
dialog input[type=text],dialog input[type=number]{background:#0f1318;color:var(--fg);
|
||||
border:1px solid var(--line);border-radius:5px;padding:4px 7px;font:inherit;font-size:13px}
|
||||
dialog input[type=text]{width:100%}
|
||||
dialog input[type=number]{width:64px;text-align:right}
|
||||
dialog input:focus{outline:none;border-color:#4d6fa8}
|
||||
/* Whole rows, so the click target is the sentence and not the 13px circle beside it. */
|
||||
.ng-radio{display:flex;gap:9px;align-items:flex-start;padding:6px 7px;border-radius:5px;cursor:pointer}
|
||||
.ng-radio:hover{background:#20262e}
|
||||
.ng-radio input{margin-top:3px;flex:0 0 auto}
|
||||
.ng-radio .dim{font-size:11.5px}
|
||||
.ng-num{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:4px 7px}
|
||||
.ng-buttons{display:flex;justify-content:flex-end;gap:8px;margin:16px 0 0;padding:0}
|
||||
.ng-buttons button{background:#2a3038;color:inherit;border:1px solid var(--line);border-radius:5px;
|
||||
padding:5px 14px;cursor:pointer;font:inherit;font-size:13px}
|
||||
.ng-buttons button:hover{border-color:#4d6fa8}
|
||||
#ng-deal{background:#31527f;border-color:#4d6fa8}
|
||||
/* The save warning is the one thing on this screen that describes something IRREVERSIBLE, and it
|
||||
sat in `.ng-note` — the same dim 11px grey as the twenty explanatory notes above it, which is
|
||||
where the eye has already learned there is nothing to act on. Sized and coloured to be read
|
||||
(Jesse, 2026-08-30). Amber rather than red: losing a saved game is a real cost, not a danger, and
|
||||
red here would outrank the actual rules of the game sitting above it. */
|
||||
#ss-saved-note{font-size:15px;font-weight:500;line-height:1.55;color:#ffcf70;background:#332a15;
|
||||
border:1px solid #b8912c;border-left:5px solid #e0a83c;border-radius:5px;padding:12px 14px;
|
||||
margin:18px 0 0}
|
||||
#ss-saved-note b{color:#ffe3a6}
|
||||
/* Two live choices, so neither is the quiet one: `Create new game` keeps the primary blue it has when
|
||||
it is the only button, and `Continue` is given the same weight rather than reading as a cancel. */
|
||||
#ss-deal{background:#31527f;border-color:#4d6fa8}
|
||||
#ss-resume{background:#2f5340;border-color:#4f8a68}
|
||||
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
|
||||
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
||||
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||
padding:10px 12px;margin-bottom:12px}
|
||||
#lobby,#solitairesetup{max-width:1040px;margin:0 auto;padding:14px}
|
||||
/* The create form is two short lists, not one long one: what game this is on the left, what its
|
||||
rules are on the right. Collapses to one column where there is no room for two. */
|
||||
.lb-two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);gap:22px;align-items:start}
|
||||
@media(max-width:860px){.lb-two{grid-template-columns:1fr;gap:0}}
|
||||
.lb-col{min-width:0}
|
||||
.lb-span{grid-column:1/-1;min-width:0}
|
||||
/* The rules in as many columns as the width allows — one in the New Game dialog, which is narrow. */
|
||||
.set-groups{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:0 26px;align-items:start}
|
||||
.set-group{min-width:0;break-inside:avoid}
|
||||
.set-group h3:first-child{margin-top:4px}
|
||||
/* A DISABLED CHOICE HAS TO LOOK DISABLED. Reported by Jesse: Solitaire is not selectable in the
|
||||
lobby and nothing on it said so — a radio that silently refuses reads as a broken radio. */
|
||||
.ng-radio.disabled{opacity:.45;cursor:not-allowed}
|
||||
.ng-radio.disabled:hover{background:none}
|
||||
#lobby h2,#solitairesetup h2{margin-top:0}
|
||||
#lobby h3,#solitairesetup h3{margin-bottom:2px}
|
||||
.lb-seat{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)}
|
||||
.lb-seat:last-child{border-bottom:none}
|
||||
.lb-seat .who{flex:1}
|
||||
#presence{color:#e0b060;font-size:12px;padding:0 14px;empty-cells:hide}
|
||||
/* WHAT YOU ARE WATCHING — v0.8.0, TODO #13/#15. An IN-FLOW row rather than a floating banner like
|
||||
#phasenote and #announce: those announce a moment and fade, this one stands for as long as the
|
||||
board is behind and has a button you have to be able to hit. Amber on the button because amber
|
||||
already means clickable everywhere else on this page; the row itself stays quiet so it does not
|
||||
compete with the three banners it sits under. */
|
||||
#watching{display:flex;align-items:center;gap:10px;padding:4px 14px;font-size:12px;color:#9aa0b4}
|
||||
#watching[hidden]{display:none}
|
||||
#watching-what{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0;
|
||||
background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap}
|
||||
#watching-skip,#watching-pause{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
|
||||
#watching-who{color:#c9cee0;font-weight:700}
|
||||
#presence:empty{display:none}
|
||||
/* division strip */
|
||||
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
|
||||
.node{border:1px solid var(--line);border-radius:6px;padding:6px 9px;min-width:112px;flex:0 0 auto}
|
||||
@@ -88,11 +171,40 @@ button.cardact.discard{color:#d6b48a}
|
||||
box-shadow:0 6px 22px rgba(0,0,0,.5)}
|
||||
#phasenote.shown{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
@media(prefers-reduced-motion:reduce){#phasenote{transition:none}}
|
||||
/* A train completing its run pays EVERY player, so it is announced rather than left to the log —
|
||||
green like a revenue gain, and its own element so it cannot fight the phase note for the slot. */
|
||||
#announce{position:fixed;left:50%;top:14px;transform:translateX(-50%) translateY(-14px);z-index:41;
|
||||
background:#16301f;border:1px solid #4c9a6a;color:#c8f0d8;border-radius:14px;padding:7px 18px;
|
||||
font-size:14px;font-weight:700;opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;
|
||||
box-shadow:0 6px 22px rgba(0,0,0,.5);max-width:min(680px,90vw);text-align:center}
|
||||
#announce.shown{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
@media(prefers-reduced-motion:reduce){#announce{transition:none}}
|
||||
/* The die's answer, said where the player is looking. Green like a revenue gain — it is a good
|
||||
thing that just happened — and it disappears with the next render, as a moment should. */
|
||||
.scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px;
|
||||
background:rgba(40,140,60,.22);border:1px solid #2f6b47;color:#bfe8cd;font-weight:600}
|
||||
.makeup-note{font-size:11px;margin:0 0 5px}
|
||||
/* WHAT THE TRAIN STILL WANTS. Deliberately NOT amber: amber means "you can click this" everywhere
|
||||
else on this page, and this line is the REASON for clicking rather than a thing to click — the
|
||||
cars in the Division Yard are. Brighter than the note beneath it, because it answers the question
|
||||
the player actually has while looking at it. `done` goes green like `.scheduled`: a complete
|
||||
consist is good news, not an instruction. */
|
||||
.makeup-needs{font-size:12px;margin:0 0 5px;color:#e6e9ee}
|
||||
.makeup-needs b{color:#f2e6cf}
|
||||
.makeup-needs.done{color:#bfe8cd}
|
||||
/* WHICH TRAIN AM I SWITCHING. A row of crews rather than a stacked list — they are alternatives,
|
||||
and the chosen one is the crew whose squares the board is drawing, so it wears the same violet
|
||||
"you are here" the rest of the page uses. */
|
||||
.crewpick{display:flex;flex-wrap:wrap;gap:6px;align-items:baseline}
|
||||
.crewpick h3{flex:0 0 100%;margin:0 0 2px}
|
||||
button.act.crew{width:auto;display:inline-block;margin:0}
|
||||
button.act.crew.on{border-color:var(--now);background:rgba(185,140,240,.18);color:#e9dcff}
|
||||
/* THE ORDER TO ADD THE CARS IN. Only ever drawn for a train whose make-up order can lock it (7/8
|
||||
Local), so it is worth a coloured rule down the side: a player who sees this once needs to read
|
||||
it, and will not see it again on the next train. `warn` is the train already made up badly. */
|
||||
.makeup-advice{font-size:11px;line-height:1.45;margin:0 0 6px;padding:5px 8px;border-radius:4px;
|
||||
border-left:3px solid #4d6fa8;background:rgba(60,100,170,.13)}
|
||||
.makeup-advice.warn{border-left-color:#e0a060;background:rgba(200,150,60,.15)}
|
||||
/* Moves left, beside the moves. Amber like the actions, because it is a budget being spent by the
|
||||
buttons underneath it; struck through in red once it is gone. */
|
||||
.moves{display:inline-block;font-size:11px;margin:0 0 7px;padding:1px 8px;border-radius:10px;
|
||||
@@ -101,12 +213,21 @@ button.cardact.discard{color:#d6b48a}
|
||||
/* actions */
|
||||
#actions{max-height:none}
|
||||
.grp{margin-bottom:6px}
|
||||
/* What a role is for, under its heading — a sentence a player reads once and stops re-asking. Sized
|
||||
below the buttons so it explains without competing with the thing being chosen. */
|
||||
.grp .scope{margin:2px 0 4px;font-size:11.5px;line-height:1.35}
|
||||
/* THESE ARE THE THINGS YOU CAN DO. An action carried the same grey border as every other panel
|
||||
on the page, so the one region that is clickable did not look it. Amber border and a lit face,
|
||||
used nowhere else, so "this is a move" is answered before the label is read. */
|
||||
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
|
||||
padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left}
|
||||
button:hover{background:#39424e;border-color:#4d6fa8}
|
||||
/* GENERIC, and it was not. `header button:disabled` and `#actions button:disabled` were the only
|
||||
disabled styles on the page, so a disabled button anywhere else — #lb-start being the one that
|
||||
mattered — kept its normal face AND still lit up under the cursor from the rule above. It was
|
||||
advertising a click it would refuse. */
|
||||
button:disabled{opacity:.45;cursor:not-allowed}
|
||||
button:disabled:hover{background:#2a3038;border-color:var(--line)}
|
||||
#actions button{background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18);
|
||||
color:#f2e6cf;font-weight:600}
|
||||
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
|
||||
@@ -117,6 +238,17 @@ button.act{display:inline-block}
|
||||
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
|
||||
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||||
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||
/* A SEGMENTED CONTROL, BECAUSE A CYCLE COULD NOT REACH EVERY STATE (TODO #16).
|
||||
One button that steps auto -> pinned -> auto can only ever offer the pin OPPOSITE to whatever
|
||||
auto is doing at that moment, which depends on the phase — so "always hidden" was unreachable
|
||||
from "always showing" without waiting for the right phase in between. Three controls, one per
|
||||
mode, and the current one is lit. The buttons still say what they DO rather than what the panel
|
||||
is doing, which was the earlier fix and is worth keeping. */
|
||||
.seg{display:inline-flex;margin-left:10px;vertical-align:middle;border-radius:4px;overflow:hidden;
|
||||
border:1px solid #4a5361}
|
||||
.seg button.ghost{margin:0;border:0;border-radius:0;border-left:1px solid #4a5361}
|
||||
.seg button.ghost:first-child{border-left:0}
|
||||
.seg button.ghost[aria-pressed="true"]{background:#2f3a4a;color:var(--fg);font-weight:600}
|
||||
#district.folded #grid{display:none}
|
||||
#district.folded .districtrule{display:none}
|
||||
/* Said once, quietly, beside the thing it governs — a rule a player needs on their first district
|
||||
@@ -126,6 +258,23 @@ button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||
.districtrule b{color:#cfd6e0}
|
||||
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||
#districtsummary{display:none}
|
||||
/* The This Game card folds the same way the district does, and for the same reason: a panel that
|
||||
vanishes entirely reads as broken, so the summary line is what a folded card still says. */
|
||||
#gamecard.folded #gamecardbody{display:none}
|
||||
#gamecard #gamecardsummary{display:none}
|
||||
#gamecard.folded #gamecardsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||
#gamecardbody dl{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;margin:4px 0 10px}
|
||||
#gamecardbody dt{color:#8b94a3;font-size:11px}
|
||||
#gamecardbody dd{margin:0;font-size:12px;color:#cfd6e0}
|
||||
#gamecardbody dd.changed{color:#f0b64a}
|
||||
#gamecardbody h4{margin:8px 0 0;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b94a3}
|
||||
/* THE GUIDE, inside the card rather than the header (Jesse, 2026-09-21). Laid out as one wrapping
|
||||
line of links rather than a list: six references stacked would make the card scroll past the
|
||||
house rules it sits under, and these are a shelf to reach for, not a thing to read down. */
|
||||
#gamecardbody .guide{margin-top:10px;border-top:1px solid var(--line);padding-top:6px}
|
||||
#gamecardbody .guide p{margin:4px 0 0;font-size:12px;line-height:1.7;color:#8b94a3}
|
||||
#gamecardbody .guide a{color:#9fb6d8;text-decoration:none;border-bottom:1px solid #33404f}
|
||||
#gamecardbody .guide a:hover{color:#cfe0f5;border-bottom-color:#5aa9e6}
|
||||
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
||||
so a disabled button must not wear it. */
|
||||
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
||||
@@ -136,6 +285,14 @@ h3.actions-hd{font-size:13px;text-transform:none;letter-spacing:.01em;color:#cfe
|
||||
.over{padding:9px;border-radius:5px;font-weight:700;margin-bottom:8px}
|
||||
.over.win{background:rgba(40,140,60,.35)}
|
||||
.over.loss{background:rgba(160,60,60,.3)}
|
||||
/* THE EXTENSION VOTE (Gitea#11) — unanimous, so who has not answered yet is the useful half. */
|
||||
.vote-tally{display:flex;flex-wrap:wrap;gap:4px 12px;margin:0 0 9px;font-size:12px}
|
||||
.vote.yes{color:#8fd6a0}
|
||||
.vote.no{color:#d98f8f}
|
||||
.vote.wait{color:var(--dim)}
|
||||
/* Wider than the New Game dialog: the results carry a seven-column per-player table (Gitea#16). */
|
||||
#resultsdlg{max-width:640px}
|
||||
#resultsdlg table{max-width:100%}
|
||||
/* cards, log, blocked */
|
||||
.card.gone{opacity:.35;text-decoration:line-through}
|
||||
.subj{display:block;width:100%;margin:2px 0}
|
||||
@@ -156,10 +313,594 @@ h3.actions-hd{font-size:13px;text-transform:none;letter-spacing:.01em;color:#cfe
|
||||
ul.blocked{list-style:none;margin:0;padding:0;font-size:12px}
|
||||
ul.blocked li{padding:2px 0}
|
||||
.sev-warn{color:#e0b060}.sev-stop{color:#e58080}
|
||||
/* THE RULES BLOCK, shared by the lobby and the New Game dialog (`settings-form.ts`).
|
||||
`.changed` is the one signal that says "this game is not the type it claims" — amber, the palette's
|
||||
attention colour, never red: a changed rule is a choice, not an error. */
|
||||
.set-row{padding:3px 0}
|
||||
.set-row.changed{border-left:3px solid #e0b060;padding-left:9px;margin-left:-12px;
|
||||
background:rgba(224,176,96,.08);border-radius:0 4px 4px 0}
|
||||
.set-hint{display:block;font-size:11px;color:var(--dim);margin:2px 0 0}
|
||||
/* The line under the type radios when a game is no longer the type it started as. */
|
||||
.changed-note{color:#e0b060}
|
||||
.set-row.changed .set-hint{color:#e0b060}
|
||||
.set-row.unavailable{opacity:.5}
|
||||
.ng-gate{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:3px 0}
|
||||
.ng-gate input[type=checkbox]{flex:none}
|
||||
.gate-num{width:82px}
|
||||
.ng-gate input:disabled{opacity:.45}
|
||||
.lb-params{display:flex;gap:14px;flex-wrap:wrap;align-items:end;margin:8px 0}
|
||||
.lb-params .ng-num{flex:1 1 150px}
|
||||
/* An error a player must not scroll past. The grey `.dim` line this replaced was routinely missed —
|
||||
it looked like the note above it. Red is the palette's `.sev-stop`. */
|
||||
.lb-error{color:#e58080;font-size:14px;font-weight:600;margin:8px 0;padding:8px 11px;
|
||||
border-left:3px solid #e58080;background:rgba(229,128,128,.12);border-radius:0 5px 5px 0;
|
||||
empty-cells:hide}
|
||||
.lb-error:empty{display:none}
|
||||
.lb-warn{color:#e0b060;font-size:13px;margin:8px 0}
|
||||
.lb-doors{display:flex;gap:9px;margin:14px 0 12px}
|
||||
.lb-door{flex:1;background:#222831;color:var(--fg);border:1px solid var(--line);border-radius:7px;
|
||||
padding:9px 12px;cursor:pointer;font:inherit;font-size:14px}
|
||||
.lb-door:hover{border-color:#4d6fa8}
|
||||
.lb-door.active{background:#2f3a4b;border-color:#6f8fc8;color:#cfe0f5;font-weight:600}
|
||||
.lb-saved{display:flex;align-items:center;gap:10px}
|
||||
/* THE LOBBY'S ACTION BUTTONS CARRY THE SAME AMBER AS THE BOARD'S (Jesse, 2026-09-22).
|
||||
`#actions button` has meant "this is a move you can make" all game; the lobby is the one screen
|
||||
where a player is also being asked to act, and it was styling those buttons as plain chrome. The
|
||||
selector is a LIST OF THE ACTIONS, not `#lobby button` — the settings form beneath Create is a
|
||||
field of inputs, and amber on all of it would say everything is a move and so say nothing.
|
||||
Disabled keeps `button:disabled` above: an unfilled Start game must not advertise a click. */
|
||||
#lb-look, #lb-join, #lb-create, #lb-start, #lb-copy, #lb-copylink, .lb-known-row .lb-rejoin{
|
||||
background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18);
|
||||
color:#f2e6cf;font-weight:600}
|
||||
#lb-look:hover, #lb-join:hover, #lb-create:hover, #lb-start:hover, #lb-copy:hover,
|
||||
#lb-copylink:hover, .lb-known-row .lb-rejoin:hover{
|
||||
background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
|
||||
#lb-look:focus-visible, #lb-join:focus-visible, #lb-create:focus-visible, #lb-start:focus-visible,
|
||||
#lb-copy:focus-visible, #lb-copylink:focus-visible, .lb-known-row .lb-rejoin:focus-visible{
|
||||
outline:2px solid #f0b64a;outline-offset:2px}
|
||||
/* Copy is still secondary — it carries the amber so it reads as actionable, but not the weight of
|
||||
the button that starts a game. `button.ghost` set its own border, so it is overridden here. */
|
||||
#lb-copy, #lb-copylink{border-width:1px;font-size:11px;box-shadow:none}
|
||||
/* A disabled action is chrome again, or the amber becomes a promise the button will not keep. */
|
||||
#lb-start:disabled, #lb-start:disabled:hover{
|
||||
background:#2a3038;border:2px solid var(--line);box-shadow:none;color:inherit}
|
||||
.lb-known-row{display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--line)}
|
||||
.lb-known-row:last-child{border-bottom:none}
|
||||
.lb-known-row .code{font-weight:700;letter-spacing:.06em;color:#f2e6cf;flex:1}
|
||||
.lb-type{font-size:15px;font-weight:600;color:#cfe0f5;margin:2px 0 8px}
|
||||
.lb-preview-head{background:#1e242c;border:1px solid var(--line);border-radius:7px;padding:10px 12px;margin:0 0 10px}
|
||||
/* The read-only rule set — what a joining player reads before sitting down, and what the seating
|
||||
screen keeps showing afterwards. Generated, never a form: nobody but the host may change these. */
|
||||
.rules-list{border:1px solid var(--line);border-radius:7px;background:#171c23;padding:4px 12px;margin:0 0 12px}
|
||||
.rules-list dl{display:grid;grid-template-columns:minmax(140px,auto) 1fr;gap:2px 14px;margin:8px 0}
|
||||
.rules-list dt{color:var(--dim);font-size:12px}
|
||||
.rules-list dd{margin:0;font-size:13px}
|
||||
.rules-list dd.changed{color:#e0b060}
|
||||
.rules-list h4{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:10px 0 0}
|
||||
/* THE HANDOFF. Between the host pressing Start and the first Frame arriving there is nothing to
|
||||
draw — and a blank board is what a broken game looks like too. */
|
||||
#handoff{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center;
|
||||
background:rgba(18,21,26,.94);text-align:center;padding:20px}
|
||||
#handoff.shown{display:flex}
|
||||
#handoff .hand-inner{max-width:460px}
|
||||
#handoff h2{font-size:15px;text-transform:none;letter-spacing:.01em;color:#cfe0f5}
|
||||
#handoff .hand-note{color:var(--dim);font-size:13px;margin-top:8px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- THE LOBBY (Phase 4) — shown instead of the game UI whenever there is no game yet to play: no
|
||||
stored session token, or a token whose game hasn't started. `lobby.ts` owns everything in here;
|
||||
`main.ts` only decides whether THIS div, `#solitairesetup` or `#gameui` is the one visible.
|
||||
`#solitairesetup` asks the same questions of a solitaire player, through the same shared module
|
||||
(`settings-form.ts`) — the two blocks are generated from one template, and since 2026-08-30
|
||||
they are the ONLY two: the in-game dialog that was a third copy is gone. -->
|
||||
<div id="lobby" hidden>
|
||||
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Multiplayer</span></header>
|
||||
|
||||
<!-- Whatever brought the player here when it was not their own click: a game that was ended under
|
||||
them, or a lobby that closed. Never a field-level error — those sit with their fields. -->
|
||||
<p class="lb-error" id="lb-notice" role="alert" hidden></p>
|
||||
|
||||
<section id="lb-secret-section">
|
||||
<h2>Join secret</h2>
|
||||
<!-- Saved: one line, not a password field asking to be filled in again. `lobby.ts` swaps these
|
||||
two, and re-opens the ask automatically when the server answers 403. -->
|
||||
<div id="lb-secret-saved" class="lb-saved" hidden>
|
||||
<span class="dim">Join secret · saved in this browser</span>
|
||||
<button id="lb-secret-change" class="ghost" type="button">Change</button>
|
||||
</div>
|
||||
<div id="lb-secret-ask">
|
||||
<p class="ng-note">Whoever is running this server gave you a secret out of band (a chat message, not a public page). It is kept in this browser only, never shown back, and sent with every lobby request.</p>
|
||||
<label class="ng-num"><span>Join secret</span><input id="lb-secret" type="password" autocomplete="off"></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- EVERY GAME THIS BROWSER IS IN. `localStorage` used to hold exactly ONE multiplayer session, so
|
||||
joining a second silently overwrote the first and locked that seat out for good. Keyed by game
|
||||
now, and this is where they are picked between — and forgotten deliberately rather than by
|
||||
accident. -->
|
||||
<section id="lb-known" hidden>
|
||||
<h2>Games you are in</h2>
|
||||
<p class="ng-note">This browser holds a seat in these. Rejoining needs the token kept here, so
|
||||
<b>Forget</b> is the one thing that cannot be undone from this screen.</p>
|
||||
<div id="lb-known-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="lb-choice-section">
|
||||
<label class="ng-num"><span>Your display name</span><input id="lb-name" type="text" autocomplete="off" maxlength="40"></label>
|
||||
|
||||
<!-- TWO DOORS, one panel at a time. Joining used to be a heading at the BOTTOM of the create
|
||||
form: someone sent a code had to scroll past fifteen fields they had no use for. -->
|
||||
<div class="lb-doors">
|
||||
<button id="lb-door-join" class="lb-door active" type="button">Join a game</button>
|
||||
<button id="lb-door-create" class="lb-door" type="button">Create a game</button>
|
||||
</div>
|
||||
|
||||
<section id="lb-join-panel">
|
||||
<h2>Join a game</h2>
|
||||
<p class="ng-note">Ask whoever created the game for its code. You will see the whole rule set
|
||||
before you take a seat.</p>
|
||||
<p class="ng-note"><b>Your seat lives in this browser.</b> Rejoining uses a token kept here, so a different browser or device — or clearing this site's data — cannot take your seat back. If that happens, whoever runs the server can issue you a single-use recovery link.</p>
|
||||
<label class="ng-num"><span>Game code</span><input id="lb-code" type="text" autocomplete="off" placeholder="RAIL-1234"></label>
|
||||
<button id="lb-look" type="button">Look up game</button>
|
||||
<p class="lb-error" id="lb-join-err" role="alert"></p>
|
||||
|
||||
<!-- Filled by `/api/lobby/preview` — the config, who is seated, and nothing else. The SEED is
|
||||
never in that response: it decides every shuffle in the game. -->
|
||||
<div id="lb-preview" hidden>
|
||||
<h3>Before you sit down</h3>
|
||||
<div class="lb-preview-head">
|
||||
<div class="lb-invite-code" id="lb-preview-code"></div>
|
||||
<div id="lb-preview-type" class="lb-type"></div>
|
||||
<div id="lb-preview-who" class="dim"></div>
|
||||
</div>
|
||||
<div id="lb-preview-rules" class="rules-list"></div>
|
||||
<button id="lb-join" type="button">Join this game</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="lb-create-panel" hidden>
|
||||
<h2>Create a new game</h2>
|
||||
<p class="ng-note">You become the host — you choose the game type and, once everyone is seated, start the game. 2 to 4 players.</p>
|
||||
<p class="ng-note"><b>Your seat lives in this browser.</b> Rejoining uses a token kept here, so a different browser or device — or clearing this site's data — cannot take your seat back. If that happens, whoever runs the server can issue you a single-use recovery link.</p>
|
||||
|
||||
<!-- TWO COLUMNS WHERE THERE IS ROOM. One 640px-wide column made this form a very long scroll
|
||||
for what is really two short lists: what game this is, and what its rules are. -->
|
||||
<div class="lb-two">
|
||||
<div class="lb-col">
|
||||
|
||||
<!-- THE PARAMETERS, above the type. Changing one of these does NOT make the game Custom: the
|
||||
types are formulas in the table size and the length, so the Revenue floor re-derives and
|
||||
"Co-op, 3 players, 8 days" is still Co-op. -->
|
||||
<div class="lb-params">
|
||||
<label class="ng-num"><span>Seed</span>
|
||||
<input id="lb-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
|
||||
<label class="ng-num"><span>Players at the table</span>
|
||||
<select id="lb-players">
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4" selected>4</option>
|
||||
</select></label>
|
||||
<label class="ng-num"><span>Days</span>
|
||||
<input id="lb-days" type="number" min="1" max="20" step="1" value="5"></label>
|
||||
</div>
|
||||
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
|
||||
can be shared, compared or replayed. Leave it blank for a random one.</p>
|
||||
<!-- WITH THE TABLE SIZE IT IS ABOUT, not below the rules block — reported by Jesse, who found
|
||||
it separated from the control it explains by fifteen settings. -->
|
||||
<p class="ng-note">Every chair must be filled before the game can start. For solitaire,
|
||||
there’s only one player. For multiplayer, that must be filled by a person or a
|
||||
bot. The number of players cannot be changed once the game is created.</p>
|
||||
</div>
|
||||
|
||||
<div class="lb-col">
|
||||
<h3>Game type (multi-player)</h3>
|
||||
<div class="set-row" id="lb-type-row">
|
||||
<label class="ng-radio"><input type="radio" name="lb-type" value="solitaire">
|
||||
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="lb-type" value="coop" checked>
|
||||
<span><b>Co-op</b><br><span class="dim">Everyone’s Revenue is one table score. You win together or lose together.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="lb-type" value="competitive">
|
||||
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins — unless the table misses its combined minimum, and then everyone loses.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="lb-type" value="cutthroat">
|
||||
<span><b>Cutthroat</b><br><span class="dim">Highest Revenue wins, and nothing is shared — the only way everyone loses is three collisions in one Day.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="lb-type" value="custom">
|
||||
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- FULL WIDTH WHEN IT OPENS. Reported by Jesse: opened inside the right-hand column it made a
|
||||
very long scroll with the left column standing empty beside it. It is a grid child of its
|
||||
own now, spanning both, and its five groups flow into as many columns as fit. -->
|
||||
<details id="lb-settings" class="lb-span">
|
||||
<summary>Game settings</summary>
|
||||
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
|
||||
Changing any of them selects <b>Custom</b>, which keeps the scoring of the type you
|
||||
started from; changing the game type resets all of them back to it. They are fixed when the
|
||||
game is created and cannot be changed once it starts.</p>
|
||||
<div class="set-groups">
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Starting hand</h3>
|
||||
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three
|
||||
either way — deal six and the first turn is spent choosing which of them to keep.</p>
|
||||
<div class="set-row" id="lb-hand-row">
|
||||
<label class="ng-radio"><input type="radio" name="lb-hand" value="threeRandom">
|
||||
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="lb-hand" value="sixRandom" checked>
|
||||
<span><b>Six random cards</b><br><span class="dim">Twice the choice, still no guaranteed track — the first turn is a discard.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="lb-hand" value="threeTrackThreeOther">
|
||||
<span><b>Three random track and three random non-track cards</b><br><span class="dim">Dealt from two piles, so the district you can build is dealt rather than waited for.</span></span></label>
|
||||
<span class="set-hint" id="lb-hand-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Where an Extra may start</h3>
|
||||
<p class="ng-note">The player who plays an Extra Train card chooses where its Crew Tray goes,
|
||||
and the place decides which way it runs — a Division Point sends it away from itself; in the
|
||||
middle of the railroad the player picks east or west. The Division Points and the Interchange
|
||||
belong to nobody and are always available. Starting one inside a district is the part that
|
||||
favours a seat, so it is set here. An Office must be a Control Point whatever this says: a
|
||||
Whistle Post never qualifies.</p>
|
||||
<div class="set-row" id="lb-extra-row">
|
||||
<label class="ng-radio"><input type="radio" name="lb-extra" value="divisionPointsOnly">
|
||||
<span><b>Division Points and the Interchange only</b><br><span class="dim">The strictest reading. Every Extra begins on shared ground.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="lb-extra" value="ownOffice">
|
||||
<span><b>Also the playing player’s own Control Point</b><br><span class="dim">You may start one at home, but not in somebody else’s district.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="lb-extra" value="anyOffice">
|
||||
<span><b>Also any player’s Control Point</b><br><span class="dim">The most permissive — an Extra may be planted in another player’s district.</span></span></label>
|
||||
<span class="set-hint" id="lb-extra-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Revenue</h3>
|
||||
<p class="ng-note">What each piece of work pays, 0 to 5. A coach pays when it is boarded and
|
||||
again when it is detrained; a load pays when it is made up and again when it is broken. Zero
|
||||
switches an economy off so the others can be read.</p>
|
||||
<div class="set-row" id="lb-passenger-row">
|
||||
<label class="ng-num"><span>Passenger revenue per coach</span>
|
||||
<input id="lb-passenger" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<span class="set-hint" id="lb-passenger-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="lb-freight-row">
|
||||
<label class="ng-num"><span>Freight revenue per load</span>
|
||||
<input id="lb-freight" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<span class="set-hint" id="lb-freight-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="lb-transit-row">
|
||||
<label class="ng-num"><span>Train revenue per transit</span>
|
||||
<input id="lb-transit" type="number" min="0" max="5" step="1" value="0"></label>
|
||||
<span class="set-hint" id="lb-transit-hint"></span>
|
||||
</div>
|
||||
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the
|
||||
Division — the one thing nobody has to work for.</p>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Victory conditions</h3>
|
||||
<p class="ng-note">The ways this game can end badly. Each one is switched on or off in its own
|
||||
right; how long the game runs is set above, with the table size.</p>
|
||||
<div class="set-row" id="lb-minrev-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="lb-minrev-on" checked>
|
||||
<span>Everyone loses if combined Revenue at the end is under</span>
|
||||
<input id="lb-minrev" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="lb-minrev-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="lb-colday-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="lb-colday-on" checked>
|
||||
<span>The game ends immediately and results in a loss if collisions in one Day reach</span>
|
||||
<input id="lb-colday" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="lb-colday-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="lb-coltotal-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="lb-coltotal-on" checked>
|
||||
<span>The game ends immediately and results in a loss after this many collisions in the whole game</span>
|
||||
<input id="lb-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="lb-coltotal-hint"></span>
|
||||
</div>
|
||||
<p class="ng-note">The opponent-directed cards — Derail, Watertower, Hobo Jungle and the
|
||||
nineteen others, along with the seven that answer them — are not implemented yet, so no game
|
||||
type deals them whatever else is set here.</p>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Optional rules</h3>
|
||||
<p class="ng-note">Each one changes how the game plays.</p>
|
||||
<div class="set-row" id="lb-visibility-row">
|
||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||
night Stages (1–3 and 11–12)</span>
|
||||
<input id="lb-visibility" type="checkbox"></label>
|
||||
<span class="set-hint" id="lb-visibility-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="lb-rotation-row">
|
||||
<label class="ng-num"><span>Employee Rotation — at the end of each Day everyone moves one
|
||||
chair left and takes over the next station up the line. Your Revenue and the Fedora go with
|
||||
you; the district stays where it is</span>
|
||||
<input id="lb-rotation" type="checkbox"></label>
|
||||
<span class="set-hint" id="lb-rotation-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="lb-toolbox-row">
|
||||
<label class="ng-num"><span>Emergency Toolbox — everyone starts holding a Red Flag, so a hand
|
||||
of four; play or discard down to three on the first turn</span>
|
||||
<input id="lb-toolbox" type="checkbox"></label>
|
||||
<span class="set-hint" id="lb-toolbox-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="lb-whistlestart-row">
|
||||
<label class="ng-num"><span>Players start with Whistle Posts, not Depots — the harder game.
|
||||
A Whistle Post has one A/D track and is not a Passenger Facility, so no passenger earns
|
||||
anything until somebody draws and plays a Depot upgrade, and a second train arriving is a
|
||||
collision. Leave this off and every district opens as a Depot, with two A/D tracks and
|
||||
passengers working from Stage 1; the Depot upgrade cards are then left out of the deck</span>
|
||||
<input id="lb-whistlestart" type="checkbox"></label>
|
||||
<span class="set-hint" id="lb-whistlestart-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="lb-tossloco-row">
|
||||
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
|
||||
Department slot, where a rival may pick it up. Turn this off and a train card can only ever
|
||||
be played onto the timetable. An Extra is never discardable either way</span>
|
||||
<input id="lb-tossloco" type="checkbox"></label>
|
||||
<span class="set-hint" id="lb-tossloco-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="lb-span">
|
||||
<button id="lb-create" type="button">Create new game</button>
|
||||
<p class="lb-error" id="lb-create-err" role="alert"></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- Shown once created or joined, in place of the choice above, until the host starts the game. -->
|
||||
<section id="lb-seating-section" hidden>
|
||||
<h2>Seating</h2>
|
||||
<div class="lb-invite">
|
||||
<div>
|
||||
<div class="lb-invite-label">Send this to your players</div>
|
||||
<div class="lb-invite-code" id="lb-gamecode"></div>
|
||||
</div>
|
||||
<button id="lb-copy" class="ghost" type="button">Copy code</button>
|
||||
<button id="lb-copylink" class="ghost" type="button">Copy invite link</button>
|
||||
<span class="ng-note" id="lb-copied"></span>
|
||||
</div>
|
||||
<p class="ng-note">They enter the code under <b>Join a game</b>, along with the same join secret
|
||||
you used — the link carries the code, never the secret.</p>
|
||||
<p class="ng-note">The host may fill an empty seat with a bot or clear an occupied one, and
|
||||
starts the game once every seat is either a player or a bot. <b>These chairs are not the
|
||||
running order</b> — who sits where along the Division is decided by a D12 roll when the game
|
||||
starts, and the map shows the result.</p>
|
||||
<div id="lb-seats"></div>
|
||||
<p class="lb-warn" id="lb-stream-note" role="status" hidden></p>
|
||||
<button id="lb-start" disabled>Start game</button>
|
||||
<button id="lb-leave" class="ghost" type="button">Leave</button>
|
||||
<p class="lb-error" id="lb-start-note" role="alert"></p>
|
||||
|
||||
<h3>The game you are in</h3>
|
||||
<div id="lb-seating-type" class="lb-type"></div>
|
||||
<div id="lb-seating-rules" class="rules-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ===================================================================
|
||||
SOLITAIRE SETUP — the same question multiplayer already asks first,
|
||||
now asked here too (Jesse, 2026-08-29): a genuinely fresh visit deals
|
||||
nothing until this screen's own Deal button is pressed. A saved game,
|
||||
an explicit `?seed=`, or a URL already carrying a Deal's answers (any
|
||||
of the shared block's fields — `hand` names the one always written)
|
||||
all skip straight past this screen, exactly as `?lobby` already skips
|
||||
past it into the lobby: those are not "no plan yet", they are a
|
||||
choice already made, elsewhere.
|
||||
|
||||
THE SAME BLOCK THE DIALOG AND THE LOBBY USE, same shared module
|
||||
(`settings-form.ts`), same order — three screens are one design now
|
||||
instead of two. Only Solitaire can be dealt from here, so the other
|
||||
four types are shown exactly as the in-game dialog shows them: present,
|
||||
disabled, with a note pointing at the Multiplayer door instead.
|
||||
==================================================================== -->
|
||||
<div id="solitairesetup" hidden>
|
||||
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Solitaire</span></header>
|
||||
|
||||
<section>
|
||||
<h2>New solitaire game</h2>
|
||||
<p class="ng-note">One railroad, one player, five full days by default — everything below is
|
||||
yours to change before you deal. Clearing the Revenue floor wins; falling short loses.</p>
|
||||
|
||||
<!-- THE SAME THREE PARAMETERS THE LOBBY ASKS, in the same order, with the same note under them
|
||||
(Jesse, 2026-08-30: "everything beneath that should be the same"). The table size is here
|
||||
rather than hidden because it is one of the three things that describe a game, and leaving
|
||||
it out made this screen a different form that happened to share a rules block. It is LOCKED
|
||||
at one: a `LocalSession` runs the engine in this browser and a table needs a server, which
|
||||
is the same reason the four multiplayer game types are shown disabled below. -->
|
||||
<div class="lb-params">
|
||||
<label class="ng-num"><span>Seed</span>
|
||||
<input id="ss-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
|
||||
<label class="ng-num"><span>Players at the table</span>
|
||||
<select id="ss-players" disabled>
|
||||
<option value="1" selected>1</option>
|
||||
</select></label>
|
||||
<label class="ng-num"><span>Days</span>
|
||||
<input id="ss-days" type="number" min="1" max="20" step="1" value="5"></label>
|
||||
</div>
|
||||
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
|
||||
can be shared, compared or replayed. Leave it blank for a random one.</p>
|
||||
<p class="ng-note">Every chair must be filled before the game can start. For solitaire,
|
||||
there’s only one player. For multiplayer, that must be filled by a person or a
|
||||
bot. The number of players cannot be changed once the game is created.</p>
|
||||
|
||||
<h3>Game type (solitaire)</h3>
|
||||
<div class="set-row" id="ss-type-row">
|
||||
<label class="ng-radio"><input type="radio" name="ss-type" value="solitaire" checked>
|
||||
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-type" value="coop">
|
||||
<span><b>Co-op</b><br><span class="dim">Everyone’s Revenue is one table score. You win together or lose together.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-type" value="competitive">
|
||||
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins — unless the table misses its combined minimum, and then everyone loses.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-type" value="cutthroat">
|
||||
<span><b>Cutthroat</b><br><span class="dim">Highest Revenue wins, and nothing is shared — the only way everyone loses is three collisions in one Day.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-type" value="custom">
|
||||
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<details id="ss-settings" open>
|
||||
<summary>Game settings</summary>
|
||||
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
|
||||
Changing any of them selects <b>Custom</b>, which keeps the scoring of the type you
|
||||
started from; changing the game type resets all of them back to it. They are fixed when the
|
||||
game is created and cannot be changed once it starts.</p>
|
||||
<div class="set-groups">
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Starting hand</h3>
|
||||
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three
|
||||
either way — deal six and the first turn is spent choosing which of them to keep.</p>
|
||||
<div class="set-row" id="ss-hand-row">
|
||||
<label class="ng-radio"><input type="radio" name="ss-hand" value="threeRandom">
|
||||
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-hand" value="sixRandom" checked>
|
||||
<span><b>Six random cards</b><br><span class="dim">Twice the choice, still no guaranteed track — the first turn is a discard.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-hand" value="threeTrackThreeOther">
|
||||
<span><b>Three random track and three random non-track cards</b><br><span class="dim">Dealt from two piles, so the district you can build is dealt rather than waited for.</span></span></label>
|
||||
<span class="set-hint" id="ss-hand-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Where an Extra may start</h3>
|
||||
<p class="ng-note">The player who plays an Extra Train card chooses where its Crew Tray goes,
|
||||
and the place decides which way it runs — a Division Point sends it away from itself; in the
|
||||
middle of the railroad the player picks east or west. The Division Points and the Interchange
|
||||
belong to nobody and are always available. Starting one inside a district is the part that
|
||||
favours a seat, so it is set here. An Office must be a Control Point whatever this says: a
|
||||
Whistle Post never qualifies.</p>
|
||||
<div class="set-row" id="ss-extra-row">
|
||||
<label class="ng-radio"><input type="radio" name="ss-extra" value="divisionPointsOnly">
|
||||
<span><b>Division Points and the Interchange only</b><br><span class="dim">The strictest reading. Every Extra begins on shared ground.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-extra" value="ownOffice">
|
||||
<span><b>Also the playing player’s own Control Point</b><br><span class="dim">You may start one at home, but not in somebody else’s district.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-extra" value="anyOffice">
|
||||
<span><b>Also any player’s Control Point</b><br><span class="dim">The most permissive — an Extra may be planted in another player’s district.</span></span></label>
|
||||
<span class="set-hint" id="ss-extra-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Revenue</h3>
|
||||
<p class="ng-note">What each piece of work pays, 0 to 5. A coach pays when it is boarded and
|
||||
again when it is detrained; a load pays when it is made up and again when it is broken. Zero
|
||||
switches an economy off so the others can be read.</p>
|
||||
<div class="set-row" id="ss-passenger-row">
|
||||
<label class="ng-num"><span>Passenger revenue per coach</span>
|
||||
<input id="ss-passenger" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<span class="set-hint" id="ss-passenger-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-freight-row">
|
||||
<label class="ng-num"><span>Freight revenue per load</span>
|
||||
<input id="ss-freight" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<span class="set-hint" id="ss-freight-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-transit-row">
|
||||
<label class="ng-num"><span>Train revenue per transit</span>
|
||||
<input id="ss-transit" type="number" min="0" max="5" step="1" value="0"></label>
|
||||
<span class="set-hint" id="ss-transit-hint"></span>
|
||||
</div>
|
||||
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the
|
||||
Division — the one thing nobody has to work for.</p>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Victory conditions</h3>
|
||||
<p class="ng-note">The ways this game can end badly. Each one is switched on or off in its own
|
||||
right; how long the game runs is set above, with the table size.</p>
|
||||
<div class="set-row" id="ss-minrev-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ss-minrev-on" checked>
|
||||
<span>You lose if Revenue at the end is under</span>
|
||||
<input id="ss-minrev" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ss-minrev-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-colday-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ss-colday-on" checked>
|
||||
<span>The game ends immediately and results in a loss if collisions in one Day reach</span>
|
||||
<input id="ss-colday" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ss-colday-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-coltotal-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ss-coltotal-on" checked>
|
||||
<span>The game ends immediately and results in a loss after this many collisions in the whole game</span>
|
||||
<input id="ss-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ss-coltotal-hint"></span>
|
||||
</div>
|
||||
<p class="ng-note">The opponent-directed cards — Derail, Watertower, Hobo Jungle and the
|
||||
nineteen others, along with the seven that answer them — are not implemented yet, so no game
|
||||
type deals them whatever else is set here.</p>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Optional rules</h3>
|
||||
<p class="ng-note">Each one changes how the game plays.</p>
|
||||
<div class="set-row" id="ss-visibility-row">
|
||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||
night Stages (1–3 and 11–12)</span>
|
||||
<input id="ss-visibility" type="checkbox"></label>
|
||||
<span class="set-hint" id="ss-visibility-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-rotation-row">
|
||||
<label class="ng-num"><span>Employee Rotation — not applicable for solitaire</span>
|
||||
<input id="ss-rotation" type="checkbox" disabled></label>
|
||||
<span class="set-hint" id="ss-rotation-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-toolbox-row">
|
||||
<label class="ng-num"><span>Emergency Toolbox — start holding a Red Flag, so a hand of four;
|
||||
play or discard down to three on the first turn</span>
|
||||
<input id="ss-toolbox" type="checkbox"></label>
|
||||
<span class="set-hint" id="ss-toolbox-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-whistlestart-row">
|
||||
<label class="ng-num"><span>Players start with Whistle Posts, not Depots — the harder game.
|
||||
A Whistle Post has one A/D track and is not a Passenger Facility, so no passenger earns
|
||||
anything until somebody draws and plays a Depot upgrade, and a second train arriving is a
|
||||
collision. Leave this off and every district opens as a Depot, with two A/D tracks and
|
||||
passengers working from Stage 1; the Depot upgrade cards are then left out of the deck</span>
|
||||
<input id="ss-whistlestart" type="checkbox"></label>
|
||||
<span class="set-hint" id="ss-whistlestart-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-tossloco-row">
|
||||
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
|
||||
Department slot. Turn this off and a train card can only ever be played onto the timetable.
|
||||
An Extra is never discardable either way</span>
|
||||
<input id="ss-tossloco" type="checkbox"></label>
|
||||
<span class="set-hint" id="ss-tossloco-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Shown only when `station-master.save.v1` holds a game. Dealing from this screen CLEARS that
|
||||
save (`commitNewGame` calls `clearSave`), so without a way back the door would be a way to
|
||||
lose a game in progress — and the door is reached by clicking "Play solitaire", which nobody
|
||||
reads as "discard what I was playing". -->
|
||||
<p id="ss-saved-note" hidden><b>You have a solitaire game in progress.</b> Creating a new game
|
||||
replaces it permanently — there is no undo. Choose <b>Continue saved game</b> to pick it
|
||||
up where you left off.</p>
|
||||
|
||||
<menu class="ng-buttons">
|
||||
<button id="ss-resume" type="button" hidden>Continue saved game</button>
|
||||
<button id="ss-deal" type="button">Create new game</button>
|
||||
</menu>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="gameui" hidden>
|
||||
<div class="topbar">
|
||||
<header>
|
||||
<b><a href="./index.html" class="home">Station Master</a></b>
|
||||
@@ -167,12 +908,49 @@ ul.blocked li{padding:2px 0}
|
||||
<!-- The objective, and nothing else: score, target, Days left. The "behind the pace" chip and the
|
||||
engine's guess at what your score ought to be were noise on the one line that must not wrap. -->
|
||||
<span id="objective" class="pace">—</span>
|
||||
<span class="dim">seed <span id="seed">—</span></span>
|
||||
<!-- THE COLLISION COUNTS, WHICH ARE A LIVE SCORE AND NOT A SETTING (TODO #28). The Frame has
|
||||
carried `collisionsToday` and `collisionsTotal` since v0.7.0 and nothing on the board drew
|
||||
them, so the one victory condition that can end a game early was invisible while it ran.
|
||||
Empty and collapsed when both limits are 0 — a game that cannot end this way should not be
|
||||
counting towards it. The limits themselves live in the This Game card; this is progress. -->
|
||||
<span class="dim" id="collisions" title=""></span>
|
||||
<!-- THE GAME CODE SURVIVES THE LOBBY. It used to end at `Lobby.Start` — the code was never carried
|
||||
into `LobbyReady` — so a seated player could not say which game they were in, could not match
|
||||
it against the administrator's Games in Progress list, and could not pass it to a latecomer.
|
||||
Empty (and collapsed) in solitaire, where there is no code. -->
|
||||
<span class="dim" id="gamecode" title="The code this game was created under. The administrator's Games in Progress list uses it, and it is how you say which game you mean."></span>
|
||||
<!-- The seed, the seat, the game type and every house rule MOVED TO THE THIS GAME CARD, 2026-08-30
|
||||
(TODO #28). Jesse: "we can give complete information about all the game options and not take
|
||||
up valuable real estate at the top of the screen… it is not something that they're likely to
|
||||
need all the time." What stays here is what is glanced at every turn — Revenue, the objective,
|
||||
the collision counts — plus the game code, which is identity rather than settings: it is how
|
||||
you say WHICH game you are in, out loud, without opening anything. -->
|
||||
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
|
||||
<!-- BOARD ZOOM. Applies to the Division map and the Office Area grid alike — both already scroll
|
||||
horizontally (`#division`, `#grid`) when they run wide, so this only ever needs to resize the
|
||||
rendered SVG's own pixel dimensions; the existing scrollbar keeps doing the panning. -->
|
||||
<span class="zoom" title="Zoom the Division map and your Office Area. Both already scroll — this only changes their size.">
|
||||
<button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
|
||||
</span>
|
||||
<!-- HOW FAST OTHER PLAYERS' TURNS PLAY BACK — v0.8.0.3, TODO #13.
|
||||
A CONTROL RATHER THAN ONLY A URL PARAMETER. `?pace=` shipped first and is unreachable through
|
||||
the front door: `index.html`'s two doors are `play.html?lobby` and `play.html?solitaire`, so
|
||||
arriving from the splash REPLACES the query string and any pace with it. Jesse played a whole
|
||||
game believing he was at 7x when he was at 1x. -->
|
||||
<span class="zoom" title="How long another player's or a bot's move is held on screen before the next one. Your own moves are never delayed — only theirs. Starts at 1×, which holds a switching move for one second; the slowest setting, 20×, holds it for twenty. Off draws every move at once.">
|
||||
<button id="paceslower" aria-label="Slower playback">−</button><span id="pacelabel">1×</span><button id="pacefaster" aria-label="Faster playback">+</button>
|
||||
</span>
|
||||
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
||||
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
||||
<button id="newgame" title="Deal a fresh game. You will be asked for a seed — leave it blank for a random one. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
|
||||
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
|
||||
<button id="newgame" title="Set up a fresh game — the seed, the table, the opening hand and what the three economies pay. Opens the same screen a new solitaire game starts from, with your current rules filled in; your game in progress is kept until you press Deal, and Continue puts it straight back.">New game</button>
|
||||
<button id="multiplayer" title="Create or join a Competitive or Co-op game on this server, with other players.">Multiplayer</button>
|
||||
<!-- LEAVING A RUNNING GAME. Reported by Jesse 2026-08-23: "if I'm a player in the middle of the
|
||||
game and I need to leave, how do I leave the game, clear the token from my browser so I can
|
||||
play a different game later?" There was no way at all — the page rejoined the same game on
|
||||
every load and nothing let go of it. This keeps your seat — the table waits for you — and
|
||||
your token, and puts you back at the lobby, which lists every game this browser is in. -->
|
||||
<button id="leavegame" hidden title="Go back to the lobby. Your seat is kept and the game waits for you — the lobby lists it under Games you are in, so you can come back or hand the browser to a different game.">Leave game</button>
|
||||
<a class="home" href="./replays.html" style="font-size:12px" data-tip="Watch a recorded game, or open a save file. A save replays under the rules of the build that opens it, so one made on an earlier build may stop part-way — your file is never altered.">replays</a>
|
||||
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
||||
</header>
|
||||
|
||||
@@ -185,14 +963,41 @@ ul.blocked li{padding:2px 0}
|
||||
<!-- Announces a phase CHANGE. The turn chart says where you are; this says that it moved, which is
|
||||
what you miss when the automatic phases run between two clicks. -->
|
||||
<div id="phasenote"></div>
|
||||
<div id="announce"></div>
|
||||
<!-- `lobby-and-sessions.md` §5 — a disconnect keeps the seat and the game simply waits; this is
|
||||
what says WHY, instead of the table watching nothing happen with no explanation. Empty and
|
||||
collapsed whenever everyone connected is still connected — a `LocalSession` never fills it. -->
|
||||
<div id="presence"></div>
|
||||
|
||||
<!-- WHAT YOU ARE WATCHING, and how far behind the board is — v0.8.0, TODO #13/#15.
|
||||
One row rather than three additions: the countdown, the caption naming the action being shown,
|
||||
and Skip. Empty and collapsed whenever the board is level with the game, which in solitaire is
|
||||
almost always. -->
|
||||
<div id="watching" hidden>
|
||||
<!-- SKIP FIRST, on the left. It sat on the far right and a player's eye is on the countdown, not at
|
||||
the other end of the row — Jesse, 2026-09-09: "the skip button should be on the far left, in
|
||||
front of where it says [the count], so it's always close to where people are looking." -->
|
||||
<button id="watching-skip" class="ghost" type="button" title="Stop animating and jump the board to where the game actually is. Nothing is lost — every line is already in the History panel.">Skip</button>
|
||||
<!-- PAUSE BESIDE SKIP, not instead of it: they are opposite answers to "that went past too fast".
|
||||
Skip gives up the animation to reach the game; Pause holds the board on the step being shown
|
||||
for as long as you want to look at it, and gives the step back the dwell it still had. -->
|
||||
<button id="watching-pause" class="ghost" type="button" title="Hold the board on the move being shown. Nothing is lost and nothing is hurried — press again to carry on from the same step.">Pause</button>
|
||||
<span id="watching-behind" class="wbehind"></span>
|
||||
<span id="watching-what"></span>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div>
|
||||
<section><h2>The Division — west to east</h2><div id="division"></div></section>
|
||||
<section><h2>The Division — west to east</h2><div id="division"></div>
|
||||
<p class="ng-note" id="seating-chain"></p></section>
|
||||
<section id="district">
|
||||
<h2>Your Office Area
|
||||
<h2><span id="districtwho">Your Office Area</span>
|
||||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||
<button id="districttoggle" class="ghost" title="Auto-hide keeps the district open during Local Operations and Cargo — the phases that change it — and folds it otherwise. Click to pin it open or hidden instead.">auto-hide: on</button>
|
||||
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
|
||||
<!-- LOOK AT ANOTHER PLAYER'S OFFICE AREA. Filled by `renderDistrict` with one button per
|
||||
opponent, and collapsed at a table of one. The look is read-only and lasts a single
|
||||
render on purpose — see `peekPlayer` in `main.ts`. -->
|
||||
<span id="districtpeek" class="seg" role="group" aria-label="Look at another player's Office Area"></span>
|
||||
</h2>
|
||||
<div id="districtsummary" class="dim"></div>
|
||||
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
||||
@@ -236,9 +1041,80 @@ ul.blocked li{padding:2px 0}
|
||||
</section>
|
||||
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
||||
<section><h2>Facilities</h2><div id="facs"></div></section>
|
||||
<!-- THIS GAME — the settings it was dealt under, off the top line and out of the way (TODO #28).
|
||||
Last in the column and folded by default because it is looked up, not watched: "Oh wait,
|
||||
what did we set that to?" The body is `rulesListHtml`, the same renderer the join preview
|
||||
and the seating screen draw, so what you agreed to in the lobby and what you can read
|
||||
mid-game cannot drift apart. -->
|
||||
<section id="gamecard" class="folded"><h2>This Game
|
||||
<button id="gamecardtoggle" class="ghost" type="button" title="The seed, the seat, the game type and every rule this game was dealt under.">show</button>
|
||||
</h2>
|
||||
<div id="gamecardsummary" class="dim"></div>
|
||||
<div id="gamecardbody"></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- THE IN-GAME "NEW GAME" BUTTON GOES TO THE SOLITAIRE SETUP SCREEN — there is no second
|
||||
dialog any more (Jesse, 2026-08-30: "it should not go to a separate screen. We should reuse the
|
||||
Solitaire New Game Screen… in general we should reuse what we already have").
|
||||
|
||||
`#newgamedlg` was a third copy of the same questions, and the one that drifted: it kept the
|
||||
multiplayer wording ("Everyone loses if COMBINED Revenue…") on a screen only ever shown to a
|
||||
solitaire player, and explained Employee Rotation in full beside a control it had disabled.
|
||||
Deleting it removes the drift rather than re-wording it.
|
||||
|
||||
Nothing is lost by navigating away mid-game: `render()` calls `save()` on every frame, so the
|
||||
game in progress is always on disk, and the setup screen offers "Continue saved game"
|
||||
to come straight back to it. -->
|
||||
</div><!-- /gameui -->
|
||||
|
||||
<!-- THE DAY ROLLING OVER (Gitea#10). A Day turns inside the automatic phases, so it happens
|
||||
between one click and the next; the phase banner and the announcement flash both fade before
|
||||
someone reading the board notices them. A modal stops and waits, which is the whole request:
|
||||
"hard to keep track of time". Filled by `dayEndHtml` and opened from `render()`. -->
|
||||
<dialog id="dayenddlg" aria-labelledby="de-title">
|
||||
<form method="dialog">
|
||||
<div id="dayendbody"></div>
|
||||
<menu class="ng-buttons">
|
||||
<button value="ok" id="de-ok" type="submit">Carry on</button>
|
||||
</menu>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- THE END-OF-GAME RESULTS (Gitea#16). Filled by `resultsHtml` and opened from `renderEnding`,
|
||||
which puts it up once per ending unasked and leaves a button to reopen it. Reopenable matters:
|
||||
Gitea#11 lets a table play past the end, and continuing must not cost you the results screen. -->
|
||||
<!-- THE EXTENSION QUESTION IS ASKED HERE, not only behind this dialog (Gitea#11 + #16).
|
||||
This opens ITSELF at every ending, and it is modal — so `renderEnding`'s "play one more Day"
|
||||
buttons, which it writes into `#actions`, sit underneath it. A player saw a results screen whose
|
||||
only control was Close and reasonably concluded the game was over: reported by Jesse
|
||||
2026-08-30, "Solitaire game ended. I did not have an option to extend the game by a day."
|
||||
Gitea#11 was verified over the HTTP API, which renders no dialog, so the browser never was.
|
||||
The two extension buttons are hidden unless the game is actually awaiting a vote. -->
|
||||
<dialog id="resultsdlg" aria-labelledby="rs-title">
|
||||
<form method="dialog">
|
||||
<div id="resultsbody"></div>
|
||||
<menu class="ng-buttons">
|
||||
<button value="extend-yes" id="rs-extend-yes" type="submit" hidden>Play One More Day</button>
|
||||
<button value="extend-no" id="rs-extend-no" type="submit" hidden>End the Game Here</button>
|
||||
<button value="ok" id="rs-ok" type="submit">Close</button>
|
||||
</menu>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- THE HANDOFF, between `Lobby.Start` and the first Frame.
|
||||
`beginRemote` used to write "… connecting to the game" into `#presence` — the DISCONNECT banner,
|
||||
whose job is `⚠ waiting on Alice`. It worked only because the first render overwrote it, and it
|
||||
had nothing to say when the first push never came. This is its own state: it opens on the way
|
||||
into a game, holds a deliberate beat so the game visibly begins, and closes on the first Frame. -->
|
||||
<div id="handoff">
|
||||
<div class="hand-inner">
|
||||
<h2 id="handoff-title">Dealing the railroad…</h2>
|
||||
<div id="handoff-note" class="hand-note"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./web/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* THE FOUR GAME TYPES, and what "Custom" means.
|
||||
*
|
||||
* Jesse's design, 2026-08-23. A game type is not a mode and not a ruleset — it is a NAMED SET OF
|
||||
* DEFAULTS that the host may then edit. Editing any of them selects `custom`, which keeps the
|
||||
* values and inherits the scoring of the type it was edited away from; clicking a named type again
|
||||
* resets every rule back to it.
|
||||
*
|
||||
* WHY THIS FILE EXISTS RATHER THAN A CONSTANT IN EACH SCREEN. The lobby (`lobby.ts`) and the
|
||||
* solitaire New Game dialog (`main.ts`) ask the same questions, and they had already drifted apart
|
||||
* before this was written: the dialog had "where an Extra may start" and no optional rules, the
|
||||
* lobby had the optional rules and no Extra rule — so a multiplayer game silently played the most
|
||||
* permissive Extra rule and nobody was ever asked. One description of the defaults, imported by
|
||||
* both, is what stops that happening a third time.
|
||||
*
|
||||
* PARAMETERS ARE NOT SETTINGS. Seed, player count and Day count sit ABOVE the type radios on both
|
||||
* screens and never select `custom`: the presets are formulas in players and days, so "Co-op, 3
|
||||
* players, 8 days" is still Co-op and its Revenue floor re-derives. Everything below the radios is
|
||||
* a rule, and changing one is what makes a game Custom.
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
collectiveRevenueFloor,
|
||||
houseRules,
|
||||
} from '../engine/content.ts';
|
||||
import type { ExtraStartRule, RevenueRules, StartingHand, StartingOffice } from '../engine/content.ts';
|
||||
import type { GameConfig, GameMode } from '../engine/state.ts';
|
||||
|
||||
export type PresetName = 'solitaire' | 'coop' | 'competitive' | 'cutthroat';
|
||||
/** What the radio group answers: one of the four named types, or the state of having edited one. */
|
||||
export type GameType = PresetName | 'custom';
|
||||
|
||||
/**
|
||||
* Every rule a game type sets, flattened to one value per control.
|
||||
*
|
||||
* Flat rather than shaped like `GameConfig` because this is what the FORM compares against: each key
|
||||
* is one field on screen, so "which fields differ from the preset" is a key-by-key comparison rather
|
||||
* than a walk through nested objects. `configFromSettings` puts the shape back.
|
||||
*/
|
||||
export type Settings = {
|
||||
startingHand: StartingHand;
|
||||
extraStart: ExtraStartRule;
|
||||
passengerPerCoach: number;
|
||||
freightPerLoad: number;
|
||||
trainPerTransit: number;
|
||||
/** 0 means the condition is off — the engine's convention (`state.ts`). The form draws a checkbox. */
|
||||
minCombinedRevenue: number;
|
||||
maxCollisionsPerDay: number;
|
||||
maxCollisionsTotal: number;
|
||||
reducedVisibility: boolean;
|
||||
employeeRotation: boolean;
|
||||
emergencyToolbox: boolean;
|
||||
/** §6.2 (Gitea#9) — may a Timetabled train be thrown away? An Extra never may, whatever this says. */
|
||||
discardTimetabled: boolean;
|
||||
/** Ticked = everyone opens on a Whistle Post, the harder game. Unticked = a Depot. */
|
||||
startWhistlePosts: boolean;
|
||||
};
|
||||
|
||||
export const SETTING_KEYS: readonly (keyof Settings)[] = [
|
||||
'startingHand',
|
||||
'extraStart',
|
||||
'passengerPerCoach',
|
||||
'freightPerLoad',
|
||||
'trainPerTransit',
|
||||
'minCombinedRevenue',
|
||||
'maxCollisionsPerDay',
|
||||
'maxCollisionsTotal',
|
||||
'reducedVisibility',
|
||||
'employeeRotation',
|
||||
'emergencyToolbox',
|
||||
'discardTimetabled',
|
||||
'startWhistlePosts',
|
||||
];
|
||||
|
||||
export type Preset = {
|
||||
name: PresetName;
|
||||
label: string;
|
||||
/** One line under the radio, saying what winning and losing mean in this type. */
|
||||
blurb: string;
|
||||
/** The engine mode this type is scored under — what a `custom` game inherits. */
|
||||
scoring: GameMode;
|
||||
/**
|
||||
* Whether the 22 opponent-directed cards would be dealt, once they exist. Not a form control any
|
||||
* more: it is a property of the type (`TODO.md` — the cards are unbuilt, so `buildDeck` holds them
|
||||
* out regardless, and a checkbox that cannot do anything is worse than a sentence saying so).
|
||||
*/
|
||||
pvpCards: boolean;
|
||||
/**
|
||||
* The Revenue floor this type asks for, as a function of the table and the length. Co-op keeps the
|
||||
* engine's own `collectiveRevenueFloor` (3 per player per Day); Competitive asks two thirds of it,
|
||||
* because a table racing each other is not also pulling in one direction; Cutthroat asks nothing.
|
||||
*/
|
||||
revenueFloor: (players: number, days: number) => number;
|
||||
rules: Omit<Settings, 'minCombinedRevenue'>;
|
||||
};
|
||||
|
||||
const NO_OPTIONAL_RULES = {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
/**
|
||||
* ON in every type (Gitea#9). Jesse's ruling is the rule now, and the setting exists so a table
|
||||
* can put Gitea#6's pressure back rather than so a type can choose for them — the reasoning is
|
||||
* about how long a game runs, which is a dial the table already sets for itself.
|
||||
*/
|
||||
discardTimetabled: true,
|
||||
// The default opening is a Depot, so the harder setting is off.
|
||||
startWhistlePosts: false,
|
||||
} as const;
|
||||
|
||||
/** Every type deals six now (Jesse, 2026-08-23) — the hand limit is three, so the first turn is a
|
||||
* discard whichever three you keep. Solitaire moved with the rest so the two screens agree. */
|
||||
const SIX: StartingHand = 'sixRandom';
|
||||
|
||||
export const PRESETS: readonly Preset[] = [
|
||||
{
|
||||
name: 'solitaire',
|
||||
label: 'Solitaire',
|
||||
blurb: 'One railroad, one player. The whole Division is yours to run.',
|
||||
scoring: 'solitaire',
|
||||
pvpCards: false,
|
||||
revenueFloor: (players, days) => collectiveRevenueFloor(players, days),
|
||||
rules: {
|
||||
startingHand: SIX,
|
||||
/**
|
||||
* Nobody else's district exists, so "any Control Point" and "your own" are the same rule —
|
||||
* `apply.ts` only ever rejects `ownOffice` when `start.seat !== seatOf(s, player)`, which
|
||||
* cannot happen at one seat. It said `anyOffice` until 2026-08-30, which was true and read
|
||||
* wrong: a solitaire player has no "any player" to contrast themselves with, so the permissive
|
||||
* label described a permission nobody was being granted. Jesse's call; no gameplay effect.
|
||||
*/
|
||||
extraStart: 'ownOffice',
|
||||
passengerPerCoach: 1,
|
||||
freightPerLoad: 1,
|
||||
trainPerTransit: 0,
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
...NO_OPTIONAL_RULES,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'coop',
|
||||
label: 'Co-op',
|
||||
blurb: 'Everyone’s Revenue is one table score. You win together or lose together.',
|
||||
scoring: 'coop',
|
||||
// Never, even once the cards are built: there is no opponent to point them at when the table is
|
||||
// one side.
|
||||
pvpCards: false,
|
||||
revenueFloor: (players, days) => collectiveRevenueFloor(players, days),
|
||||
rules: {
|
||||
startingHand: SIX,
|
||||
extraStart: 'ownOffice',
|
||||
passengerPerCoach: 1,
|
||||
freightPerLoad: 1,
|
||||
// The one economy that pays every player at once, for work nobody had to do — which is the
|
||||
// co-operative one, so it is the type that switches it on.
|
||||
trainPerTransit: 1,
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
...NO_OPTIONAL_RULES,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'competitive',
|
||||
label: 'Competitive',
|
||||
blurb: 'Highest Revenue wins — unless the table misses its combined minimum, and then everyone loses.',
|
||||
scoring: 'competitive',
|
||||
pvpCards: true,
|
||||
revenueFloor: (players, days) => 2 * players * days,
|
||||
rules: {
|
||||
startingHand: SIX,
|
||||
extraStart: 'ownOffice',
|
||||
passengerPerCoach: 1,
|
||||
freightPerLoad: 1,
|
||||
trainPerTransit: 0,
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
...NO_OPTIONAL_RULES,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'cutthroat',
|
||||
label: 'Cutthroat',
|
||||
blurb: 'Highest Revenue wins, and nothing is shared — the only way everyone loses is three collisions in one Day.',
|
||||
scoring: 'competitive',
|
||||
pvpCards: true,
|
||||
// Off. Cutthroat has no collective obligation of any kind.
|
||||
revenueFloor: () => 0,
|
||||
rules: {
|
||||
startingHand: SIX,
|
||||
// The one type that lets you plant an Extra in somebody else's district.
|
||||
extraStart: 'anyOffice',
|
||||
passengerPerCoach: 1,
|
||||
freightPerLoad: 1,
|
||||
trainPerTransit: 0,
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
// Off, with the per-Day check left standing: a table can bleed collisions all game, but three
|
||||
// in one Day still ends it.
|
||||
maxCollisionsTotal: 0,
|
||||
...NO_OPTIONAL_RULES,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function preset(name: PresetName): Preset {
|
||||
const found = PRESETS.find((p) => p.name === name);
|
||||
if (!found) throw new Error(`no such preset: ${name}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Every rule a named type sets, at the table size and length the form currently shows. */
|
||||
export function presetSettings(name: PresetName, players: number, days: number): Settings {
|
||||
const p = preset(name);
|
||||
return { ...p.rules, minCombinedRevenue: p.revenueFloor(players, days) };
|
||||
}
|
||||
|
||||
/** The settings a config is actually carrying — the other half of every comparison below. */
|
||||
export function settingsOf(config: GameConfig): Settings {
|
||||
const rules = houseRules(config);
|
||||
return {
|
||||
startingHand: rules.startingHand,
|
||||
extraStart: rules.extraStart,
|
||||
passengerPerCoach: rules.revenue.passengerPerCoach,
|
||||
freightPerLoad: rules.revenue.freightPerLoad,
|
||||
trainPerTransit: rules.revenue.trainPerTransit,
|
||||
minCombinedRevenue: config.minCombinedRevenue,
|
||||
maxCollisionsPerDay: config.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: config.maxCollisionsTotal,
|
||||
reducedVisibility: config.optionalRules.reducedVisibility,
|
||||
employeeRotation: config.optionalRules.employeeRotation,
|
||||
emergencyToolbox: config.optionalRules.emergencyToolbox,
|
||||
discardTimetabled: rules.discardTimetabled,
|
||||
startWhistlePosts: rules.startingOffice === 'whistlePost',
|
||||
};
|
||||
}
|
||||
|
||||
/** Which controls differ from a named type — what the form paints amber and counts in its summary. */
|
||||
export function differencesFrom(
|
||||
name: PresetName,
|
||||
settings: Settings,
|
||||
players: number,
|
||||
days: number,
|
||||
): (keyof Settings)[] {
|
||||
const want = presetSettings(name, players, days);
|
||||
return SETTING_KEYS.filter((k) => settings[k] !== want[k]);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH TYPE IS THIS, derived rather than stored.
|
||||
*
|
||||
* Nothing writes a type name into `GameConfig` — a saved game is its numbers, and a name in the save
|
||||
* would be one more thing that can disagree with them. So the screens ask this instead, which means
|
||||
* a hand-tuned game that happens to match Competitive exactly reads as Competitive, and that is the
|
||||
* honest answer: it IS one.
|
||||
*
|
||||
* `mode` is part of the comparison, so a Co-op game whose dials happen to equal Competitive's is
|
||||
* still not Competitive.
|
||||
*/
|
||||
export function presetOf(config: GameConfig, players: number, days: number): GameType {
|
||||
const settings = settingsOf(config);
|
||||
const match = PRESETS.find(
|
||||
(p) => p.scoring === config.mode && differencesFrom(p.name, settings, players, days).length === 0,
|
||||
);
|
||||
return match?.name ?? 'custom';
|
||||
}
|
||||
|
||||
/**
|
||||
* A `Frame`'s copy of the config, as a `GameConfig` the functions above can read.
|
||||
*
|
||||
* The page has a Frame, never a `GameState` — a remote client holds no game — and the Frame carries
|
||||
* every field these comparisons need. Structurally typed rather than importing `Frame` so this
|
||||
* module stays free of `sim/view.ts`.
|
||||
*/
|
||||
export function configFromFrame(f: {
|
||||
mode: GameMode;
|
||||
days: number;
|
||||
minCombinedRevenue: number;
|
||||
maxCollisionsPerDay: number;
|
||||
maxCollisionsTotal: number;
|
||||
optionalRules: GameConfig['optionalRules'];
|
||||
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules; discardTimetabled: boolean; startingOffice?: StartingOffice };
|
||||
}): GameConfig {
|
||||
return {
|
||||
mode: f.mode,
|
||||
days: f.days,
|
||||
minCombinedRevenue: f.minCombinedRevenue,
|
||||
maxCollisionsPerDay: f.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: f.maxCollisionsTotal,
|
||||
// Never shown from a Frame — the cards are unbuilt, and the type carries the answer.
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: f.optionalRules,
|
||||
houseRules: {
|
||||
startingHand: f.houseRules.startingHand,
|
||||
extraStart: f.houseRules.extraStart,
|
||||
// Carried like the rest: this path describes SOMEONE ELSE'S game to a joiner, so a setting
|
||||
// dropped here shows them a rule the table is not playing (§6.2, Gitea#9).
|
||||
discardTimetabled: f.houseRules.discardTimetabled,
|
||||
// Spread rather than assigned: `exactOptionalPropertyTypes` refuses an explicit `undefined`,
|
||||
// and a Frame from a server that predates this setting simply does not carry it.
|
||||
...(f.houseRules.startingOffice ? { startingOffice: f.houseRules.startingOffice } : {}),
|
||||
revenue: f.houseRules.revenue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The named type a config is NEAREST to, for a screen that has to describe someone else's game.
|
||||
*
|
||||
* The create form always knows which type was clicked, so it compares against that. A join preview
|
||||
* and the seating screen do not — they are handed a finished config and nothing else — and "Custom"
|
||||
* on its own tells a player nothing about what they are joining. Comparing against the same-scoring
|
||||
* type with the fewest differences answers the question they actually have: how far from a normal
|
||||
* game is this, and which normal game?
|
||||
*/
|
||||
export function closestPreset(
|
||||
config: GameConfig,
|
||||
players: number,
|
||||
days: number,
|
||||
): { name: PresetName; differing: (keyof Settings)[] } {
|
||||
const settings = settingsOf(config);
|
||||
const candidates = PRESETS.filter((p) => p.scoring === config.mode);
|
||||
const scored = (candidates.length > 0 ? candidates : PRESETS).map((p) => ({
|
||||
name: p.name,
|
||||
differing: differencesFrom(p.name, settings, players, days),
|
||||
}));
|
||||
return scored.reduce((best, c) => (c.differing.length < best.differing.length ? c : best));
|
||||
}
|
||||
|
||||
/**
|
||||
* The label a screen shows for the current state of the form — including what a Custom game is being
|
||||
* scored as, which is the one thing a player cannot see from the dials.
|
||||
*/
|
||||
export function gameTypeLabel(type: GameType, scoring: GameMode): string {
|
||||
if (type !== 'custom') return preset(type).label;
|
||||
const named = PRESETS.find((p) => p.scoring === scoring && p.name !== 'cutthroat');
|
||||
return `Custom — scored as ${named?.label ?? scoring}`;
|
||||
}
|
||||
|
||||
/** Assembles the config a form's answers describe. `players` reaches the caller separately: it sizes
|
||||
* the lobby's seat array rather than being part of the rules. */
|
||||
export function configFromSettings(
|
||||
settings: Settings,
|
||||
scoring: GameMode,
|
||||
days: number,
|
||||
pvpCards: boolean,
|
||||
): GameConfig {
|
||||
return {
|
||||
mode: scoring,
|
||||
days,
|
||||
minCombinedRevenue: settings.minCombinedRevenue,
|
||||
maxCollisionsPerDay: settings.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: settings.maxCollisionsTotal,
|
||||
// Inert until the cards are built (`setup.ts`'s `buildDeck` ANDs it with `cardsImplemented`),
|
||||
// and no longer a control on any screen — it is a property of the game type.
|
||||
pvpCardsAllowed: pvpCards,
|
||||
optionalRules: {
|
||||
reducedVisibility: settings.reducedVisibility,
|
||||
employeeRotation: settings.employeeRotation,
|
||||
emergencyToolbox: settings.emergencyToolbox,
|
||||
},
|
||||
houseRules: {
|
||||
startingHand: settings.startingHand,
|
||||
extraStart: settings.extraStart,
|
||||
discardTimetabled: settings.discardTimetabled,
|
||||
startingOffice: settings.startWhistlePosts ? 'whistlePost' : 'depot',
|
||||
revenue: {
|
||||
passengerPerCoach: settings.passengerPerCoach,
|
||||
freightPerLoad: settings.freightPerLoad,
|
||||
trainPerTransit: settings.trainPerTransit,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The whole config a named type produces, with nothing edited. */
|
||||
export function configFromPreset(name: PresetName, players: number, days: number): GameConfig {
|
||||
const p = preset(name);
|
||||
return configFromSettings(presetSettings(name, players, days), p.scoring, days, p.pvpCards);
|
||||
}
|
||||
@@ -69,6 +69,10 @@ input[type=range]{flex:1;min-width:180px}
|
||||
<p class="dim">A Station Master save is a small <code>.json</code> file — a seed and the list of
|
||||
moves made. That is enough to rebuild the whole game, so a replay can be emailed like a text file.
|
||||
Save one from inside a game with <b>Save replay</b>.</p>
|
||||
<!-- #40 — the one thing about saves a player has to be told, wherever saves are handled. -->
|
||||
<p class="dim">A save replays under the rules of the build that opens it, so one made on an
|
||||
earlier build may stop part-way. The viewer says which move it stopped on, and your file is
|
||||
never altered.</p>
|
||||
<input type="file" id="file" accept=".json,application/json">
|
||||
<div id="perr"></div>
|
||||
</section>
|
||||
|
||||
+13
-4
@@ -26,6 +26,7 @@ import { createGame } from '../engine/setup.ts';
|
||||
import { snapshot } from '../sim/view.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { SOLO_CONFIG } from './game.ts';
|
||||
import { actingPlayer } from '../engine/state.ts';
|
||||
|
||||
type Save = { seed: number; history: Intent[] };
|
||||
type Entry = { file: string; title: string; note?: string; seed?: number };
|
||||
@@ -55,7 +56,12 @@ type Step = {
|
||||
function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
|
||||
const s = createGame({ id: `replay-${save.seed}`, seed: save.seed, config: SOLO_CONFIG, playerNames: ['player'] });
|
||||
const steps: Step[] = [];
|
||||
const ctx = { cardName: (id: string) => cardName(s, id), trainName: (id: string) => trainName(s, id) };
|
||||
const ctx = {
|
||||
cardName: (id: string) => cardName(s, id),
|
||||
trainName: (id: string) => trainName(s, id),
|
||||
// A player index is not a seat index, so the fallback names no number at all — see `game.ts`.
|
||||
playerName: (p: number) => s.players[p]?.name ?? 'another player',
|
||||
};
|
||||
const push = (events: ReturnType<typeof pump>): void => {
|
||||
const lines = events
|
||||
.filter((e) => e.type !== 'actorChanged')
|
||||
@@ -69,7 +75,7 @@ function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
|
||||
push(pump(s));
|
||||
let stoppedEarly = false;
|
||||
for (const intent of save.history) {
|
||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
const actor = actingPlayer(s);
|
||||
if (actor === null || s.status !== 'active') break;
|
||||
const r = applyIntent(s, actor, intent);
|
||||
if (!r.ok) {
|
||||
@@ -117,12 +123,15 @@ function show(i: number): void {
|
||||
// A replay carries the seat names in the frame's own lines rather than a player table, so the
|
||||
// solitaire seat is named directly; a multi-player replay reports the index it has.
|
||||
const actorName = f.actor === null ? null : `Player ${f.actor + 1}`;
|
||||
$('turnchart').innerHTML = turnChartHtml(f, actorName);
|
||||
// A replay of a multi-player game gets the Fedora too — it is the answer to "why is it asking
|
||||
// THEM", which a replay raises exactly as a live game does.
|
||||
const superName = f.players.length > 1 ? `Player ${f.superintendent + 1}` : null;
|
||||
$('turnchart').innerHTML = turnChartHtml(f, actorName, superName);
|
||||
$('vrev').textContent = String(f.revenue);
|
||||
$('vpos').textContent = `${at} / ${steps.length - 1}`;
|
||||
($('vscrub') as HTMLInputElement).value = String(at);
|
||||
$('vdivision').innerHTML = divisionSvg(f.division);
|
||||
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow);
|
||||
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow, [], [], f.limits);
|
||||
|
||||
// Auto-hide: the district is worth its space during the phases that change it.
|
||||
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* 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` (`docs/architecture/multiplayer.md` Phase 2, built) holds 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, PublicFrame } from '../sim/view.ts';
|
||||
import { publicSnapshot } from '../sim/view.ts';
|
||||
import { takeSteps } from '../sim/display-step.ts';
|
||||
import type { DisplayStep } from '../sim/display-step.ts';
|
||||
import type { PlayerIndex } from '../engine/state.ts';
|
||||
import { applyDelta } from '../sim/frame-delta.ts';
|
||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||
import type { Game, Menu, NewGameOptions, Save } from './game.ts';
|
||||
import {
|
||||
actionMenu,
|
||||
configWith,
|
||||
currentActor,
|
||||
fromSave,
|
||||
handPlayable,
|
||||
isOutOfTurn,
|
||||
newGame,
|
||||
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;
|
||||
/** 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;
|
||||
/** A one-line announcement to flash, once. Draining. */
|
||||
takeAnnouncement(): string | null;
|
||||
/** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */
|
||||
justDrawn(): string | null;
|
||||
/**
|
||||
* Which OTHER seats are currently connected, as last reported by the server — always empty for a
|
||||
* `LocalSession` (there is nobody else to track). `lobby-and-sessions.md` §5: a disconnect keeps
|
||||
* the seat and waits; this is what lets the page say why, instead of going quiet with no
|
||||
* explanation. Reflects the last presence push for each seat the server has ever mentioned, not
|
||||
* only the ones currently disconnected — a seat that reconnects updates its own entry rather than
|
||||
* disappearing.
|
||||
*
|
||||
* `seen` is what separates the two absences the page must not conflate: a seat that has connected
|
||||
* at some point and dropped, against one that has never opened the game at all. The server now
|
||||
* reports every other seat on connect (2026-08-23), so this is answerable at the moment a game
|
||||
* starts — which is exactly when "is everyone here?" is the question.
|
||||
*/
|
||||
presence(): { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
||||
/**
|
||||
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. What everyone else did, in order, so it can be
|
||||
* WATCHED rather than discovered.
|
||||
*
|
||||
* On the interface rather than on `LocalSession`, which is the whole point: solitaire drains its
|
||||
* own collector and a remote session reads the same steps off `Push.steps`, so the page animates
|
||||
* one queue and cannot tell which it has. That is what makes TODO #18 (solitaire's phases flying
|
||||
* past) and TODO #13 (multiplayer's invisible turns) the same code path.
|
||||
*
|
||||
* NOT `steps()` — `LocalSession.steps()` already exists and counts submitted intents for the Undo
|
||||
* button. Different thing entirely, hence the longer name.
|
||||
*/
|
||||
takeDisplaySteps(): DisplayStep[];
|
||||
/**
|
||||
* A public frame to start the queue from, once — draining, and non-null only when the queue must
|
||||
* be RESET rather than advanced.
|
||||
*
|
||||
* Steps carry deltas against a chain, so a client with no baseline cannot merge the next one. That
|
||||
* happens on a first connect, on a reconnect, and locally after an undo or a restore — all of
|
||||
* which rebuild from scratch. A reset means "throw away what is queued and draw this".
|
||||
*/
|
||||
takeDisplayReset(): PublicFrame | null;
|
||||
/**
|
||||
* Stop listening, for good.
|
||||
*
|
||||
* Only a remote session has anything to close, and only one caller needs it: a player LEAVING a
|
||||
* running game (2026-08-23). Without it the page went back to the lobby with its `EventSource`
|
||||
* still open, so the server — and therefore every other seat — went on reporting them as present
|
||||
* at a table they had walked away from.
|
||||
*/
|
||||
close?(): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* It takes `NewGameOptions` rather than a whole `GameConfig` because DEALING is the only thing on the
|
||||
* far side of this that the page is allowed to decide. A config carries the mode and the optional
|
||||
* rules — table settings a lobby owns — and handing main.ts a `GameConfig` to build meant importing
|
||||
* the engine's own defaults into the page, which is the boundary `session.test.ts` guards. The seed,
|
||||
* the house rules and the victory-condition dials are what a player picks when they press New game.
|
||||
*/
|
||||
export function createLocalSession(seed: number, options?: NewGameOptions): LocalSession {
|
||||
let game: Game = options ? newGame(seed, configWith(options)) : newGame(seed);
|
||||
/**
|
||||
* The baseline the step queue starts from. Set here, and again whenever the game is REPLACED —
|
||||
* `undo` and `restore` rebuild by replaying history, which (by design) collects no steps, so the
|
||||
* queue has to be told to start over rather than left holding a chain that no longer continues.
|
||||
*/
|
||||
let pendingReset: PublicFrame | null = publicSnapshot(game.state);
|
||||
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),
|
||||
handPlayable: () => handPlayable(game),
|
||||
submit: async (intent: Intent) => {
|
||||
// Seat 0 is the solitaire player, and the extension vote (Gitea#11) is the one intent that
|
||||
// arrives when `currentActor` is null — so it has to name its seat. See `isOutOfTurn`.
|
||||
const ok = submit(game, intent, isOutOfTurn(intent) ? 0 : null);
|
||||
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;
|
||||
},
|
||||
takeAnnouncement: () => {
|
||||
const text = game.announced;
|
||||
game.announced = null;
|
||||
return text;
|
||||
},
|
||||
justDrawn: () => game.justDrawn,
|
||||
presence: () => [],
|
||||
// Solitaire's own steps, from the same collector `submit()` fills for every seat of a
|
||||
// multiplayer game. No separate code path — see `sim/display-step.ts`.
|
||||
takeDisplaySteps: () => takeSteps(game.display),
|
||||
takeDisplayReset: () => {
|
||||
const reset = pendingReset;
|
||||
pendingReset = null;
|
||||
return reset;
|
||||
},
|
||||
|
||||
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;
|
||||
back.announced = null;
|
||||
// The rebuilt game has an empty collector and a chain that starts over, so the queue must too.
|
||||
pendingReset = publicSnapshot(back.state);
|
||||
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;
|
||||
game.announced = null;
|
||||
pendingReset = publicSnapshot(game.state);
|
||||
changed();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The push envelope `src/server/session.ts` sends over SSE — mirrored here rather than imported,
|
||||
* so this file never depends on anything under `src/server/` even at the type level. */
|
||||
type Push = {
|
||||
frame?: FrameDelta;
|
||||
menu: Menu | null;
|
||||
lines: { text: string; tone: string }[];
|
||||
/** One entry for a change; every other seat at once on the connect push. */
|
||||
presence?: { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
||||
/** Ordered presentation steps — v0.8.0, identical in every seat's push because they are public. */
|
||||
steps?: DisplayStep[];
|
||||
/** The baseline for the step queue, sent on a connect only. */
|
||||
publicReset?: PublicFrame;
|
||||
/**
|
||||
* THE FOUR TRANSIENT SIGNALS, added 2026-08-23.
|
||||
*
|
||||
* A remote session used to return nothing for any of them, so multiplayer had no sound at all, no
|
||||
* timetable flash when the D12 filled a slot, no announcement when a completed run paid the table,
|
||||
* and no highlight on the card you had just drawn — four things solitaire has had all along, and
|
||||
* most of why a multiplayer game felt inert.
|
||||
*
|
||||
* `cues` and `announcement` are SHARED events (a collision anywhere, the Stage bell, a train
|
||||
* leaving the Division) and reach every seat; `justDrawn` is the recipient's own card and nobody
|
||||
* else's — `test/redaction.test.ts` covers exactly that.
|
||||
*/
|
||||
cues?: string[];
|
||||
scheduled?: number | null;
|
||||
announcement?: string | null;
|
||||
justDrawn?: string | null;
|
||||
/** Where the server's count of this seat's accepted intents stands — connect push only (v0.8.4). */
|
||||
lastSeq?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A session backed by a server (Phase 2, extended Phase 4). Holds no authoritative state — no deck
|
||||
* order, no other seat's hand — only the last `Frame`/`Menu` a push actually told it. `capabilities`
|
||||
* are all `false`: undo would have to un-see what other players already saw, a local save is
|
||||
* meaningless when the server is the store, and dealing a new game is the lobby's job.
|
||||
*
|
||||
* `token` is `lobby-and-sessions.md` §1's session token, issued at `Lobby.Create`/`Lobby.Join` and
|
||||
* stored by the caller (`main.ts`, in `localStorage`) — it alone proves identity to `/api/stream`
|
||||
* and `/api/intent`, so this function no longer takes a join secret at all; that gate belongs to
|
||||
* the lobby endpoints only. `seat` still has to be passed in rather than learned from a push,
|
||||
* because the very FIRST thing this session needs — `seat()` — has to answer before any push has
|
||||
* necessarily arrived; the caller already knows it from the join/create/start response.
|
||||
*
|
||||
* Construction is synchronous (the `Session` interface has no async surface), but the first real
|
||||
* `Frame` only exists once the SSE connection's first push arrives — `main.ts` accounts for this by
|
||||
* not rendering until `subscribe`'s callback fires at least once for a session whose `capabilities`
|
||||
* are all `false` (a `LocalSession` always has data the instant it is constructed; this does not).
|
||||
*/
|
||||
export function createRemoteSession(
|
||||
token: string,
|
||||
seat: PlayerIndex,
|
||||
/**
|
||||
* Called once when this session's game is established to be gone for good, so the page can stop
|
||||
* waiting for it. Without this the only symptom is a blank screen: `EventSource` retries a 404
|
||||
* forever and reports nothing, and `frame` never becomes non-null.
|
||||
*/
|
||||
onGone?: () => void,
|
||||
): Session {
|
||||
let frame: Frame | null = null;
|
||||
let menu: Menu | null = null;
|
||||
let lines: { text: string; tone: string }[] = [];
|
||||
const presence = new Map<PlayerIndex, { connected: boolean; seen: boolean }>();
|
||||
let displaySteps: DisplayStep[] = [];
|
||||
let displayReset: PublicFrame | null = null;
|
||||
let cues: string[] = [];
|
||||
let scheduled: number | null = null;
|
||||
let announcement: string | null = null;
|
||||
let justDrawnCard: string | null = null;
|
||||
/**
|
||||
* NUMBERED FROM WHERE THE SERVER SAYS, NOT FROM 1 (v0.8.4).
|
||||
*
|
||||
* This started at 1 on every page load, and the server remembers a seat's last accepted number
|
||||
* for the life of the game and answers a repeat with "already applied" (`protocol.md` §5). So a
|
||||
* seat that had made one move, reloaded, and clicked again sent `seq: 1` twice: the server said
|
||||
* ok, did nothing, pushed nothing, and the click looked dead. The connect push now carries the
|
||||
* server's count and this continues from it — never backwards, in case a submit is in flight
|
||||
* across a reconnect.
|
||||
*/
|
||||
let nextSeq = 1;
|
||||
/** One submit in flight at a time — see `submit`. */
|
||||
let inFlight = false;
|
||||
const listeners = new Set<() => void>();
|
||||
const changed = (): void => {
|
||||
for (const fn of [...listeners]) fn();
|
||||
};
|
||||
|
||||
const qs = `token=${encodeURIComponent(token)}`;
|
||||
const source = new EventSource(`/api/stream?${qs}`);
|
||||
|
||||
/**
|
||||
* A DROPPED CONNECTION AND A DEAD GAME LOOK IDENTICAL HERE, so ask before giving up.
|
||||
*
|
||||
* `EventSource` fires `error` for both a transient blip — which it recovers from by itself, and
|
||||
* which is the expected shape of a game that sits idle for minutes (multiplayer.md §9) — and a
|
||||
* 404 it will nonetheless retry forever. It exposes no status code either way. `/api/session` is
|
||||
* the cheap question that separates them: only a definite 404 closes the stream and reports the
|
||||
* game gone, so a flaky network still self-heals.
|
||||
*/
|
||||
let reportedGone = false;
|
||||
source.onerror = () => {
|
||||
if (reportedGone) return;
|
||||
void fetch(`/api/session?${qs}`)
|
||||
.then((r) => {
|
||||
if (r.status !== 404 || reportedGone) return;
|
||||
reportedGone = true;
|
||||
source.close();
|
||||
onGone?.();
|
||||
})
|
||||
.catch(() => {
|
||||
// The probe itself failed, so this says nothing about the game — leave the retry running.
|
||||
});
|
||||
};
|
||||
source.onmessage = (ev: MessageEvent<string>) => {
|
||||
let push: Push;
|
||||
try {
|
||||
push = JSON.parse(ev.data) as Push;
|
||||
} catch {
|
||||
// A push that is not JSON is dropped rather than thrown out of an event handler nothing
|
||||
// catches; the next push carries a full delta chain from what this seat was last sent.
|
||||
return;
|
||||
}
|
||||
if (push.lastSeq !== undefined) nextSeq = Math.max(nextSeq, push.lastSeq + 1);
|
||||
// A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this
|
||||
// seat's turn — only a push that actually came from the game (always carries a real `frame`,
|
||||
// per `session.ts`'s `Push`) updates the board or the menu.
|
||||
if (push.frame) {
|
||||
frame = applyDelta(frame, push.frame);
|
||||
menu = push.menu;
|
||||
}
|
||||
lines = [...lines, ...push.lines];
|
||||
for (const p of push.presence ?? []) presence.set(p.seat, { connected: p.connected, seen: p.seen });
|
||||
// Accumulated rather than replaced: two pushes can arrive between two renders, and a cue that
|
||||
// was earned is a cue that should be heard.
|
||||
if (push.cues) cues = [...cues, ...push.cues];
|
||||
if (push.scheduled !== undefined && push.scheduled !== null) scheduled = push.scheduled;
|
||||
if (push.announcement !== undefined && push.announcement !== null) announcement = push.announcement;
|
||||
// Persists until another draw replaces it, matching the local session's own `justDrawn`.
|
||||
if (push.justDrawn !== undefined) justDrawnCard = push.justDrawn;
|
||||
/**
|
||||
* A RESET DISCARDS WHAT WAS QUEUED, rather than arriving alongside it.
|
||||
*
|
||||
* `publicReset` comes on a connect, which is also a RECONNECT — and a reconnecting client's
|
||||
* queue holds steps whose deltas chain off a baseline the server has since moved past. Merging
|
||||
* them onto the new baseline would draw a board that never existed. The history panel is what
|
||||
* carries what was missed; the animation does not replay it (§ v0.8.0).
|
||||
*/
|
||||
if (push.publicReset) {
|
||||
displayReset = push.publicReset;
|
||||
displaySteps = [];
|
||||
}
|
||||
// Accumulated, like cues: two pushes can land between two renders and every step is one thing
|
||||
// that happened.
|
||||
if (push.steps) displaySteps = [...displaySteps, ...push.steps];
|
||||
changed();
|
||||
};
|
||||
|
||||
const need = (): Frame => {
|
||||
if (frame === null) throw new Error('RemoteSession.view() called before the first Frame arrived');
|
||||
return frame;
|
||||
};
|
||||
|
||||
return {
|
||||
view: need,
|
||||
menu: () => menu ?? { options: [], direct: [], placeable: [], hand: [], makeUp: null },
|
||||
seat: () => seat,
|
||||
actor: () => need().actor,
|
||||
handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null),
|
||||
async submit(intent: Intent): Promise<boolean> {
|
||||
/**
|
||||
* ONE AT A TIME (v0.8.4). The page redraws the SAME menu the instant a submit is sent — the
|
||||
* new one only arrives with the push — so a second click before the round trip posted a
|
||||
* second, fresh `seq` for the same option, and the server, which de-duplicates on `seq`
|
||||
* alone, applied it again when it was still legal: two cards drawn, two Moves spent, two cars
|
||||
* coupled. A click that lands while one is in flight is dropped; the push is milliseconds away.
|
||||
*/
|
||||
if (inFlight) return false;
|
||||
inFlight = true;
|
||||
const seq = nextSeq++;
|
||||
try {
|
||||
const res = await fetch(`/api/intent?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seq, intent }),
|
||||
});
|
||||
const result = (await res.json()) as { ok?: boolean; code?: string };
|
||||
// The visible update arrives via the SSE push (broadcast to every seat, including this one),
|
||||
// not from this response — this only reports whether the rules accepted it.
|
||||
return result.ok === true;
|
||||
} catch {
|
||||
// A network failure or a non-JSON answer used to reject out of a `void`ed promise — an
|
||||
// unhandled rejection and nothing on screen. False is honest: the move was not confirmed.
|
||||
return false;
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
},
|
||||
subscribe(fn: () => void) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
capabilities: { undo: false, saveLocal: false, newGame: false },
|
||||
|
||||
lines: () => lines,
|
||||
// Draining, exactly as the local session's are: each of these marks a moment, so it plays once
|
||||
// and is gone by the next render rather than re-firing on every redraw.
|
||||
takeCues: () => {
|
||||
const out = cues;
|
||||
cues = [];
|
||||
return out;
|
||||
},
|
||||
takeScheduled: () => {
|
||||
const out = scheduled;
|
||||
scheduled = null;
|
||||
return out;
|
||||
},
|
||||
takeAnnouncement: () => {
|
||||
const out = announcement;
|
||||
announcement = null;
|
||||
return out;
|
||||
},
|
||||
justDrawn: () => justDrawnCard,
|
||||
presence: () => [...presence].map(([seat, p]) => ({ seat, connected: p.connected, seen: p.seen })),
|
||||
takeDisplaySteps: () => displaySteps.splice(0, displaySteps.length),
|
||||
takeDisplayReset: () => {
|
||||
const reset = displayReset;
|
||||
displayReset = null;
|
||||
return reset;
|
||||
},
|
||||
close() {
|
||||
// `reportedGone` first: closing the stream fires `onerror`, and this is a deliberate exit, not
|
||||
// a game that vanished — `onGone` must not be called and land the page in "that game is no
|
||||
// longer on this server".
|
||||
reportedGone = true;
|
||||
source.close();
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* THE RULES BLOCK, driven identically on both screens.
|
||||
*
|
||||
* The lobby (`lobby.ts`) and the solitaire New Game dialog (`main.ts`) ask the same twelve questions.
|
||||
* They used to ask them in two hand-written copies and had already drifted — the dialog had "where
|
||||
* an Extra may start" and no optional rules, the lobby the reverse — so this module owns reading,
|
||||
* writing, comparing and annotating the block, and each screen supplies only the id prefix its
|
||||
* markup uses (`lb-` or `ng-`).
|
||||
*
|
||||
* THE MARKUP ITSELF STAYS STATIC IN `play.html`, deliberately. Generating it here would be less
|
||||
* duplication, but `test/web.test.ts` drives the emitted bundle against a hand-built DOM stub with
|
||||
* no HTML parser in it (there is no jsdom in this project, and adding one to test a form is a poor
|
||||
* trade), and the ids in that stub come from scanning the real `play.html`. A test in the same suite
|
||||
* asserts both screens carry every key below, which is the drift guard the shared markup would have
|
||||
* been.
|
||||
*/
|
||||
|
||||
import { REVENUE_MAX, REVENUE_MIN } from '../engine/content.ts';
|
||||
import type { ExtraStartRule, StartingHand } from '../engine/content.ts';
|
||||
import { closestPreset, differencesFrom, presetOf, presetSettings, settingsOf } from './presets.ts';
|
||||
import type { PresetName, Settings } from './presets.ts';
|
||||
import type { GameConfig } from '../engine/state.ts';
|
||||
|
||||
/**
|
||||
* How each rule is asked on screen.
|
||||
*
|
||||
* `gated` is the pair introduced 2026-08-23: a checkbox that says whether the condition applies at
|
||||
* all, plus the number it applies at. The engine's convention is that `0` switches these off, which
|
||||
* is exact but unreadable — a Cutthroat game showed two zeroes and left the player to know the
|
||||
* convention. Unchecked still WRITES 0, so nothing under this changed.
|
||||
*/
|
||||
type Field =
|
||||
| { key: keyof Settings; kind: 'radio'; id: string }
|
||||
| { key: keyof Settings; kind: 'number'; id: string }
|
||||
| { key: keyof Settings; kind: 'checkbox'; id: string }
|
||||
| { key: keyof Settings; kind: 'gated'; id: string };
|
||||
|
||||
export const FIELDS: readonly Field[] = [
|
||||
{ key: 'startingHand', kind: 'radio', id: 'hand' },
|
||||
{ key: 'extraStart', kind: 'radio', id: 'extra' },
|
||||
{ key: 'passengerPerCoach', kind: 'number', id: 'passenger' },
|
||||
{ key: 'freightPerLoad', kind: 'number', id: 'freight' },
|
||||
{ key: 'trainPerTransit', kind: 'number', id: 'transit' },
|
||||
{ key: 'minCombinedRevenue', kind: 'gated', id: 'minrev' },
|
||||
{ key: 'maxCollisionsPerDay', kind: 'gated', id: 'colday' },
|
||||
{ key: 'maxCollisionsTotal', kind: 'gated', id: 'coltotal' },
|
||||
{ key: 'reducedVisibility', kind: 'checkbox', id: 'visibility' },
|
||||
{ key: 'employeeRotation', kind: 'checkbox', id: 'rotation' },
|
||||
{ key: 'emergencyToolbox', kind: 'checkbox', id: 'toolbox' },
|
||||
{ key: 'discardTimetabled', kind: 'checkbox', id: 'tossloco' },
|
||||
{ key: 'startWhistlePosts', kind: 'checkbox', id: 'whistlestart' },
|
||||
];
|
||||
|
||||
/**
|
||||
* What `play.html` must contain for a screen to be able to ask all twelve questions — the exact
|
||||
* attribute text, so `test/web.test.ts` can assert it against the built page.
|
||||
*
|
||||
* THIS IS THE DRIFT GUARD. The two blocks are generated from one template today; this is what says
|
||||
* so tomorrow, when someone edits one of them by hand.
|
||||
*/
|
||||
export function fieldSelectors(prefix: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const f of FIELDS) {
|
||||
// A radio group is addressed by NAME — there is no one element carrying the field's id.
|
||||
out.push(f.kind === 'radio' ? `name="${prefix}${f.id}"` : `id="${prefix}${f.id}"`);
|
||||
if (f.kind === 'gated') out.push(`id="${prefix}${f.id}-on"`);
|
||||
out.push(`id="${prefix}${f.id}-row"`, `id="${prefix}${f.id}-hint"`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The label each field goes under in the read-only view below. */
|
||||
const FIELD_LABELS: Record<keyof Settings, string> = {
|
||||
startingHand: 'Starting hand',
|
||||
extraStart: 'An Extra may start at',
|
||||
discardTimetabled: 'A Timetabled train may be discarded',
|
||||
startWhistlePosts: 'Players start with Whistle Posts, not Depots',
|
||||
passengerPerCoach: 'Passenger per coach',
|
||||
freightPerLoad: 'Freight per load',
|
||||
trainPerTransit: 'Train per transit',
|
||||
minCombinedRevenue: 'Combined Revenue floor',
|
||||
maxCollisionsPerDay: 'Collisions in one Day',
|
||||
maxCollisionsTotal: 'Collisions in the game',
|
||||
reducedVisibility: 'Reduced Visibility',
|
||||
employeeRotation: 'Employee Rotation',
|
||||
emergencyToolbox: 'Emergency Toolbox',
|
||||
};
|
||||
|
||||
const esc = (s: string): string =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
/**
|
||||
* THE WHOLE RULE SET, READ-ONLY — what a player weighing a join reads before taking a seat, and what
|
||||
* the seating screen keeps showing afterwards.
|
||||
*
|
||||
* Generated rather than a fourth copy of the form: nobody may change these, so form controls would
|
||||
* be a lie, and a `<dl>` says "this is what you are joining" in a third of the space. Fields that
|
||||
* differ from the nearest named type are amber here for the same reason they are amber in the form —
|
||||
* a non-standard game should never be a surprise.
|
||||
*/
|
||||
export function rulesListHtml(config: GameConfig, players: number, days: number): string {
|
||||
const settings = settingsOf(config);
|
||||
const type = presetOf(config, players, days);
|
||||
const near = closestPreset(config, players, days);
|
||||
const differing = type === 'custom' ? near.differing : [];
|
||||
|
||||
const rows = (keys: (keyof Settings)[]): string =>
|
||||
keys
|
||||
.map((k) => {
|
||||
const changed = differing.includes(k) ? ' class="changed"' : '';
|
||||
return `<dt>${esc(FIELD_LABELS[k])}</dt><dd${changed}>${esc(describe(k, settings[k]))}</dd>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const head =
|
||||
`<dl><dt>Players</dt><dd>${players}</dd><dt>Days</dt><dd>${days}</dd>` +
|
||||
`<dt>Opponent-directed cards</dt><dd>not implemented yet</dd></dl>`;
|
||||
|
||||
return (
|
||||
head +
|
||||
`<h4>Opening</h4><dl>${rows(['startingHand', 'extraStart', 'startWhistlePosts'])}</dl>` +
|
||||
`<h4>Train cards</h4><dl>${rows(['discardTimetabled'])}</dl>` +
|
||||
`<h4>Revenue</h4><dl>${rows(['passengerPerCoach', 'freightPerLoad', 'trainPerTransit'])}</dl>` +
|
||||
`<h4>Victory conditions</h4><dl>${rows(['minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal'])}</dl>` +
|
||||
`<h4>Optional rules</h4><dl>${rows(['reducedVisibility', 'employeeRotation', 'emergencyToolbox'])}</dl>`
|
||||
);
|
||||
}
|
||||
|
||||
/** What a value looks like beside its label — "Co-op default: 75", "Co-op default: six random cards". */
|
||||
function describe(key: keyof Settings, value: Settings[keyof Settings]): string {
|
||||
if (typeof value === 'boolean') return value ? 'on' : 'off';
|
||||
if (key === 'startingHand') {
|
||||
const words: Record<StartingHand, string> = {
|
||||
threeRandom: 'three random',
|
||||
sixRandom: 'six random',
|
||||
threeTrackThreeOther: 'three track and three other',
|
||||
};
|
||||
return words[value as StartingHand];
|
||||
}
|
||||
if (key === 'extraStart') {
|
||||
const words: Record<ExtraStartRule, string> = {
|
||||
divisionPointsOnly: 'Division Points only',
|
||||
ownOffice: 'your own Control Point',
|
||||
anyOffice: 'any Control Point',
|
||||
};
|
||||
return words[value as ExtraStartRule];
|
||||
}
|
||||
// A victory condition at 0 is not "0" on screen, it is a condition that does not apply.
|
||||
if (value === 0 && (key === 'minCombinedRevenue' || key === 'maxCollisionsPerDay' || key === 'maxCollisionsTotal')) {
|
||||
return 'off';
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export type SettingsForm = {
|
||||
read(): Settings;
|
||||
/** `suggestions` fills the placeholder of a switched-off condition, so ticking it back on has a
|
||||
* number to offer rather than an empty box. */
|
||||
write(values: Settings, suggestions: Settings): void;
|
||||
/**
|
||||
* Paint the block against a named type: every field that differs goes amber and every field gets
|
||||
* its "<Type> default: x" hint. Returns what differs, for the summary line the screen draws.
|
||||
*/
|
||||
mark(name: PresetName, players: number, days: number): (keyof Settings)[];
|
||||
/** Custom with no named baseline to compare against — clears the paint rather than lying. */
|
||||
clearMarks(): void;
|
||||
/** Called whenever the player changes any rule; the screen answers by selecting Custom. */
|
||||
onEdit(fn: (key: keyof Settings) => void): void;
|
||||
/** Read-only for the join preview and the seating screen: shown in full, changeable by nobody. */
|
||||
setEditable(on: boolean): void;
|
||||
/** Employee Rotation is meaningless at one player — disabled with a note rather than hidden, so
|
||||
* the two screens still read the same. */
|
||||
setEmployeeRotationAvailable(on: boolean): void;
|
||||
};
|
||||
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string): T | null =>
|
||||
document.getElementById(id) as T | null;
|
||||
|
||||
export function settingsForm(prefix: string): SettingsForm {
|
||||
const el = <T extends HTMLElement = HTMLElement>(id: string): T | null => $<T>(`${prefix}${id}`);
|
||||
const radios = (name: string): HTMLInputElement[] =>
|
||||
Array.from(document.querySelectorAll<HTMLInputElement>(`input[name="${prefix}${name}"]`));
|
||||
const checkedRadio = (name: string): string | null =>
|
||||
radios(name).find((r) => r.checked)?.value ?? null;
|
||||
|
||||
let editHandler: ((key: keyof Settings) => void) | null = null;
|
||||
let editable = true;
|
||||
|
||||
function num(id: string, fallback: number): number {
|
||||
const raw = el<HTMLInputElement>(id)?.value ?? '';
|
||||
return raw.trim() === '' || !Number.isFinite(Number(raw)) ? fallback : Math.round(Number(raw));
|
||||
}
|
||||
|
||||
function readGated(id: string): number {
|
||||
// Unchecked IS zero — the engine's "off". The number in the box is kept so re-ticking restores it.
|
||||
if (el<HTMLInputElement>(`${id}-on`)?.checked !== true) return 0;
|
||||
return Math.max(0, num(id, 0));
|
||||
}
|
||||
|
||||
function read(): Settings {
|
||||
return {
|
||||
startingHand: (checkedRadio('hand') ?? 'sixRandom') as StartingHand,
|
||||
extraStart: (checkedRadio('extra') ?? 'anyOffice') as ExtraStartRule,
|
||||
passengerPerCoach: clampRevenue(num('passenger', 1)),
|
||||
freightPerLoad: clampRevenue(num('freight', 1)),
|
||||
trainPerTransit: clampRevenue(num('transit', 0)),
|
||||
minCombinedRevenue: readGated('minrev'),
|
||||
maxCollisionsPerDay: readGated('colday'),
|
||||
maxCollisionsTotal: readGated('coltotal'),
|
||||
reducedVisibility: el<HTMLInputElement>('visibility')?.checked === true,
|
||||
employeeRotation: el<HTMLInputElement>('rotation')?.checked === true,
|
||||
emergencyToolbox: el<HTMLInputElement>('toolbox')?.checked === true,
|
||||
discardTimetabled: el<HTMLInputElement>('tossloco')?.checked === true,
|
||||
startWhistlePosts: el<HTMLInputElement>('whistlestart')?.checked === true,
|
||||
};
|
||||
}
|
||||
|
||||
function write(values: Settings, suggestions: Settings): void {
|
||||
for (const r of radios('hand')) r.checked = r.value === values.startingHand;
|
||||
for (const r of radios('extra')) r.checked = r.value === values.extraStart;
|
||||
setNumber('passenger', values.passengerPerCoach);
|
||||
setNumber('freight', values.freightPerLoad);
|
||||
setNumber('transit', values.trainPerTransit);
|
||||
writeGated('minrev', values.minCombinedRevenue, suggestions.minCombinedRevenue);
|
||||
writeGated('colday', values.maxCollisionsPerDay, suggestions.maxCollisionsPerDay);
|
||||
writeGated('coltotal', values.maxCollisionsTotal, suggestions.maxCollisionsTotal);
|
||||
setChecked('visibility', values.reducedVisibility);
|
||||
setChecked('rotation', values.employeeRotation);
|
||||
setChecked('toolbox', values.emergencyToolbox);
|
||||
setChecked('tossloco', values.discardTimetabled);
|
||||
setChecked('whistlestart', values.startWhistlePosts);
|
||||
}
|
||||
|
||||
function setNumber(id: string, value: number): void {
|
||||
const input = el<HTMLInputElement>(id);
|
||||
if (input) input.value = String(value);
|
||||
}
|
||||
|
||||
function setChecked(id: string, on: boolean): void {
|
||||
const input = el<HTMLInputElement>(id);
|
||||
if (input) input.checked = on;
|
||||
}
|
||||
|
||||
function writeGated(id: string, value: number, suggestion: number): void {
|
||||
const box = el<HTMLInputElement>(`${id}-on`);
|
||||
const input = el<HTMLInputElement>(id);
|
||||
if (box) box.checked = value > 0;
|
||||
if (input) {
|
||||
// A switched-off condition shows an empty box with the suggestion as its placeholder, rather
|
||||
// than a `0` the player has to decode.
|
||||
input.value = value > 0 ? String(value) : '';
|
||||
input.placeholder = String(suggestion);
|
||||
input.disabled = !editable || value === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function mark(name: PresetName, players: number, days: number): (keyof Settings)[] {
|
||||
const want = presetSettings(name, players, days);
|
||||
const differing = differencesFrom(name, read(), players, days);
|
||||
const label = presetLabelOf(name);
|
||||
for (const f of FIELDS) {
|
||||
const row = el(`${f.id}-row`);
|
||||
const hint = el(`${f.id}-hint`);
|
||||
const off = differing.includes(f.key);
|
||||
row?.classList.toggle('changed', off);
|
||||
if (hint) hint.textContent = `${label} default: ${describe(f.key, want[f.key])}`;
|
||||
}
|
||||
return differing;
|
||||
}
|
||||
|
||||
function clearMarks(): void {
|
||||
for (const f of FIELDS) {
|
||||
el(`${f.id}-row`)?.classList.remove('changed');
|
||||
const hint = el(`${f.id}-hint`);
|
||||
if (hint) hint.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function setEditable(on: boolean): void {
|
||||
editable = on;
|
||||
for (const f of FIELDS) {
|
||||
if (f.kind === 'radio') {
|
||||
for (const r of radios(f.id)) r.disabled = !on;
|
||||
continue;
|
||||
}
|
||||
const input = el<HTMLInputElement>(f.id);
|
||||
if (input) {
|
||||
// A gated number stays disabled when its own condition is off, whatever the block's state.
|
||||
const gatedOff = f.kind === 'gated' && el<HTMLInputElement>(`${f.id}-on`)?.checked !== true;
|
||||
input.disabled = !on || gatedOff;
|
||||
}
|
||||
if (f.kind === 'gated') {
|
||||
const box = el<HTMLInputElement>(`${f.id}-on`);
|
||||
if (box) box.disabled = !on;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setEmployeeRotationAvailable(on: boolean): void {
|
||||
const input = el<HTMLInputElement>('rotation');
|
||||
if (input) {
|
||||
input.disabled = !on || !editable;
|
||||
if (!on) input.checked = false;
|
||||
}
|
||||
el('rotation-row')?.classList.toggle('unavailable', !on);
|
||||
}
|
||||
|
||||
/** One change handler for every control — the screens all answer it the same way (select Custom). */
|
||||
for (const f of FIELDS) {
|
||||
const fire = (): void => editHandler?.(f.key);
|
||||
if (f.kind === 'radio') {
|
||||
for (const r of radios(f.id)) r.onchange = fire;
|
||||
continue;
|
||||
}
|
||||
const input = el<HTMLInputElement>(f.id);
|
||||
if (input) {
|
||||
input.oninput = fire;
|
||||
input.onchange = fire;
|
||||
}
|
||||
if (f.kind === 'gated') {
|
||||
const box = el<HTMLInputElement>(`${f.id}-on`);
|
||||
if (box) {
|
||||
box.onchange = () => {
|
||||
const number = el<HTMLInputElement>(f.id);
|
||||
if (number) {
|
||||
number.disabled = !box.checked || !editable;
|
||||
// Ticking a condition back on with an empty box takes the suggestion showing in it,
|
||||
// so "on" never means "on, at zero".
|
||||
if (box.checked && number.value.trim() === '') number.value = number.placeholder;
|
||||
}
|
||||
fire();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
write,
|
||||
mark,
|
||||
clearMarks,
|
||||
onEdit: (fn) => void (editHandler = fn),
|
||||
setEditable,
|
||||
setEmployeeRotationAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
function clampRevenue(n: number): number {
|
||||
return Math.max(REVENUE_MIN, Math.min(REVENUE_MAX, n));
|
||||
}
|
||||
|
||||
/** Kept local rather than imported from `presets.ts`'s `preset()`, which would drag the whole table
|
||||
* in for one word — and this is the only place a hint needs it. */
|
||||
function presetLabelOf(name: PresetName): string {
|
||||
const labels: Record<PresetName, string> = {
|
||||
solitaire: 'Solitaire',
|
||||
coop: 'Co-op',
|
||||
competitive: 'Competitive',
|
||||
cutthroat: 'Cutthroat',
|
||||
};
|
||||
return labels[name];
|
||||
}
|
||||
+92
-17
@@ -64,6 +64,38 @@ export function playCue(cue: string): void {
|
||||
osc.stop(at + dur + 0.02);
|
||||
};
|
||||
|
||||
/**
|
||||
* A burst of filtered noise — the breath of steam behind a whistle, one chuff of a moving
|
||||
* locomotive, or the boom of an impact. Shared by `stage` (one long burst) and the chuffs in
|
||||
* `arrive`/`depart` (several short ones), and the wide-open burst under `crash`.
|
||||
*/
|
||||
const noiseBurst = (
|
||||
at: number,
|
||||
dur: number,
|
||||
gain: number,
|
||||
freq: number,
|
||||
q: number,
|
||||
type: BiquadFilterType = 'bandpass',
|
||||
): void => {
|
||||
const frames = Math.max(1, Math.floor(c.sampleRate * dur));
|
||||
const buf = c.createBuffer(1, frames, c.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
|
||||
const src = c.createBufferSource();
|
||||
src.buffer = buf;
|
||||
const filt = c.createBiquadFilter();
|
||||
filt.type = type;
|
||||
filt.frequency.value = freq;
|
||||
filt.Q.value = q;
|
||||
const amp = c.createGain();
|
||||
amp.gain.setValueAtTime(0, at);
|
||||
amp.gain.linearRampToValueAtTime(gain, at + Math.min(0.05, dur / 4));
|
||||
amp.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
||||
src.connect(filt).connect(amp).connect(c.destination);
|
||||
src.start(at);
|
||||
src.stop(at + dur + 0.02);
|
||||
};
|
||||
|
||||
const t = c.currentTime + 0.02;
|
||||
|
||||
if (cue === 'stage') {
|
||||
@@ -73,23 +105,55 @@ export function playCue(cue: string): void {
|
||||
for (const fg of [[392, 0.16], [466, 0.13], [587, 0.1], [784, 0.05]]) {
|
||||
tone(t, fg[0]!, d, fg[1]!, 'sawtooth', fg[0]! * 0.985);
|
||||
}
|
||||
const frames = Math.floor(c.sampleRate * d);
|
||||
const buf = c.createBuffer(1, Math.max(1, frames), c.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
|
||||
const src = c.createBufferSource();
|
||||
src.buffer = buf;
|
||||
const band = c.createBiquadFilter();
|
||||
band.type = 'bandpass';
|
||||
band.frequency.value = 1400;
|
||||
band.Q.value = 0.8;
|
||||
const amp = c.createGain();
|
||||
amp.gain.setValueAtTime(0, t);
|
||||
amp.gain.linearRampToValueAtTime(0.05, t + 0.05);
|
||||
amp.gain.exponentialRampToValueAtTime(0.0001, t + d);
|
||||
src.connect(band).connect(amp).connect(c.destination);
|
||||
src.start(t);
|
||||
src.stop(t + d);
|
||||
noiseBurst(t, d, 0.05, 1400, 0.8);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cue === 'arrive') {
|
||||
/**
|
||||
* A LOCOMOTIVE SLOWING TO A STOP: chuffs (one per wheel revolution) spaced FARTHER apart as
|
||||
* road speed drops, then a soft two-partial bell tap as it settles — a lighter cousin of the
|
||||
* `day` bell, not a repeat of it.
|
||||
*/
|
||||
let at = t;
|
||||
const gaps = [0.13, 0.17, 0.22, 0.28];
|
||||
for (let i = 0; i < gaps.length; i++) {
|
||||
noiseBurst(at, 0.07, 0.11 - i * 0.015, 190, 1.1, 'bandpass');
|
||||
at += gaps[i]!;
|
||||
}
|
||||
for (const fg of [[1046, 0.07], [1568, 0.04]]) {
|
||||
tone(at + 0.12, fg[0]!, 0.3, fg[1]!, 'sine');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cue === 'depart') {
|
||||
/**
|
||||
* THE INVERSE OF `arrive`: chuffs starting slow and pulling closer together as the train
|
||||
* gathers speed, then one falling, fading horn note — a single call, not `completed`'s pair,
|
||||
* since this train is only leaving the Office, not clearing the whole Division.
|
||||
*/
|
||||
let at = t;
|
||||
const gaps = [0.22, 0.15, 0.1, 0.07, 0.05];
|
||||
for (let i = 0; i < gaps.length; i++) {
|
||||
noiseBurst(at, 0.06, 0.06 + i * 0.012, 210, 1.2, 'bandpass');
|
||||
at += gaps[i]!;
|
||||
}
|
||||
tone(at, 349, 0.45, 0.1, 'sawtooth', 300);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cue === 'crash') {
|
||||
/**
|
||||
* IMPACT, NOT MUSIC: two tones bent sharply downward at a DISSONANT (non-harmonic) interval,
|
||||
* so the ear reads a collision rather than a chord — `couple`'s clank is consonant on purpose,
|
||||
* and this is its opposite. A wide-open noise boom and a percussive sub-bass thump give it
|
||||
* weight under the screech.
|
||||
*/
|
||||
tone(t, 311, 0.35, 0.16, 'square', 70);
|
||||
tone(t, 233, 0.4, 0.13, 'sawtooth', 52);
|
||||
noiseBurst(t, 0.4, 0.16, 900, 0.4, 'lowpass');
|
||||
tone(t, 45, 0.3, 0.18, 'sine');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,6 +188,17 @@ export function playCue(cue: string): void {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cue === 'completed') {
|
||||
// A train clearing the Division and whistling off: two long horn notes falling away, with the
|
||||
// second quieter and lower so it reads as distance rather than repetition. Longer than any other
|
||||
// cue on purpose — it is the only one that pays every player, and it should land as an event.
|
||||
tone(t, 392, 0.55, 0.13, 'sawtooth', 370);
|
||||
tone(t, 262, 0.55, 0.09, 'triangle', 247);
|
||||
tone(t + 0.42, 330, 0.75, 0.08, 'sawtooth', 300);
|
||||
tone(t + 0.42, 220, 0.75, 0.055, 'triangle', 200);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cue === 'day') {
|
||||
// The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
|
||||
for (const at of [t, t + 0.22]) {
|
||||
|
||||
@@ -7,3 +7,88 @@ if (ties) {
|
||||
}
|
||||
ties.innerHTML = out;
|
||||
}
|
||||
|
||||
/** Clicking the box art opens it full size, since the thumbnail is too small to read. */
|
||||
const heroImage = document.getElementById('hero-image');
|
||||
const lightbox = document.getElementById('lightbox');
|
||||
if (heroImage && lightbox) {
|
||||
const open = () => { lightbox.hidden = false; };
|
||||
const close = () => { lightbox.hidden = true; };
|
||||
heroImage.addEventListener('click', open);
|
||||
heroImage.addEventListener('keydown', (e) => {
|
||||
if (e instanceof KeyboardEvent && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
lightbox.addEventListener('click', close);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e instanceof KeyboardEvent && e.key === 'Escape') close();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a Station Master server actually behind this page?
|
||||
*
|
||||
* The same `dist/` ships two ways — served by `src/server/`, or uploaded as flat files with no
|
||||
* server at all (`scripts/deploy-web.ts`) — and the bundle is identical in both, so the only
|
||||
* honest way to answer is to ask. `/api/health` is the one route that exists to be failed.
|
||||
*
|
||||
* THE DOOR STARTS OPEN AND ONLY EVER CLOSES. Getting this wrong in the "no server" direction is
|
||||
* the bug this whole change exists to fix: a disabled door is invisible and leaves a player who
|
||||
* DOES have a server with no way in, and nothing on screen to explain it. Getting it wrong the
|
||||
* other way costs a click and a lobby that says it cannot reach a server — which is legible, and
|
||||
* recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it.
|
||||
*/
|
||||
/**
|
||||
* CARRY `?pace=` THROUGH THE DOORS — v0.8.0.3.
|
||||
*
|
||||
* Both doors are static hrefs that REPLACE the query string (`play.html?lobby`,
|
||||
* `play.html?solitaire`), so a `pace` typed on this page was silently dropped on the way in: Jesse
|
||||
* played a whole game believing he was at 7× when the play page had only ever seen `?lobby`. The
|
||||
* durable answer is the speed control on the play screen, which persists per viewer — this keeps the
|
||||
* URL lever honest for handing two playtesters different speeds, which is the only thing it was ever
|
||||
* for.
|
||||
*/
|
||||
try {
|
||||
const pace = new URLSearchParams(location.search).get('pace');
|
||||
if (pace !== null) {
|
||||
for (const door of Array.from(document.querySelectorAll('a.door'))) {
|
||||
const href = door.getAttribute('href');
|
||||
// Only the doors into the game, and only ones that have not been disabled above.
|
||||
if (href?.startsWith('./play.html?')) door.setAttribute('href', `${href}&pace=${encodeURIComponent(pace)}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A door that keeps its own href is the status quo, not a broken page.
|
||||
}
|
||||
|
||||
const mpDoor = document.getElementById('door-multiplayer');
|
||||
if (mpDoor) {
|
||||
const close = (): void => {
|
||||
mpDoor.classList.add('disabled');
|
||||
mpDoor.removeAttribute('href');
|
||||
const go = document.getElementById('door-multiplayer-go');
|
||||
if (go) go.textContent = 'Requires a Station Master server';
|
||||
const blurb = document.getElementById('door-multiplayer-blurb');
|
||||
if (blurb) {
|
||||
blurb.textContent =
|
||||
'Play with your friends across the internet, each running your own office area within the ' +
|
||||
'entire division. This copy of the game is a plain website with no server behind it, so ' +
|
||||
'there is nowhere to host a table — multiplayer needs a Station Master server.';
|
||||
}
|
||||
};
|
||||
|
||||
// Relative, never absolute: the page must work at whatever address it is reached by, and the
|
||||
// server is always the origin that served it (D16).
|
||||
void fetch('./api/health')
|
||||
.then((r) => (r.ok ? (r.json() as Promise<unknown>) : null))
|
||||
.then((body) => {
|
||||
// A static host that answers unknown paths with 200 and its own index page would sail past
|
||||
// an `r.ok` check, so the body has to name itself before the door is believed.
|
||||
const named =
|
||||
typeof body === 'object' && body !== null && (body as { service?: unknown }).service === 'station-master';
|
||||
if (!named) close();
|
||||
})
|
||||
.catch(() => close());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* THE ANIMATION QUEUE — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 4-6.
|
||||
*
|
||||
* Holds the public board the screen is currently showing, which is not always the board the game is
|
||||
* actually on. Steps arrive faster than a person can follow — a bot's whole switching turn lands in
|
||||
* ONE push, because `driveBots()` plays it out before the push goes back — so this is what turns a
|
||||
* burst into something watchable. TODO #13.
|
||||
*
|
||||
* TRANSPORT-AGNOSTIC ON PURPOSE. It takes `DisplayStep`s and does not care whether they came from
|
||||
* the engine in this tab or off an SSE stream, which is what lets solitaire (#18: phases that are
|
||||
* never drawn) and multiplayer (#13: turns you never see) run one implementation. Nothing here
|
||||
* imports the DOM either, so it is testable without one.
|
||||
*
|
||||
* NO TIMERS OF ITS OWN. The caller drives it with `advance(now)` from whatever loop it already has
|
||||
* — a `requestAnimationFrame`, a test's fake clock. A queue that owned a `setInterval` would need
|
||||
* starting, stopping and cleaning up on every game replacement, and would be untestable without
|
||||
* faking timers.
|
||||
*/
|
||||
|
||||
import type { PublicFrame } from '../sim/view.ts';
|
||||
import type { DisplayStep } from '../sim/display-step.ts';
|
||||
import { applyPublicDelta, changedDivisionCards, changedPiles } from '../sim/public-delta.ts';
|
||||
import type { PileKey } from '../sim/public-delta.ts';
|
||||
import { dwellForStep } from '../sim/pacing.ts';
|
||||
|
||||
export type StepQueue = {
|
||||
/** Throw away what is queued and show this board — a first connect, a reconnect, an undo. */
|
||||
reset(frame: PublicFrame): void;
|
||||
/** Queue steps to be shown in order. */
|
||||
push(steps: readonly DisplayStep[]): void;
|
||||
/**
|
||||
* Show as much as `now` allows. Returns true if the displayed board changed, so a caller can skip
|
||||
* a redraw when nothing did.
|
||||
*/
|
||||
advance(now: number): boolean;
|
||||
/** Show everything immediately. Returns true if anything was skipped. */
|
||||
skip(): boolean;
|
||||
/** The board to draw, or null before any reset has arrived. */
|
||||
current(): PublicFrame | null;
|
||||
/**
|
||||
* How many queued steps the player is still going to WATCH — the number the "N behind" counter
|
||||
* shows. Not the queue length: see `watchableCount` in `sim/pacing.ts`.
|
||||
*/
|
||||
behind(): number;
|
||||
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
|
||||
showing(): DisplayStep | null;
|
||||
/**
|
||||
* The piles the step now on screen moved, for the display to light.
|
||||
*
|
||||
* Here because this is the only place that holds both the frame before a step and the frame after
|
||||
* it — deriving it anywhere else would mean keeping a second copy of the board in step.
|
||||
*/
|
||||
lit(): readonly PileKey[];
|
||||
/** True while there is anything left to show. */
|
||||
busy(): boolean;
|
||||
/**
|
||||
* How many narrated lines belong to steps NOT yet shown.
|
||||
*
|
||||
* The log and the board are two different moments while the queue is behind: a push carries its
|
||||
* narration and its steps together, so every line of a bot's turn is in the history panel before the
|
||||
* board has drawn a single move of it (playtest, 2026-09-15: *"is it possible to stall history so it
|
||||
* stays in sync with the number behind?"*). Those lines are the TAIL of the log — they arrived last —
|
||||
* so the caller holds back exactly this many and reveals each as its step goes up.
|
||||
*/
|
||||
pendingLines(): number;
|
||||
/** Division nodes whose card changed in the step now on screen, for the map to flash. */
|
||||
flashing(): readonly number[];
|
||||
/**
|
||||
* HOLD THE PLAYBACK ON THE STEP NOW SHOWING — Jesse, playtest 2026-09-16, asking for a pause
|
||||
* beside Skip.
|
||||
*
|
||||
* Skip is the only control the row has had, and it is one-way and total: the way to look harder at
|
||||
* a move that just went past was to not be too slow about it. Pause is the opposite lever — the
|
||||
* board stops where it is and nothing is consumed, so a player can read the caption, look at the
|
||||
* district and then carry on from exactly that step.
|
||||
*
|
||||
* TAKES `now` BECAUSE THE QUEUE OWNS NO CLOCK (see the note at the top of this file). The dwell
|
||||
* still owing is preserved across the hold rather than being spent while nobody was watching:
|
||||
* `resume` pushes the deadline out by however long the pause lasted, so a step paused with 200ms
|
||||
* left resumes with 200ms left instead of vanishing on the next frame.
|
||||
*
|
||||
* Returns false when there is nothing to hold, or nothing being held.
|
||||
*/
|
||||
pause(now: number): boolean;
|
||||
resume(now: number): boolean;
|
||||
paused(): boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* WHOSE MOVE THE SCREEN IS SHOWING (Gitea#25).
|
||||
*
|
||||
* The game and the board on screen are two different moments. The server plays every bot move the
|
||||
* instant a human's turn ends (`driveBots`), so the LIVE game is nearly always waiting on the human —
|
||||
* while this queue is still replaying the bots, step by step. The turn chart and the Division map's
|
||||
* move marker read the live actor, so a table of one person and three bots said "waiting on" that
|
||||
* person throughout, against a playback row naming the bot actually moving.
|
||||
*
|
||||
* While the queue is behind or still showing a step, the answer is that step's player — `null` for an
|
||||
* automatic phase, which is "the Division is running itself". Otherwise it is the live actor, and
|
||||
* `replaying` is false so a caller can keep live-only detail, such as a ruling the game is waiting on,
|
||||
* off a screen that has not caught up with it yet.
|
||||
*/
|
||||
export function actorOnScreen(
|
||||
queue: Pick<StepQueue, 'behind' | 'busy' | 'showing'>,
|
||||
live: number | null,
|
||||
): { actor: number | null; replaying: boolean } {
|
||||
if (queue.behind() === 0 && !queue.busy()) return { actor: live, replaying: false };
|
||||
const shown = queue.showing();
|
||||
return shown === null ? { actor: live, replaying: false } : { actor: shown.player, replaying: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* `pace` is read on every step rather than captured, so changing the setting takes effect at once.
|
||||
*
|
||||
* `viewer` says which seat is watching, so THIS PLAYER'S OWN MOVES COST NO TIME. They are already on
|
||||
* screen: a seated player's own board is drawn from their authoritative `Frame`, not from the queue,
|
||||
* so holding their click for a dwell shows them nothing and delays the thing they actually want to
|
||||
* watch — the 700ms before a bot's turn starts animating is 700ms of their own move being replayed
|
||||
* at them. The step is still APPLIED, because the delta chain runs through it.
|
||||
*
|
||||
* Automatic phases have no player and are unaffected, which is what keeps TODO #18 working in
|
||||
* solitaire where every intent is the viewer's own.
|
||||
*/
|
||||
export function createStepQueue(
|
||||
pace: () => number = () => 1,
|
||||
viewer: () => number | null = () => null,
|
||||
): StepQueue {
|
||||
let shown: PublicFrame | null = null;
|
||||
let last: DisplayStep | null = null;
|
||||
let litPiles: readonly PileKey[] = [];
|
||||
let flashedCards: readonly number[] = [];
|
||||
let pending: DisplayStep[] = [];
|
||||
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
||||
let dueAt: number | null = null;
|
||||
/** When the player pressed Pause, so `resume` can give the current step back the time it had. */
|
||||
let pausedAt: number | null = null;
|
||||
|
||||
/** How long this step holds the screen — zero for the viewer's own moves; see above. */
|
||||
const dwell = (step: DisplayStep): number =>
|
||||
step.player !== null && step.player === viewer() ? 0 : dwellForStep(step, pace());
|
||||
|
||||
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
|
||||
const show = (step: DisplayStep): void => {
|
||||
const before = shown;
|
||||
shown = applyPublicDelta(shown, step.frame);
|
||||
last = step;
|
||||
/**
|
||||
* NOT FOR YOUR OWN MOVES. You drew that card; you do not need the deck flashed at you. Same rule
|
||||
* that gives your own steps no dwell — the display is for watching everybody else.
|
||||
*/
|
||||
litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown);
|
||||
// A Realignment changes the Division under everyone, so it is flashed for the player who did it
|
||||
// too — unlike a pile, which only tells the drawer what they already know.
|
||||
flashedCards = changedDivisionCards(before, shown);
|
||||
};
|
||||
|
||||
return {
|
||||
reset(frame) {
|
||||
shown = frame;
|
||||
pending = [];
|
||||
dueAt = null;
|
||||
// A reconnect, an undo or a fresh deal replaces the board outright; a hold on the playback that
|
||||
// no longer exists would leave the row stuck reading "paused" with nothing behind it.
|
||||
pausedAt = null;
|
||||
// Nothing was watched arriving at this board, so nothing on it is lit.
|
||||
litPiles = [];
|
||||
flashedCards = [];
|
||||
// `last` deliberately survives: a reconnect should not blank the caption line, and the
|
||||
// sentence describing the most recent action is still true.
|
||||
},
|
||||
|
||||
push(steps) {
|
||||
pending.push(...steps);
|
||||
},
|
||||
|
||||
advance(now) {
|
||||
// Held. Nothing is shown and, crucially, nothing is CONSUMED — `dueAt` is left where it was
|
||||
// and `resume` moves it, so the hold costs the current step none of its dwell.
|
||||
if (pausedAt !== null) return false;
|
||||
if (pending.length === 0) {
|
||||
// The LAST step of a burst still owes its dwell. Clearing `dueAt` here reported the queue
|
||||
// idle the instant that step was shown, which snapped the district panel home before anyone
|
||||
// could look at it — see `busy()`.
|
||||
if (dueAt !== null && now >= dueAt) dueAt = null;
|
||||
return false;
|
||||
}
|
||||
// First step of a burst: show it immediately rather than waiting out a dwell for a board the
|
||||
// player has not been shown yet.
|
||||
if (dueAt === null) {
|
||||
const first = pending.shift()!;
|
||||
show(first);
|
||||
dueAt = now + dwell(first);
|
||||
return true;
|
||||
}
|
||||
let drew = false;
|
||||
/**
|
||||
* A LOOP, not a single step. A dwell of zero means "do not spend the player's attention on
|
||||
* this" — bookkeeping, and phases where nothing happened (TODO #18) — so a run of them must
|
||||
* collapse within one call instead of costing a frame each. The board still passes through
|
||||
* every state in order; nobody is shown a state that never existed.
|
||||
*/
|
||||
while (pending.length > 0 && now >= dueAt) {
|
||||
const next = pending.shift()!;
|
||||
show(next);
|
||||
dueAt = dueAt + dwell(next);
|
||||
drew = true;
|
||||
}
|
||||
if (pending.length === 0 && now >= dueAt) dueAt = null;
|
||||
return drew;
|
||||
},
|
||||
|
||||
skip() {
|
||||
// Skipping while held is a decision to stop watching, so it also lifts the hold — otherwise the
|
||||
// board would jump to the game and then sit there paused, with a Resume button that does nothing.
|
||||
pausedAt = null;
|
||||
if (pending.length === 0) return false;
|
||||
for (const step of pending) show(step);
|
||||
pending = [];
|
||||
dueAt = null;
|
||||
return true;
|
||||
},
|
||||
|
||||
pause(now) {
|
||||
if (pausedAt !== null) return false;
|
||||
// Nothing on screen owes any time and nothing is queued: there is no playback to hold.
|
||||
if (pending.length === 0 && dueAt === null) return false;
|
||||
pausedAt = now;
|
||||
return true;
|
||||
},
|
||||
|
||||
resume(now) {
|
||||
if (pausedAt === null) return false;
|
||||
// Give the step on screen back exactly the dwell it was holding when the player pressed Pause.
|
||||
if (dueAt !== null) dueAt += now - pausedAt;
|
||||
pausedAt = null;
|
||||
return true;
|
||||
},
|
||||
|
||||
paused: () => pausedAt !== null,
|
||||
|
||||
current: () => shown,
|
||||
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
||||
showing: () => last,
|
||||
lit: () => litPiles,
|
||||
pendingLines: () => pending.reduce((n, s) => n + s.lines.length, 0),
|
||||
/**
|
||||
* STILL SHOWING SOMETHING, not just still holding something back.
|
||||
*
|
||||
* This was `pending.length > 0`, which went false the moment the last step of a burst was
|
||||
* shown — so the animation loop stopped and the district panel snapped back to the viewer's own
|
||||
* board without that step ever being visible. Reported from real play: "I briefly saw that it was
|
||||
* the bot's office area, then their turn was done and it pointed back to my office area."
|
||||
*
|
||||
* `dueAt` is non-null exactly while the step on screen has time left, so the two together mean
|
||||
* "there is more to come, or what is up has not had its moment yet".
|
||||
*/
|
||||
flashing: () => flashedCards,
|
||||
busy: () => pending.length > 0 || dueAt !== null,
|
||||
};
|
||||
}
|
||||
+1205
-43
File diff suppressed because it is too large
Load Diff
+1424
-85
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The card reference must not drift from the cards.
|
||||
*
|
||||
* `docs/rules/card-reference.md` spent several releases describing the v0.4.5 deck — twelve numbered
|
||||
* trains, "3 / 4 Mail-Express, 3 coaches" — while `content.ts` had train 3 as the Express with two
|
||||
* freight cars and a per-location freight rule. Worse, `content.ts` named that file as "the place
|
||||
* that now carries what the cards say", so the code sent readers to a table its own banner told them
|
||||
* not to trust. Nothing failed, because nothing checked.
|
||||
*
|
||||
* The card tables in `docs/home-deck.md` and `docs/mainline-deck.md` are emitted from the same
|
||||
* exported catalogues the engine instantiates
|
||||
* from, and this re-runs the generator and compares. Change a card face without regenerating and
|
||||
* this goes red — which is the whole point: a document nothing verifies is a document that will be
|
||||
* wrong, and this project's own history is the evidence.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const DOCS = ['docs/home-deck.md', 'docs/mainline-deck.md'].map((rel) => join(root, rel));
|
||||
|
||||
describe('the deck references carry generated card tables, and they are current', () => {
|
||||
it('matches what the generator emits from content.ts today', () => {
|
||||
const before = DOCS.map((d) => readFileSync(d, 'utf8'));
|
||||
execFileSync(process.execPath, [join(root, 'scripts/build-card-reference.ts')], { cwd: root });
|
||||
DOCS.forEach((d, i) => {
|
||||
assert.equal(
|
||||
readFileSync(d, 'utf8'),
|
||||
before[i],
|
||||
`${d} is stale — run \`npm run build:cards\` and commit the result`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('puts every generated block inside a marker pair that exists', () => {
|
||||
/**
|
||||
* The generator throws on a block with nowhere to go, so this guards the other direction: a
|
||||
* marker pair left in a document with no block to fill it would sit there empty and silent.
|
||||
*/
|
||||
for (const d of DOCS) {
|
||||
const text = readFileSync(d, 'utf8');
|
||||
const begins = [...text.matchAll(/<!-- BEGIN CARDS: ([a-z]+) -->/g)].map((m) => m[1]!);
|
||||
const ends = [...text.matchAll(/<!-- END CARDS: ([a-z]+) -->/g)].map((m) => m[1]!);
|
||||
assert.deepEqual(begins, ends, `${d}: card markers are unbalanced`);
|
||||
for (const key of begins) {
|
||||
const body = text.slice(
|
||||
text.indexOf(`<!-- BEGIN CARDS: ${key} -->`) + `<!-- BEGIN CARDS: ${key} -->`.length,
|
||||
text.indexOf(`<!-- END CARDS: ${key} -->`),
|
||||
);
|
||||
assert.match(body, /\|/, `${d}: the "${key}" block has no table in it`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('carries the current train catalogue, not the v0.4.5 deck', () => {
|
||||
// The specific drift that went unnoticed for several releases, asserted by name so a future
|
||||
// regeneration against an old content.ts cannot quietly reintroduce it.
|
||||
const md = readFileSync(join(root, 'docs/home-deck.md'), 'utf8');
|
||||
assert.match(md, /Crack Limited/);
|
||||
assert.match(md, /\| 3 \| Express \|/);
|
||||
assert.ok(!/Mail-Express/.test(md), 'the superseded v0.4.5 train names are back');
|
||||
assert.ok(!/Manifest Freight/.test(md), 'the superseded v0.4.5 train names are back');
|
||||
});
|
||||
|
||||
it('says the tables are generated, so nobody edits them by hand', () => {
|
||||
for (const d of DOCS) {
|
||||
const md = readFileSync(d, 'utf8');
|
||||
assert.match(md, /GENERATED from/, `${d} does not say its tables are generated`);
|
||||
assert.match(md, /`npm run build:cards`/, `${d} does not say what regenerates them`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,693 @@
|
||||
/**
|
||||
* §A.3 — cars occupy the track "in the same order they originally held, left-to-right".
|
||||
*
|
||||
* Left-to-right is west-to-east, and `TrackCard.standing` had no defined orientation at all until
|
||||
* this release. Three reports came out of that one omission and each gets its own battery here:
|
||||
* a cut parked in a different order depending on how many cars were set out at once; a train that
|
||||
* got the identical consist whichever end it approached a cut from; and a train that drove straight
|
||||
* through the cars it had just set out.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent, areaOf, check, movesFor } from '../src/engine/apply.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { describeIntent } from '../src/sim/view.ts';
|
||||
import { badlyMadeUp } from '../src/engine/advance.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackArc, TrackCard } from '../src/engine/state.ts';
|
||||
import { carsOn, coordKey, turnOf } from '../src/engine/state.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
days: 5,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
|
||||
const game = (seed = 77): GameState => createGame({ id: 'g', seed, config, playerNames: ['Jesse'] });
|
||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||||
const car = (type: string, loaded = false): RollingStock => ({ type, loaded }) as RollingStock;
|
||||
|
||||
const straight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
|
||||
areaOf(s, 0).grid.set(coordKey(coord), card);
|
||||
}
|
||||
|
||||
/** A row of plain straights, on row 1 — row 0 is the Running Track and holds the Office. */
|
||||
function row(s: GameState, n: number): void {
|
||||
for (let c = 0; c < n; c++) addCard(s, at(1, c), straight());
|
||||
}
|
||||
|
||||
/**
|
||||
* `facing` is the PORT the engine points out through, which is not always an east-west one: a train
|
||||
* standing on a curve points along its 45° leg. `railFacing` carries the east-west sense the train
|
||||
* arrived with, so it keeps a straight answer whatever port the nose is on (`railFacingOf`).
|
||||
*/
|
||||
function placeTray(
|
||||
s: GameState,
|
||||
coord: GridCoord,
|
||||
consist: RollingStock[],
|
||||
facing: 'n' | 's' | 'e' | 'w',
|
||||
engineAt = 0,
|
||||
railFacing: 'e' | 'w' = facing === 'w' ? 'w' : 'e',
|
||||
): string {
|
||||
const id = s.freeTrays.pop()!;
|
||||
s.trays.set(id, {
|
||||
id,
|
||||
trainNumber: null,
|
||||
trainIsExtra: false,
|
||||
engineAt,
|
||||
consist,
|
||||
direction: railFacing === 'w' ? 'west' : 'east',
|
||||
facing,
|
||||
railFacing,
|
||||
position: { at: 'grid', seat: 0, coord },
|
||||
movesUsed: 0,
|
||||
} as CrewTray);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Puts the game in the one phase switching intents are legal in. */
|
||||
function switching(s: GameState): void {
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
turnOf(s, 0).option = 'switch';
|
||||
}
|
||||
|
||||
const types = (cars: readonly RollingStock[]): string[] => cars.map((c) => c.type);
|
||||
|
||||
const FOUR = (): RollingStock[] => [car('boxcar'), car('hopper'), car('reefer'), car('tank')];
|
||||
|
||||
const standingAt = (s: GameState, coord: GridCoord): string[] =>
|
||||
types(carsOn(areaOf(s, 0).grid.get(coordKey(coord))!));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('a cut parks in the same order however many cars come off at once', () => {
|
||||
/**
|
||||
* Reported: "when dropping all 4 cars, order was reversed. It worked properly if we dropped cars
|
||||
* individually." Both halves are the same bug — `carsDropped` pushed onto the end of the array
|
||||
* whatever end the cut came off, so the answer depended on how the player chose to spend the drop.
|
||||
*
|
||||
* A drop costs no Moves, so one drop of four and four drops of one are the SAME turn played two
|
||||
* ways. They must park the same cars in the same order, and that is the whole property.
|
||||
*/
|
||||
function park(facing: 'e' | 'w', fromNose: boolean, batches: number[]): string[] {
|
||||
const s = game();
|
||||
row(s, 1);
|
||||
// The engine goes at whichever end lets the cut come off the one being tested: cars ahead of it
|
||||
// for a nose drop, behind it for a tail drop.
|
||||
const id = placeTray(s, at(1, 0), FOUR(), facing, fromNose ? 4 : 0);
|
||||
switching(s);
|
||||
for (const count of batches) {
|
||||
const r = applyIntent(s, 0, {
|
||||
type: 'switch.dropCars',
|
||||
trayId: id,
|
||||
count,
|
||||
...(fromNose ? { fromNose: true } : {}),
|
||||
});
|
||||
assert.ok(r.ok, `drop of ${count} refused: ${r.ok ? '' : r.code}`);
|
||||
}
|
||||
return standingAt(s, at(1, 0));
|
||||
}
|
||||
|
||||
for (const facing of ['e', 'w'] as const) {
|
||||
for (const fromNose of [false, true]) {
|
||||
const end = fromNose ? 'nose' : 'tail';
|
||||
it(`is batch-invariant off the ${end}, facing ${facing}`, () => {
|
||||
const all = park(facing, fromNose, [4]);
|
||||
assert.equal(all.length, 4, 'all four cars should be standing on the card');
|
||||
assert.deepEqual(park(facing, fromNose, [1, 1, 1, 1]), all, 'four singles parked a different order than one four');
|
||||
assert.deepEqual(park(facing, fromNose, [2, 2]), all, 'two pairs parked a different order again');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it('parks west-to-east, so an east-facing train sets its tail cut out behind itself', () => {
|
||||
// Consist is nose first and the nose points east, so `[boxcar hopper reefer tank]` stands on the
|
||||
// ground as tank, reefer, hopper, boxcar reading west to east. The tail car is the westmost.
|
||||
assert.deepEqual(park('e', false, [4]), ['tank', 'reefer', 'hopper', 'boxcar']);
|
||||
// Facing west the tray already reads west-to-east, so it goes down as it stands.
|
||||
assert.deepEqual(park('w', false, [4]), ['boxcar', 'hopper', 'reefer', 'tank']);
|
||||
});
|
||||
|
||||
it('keeps the two cuts apart when a train sets out off both ends on one square', () => {
|
||||
// §A.3 lets a cut come off either outer end, so both can happen on one square. The engine ends
|
||||
// up standing between them, which is the thing `standingWest` exists to record.
|
||||
const s = game();
|
||||
row(s, 1);
|
||||
const id = placeTray(s, at(1, 0), FOUR(), 'e', 2);
|
||||
switching(s);
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1 }).ok);
|
||||
|
||||
// Facing east: the nose cut (boxcar) is set out east of the engine, the tail cut (tank) west.
|
||||
assert.deepEqual(standingAt(s, at(1, 0)), ['tank', 'boxcar']);
|
||||
// `standingWest` lives on the CARD now, not the tray (docs/plans/switching-paths.md — "The
|
||||
// Roster Pass"): the Office is the one square more than one train may share, and a cut lies
|
||||
// west or east of the block of A/D tracks rather than of any one engine.
|
||||
assert.equal(
|
||||
areaOf(s, 0).grid.get(coordKey(at(1, 0)))!.standingWest,
|
||||
1,
|
||||
'the engine should be standing between the two cuts',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('a parked cut mirrors depending on which end the train comes from', () => {
|
||||
/**
|
||||
* The consist a train ends up with must depend on which way it ran, or a run-around buys nothing.
|
||||
* `couples` was accumulated in path order with no reference to the direction of travel, so both
|
||||
* approaches produced the identical tray.
|
||||
*/
|
||||
function collect(from: 'west' | 'east'): string[] {
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
// The cut stands on the middle square, boxcar west of hopper.
|
||||
addCard(s, at(1, 1), straight([car('boxcar'), car('hopper')]));
|
||||
const start = from === 'west' ? at(1, 0) : at(1, 2);
|
||||
const to = from === 'west' ? at(1, 2) : at(1, 0);
|
||||
const id = placeTray(s, start, [], from === 'west' ? 'e' : 'w');
|
||||
switching(s);
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to, reverse: false });
|
||||
assert.ok(r.ok, `the run was refused: ${r.ok ? '' : r.code}`);
|
||||
return types(s.trays.get(id)!.consist);
|
||||
}
|
||||
|
||||
it('gives mirrored consists, never the same one twice', () => {
|
||||
const eastbound = collect('west');
|
||||
const westbound = collect('east');
|
||||
assert.notDeepEqual(eastbound, westbound, 'approaching from either end gave the identical consist');
|
||||
assert.deepEqual(eastbound, [...westbound].reverse(), 'the two approaches are not mirror images');
|
||||
});
|
||||
|
||||
it('puts the car the engine met LAST on the nose, running forward', () => {
|
||||
// Running east onto boxcar-then-hopper, the engine pushes the boxcar ahead of it and picks the
|
||||
// hopper up in front of that. Nose first, the tray reads hopper, boxcar.
|
||||
assert.deepEqual(collect('west'), ['hopper', 'boxcar']);
|
||||
assert.deepEqual(collect('east'), ['boxcar', 'hopper']);
|
||||
});
|
||||
|
||||
it('couples behind the train nearest-car-first when backing up', () => {
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 1), straight([car('boxcar'), car('hopper')]));
|
||||
// Facing east at the east end, so backing up runs WEST into the cut: it meets the hopper first.
|
||||
const id = placeTray(s, at(1, 2), [car('caboose')], 'e');
|
||||
switching(s);
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: true });
|
||||
assert.ok(r.ok, `the reverse move was refused: ${r.ok ? '' : r.code}`);
|
||||
assert.deepEqual(types(s.trays.get(id)!.consist), ['caboose', 'hopper', 'boxcar']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('a train may not drive through the cut on its own card (§A.4)', () => {
|
||||
/**
|
||||
* Reported: "if I put cars off the nose on a given track, and my next move is go forward, I need
|
||||
* to couple those cars right back on." The walk started at the NEIGHBOUR of the start square and
|
||||
* never read the start card at all, so the cut was simply left behind.
|
||||
*
|
||||
* The rule is direction-sensitive, which is the point: only the cut at the end the train pulls out
|
||||
* through is in the way. That is what makes setting out off one particular end a decision.
|
||||
*/
|
||||
function runOut(fromNose: boolean, reverse: boolean): { consist: string[]; left: string[] } {
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
const id = placeTray(s, at(1, 1), [car('boxcar'), car('hopper')], 'e', fromNose ? 2 : 0);
|
||||
switching(s);
|
||||
assert.ok(
|
||||
applyIntent(s, 0, {
|
||||
type: 'switch.dropCars',
|
||||
trayId: id,
|
||||
count: 1,
|
||||
...(fromNose ? { fromNose: true } : {}),
|
||||
}).ok,
|
||||
);
|
||||
const to = reverse ? at(1, 0) : at(1, 2);
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to, reverse });
|
||||
assert.ok(r.ok, `the move was refused: ${r.ok ? '' : r.code}`);
|
||||
return { consist: types(s.trays.get(id)!.consist), left: standingAt(s, at(1, 1)) };
|
||||
}
|
||||
|
||||
it('picks a nose cut back up when it pulls forward through it', () => {
|
||||
const out = runOut(true, false);
|
||||
assert.deepEqual(out.left, [], 'the cut was left standing on the card the train pulled out of');
|
||||
assert.equal(out.consist.length, 2, 'the train should have both cars back');
|
||||
});
|
||||
|
||||
it('leaves a nose cut standing when it backs away from it instead', () => {
|
||||
const out = runOut(true, true);
|
||||
assert.deepEqual(out.left, ['boxcar'], 'backing away from a nose cut should leave it where it is');
|
||||
assert.deepEqual(out.consist, ['hopper']);
|
||||
});
|
||||
|
||||
it('encodes §A.5’s trailing-point step 1 — drop the back cars, then pull away from them', () => {
|
||||
// "Drop the back two cars (blue and red) on Card F. Move train to Card B." The whole example
|
||||
// collapses if that first move drags them along again.
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
const id = placeTray(s, at(1, 2), FOUR(), 'w');
|
||||
switching(s);
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }).ok);
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: false });
|
||||
assert.ok(r.ok, `pulling away from the set-out cars was refused: ${r.ok ? '' : r.code}`);
|
||||
assert.deepEqual(types(s.trays.get(id)!.consist), ['boxcar', 'hopper'], 'the train kept cars it had set out');
|
||||
assert.deepEqual(
|
||||
standingAt(s, at(1, 2)),
|
||||
['reefer', 'tank'],
|
||||
'the two cars set out should still be standing on the square the train left',
|
||||
);
|
||||
});
|
||||
|
||||
it('counts the own cut against the four-car limit', () => {
|
||||
// A full tray that sets out one car cannot simply pull forward through it again — that would be
|
||||
// five cars, and coupling is mandatory, so the MOVE is what has to be refused.
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
const id = placeTray(s, at(1, 1), FOUR(), 'e', 4);
|
||||
switching(s);
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||||
// Refill the tray so the recoupled car would be a fifth.
|
||||
s.trays.get(id)!.consist = FOUR();
|
||||
assert.equal(check(s, 0, { type: 'switch.move', trayId: id, to: at(1, 2), reverse: false }), 'ILLEGAL_MOVE');
|
||||
// Backing away from it is still legal — the cut is not at that end.
|
||||
assert.equal(check(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: true }), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the round trip comes back with the train it started with', () => {
|
||||
/**
|
||||
* Drop, run clear, back up, collect. This was already self-consistent before the fix — whatever
|
||||
* order got parked came back — and it has to STAY that way now that both halves have changed, at
|
||||
* both facings and every batch size.
|
||||
*/
|
||||
for (const facing of ['e', 'w'] as const) {
|
||||
for (const batches of [[4], [1, 1, 1, 1], [2, 2]]) {
|
||||
it(`survives ${batches.join('+')} cars set out, facing ${facing}`, () => {
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
const start = facing === 'e' ? at(1, 0) : at(1, 2);
|
||||
const away = facing === 'e' ? at(1, 2) : at(1, 0);
|
||||
const before = types(FOUR());
|
||||
const id = placeTray(s, start, FOUR(), facing);
|
||||
switching(s);
|
||||
turnOf(s, 0).movesRemaining = 6;
|
||||
|
||||
for (const count of batches) {
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count }).ok);
|
||||
}
|
||||
assert.equal(s.trays.get(id)!.consist.length, 0, 'the whole train should be on the ground');
|
||||
|
||||
// Pull forward clear of the cut, then back up onto it again.
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: away, reverse: false }).ok);
|
||||
const back = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: start, reverse: true });
|
||||
assert.ok(back.ok, `backing onto the cut was refused: ${back.ok ? '' : back.code}`);
|
||||
|
||||
assert.deepEqual(types(s.trays.get(id)!.consist), before, 'the train came back in a different order');
|
||||
assert.deepEqual(standingAt(s, start), [], 'cars were left behind');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('taking your own cut back is undoing the drop, not a fresh pick-up', () => {
|
||||
/**
|
||||
* Jesse's call, of the two answers the plan put up. Trains 3/4 print "may drop OR pick up one
|
||||
* freight car at every location" and the two share one budget, so a drop off the nose would
|
||||
* otherwise leave the train forbidden to pull forward past its own cars — a legal-looking move
|
||||
* that is silently one-way. Same shape for X13's "drop but not pick up".
|
||||
*/
|
||||
it('refunds the 3/4 Express its per-location freight budget', () => {
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
const id = placeTray(s, at(1, 1), [car('boxcar'), car('hopper')], 'e', 2);
|
||||
s.trays.get(id)!.trainNumber = 3;
|
||||
switching(s);
|
||||
turnOf(s, 0).movesRemaining = 6;
|
||||
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 2), reverse: false });
|
||||
assert.ok(r.ok, `the Express could not take its own car back: ${r.ok ? '' : r.code}`);
|
||||
assert.equal(s.trays.get(id)!.consist.length, 2, 'the car it set out should be back on the train');
|
||||
assert.deepEqual(
|
||||
Object.values(turnOf(s, 0).freightWorked).filter((n) => n > 0),
|
||||
[],
|
||||
'the drop it undid is still charged against the square it was made on',
|
||||
);
|
||||
});
|
||||
|
||||
it('still charges a genuine pick-up', () => {
|
||||
// The exemption is for YOUR OWN cut and nothing else: a car that was already standing there when
|
||||
// the crew arrived is an ordinary pick-up and the budget still bites.
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 2), straight([car('reefer')]));
|
||||
const id = placeTray(s, at(1, 1), [car('boxcar'), car('hopper')], 'e', 2);
|
||||
s.trays.get(id)!.trainNumber = 3;
|
||||
switching(s);
|
||||
turnOf(s, 0).movesRemaining = 6;
|
||||
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 2), reverse: false }).ok);
|
||||
assert.equal(
|
||||
turnOf(s, 0).freightWorked[`${id}@${coordKey(at(1, 2))}`],
|
||||
1,
|
||||
'only the car that was already standing there should be charged',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('a 45° leg is part of the west-to-east row, not outside it (Gitea#17)', () => {
|
||||
/**
|
||||
* Reported: "Cars were West to East Caboose, Loaded boxcar, Loaded boxcar, Loaded boxcar. After
|
||||
* backing into that square cars were attached to the train Loaded boxcar, Loaded boxcar, Loaded
|
||||
* boxcar, Caboose, Engine." The caboose came back next to the engine instead of at the far end,
|
||||
* which also leaves the train badly made up under §8.2.
|
||||
*
|
||||
* The square was a `sw` CURVE and the train backed in through its SOUTH leg. `standing` runs west
|
||||
* to east, and the two places that walk it both asked the PORT which end of the row they were at:
|
||||
* `exploreMoves` reversed the row for an 'e' entry and for nothing else, and `cutTowards` answered
|
||||
* "you meet nothing" for a north or south exit. Neither is a property of the port.
|
||||
*
|
||||
* A 45° leg leaves through the MIDDLE of its edge, so its end of the run is whichever end the arc
|
||||
* does not reach: the south leg of a `sw` curve is the row's EAST end, and the south leg of an
|
||||
* `se` curve is its WEST end. Same port, opposite answers — which is why `rowEndAt` has to ask the
|
||||
* card.
|
||||
*/
|
||||
const curve = (arc: TrackArc, standing: RollingStock[] = [], standingWest = 0): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
standingWest,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* The reported board, minimally: a `sw` curve holding the cut, and an `ne` curve below it for the
|
||||
* train to run from. Both legs lie on the `ne_sw` diagonal, so the two cards actually join.
|
||||
*/
|
||||
function board(standing: RollingStock[], standingWest = standing.length): GameState {
|
||||
const s = game();
|
||||
addCard(s, at(1, 0), curve('sw', standing, standingWest));
|
||||
addCard(s, at(0, 0), curve('ne'));
|
||||
switching(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
it('backs into a cut through the south leg and meets the EAST end of the row first', () => {
|
||||
const s = board([car('caboose', true), car('boxcar', true), car('boxcar', true), car('boxcar', true)]);
|
||||
// Facing east on the `ne` curve, so reversing pulls out through its north leg and into the
|
||||
// curve above through that card's south leg — the move in the reported save.
|
||||
const id = placeTray(s, at(0, 0), [], 'e');
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: true });
|
||||
assert.ok(r.ok, `the reverse move was refused: ${r.ok ? '' : r.code}`);
|
||||
// Coupled behind the engine nearest-car-first, and the nearest car is the one at the south end
|
||||
// — the LAST of a west-to-east row on a `sw` curve. The caboose was westmost, so it ends up
|
||||
// furthest from the engine, which is where §8.2 needs it.
|
||||
assert.deepEqual(types(s.trays.get(id)!.consist), ['boxcar', 'boxcar', 'boxcar', 'caboose']);
|
||||
});
|
||||
|
||||
it('meets the WEST end of the row first where the same leg belongs to an `se` curve', () => {
|
||||
// The mirror, and the reason the port alone cannot answer: an `se` curve's south leg is the
|
||||
// west end of its row, so the same reverse move meets the caboose first.
|
||||
const s = game();
|
||||
addCard(s, at(1, 0), curve('se', [car('caboose', true), car('boxcar', true)], 2));
|
||||
addCard(s, at(0, 0), curve('nw'));
|
||||
switching(s);
|
||||
const id = placeTray(s, at(0, 0), [], 'w');
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: true });
|
||||
assert.ok(r.ok, `the reverse move was refused: ${r.ok ? '' : r.code}`);
|
||||
assert.deepEqual(types(s.trays.get(id)!.consist), ['caboose', 'boxcar']);
|
||||
});
|
||||
|
||||
it('takes its own cut back with it when it pulls out through the south leg (§A.4)', () => {
|
||||
// The other half of the same assumption: `cutTowards` said a train leaving north or south meets
|
||||
// nothing, so a crew standing on a curve drove away and left the cars beside it standing —
|
||||
// exactly what mandatory coupling forbids.
|
||||
const s = board([car('boxcar', true)], 0);
|
||||
// `standingWest` 0 puts the boxcar EAST of the train, which on a `sw` curve is between it and
|
||||
// the south leg it is about to leave by.
|
||||
const id = placeTray(s, at(1, 0), [], 's');
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: false });
|
||||
assert.ok(r.ok, `the move off the curve was refused: ${r.ok ? '' : r.code}`);
|
||||
assert.deepEqual(types(s.trays.get(id)!.consist), ['boxcar'], 'the cut beside the train was left standing');
|
||||
assert.deepEqual(standingAt(s, at(1, 0)), [], 'the cars should have come off the card');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The Small Yard's re-order menu, reported from Day 3 of the 2026-09-17 playtest.
|
||||
*
|
||||
* Jesse had train 10 on his Small Yard — nose first, `[loaded boxcar, loaded hopper, empty tank,
|
||||
* caboose]` — wanted the boxcar on the tail, and could not tell which button did it: every option
|
||||
* read `re-order consist [1,2,3,0]`, the engine's own array indices offered to a person. The move he
|
||||
* wanted was the FIRST of the five. One of the other four re-ordered nothing at all and would still
|
||||
* have spent one of his six Moves.
|
||||
*/
|
||||
describe('the Small Yard says what each re-order would build', () => {
|
||||
const smallYard = (standing: RollingStock[] = []): TrackCard => ({
|
||||
...straight(standing),
|
||||
enhancements: ['smallYard'],
|
||||
});
|
||||
|
||||
/** Train 10's consist as it actually stood, on a card carrying a Small Yard. */
|
||||
const onTheYard = (): { s: GameState; trayId: string } => {
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 1), smallYard());
|
||||
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true), car('tank'), car('caboose', true)], 'e');
|
||||
switching(s);
|
||||
return { s, trayId };
|
||||
};
|
||||
|
||||
it('never offers a sort that changes nothing', () => {
|
||||
/**
|
||||
* THE PAIR IS WHAT COUNTS, since the engine position became part of a sort (2026-09-17). The
|
||||
* identity car order is now a perfectly good option when it moves the ENGINE — that is the whole
|
||||
* of the separate engine control — so what must never be offered is the pair that reproduces the
|
||||
* train already standing there, and no pair may appear twice.
|
||||
*/
|
||||
const { s, trayId } = onTheYard();
|
||||
const tray = s.trays.get(trayId)!;
|
||||
const offered = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => {
|
||||
const sort = i as { order: number[]; engineAt?: number };
|
||||
return `${sort.order.join(',')}|${sort.engineAt ?? 0}`;
|
||||
});
|
||||
assert.ok(offered.length > 0, 'no sort was offered at all, so this proved nothing');
|
||||
const unchanged = `${[...tray.consist.keys()].join(',')}|${tray.engineAt}`;
|
||||
assert.ok(
|
||||
!offered.includes(unchanged),
|
||||
'the menu offered the train as it already stands — a Move spent to change nothing',
|
||||
);
|
||||
assert.equal(new Set(offered).size, offered.length, 'the menu offered the same sort twice');
|
||||
});
|
||||
|
||||
it('offers the engine every position, including ahead of its own cars', () => {
|
||||
// Jesse's ruling, 2026-09-17, following `implications.md` against the v0.4.5 card text: a train
|
||||
// in a Small Yard "may sort itself into any order, INCLUDING cars ahead of the engine".
|
||||
const { s, trayId } = onTheYard();
|
||||
const n = s.trays.get(trayId)!.consist.length;
|
||||
const positions = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => (i as { engineAt?: number }).engineAt ?? 0);
|
||||
for (let k = 1; k <= n; k++) {
|
||||
assert.ok(positions.includes(k), `the engine was never offered position ${k} of ${n}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('always offers a made-up order to a train that is not in one', () => {
|
||||
/**
|
||||
* Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the back… sorting all
|
||||
* the cars back in, ready to leave the station."
|
||||
*
|
||||
* A train with its caboose mid-consist is one §8.2 will not let out of the Office, so at least
|
||||
* one offer has to put it right. It comes out of the ordinary curated set — "bring car k to the
|
||||
* tail" is enumerated for every car, and the caboose is one of them.
|
||||
*/
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 1), smallYard());
|
||||
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('caboose', true), car('hopper', true)], 'e');
|
||||
const tray = s.trays.get(trayId)!;
|
||||
tray.trainNumber = 10;
|
||||
switching(s);
|
||||
|
||||
const fit = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.filter((i) => {
|
||||
const sort = i as { order: number[]; engineAt?: number };
|
||||
const after = sort.order.map((n) => tray.consist[n]!);
|
||||
return badlyMadeUp({ ...tray, consist: after, engineAt: sort.engineAt ?? 0 }) === null;
|
||||
});
|
||||
assert.ok(
|
||||
fit.length > 0,
|
||||
'a train that cannot leave the Office was offered no sort that would make it up',
|
||||
);
|
||||
// And the one that does it says so on the button, rather than leaving it to be discovered.
|
||||
assert.ok(
|
||||
fit.some((i) => describeIntent(s, i).includes('MADE UP, ready to leave')),
|
||||
'the sort that makes the train up does not say so',
|
||||
);
|
||||
});
|
||||
|
||||
it('lays the train out west to east, with the engine where it will be', () => {
|
||||
/**
|
||||
* "You specify above that the order is front to back, but on the screen, if it's eastbound or
|
||||
* westbound, it may look different" — Jesse, 2026-09-17.
|
||||
*
|
||||
* `board-svg.ts` draws the crew strip west on the left, reversing a consist for an east-facing
|
||||
* train so its nose lands at the east end. The button has to read the same way or it describes a
|
||||
* different train from the one on the board.
|
||||
*/
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 1), smallYard());
|
||||
// Engine points WEST: the consist is stored nose first, so west to east reads engine first.
|
||||
const west = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true)], 'w');
|
||||
switching(s);
|
||||
const westLabel = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => describeIntent(s, i))
|
||||
.find((l) => l.startsWith('re-order'));
|
||||
assert.ok(westLabel, 'no re-order was offered for the west-facing train');
|
||||
assert.match(westLabel, /west to east: ◀ ENGINE · /, `a west-facing engine was not drawn leading: ${westLabel}`);
|
||||
|
||||
// The same train pointing EAST puts the engine at the far end of the same sentence.
|
||||
s.trays.get(west)!.railFacing = 'e';
|
||||
s.trays.get(west)!.facing = 'e';
|
||||
const eastLabel = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => describeIntent(s, i))
|
||||
.find((l) => l.startsWith('re-order'));
|
||||
assert.ok(eastLabel, 'no re-order was offered for the east-facing train');
|
||||
assert.match(eastLabel, / · ENGINE ▶($| ·)/, `an east-facing engine was not drawn at the east end: ${eastLabel}`);
|
||||
});
|
||||
|
||||
it('labels each option with the train it would make, not with array indices', () => {
|
||||
const { s } = onTheYard();
|
||||
const labels = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => describeIntent(s, i));
|
||||
|
||||
assert.ok(
|
||||
labels.every((l) => !/\[\d(,\d)*\]/.test(l)),
|
||||
`a re-order option still reads as a permutation: ${labels.find((l) => /\[\d(,\d)*\]/.test(l))}`,
|
||||
);
|
||||
/**
|
||||
* The one Jesse wanted, written the way the board draws it: this crew faces EAST, so the strip
|
||||
* runs west to east and the engine sits at the east end with the boxcar now furthest west.
|
||||
*/
|
||||
assert.ok(
|
||||
labels.includes('re-order — west to east: loaded boxcar · caboose · empty tank · loaded hopper · ENGINE ▶'),
|
||||
`the move that puts the boxcar on the tail was not offered in words — got: ${labels.join(' | ')}`,
|
||||
);
|
||||
// And the engine's own positions read as what they do, not as an index.
|
||||
assert.ok(
|
||||
labels.some((l) => l.startsWith('put the whole consist ahead of the engine —')),
|
||||
`the shoving sort was not offered in words — got: ${labels.join(' | ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* WHY A SQUARE IS REFUSED, AND THE ONE PICK-UP EVERY TRAIN MAY MAKE.
|
||||
*
|
||||
* Asked from a table, 2026-09-23: "where does it show that you can't make a particular move because
|
||||
* of a rule that's violated… how does a user know what rule is violated and why you can't go
|
||||
* there?" `exploreMoves` decides where the rails go; the train's own card is enforced afterwards in
|
||||
* `check` — so a square the rails reach and the card forbids was reachable, un-offered, and absent
|
||||
* from the block list with no reason given.
|
||||
*/
|
||||
describe("a train's own card explains the squares it may not enter", () => {
|
||||
/** A drop-only Extra (X13 "may drop MTs but not pick up anything") on row 1, with track beside it. */
|
||||
const dropOnlyAt = (s: GameState, cars: RollingStock[]): { trayId: string; there: GridCoord } => {
|
||||
row(s, 3);
|
||||
switching(s);
|
||||
const trayId = placeTray(s, at(1, 0), [], 'e');
|
||||
const tray = s.trays.get(trayId)!;
|
||||
tray.trainNumber = 13;
|
||||
tray.trainIsExtra = true;
|
||||
const there = at(1, 1);
|
||||
areaOf(s, 0).grid.get(coordKey(there))!.standing = cars;
|
||||
return { trayId, there };
|
||||
};
|
||||
|
||||
it('reports a drop-only train as blocked, in words, rather than silently', () => {
|
||||
const s = game();
|
||||
const { trayId, there } = dropOnlyAt(s, [{ type: 'boxcar', loaded: false }]);
|
||||
|
||||
const { to, blocked } = movesFor(s, 0, trayId);
|
||||
const k = (c: GridCoord): string => `${c.row},${c.col}`;
|
||||
assert.ok(!to.some((c) => k(c) === k(there)), 'a square the card forbids is still offered');
|
||||
|
||||
const b = blocked.find((x) => k(x.coord) === k(there));
|
||||
assert.ok(b, 'the forbidden square is missing from the block list entirely — no reason is shown');
|
||||
assert.equal(b!.kind, 'cardRule', "the block is not attributed to the train's card");
|
||||
assert.match(b!.why, /forbids picking cars up/, `the reason does not name the rule: ${b!.why}`);
|
||||
assert.match(b!.why, /coupling is mandatory/, `the reason does not say why it bites: ${b!.why}`);
|
||||
});
|
||||
|
||||
it('lets a drop-only train recover its OWN caboose', () => {
|
||||
/**
|
||||
* JESSE'S RULING, 2026-09-23. X13 prints "may drop MTs but not pick up anything", and a train
|
||||
* needs its caboose at the far end to be made up (§8.2) — so a train that parted with its
|
||||
* caboose could never legally leave again. It stranded itself, permanently and silently.
|
||||
*
|
||||
* The caboose only, not "your own cars" generally: it is the one car whose absence stops the
|
||||
* train departing, so recovering it repairs a consist rather than doing fresh work.
|
||||
*/
|
||||
const s = game();
|
||||
const { trayId, there } = dropOnlyAt(s, [{ type: 'caboose', loaded: false }]);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'switch.move', trayId, to: there, reverse: false }),
|
||||
null,
|
||||
'a drop-only train may not recover its own caboose, so it can never be made up again',
|
||||
);
|
||||
|
||||
// A boxcar in the same place is still a pick-up and still refused.
|
||||
areaOf(s, 0).grid.get(coordKey(there))!.standing = [{ type: 'boxcar', loaded: false }];
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'switch.move', trayId, to: there, reverse: false }),
|
||||
'PICKUP_NOT_ALLOWED',
|
||||
'the caboose exemption leaked into ordinary cars',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* WHAT THE ENGINE KNOWS AND THE SCREEN DOES NOT — the 2026-09-07 sweep.
|
||||
*
|
||||
* Gitea#21, Gitea#22, #94 and #96 were four instances of one fault in a row: the engine gained a
|
||||
* thing that changes what a train may do, and nothing drew it. Each was found by a player hitting
|
||||
* it. So rather than wait for the fifth, every field of `GameState` and its nested types was
|
||||
* enumerated and checked for a reader in `sim/view.ts`, `src/web/` and `sim/narrate.ts`, and the
|
||||
* survivors were then verified by running the engine rather than by trusting the grep.
|
||||
*
|
||||
* Four fields had no reader anywhere. `movedThisPhase` is set and cleared inside one `advance` call
|
||||
* and is genuinely nobody's business. The other three are these tests. The bookkeeping fields whose
|
||||
* EFFECT is already visible as legality — `freightWorked`, `drawnThisTurn`, `freightAgentUsed`,
|
||||
* `movesUsed` (its complement `movesRemaining` is on the Frame), `switchedSince` — are deliberately
|
||||
* not here: a field is not a display gap merely because no one renders it.
|
||||
*
|
||||
* The method is worth more than the three fixes, and is written down in TODO Reference · #98.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { crewTrayCount, enhancementText } from '../src/engine/content.ts';
|
||||
import { areaOf } from '../src/engine/apply.ts';
|
||||
import { impediments } from '../src/sim/narrate.ts';
|
||||
import { projectDistrict, projectSharedTable, publicSnapshot, trainRules } from '../src/sim/view.ts';
|
||||
import type { CrewTray, GameConfig, GameState, PlayerIndex, SeatIndex } from '../src/engine/state.ts';
|
||||
import { seatOf } from '../src/engine/state.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'competitive',
|
||||
days: 3,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
|
||||
const game = (): GameState => {
|
||||
const s = createGame({ id: 'g', seed: 4242, config, playerNames: ['Ann', 'Bob'] });
|
||||
s.status = 'active';
|
||||
return s;
|
||||
};
|
||||
|
||||
/**
|
||||
* THE CREW TRAY POOL IS A MECHANIC, AND IT WAS INVISIBLE (#98).
|
||||
*
|
||||
* `state.ts` calls tray scarcity "an explicit mechanic": there are fewer trays than there are trains
|
||||
* wanting one, and which trains get held is the whole of §7. The engine knows three things about it
|
||||
* — how many trays are free, which Extras are queued for one, and which second sections are — and
|
||||
* the view read none of them.
|
||||
*
|
||||
* The panel that answers "why is nothing moving?" had exactly one tray rule, keyed off the train due
|
||||
* out THIS Stage (`s.timetable[stage - 1]`). So a player who spent a card on an Extra, or ordered a
|
||||
* second section, got a blocked panel that was completely EMPTY while their train sat behind an
|
||||
* exhausted pool — and both had been announced once in the log, in a line that promised a future
|
||||
* event ("as soon as a Crew Tray frees up") which nothing then confirmed.
|
||||
*/
|
||||
describe('the Crew Tray pool is a mechanic the player can see (#98)', () => {
|
||||
/** A table whose trays are all out, with one Extra and one second section queued behind them. */
|
||||
const jammed = (): GameState => {
|
||||
const s = game();
|
||||
s.freeTrays = [];
|
||||
s.pendingExtras.push({ trainNumber: 17, player: 0 as PlayerIndex });
|
||||
s.pendingSecondSections.push(8);
|
||||
return s;
|
||||
};
|
||||
|
||||
it('reports how many Crew Trays are free, and how many there are', () => {
|
||||
const s = game();
|
||||
const total = crewTrayCount(2);
|
||||
assert.equal(s.freeTrays.length, total, 'the premise is gone: trays were already out at setup');
|
||||
assert.deepEqual(projectSharedTable(s).crewTrays, { free: total, total });
|
||||
|
||||
s.freeTrays = s.freeTrays.slice(0, 1);
|
||||
assert.deepEqual(
|
||||
projectSharedTable(s).crewTrays,
|
||||
{ free: 1, total },
|
||||
'the pool emptied and the shared table did not notice',
|
||||
);
|
||||
});
|
||||
|
||||
it('names the trains queued for a tray, so a promise made in the log is kept on the board', () => {
|
||||
const q = projectSharedTable(jammed()).queued;
|
||||
assert.deepEqual(q.extras, [{ trainNumber: 17, player: 0 }], 'a played Extra is waiting nowhere visible');
|
||||
assert.deepEqual(q.secondSections, [8], 'an ordered second section is waiting nowhere visible');
|
||||
});
|
||||
|
||||
it('tells the player who played the Extra that it is held for want of a crew', () => {
|
||||
const blocked = impediments(jammed(), 0 as PlayerIndex);
|
||||
const extra = blocked.find((b) => /X17/.test(b.where));
|
||||
assert.ok(extra, 'the blocked panel said nothing about an Extra held for want of a Crew Tray');
|
||||
assert.match(extra.why, /Crew Tray/, 'it was listed without naming the thing it is waiting for');
|
||||
assert.equal(extra.severity, 'stuck');
|
||||
// The POOL SIZE, not a second derivation of it. Written as `trays.size + freeTrays.length`
|
||||
// first, which reads 0 of 0 for any state where a tray is neither free nor carrying a train.
|
||||
assert.match(
|
||||
extra.why,
|
||||
new RegExp(`0 of ${crewTrayCount(2)} Crew Trays free`),
|
||||
'the panel reported the wrong pool size',
|
||||
);
|
||||
});
|
||||
|
||||
it('says the same for a second section, which waits on the identical pool', () => {
|
||||
const blocked = impediments(jammed(), 0 as PlayerIndex);
|
||||
const second = blocked.find((b) => /Train 8\b/.test(b.where) && /second section/i.test(b.why));
|
||||
assert.ok(second, 'an ordered second section was queued invisibly');
|
||||
assert.match(second.why, /Crew Tray/);
|
||||
});
|
||||
|
||||
it('says none of it once a tray is free, because then nothing is being waited on', () => {
|
||||
const s = jammed();
|
||||
s.freeTrays = ['tray0'];
|
||||
const blocked = impediments(s, 0 as PlayerIndex);
|
||||
assert.equal(
|
||||
blocked.filter((b) => /Crew Tray/.test(b.why)).length,
|
||||
0,
|
||||
'a free tray still reported trains held for want of one',
|
||||
);
|
||||
});
|
||||
|
||||
it('is on the common board too — the pool is on the table, not in a hand', () => {
|
||||
const pub = publicSnapshot(jammed()) as unknown as Record<string, unknown>;
|
||||
assert.ok('crewTrays' in pub, 'a spectator cannot see the scarcity everyone at the table can');
|
||||
assert.ok('queued' in pub);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A TRAIN HELD AT THE LIMITS IS STILL ON THE BOARD (#99).
|
||||
*
|
||||
* The Interlocking enhancement is the designed answer to a full Office: instead of the automatic
|
||||
* collision of Gap 2d, "may stop an inbound train on the Limit Track" — the train is held inside the
|
||||
* player's Limits, and takes the first A/D track that frees, ahead of any newcomer.
|
||||
*
|
||||
* It was drawn NOWHERE. `arriveAtOffice` removes the tray from the Mainline node's `transits`
|
||||
* (advance.ts) and the Interlocking branch pushes it onto `area.heldAtLimits` without assigning
|
||||
* `tray.position` — so the map, which draws mainline nodes from `transits` and district squares from
|
||||
* `position.at === 'grid'`, has nothing to draw it from in either place. The train vanished off the
|
||||
* board on arrival and reappeared in the Office some Stages later.
|
||||
*
|
||||
* Measured before fixing: with the tray in `transits` the Interchange node carries its chip; with
|
||||
* the tray moved to `heldAtLimits` exactly as the engine moves it, the node's `trains` is empty and
|
||||
* no grid square has gained it.
|
||||
*
|
||||
* The fix is in the VIEW, not the engine. The engine's state is right — a held train is inside the
|
||||
* Limits and not on an A/D track, which is what `heldAtLimits` says — and `position` is left alone
|
||||
* deliberately, so nothing may treat the train as standing on a square it could be switched from.
|
||||
*/
|
||||
describe('a train held at the Limits is drawn at the Limits (#99)', () => {
|
||||
const held = (direction: 'east' | 'west') => {
|
||||
const s = game();
|
||||
const seat = seatOf(s, 0 as PlayerIndex);
|
||||
const area = areaOf(s, 0 as PlayerIndex);
|
||||
const trayId = s.freeTrays.pop()!;
|
||||
const tray: CrewTray = {
|
||||
id: trayId,
|
||||
trainNumber: 5,
|
||||
trainIsExtra: false,
|
||||
engineAt: 0,
|
||||
consist: [],
|
||||
direction,
|
||||
movesUsed: 0,
|
||||
position: { at: 'mainline', index: 1 },
|
||||
} as CrewTray;
|
||||
s.trays.set(trayId, tray);
|
||||
area.heldAtLimits.push(trayId);
|
||||
return { s, seat, trayId, area };
|
||||
};
|
||||
|
||||
it('draws it on the Limits square it is standing at, not nowhere', () => {
|
||||
const { s, seat, trayId, area } = held('east');
|
||||
const { cells } = projectDistrict(s, seat as SeatIndex);
|
||||
const on = cells.filter((c) => c.trains.some((t) => t.trayId === trayId));
|
||||
assert.equal(on.length, 1, 'a train held at the Limits is drawn on no square at all');
|
||||
assert.equal(on[0]!.row, area.limitsWest.row, 'drawn at the wrong Limits');
|
||||
assert.equal(on[0]!.col, area.limitsWest.col);
|
||||
});
|
||||
|
||||
it('holds an EASTBOUND train at the western Limits, because that is the end it came in by', () => {
|
||||
const { s, seat, area } = held('east');
|
||||
const { cells } = projectDistrict(s, seat as SeatIndex);
|
||||
const on = cells.find((c) => c.trains.length > 0)!;
|
||||
assert.deepEqual({ row: on.row, col: on.col }, { row: area.limitsWest.row, col: area.limitsWest.col });
|
||||
});
|
||||
|
||||
it('and a WESTBOUND train at the eastern Limits', () => {
|
||||
const { s, seat, area } = held('west');
|
||||
const { cells } = projectDistrict(s, seat as SeatIndex);
|
||||
const on = cells.find((c) => c.trains.length > 0)!;
|
||||
assert.deepEqual({ row: on.row, col: on.col }, { row: area.limitsEast.row, col: area.limitsEast.col });
|
||||
});
|
||||
|
||||
it('says on the chip that it is HELD, so it is not read as a train free to switch', () => {
|
||||
const { s, seat, trayId } = held('east');
|
||||
const { cells } = projectDistrict(s, seat as SeatIndex);
|
||||
const chip = cells.flatMap((c) => c.trains).find((t) => t.trayId === trayId)!;
|
||||
assert.equal(chip.heldAtLimits, true, 'a held train looked exactly like one standing on the square');
|
||||
assert.match(chip.what, /Interlocking|held/i, 'the chip does not say why it is standing there');
|
||||
});
|
||||
|
||||
it('tells the district owner it is waiting, and what for', () => {
|
||||
const { s } = held('east');
|
||||
const blocked = impediments(s, 0 as PlayerIndex);
|
||||
const b = blocked.find((x) => /Train 5/.test(x.where) && /Limits/i.test(x.why));
|
||||
assert.ok(b, 'the blocked panel said nothing about a train held at the Limits');
|
||||
});
|
||||
|
||||
it('does not invent a train on a square when nothing is held', () => {
|
||||
const s = game();
|
||||
const { cells } = projectDistrict(s, seatOf(s, 0 as PlayerIndex) as SeatIndex);
|
||||
assert.equal(cells.flatMap((c) => c.trains).length, 0, 'a district with no trains drew one');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* THE CAMPAIGN TRAIN'S SPEECHES CHANGE ITS RULES, AND THE CARD HAS TO SAY WHICH HALF IT IS IN (#100).
|
||||
*
|
||||
* X17 is "one turn at station (speeches) then expedite". Its first Office arrival is an ordinary
|
||||
* stop; every arrival after that runs EXPEDITED, which means that if it is not back on the Office
|
||||
* square when the next Mainline Phase begins it is a Station Master fault costing 1 Revenue.
|
||||
*
|
||||
* `trainRules()` took `{ trainNumber, trainIsExtra }` — it could not see `speechMade` even though
|
||||
* both of its tray-side callers pass a whole `CrewTray` that has it. So the chip read identically
|
||||
* before and after, and worse: the "EXPEDITED — must be kept ready to highball… costs 1 Revenue"
|
||||
* warning is printed only under `rules.expedite`, so X17 became subject to a fault whose warning the
|
||||
* game shows to other trains and never to it.
|
||||
*/
|
||||
describe('the Campaign Train says whether its speeches are made (#100)', () => {
|
||||
const X17 = { trainNumber: 17, trainIsExtra: true } as const;
|
||||
|
||||
it('before the speeches, says they are still to come and does not claim it is expedited yet', () => {
|
||||
const t = trainRules({ ...X17 });
|
||||
assert.match(t, /SPEECHES/i);
|
||||
assert.doesNotMatch(t, /costs 1 Revenue/, 'it warned of a fault the train is not yet subject to');
|
||||
});
|
||||
|
||||
it('after the speeches, says it is expedited NOW and carries the fault it is now subject to', () => {
|
||||
const t = trainRules({ ...X17, speechMade: true });
|
||||
assert.match(t, /EXPEDITED/, 'a train that is now expedited did not say so');
|
||||
assert.match(t, /costs 1 Revenue/, 'the fault warning is shown to other trains and not to this one');
|
||||
});
|
||||
|
||||
it('reads differently before and after — the whole of the bug was that it did not', () => {
|
||||
assert.notEqual(trainRules({ ...X17 }), trainRules({ ...X17, speechMade: true }));
|
||||
});
|
||||
|
||||
it('still warns a permanently expedited train, which must not regress', () => {
|
||||
const fast = trainRules({ trainNumber: 5, trainIsExtra: false });
|
||||
assert.match(fast, /EXPEDITED/);
|
||||
assert.match(fast, /costs 1 Revenue/);
|
||||
});
|
||||
|
||||
it('says nothing about speeches for a train that has no such rule', () => {
|
||||
assert.doesNotMatch(trainRules({ trainNumber: 5, trainIsExtra: false }), /SPEECHES/i);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A DISPATCH DEVICE SAYS WHETHER IT IS STILL AVAILABLE TODAY (#101).
|
||||
*
|
||||
* Telegraph (+4), Telephone (+8) and Radio (+12) are "once a day, when dispatching facing trains,
|
||||
* add +N to the other train's number". The device is drawn on its card and its effect text is in the
|
||||
* tooltip — but `enhancementText(key)` takes only the KEY, so it could not vary with anything, and
|
||||
* the Radio read "once a day, add +12…" all Day after it had been spent. That is `trainRules` before
|
||||
* #100, in a different corner of the same view.
|
||||
*
|
||||
* THE SECOND HALF IS THE ONE THAT SURPRISES. `spendDispatchBonus` reads
|
||||
* `areaOf(s, s.clock.superintendent)` — the SUPERINTENDENT's own devices, not the train owner's —
|
||||
* and the Fedora moves every `STAGES_PER_SHIFT` Stages, four times a Day. So a player's Radio does
|
||||
* nothing at all for three-quarters of the Day, and is spent automatically, without being asked,
|
||||
* during the quarter it is theirs to use. The board said neither half.
|
||||
*
|
||||
* Shown on EVERY district (Jesse, 2026-09-07), not only the viewer's: it is public, and a rival's
|
||||
* spent Radio is exactly what you want to know before forcing a meet. `projectDistrict` serves both
|
||||
* the player's own cells and the common board's `districts`, so one change covers both.
|
||||
*/
|
||||
describe('a dispatch device says whether it is still available today (#101)', () => {
|
||||
/** Puts `key` on the first card of seat 0's district and returns the pieces to assert on. */
|
||||
const withDevice = (key: string, opts: { spent?: boolean; fedora?: boolean } = {}) => {
|
||||
const s = game();
|
||||
const area = areaOf(s, 0 as PlayerIndex);
|
||||
const card = [...area.grid.values()][0]!;
|
||||
card.enhancements.push(key);
|
||||
if (opts.spent) area.dispatchUsedToday.push(key);
|
||||
// The Fedora is a PLAYER; give it to somebody whose seat is not this district's.
|
||||
s.clock.superintendent = (opts.fedora ? 0 : 1) as PlayerIndex;
|
||||
const seat = seatOf(s, 0 as PlayerIndex);
|
||||
const cells = projectDistrict(s, seat as SeatIndex).cells;
|
||||
const cell = cells.find((c) => c.enhancements.length > 0)!;
|
||||
const i = cell.enhancementsWhat.findIndex((w) => new RegExp(key, 'i').test(w));
|
||||
return { s, area, cell, what: cell.enhancementsWhat[i] ?? '', spent: cell.enhancementsSpent[i] };
|
||||
};
|
||||
|
||||
it('reads as available before it is used, and says what it is worth', () => {
|
||||
const { what, spent } = withDevice('radio', { fedora: true });
|
||||
assert.equal(spent, false, 'an unused device reported itself spent');
|
||||
assert.match(what, /\+12/, 'the device no longer says what it is worth');
|
||||
assert.doesNotMatch(what, /spent/i, 'an unused device claimed it had been spent');
|
||||
});
|
||||
|
||||
it('says so once it has been spent, and says when it comes back', () => {
|
||||
const { what, spent } = withDevice('radio', { spent: true, fedora: true });
|
||||
assert.equal(spent, true, 'a spent device still reported itself available');
|
||||
assert.match(what, /spent/i, 'a spent device read exactly as it did before it was spent');
|
||||
assert.match(what, /next Day|tomorrow/i, 'it does not say the device comes back');
|
||||
});
|
||||
|
||||
it('reads differently spent and unspent — the whole of the bug was that it did not', () => {
|
||||
assert.notEqual(
|
||||
withDevice('telegraph', { fedora: true }).what,
|
||||
withDevice('telegraph', { spent: true, fedora: true }).what,
|
||||
);
|
||||
});
|
||||
|
||||
it('says a device is idle while somebody else holds the Fedora', () => {
|
||||
const { what } = withDevice('telephone');
|
||||
assert.match(what, /Fedora|Superintendent/i, 'nothing said the device is only used while dispatching');
|
||||
});
|
||||
|
||||
it('does not say that when this district IS the Superintendent', () => {
|
||||
const { what } = withDevice('telephone', { fedora: true });
|
||||
assert.doesNotMatch(what, /while .*holds? the Fedora/i);
|
||||
});
|
||||
|
||||
/**
|
||||
* EXACT equality, not "does not mention the Fedora". The first draft asserted the absence of
|
||||
* /spent|Fedora/i with the Fedora held, and a mutation that removed the `dispatchBonus` guard
|
||||
* altogether PASSED it — because the leaked text in that case reads "Available today, and this
|
||||
* district is dispatching", which contains neither word. A test for a field being left alone has
|
||||
* to compare it to what it should be.
|
||||
*/
|
||||
it('leaves an enhancement that is not a dispatch device exactly as it was', () => {
|
||||
for (const fedora of [true, false]) {
|
||||
const { what, spent } = withDevice('interlocking', { fedora });
|
||||
assert.equal(spent, false, 'a non-dispatch enhancement was marked spendable');
|
||||
assert.equal(what, enhancementText('interlocking'), 'an Interlocking was given a dispatch caveat');
|
||||
}
|
||||
});
|
||||
|
||||
it('comes back when the Day turns, which is what clears the record', () => {
|
||||
const { s, area } = withDevice('radio', { spent: true, fedora: true });
|
||||
area.dispatchUsedToday = [];
|
||||
const cell = projectDistrict(s, seatOf(s, 0 as PlayerIndex) as SeatIndex).cells.find(
|
||||
(c) => c.enhancements.length > 0,
|
||||
)!;
|
||||
assert.equal(cell.enhancementsSpent.some((x) => x), false, 'a new Day did not restore the device');
|
||||
});
|
||||
|
||||
/**
|
||||
* SEAT IS NOT PLAYER INDEX, and this is the one place the two are joined: the Fedora is held by a
|
||||
* PLAYER and the devices sit in an Office Area keyed by SEAT. `advance.ts` carried a comment
|
||||
* warning that indexing one with the other was safe only while seating was the identity map — it
|
||||
* read as a live bug and was not one, because `areaOf` resolves through `seatOf`. The comment is
|
||||
* corrected; this is the guard, because the next reader deserves better than a claim.
|
||||
*
|
||||
* §4.4's D12 makes seating a real permutation, so a two-player game where player 1 sits in seat 0
|
||||
* is ordinary rather than contrived.
|
||||
*/
|
||||
it("marks the SUPERINTENDENT's own district as dispatching under non-identity seating", () => {
|
||||
const s = game();
|
||||
s.seating = [1, 0] as PlayerIndex[];
|
||||
assert.notEqual(seatOf(s, 0 as PlayerIndex), 0, 'the premise is gone: seating is still identity');
|
||||
s.clock.superintendent = 0 as PlayerIndex;
|
||||
|
||||
const mine = areaOf(s, 0 as PlayerIndex);
|
||||
[...mine.grid.values()][0]!.enhancements.push('radio');
|
||||
const other = areaOf(s, 1 as PlayerIndex);
|
||||
[...other.grid.values()][0]!.enhancements.push('radio');
|
||||
|
||||
const whatAt = (player: PlayerIndex): string => {
|
||||
const cells = projectDistrict(s, seatOf(s, player) as SeatIndex).cells;
|
||||
return cells.find((c) => c.enhancements.length > 0)!.enhancementsWhat.join(' ');
|
||||
};
|
||||
// The Fedora is player 0's, whatever seat that is.
|
||||
assert.doesNotMatch(whatAt(0 as PlayerIndex), /IDLE/, "the Superintendent's own device read as idle");
|
||||
assert.match(whatAt(1 as PlayerIndex), /IDLE/, "somebody else's device read as dispatching");
|
||||
});
|
||||
|
||||
it('is on every district of the common board, not only the viewer own', () => {
|
||||
const { s } = withDevice('radio', { spent: true, fedora: true });
|
||||
const pub = publicSnapshot(s);
|
||||
const all = pub.districts.flatMap((d) => d.cells);
|
||||
assert.ok(
|
||||
all.some((c) => c.enhancementsSpent.some((x) => x)),
|
||||
"a spectator cannot see which devices are spent in a player's district",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* THE RED FLAG HOLDER IS NOT A PER-PLAYER FACT, so it does not belong on the public player view.
|
||||
*
|
||||
* `docs/plans/jitsi-common-board.md` step 1 asks for one: "Add the Red Flag holder to the public
|
||||
* player projection. It is public game state but is currently absent from `Frame`", and its
|
||||
* `PublicPlayerView` carries `redFlagHeld: boolean`. Every other step-1 item shipped across v0.7.9.2
|
||||
* to v0.7.9.5; this one is STRUCK OFF instead, because the premise does not hold in this codebase.
|
||||
*
|
||||
* `decks.redFlags` is written in exactly one place — `setup.ts`, from
|
||||
* `config.optionalRules.emergencyToolbox` — and never again. There is no `.set` anywhere else, and
|
||||
* `redFlag.play` (the intent gated on holding one) emits a `phaseEnded` event and does not spend it.
|
||||
* So every player holds one or none of them does, decided before the first card is dealt.
|
||||
*
|
||||
* A `redFlagHeld` on each player would therefore be `optionalRules.emergencyToolbox` copied N times
|
||||
* — already on the public projection — while implying to every reader of the common board that it
|
||||
* varies by player and might change during a game. That is worse than the absence.
|
||||
*
|
||||
* This test exists so the plan item is not re-raised from the plan text: if the rule ever DOES
|
||||
* become per-player, this fails and the projection is the right place to look.
|
||||
*/
|
||||
describe('the Red Flag holding is the Emergency Toolbox option, not a per-player fact', () => {
|
||||
const dealt = (emergencyToolbox: boolean): GameState =>
|
||||
createGame({
|
||||
id: 'g',
|
||||
seed: 99,
|
||||
config: { ...config, optionalRules: { ...config.optionalRules, emergencyToolbox } },
|
||||
playerNames: ['Ann', 'Bob', 'Cy'],
|
||||
});
|
||||
|
||||
for (const toolbox of [true, false]) {
|
||||
it(`gives every player the same answer with the toolbox ${toolbox ? 'on' : 'off'}`, () => {
|
||||
const s = dealt(toolbox);
|
||||
const held = s.players.map((p) => s.decks.redFlags.get(p.index) === true);
|
||||
assert.deepEqual(held, [toolbox, toolbox, toolbox], 'the Red Flag has become a per-player fact');
|
||||
});
|
||||
}
|
||||
|
||||
it('is already public, through the option it comes from', () => {
|
||||
const pub = publicSnapshot(dealt(true));
|
||||
assert.equal(
|
||||
pub.optionalRules.emergencyToolbox,
|
||||
true,
|
||||
'a spectator cannot tell whether the hand limit is three or four',
|
||||
);
|
||||
});
|
||||
});
|
||||
+422
-39
@@ -12,15 +12,24 @@ import assert from 'node:assert/strict';
|
||||
import { advance } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDerail } from '../src/engine/apply.ts';
|
||||
import { ENHANCEMENT_RULES, enhancementRule, trainProfile } from '../src/engine/content.ts';
|
||||
import { officeProfile } from '../src/engine/content.ts';
|
||||
import { narrate } from '../src/sim/narrate.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||
import { coordKey, subdivisions } from '../src/engine/state.ts';
|
||||
import { coordKey, decisionActor, subdivisions, turnOf } from '../src/engine/state.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'standard',
|
||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||
days: 5,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||||
@@ -29,6 +38,7 @@ const straight = (): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -38,7 +48,15 @@ function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
|
||||
areaOf(s, 0).grid.set(coordKey(coord), card);
|
||||
}
|
||||
|
||||
/** Puts an enhancement card in hand and returns its id. */
|
||||
/**
|
||||
* An Enhancement in hand — minting one if the deck does not deal it.
|
||||
*
|
||||
* Facing Point Locks, the Water Column and the Overpass answer opponent-directed cards, which are
|
||||
* held out of every deck until they are implemented, so those three are no longer dealt either
|
||||
* (v0.4.7). Their RULES are still live and still worth testing: they fire the moment their attacker
|
||||
* returns, and a rule nobody exercises is a rule that rots. So the card is put in hand directly
|
||||
* rather than fished out of a deck that deliberately no longer contains it.
|
||||
*/
|
||||
function handEnhancement(s: GameState, key: string): string {
|
||||
for (const [id, card] of s.cards) {
|
||||
if (card.kind.kind === 'enhancement' && card.kind.key === key) {
|
||||
@@ -46,7 +64,10 @@ function handEnhancement(s: GameState, key: string): string {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
throw new Error(`no enhancement card: ${key}`);
|
||||
const id = `mint-${key}-${s.cards.size}`;
|
||||
s.cards.set(id, { id, kind: { kind: 'enhancement', key } } as never);
|
||||
s.decks.hands.set(0, [id]);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Puts a specific track card in hand and returns its id. */
|
||||
@@ -65,7 +86,7 @@ function placeTray(s: GameState, coord: GridCoord, consist: TrackCard['standing'
|
||||
const id = s.freeTrays.pop()!;
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||
consist, direction: 'east', position: { at: 'grid', owner: 0, coord }, movesUsed: 0,
|
||||
consist, direction: 'east', position: { at: 'grid', seat: 0, coord }, movesUsed: 0,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
@@ -197,9 +218,9 @@ describe('Small Yard — the card that makes switching solvable', () => {
|
||||
|
||||
it('costs one Move — "spends one move in the yard"', () => {
|
||||
const { s, tray } = yardGame();
|
||||
const before = s.turn.movesRemaining;
|
||||
const before = turnOf(s, 0).movesRemaining;
|
||||
applyIntent(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [2, 1, 0] });
|
||||
assert.equal(s.turn.movesRemaining, before - 1);
|
||||
assert.equal(turnOf(s, 0).movesRemaining, before - 1);
|
||||
});
|
||||
|
||||
it('is refused anywhere without a Small Yard', () => {
|
||||
@@ -264,6 +285,86 @@ describe('Interlocking and Yard Office relieve the Office', () => {
|
||||
assert.ok(areaOf(s, 0).heldAtLimits.includes(id), 'train is held at the Limits');
|
||||
});
|
||||
|
||||
it('does not let a released train and a newcomer share one A/D track', () => {
|
||||
/**
|
||||
* REPORTED FROM A TABLE, 2026-09-23, and measured on the save: a Whistle Post with ONE A/D
|
||||
* track held Trains 8 and 19 at once. The capacity test passed (nothing standing), the train
|
||||
* the Interlocking had been holding at the Limits was then moved into the free slot, and the
|
||||
* arriving train was pushed in after it without anyone asking again whether there was room.
|
||||
*
|
||||
* The held train has priority — it has been waiting — so the NEWCOMER takes the consequence,
|
||||
* and it is the same consequence it would have met had the held train arrived first: held at
|
||||
* the Limits where there is an Interlocking, a collision where there is not.
|
||||
*/
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
const capacity = officeProfile(area.tier).adTracks;
|
||||
|
||||
// One train already waiting at the Limits, and the Office just cleared.
|
||||
s.trays.set('waiting', {
|
||||
id: 'waiting', trainNumber: 8, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
} as never);
|
||||
area.heldAtLimits = ['waiting'];
|
||||
area.adOccupancy = [];
|
||||
|
||||
const card = straight();
|
||||
card.enhancements.push('interlocking');
|
||||
addCard(s, at(0, 2), card);
|
||||
const arriving = inbound(s, []);
|
||||
|
||||
advance(s);
|
||||
|
||||
assert.ok(
|
||||
area.adOccupancy.length <= capacity,
|
||||
`the Office holds ${area.adOccupancy.length} trains on ${capacity} A/D track(s)`,
|
||||
);
|
||||
assert.ok(area.adOccupancy.includes('waiting'), 'the train that had been waiting did not get the track');
|
||||
assert.ok(!area.adOccupancy.includes(arriving), 'the newcomer squeezed onto an occupied track');
|
||||
// With an Interlocking it waits its turn rather than wrecking.
|
||||
assert.ok(area.heldAtLimits.includes(arriving), 'the newcomer was neither held nor collided');
|
||||
});
|
||||
|
||||
it('collides the newcomer when a released train takes the last track and there is no Interlocking', () => {
|
||||
// Same situation, no Interlocking: §8.3's collision is what should happen, and did not.
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
s.trays.set('waiting', {
|
||||
id: 'waiting', trainNumber: 8, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
} as never);
|
||||
area.heldAtLimits = ['waiting'];
|
||||
area.adOccupancy = [];
|
||||
inbound(s, []);
|
||||
|
||||
advance(s);
|
||||
assert.equal(s.players[0]!.revenue, -5, 'no collision was scored for the train with nowhere to go');
|
||||
assert.ok(area.adOccupancy.length <= officeProfile(area.tier).adTracks, 'the Office is over capacity');
|
||||
});
|
||||
|
||||
it('says so in the history when a held train takes the track that just freed', () => {
|
||||
// The release used to be a silent side effect of somebody else's arrival — reported as "wasn't
|
||||
// clear what changed and why train 8 was suddenly released".
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
s.trays.set('waiting', {
|
||||
id: 'waiting', trainNumber: 8, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
} as never);
|
||||
area.heldAtLimits = ['waiting'];
|
||||
area.adOccupancy = [];
|
||||
const card = straight();
|
||||
card.enhancements.push('interlocking');
|
||||
addCard(s, at(0, 2), card);
|
||||
inbound(s, []);
|
||||
|
||||
const released = advance(s).events.find((e) => e.type === 'trainReleasedFromLimits');
|
||||
assert.ok(released, 'the release is still silent');
|
||||
const line = narrate(released as never, { playerName: () => 'A' });
|
||||
assert.match(line.text, /RELEASED from the Limits/, `the line does not say what happened: ${line.text}`);
|
||||
assert.match(line.text, /freed the A\/D track/, `the line does not say why now: ${line.text}`);
|
||||
});
|
||||
|
||||
it('still collides without an Interlocking', () => {
|
||||
const s = game();
|
||||
areaOf(s, 0).adOccupancy = ['blocker'];
|
||||
@@ -272,17 +373,124 @@ describe('Interlocking and Yard Office relieve the Office', () => {
|
||||
assert.equal(s.players[0]!.revenue, -5);
|
||||
});
|
||||
|
||||
it('diverts a coachless train to the Yard Office', () => {
|
||||
/**
|
||||
* §11, THE YARD OFFICE (Gitea#5) — offered, not imposed, and only down a route that exists.
|
||||
*
|
||||
* Jesse: "you have to ask if non-coach trains wish to go in there, rather than to the office",
|
||||
* "if the Yard Office is not accessible in one move, you should not get the option", and cars on
|
||||
* the way in "result in a crash". All three were missing: the train was teleported onto the card.
|
||||
*/
|
||||
const answer = (s: GameState, take: boolean) => {
|
||||
const who = decisionActor(s);
|
||||
assert.notEqual(who, null, 'nothing was pending, so there was nothing to answer');
|
||||
const r = applyIntent(s, who!, { type: 'mainline.yardOffice', take });
|
||||
assert.ok(r.ok, 'the district owner could not answer the Yard Office offer');
|
||||
advance(s);
|
||||
};
|
||||
|
||||
it('OFFERS the Yard Office to the district owner rather than diverting automatically', () => {
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(-1, 0), card);
|
||||
addCard(s, at(0, 2), card);
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
assert.equal(s.clock.pendingDecision?.kind, 'yardOffice', 'the phase did not stop to ask');
|
||||
assert.equal(decisionActor(s), 0, 'the question went to the wrong player');
|
||||
const pos = s.trays.get(id)!.position;
|
||||
assert.ok(pos.at === 'grid' && pos.coord.row === -1, 'arrived at the Yard Office');
|
||||
assert.ok(!areaOf(s, 0).adOccupancy.includes(id), 'did not take an A/D track');
|
||||
assert.ok(pos.at !== 'grid' || pos.coord.col !== 2, 'the train moved before anyone answered');
|
||||
});
|
||||
|
||||
it('takes the Yard Office when the owner says yes', () => {
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(0, 2), card);
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
answer(s, true);
|
||||
const pos = s.trays.get(id)!.position;
|
||||
assert.ok(pos.at === 'grid' && pos.coord.col === 2, 'did not arrive at the Yard Office');
|
||||
assert.ok(!areaOf(s, 0).adOccupancy.includes(id), 'took an A/D track anyway');
|
||||
assert.equal(s.players[0]!.revenue, 0, 'a clear lead should not have collided');
|
||||
});
|
||||
|
||||
it('goes to the Train Order Office when the owner says no', () => {
|
||||
// "They can of course still choose to have the train go to the standard office."
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(0, 2), card);
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
answer(s, false);
|
||||
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'declining did not put it on an A/D track');
|
||||
});
|
||||
|
||||
it('does not offer what cannot be reached, and says why in the history', () => {
|
||||
/**
|
||||
* Jesse, 2026-08-29: "make sure this is logged in history — why can't move so user knows why
|
||||
* they can't get to yard." A silent absence is indistinguishable from a broken feature, which
|
||||
* is how the missing reachability check survived this long.
|
||||
*/
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
// Far off the Running Track, with nothing laid between: no route in one move.
|
||||
addCard(s, at(3, 4), card);
|
||||
inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
const events = advance(s).events;
|
||||
assert.equal(s.clock.pendingDecision, null, 'offered a Yard Office it cannot reach');
|
||||
const said = events.find(
|
||||
(e) => e.type === 'trainDiverted' && e.reason.includes('could not be offered'),
|
||||
);
|
||||
assert.ok(said, `nothing in the history explains why:\n${JSON.stringify(events, null, 1)}`);
|
||||
assert.match(
|
||||
(said as { reason: string }).reason,
|
||||
/one move/,
|
||||
'the reason does not say it is out of reach in one move',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a fouled lead, and taking it collides', () => {
|
||||
/**
|
||||
* The third missing condition. "Just like other trains finding cars on the tracks you use to
|
||||
* get into either result in a crash" — and Jesse's ruling keeps the OFFER: a route that exists
|
||||
* is offered, and the consequence of taking it is the player's. §8.3 already reads cars in the
|
||||
* path of an arriving train as a collision rather than a coupling.
|
||||
*/
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(0, 2), card);
|
||||
// A car standing on the lead between the Office and the yard.
|
||||
areaOf(s, 0).grid.get(coordKey(at(0, 1)))!.standing = [{ type: 'boxcar', loaded: false }];
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
assert.equal(s.clock.pendingDecision?.kind, 'yardOffice', 'a fouled lead was not offered at all');
|
||||
|
||||
answer(s, true);
|
||||
assert.equal(s.players[0]!.revenue, -5, 'running through standing cars did not collide');
|
||||
assert.ok(!s.trays.has(id), 'the train survived the collision');
|
||||
});
|
||||
|
||||
it('declining a fouled lead is safe — the standard Office is unaffected', () => {
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(0, 2), card);
|
||||
areaOf(s, 0).grid.get(coordKey(at(0, 1)))!.standing = [{ type: 'boxcar', loaded: false }];
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
answer(s, false);
|
||||
assert.equal(s.players[0]!.revenue, 0, 'declining the Yard Office still cost a collision');
|
||||
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'the train did not reach the Office');
|
||||
});
|
||||
|
||||
it('does not divert a train carrying coaches', () => {
|
||||
@@ -332,13 +540,26 @@ describe('ABS Signals amend the collision rule', () => {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('one Revenue for every train that clears your section', () => {
|
||||
describe('train revenue per transit — paid to EVERY player when a train completes its run', () => {
|
||||
/**
|
||||
* `trainPerTransit` is a dial now, and it defaults to ZERO: at 1 it was worth ~5.4 Revenue against
|
||||
* a bot mean of 7.0, drowning out the freight and passenger economies the game is about. These
|
||||
* tests are about the rule, so they set the rate; the last one is about the default.
|
||||
*/
|
||||
const paying = (rate = 1, seed = 5): GameState =>
|
||||
createGame({
|
||||
id: 'g',
|
||||
seed,
|
||||
config: { ...config, houseRules: { revenue: { trainPerTransit: rate } } },
|
||||
playerNames: ['p'],
|
||||
});
|
||||
|
||||
/** A train sitting on the A/D track, made up and ready to highball. */
|
||||
const readyToLeave = (s: GameState, direction: 'east' | 'west' = 'east'): string => {
|
||||
const id = 'leaving';
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction, position: { at: 'grid', owner: 0, coord: areaOf(s, 0).officeCoord },
|
||||
consist: [], direction, position: { at: 'grid', seat: 0, coord: areaOf(s, 0).officeCoord },
|
||||
movesUsed: 0,
|
||||
});
|
||||
areaOf(s, 0).adOccupancy.push(id);
|
||||
@@ -348,35 +569,87 @@ describe('one Revenue for every train that clears your section', () => {
|
||||
return id;
|
||||
};
|
||||
|
||||
it('pays the owner when a train departs onto the Mainline', () => {
|
||||
it('pays nothing for merely leaving the Office', () => {
|
||||
/**
|
||||
* THE RULE CHANGED IN v0.4.2. It used to pay 1 to the Office a train departed, which meant a
|
||||
* five-Office railroad paid five times for one train and paid most to whoever it happened to
|
||||
* pass first. Departing is now worth nothing on its own.
|
||||
*/
|
||||
const s = game();
|
||||
const before = s.players[0]!.revenue;
|
||||
readyToLeave(s, 'east');
|
||||
|
||||
const r = advance(s);
|
||||
advance(s);
|
||||
|
||||
assert.equal(s.players[0]!.revenue, before + 1, 'clearing a train earned nothing');
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'revenueChanged' && e.reason === 'a train cleared your section'),
|
||||
'no revenue event was emitted for the departure',
|
||||
);
|
||||
|
||||
// Paid ONCE. Running off the end of the Division later is not a second departure — that far
|
||||
// Division Point belongs to whoever is seated at it.
|
||||
const after = s.players[0]!.revenue;
|
||||
for (let i = 0; i < 8; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; advance(s); }
|
||||
assert.equal(s.players[0]!.revenue, after, 'the same train was paid for a second time on its way out');
|
||||
assert.equal(s.players[0]!.revenue, before, 'departing the Office still paid');
|
||||
});
|
||||
|
||||
it('pays nothing for a local crew, which is switching rather than departing', () => {
|
||||
const s = game();
|
||||
it('pays every player once the train runs off the end of the Division', () => {
|
||||
const s = paying();
|
||||
const before = s.players.map((p) => p.revenue);
|
||||
readyToLeave(s, 'east');
|
||||
|
||||
// Run the Mainline Phase until the train has crossed the Division and left it.
|
||||
const seen: typeof s.players[number]['revenue'][] = [];
|
||||
let completed: { type: string; side?: string } | undefined;
|
||||
for (let i = 0; i < 20 && !completed; i++) {
|
||||
s.movedThisPhase = new Set();
|
||||
s.clock.phase = 'mainline';
|
||||
const r = advance(s);
|
||||
completed = r.events.find((e) => e.type === 'trainCompleted');
|
||||
if (completed) {
|
||||
assert.equal(completed.side, 'east', 'an eastbound train left by the wrong Division Point');
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'revenueChanged' && e.reason === 'a train completed its run'),
|
||||
'no revenue event was emitted for the completed run',
|
||||
);
|
||||
}
|
||||
seen.push(s.players[0]!.revenue);
|
||||
}
|
||||
assert.ok(completed, 'the train never left the Division');
|
||||
assert.deepEqual(s.players.map((p) => p.revenue), before.map((r) => r + 1), 'not every player was paid');
|
||||
|
||||
// Paid ONCE — the train is gone, so there is nothing left to pay again.
|
||||
const after = s.players.map((p) => p.revenue);
|
||||
for (let i = 0; i < 8; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; advance(s); }
|
||||
assert.deepEqual(s.players.map((p) => p.revenue), after, 'the same run was paid twice');
|
||||
});
|
||||
|
||||
it('pays nothing for a local crew, which is switching rather than running', () => {
|
||||
const s = paying();
|
||||
const before = s.players[0]!.revenue;
|
||||
const id = readyToLeave(s, 'east');
|
||||
s.trays.get(id)!.trainNumber = null;
|
||||
|
||||
advance(s);
|
||||
for (let i = 0; i < 20; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; advance(s); }
|
||||
|
||||
assert.equal(s.players[0]!.revenue, before, 'a switching crew was paid as though it had cleared the section');
|
||||
assert.equal(s.players[0]!.revenue, before, 'a switching crew was paid as though it had completed a run');
|
||||
});
|
||||
|
||||
it('pays at the rate the game was dealt with, and nothing at the default of zero', () => {
|
||||
// Zero is not "one, suppressed": no revenue event is emitted at all, so the history does not
|
||||
// fill with "+0 Revenue" for work that did not pay.
|
||||
const run = (rate: number): { revenue: number; events: number } => {
|
||||
const s = rate === 0 ? game() : paying(rate);
|
||||
readyToLeave(s, 'east');
|
||||
let events = 0;
|
||||
let done = false;
|
||||
for (let i = 0; i < 20 && !done; i++) {
|
||||
s.movedThisPhase = new Set();
|
||||
s.clock.phase = 'mainline';
|
||||
const r = advance(s);
|
||||
events += r.events.filter(
|
||||
(e) => e.type === 'revenueChanged' && e.reason === 'a train completed its run',
|
||||
).length;
|
||||
done = r.events.some((e) => e.type === 'trainCompleted');
|
||||
}
|
||||
assert.ok(done, `the train never left the Division at rate ${rate}`);
|
||||
return { revenue: s.players[0]!.revenue, events };
|
||||
};
|
||||
|
||||
assert.deepEqual(run(0), { revenue: 0, events: 0 }, 'the default paid for a transit');
|
||||
assert.deepEqual(run(1), { revenue: 1, events: 1 });
|
||||
assert.deepEqual(run(5), { revenue: 5, events: 1 }, 'the top of the range pays 5 in one event');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -391,8 +664,10 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
|
||||
* Nothing states the rule, which makes it exactly the kind of property a later change to the loop
|
||||
* would break in silence. These pin it.
|
||||
*
|
||||
* It is also what gives the printed **Expedite** rule something to do — an Expedited train is
|
||||
* granted a second `moveTrain` in the same phase precisely because the general rule forbids it.
|
||||
* v0.4.9 CORRECTED Q3 so **Expedite is not an exception to this any more**: an Expedited train
|
||||
* stops for the Stage exactly like any other and may be switched in the Local Operations turn that
|
||||
* follows. What Expedite restricts is where the train may be left standing afterwards, not when it
|
||||
* departs — see `advance.test.ts`'s `expediteFault` coverage for that half.
|
||||
*/
|
||||
const arriving = (s: GameState, trainNumber: number, isExtra = false): string => {
|
||||
const id = 'arriving';
|
||||
@@ -425,9 +700,15 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
|
||||
);
|
||||
});
|
||||
|
||||
it('unless its card prints Expedite, which exists to override exactly this', () => {
|
||||
// Q3. Train 3 prints `expedite`, so it arrives and departs in one Stage — still subject to the
|
||||
// rest of §8.1, which is why the clearance path is re-entered rather than skipped.
|
||||
it('an Expedite train follows the same rule now — no exception any more', () => {
|
||||
/**
|
||||
* Q3, as CORRECTED in v0.4.9. Before this, an Expedite train left the Stage it arrived — at the
|
||||
* end of it rather than inside the Mainline Phase (v0.4.2's fix for coaches otherwise never
|
||||
* reaching Load/Unload) — which made it the one exception to "stops for the Stage". That forced
|
||||
* departure is gone: Expedite no longer changes WHEN a train leaves, only where it may be left
|
||||
* standing afterwards (`expediteFault`, pinned in `advance.test.ts`). So it stops for the Stage
|
||||
* exactly like Train 9 above.
|
||||
*/
|
||||
const s = game();
|
||||
assert.equal(trainProfile(3, false)?.rules.expedite, true, 'train 3 should be an Expedite train');
|
||||
const id = arriving(s, 3);
|
||||
@@ -435,9 +716,10 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
|
||||
advance(s);
|
||||
|
||||
assert.equal(
|
||||
s.trays.get(id)!.position.at, 'mainline',
|
||||
'an Expedite train should leave again the Stage it arrives',
|
||||
s.trays.get(id)!.position.at, 'grid',
|
||||
'an Expedite train departed again in the same Stage it arrived from a mainline card',
|
||||
);
|
||||
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'it is not standing on an A/D track');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -573,6 +855,50 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => {
|
||||
assert.deepEqual(s.trays.get('mine')!.position, { at: 'divisionPoint', side: 'west' });
|
||||
});
|
||||
|
||||
it('holds against an uncleared facing train even if a same-direction one is checked first', () => {
|
||||
// Reproduces a playtest report: the Superintendent was asked to rule on a same-direction train
|
||||
// instead of being automatically held against an opposite-direction one also occupying the
|
||||
// card. Root cause — `evaluateClearance` returned on whichever occupant it examined first, in
|
||||
// `node.transits` insertion order, rather than checking every occupant. Only visible once a
|
||||
// card can hold two trains at once, which the Telegraph/Telephone/Radio exception is exactly
|
||||
// what creates: a facing train dispatched past another sits on the same card as it.
|
||||
const s = game();
|
||||
const ml = s.division.nodes[1];
|
||||
assert.equal(ml?.kind, 'mainline');
|
||||
if (ml?.kind === 'mainline') {
|
||||
ml.card = 'plains';
|
||||
// SAME direction, entered the transit list FIRST — what the old code asked about.
|
||||
s.trays.set('sameDir', {
|
||||
id: 'sameDir', trainNumber: 3, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
ml.transits.push({ tray: 'sameDir', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
// OPPOSITE direction, entered SECOND, no dispatch device on the board — an absolute bar.
|
||||
s.trays.set('facing', {
|
||||
id: 'facing', trainNumber: 5, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
ml.transits.push({ tray: 'facing', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||
}
|
||||
s.trays.set('mine', {
|
||||
id: 'mine', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
|
||||
const r = advance(s);
|
||||
|
||||
assert.equal(
|
||||
s.trays.get('mine')!.position.at, 'divisionPoint',
|
||||
'the train entered despite an uncleared opposite-direction occupant on the card',
|
||||
);
|
||||
assert.ok(
|
||||
!r.events.some((e) => e.type === 'clearanceRequested'),
|
||||
'the Superintendent was asked to rule on the same-direction occupant instead of an automatic hold',
|
||||
);
|
||||
});
|
||||
|
||||
it('lets a junior train win the meet with a Telegraph', () => {
|
||||
// Train 9 against Train 3: +4 makes the other count as 7 — still senior. Radio's +12 wins.
|
||||
const s = game();
|
||||
@@ -615,3 +941,60 @@ describe('defensive enhancements', () => {
|
||||
assert.equal(hasDistrictEnhancement(areaOf(s, 0), 'waterColumn'), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.8.3 — audit findings (2026-09-29)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('a train held at the Limits is released when a track frees (v0.8.3)', () => {
|
||||
it('takes a free A/D track at the end of the Mainline Phase without waiting for another arrival', () => {
|
||||
/**
|
||||
* The only release was inside `arriveAtOffice` for a DIFFERENT train — so an Office that
|
||||
* emptied by departures alone kept the held train at its Limits for the rest of the game,
|
||||
* invisible: no transit, not in `adOccupancy`, not counted by the clearance check.
|
||||
*/
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
const card = straight();
|
||||
card.enhancements.push('interlocking');
|
||||
addCard(s, at(0, 2), card);
|
||||
s.trays.set('waiting', {
|
||||
id: 'waiting', trainNumber: 8, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
} as never);
|
||||
area.heldAtLimits = ['waiting'];
|
||||
area.adOccupancy = []; // the Office cleared, and nothing is arriving
|
||||
s.timetable = s.timetable.map(() => null);
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
const r = advance(s);
|
||||
|
||||
assert.ok(area.adOccupancy.includes('waiting'), 'the held train is still at the Limits with the Office empty');
|
||||
assert.deepEqual(area.heldAtLimits, []);
|
||||
assert.deepEqual(s.trays.get('waiting')!.position, { at: 'grid', seat: 0, coord: area.officeCoord });
|
||||
const released = r.events.find((e) => e.type === 'trainReleasedFromLimits');
|
||||
assert.ok(released, 'the release is silent');
|
||||
const line = narrate(released as never, { playerName: () => 'A' });
|
||||
assert.match(line.text, /RELEASED from the Limits/, line.text);
|
||||
});
|
||||
|
||||
it('still waits while the Office is full', () => {
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
s.trays.set('waiting', {
|
||||
id: 'waiting', trainNumber: 8, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
} as never);
|
||||
area.heldAtLimits = ['waiting'];
|
||||
area.adOccupancy = ['blocker'];
|
||||
s.trays.set('blocker', {
|
||||
id: 'blocker', trainNumber: null, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
} as never);
|
||||
s.timetable = s.timetable.map(() => null);
|
||||
s.clock.phase = 'mainline';
|
||||
advance(s);
|
||||
assert.deepEqual(area.heldAtLimits, ['waiting']);
|
||||
assert.ok(!area.adOccupancy.includes('waiting'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* What the event log is, and what it is not.
|
||||
*
|
||||
* The README, four architecture documents and six source comments all claimed `state = fold(events)`
|
||||
* until v0.4.0. It was never true, and the claim was load-bearing — it was the stated justification
|
||||
* for reconnection, restart recovery and persistence, none of which were built yet. This file makes
|
||||
* the real shape checkable so the claim cannot quietly come back.
|
||||
*
|
||||
* The decision (`docs/architecture/protocol.md` §3): **the intents are canonical.** A game is
|
||||
* `{ seed, history: Intent[] }`, `fromSave` replays it exactly, and events narrate.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { LOG_LIMIT, fromSave, newGame, newMultiplayerGame, pushLine, submit, toSave, view } from '../src/web/game.ts';
|
||||
import { actionGroups, currentActor } from '../src/web/game.ts';
|
||||
import { narrate } from '../src/sim/narrate.ts';
|
||||
|
||||
const src = (rel: string): string => readFileSync(join(import.meta.dirname, '..', 'src', rel), 'utf8');
|
||||
|
||||
/** Every `type: 'x'` in the `GameEvent` union. */
|
||||
function eventTypes(): Set<string> {
|
||||
const text = src('engine/events.ts');
|
||||
const union = text.slice(text.indexOf('export type GameEvent ='));
|
||||
return new Set([...union.matchAll(/type: '([a-zA-Z]+)'/g)].map((m) => m[1]!));
|
||||
}
|
||||
|
||||
/** Every `case 'x':` inside `reduce`. */
|
||||
function reducedTypes(): Set<string> {
|
||||
const text = src('engine/apply.ts');
|
||||
const body = text.slice(text.indexOf('export function reduce'));
|
||||
return new Set([...body.matchAll(/case '([a-zA-Z]+)':/g)].map((m) => m[1]!));
|
||||
}
|
||||
|
||||
/**
|
||||
* The event types the reducer does not handle, as of v0.4.0.
|
||||
*
|
||||
* Every one is emitted by the phase driver in `advance.ts`, which mutates state and then describes
|
||||
* what it did. Read the list: it is the clock, plus the entire Mainline phase — which is to say every
|
||||
* train movement in the game. That is why folding the log rebuilds a district and not a railroad.
|
||||
*/
|
||||
const KNOWN_UNREDUCED = [
|
||||
'actorChanged',
|
||||
'carPassed',
|
||||
'clearanceRequested',
|
||||
'dispatchBonusUsed',
|
||||
'expediteFault',
|
||||
/**
|
||||
* The Freight Agent chose to do nothing (§6.3 requires no action). Emitted by `freightAgent.end`
|
||||
* beside the `phaseEnded` that ends the turn, and reduces to nothing itself — exactly the
|
||||
* `switchingEnded` pattern below.
|
||||
*/
|
||||
'freightAgentIdled',
|
||||
/**
|
||||
* The New Train Phase's report that it could give a train nothing (playtest, 2026-09-16, the
|
||||
* Sparrow running empty). Emitted by the phase driver after the make-up round has nothing left to
|
||||
* offer, so it describes rather than reduces, like every entry on this list.
|
||||
*/
|
||||
'makeUpShort',
|
||||
'phaseBegan',
|
||||
/**
|
||||
* §Q, Red Flags (Gitea#19). The flag comes down inside the phase driver as it stops a train, so
|
||||
* this is described rather than reduced like everything else here.
|
||||
*
|
||||
* ADDED DELIBERATELY, and it cost a bug first: the flag was originally taken down in a `reduce`
|
||||
* case, which never fires for an event `advance.ts` emits — so it stayed up and held every train
|
||||
* that came. That is precisely the failure this list exists to make visible.
|
||||
*/
|
||||
'redFlagSpent',
|
||||
// Employee Rotation moves `seating` in the phase driver and then describes what it did, which is
|
||||
// the pattern every entry on this list follows.
|
||||
'seatsRotated',
|
||||
'stageBegan',
|
||||
/**
|
||||
* §5's Fedora handover, emitted by `shiftChange` on the same mutate-then-describe path as its
|
||||
* neighbours here: the clock moves the Superintendent and then says so. Added 2026-09-16 because
|
||||
* riding on `actorChanged` meant the log dropped it as turn bookkeeping.
|
||||
*/
|
||||
'superintendentChanged',
|
||||
/**
|
||||
* The line that closes a switching turn. Emitted by `switch.end` beside the `phaseEnded` that
|
||||
* actually ends the turn, and reduces to nothing itself: it reports what the Moves were spent on
|
||||
* and where the crew was left, both of which the state already holds.
|
||||
*/
|
||||
'switchingEnded',
|
||||
'trainArrived',
|
||||
'trainCompleted',
|
||||
'trainDiverted',
|
||||
'trainHeld',
|
||||
'trainHighballed',
|
||||
'trainMadeUp',
|
||||
/**
|
||||
* A train the Interlocking held at the Limits taking the A/D track that just freed. Emitted by
|
||||
* `arriveAtOffice` in the phase driver, which moves the tray itself — so this describes rather
|
||||
* than reduces, like every entry on this list.
|
||||
*/
|
||||
'trainReleasedFromLimits',
|
||||
'trainStoodStill',
|
||||
'trainsDestroyed',
|
||||
];
|
||||
|
||||
describe('the event log narrates but does not reconstruct', () => {
|
||||
it('has exactly the unreduced event types it is documented to have', () => {
|
||||
/**
|
||||
* A CHANGE-DETECTOR ON PURPOSE. If this fails because the list shrank, someone has made the
|
||||
* phase driver reduce — good, and the docs in `protocol.md` §3, `events.ts` and the README now
|
||||
* understate the engine and should be corrected in the same change. If it fails because the list
|
||||
* GREW, a new event was added on the mutate-then-describe path, which is worth knowing before it
|
||||
* becomes another thing the log cannot rebuild.
|
||||
*/
|
||||
const all = eventTypes();
|
||||
const reduced = reducedTypes();
|
||||
const unreduced = [...all].filter((t) => !reduced.has(t)).sort();
|
||||
assert.deepEqual(
|
||||
unreduced,
|
||||
KNOWN_UNREDUCED,
|
||||
'the set of events the reducer ignores has changed — see the comment above KNOWN_UNREDUCED',
|
||||
);
|
||||
});
|
||||
|
||||
it('never folds events in the phase driver, which is what makes the above true', () => {
|
||||
// `advance.ts` mutating directly is the whole mechanism. If it ever starts calling `reduce`,
|
||||
// the claim becomes recoverable and this file should be rewritten rather than relaxed.
|
||||
assert.doesNotMatch(
|
||||
src('engine/advance.ts'),
|
||||
/\breduce\(s[,)]/,
|
||||
'the phase driver now folds events — reconsider the canonical-record decision',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the intents are what reconstructs a game', () => {
|
||||
it('replays a partly-played game to exactly the same position', () => {
|
||||
// The property that actually holds, stated as a test rather than as a comment. This is what
|
||||
// save, share, undo, restart recovery and post-game replay all rest on.
|
||||
const game = newGame(31337);
|
||||
for (let i = 0; i < 120; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
if (!submit(game, options[0]!)) break;
|
||||
}
|
||||
assert.ok(game.history.length > 20, 'the driver did not play far enough to be a real test');
|
||||
|
||||
const back = fromSave(toSave(game));
|
||||
assert.deepEqual(view(back).cells, view(game).cells, 'the board differs after replay');
|
||||
assert.deepEqual(
|
||||
back.state.players.map((p) => p.revenue),
|
||||
game.state.players.map((p) => p.revenue),
|
||||
'the score differs after replay',
|
||||
);
|
||||
assert.equal(back.state.clock.day, game.state.clock.day);
|
||||
assert.equal(back.state.clock.stage, game.state.clock.stage);
|
||||
assert.equal(back.state.clock.phase, game.state.clock.phase);
|
||||
// The trains are the part folding the log would have lost, so check them specifically.
|
||||
assert.deepEqual(
|
||||
[...back.state.trays.keys()].sort(),
|
||||
[...game.state.trays.keys()].sort(),
|
||||
'the crews differ after replay',
|
||||
);
|
||||
});
|
||||
|
||||
it('carries no POSITION in the save — the seed, the rules dealt under, and the intents', () => {
|
||||
// If anything else ever creeps into `Save`, the claim above weakens: the game would no longer be
|
||||
// reconstructible from decisions alone, and persistence would have a schema to migrate.
|
||||
//
|
||||
// `rules` is the one addition, and it is not position: it is the other half of the seed. A seed
|
||||
// only names a game together with the rules it was dealt under, which is why two published
|
||||
// replays went dead when the rules moved (`TODO.md`) — the intents were fine, the deal was not.
|
||||
const game = newGame(7);
|
||||
assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'rules', 'seed']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A FREIGHT AGENT TURN, AS THE TABLE READS IT.
|
||||
*
|
||||
* Reported on Day 1 Stage 3 of v0.8.0.16, watching a bot: "one car moved to or from a facility —
|
||||
* can we tell what car, what facility and whether it was to or from." Two separate faults sat
|
||||
* behind that. The choice line ASSERTED a car had moved, which §6.3 does not require and the bot
|
||||
* deliberately declines; and the three lines that do report the work named the grid coordinate
|
||||
* rather than the industry standing on it.
|
||||
*/
|
||||
describe('a Freight Agent turn says what it did, to what, and where', () => {
|
||||
const ctx = {
|
||||
playerName: () => 'Bot 1',
|
||||
facilityAt: (_p: number, c: { row: number; col: number }) =>
|
||||
c.row === -1 && c.col === 1 ? 'the Freight House' : null,
|
||||
};
|
||||
|
||||
it('announces the OPTION without claiming a car moved', () => {
|
||||
const line = narrate({ type: 'localOpsOptionChosen', player: 0, option: 'freightAgent' } as never, ctx);
|
||||
assert.ok(
|
||||
!/one car moved/.test(line.text),
|
||||
`the choice line still asserts an outcome: ${line.text}`,
|
||||
);
|
||||
// It must still say what the Freight Agent is FOR, or the option is a bare name.
|
||||
assert.match(line.text, /Outbound|Inbound|jam/, `the choice line says nothing about the work: ${line.text}`);
|
||||
});
|
||||
|
||||
it('says so when the Freight Agent deliberately does nothing', () => {
|
||||
// The bot takes this route on purpose: unjamming a healthy box destroys a stocked load. Silence
|
||||
// here is what made the turn read as a dropped turn.
|
||||
const line = narrate({ type: 'freightAgentIdled', player: 0 } as never, ctx);
|
||||
assert.match(line.text, /nothing worth doing/, `an idle Freight Agent is silent: ${line.text}`);
|
||||
});
|
||||
|
||||
it('names the industry and the direction, not a coordinate', () => {
|
||||
const at = { row: -1, col: 1 };
|
||||
const stocked = narrate(
|
||||
{ type: 'stockToOutbound', player: 0, at, stock: { type: 'boxcar', loaded: true } } as never,
|
||||
ctx,
|
||||
);
|
||||
assert.match(stocked.text, /the Freight House/, `no industry named: ${stocked.text}`);
|
||||
assert.ok(!/\(1, -1\)/.test(stocked.text), `still speaking in coordinates: ${stocked.text}`);
|
||||
assert.match(stocked.text, /boxcar/, `the car is not named: ${stocked.text}`);
|
||||
assert.match(stocked.text, /INTO/, `the direction is not stated: ${stocked.text}`);
|
||||
|
||||
const cleared = narrate(
|
||||
{ type: 'inboundCleared', player: 0, at, stock: { type: 'hopper', loaded: true } } as never,
|
||||
ctx,
|
||||
);
|
||||
assert.match(cleared.text, /the Freight House/, `no industry named: ${cleared.text}`);
|
||||
assert.match(cleared.text, /OUT of/, `the direction is not stated: ${cleared.text}`);
|
||||
|
||||
const jam = narrate(
|
||||
{ type: 'facilityUnjammed', player: 0, at, from: 'menAtWork', stock: { type: 'tank', loaded: true } } as never,
|
||||
ctx,
|
||||
);
|
||||
assert.match(jam.text, /the Freight House/, `no industry named: ${jam.text}`);
|
||||
|
||||
/**
|
||||
* Where there is no industry, the coordinate is still the honest fallback rather than "nowhere".
|
||||
*
|
||||
* AND IT IS SPELLED THE WAY THE ACTION MENU SPELLS IT — X,Y, east/west then north/south. The
|
||||
* log printed the internal row/col order until 2026-09-21, so the same square read "(1,-1)" in
|
||||
* the menu and "(-1,1)" in the log, side by side. `test/web.test.ts` pins the two against each
|
||||
* other; this pins the order itself.
|
||||
*/
|
||||
const plain = narrate(
|
||||
{ type: 'stockToOutbound', player: 0, at: { row: -2, col: 4 }, stock: { type: 'boxcar', loaded: true } } as never,
|
||||
ctx,
|
||||
);
|
||||
assert.match(plain.text, /\(4,-2\)/, `the fallback coordinate is not in X,Y order: ${plain.text}`);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* WHICH MAINLINE CARD, AND WHAT THE CARD IS CALLED.
|
||||
*
|
||||
* Reported 2026-09-22, playing ABS Signals: "In the history, it referred to it as Mainline card 7,
|
||||
* but didn't give the actual card type, which was plains. It should specify both. Note there were
|
||||
* two plains cards dealt in this hand." Both halves matter for that reason — the name informs, the
|
||||
* slot is the only thing telling two Plains apart.
|
||||
*/
|
||||
describe('the log names the Mainline card an Enhancement was built on', () => {
|
||||
const ctx = {
|
||||
mainlineAt: (node: number) => (node === 7 ? 'Plains' : node === 3 ? 'Heavy Grade' : null),
|
||||
enhancementName: (key: string) => (key === 'absSignals' ? 'ABS Signals' : null),
|
||||
};
|
||||
|
||||
it('gives the slot AND the card type', () => {
|
||||
const line = narrate({ type: 'enhancementPlaced', player: 0, key: 'absSignals', node: 7 } as never, ctx);
|
||||
assert.match(line.text, /Mainline card 7/, `the slot is gone: ${line.text}`);
|
||||
assert.match(line.text, /Plains/, `the card type is missing: ${line.text}`);
|
||||
});
|
||||
|
||||
it('calls the card by its printed name, not a split key', () => {
|
||||
const line = narrate({ type: 'enhancementPlaced', player: 0, key: 'absSignals', node: 7 } as never, ctx);
|
||||
assert.match(line.text, /ABS Signals/, `not the printed name: ${line.text}`);
|
||||
assert.ok(!/abs Signals/.test(line.text), `still splitting the key: ${line.text}`);
|
||||
});
|
||||
|
||||
it('still says something useful when the card cannot be resolved', () => {
|
||||
// No resolver at all — an engine test narrating events has no division to ask.
|
||||
const bare = narrate({ type: 'enhancementPlaced', player: 0, key: 'absSignals', node: 2 } as never, {});
|
||||
assert.match(bare.text, /Mainline card 2/, `the slot must survive with no resolver: ${bare.text}`);
|
||||
});
|
||||
|
||||
it('leaves a grid-square Enhancement naming its coordinate', () => {
|
||||
const line = narrate(
|
||||
{ type: 'enhancementPlaced', player: 0, key: 'smallYard', at: { row: -1, col: 2 } } as never,
|
||||
{ enhancementName: (k: string) => (k === 'smallYard' ? 'Small Yard' : null) },
|
||||
);
|
||||
assert.match(line.text, /Small Yard/, `not the printed name: ${line.text}`);
|
||||
assert.match(line.text, /\(2,-1\)/, `the square is gone or in the wrong order: ${line.text}`);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* WHOSE REVENUE IT IS.
|
||||
*
|
||||
* Reported from a table: "in the history on +1 revenue and gives the current score, it doesn't list
|
||||
* the player name." The history prefixes lines with the ACTOR, and revenue is not always the
|
||||
* actor's — a train completing its run pays every player with no actor at all, so those lines
|
||||
* carried no name whatsoever.
|
||||
*/
|
||||
describe('a Revenue line names the player who earned it', () => {
|
||||
const ctx = { playerName: (p: number) => ['Alice', 'Bob'][p] ?? `Seat ${p}` };
|
||||
|
||||
it('names the earner on a gain', () => {
|
||||
const line = narrate({ type: 'revenueChanged', player: 1, delta: 1, total: 12, reason: 'boarding' } as never, ctx);
|
||||
assert.match(line.text, /Bob/, `no player named: ${line.text}`);
|
||||
assert.match(line.text, /\+1 Revenue/, `the change is gone: ${line.text}`);
|
||||
assert.match(line.text, /now 12/, `the running total is gone: ${line.text}`);
|
||||
});
|
||||
|
||||
it('names the earner on a loss', () => {
|
||||
const line = narrate({ type: 'revenueChanged', player: 0, delta: -5, total: 7, reason: 'collision' } as never, ctx);
|
||||
assert.match(line.text, /Alice/, `no player named: ${line.text}`);
|
||||
assert.match(line.text, /now 7/, `the running total is gone: ${line.text}`);
|
||||
});
|
||||
|
||||
it('names the player it belongs to, not the one who acted', () => {
|
||||
// The distinction that matters: a train completing its run pays everybody.
|
||||
const a = narrate({ type: 'revenueChanged', player: 0, delta: 1, total: 3, reason: 'a train completed its run' } as never, ctx);
|
||||
const b = narrate({ type: 'revenueChanged', player: 1, delta: 1, total: 9, reason: 'a train completed its run' } as never, ctx);
|
||||
assert.match(a.text, /Alice/, `the first payee is unnamed: ${a.text}`);
|
||||
assert.match(b.text, /Bob/, `the second payee is unnamed: ${b.text}`);
|
||||
assert.notEqual(a.text, b.text, 'both payees produced the same line');
|
||||
});
|
||||
|
||||
it('is excluded from the history prefix, so no line names a player twice', () => {
|
||||
// Gitea#31. `record` prefixes `Player <actor>` onto events carrying a `player`; this one
|
||||
// resolves its own name, so it must be on the exclusion list or it reads "Player Bob Bob +1".
|
||||
const src = readFileSync(join(import.meta.dirname, '..', 'src', 'web', 'game.ts'), 'utf8');
|
||||
const guard = src.slice(src.indexOf('const SELF_NAMED'), src.indexOf('const SELF_NAMED') + 400);
|
||||
assert.match(guard, /'revenueChanged'/, 'revenueChanged is not excluded from the actor prefix');
|
||||
assert.match(guard, /!SELF_NAMED\.includes\(e\.type\)/, 'the exclusion is not applied to `mine`');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* THE HISTORY MUST NOT FREEZE WHEN THE LOG IS TRIMMED.
|
||||
*
|
||||
* Reported from a two-player game that did not reach Day 5: one player's history stopped gaining
|
||||
* lines at Day 2 Stage 8 and the other's at Day 2 Stage 4. The log is trimmed to a limit, and each
|
||||
* seat's "what have I sent you" bookmark was an INDEX into that array — so once a seat's bookmark
|
||||
* reached the limit, the array never grew past it again and the slice returned nothing for the rest
|
||||
* of the game. Different moments per seat because each holds its own bookmark.
|
||||
*/
|
||||
describe('a trimmed log still delivers every line', () => {
|
||||
const config = {
|
||||
mode: 'competitive' as const, days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
|
||||
it('numbers lines by sequence, which survives trimming', () => {
|
||||
const g = newMultiplayerGame(7, config as never, ['A', 'B']);
|
||||
const first = g.log[g.log.length - 1]!.seq;
|
||||
pushLine(g, 'one', 'plain');
|
||||
pushLine(g, 'two', 'plain');
|
||||
assert.equal(g.log[g.log.length - 1]!.seq, first + 2, 'sequence numbers do not advance');
|
||||
|
||||
// Trim the front; the survivors keep the numbers they were given.
|
||||
const keep = g.log[g.log.length - 1]!.seq;
|
||||
g.log.splice(0, g.log.length - 1);
|
||||
assert.equal(g.log[0]!.seq, keep, 'trimming renumbered the lines');
|
||||
});
|
||||
|
||||
it('delivers every line to a seat across many trims', () => {
|
||||
const g = newMultiplayerGame(7, config as never, ['A', 'B']);
|
||||
// The same bookmark the server keeps per seat (`linesSince` in server/session.ts).
|
||||
let bookmark = -1;
|
||||
const since = (): number => {
|
||||
const fresh = g.log.filter((l) => l.seq > bookmark);
|
||||
const last = g.log[g.log.length - 1];
|
||||
if (last) bookmark = last.seq;
|
||||
return fresh.length;
|
||||
};
|
||||
since();
|
||||
|
||||
const pushes = LOG_LIMIT * 2;
|
||||
let delivered = 0;
|
||||
for (let i = 0; i < pushes; i++) {
|
||||
pushLine(g, `line ${i}`, 'plain');
|
||||
if (g.log.length > LOG_LIMIT) g.log.splice(0, g.log.length - LOG_LIMIT);
|
||||
delivered += since();
|
||||
}
|
||||
// Every line reaches the seat, though the log holds only the last LOG_LIMIT of them.
|
||||
assert.equal(delivered, pushes, `only ${delivered} of ${pushes} lines were delivered`);
|
||||
assert.equal(g.log.length, LOG_LIMIT, 'the log is not being trimmed at all');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY — Gitea#11, "when game ends allow players to continue playing if they wish".
|
||||
*
|
||||
* The rule as Jesse specified it (2026-08-28), which is what these tests are written against:
|
||||
*
|
||||
* - the OFFICIAL result is decided at the original game length and never changes. "In a five-day
|
||||
* game, even if it's extended to eight or nine days, the winner and the official answer is the
|
||||
* winner at the end of five days";
|
||||
* - extending grants exactly ONE Day, and the question is put again at the end of it;
|
||||
* - solitaire: the player decides alone. Multiplayer: unanimous, and one refusal ends it there;
|
||||
* - only days-based endings offer it. A §3.4 collision breach is final, during an extended Day
|
||||
* just as during the regular game.
|
||||
*
|
||||
* The persistence half matters as much as the rules half: a save is `{ seed, config, history }`
|
||||
* replayed through the engine, so an extension that is not an INTENT does not survive a reload, an
|
||||
* Undo, or a server restart. `replays the extension` below is the test that pins that.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, check } from '../src/engine/apply.ts';
|
||||
import { STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { currentActorOfState, publicSnapshot, snapshot } from '../src/sim/view.ts';
|
||||
import { currentActor } from '../src/web/game.ts';
|
||||
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
|
||||
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||
mode: 'solitaire',
|
||||
days: 3,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
...over,
|
||||
});
|
||||
|
||||
const game = (over: Partial<GameConfig> = {}, names = ['Jesse']): GameState =>
|
||||
createGame({ id: 'g', seed: 1, config: baseConfig(over), playerNames: names });
|
||||
|
||||
/** Parks a game on the last Stage of its final Day, so one `advance` runs the clock off the end. */
|
||||
function atTheEnd(s: GameState): GameState {
|
||||
s.clock.day = s.config.days + s.extraDays + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
return s;
|
||||
}
|
||||
|
||||
describe('§3.3 extended play — the ending pauses rather than stopping (Gitea#11)', () => {
|
||||
it('offers another Day on a days-based ending, and records the result anyway', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
assert.equal(s.status, 'awaitingExtension', 'a days-based ending did not offer another Day');
|
||||
assert.ok(s.outcome, 'the result was not decided');
|
||||
assert.ok(s.official, 'the official result was not frozen');
|
||||
assert.equal(s.official!.day, s.config.days, 'the official Day is not the original game length');
|
||||
});
|
||||
|
||||
it('does NOT offer another Day after a collision breach — §3.4 is final', () => {
|
||||
const s = game({ mode: 'competitive', maxCollisionsPerDay: 2 });
|
||||
s.collisionsToday = 2;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished', 'a railroad declared unsafe offered to carry on');
|
||||
assert.equal(s.outcome!.reason, 'collisionFloor');
|
||||
});
|
||||
|
||||
it('refuses every ordinary intent while the extension question is open', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
assert.equal(check(s, 0, { type: 'draw.fromHomeOffice' }), 'WRONG_PHASE');
|
||||
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), null, 'the vote itself was refused');
|
||||
});
|
||||
|
||||
it('offers only the two votes, and only to a seat that has not voted', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
|
||||
advance(s);
|
||||
assert.deepEqual(
|
||||
legalActions(s, 0).map((i) => i.type),
|
||||
['game.extend', 'game.extend'],
|
||||
'something other than the vote is legal while the game is stopped',
|
||||
);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.equal(legalActions(s, 0).length, 0, 'a seat was offered a second vote');
|
||||
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), 'ALREADY_VOTED');
|
||||
assert.equal(legalActions(s, 1).length, 2, 'the seat still to vote was not offered the vote');
|
||||
});
|
||||
|
||||
it('refuses the vote when no extension is pending', () => {
|
||||
const s = game();
|
||||
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), 'NOT_AWAITING_EXTENSION');
|
||||
});
|
||||
});
|
||||
|
||||
describe('§3.3 extended play — one Day at a time (Gitea#11)', () => {
|
||||
it('grants exactly one Day in solitaire, then asks again at the end of it', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
const official = { ...s.official!.outcome };
|
||||
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.equal(s.status, 'active', 'agreeing did not resume play');
|
||||
assert.equal(s.extraDays, 1, 'more or less than one Day was granted');
|
||||
assert.deepEqual(s.extensionVotes, [null], 'the votes were not cleared for the next question');
|
||||
|
||||
// Run the extra Day off the end: the question comes round again.
|
||||
atTheEnd(s);
|
||||
advance(s);
|
||||
assert.equal(s.status, 'awaitingExtension', 'the second ending did not ask again');
|
||||
assert.equal(s.extraDays, 1, 'a second Day was granted without being asked for');
|
||||
assert.deepEqual(s.official!.outcome, official, 'the official result was rewritten');
|
||||
assert.equal(s.official!.day, s.config.days, 'the official Day moved with the extension');
|
||||
});
|
||||
|
||||
it('ends the moment one seat declines, without waiting for the rest', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B', 'C']));
|
||||
advance(s);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.equal(s.status, 'awaitingExtension', 'one yes ended the vote');
|
||||
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
|
||||
assert.equal(s.status, 'finished', 'a refusal did not end the game immediately');
|
||||
assert.equal(s.extraDays, 0, 'a Day was granted despite a refusal');
|
||||
assert.equal(check(s, 2, { type: 'game.extend', player: 2, agree: true }), 'NOT_AWAITING_EXTENSION',
|
||||
'the seat that never voted is still being waited on');
|
||||
});
|
||||
|
||||
it('needs every seat before it grants the Day', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B', 'C']));
|
||||
advance(s);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
|
||||
assert.equal(s.status, 'awaitingExtension', 'two of three votes granted the Day');
|
||||
applyIntent(s, 2, { type: 'game.extend', player: 2, agree: true });
|
||||
assert.equal(s.status, 'active', 'a unanimous table was not given its Day');
|
||||
assert.equal(s.extraDays, 1);
|
||||
});
|
||||
|
||||
it('keeps the official result when a collision ends an EXTENDED Day', () => {
|
||||
// The case the freeze exists for: a railroad declared unsafe on the extra Day does not retract
|
||||
// who won on the last scheduled one.
|
||||
const s = atTheEnd(game({ mode: 'competitive', maxCollisionsPerDay: 2 }, ['A', 'B']));
|
||||
s.players[0]!.revenue = 9;
|
||||
s.players[1]!.revenue = 2;
|
||||
advance(s);
|
||||
const official = { ...s.official!.outcome };
|
||||
assert.equal(official.result, 'win');
|
||||
assert.equal(official.winner, 0);
|
||||
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
|
||||
assert.equal(s.status, 'active');
|
||||
|
||||
s.collisionsToday = 2;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.outcome!.reason, 'collisionFloor', 'the current evaluation was not updated');
|
||||
assert.deepEqual(s.official!.outcome, official, 'a late collision rewrote a recorded win');
|
||||
});
|
||||
|
||||
it('decides the winner at the ORIGINAL game length, whoever leads afterwards', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
|
||||
s.players[0]!.revenue = 9;
|
||||
s.players[1]!.revenue = 2;
|
||||
advance(s);
|
||||
assert.equal(s.official!.outcome.winner, 0);
|
||||
assert.deepEqual(s.official!.revenues, [9, 2], 'the official standings were not frozen');
|
||||
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
|
||||
|
||||
// B overtakes A during the extra Day, and it changes nothing official.
|
||||
s.players[1]!.revenue = 40;
|
||||
atTheEnd(s);
|
||||
advance(s);
|
||||
assert.equal(s.official!.outcome.winner, 0, 'overtaking after the timetable took the win');
|
||||
assert.deepEqual(s.official!.revenues, [9, 2], 'the frozen standings moved');
|
||||
assert.equal(s.outcome!.winner, 1, 'the informational evaluation did not follow the new leader');
|
||||
});
|
||||
});
|
||||
|
||||
describe('§3.3 extended play — it survives being replayed (Gitea#11)', () => {
|
||||
it('reproduces an extended game from seed and intents alone', () => {
|
||||
// The reason the vote is an intent at all. A save is a replay, so a decision that is not in the
|
||||
// history did not happen — an extended game would evaporate on the next reload.
|
||||
const play = (): GameState => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
return s;
|
||||
};
|
||||
const a = play();
|
||||
const b = play();
|
||||
assert.equal(a.extraDays, b.extraDays);
|
||||
assert.equal(a.status, b.status);
|
||||
assert.deepEqual(a.official!.outcome, b.official!.outcome);
|
||||
});
|
||||
|
||||
it('narrates the vote, so a table can see who called time', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
|
||||
advance(s);
|
||||
const yes = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.ok(yes.ok);
|
||||
assert.deepEqual(yes.events.map((e) => e.type), ['extensionVoted']);
|
||||
const no = applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
|
||||
assert.ok(no.ok);
|
||||
assert.deepEqual(no.events.map((e) => e.type), ['extensionVoted', 'playConcluded']);
|
||||
});
|
||||
|
||||
it('announces the granted Day', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
const r = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.ok(r.ok);
|
||||
assert.deepEqual(r.events.map((e) => e.type), ['extensionVoted', 'dayExtended']);
|
||||
const extended = r.events.find((e) => e.type === 'dayExtended');
|
||||
assert.equal(extended && 'day' in extended ? extended.day : null, s.config.days + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('§3.3 extended play — bots play the timetable they were dealt (Gitea#11)', () => {
|
||||
it('REGRESSION: a simulated game terminates whatever the policy would vote', () => {
|
||||
/**
|
||||
* `playGame` declines on the driver's own account rather than leaving it to the policy, and this
|
||||
* is why. `randomBot` picks uniformly among its legal options, so it takes another Day about
|
||||
* half the time — and since a table may go on granting Days for ever, the game then runs to
|
||||
* `maxTurns`. It did: `test/sim.test.ts` went from under a second to an unbounded hang, every
|
||||
* seeded game in the harness playing fifty thousand turns instead of a couple of hundred.
|
||||
*
|
||||
* `randomBot` is the policy that exposes it, but the guarantee has to hold for any policy,
|
||||
* including ones not written yet — hence the fix living in the driver and the test living here.
|
||||
*/
|
||||
const s = createGame({ id: 'g', seed: 9, config: baseConfig({ days: 1 }), playerNames: ['Jesse'] });
|
||||
const out = playGame(s, randomBot(7), pump, 4_000);
|
||||
assert.equal(s.status, 'finished', 'a simulated game did not finish');
|
||||
assert.equal(s.extraDays, 0, 'the harness played Days the game was not dealt');
|
||||
assert.ok(out.turns < 4_000, `ran to the turn cap (${out.turns}) instead of ending`);
|
||||
});
|
||||
|
||||
it('developerBot declines, so a bot-only game ends on schedule', () => {
|
||||
// "If only bots are playing, they never vote to extend" (Jesse, 2026-08-28). The server votes
|
||||
// yes on a bot's behalf once every human has already agreed; this policy is what is left when
|
||||
// there are no humans to follow.
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
const choice = developerBot.choose(s, 0, legalActions(s, 0));
|
||||
assert.equal(choice.type, 'game.extend');
|
||||
assert.equal('agree' in choice ? choice.agree : null, false, 'a bot asked for another Day');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* §3.3 — WHO THE SCREEN SAYS THE TABLE IS WAITING ON, while the vote is open.
|
||||
*
|
||||
* The vote is PARALLEL, and `apply.ts` says so where it accepts one: "open to every seat at once:
|
||||
* it is a table decision rather than a ruling, so THERE IS NO ACTOR TO BE". Any seat that has not
|
||||
* voted may vote at any moment, in any order, and one refusal ends it. So the honest answer to "who
|
||||
* are we waiting on" is every un-voted seat — which is exactly what the vote tally beside the chart
|
||||
* already draws — and the honest answer to "whose turn is it" is nobody.
|
||||
*
|
||||
* The engine gave that answer and the screen did not. `currentActor(game)` guards on
|
||||
* `status !== 'active'` and returned null; `actingPlayer(state)` has no such guard and returned
|
||||
* `clock.currentActor`, which still holds whoever moved last before the timetable ran out. The
|
||||
* frame took the second, so the turn chart named one arbitrary seat — the last to act, who has no
|
||||
* more claim on the vote than anybody else — while the tally underneath correctly showed three
|
||||
* seats outstanding.
|
||||
*
|
||||
* The fourth of these in a row after Gitea#21, #22 and #94, and the first found by asking the
|
||||
* question of a state the game is not ACTIVE in. See TODO #96.
|
||||
*/
|
||||
describe('§3.3 extended play — the vote has no actor (#96)', () => {
|
||||
const table = (): GameState => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['Ann', 'Bob', 'Cy']));
|
||||
advance(s);
|
||||
assert.equal(s.status, 'awaitingExtension', 'the table is not being asked');
|
||||
return s;
|
||||
};
|
||||
|
||||
it('reports nobody acting while the vote is open, to a player and to a spectator alike', () => {
|
||||
const s = table();
|
||||
assert.notEqual(s.clock.currentActor, null, 'the premise is gone: nothing was left on the clock');
|
||||
|
||||
assert.equal(currentActorOfState(s), null, 'the engine named an actor during a parallel vote');
|
||||
assert.equal(
|
||||
snapshot(s, [], null, null, null, false, 0).actor,
|
||||
null,
|
||||
'the turn chart named a seat while the whole table was voting',
|
||||
);
|
||||
assert.equal(publicSnapshot(s).actor, null, 'the common board named a seat during the vote');
|
||||
});
|
||||
|
||||
it('agrees with the session, which is the half that decides what is legal', () => {
|
||||
// The disagreement is the bug, not either answer on its own: `currentActor` is what refuses an
|
||||
// intent, so a screen that names somebody it would refuse is telling the table to wait on a
|
||||
// player who cannot act.
|
||||
const s = table();
|
||||
const g = { state: s, log: [] } as unknown as Parameters<typeof currentActor>[0];
|
||||
assert.equal(currentActorOfState(s), currentActor(g), 'the screen and the session disagree');
|
||||
});
|
||||
|
||||
it('still names the actor during ordinary play, which is the case that must not regress', () => {
|
||||
const s = game({ mode: 'competitive' }, ['Ann', 'Bob', 'Cy']);
|
||||
pump(s);
|
||||
assert.equal(s.status, 'active', 'the premise is gone: the game is not running');
|
||||
assert.equal(currentActorOfState(s), s.clock.currentActor, 'an active game lost its actor');
|
||||
assert.equal(snapshot(s, [], null, null, null, false, 0).actor, s.clock.currentActor);
|
||||
});
|
||||
|
||||
it('reports nobody once the table has declined and the game is finished', () => {
|
||||
const s = table();
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
|
||||
assert.equal(s.status, 'finished', 'a refusal did not end it');
|
||||
assert.equal(currentActorOfState(s), null, 'a finished game still had somebody to move');
|
||||
assert.equal(publicSnapshot(s).actor, null, 'the common board named a seat after the game ended');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { areaOf } from '../src/engine/apply.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { coordKey } from '../src/engine/state.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
import { applyDelta, deltaFrame } from '../src/sim/frame-delta.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
days: 5,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
|
||||
const game = (seed = 1): GameState => createGame({ id: 'g', seed, config, playerNames: ['a'] });
|
||||
|
||||
describe('deltaFrame — the live one-step-back board diff', () => {
|
||||
it('nulls cells/facilities/division when the board has not changed, keeps everything else', () => {
|
||||
const s = game();
|
||||
const first = snapshot(s, [], null);
|
||||
s.players[0]!.revenue = 42; // a non-board change
|
||||
const second = snapshot(s, [], null);
|
||||
|
||||
const delta = deltaFrame(first, second);
|
||||
assert.equal(delta.cells, null, 'unchanged cells were not nulled');
|
||||
assert.equal(delta.facilities, null, 'unchanged facilities were not nulled');
|
||||
assert.equal(delta.division, null, 'unchanged division was not nulled');
|
||||
assert.equal(delta.revenue, 42, 'a real change was dropped along with the unchanged board');
|
||||
});
|
||||
|
||||
it('sends the real board when it changed', () => {
|
||||
const s = game();
|
||||
const first = snapshot(s, [], null);
|
||||
const area = areaOf(s, 0);
|
||||
area.grid.set(coordKey({ row: area.runningRow - 1, col: 0 }), {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
const second = snapshot(s, [], null);
|
||||
|
||||
const delta = deltaFrame(first, second);
|
||||
assert.notEqual(delta.cells, null, 'a real board change was nulled');
|
||||
assert.deepEqual(delta.cells, second.cells);
|
||||
});
|
||||
|
||||
it('never nulls anything on a first connect (no previous Frame)', () => {
|
||||
const s = game();
|
||||
const frame = snapshot(s, [], null);
|
||||
const delta = deltaFrame(null, frame);
|
||||
assert.deepEqual(delta.cells, frame.cells);
|
||||
assert.deepEqual(delta.facilities, frame.facilities);
|
||||
assert.deepEqual(delta.division, frame.division);
|
||||
});
|
||||
|
||||
it('applyDelta round-trips: merge(delta(A, B)) against A reconstructs B exactly', () => {
|
||||
const s = game();
|
||||
const a = snapshot(s, [], null);
|
||||
s.players[0]!.revenue = 7;
|
||||
const area = areaOf(s, 0);
|
||||
area.grid.set(coordKey({ row: area.runningRow - 1, col: 1 }), {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
const b = snapshot(s, [], null);
|
||||
|
||||
const merged = applyDelta(a, deltaFrame(a, b));
|
||||
assert.deepEqual(merged, b);
|
||||
});
|
||||
|
||||
it('throws rather than silently reconstructing garbage when previous is missing but the delta says "unchanged"', () => {
|
||||
const s = game();
|
||||
const frame = snapshot(s, [], null);
|
||||
const delta = deltaFrame(frame, frame); // everything nulled, since nothing changed
|
||||
assert.throws(() => applyDelta(null, delta));
|
||||
});
|
||||
});
|
||||
+50
-14
@@ -29,11 +29,17 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'standard',
|
||||
days: 5,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
@@ -225,15 +231,29 @@ describe('the game conserves Rolling Stock', () => {
|
||||
|
||||
for (let t = 0; t < 50_000; t++) {
|
||||
const before = census(s);
|
||||
const pumped = pump(s);
|
||||
// §10 — a collision destroys both trains and everything they were carrying. That is the one
|
||||
// legitimate way the count falls, so the expectation follows it down.
|
||||
for (const e of pumped) {
|
||||
if (e.type === 'trainsDestroyed') for (const tr of e.trains) expected -= tr.consist.length;
|
||||
}
|
||||
assert.ok(
|
||||
census(s) === before || pumped.some((e) => e.type === 'trainsDestroyed'),
|
||||
`seed ${seed}: the engine changed the census by ${census(s) - before} outside a collision`,
|
||||
pump(s);
|
||||
/**
|
||||
* A COLLISION DESTROYS NO CAR, and this used to assume it destroyed all of them.
|
||||
*
|
||||
* The subtraction that stood here — `expected -= tr.consist.length` for every train in a
|
||||
* `trainsDestroyed` event — describes a rule the engine does not have. Gap 2c (`advance.ts`,
|
||||
* "TAKE THE WRECK OFF THE CARD") sends the wreck's cabooses back to the Division Yard and
|
||||
* everything else to Classification, so the stock is conserved through a collision like any
|
||||
* other move. The train is destroyed; its cars are not.
|
||||
*
|
||||
* It passed for as long as it did because none of the six seeds below ever collided, so the
|
||||
* branch never ran. Changing the deck to the sheet's counts (Gitea#14) moved the deals, seed
|
||||
* 24757 collided, and the test failed claiming the engine had CONJURED three cars — the
|
||||
* exact opposite of what had happened.
|
||||
*
|
||||
* So the census is now held flat, unconditionally, which is both the real invariant and a
|
||||
* stronger test than the one it replaces: there is no longer any event that excuses a
|
||||
* change, and `expected` cannot drift away from the supply it was dealt.
|
||||
*/
|
||||
assert.equal(
|
||||
census(s),
|
||||
before,
|
||||
`seed ${seed}: the engine changed the census by ${census(s) - before} while pumping`,
|
||||
);
|
||||
if (s.status === 'finished') break;
|
||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
@@ -275,15 +295,31 @@ describe('every published replay actually replays', () => {
|
||||
const save = JSON.parse(readFileSync(join(dir, f), 'utf8')) as {
|
||||
seed: number;
|
||||
history: unknown[];
|
||||
rules?: unknown;
|
||||
};
|
||||
const back = fromSave({ seed: save.seed, history: save.history as never });
|
||||
// The WHOLE save, `rules` included. Rebuilding it from seed and history alone threw away the
|
||||
// one field that says which ruleset the file was recorded under, so this replayed every
|
||||
// published file under the pre-dialog defaults no matter what it said — a test that would
|
||||
// pass a genuinely dead replay the moment the defaults and the file disagreed.
|
||||
const back = fromSave(save as never);
|
||||
assert.equal(
|
||||
back.history.length,
|
||||
save.history.length,
|
||||
`${f} is dead — it replays ${back.history.length} of ${save.history.length} intents. ` +
|
||||
'Re-record it with save-replay.ts rather than editing the file.',
|
||||
);
|
||||
assert.equal(back.state.status, 'finished', `${f} does not reach the end of its game`);
|
||||
/**
|
||||
* `awaitingExtension` counts as the end since Gitea#11 — and for a published file it is the
|
||||
* EXPECTED end. These saves were recorded before extended play existed, so their histories
|
||||
* carry no `game.extend` vote: replaying one runs the timetable out and stops on the question
|
||||
* nobody was there to answer. That is a game that reached the end of its own history, which is
|
||||
* what this test is about. `active` would still be a dead replay.
|
||||
*/
|
||||
assert.ok(
|
||||
back.state.status === 'finished' || back.state.status === 'awaitingExtension',
|
||||
`${f} does not reach the end of its game — status ${back.state.status}`,
|
||||
);
|
||||
assert.ok(back.state.official !== null, `${f} ends without recording a result`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+689
-117
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user