Compare commits

...
5 Commits
Author SHA1 Message Date
Jesse.MarkowitzandClaude Opus 5.5 8a29de54ee Docs: the StartOS package is the only place the game is served
The static site on the File Browser host was retired with the 0.4.9
line on 2026-09-29. README, TODO and the structure plan no longer
describe a site deploy as the release step or the zero-dependency rule
as what keeps the site a static upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rwKTmug58sEsJ72AuWsEi
2026-10-07 06:05:59 -04:00
Jesse.MarkowitzandClaude Fable 5.1 d04f4c2fe4 Two documents catch up with the save rule
The quickstart's bug-reporting section told a player to save the game and send the file;
in a Competitive multiplayer game that button now waits for the end, so it says so and says
to note the Day and Stage instead. The route table in multiplayer.md gains `/api/save`,
which had been in the code since 0.8.0.10 and never in the table. No code, no version.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
2026-09-29 18:23:03 -04:00
Jesse.MarkowitzandClaude Fable 5.1 def48201e4 v0.8.6 — a Competitive seat gets its save when the game is over
Jesse's ruling on TODO #117: accept the leak in Co-op; otherwise, save only at the end of
the game. `/api/save` answers 403 SAVE_AFTER_FINISH to a Competitive seat while the game
runs, and serves a Co-op or one-seat game at any time. The Save replay button says why and
stays disabled until the end. Pinned in the HTTP suite; documented in rules.md §6.4.

Also corrects the 0.8.3 changelog entry: 0.8.2's notes did name the Second Section card
going into the deck; what was missing was the save check after it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
2026-09-29 17:31:23 -04:00
Jesse.MarkowitzandClaude Fable 5.1 04ca74c365 v0.8.5 — housekeeping from the audit, and the playtest line retired
The third release from the audit; nothing a player sees changes. CHANGELOG has the detail.

The 0.4.9 playtest line is no longer maintained (Jesse, 2026-09-29): the deploy rule that
existed for it is gone and #85 is moot. The table test (#39 #35 #42a #40) is closed — every
line of the checklist was met at a table. #46 is done and cannot regrow: the 36 unused
declarations are removed and `noUnusedLocals`/`noUnusedParameters` are on; two of them were
dead bot functions from rejected candidates the round said it had deleted. The documents no
longer teach `trainCapSlack` (a knob that throws), point at `as-built.md` (deleted in 0.8.2),
model `officeType` (the engine says `tier`) or describe `collisionOccurred` (never emitted);
the README's account of bot flags now matches the bot's. Five playtest saves committed in
`docs/` against the repository's own rule are in the ignored `playtests/`.

What the audit found and did not fix is written down as TODO #112-#117, each with its reason.
#112 is `docs/plans/structure.md`, the proposal for `http.ts`, `main.ts` and `check`. #117 —
`/api/save` hands a seat the seed mid-game — waits on a conversation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
2026-09-29 17:02:33 -04:00
Jesse.MarkowitzandClaude Fable 5.1 e47cd3d400 v0.8.4 — the multiplayer transport: server and browser
The second release from the audit. Every fault here was invisible in solitaire, and four of
the five server faults were in the one file no test had ever stood up; `http.ts` now has an
end-to-end suite on a real port. CHANGELOG has the reasoning.

SERVER. Leaving a lobby freed the chair and kept the token, so a leaver could stream and
move for whoever took the seat next — revoked now, in memory and on disk. The browser
numbered intents from 1 per page load while the server remembered the seat's last number,
so the first move after a reload was swallowed as a resend — the connect push carries the
count and the client continues from it. Nothing serialised moves within a game and every
write shared one `.tmp` name, so two moves at once tore `game.json` (measured: 6 of 200),
and the boot's bare `JSON.parse` then took every game down — per-path write queues, a
per-game move queue, and a boot that skips one bad file. An error after the SSE head was
sent crashed the process. Bodies were unbounded before any secret check.

BROWSER. A double-click did the thing twice: one submit in flight at a time. A failed
submit is `false`, not an unhandled rejection. The documentation renderer flattened nested
bullets into a literal "- " mid-sentence on the published home-deck page. The make-up panel
promised cars the engine refuses; it asks `acceptsCar` now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
2026-09-29 17:02:32 -04:00
58 changed files with 1261 additions and 4926 deletions
+139
View File
@@ -19,6 +19,145 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
--- ---
## 0.8.6 — 2026-09-29
### A Competitive seat gets its save when the game is over
`/api/save` handed any seat its save — seed and all — while the game ran. The comment said every
move was already on the player's screen, which was true of the moves and false of the seed, and the
seed is every rival's hand and the order of the deck: the exact thing the shared log strips. But a
save without a seed is not a save. **Jesse's ruling: accept the leak in Co-op; otherwise, save only
at the end of the game.** A Competitive seat is refused with `SAVE_AFTER_FINISH` until the game is
finished, and the Save replay button says why and waits; a Co-op table, which has nothing to hide
from itself, and a one-seat game download at any time.
### A correction to the 0.8.3 entry
It says the Second Section card went into the deck "without a line in its notes". 0.8.2's release
notes did name it — "ordering a Second Section costs the card, which was declared in the rules and
never actually dealt". What was missing was not the line but the save check after the change; the
process rule in `TODO.md` stands as written.
---
## 0.8.5 — 2026-09-29
The third release from the audit: housekeeping. Nothing a player can see changes; what changes is
what the repository says about itself and what the build refuses.
### The playtest line is retired
The 0.4.9 line — the build handed to playtesters since August, ported by hand from `main` and
diverging structurally by 0.7 — is no longer maintained (Jesse, 2026-09-29). Its worktree and
branches are deleted, the "deploy from the right line" rule is gone with it, and #85 (the line
being behind on a ruling) is moot. The public site serves the last 0.4.9h build until `npm run
deploy:web` is run once from `main`.
### The table test is closed
#39, #35, #42a and #40 — the Yard Office offer, the Red Flag hold, the loaded Extra, extended play
through two browsers, the setup screen, the save-file sentence — were all met at a table. The
section stays in `TODO.md` for the one measurement it holds: which interruptions fire on their own
and which have to be set up.
### #46, and the flag that keeps it at zero
The unused-declaration count had gone 29 → 40 → 36 across six weeks because nothing ran the flag.
All 36 are gone and `noUnusedLocals` + `noUnusedParameters` are on in `tsconfig.json`. Two were dead
bot functions from rejected candidates the round said it had deleted (`wouldBuryTheEngine`,
`strandedWantedCars`); `isLegal` and `restoreRng` had no callers; `trayySeat` was a typo; the ten
in `sim/replay.ts` were imports, so #48 (what that viewer is for) is untouched.
### What the documents said that was not so
`README.md`, `compare.ts`, `save-replay.ts` and the bot all taught `trainCapSlack`, a knob deleted
weeks ago — `parseTweaks` throws on it. The README also said a flag is deleted when adopted while
the bot's own doc block says every flag is a permanent ablation of an adopted heuristic; the bot was
right, and had two doc blocks back to back, the first attached to nothing. `card-reference.md` sent
readers to `as-built.md`, deleted in 0.8.2; three `TODO.md` entries and two architecture pages said
the same. `game-state.md` modelled `officeType`, which the engine calls `tier`; `protocol.md`
described a `collisionOccurred` event the engine has never emitted (`trainsDestroyed`). `design.md`
carried its print-and-play paragraph twice. Five playtest saves — seeds, names, an August ruleset —
sat committed in `docs/` against the repository's own rule; they are in `playtests/` now, ignored.
### The audit's leftovers, written down
Everything the four reviewers found that was real and is not fixed is in `TODO.md` §11 as #112-#117,
each with its reason. #112 is `docs/plans/structure.md`: the route table for `http.ts`, five
extractions for `main.ts`, `check` per phase — each naming the test it would make possible. #117 is
the one that needs a conversation: `/api/save` hands a seat the seed mid-game, and a save without a
seed is not a save.
---
## 0.8.4 — 2026-09-29
The second release from the audit: the multiplayer transport, server and browser. Every fault here
was invisible in solitaire, and four of the five server faults were in the one file no test had
ever stood up — `http.ts` now has an end-to-end suite that binds a real port.
### A player who left could play the seat the next arrival took
Leaving a lobby freed the chair and kept the token: it stayed in memory and on disk, and the next
player to join was given the vacated chair. So once the game began, the leaver's browser still held
a token for that seat — `/api/stream` served it the newcomer's hand and menu, `/api/intent` let it
move for them, and its stream connection displaced theirs. `lobby-and-sessions.md` had said all
along that Leave "drops the token"; the code did not. It does now, for a player's own leave and for
the host's remove alike, in memory and in `sessions.json`, before the chair is offered to anyone.
### The first move after a reload could be silently swallowed
The browser numbered its intents from 1 on every page load; the server remembers a seat's last
accepted number for the life of the game and answers a repeat with "already applied". A seat that
had made one move, reloaded, and clicked again sent `seq: 1` twice — ok, nothing happened, nothing
pushed, the click looked dead. The connect push now carries the server's count (`lastSeq`) and the
client continues from it, never backwards.
### Two moves at once could tear the save, and a torn save took every game down
Nothing serialised moves within a game: the handler awaits the disk write between applying and
answering, and two moves arriving together interleaved across it — both applied in memory, both
writing the same `game.json.tmp`. Measured at 200 rounds of two concurrent writes: every round lost
one to `rename` ENOENT, six left the file as invalid JSON. And the boot did a bare `JSON.parse` on
each save at the top level, so one such file was a crash loop with every game on the server
unreachable. Three fixes, each pinned: every write to a path queues behind the one before it with a
unique temp name; each game's moves run one at a time through apply, persist, answer, broadcast; and
an unreadable save is logged and skipped rather than fatal, as is one whose replay throws.
### One error after the SSE head was sent was a whole-server crash
The handler's one `catch` answered every error with a JSON 500 — and on a response whose head was
already written (both streams, static files) `writeHead` throws inside the catch, with nothing above
it. Node exits on an unhandled rejection. `sendJson` now ends such a response instead; the lobby's
host-reassignment write and the static file stream have their own error paths; and the 500 no longer
echoes the error's message, which for a disk error carried the data directory's absolute path.
### Anyone could exhaust the process's memory with one POST
Request bodies were buffered whole, with no cap, before any secret was checked. A 64 KiB limit —
the largest body any route has a use for is a few hundred bytes — answers 413; a body that is not a
JSON object answers 400 rather than surfacing as a 500.
### In the browser
**A double-click did the thing twice.** The page redraws the same menu the instant a submit is sent,
so a second click before the round trip posted a second, fresh `seq` for the same option, and the
server applied it again: two cards drawn, two Moves spent, two cars coupled. One submit in flight at
a time now; a click that lands during one is dropped, and the push is milliseconds away. A network
failure or a non-JSON answer is `false` from `submit` rather than an unhandled rejection.
**The documentation renderer flattened nested bullets.** The comment said nesting was rendered by
recursion; the code appended the nested bullet to its parent as text, and the published home-deck
page read "…knows. - ABS Signals is the exception…" with a literal dash mid-sentence. Real nesting
now, and the test renders the real document to prove the dash is gone.
**"Still needs 2 boxcar" behind a caboose.** The make-up panel counted by category and promised cars
the engine would refuse: nothing couples behind a caboose (§A.3), so it printed a need while no yard
chip lit and the only offer was to send the train out as it stands. It asks `acceptsCar` per
category now, the way the engine's own make-up report does, and says why when nothing more couples.
---
## 0.8.3 — 2026-09-29 ## 0.8.3 — 2026-09-29
The first of three releases from a code audit (engine, server, client, tests and hygiene, each read The first of three releases from a code audit (engine, server, client, tests and hygiene, each read
+8 -7
View File
@@ -84,14 +84,14 @@ station-master/
├── CHANGELOG.md ← what changed and why, in detail, commit to commit ├── CHANGELOG.md ← what changed and why, in detail, commit to commit
├── TODO.md ← open questions, provisional numbers, things to come back to ├── TODO.md ← open questions, provisional numbers, things to come back to
├── docs/ ├── 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 │ ├── architecture/ ← how it is built, and what the pieces are
│ ├── plans/ ← worked plans for a single change, kept for the reasoning │ ├── plans/ ← worked plans for a single change, kept for the reasoning
│ └── design/ ← board layout studies and rendering samples │ └── design/ ← board layout studies and rendering samples
├── public/ ├── public/
│ ├── replays/ ← saved games published to the site's replay directory │ ├── replays/ ← saved games published to the site's replay directory
│ └── images/ ← art the build copies into the site │ └── images/ ← art the build copies into the site
├── scripts/ ← build and deploy the static site ├── scripts/ ← build the site the package serves (and a static-host deploy, unused since 2026-09-29)
├── src/ ├── src/
│ ├── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed │ ├── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed
│ ├── server/ ← the authoritative multiplayer server: lobby, sessions, persistence │ ├── server/ ← the authoritative multiplayer server: lobby, sessions, persistence
@@ -124,7 +124,7 @@ syntax**: no `enum`, no parameter properties, no namespaces. `tsconfig.json` enf
```sh ```sh
node src/sim/harness.ts 200 # how the bot does, with the funnel 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 **Never judge a heuristic on an unpaired run.** Revenue has σ ≈ 9 across games, so two runs of the
@@ -134,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 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. claim from one spread across the field.
Variants come from `makeDeveloperBot(tweaks)`. A tweak is **temporary** — when it measures well it Variants come from `makeDeveloperBot(tweaks)`. Every flag is an **ablation**: it turns OFF a
becomes the default and the flag is deleted in the same commit; when it measures badly it is deleted heuristic that is now the bot's default play (`noPlanSwitching`, `noValueLays`, …), so an adopted
with the finding recorded in `CHANGELOG.md`. A bot that accumulates switches nobody can account for heuristic can be re-measured when the deck or the rules move under it. A candidate that measures
is the thing this machinery exists to prevent. 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 ## Design notes worth knowing
+141 -27
View File
@@ -54,8 +54,15 @@ Not items. Things that are true of every change, and that have gone wrong when s
- **Commits and tags are GPG-signed and I cannot make them.** Stage the work, write the message to - **Commits and tags are GPG-signed and I cannot make them.** Stage the work, write the message to
a file, hand over a `!` command. Tags must be `git tag -s` — a bare `git tag` makes a lightweight a file, hand over a `!` command. Tags must be `git tag -s` — a bare `git tag` makes a lightweight
tag and `git push --follow-tags` skips it *without any error*. tag and `git push --follow-tags` skips it *without any error*.
- **Deploy from the right line.** `npm run deploy:web` defaults to the same destination on both - **There is one line and one place the game is served.** The 0.4.9 playtest line was retired on
lines, so deploying from `station-master/` silently replaces the playtesters' build with main's. 2026-09-29 (Jesse: "no longer being maintained"), and the static site on the File Browser host
with it: "the game is served on the StartOS service for station master." Releasing is the wrapper
bump and `make install`; there is no site deploy. `npm run deploy:web` stays in the repo only in
case a static host ever returns, and nothing depends on it.
- **The save check is the LAST thing before the tag, not a thing done during the work.** 0.8.2
replayed every server save, wrote "three resume, ten refuse" into its notes, and then put a card
into the deck — and shipped with zero of thirteen resuming. Replay the server's saves against the
exact tree being tagged (`tryResumeSession`, not `fromSave`).
- **A failing test written before the fix is the only thing that proves a fix.** Three releases - **A failing test written before the fix is the only thing that proves a fix.** Three releases
(v0.7.5 through v0.7.8) each reported the same bug fixed, and each fixed something real that was (v0.7.5 through v0.7.8) each reported the same bug fixed, and each fixed something real that was
not the reported fault, because every verification read what the SERVER served rather than not the reported fault, because every verification read what the SERVER served rather than
@@ -97,7 +104,7 @@ Not items. Things that are true of every change, and that have gone wrong when s
## Sections ## Sections
1. **Play it at a table** — #39 #35 #42a #40 1. **Play it at a table** — CLOSED 2026-09-29: #39 #35 #42a #40 all confirmed at a table
2. **The common board, and watching play happen — Gitea#20** — #13 #15 #18 #75 2. **The common board, and watching play happen — Gitea#20** — #13 #15 #18 #75
3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79 3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79
4. **The screen** — #44 #81 #33 #36 4. **The screen** — #44 #81 #33 #36
@@ -107,6 +114,7 @@ Not items. Things that are true of every change, and that have gone wrong when s
8. **The bot** — #104 #105 #106 #41 #57 #59 #54 #58 #55 #56 #60 8. **The bot** — #104 #105 #106 #41 #57 #59 #54 #58 #55 #56 #60
9. **Code health and housekeeping** — #46 #45 #84 #87 9. **Code health and housekeeping** — #46 #45 #84 #87
10. **Documentation and assets** — #15a #86 #88 #111 10. **Documentation and assets** — #15a #86 #88 #111
11. **The 2026-09-29 audit — what it found and did not fix** — #112 #113 #114 #115 #116 #117
Then, at the back: **Reference** (the measurements, rulings and rejected approaches behind the Then, at the back: **Reference** (the measurements, rulings and rejected approaches behind the
items above) and **Done** (everything closed, kept because several of them are the only record of a items above) and **Done** (everything closed, kept because several of them are the only record of a
@@ -116,6 +124,10 @@ ruling or a lesson).
## Play it at a table ## Play it at a table
**CLOSED 2026-09-29.** Jesse: "The table test was completed." Every item and every checklist line
below was met at a table; the section is kept because the measurement in *Preparing the session*
(which interruptions fire by themselves and which have to be set up) is the only record of it.
The largest gap in the project, and none of it is a coding gap. Features are shipped, packed, The largest gap in the project, and none of it is a coding gap. Features are shipped, packed,
running on `phoenix.local` — and the items below name the ones no person has met at a board. running on `phoenix.local` — and the items below name the ones no person has met at a board.
Everything else in this file waits behind a release; this waits behind an afternoon. Everything else in this file waits behind a release; this waits behind an afternoon.
@@ -188,41 +200,41 @@ developer present; what it needs is somebody writing down what they saw.
**Game A — `days: 1`, two humans, two browsers.** Reaches the extension vote in one Day. **Game A — `days: 1`, two humans, two browsers.** Reaches the extension vote in one Day.
- [ ] The vote appears **in front of both players**, not underneath the results dialog (#35 — this is - [x] The vote appears **in front of both players**, not underneath the results dialog (#35 — this is
the exact shape of the bug v0.7.9 fixed). the exact shape of the bug v0.7.9 fixed).
- [ ] While one player has not voted, the other's turn chart says **who** it is waiting on (#35). - [x] While one player has not voted, the other's turn chart says **who** it is waiting on (#35).
- [ ] **Close the second laptop mid-vote.** Does the first player learn why nothing is happening, and - [x] **Close the second laptop mid-vote.** Does the first player learn why nothing is happening, and
does "waiting on Carol" still read once Carol is gone? (#35 — never tested.) does "waiting on Carol" still read once Carol is gone? (#35 — never tested.)
- [ ] Reopen it. The history panel comes back **populated**, not empty, and the board is current - [x] Reopen it. The history panel comes back **populated**, not empty, and the board is current
(the v0.7.9.5 reconnect fix, never seen by a person). (the v0.7.9.5 reconnect fix, never seen by a person).
- [ ] Vote yes. The extra Day begins and the official result is **unchanged** from when the - [x] Vote yes. The extra Day begins and the official result is **unchanged** from when the
timetable ran out (#35). timetable ran out (#35).
**Game B — ordinary length, two humans, bots to fill.** Everything else. **Game B — ordinary length, two humans, bots to fill.** Everything else.
- [ ] Somebody **builds a Yard Office** and lets a freight train (no coach) arrive at it. The offer - [x] Somebody **builds a Yard Office** and lets a freight train (no coach) arrive at it. The offer
interrupts the Mainline Phase and asks a question mid-thought — is it legible, and does it say interrupts the Mainline Phase and asks a question mid-thought — is it legible, and does it say
which train? (#39) which train? (#39)
- [ ] Somebody **holds the Red Flags card** while their A/D tracks are full, so an arrival would - [x] Somebody **holds the Red Flags card** while their A/D tracks are full, so an arrival would
collide. The hold is offered out of phase (#39). collide. The hold is offered out of phase (#39).
- [ ] A **loaded Extra** is made up and run (#39 — the third of its three). - [x] A **loaded Extra** is made up and run (#39 — the third of its three).
- [ ] Watch a bot take a whole turn: does the district follow it, does the lit pile catch the eye, - [x] Watch a bot take a whole turn: does the district follow it, does the lit pile catch the eye,
does the caption say who and what? (v0.8.0) does the caption say who and what? (v0.8.0)
- [ ] Find the speed that suits you and say what it is — it becomes the committed default. - [x] Find the speed that suits you and say what it is — it becomes the committed default.
- [ ] Let the board fall behind, then press **Skip**. Nothing is lost; the history has it all. - [x] Let the board fall behind, then press **Skip**. Nothing is lost; the history has it all.
- [ ] End a Day with a collision on it: the summary reads "N on Day D, N in all" and cannot - [x] End a Day with a collision on it: the summary reads "N on Day D, N in all" and cannot
contradict itself (v0.8.0.2). contradict itself (v0.8.0.2).
**Solitaire, five minutes, alone.** **Solitaire, five minutes, alone.**
- [ ] Click through **every field** on the setup screen and confirm the dealt game matches what was - [x] Click through **every field** on the setup screen and confirm the dealt game matches what was
chosen (#42a). chosen (#42a).
**Whatever else happens.** The two bugs that came out of the 0.7.4-0.7.9 runs were both things **Whatever else happens.** The two bugs that came out of the 0.7.4-0.7.9 runs were both things
nobody set out to test. Write down anything that reads wrong, even where the rule underneath is nobody set out to test. Write down anything that reads wrong, even where the rule underneath is
right — most of this release's defects were legible-but-wrong rather than broken. right — most of this release's defects were legible-but-wrong rather than broken.
- [ ] **#39** — **None of v0.7.4 has been played by a human.** The Yard Office offer, the Red Flag - [x] **#39** — **CONFIRMED at a table, 2026-09-29.** Originally: **None of v0.7.4 has been played by a human.** The Yard Office offer, the Red Flag
hold and its out-of-phase prompt, and the loaded-Extra make-up rules are tested end to end, hold and its out-of-phase prompt, and the loaded-Extra make-up rules are tested end to end,
packed, and running on `phoenix.local` — and nobody has met any of them at a board. **Two are packed, and running on `phoenix.local` — and nobody has met any of them at a board. **Two are
interruptions that stop the Mainline Phase and put a question in front of somebody interruptions that stop the Mainline Phase and put a question in front of somebody
@@ -230,7 +242,7 @@ right — most of this release's defects were legible-but-wrong rather than brok
happens by itself — 0/10 games. See Preparing the session above for what to set up.** See happens by itself — 0/10 games. See Preparing the session above for what to set up.** See
**Reference · #39**. **Reference · #39**.
- [ ] **#35** — **Extended play has never been played at a real table.** It was verified over the HTTP - [x] **#35** — **CONFIRMED at a table, 2026-09-29.** Originally: **Extended play has never been played at a real table.** It was verified over the HTTP
API, which renders no dialog — and when a human first reached it in a browser it was unusable API, which renders no dialog — and when a human first reached it in a browser it was unusable
(fixed in v0.7.9). The multiplayer vote has still never been driven through two browsers: what a (fixed in v0.7.9). The multiplayer vote has still never been driven through two browsers: what a
second player sees while waiting, and whether "waiting on Carol" reads once Carol has closed her second player sees while waiting, and whether "waiting on Carol" reads once Carol has closed her
@@ -426,8 +438,9 @@ need RAR or Jesse rather than code.**
Gitea#14 closed with those ten listed on the issue so they do not vanish with it. See Gitea#14 closed with those ten listed on the issue so they do not vanish with it. See
**Reference · #83**. **Reference · #83**.
- [ ] **#85** — The 0.4.9 playtest line is behind on a rules ruling, and that was checked rather than - [x] **#85** — **MOOT 2026-09-29** — the 0.4.9 playtest line is retired, so it is behind on every ruling
assumed. See **Reference · #85**. since and that no longer matters. Originally: behind on a rules ruling, checked rather than assumed.
See **Reference · #85**.
- [x] **#107** — **May a Small Yard put cars on the NOSE of the engine? YES** — raised by Jesse - [x] **#107** — **May a Small Yard put cars on the NOSE of the engine? YES** — raised by Jesse
2026-09-17, discussed the same day and built. Two sources disagreed: the v0.4.5 card text says 2026-09-17, discussed the same day and built. Two sources disagreed: the v0.4.5 card text says
@@ -580,9 +593,11 @@ non-player advantage — it reads the board, never the deck.
Dead code, untrustworthy tests, and things carried but not used. Individually small; the reason they Dead code, untrustworthy tests, and things carried but not used. Individually small; the reason they
are one section is that each one found the next. are one section is that each one found the next.
- [ ] **#46** — 29 unused declarations across 14 files, and the build does not run the flag that finds - [x] **#46** — **DONE 2026-09-29 (v0.8.5).** The 36 it had regrown to are gone and
them. **The flag matters more than the 29** — two are Gitea#18 leftovers in one file, one found `noUnusedLocals` + `noUnusedParameters` are on in `tsconfig.json`, so the list cannot regrow.
by hand and the other missed. Do #48 first; it settles ten of them. See **Reference · #46**. The ten in `sim/replay.ts` were unused IMPORTS, removed without deciding #48 — that question is
untouched. Two dead bot functions (`wouldBuryTheEngine`, `strandedWantedCars`) were rejected
candidates left behind; `isLegal` and `restoreRng` had no callers. See **Reference · #46**.
- [ ] **#84** — Five test fixtures pinned a seed and meant "a game like this". All five broke on - [ ] **#84** — Five test fixtures pinned a seed and meant "a game like this". All five broke on
Gitea#14 for that reason. See **Reference · #84**. Gitea#14 for that reason. See **Reference · #84**.
@@ -613,6 +628,88 @@ What the project says about itself, and what it ships alongside the code.
--- ---
## The 2026-09-29 audit — what it found and did not fix
Four reviewers read the engine, the server, the browser client and the sim/tests/hygiene, and every
finding was re-verified against the code before anything was acted on. v0.8.3 (engine), v0.8.4
(transport) and v0.8.5 (housekeeping) took the faults; these are the findings that were real and
were NOT fixed, each with the reason, so nothing quietly evaporates.
- [ ] **#112** — **The structure proposal.** `docs/plans/structure.md`: a route table with auth
wrappers for `http.ts`; five extractions and a `Selection` value for `main.ts`; `check` and
`reduce` split per phase; one `carCategory`; one `Push` type. Each names the test it makes
possible. Ordered by payoff; the first two are afternoons. Do the `http.ts` table before the
next route (#20's display stream).
- [ ] **#113** — **Server faults left as found.** (a) A second SSE connection from the same seat
shadows the first without ending it, and the old socket's close then broadcasts "disconnected"
for a seat that is still there — end the old response on replace, and only announce a close
when the closing response is the live one. (b) Seat tokens travel in URLs on `/api/intent`,
`/api/save`, `/api/session`, and the JOIN SECRET on `/api/lobby/preview?secret=` — every
reverse proxy's access log holds them; `lobby-and-sessions.md` §1 says keep them out. Move to a
header or the body (EventSource forces the two stream routes). (c) `gameCodes` is not seeded
from running games on boot, so a game that survived a restart finishes with `gameCode: ''` in
the index and the admin listing, and `freshGameCode` can reissue its code. (d) `/api/lobby/start`
mutates memory and tells every lobby watcher the game began BEFORE the writes; a failed write
resurrects the lobby on restart with a fresh seed. (e) Secret comparisons are `!==`;
`timingSafeEqual` costs nothing. (f) An admin can mint a claim for a lobby seat that `/api/claim`
then cannot redeem. (g) `everConnected` is never pruned on delete. (h) `body.config` from the
host is never shape-checked — a bad one wedges the lobby at Start with a 500 each time.
- [ ] **#114** — **Client faults left as found.** (a) Rules refusals and transport failures are
invisible: every `void session.submit(...)` discards the `false`, and `lobby.ts`'s `postJson`
has no catch, so a host who presses Start while the server restarts sits on "Starting…" until
a reload. (b) `lobby.ts` reads `localStorage` bare (four sites) where `main.ts` guards every
access — a browser with site storage blocked throws before any button is wired. (c) `claimSeat`
awaits with no try: a 502 leaves the lobby doors drawn and dead. (d) `build-web.ts` stamps
`sha-dirty` for every dirty build of one commit, so two dirty deploys publish byte-identical
module URLs and a returning browser serves stale modules against new HTML. (e) "New game" on
the results screen does `location.search = ''`, which the code elsewhere asserts is a no-op
when the search is already empty — the save is wiped and the player stays on the finished
board; `commitNewGame` has the `reload()` fallback, this button does not. (f) The animation
loop outlives the session: `leavegame` does not reset the step queue, so rejoining another game
runs the old game's steps against the new session until the first push. (g) Seven independent
HTML-escape helpers with differing coverage, none escaping `'`; no test feeds a display name
containing `<` or `"`. No XSS was found; the risk is the next helper.
- [ ] **#115** — **Engine drift left as found.** (a) `maneuver.flyingSwitch` is a weaker copy of
`switch.dropCars` — no `switchingRefusal`, no `engineAt` clamp, no `standingWest` handling — latent
at 0 copies, wrong the day the card is dealt. (b) Car category is spelled three times
(`acceptsCar`, `newTrainPhase`, `isFreight`/`carriesLoad`). (c) `redFlag.play` emits a
`phaseEnded` the reducer ignores — a no-op intent offered whenever the Emergency Toolbox is on;
either the toolbox or the intent is vestigial. **Needs Jesse.** (d) The hand limit is enforced
only on `draw.end`; `switch.end` and `freightAgent.end` let a `sixRandom` hand stay at six all
game, and `card.discard` is ungated by option. (e) `freightAgent.unjam` from an inbound box
returns the car `pooled()` but loaded — the same "coach that can never unload again" 0.8.1.0
fixed for `clearInbound`. (f) `mainlinePhase` iterates a snapshot of trays after `collide`
deletes some, so later per-train logic in that loop reads a dead tray; benign today. (g) A
`Map`-order dependence in candidate ordering that would not survive deserialising state from
JSON with a different key order — worth one comment in `legal.ts`.
- [ ] **#116** — **Test-suite faults left as found.** (a) `test/card-reference.test.ts` REWRITES
`docs/home-deck.md` and `mainline-deck.md` and then compares — when they are stale the test is
red AND the diff to inspect is already gone. Generate to a string and compare. (b)
`test/track.test.ts` asserts on wall-clock elapsed time (`< 5000 ms`) in the default suite.
(c) `test/sim.test.ts` pins 300 games to `seed: 1000 + i*7919` to reach the one where a tank
car is first dropped (#84's shape; it is the seven-minute suite that breaks). (d)
`test/web.test.ts` slices `src/server/http.ts`'s source text between two constant names. (e)
`multiplayer.test.ts` still hard-codes seed 4242 where the playtest line had a seed search.
(f) `package.json`'s `test/**/*.test.ts` only works because dash has no globstar and there is
exactly one nesting level; spell it `test/*/*.test.ts`.
- [x] **#117** — **RULED 2026-09-29 (Jesse): "accept the leak in coop. otherwise save only at end of
game."** Built the same day: `/api/save` answers `403 SAVE_AFTER_FINISH` to a Competitive seat
while the game runs and serves a Co-op or one-seat game at any time; the page's Save replay
button says so and stays disabled until the end. Originally: **`/api/save` hands every seat the seed mid-game — NEEDS JESSE.** The seat's own
save download returns `seed` while the game is active; in Competitive that is every rival's
hand and the deck order, the exact leak `game.ts` strips from the log. But a save without the
seed cannot replay, which is the whole point of a save. Jesse (2026-09-29): "We should discuss
before making changes on this one." Options on the table: serve the seat's save only once the
game is finished; serve it mid-game without the seed (a receipt, not a replay) and with the seed
once finished; or accept the leak in Co-op only. See **Reference · #117**.
---
## Reference — measurements, rulings and rejected approaches ## Reference — measurements, rulings and rejected approaches
The argument behind each open item, kept out of the work sections so those stay scannable. Moved The argument behind each open item, kept out of the work sections so those stay scannable. Moved
@@ -2054,7 +2151,8 @@ same rule was applied to `content.ts`'s own comments on 2026-08-22 — see the p
CHANGELOG for what came out and what was kept. CHANGELOG for what came out and what was kept.
**BUILT 2026-09-07 in v0.7.9.2, completed in v0.7.9.3, as the first of the two options** — a build step writing Markdown, **BUILT 2026-09-07 in v0.7.9.2, completed in v0.7.9.3, as the first of the two options** — a build step writing Markdown,
`scripts/build-card-reference.ts` → `docs/rules/as-built.md`, with `test/card-reference.test.ts` `scripts/build-card-reference.ts` → `docs/rules/as-built.md` (since 0.8.2 the tables are written
into `docs/home-deck.md` and `docs/mainline-deck.md` instead), with `test/card-reference.test.ts`
re-running the generator and failing when the checked-in file disagrees. All six sections are re-running the generator and failing when the checked-in file disagrees. All six sections are
there, and so is the honesty column: every Enhancement carries its `live` / `dormantSolo` / there, and so is the honesty column: every Enhancement carries its `live` / `dormantSolo` /
`unbuilt` status, and the opponent-directed cards say plainly that none of them is dealt. `unbuilt` status, and the opponent-directed cards say plainly that none of them is dealt.
@@ -2108,8 +2206,9 @@ deliberately did NOT rewrite them, on the grounds that changing a Laborer count
decision rather than a documentation one. **That reasoning still stands and nothing was changed in decision rather than a documentation one. **That reasoning still stands and nothing was changed in
the engine.** the engine.**
What changed is that `card-reference.md` is no longer where anyone looks. `docs/rules/as-built.md` What changed is that `card-reference.md` is no longer where anyone looks. The card tables (in
is generated from `content.ts` and carries the industry table the game actually runs; the old file `docs/home-deck.md` and `docs/mainline-deck.md` since 0.8.2; `docs/rules/as-built.md` before) are
generated from `content.ts` and carries the industry table the game actually runs; the old file
keeps its SUPERSEDED banner, now pointing forward, and its numbers are read as what the v0.4.5 keeps its SUPERSEDED banner, now pointing forward, and its numbers are read as what the v0.4.5
placeholder said. **The balance question the entry was really guarding is #70** (the rolling stock placeholder said. **The balance question the entry was really guarding is #70** (the rolling stock
supply and the uniform 1/1/1 industry model, both marked provisional in `content.ts`) — that is supply and the uniform 1/1/1 industry model, both marked provisional in `content.ts`) — that is
@@ -2191,6 +2290,20 @@ distinction that matters:
is the editorial half that generation was never going to do. is the editorial half that generation was never going to do.
### The 2026-09-29 audit
#### #117 — `/api/save` and the seed
Found by the server reviewer, verified: `http.ts`'s `/api/save` returns `session.exportSave()` —
seed, config, names, full history — to any valid seat token while `status === 'active'`. The comment
above the route says "every one of those moves is already on this player's screen", which is true
of the moves and false of the seed. `game.ts` strips the seed from the shared log for exactly this
reason ("handed each of them the whole future of the deal"), `test/redaction.test.ts` pins that a
Frame never carries it, and `/api/lobby/preview` refuses to send it. Jesse's question, which is the
right one: a save without the seed is not a save. The finished-game-only answer keeps the download
button honest (it works; it just waits for the end); the receipt answer keeps a mid-game download
possible but replays nothing; the Co-op answer accepts that a co-operative table has nothing to hide.
## Done ## Done
Closed items, kept because several are the only record of a ruling or a lesson. Newest first within Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
@@ -2501,7 +2614,8 @@ the numbers stay so cross-references above and below still resolve.
it describes the v0.4.5 deck, where 3/4 is a Mail-Express with three coaches against a `content.ts` it describes the v0.4.5 deck, where 3/4 is a Mail-Express with three coaches against a `content.ts`
whose train 3 is the Express with two freight cars. **The fix is not a rewritten table** — every whose train 3 is the Express with two freight cars. **The fix is not a rewritten table** — every
file in `docs/rules/` is a deliberate historical record and worth more intact than patched. A new file in `docs/rules/` is a deliberate historical record and worth more intact than patched. A new
`as-built.md` is GENERATED from the same catalogues the engine instantiates from, by `as-built.md` (folded into `docs/home-deck.md` and `docs/mainline-deck.md` in 0.8.2) is
GENERATED from the same catalogues the engine instantiates from, by
`scripts/build-card-reference.ts` (`npm run build:cards`), and `test/card-reference.test.ts` `scripts/build-card-reference.ts` (`npm run build:cards`), and `test/card-reference.test.ts`
re-runs the generator and fails if the checked-in file disagrees. **Worth knowing:** a re-runs the generator and fails if the checked-in file disagrees. **Worth knowing:** a
hand-written replacement would have drifted the same way and for the same reason — nothing fails hand-written replacement would have drifted the same way and for the same reason — nothing fails
+3 -1
View File
@@ -60,7 +60,9 @@ is what makes the whole system testable without a server.
- **Where:** Engine · **When:** loaded once at startup, immutable thereafter - **Where:** Engine · **When:** loaded once at startup, immutable thereafter
- **Does:** the 52-card deck composition, freight and passenger facility profiles, Modifier effects, - **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 - **MVP: S** · **Final: S** · ~200 → ~350 LOC
- **Size driver:** it is data, not logic. Grows only if card variants are added. - **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, - **Note:** every number here is provisional and will be retuned repeatedly. Keep it as data files,
+12 -10
View File
@@ -131,24 +131,24 @@ Office; everything else is Secondary Track.
``` ```
OfficeArea OfficeArea
owner : PlayerIndex 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 grid : Map<GridCoord, TrackCard> -- sparse; cards are placed during play
officeCoord : GridCoord -- where the Office card sits officeCoord : GridCoord -- where the Office card sits
runningRow : integer -- the grid row that is the Running Track runningRow : integer -- the grid row that is the Running Track
limitsWest : GridCoord -- moves outward as the Running Track grows limitsWest : GridCoord -- moves outward as the Running Track grows
limitsEast : GridCoord limitsEast : GridCoord
adOccupancy : CrewTrayId[] -- length ≤ adTrackCount(officeType) adOccupancy : CrewTrayId[] -- length ≤ adTrackCount(tier)
``` ```
``` ```
adTrackCount: whistlePost 1 | depot 2 | station 3 | terminal 4 -- §11.1 adTrackCount: whistlePost 1 | depot 2 | station 3 | terminal 4 -- §11.1
isControlPoint = officeType != whistlePost isControlPoint = tier != whistlePost
isPassengerFacility = officeType != whistlePost isPassengerFacility = tier != whistlePost
``` ```
**Office cards are geometrically interchangeable** (§11.3). All four tiers carry the same track **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 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 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. 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 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 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 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 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. 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 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 All of these are populated from `content.ts`, transcribed from the catalogue spreadsheets; the
the authoritative per-card catalogue. 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 **`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 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 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 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 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 [`../rules/card-reference.md`](../rules/card-reference.md#7-economy-summary) for the model as it was
what §3's targets are calibrated against. 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.
--- ---
+3 -1
View File
@@ -107,7 +107,9 @@ clashing join is refused (`NAME_TAKEN`, compared trimmed and case-insensitively)
suffixed: a player should play under the name they chose, or be asked for another. 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 **Anybody may leave, and the host may clear a chair.** `Lobby.Leave` frees the seat, drops the token
from `joinOrder`, and passes host rights on exactly as a dropped connection does. Naming somebody 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 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 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 existed a mis-join or a player who wandered off wedged the whole table, since Start needs every chair
+1
View File
@@ -245,6 +245,7 @@ special handling: `pump` stops, and the next push simply carries a `Menu` contai
POST /api/lobby/create, /api/lobby/join lobby POST /api/lobby/create, /api/lobby/join lobby
POST /api/intent { gameId, seq, intent } POST /api/intent { gameId, seq, intent }
GET /api/stream EventSource — per-seat frames, with Last-Event-ID resume 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/health { ok, service, engineVersion, games } — unauthenticated; see below
GET /api/games every game and lobby, summarised ┐ GET /api/games every game and lobby, summarised ┐
GET /api/games/<id>/save the save, for keeping or replaying ├ ADMIN_SECRET GET /api/games/<id>/save the save, for keeping or replaying ├ ADMIN_SECRET
+10 -3
View File
@@ -96,7 +96,8 @@ or a replay viewer has to reconstruct the whole board to draw one frame.
Two consequences worth knowing before adding an event: Two consequences worth knowing before adding an event:
- **`collisionOccurred` carries `faultPlayer` explicitly** rather than leaving clients to derive it. - **`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 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 between their Limits (§10) — and getting it wrong misattributes a −5 and, in Competitive, feeds a
floor that ends the game. floor that ends the game.
@@ -151,8 +152,14 @@ multiplayer work: everything else degrades gracefully, a redaction bug hands a p
- Events carry a monotonic sequence per game. Clients apply strictly in order and request a replay on - Events carry a monotonic sequence per game. Clients apply strictly in order and request a replay on
a gap rather than guessing. a gap rather than guessing.
- Intents carry a client `seq`. The server ignores a repeat of one it has already applied, so a - Intents carry a client `seq`. The server ignores a repeat of one it has already applied, so a
reconnecting client can safely resend anything it is unsure about. reconnecting client can safely resend anything it is unsure about. **The 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 - **The server never applies two intents concurrently within a game.** A per-game queue is sufficient
and there is nothing cleverer to do at this scale. Note this is a serialisation rule, not a 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 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. players may hold an open turn at once, and their intents still land one at a time.
+1 -1
View File
@@ -1,6 +1,6 @@
# Station Master — Components and Markers # Station Master — Components and Markers
**Version 0.8.3** · 2026-09-29 **Version 0.8.6** · 2026-09-29
**Scope:** non-card physical components and supplies. Card-created facilities, workers, deck piles, **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 hand state, timetable state and other markers are documented with their cards or in the
-3
View File
@@ -120,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 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. 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.
+1 -1
View File
@@ -1,6 +1,6 @@
# Station Master — Home Deck # Station Master — Home Deck
**Version 0.8.3** · 2026-09-29 **Version 0.8.6** · 2026-09-29
**Scope:** the Home Office deck — how it is dealt, drawn, discarded and reshuffled, and what the **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. rules are for playing each kind of card out of it.
+1 -1
View File
@@ -1,6 +1,6 @@
# Station Master — Mainline Deck # Station Master — Mainline Deck
**Version 0.8.3** · 2026-09-29 **Version 0.8.6** · 2026-09-29
**Scope:** the tarot-sized Mainline cards placed between Offices — how the deck is dealt, what a **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. card does to a train crossing it, and the Home Deck cards played onto one.
+113
View File
@@ -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`.
+4 -2
View File
@@ -1,6 +1,6 @@
# Station Master — Quickstart # Station Master — Quickstart
**Version 0.8.3** · 2026-09-29 **Version 0.8.6** · 2026-09-29
For a player who has never played. For a player who has never played.
@@ -190,7 +190,9 @@ 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 - **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". 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 - **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. 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 - **Anything the screen did not explain.** If you had to guess a rule, that is worth saying even
when the game was right. when the game was right.
- **Anything you went looking for and could not find.** - **Anything you went looking for and could not find.**
+6 -1
View File
@@ -1,6 +1,6 @@
# Station Master — Rules # Station Master — Rules
**Version 0.8.3** · 2026-09-29 **Version 0.8.6** · 2026-09-29
**Authority:** observed code paths and tests. Where a card face, a prototype document and executable **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. behaviour differ, this document reports **executable behaviour** and marks unimplemented material.
@@ -372,6 +372,11 @@ stay where they are.
So multiplayer changes shared traffic, scores, turn order and how the game is won or lost — not card 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. 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 ## 7. Frequently asked questions
### Is the same seed always the same game? ### Is the same seed always the same game?
+4 -3
View File
@@ -4,9 +4,10 @@
> 2026-07-30 in `docs/Deck cards2.xlsx`, `Trains3.pdf` and `Mainline Cards.pdf`, and is transcribed > 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. > in `src/engine/content.ts`. See [`implications.md`](implications.md) for the full comparison.
> >
> **For what the cards say today, read [`as-built.md`](as-built.md)** — generated from > **For what the cards say today, read the generated tables in [`home-deck.md`](../home-deck.md)
> `content.ts` and checked against it by the test suite, so it cannot fall behind the way this file > and [`mainline-deck.md`](../mainline-deck.md)** — written by `npm run build:cards` from
> did. For several releases `content.ts` named *this* page as the current reference while the banner > `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. > 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 > Kept for the reasoning it records — the economy analysis in §7 was how we knew what questions to
-301
View File
@@ -1,301 +0,0 @@
{
"seed": 116956197,
"history": [
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c27"
},
{
"type": "card.play",
"cardId": "c30"
},
{
"type": "card.play",
"cardId": "c4"
},
{
"type": "card.play",
"cardId": "c2"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.play",
"cardId": "c197",
"placement": {
"row": 0,
"col": -1
},
"variant": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.play",
"cardId": "c163",
"placement": {
"row": 1,
"col": -1
},
"variant": 0
},
{
"type": "card.play",
"cardId": "c41",
"placement": {
"row": 1,
"col": 0
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c127",
"placement": {
"row": 0,
"col": 1
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c183",
"placement": {
"row": 0,
"col": 2
},
"variant": 1
},
{
"type": "card.play",
"cardId": "c150",
"placement": {
"row": 1,
"col": 2
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.play",
"cardId": "c136",
"placement": {
"row": 1,
"col": 1
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c77",
"toSlot": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c171",
"toSlot": 0
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "boxcar",
"loaded": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "boxcar",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c153",
"toSlot": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c9"
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
}
],
"rules": {
"startingHand": "sixRandom",
"revenue": {
"passengerPerCoach": 1,
"freightPerLoad": 1,
"trainPerTransit": 0
}
}
}
@@ -1,714 +0,0 @@
{
"seed": 116956197,
"history": [
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c27"
},
{
"type": "card.play",
"cardId": "c30"
},
{
"type": "card.play",
"cardId": "c4"
},
{
"type": "card.play",
"cardId": "c2"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.play",
"cardId": "c197",
"placement": {
"row": 0,
"col": -1
},
"variant": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.play",
"cardId": "c163",
"placement": {
"row": 1,
"col": -1
},
"variant": 0
},
{
"type": "card.play",
"cardId": "c41",
"placement": {
"row": 1,
"col": 0
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c127",
"placement": {
"row": 0,
"col": 1
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c183",
"placement": {
"row": 0,
"col": 2
},
"variant": 1
},
{
"type": "card.play",
"cardId": "c150",
"placement": {
"row": 1,
"col": 2
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.play",
"cardId": "c136",
"placement": {
"row": 1,
"col": 1
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c77",
"toSlot": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c171",
"toSlot": 0
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "boxcar",
"loaded": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "boxcar",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c153",
"toSlot": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c9"
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": -2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 1,
"col": 0
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray3",
"count": 1
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": -2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 0
},
"reverse": true
},
{
"type": "switch.end"
},
{
"type": "laborer.beginUnload",
"at": {
"row": 1,
"col": 0
},
"carIndex": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c162",
"toSlot": 2
},
{
"type": "draw.end"
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": 0,
"col": 0
},
"index": 0
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 1
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": 0,
"col": 0
},
"index": 0
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 1,
"col": 0
},
"carType": "boxcar"
},
{
"type": "laborer.startLoad",
"at": {
"row": 1,
"col": 0
}
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 0,
"col": 0
},
"carType": "coach"
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "boxcar",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "boxcar",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "boxcar",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "caboose",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": 1,
"col": 0
},
"index": 0
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 0,
"col": 0
},
"carType": "coach"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 1
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c50",
"toSlot": 0
},
{
"type": "draw.end"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": -2
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray2",
"count": 1
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 3
},
"reverse": false,
"via": {
"row": 0,
"col": 0
}
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 1,
"col": 0
},
"reverse": true
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 3
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 1
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray2",
"count": 1
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 3
},
"reverse": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "tank",
"loaded": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "tank",
"loaded": false
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 1,
"col": 0
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray2",
"count": 3
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 3
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": -2
},
"reverse": true,
"via": {
"row": 0,
"col": 1
}
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 0
},
"reverse": false
},
{
"type": "switch.end"
}
],
"rules": {
"startingHand": "sixRandom",
"revenue": {
"passengerPerCoach": 1,
"freightPerLoad": 1,
"trainPerTransit": 0
}
}
}
File diff suppressed because it is too large Load Diff
-656
View File
@@ -1,656 +0,0 @@
{
"seed": 493290760,
"history": [
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c138",
"placement": {
"row": 0,
"col": -1
},
"variant": 0
},
{
"type": "card.play",
"cardId": "c144",
"placement": {
"row": 0,
"col": 1
},
"variant": 0
},
{
"type": "card.play",
"cardId": "c11"
},
{
"type": "card.play",
"cardId": "c24"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.play",
"cardId": "c31"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 0,
"col": 0
},
"carType": "coach"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 0,
"col": 0
},
"carType": "coach"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.play",
"cardId": "c182",
"placement": {
"row": 0,
"col": 1
},
"variant": 1
},
{
"type": "card.play",
"cardId": "c159",
"placement": {
"row": 1,
"col": 1
},
"variant": 0
},
{
"type": "card.play",
"cardId": "c49",
"placement": {
"row": 1,
"col": 0
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c5"
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "tank",
"loaded": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "tank",
"loaded": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "caboose",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": false
},
{
"type": "mainline.clearance",
"allow": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 1,
"col": 0
},
"reverse": true
},
{
"type": "switch.end"
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "porter.board",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": -1
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray3",
"count": 1
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 1,
"col": 0
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray3",
"count": 1
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": -1
},
"reverse": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 0
},
"reverse": false
},
{
"type": "switch.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 1,
"col": 0
},
"carType": "tank"
},
{
"type": "laborer.startLoad",
"at": {
"row": 1,
"col": 0
}
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c194",
"placement": {
"row": 0,
"col": -2
},
"variant": 1
},
{
"type": "draw.end"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 0,
"col": 0
},
"carType": "coach"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 1
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.end"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.play",
"cardId": "c154",
"placement": {
"row": 1,
"col": -2
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c102",
"node": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "boxcar",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "tank",
"loaded": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "caboose",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 1,
"col": -2
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray3",
"count": 1
},
{
"type": "switch.dropCars",
"trayId": "tray3",
"count": 2
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 1,
"col": 0
},
"reverse": true
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 1,
"col": -2
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray3",
"count": 3
},
{
"type": "switch.dropCars",
"trayId": "tray3",
"count": 1
},
{
"type": "switch.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.end"
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "porter.board",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": -1
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 1,
"col": -2
},
"reverse": true
}
]
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.8.3", "version": "0.8.6",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
+43 -18
View File
@@ -109,6 +109,39 @@ export type Rendered = { html: string; headings: Heading[] };
* `linkHref` rewrites link targets — the build uses it to send `rules.md` to `rules.html` — and * `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. * 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 { export function renderMarkdown(src: string, linkHref: (href: string) => string = (h) => h): Rendered {
const lines = src.replace(/\r\n/g, '\n').split('\n'); const lines = src.replace(/\r\n/g, '\n').split('\n');
const out: string[] = []; const out: string[] = [];
@@ -196,13 +229,14 @@ export function renderMarkdown(src: string, linkHref: (href: string) => string =
continue; continue;
} }
// Lists. A bullet or a number opens one; continuation lines are indented under their item. // 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); const bullet = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
if (bullet) { if (bullet) {
const ordered = /\d/.test(bullet[2]!);
const baseIndent = bullet[1]!.length; const baseIndent = bullet[1]!.length;
const items: string[] = []; const block: string[] = [];
let current: string[] | null = null;
while (i < lines.length) { while (i < lines.length) {
const l = lines[i]!; const l = lines[i]!;
if (l.trim() === '') { if (l.trim() === '') {
@@ -210,28 +244,19 @@ export function renderMarkdown(src: string, linkHref: (href: string) => string =
const next = lines[i + 1] ?? ''; const next = lines[i + 1] ?? '';
const continues = /^(\s*)([-*+]|\d+[.)])\s+/.test(next) || /^\s{2,}\S/.test(next); const continues = /^(\s*)([-*+]|\d+[.)])\s+/.test(next) || /^\s{2,}\S/.test(next);
if (!continues) break; if (!continues) break;
block.push('');
i++; i++;
continue; continue;
} }
const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(l); const m = /^(\s*)([-*+]|\d+[.)])\s+/.exec(l);
if (m && m[1]!.length <= baseIndent) { if ((m && m[1]!.length <= baseIndent) || (m && m[1]!.length > baseIndent) || /^\s{2,}\S/.test(l)) {
if (current) items.push(current.join(' ')); block.push(l);
current = [m[3]!];
i++;
continue;
}
if (m || /^\s{2,}\S/.test(l)) {
// A nested item or a wrapped continuation. Nesting is rendered by recursion on the block.
if (!current) break;
current.push(l.trim());
i++; i++;
continue; continue;
} }
break; break;
} }
if (current) items.push(current.join(' ')); ln(listHtml(block, text));
const tag = ordered ? 'ol' : 'ul';
ln(`<${tag}>${items.map((it) => `<li>${text(it)}</li>`).join('')}</${tag}>`);
continue; continue;
} }
+5 -6
View File
@@ -730,7 +730,6 @@ function entryConflict(
/** Puts a train onto a Mainline card with its crossing time already computed. */ /** Puts a train onto a Mainline card with its crossing time already computed. */
function enterMainline( function enterMainline(
s: GameState,
node: Extract<DivisionNode, { kind: 'mainline' }>, node: Extract<DivisionNode, { kind: 'mainline' }>,
id: TrayId, id: TrayId,
tray: CrewTray, tray: CrewTray,
@@ -862,7 +861,7 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
if (conflict === 'held') return 'held'; if (conflict === 'held') return 'held';
if (conflict === 'collided') return 'moved'; if (conflict === 'collided') return 'moved';
enterMainline(s, node, id, tray, target); enterMainline(node, id, tray, target);
const dp = s.division.nodes[dpIndex]; const dp = s.division.nodes[dpIndex];
if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id); if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id);
events.push({ events.push({
@@ -926,7 +925,7 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
// The wreck's A/D track is released by `collide` itself, which is why it has to be. // The wreck's A/D track is released by `collide` itself, which is why it has to be.
if (conflict === 'collided') return 'moved'; if (conflict === 'collided') return 'moved';
enterMainline(s, node, id, tray, target); enterMainline(node, id, tray, target);
area.adOccupancy = area.adOccupancy.filter((t) => t !== id); area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
events.push({ events.push({
type: 'trainHighballed', type: 'trainHighballed',
@@ -998,7 +997,7 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
if (conflict === 'collided') return 'moved'; if (conflict === 'collided') return 'moved';
node.holding = node.holding.filter((t) => t !== id); node.holding = node.holding.filter((t) => t !== id);
enterMainline(s, node, id, tray, index, true); enterMainline(node, id, tray, index, true);
events.push({ events.push({
type: 'trainHighballed', type: 'trainHighballed',
trainNumber: tray.trainNumber ?? 0, trainNumber: tray.trainNumber ?? 0,
@@ -1400,7 +1399,7 @@ function redFlagStop(
return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags'; return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags';
}); });
if (!holdsFlag) return 'proceed'; if (!holdsFlag) return 'proceed';
if (!arrivalWouldCollide(s, id, tray, dest.seat)) return 'proceed'; if (!arrivalWouldCollide(s, dest.seat)) return 'proceed';
s.clock.pendingDecision = { kind: 'redFlag', train: id, seat: dest.seat, from }; s.clock.pendingDecision = { kind: 'redFlag', train: id, seat: dest.seat, from };
return 'ask'; return 'ask';
@@ -1413,7 +1412,7 @@ function redFlagStop(
* if these two ever diverge, the prompt offers a flag against a collision that will not happen, or * if these two ever diverge, the prompt offers a flag against a collision that will not happen, or
* stays silent before one that will. * stays silent before one that will.
*/ */
function arrivalWouldCollide(s: GameState, id: TrayId, tray: CrewTray, seat: SeatIndex): boolean { function arrivalWouldCollide(s: GameState, seat: SeatIndex): boolean {
const area = areaAtSeat(s, seat); const area = areaAtSeat(s, seat);
const hasInterlocking = [...area.grid.values()].some((c) => c.enhancements.includes('interlocking')); const hasInterlocking = [...area.grid.values()].some((c) => c.enhancements.includes('interlocking'));
const full = area.adOccupancy.length >= officeProfile(area.tier).adTracks; const full = area.adOccupancy.length >= officeProfile(area.tier).adTracks;
+6 -10
View File
@@ -16,7 +16,6 @@
import { import {
FREIGHT_PROFILES, FREIGHT_PROFILES,
LABORER_ACTIONS_PER_LOAD,
MAX_CONSIST, MAX_CONSIST,
REALIGNMENTS, REALIGNMENTS,
consistSize, consistSize,
@@ -65,7 +64,6 @@ import {
pooled, pooled,
railFacingOf, railFacingOf,
seatOf, seatOf,
spaceOn,
standingSides, standingSides,
trackOrder, trackOrder,
turnOf, turnOf,
@@ -647,7 +645,7 @@ function passengerWork(
* crew is unaffected by all of them. * crew is unaffected by all of them.
*/ */
/** Which district a tray is standing in; 0 when it is out on the Division. */ /** Which district a tray is standing in; 0 when it is out on the Division. */
function trayySeat(tray: CrewTray): SeatIndex { function traySeat(tray: CrewTray): SeatIndex {
return tray.position.at === 'grid' ? tray.position.seat : 0; return tray.position.at === 'grid' ? tray.position.seat : 0;
} }
@@ -1578,7 +1576,7 @@ function checkPlay(
return target && target.kind === 'mainline' ? null : 'NOT_CONNECTED'; return target && target.kind === 'mainline' ? null : 'NOT_CONNECTED';
} }
if (!placement) return 'NO_PLACEMENT'; if (!placement) return 'NO_PLACEMENT';
return checkEnhancementPlacement(s, area, card.kind.key, placement); return checkEnhancementPlacement(area, card.kind.key, placement);
} }
case 'mainlineModifier': case 'mainlineModifier':
@@ -1587,7 +1585,7 @@ function checkPlay(
// copy uses the enhancement's grid placement rather than going onto a Mainline card. // copy uses the enhancement's grid placement rather than going onto a Mainline card.
if (card.kind.key === 'facingPointLocksMainline') { if (card.kind.key === 'facingPointLocksMainline') {
if (!placement) return 'NO_PLACEMENT'; if (!placement) return 'NO_PLACEMENT';
return checkEnhancementPlacement(s, area, 'facingPointLocks', placement); return checkEnhancementPlacement(area, 'facingPointLocks', placement);
} }
// The rest are laid on a Mainline card, which is not a grid coordinate — see // The rest are laid on a Mainline card, which is not a grid coordinate — see
// `mainline.modify`. // `mainline.modify`.
@@ -1830,7 +1828,6 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
const from = trayCoord(s, i.trayId)!; const from = trayCoord(s, i.trayId)!;
const dests = destinationsFor(s, player, i.trayId, from, i.reverse); const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
const dest = selectDestination(dests, i.to, i.via)!; const dest = selectDestination(dests, i.to, i.via)!;
const tray = s.trays.get(i.trayId)!;
// The port the crew pulls out THROUGH — the same one `destinationsFor` explored from, so the // The port the crew pulls out THROUGH — the same one `destinationsFor` explored from, so the
// cut it recouples on the way out is the cut the walk counted. // cut it recouples on the way out is the cut the walk counted.
const facingNow = facingPort(s, i.trayId); const facingNow = facingPort(s, i.trayId);
@@ -2454,7 +2451,7 @@ export function reduce(s: GameState, e: GameEvent): void {
* front of `stock` is a drop being undone, so it is refunded on the square it was left on * front of `stock` is a drop being undone, so it is refunded on the square it was left on
* instead of being charged again at the far end. * instead of being charged again at the far end.
*/ */
const owner = playerAtSeat(s, trayySeat(tray)); const owner = playerAtSeat(s, traySeat(tray));
const taken = e.recoupled ? e.stock.slice(e.recoupled.stock.length) : e.stock; const taken = e.recoupled ? e.stock.slice(e.recoupled.stock.length) : e.stock;
spendFreightBudget(s, owner, tray, e.at, taken); spendFreightBudget(s, owner, tray, e.at, taken);
if (e.recoupled) refundFreightBudget(s, owner, tray, e.recoupled.at, e.recoupled.stock); if (e.recoupled) refundFreightBudget(s, owner, tray, e.recoupled.at, e.recoupled.stock);
@@ -2477,7 +2474,7 @@ export function reduce(s: GameState, e: GameEvent): void {
*/ */
tray.engineAt = e.engineAt; tray.engineAt = e.engineAt;
// "Spends one move in the yard" — the sort costs a Move. // "Spends one move in the yard" — the sort costs a Move.
const sorter = turnOf(s, playerAtSeat(s, trayySeat(tray))); const sorter = turnOf(s, playerAtSeat(s, traySeat(tray)));
sorter.movesRemaining = Math.max(0, sorter.movesRemaining - 1); sorter.movesRemaining = Math.max(0, sorter.movesRemaining - 1);
break; break;
} }
@@ -2524,7 +2521,7 @@ export function reduce(s: GameState, e: GameEvent): void {
// cars set out to the east leave it where it was. // cars set out to the east leave it where it was.
card.standingWest = onWestSide ? k + cut.length : k; card.standingWest = onWestSide ? k + cut.length : k;
} }
spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), tray, e.at, e.stock); spendFreightBudget(s, playerAtSeat(s, traySeat(tray)), tray, e.at, e.stock);
break; break;
} }
@@ -3184,7 +3181,6 @@ export function watertowersRemovable(area: OfficeArea): GridCoord[] {
* - ABS Signals → a Mainline card, not the Office Area * - ABS Signals → a Mainline card, not the Office Area
*/ */
export function checkEnhancementPlacement( export function checkEnhancementPlacement(
s: GameState,
area: OfficeArea, area: OfficeArea,
key: string, key: string,
placement: GridCoord, placement: GridCoord,
+1 -1
View File
@@ -4,7 +4,7 @@
* An intent is a PROPOSAL. It may be rejected. Contrast with an event (events.ts), which is a fact. * 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 { CarType, Direction, OfficeTier } from './content.ts';
import type { CardId, GridCoord, PlayerIndex, SeatIndex, TrayId } from './state.ts'; import type { CardId, GridCoord, PlayerIndex, SeatIndex, TrayId } from './state.ts';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1 -4
View File
@@ -12,7 +12,7 @@
* If you find yourself writing a rule here, it belongs in apply.ts. * If you find yourself writing a rule here, it belongs in apply.ts.
*/ */
import type { CarType, Hand, TrackGeometry } from './content.ts'; import type { CarType } from './content.ts';
import { enhancementRule, mainlineProfile } from './content.ts'; import { enhancementRule, mainlineProfile } from './content.ts';
import { check, areaOf, destinationsFor, withRouteCache } from './apply.ts'; import { check, areaOf, destinationsFor, withRouteCache } from './apply.ts';
import type { Intent } from './intents.ts'; import type { Intent } from './intents.ts';
@@ -191,9 +191,6 @@ export function legalSwitchingActions(s: GameState, player: PlayerIndex): Intent
return withRouteCache(s, () => switchCandidates(s, player).filter((i) => check(s, player, i) === null)); return withRouteCache(s, () => switchCandidates(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;
}
/** /**
* Candidate generation. Over-generates freely — `check` is the authority, so a candidate that * Candidate generation. Over-generates freely — `check` is the authority, so a candidate that
-6
View File
@@ -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);
}
-1
View File
@@ -19,7 +19,6 @@ import {
OPENING_DEALS, OPENING_DEALS,
MAINLINE_DECK, MAINLINE_DECK,
houseRules, houseRules,
mainlineProfile,
TRACK_CARDS, TRACK_CARDS,
ROLLING_STOCK_SUPPLY, ROLLING_STOCK_SUPPLY,
STAGES_PER_DAY, STAGES_PER_DAY,
+134 -14
View File
@@ -18,7 +18,7 @@
*/ */
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import type { IncomingMessage, ServerResponse } from 'node:http'; import type { IncomingMessage, Server, ServerResponse } from 'node:http';
import { createReadStream } from 'node:fs'; import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises'; import { stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path'; import { extname, join, normalize } from 'node:path';
@@ -97,14 +97,57 @@ const MIME: Record<string, string> = {
const HEARTBEAT_MS = 20_000; const HEARTBEAT_MS = 20_000;
async function readJson(req: IncomingMessage): Promise<unknown> { /**
* 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[] = []; const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as 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'); const text = Buffer.concat(chunks).toString('utf8');
return text.trim() === '' ? {} : JSON.parse(text); 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 { 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); const text = JSON.stringify(body);
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text) }); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text) });
res.end(text); res.end(text);
@@ -149,7 +192,11 @@ async function serveStatic(
'Content-Length': info.size, 'Content-Length': info.size,
'Cache-Control': buildTagged ? 'public, max-age=31536000, immutable' : 'no-cache', 'Cache-Control': buildTagged ? 'public, max-age=31536000, immutable' : 'no-cache',
}); });
createReadStream(full).pipe(res); // 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 { } catch {
res.writeHead(404, { 'Content-Type': 'text/plain' }); res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found'); res.end('not found');
@@ -175,7 +222,8 @@ type LobbyPreview = {
seated: { seat: number; who: string | null; bot: boolean }[]; seated: { seat: number; who: string | null; bot: boolean }[];
}; };
export function startServer(opts: ServerOptions): void { /** 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 games = opts.initialGames;
const lobbies = opts.initialLobbies; const lobbies = opts.initialLobbies;
const sessions = opts.initialSessions; const sessions = opts.initialSessions;
@@ -283,8 +331,29 @@ export function startServer(opts: ServerOptions): void {
async function persistSession(ps: PlayerSession): Promise<void> { async function persistSession(ps: PlayerSession): Promise<void> {
sessions.set(ps.token, ps); sessions.set(ps.token, ps);
const all = [...sessions.values()].filter((s) => s.gameId === ps.gameId); await persistSessionsOf(ps.gameId);
await writeSessions(opts.dataDir, ps.gameId, all); }
/** 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) => { const server = createServer((req, res) => {
@@ -571,6 +640,17 @@ export function startServer(opts: ServerOptions): void {
sendJson(res, 403, { error: 'NOT_HOST' }); sendJson(res, 403, { error: 'NOT_HOST' });
return; 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); const result = leaveLobby(lobby, ps.token, seat);
if (result.empty) { if (result.empty) {
// Nobody human is left to start it. Everything about this lobby goes, including the code, // Nobody human is left to start it. Everything about this lobby goes, including the code,
@@ -579,6 +659,7 @@ export function startServer(opts: ServerOptions): void {
gameCodes.delete(lobby.gameCode); gameCodes.delete(lobby.gameCode);
for (const [, watcher] of lobbyConnections.get(lobby.gameId) ?? []) watcher.end(); for (const [, watcher] of lobbyConnections.get(lobby.gameId) ?? []) watcher.end();
lobbyConnections.delete(lobby.gameId); lobbyConnections.delete(lobby.gameId);
for (const [token, s] of [...sessions]) if (s.gameId === lobby.gameId) sessions.delete(token);
await deleteLobby(opts.dataDir, lobby.gameId); await deleteLobby(opts.dataDir, lobby.gameId);
// The row goes with the lobby rather than being marked: a game that never started is not a // 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. // game an administrator has any use for a record of.
@@ -586,6 +667,12 @@ export function startServer(opts: ServerOptions): void {
sendJson(res, 200, { ok: true, closed: true }); sendJson(res, 200, { ok: true, closed: true });
return; 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); await persistLobby(result.lobby);
broadcastLobby(lobby.gameId); broadcastLobby(lobby.gameId);
sendJson(res, 200, { ok: true }); sendJson(res, 200, { ok: true });
@@ -690,7 +777,9 @@ export function startServer(opts: ServerOptions): void {
// `lobby`, because it may have changed (another join, another bot toggle) since connect. // `lobby`, because it may have changed (another join, another bot toggle) since connect.
const current = lobbies.get(lobby.gameId); const current = lobbies.get(lobby.gameId);
if (current && current.hostToken === token) { if (current && current.hostToken === token) {
void persistLobby(reassignHost(current, token)).then(() => broadcastLobby(lobby.gameId)); persistLobby(reassignHost(current, token))
.then(() => broadcastLobby(lobby.gameId))
.catch((err: unknown) => console.error(`reassigning the host of ${lobby.gameId} failed:`, err));
} }
}); });
return; return;
@@ -756,8 +845,15 @@ export function startServer(opts: ServerOptions): void {
* The administrative export at `/api/games/<id>/save` is gated on the admin secret, which a player * 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 * 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 * 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, and nothing else is * for `/api/stream` and `/api/intent` — it proves which game and which chair. The page turns the
* disclosed. The page turns the JSON into a file (`main.ts`'s `downloadSave`). * 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') { if (url.pathname === '/api/save' && req.method === 'GET') {
const ps = sessions.get(url.searchParams.get('token') ?? ''); const ps = sessions.get(url.searchParams.get('token') ?? '');
@@ -766,7 +862,13 @@ export function startServer(opts: ServerOptions): void {
sendJson(res, 404, { error: 'no such game' }); sendJson(res, 404, { error: 'no such game' });
return; return;
} }
sendJson(res, 200, { gameId: ps.gameId, save: session.exportSave() }); 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; return;
} }
@@ -820,11 +922,14 @@ export function startServer(opts: ServerOptions): void {
sendJson(res, 400, { error: 'expected { seq, intent }' }); sendJson(res, 400, { error: 'expected { seq, intent }' });
return; return;
} }
const result = session.intent(ps.player, body.seq, body.intent); const { seq, intent } = body;
await inTurn(ps.gameId, async () => {
const result = session.intent(ps.player, seq, intent);
if (result.accepted) { if (result.accepted) {
// Persisted BEFORE the response goes out — "accepted" should mean "durably on disk" at // Persisted BEFORE the response goes out — "accepted" should mean "durably on disk" at
// this scale, not just "applied in memory" (§12 step 14). // this scale, not just "applied in memory" (§12 step 14).
const dir = gameDir(opts.dataDir, ps.gameId); const dir = gameDir(opts.dataDir, ps.gameId);
try {
await writeGame(dir, session.exportSave(), opts.engineVersion); await writeGame(dir, session.exportSave(), opts.engineVersion);
if (result.timing) await appendTiming(dir, result.timing); if (result.timing) await appendTiming(dir, result.timing);
if (session.exportSave().status === 'finished') { if (session.exportSave().status === 'finished') {
@@ -834,17 +939,32 @@ export function startServer(opts: ServerOptions): void {
const gameCode = [...gameCodes.entries()].find(([, id]) => id === ps.gameId)?.[0] ?? ''; const gameCode = [...gameCodes.entries()].find(([, id]) => id === ps.gameId)?.[0] ?? '';
await upsertIndexEntry(opts.dataDir, { gameId: ps.gameId, gameCode, status: 'finished' }); 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 }); sendJson(res, 200, result.accepted ? { ok: true } : { ok: false, code: result.code });
if (result.accepted) broadcastGame(ps.gameId, result.pushes); if (result.accepted) broadcastGame(ps.gameId, result.pushes);
});
return; return;
} }
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v')); await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
})().catch((err: unknown) => { })().catch((err: unknown) => {
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' }); 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); server.listen(opts.port, opts.bindAddress);
return server;
} }
+15 -1
View File
@@ -61,8 +61,22 @@ for (const entry of index) {
} }
const loaded = await loadGame(gameDir(dataDir, entry.gameId)); 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) { if (loaded.found) {
const resumed = tryResumeSession(loaded.saved); 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) { if (resumed.ok) {
initialGames.set(entry.gameId, resumed.session); initialGames.set(entry.gameId, resumed.session);
console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`); console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`);
+2 -1
View File
@@ -91,8 +91,9 @@ export function playerCountAllowed(mode: GameConfig['mode'], count: number): boo
return mode === 'solitaire' ? count === 1 : count >= 2 && count <= 4; 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 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 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 * The host says how many are playing, so the seats array is built at full length with the host in
+49 -3
View File
@@ -24,10 +24,33 @@ const INDEX_FILE = 'index.json';
type PersistedGame = SavedGame & { engineVersion: string }; 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> { async function atomicWrite(path: string, text: string): Promise<void> {
const tmp = `${path}.tmp`; await serial(path, async () => {
const tmp = `${path}.${process.pid}.${++writeSerial}.tmp`;
await writeFile(tmp, text); await writeFile(tmp, text);
await rename(tmp, path); await rename(tmp, path);
});
} }
export async function writeGame(dataDir: string, saved: SavedGame, engineVersion: string): Promise<void> { export async function writeGame(dataDir: string, saved: SavedGame, engineVersion: string): Promise<void> {
@@ -37,7 +60,8 @@ export async function writeGame(dataDir: string, saved: SavedGame, engineVersion
} }
export type LoadResult = export type LoadResult =
| { found: false } /** `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. */ /** The version that wrote the file, for diagnostics — it is no longer what decides. */
| { found: true; saved: SavedGame; storedVersion: string }; | { found: true; saved: SavedGame; storedVersion: string };
@@ -61,7 +85,21 @@ export async function loadGame(dataDir: string): Promise<LoadResult> {
} catch { } catch {
return { found: false }; return { found: false };
} }
const payload = JSON.parse(text) as PersistedGame; /**
* 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; const { engineVersion, ...saved } = payload;
return { found: true, saved, storedVersion: engineVersion }; return { found: true, saved, storedVersion: engineVersion };
} }
@@ -71,6 +109,9 @@ export async function loadGame(dataDir: string): Promise<LoadResult> {
export async function appendTiming(dataDir: string, timing: TurnTiming): Promise<void> { export async function appendTiming(dataDir: string, timing: TurnTiming): Promise<void> {
await mkdir(dataDir, { recursive: true }); await mkdir(dataDir, { recursive: true });
const path = join(dataDir, TIMINGS_FILE); 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[]; let existing: TurnTiming[];
try { try {
existing = JSON.parse(await readFile(path, 'utf8')) as TurnTiming[]; existing = JSON.parse(await readFile(path, 'utf8')) as TurnTiming[];
@@ -79,6 +120,7 @@ export async function appendTiming(dataDir: string, timing: TurnTiming): Promise
} }
existing.push(timing); existing.push(timing);
await atomicWrite(path, JSON.stringify(existing, null, 1)); await atomicWrite(path, JSON.stringify(existing, null, 1));
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -113,11 +155,13 @@ async function writeIndex(dataDir: string, entries: GameIndexEntry[]): Promise<v
* silently drop every other game's row the moment two writes happened close together. * silently drop every other game's row the moment two writes happened close together.
*/ */
export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry): Promise<void> { export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry): Promise<void> {
await serial(`rmw:${join(dataDir, INDEX_FILE)}`, async () => {
const entries = await readIndex(dataDir); const entries = await readIndex(dataDir);
const i = entries.findIndex((e) => e.gameId === entry.gameId); const i = entries.findIndex((e) => e.gameId === entry.gameId);
if (i >= 0) entries[i] = entry; if (i >= 0) entries[i] = entry;
else entries.push(entry); else entries.push(entry);
await writeIndex(dataDir, entries); await writeIndex(dataDir, entries);
});
} }
/** /**
@@ -126,11 +170,13 @@ export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry):
* it on the next boot or leave `index.json` pointing at nothing. * it on the next boot or leave `index.json` pointing at nothing.
*/ */
export async function removeIndexEntry(dataDir: string, gameId: string): Promise<void> { export async function removeIndexEntry(dataDir: string, gameId: string): Promise<void> {
await serial(`rmw:${join(dataDir, INDEX_FILE)}`, async () => {
const entries = await readIndex(dataDir); const entries = await readIndex(dataDir);
await writeIndex( await writeIndex(
dataDir, dataDir,
entries.filter((e) => e.gameId !== gameId), entries.filter((e) => e.gameId !== gameId),
); );
});
} }
/** Deletes a game's whole directory — its save, its turn timings, its sessions, its lobby file. */ /** Deletes a game's whole directory — its save, its turn timings, its sessions, its lobby file. */
+12 -2
View File
@@ -72,6 +72,16 @@ export type Push = {
scheduled?: number | null; scheduled?: number | null;
announcement?: string | null; announcement?: string | null;
justDrawn?: 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. * ORDERED PRESENTATION STEPS — v0.8.0, TODO #13.
* *
@@ -316,8 +326,7 @@ function buildSession(
* `lobby-and-sessions.md` §5's turn clock exists to learn how long HUMANS take, and a bot decides * `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 * 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. * 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. * §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 will not disagree with the human. Humans get to vote first. If all humans vote yes, then
@@ -415,6 +424,7 @@ function buildSession(
// connect IS the history, which is what lets the Frame stop carrying a second copy. // connect IS the history, which is what lets the Frame stop carrying a second copy.
sentLines.delete(seat); sentLines.delete(seat);
const push = pushFor(seat, null); 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 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 * the shared delta chain has reached, so the next step merges onto it; before any step has
-8
View File
@@ -113,14 +113,6 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
const CHIP_Y = RAIL_Y - 10; const CHIP_Y = RAIL_Y - 10;
const BELOW_Y = RAIL_Y + 13; const BELOW_Y = RAIL_Y + 13;
const GAP = 6; const GAP = 6;
/**
* ONE FIXED SLOT PER A/D TRACK, so the Office Running Track cell is drawn wide enough to hold
* them without spilling onto its neighbours (docs/plans/switching-paths.md — "The Roster Pass").
* Sized by CAPACITY, not by how many are occupied right now: a cell drawn for the trains it HAS
* holds still as they come and go, where sizing by occupancy moved the East Division Point (and
* everything past it) sideways every time an A/D track filled or cleared.
*/
const CHIP_W = 54;
/** /**
* Room for the buffer stops. THE LABELS NO LONGER LIVE OUT HERE. * Room for the buffer stops. THE LABELS NO LONGER LIVE OUT HERE.
* *
+1 -56
View File
@@ -91,19 +91,6 @@ function because(reason: string, intent: Intent): Intent {
return 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. * ABLATIONS, for re-measuring the heuristics that are now the bot's default play.
* *
@@ -356,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 * §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 * 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 { function trainWouldOverfillTheOffice(s: GameState, player: PlayerIndex, tweaks: BotTweaks): boolean {
if (tweaks.noTrainCap) return false; if (tweaks.noTrainCap) return false;
return committedTrains(s) >= officeProfile(areaOf(s, player).tier).adTracks; 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( function chooseLocalOption(
s: GameState, s: GameState,
@@ -1427,8 +1387,6 @@ function followThrough(
(i): i is Extract<Intent, { type: 'draw.fromDepartment' }> => (i): i is Extract<Intent, { type: 'draw.fromDepartment' }> =>
i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot, tweaks), 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 // 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 // and a train card are face up together 1.6 decisions a game — but ranking them is what the
// ranking function is for, and a coin flip on the card that decides whether the district // ranking function is for, and a coin flip on the card that decides whether the district
@@ -1980,19 +1938,6 @@ function sidingsWorthCollecting(
return out; 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) { function trayOf(s: GameState, player: PlayerIndex) {
for (const tray of s.trays.values()) { for (const tray of s.trays.values()) {
+1 -1
View File
@@ -251,7 +251,7 @@ if (isMain) {
const tweaks = parseTweaks(args); const tweaks = parseTweaks(args);
if (Object.keys(tweaks).length === 0) { 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; process.exitCode = 1;
} else { } else {
console.log(formatPaired(compare(tweaks, games, length))); console.log(formatPaired(compare(tweaks, games, length)));
+3 -6
View File
@@ -19,25 +19,22 @@
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import { advance } from '../engine/advance.ts'; 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 type { GameLength } from '../engine/content.ts';
import { import {
DEFAULT_MAX_COLLISIONS_PER_DAY, DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL, DEFAULT_MAX_COLLISIONS_TOTAL,
MAINLINE_PROFILES,
collectiveRevenueFloor, collectiveRevenueFloor,
lengthProfile, lengthProfile,
officeProfile,
} from '../engine/content.ts'; } from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts'; import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts'; import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.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 { actingPlayer } from '../engine/state.ts';
import { reasonSentence } from '../web/panels.ts'; import { reasonSentence } from '../web/panels.ts';
import { developerBot, lastChoiceReason } from './bot.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 // 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. // 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'; export type { CellView, DivisionView, FacilityView, Frame, Decision, TrainChip } from './view.ts';
+1 -1
View File
@@ -18,7 +18,7 @@
* *
* Run with: * Run with:
* node src/sim/save-replay.ts 400 --top 3 * 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 { readdirSync, unlinkSync, writeFileSync } from 'node:fs'; import { readdirSync, unlinkSync, writeFileSync } from 'node:fs';
+2 -2
View File
@@ -889,14 +889,14 @@ export function describeDecision(
groups.set(o.type, list); groups.set(o.type, list);
} }
const rejected = [...groups.entries()] const rejected = [...groups.entries()]
.map(([kind, list]) => ({ kind, count: list.length, detail: sampleDetail(s, kind, list) })) .map(([kind, list]) => ({ kind, count: list.length, detail: sampleDetail(s, list) }))
.sort((a, b) => b.count - a.count); .sort((a, b) => b.count - a.count);
return { actor, chose: describeIntent(s, chosen), why, rejected, totalOptions: options.length }; return { actor, chose: describeIntent(s, chosen), why, rejected, totalOptions: options.length };
} }
/** A short, concrete example of what a group of rejected options would have done. */ /** A short, concrete example of what a group of rejected options would have done. */
function sampleDetail(s: GameState, kind: string, list: Intent[]): string { function sampleDetail(s: GameState, list: Intent[]): string {
// Deduplicate by DESCRIPTION. Orientation variants and repeated copies of a card describe // Deduplicate by DESCRIPTION. Orientation variants and repeated copies of a card describe
// identically, so the raw list reads "play Overpass at (0,0)" three times over and hides the // identically, so the raw list reads "play Overpass at (0,0)" three times over and hides the
// actual range of choices — the opposite of what this panel is for. // actual range of choices — the opposite of what this panel is for.
+27 -12
View File
@@ -37,7 +37,7 @@ import { collectStep, newCollector } from '../sim/display-step.ts';
import type { DisplayCollector } from '../sim/display-step.ts'; import type { DisplayCollector } from '../sim/display-step.ts';
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv, // Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
// which would pull node:fs into a browser bundle. // which would pull node:fs into a browser bundle.
import { cardDescription, cardName, currentActorOfState, describeIntent, geometryLabel, simpleCardName, snapshot, trainName, variantLabel } from '../sim/view.ts'; import { cardDescription, cardName, currentActorOfState, describeIntent, simpleCardName, snapshot, trainName, variantLabel } from '../sim/view.ts';
import { import {
DEFAULT_DAYS, DEFAULT_DAYS,
DEFAULT_HOUSE_RULES, DEFAULT_HOUSE_RULES,
@@ -51,10 +51,10 @@ import {
mainlineProfile, mainlineProfile,
trainProfile, trainProfile,
} from '../engine/content.ts'; } from '../engine/content.ts';
import type { Hand, HouseRuleOverrides, TrackGeometry } from '../engine/content.ts'; import type { CarType, Hand, HouseRuleOverrides, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts'; import type { Port } from '../engine/track.ts';
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts'; import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
import { areaOf, destinationsFor, selectDestination, trainNeedingCars } from '../engine/apply.ts'; import { acceptsCar, areaOf, destinationsFor, selectDestination, trainNeedingCars } from '../engine/apply.ts';
import type { Frame } from '../sim/view.ts'; import type { Frame } from '../sim/view.ts';
/** /**
@@ -1007,16 +1007,31 @@ function consistNeeds(game: Game, trayId: string): string | null {
const have = (k: 'coach' | 'caboose' | 'freight'): number => const have = (k: 'coach' | 'caboose' | 'freight'): number =>
tray.consist.filter((c) => cat(c.type) === k).length; tray.consist.filter((c) => cat(c.type) === k).length;
/**
* ASKED OF `acceptsCar`, NOT RECOUNTED (v0.8.4). Counting by category alone promised cars the
* engine would then refuse: with the caboose already coupled, nothing may go on behind it
* (§A.3), so "Still needs 2 boxcar" was printed while no yard chip lit and the only offer was
* to send the train out as it stands. One sample car per category settles it the same way
* `advance.ts`'s make-up report does.
*/
const sample: Record<'coach' | 'caboose' | 'freight', CarType> = {
coach: 'coach',
caboose: 'caboose',
freight: p.consist.freightTypes?.[0] ?? 'boxcar',
};
const parts: string[] = []; const parts: string[] = [];
const freight = p.consist.freight - have('freight'); let blocked = false;
const coach = p.consist.coach - have('coach'); const want = (k: 'coach' | 'caboose' | 'freight', label: (n: number) => string): void => {
const caboose = p.consist.caboose - have('caboose'); const n = (k === 'coach' ? p.consist.coach : k === 'caboose' ? p.consist.caboose : p.consist.freight) - have(k);
if (freight > 0) { if (n <= 0) return;
parts.push(`${freight} ${p.consist.freightTypes?.join('/') ?? 'freight'}${p.consist.emptiesOnly ? ' (empties only)' : ''}`); if (acceptsCar(tray, sample[k])) parts.push(label(n));
} else blocked = true;
if (coach > 0) parts.push(`${coach} coach${coach > 1 ? 'es' : ''}`); };
if (caboose > 0) parts.push(`${caboose} caboose`); want('freight', (n) => `${n} ${p.consist.freightTypes?.join('/') ?? 'freight'}${p.consist.emptiesOnly ? ' (empties only)' : ''}`);
return parts.length > 0 ? parts.join(' + ') : null; want('coach', (n) => `${n} coach${n > 1 ? 'es' : ''}`);
want('caboose', (n) => `${n} caboose`);
if (parts.length > 0) return parts.join(' + ');
return blocked ? 'nothing more — the caboose is on, and nothing couples behind it' : null;
} }
/** /**
+2 -2
View File
@@ -421,7 +421,7 @@ export function runLobby(handlers: LobbyHandlers, resume?: { token: string; game
// -- seating -------------------------------------------------------------------------------- // -- seating --------------------------------------------------------------------------------
function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void { function renderSeating(lobby: Lobby, token: string): void {
$('lb-gamecode').textContent = lobby.gameCode; $('lb-gamecode').textContent = lobby.gameCode;
const isHost = lobby.hostToken === token; const isHost = lobby.hostToken === token;
@@ -566,7 +566,7 @@ export function runLobby(handlers: LobbyHandlers, resume?: { token: string; game
handlers.onReady({ token, gameId, seat: push.you, gameCode: push.lobby.gameCode }); handlers.onReady({ token, gameId, seat: push.you, gameCode: push.lobby.gameCode });
return; return;
} }
renderSeating(push.lobby, push.you, token); renderSeating(push.lobby, token);
}; };
/** /**
+15 -2
View File
@@ -149,8 +149,8 @@ function saveSettings(patch: Partial<Settings>): void {
} }
/** /**
* The game, behind the Session boundary — `LocalSession` (solitaire, `?seat=` absent from the URL) * The game, behind the Session boundary — `LocalSession` (solitaire) or `RemoteSession` (a seat
* or `RemoteSession` (`?seat=` present, Phase 2). Typed as the common `Session` surface; every * this browser holds in `localStorage`, or a claim link; `?seat=` is no longer a route). Typed as the common `Session` surface; every
* LocalSession-only touch (undo, local saves, dealing a new game) goes through `isLocal` below rather * LocalSession-only touch (undo, local saves, dealing a new game) goes through `isLocal` below rather
* than assuming, since `session` may now be either. * than assuming, since `session` may now be either.
*/ */
@@ -1497,6 +1497,19 @@ function render(): void {
const f = session.view(); const f = session.view();
const menu = session.menu(); const menu = session.menu();
noteFirstFrame(f, rejoiningRemote); noteFirstFrame(f, rejoiningRemote);
/**
* A COMPETITIVE SAVE WAITS FOR THE END (Jesse's ruling, TODO #117). The file carries the seed,
* which is every rival's hand, so `/api/save` refuses it while the game runs — and the button
* says so here rather than silently doing nothing when clicked.
*/
const saveBtn = document.getElementById('savefile') as HTMLButtonElement | null;
if (saveBtn && !isLocal(session)) {
const locked = f.mode === 'competitive' && f.status !== 'finished';
saveBtn.disabled = locked;
saveBtn.title = locked
? 'In a Competitive game the save file carries the whole deal, so it can be downloaded once the game is over'
: 'Download this game as a save file you can replay or share';
}
// Which squares the selected card or track piece may go on. Highlighting them is what turns the // Which squares the selected card or track piece may go on. Highlighting them is what turns the
// coordinate list into a board: you pick the thing, then click where it goes. // coordinate list into a board: you pick the thing, then click where it goes.
+44 -5
View File
@@ -6,8 +6,8 @@
* *
* - `LocalSession` runs the engine in this browser. Solitaire, exactly as it has always worked, * - `LocalSession` runs the engine in this browser. Solitaire, exactly as it has always worked,
* with no server involved at any point. * with no server involved at any point.
* - `RemoteSession` (not built yet — see `docs/architecture/multiplayer.md` Phase 2) will hold no * - `RemoteSession` (`docs/architecture/multiplayer.md` Phase 2, built) holds no authoritative
* authoritative state at all. It cannot: it has neither the deck order nor the other players' * 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. * 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 * So this interface is deliberately the SMALLER of the two — everything a remote client could
@@ -282,6 +282,8 @@ type Push = {
scheduled?: number | null; scheduled?: number | null;
announcement?: string | null; announcement?: string | null;
justDrawn?: 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;
}; };
/** /**
@@ -322,7 +324,19 @@ export function createRemoteSession(
let scheduled: number | null = null; let scheduled: number | null = null;
let announcement: string | null = null; let announcement: string | null = null;
let justDrawnCard: 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; let nextSeq = 1;
/** One submit in flight at a time — see `submit`. */
let inFlight = false;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
const changed = (): void => { const changed = (): void => {
for (const fn of [...listeners]) fn(); for (const fn of [...listeners]) fn();
@@ -355,7 +369,15 @@ export function createRemoteSession(
}); });
}; };
source.onmessage = (ev: MessageEvent<string>) => { source.onmessage = (ev: MessageEvent<string>) => {
const push = JSON.parse(ev.data) as Push; 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 // 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`, // 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. // per `session.ts`'s `Push`) updates the board or the menu.
@@ -402,16 +424,33 @@ export function createRemoteSession(
actor: () => need().actor, actor: () => need().actor,
handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null), handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null),
async submit(intent: Intent): Promise<boolean> { 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++; const seq = nextSeq++;
try {
const res = await fetch(`/api/intent?${qs}`, { const res = await fetch(`/api/intent?${qs}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seq, intent }), body: JSON.stringify({ seq, intent }),
}); });
const result = (await res.json()) as { ok: boolean; code?: string }; 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), // 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. // not from this response — this only reports whether the rules accepted it.
return result.ok; 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) { subscribe(fn: () => void) {
listeners.add(fn); listeners.add(fn);
+1 -1
View File
@@ -242,6 +242,7 @@ export function createStepQueue(
behind: () => pending.filter((s) => dwell(s) > 0).length, behind: () => pending.filter((s) => dwell(s) > 0).length,
showing: () => last, showing: () => last,
lit: () => litPiles, lit: () => litPiles,
pendingLines: () => pending.reduce((n, s) => n + s.lines.length, 0),
/** /**
* STILL SHOWING SOMETHING, not just still holding something back. * STILL SHOWING SOMETHING, not just still holding something back.
* *
@@ -253,7 +254,6 @@ export function createStepQueue(
* `dueAt` is non-null exactly while the step on screen has time left, so the two together mean * `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". * "there is more to come, or what is up has not had its moment yet".
*/ */
pendingLines: () => pending.reduce((n, s) => n + s.lines.length, 0),
flashing: () => flashedCards, flashing: () => flashedCards,
busy: () => pending.length > 0 || dueAt !== null, busy: () => pending.length > 0 || dueAt !== null,
}; };
-1
View File
@@ -12,7 +12,6 @@ import { applyIntent, areaOf, check, isBeingMadeUp } from '../src/engine/apply.t
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, MAX_CONSIST, STAGES_PER_DAY, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts'; import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, MAX_CONSIST, STAGES_PER_DAY, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts'; import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts'; import { createGame } from '../src/engine/setup.ts';
import { developerBot } from '../src/sim/bot.ts';
import type { CrewTray, DivisionNode, GameConfig, GameState } from '../src/engine/state.ts'; import type { CrewTray, DivisionNode, GameConfig, GameState } from '../src/engine/state.ts';
import { coordKey, railFacingOf } from '../src/engine/state.ts'; import { coordKey, railFacingOf } from '../src/engine/state.ts';
+1 -2
View File
@@ -14,7 +14,7 @@ import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts'; import { createGame } from '../src/engine/setup.ts';
import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts'; import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
import { carsOn, coordKey, railFacingOf, spaceOn, turnOf } from '../src/engine/state.ts'; import { carsOn, coordKey, railFacingOf, spaceOn, turnOf } from '../src/engine/state.ts';
import { cardDescription, snapshot } from '../src/sim/view.ts'; import { snapshot } from '../src/sim/view.ts';
const config: GameConfig = { const config: GameConfig = {
mode: 'solitaire', mode: 'solitaire',
@@ -1243,7 +1243,6 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
// REPORTED from playtesting: two Ice Houses in one district. Industries have been barred from // REPORTED from playtesting: two Ice Houses in one district. Industries have been barred from
// doubling up since Q4, but a Modifier is a different card kind and had no such check at all. // doubling up since Q4, but a Modifier is a different card kind and had no such check at all.
const s = game(); const s = game();
const area = areaOf(s, 0);
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const copies = [...s.cards.entries()] const copies = [...s.cards.entries()]
+1 -1
View File
@@ -231,7 +231,7 @@ describe('the game conserves Rolling Stock', () => {
for (let t = 0; t < 50_000; t++) { for (let t = 0; t < 50_000; t++) {
const before = census(s); const before = census(s);
const pumped = pump(s); pump(s);
/** /**
* A COLLISION DESTROYS NO CAR, and this used to assume it destroyed all of them. * A COLLISION DESTROYS NO CAR, and this used to assume it destroyed all of them.
* *
+15
View File
@@ -110,4 +110,19 @@ describe('the documentation renderer', () => {
assert.ok(!/BEGIN CARDS/.test(out), `${name}.md leaked a build marker onto the page`); assert.ok(!/BEGIN CARDS/.test(out), `${name}.md leaked a build marker onto the page`);
} }
}); });
it('nests a more-indented bullet as a list inside its item (v0.8.4)', () => {
/**
* The code said "nesting is rendered by recursion" and appended the nested bullet to the parent
* as text, so the published home-deck page read "…knows. - ABS Signals is the exception…" with
* a literal dash mid-sentence.
*/
const out = html(['- parent', ' continues here.', ' - child one', ' wraps too', ' - child two', '- second'].join('\n'));
assert.equal(
out.trim(),
'<ul><li>parent continues here.<ul><li>child one wraps too</li><li>child two</li></ul></li><li>second</li></ul>',
);
assert.doesNotMatch(html(readFileSync(join(import.meta.dirname, '..', 'docs', 'home-deck.md'), 'utf8')), /\. - <strong>/);
}); });
});
+81
View File
@@ -0,0 +1,81 @@
/**
* The browser's half of the multiplayer transport, driven with a fake `EventSource` and `fetch`.
* `createRemoteSession` is pure otherwise — no DOM — so it runs here as it does in the page.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createRemoteSession } from '../src/web/session.ts';
type Fake = { onmessage: ((ev: { data: string }) => void) | null; emit(data: unknown): void; close(): void };
function fakeTransport(): { source: () => Fake; bodies: () => { seq: number }[]; fail: (on: boolean) => void } {
let last: Fake | null = null;
const bodies: { seq: number }[] = [];
let failing = false;
const g = globalThis as unknown as Record<string, unknown>;
g['EventSource'] = class {
onmessage: ((ev: { data: string }) => void) | null = null;
onerror: (() => void) | null = null;
constructor() {
last = this;
}
emit(data: unknown): void {
this.onmessage?.({ data: JSON.stringify(data) });
}
close(): void {}
};
g['fetch'] = async (_url: string, init?: { body?: string }) => {
if (init?.body) bodies.push(JSON.parse(init.body) as { seq: number });
await new Promise((r) => setTimeout(r, 5));
if (failing) throw new Error('network down');
return { ok: true, status: 200, json: async () => ({ ok: true }) };
};
return { source: () => last!, bodies: () => bodies, fail: (on) => (failing = on) };
}
const connectPush = (lastSeq: number): unknown => ({ frame: null, menu: null, lines: [], lastSeq });
describe('the remote session (v0.8.4)', () => {
it('continues the intent count from where the server says, not from 1', async () => {
const t = fakeTransport();
const s = createRemoteSession('tok', 0);
t.source().emit(connectPush(7));
await s.submit({ type: 'draw.end' });
assert.deepEqual(t.bodies().map((b) => b.seq), [8], 'the first intent after a reload re-used a number the server had already applied');
// A later reconnect never moves the count backwards.
t.source().emit(connectPush(3));
await s.submit({ type: 'draw.end' });
assert.deepEqual(t.bodies().map((b) => b.seq), [8, 9]);
});
it('drops a second submit while the first is still in flight', async () => {
const t = fakeTransport();
const s = createRemoteSession('tok', 0);
t.source().emit(connectPush(0));
const [a, b] = await Promise.all([s.submit({ type: 'draw.end' }), s.submit({ type: 'draw.end' })]);
assert.equal(a, true);
assert.equal(b, false, 'a double-click posted twice');
assert.equal(t.bodies().length, 1, 'two intents went over the wire for one click');
// And the next one, after the round trip, goes through as normal.
assert.equal(await s.submit({ type: 'draw.end' }), true);
assert.equal(t.bodies().length, 2);
});
it('answers false, not an unhandled rejection, when the network fails', async () => {
const t = fakeTransport();
const s = createRemoteSession('tok', 0);
t.source().emit(connectPush(0));
t.fail(true);
assert.equal(await s.submit({ type: 'draw.end' }), false);
t.fail(false);
assert.equal(await s.submit({ type: 'draw.end' }), true, 'the session did not recover after a failed submit');
});
it('drops a push that is not JSON instead of throwing out of the handler', () => {
const t = fakeTransport();
createRemoteSession('tok', 0);
assert.doesNotThrow(() => t.source().onmessage?.({ data: '{not json' }));
});
});
+190
View File
@@ -0,0 +1,190 @@
/**
* The HTTP layer, driven end to end over a real socket. `startServer` binds port 0 on a temp data
* directory; nothing here reads the built site, so `distDir` is a directory with nothing in it.
*
* Added in v0.8.4, when four faults in `http.ts` turned out to be uncovered because no test had ever
* stood the server up: a leaver's token surviving the leave, an unbounded body, a torn save under
* concurrent moves, and a crash on an error after the SSE head was sent.
*/
import { describe, it, after, before } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Server } from 'node:http';
import type { GameConfig } from '../../src/engine/state.ts';
import { startServer } from '../../src/server/http.ts';
const SECRET = 'test-secret';
const config: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
houseRules: { startingOffice: 'whistlePost' },
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
};
let server: Server;
let base = '';
let dataDir = '';
before(async () => {
dataDir = await mkdtemp(join(tmpdir(), 'station-master-http-'));
server = startServer({
port: 0,
bindAddress: '127.0.0.1',
joinSecret: SECRET,
distDir: dataDir,
dataDir,
engineVersion: 'test',
initialGames: new Map(),
initialLobbies: new Map(),
initialSessions: new Map(),
});
await new Promise<void>((resolve) => server.once('listening', resolve));
const addr = server.address();
if (!addr || typeof addr === 'string') throw new Error('no port');
base = `http://127.0.0.1:${addr.port}`;
});
after(async () => {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
// A write queued behind the last move may still be landing; retry rather than race it.
await rm(dataDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
});
const post = async (path: string, body: unknown): Promise<{ status: number; json: Record<string, unknown> }> => {
const res = await fetch(base + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return { status: res.status, json: (await res.json()) as Record<string, unknown> };
};
const get = async (path: string): Promise<number> => (await fetch(base + path)).status;
/** The first SSE message on a stream, then the stream is dropped. */
async function firstPush(path: string): Promise<Record<string, unknown>> {
const res = await fetch(base + path);
assert.equal(res.status, 200, `${path} answered ${res.status}`);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) throw new Error('stream ended before a push');
buffer += decoder.decode(value, { stream: true });
const m = /data: (.*)\n\n/.exec(buffer);
if (m) {
await reader.cancel();
return JSON.parse(m[1]!) as Record<string, unknown>;
}
}
}
type Seat = { token: string; player: number; gameId: string; gameCode: string };
async function table(): Promise<{ host: Seat; guest: Seat }> {
const created = await post('/api/lobby/create', { secret: SECRET, config, displayName: 'Host', players: 2 });
assert.equal(created.status, 200, JSON.stringify(created.json));
const host = created.json as unknown as Seat;
const joined = await post('/api/lobby/join', { secret: SECRET, gameCode: host.gameCode, displayName: 'Guest' });
assert.equal(joined.status, 200, JSON.stringify(joined.json));
return { host, guest: joined.json as unknown as Seat };
}
describe('the HTTP layer (v0.8.4)', () => {
it('revokes the token of a player who leaves, so it cannot play the seat the next arrival takes', async () => {
const { host, guest } = await table();
const left = await post('/api/lobby/leave', { token: guest.token });
assert.equal(left.status, 200);
// The leaver's token is dead at once — for the lobby and for the game that follows.
assert.equal(await get(`/api/lobby/stream?token=${guest.token}`), 404, 'a leaver can still watch the lobby');
const again = await post('/api/lobby/join', { secret: SECRET, gameCode: host.gameCode, displayName: 'Newcomer' });
assert.equal(again.status, 200);
assert.equal(again.json['player'], guest.player, 'the vacated chair was not the one re-offered');
const started = await post('/api/lobby/start', { token: host.token });
assert.equal(started.status, 200, JSON.stringify(started.json));
assert.equal(await get(`/api/session?token=${guest.token}`), 404, 'the leaver still holds a seat in the running game');
assert.equal(await get(`/api/stream?token=${guest.token}`), 404, "the leaver can read the newcomer's stream");
const move = await post(`/api/intent?token=${guest.token}`, { seq: 1, intent: { type: 'localOps.choose', option: 'draw' } });
assert.equal(move.status, 404, 'the leaver can move for the newcomer');
// And the newcomer's own token works.
assert.equal(await get(`/api/session?token=${again.json['token'] as string}`), 200);
// On disk too, so a restart does not hand the seat back.
const onDisk = JSON.parse(await readFile(join(dataDir, 'games', host.gameId, 'sessions.json'), 'utf8')) as { token: string }[];
assert.ok(!onDisk.some((s) => s.token === guest.token), 'the revoked token is still in sessions.json');
});
it('lets the host remove a player, revoking that token the same way', async () => {
const { host, guest } = await table();
const removed = await post('/api/lobby/leave', { token: host.token, seat: guest.player });
assert.equal(removed.status, 200, JSON.stringify(removed.json));
assert.equal(await get(`/api/lobby/stream?token=${guest.token}`), 404);
assert.equal(await get(`/api/lobby/stream?token=${host.token}`), 200, 'the host lost their own seat');
});
it('refuses an oversized body before reading it, and a malformed one with 400', async () => {
const big = await fetch(base + '/api/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 'x'.repeat(200_000) }),
});
assert.equal(big.status, 413);
const bad = await fetch(base + '/api/lobby/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{not json' });
assert.equal(bad.status, 400);
const notObject = await fetch(base + '/api/lobby/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: 'null' });
assert.equal(notObject.status, 400);
});
it('tells a connecting seat where its intent count stands', async () => {
const { host, guest } = await table();
assert.equal((await post('/api/lobby/start', { token: host.token })).status, 200);
const hostPush = await firstPush(`/api/stream?token=${host.token}`);
const actor = hostPush['menu'] !== null ? host : guest;
assert.equal(hostPush['lastSeq'], 0);
const move = await post(`/api/intent?token=${actor.token}`, { seq: 1, intent: { type: 'localOps.choose', option: 'draw' } });
assert.deepEqual(move.json, { ok: true });
const reconnect = await firstPush(`/api/stream?token=${actor.token}`);
assert.equal(reconnect['lastSeq'], 1, 'the reconnect push does not carry the count');
});
it('hands a Competitive seat its save only once the game is over, and a Co-op seat at any time', async () => {
// Jesse's ruling on TODO #117: the seed in a save is every rival's hand, so a Competitive
// download waits for the end; a co-operative table has nothing to hide from itself.
const { host } = await table();
assert.equal((await post('/api/lobby/start', { token: host.token })).status, 200);
const refused = await fetch(base + `/api/save?token=${host.token}`);
assert.equal(refused.status, 403, 'a running Competitive game handed out its seed');
assert.deepEqual(await refused.json(), { error: 'SAVE_AFTER_FINISH' });
const coop = await post('/api/lobby/create', { secret: SECRET, config: { ...config, mode: 'coop' }, displayName: 'Host', players: 2 });
const coopHost = coop.json as unknown as Seat;
assert.equal((await post('/api/lobby/join', { secret: SECRET, gameCode: coopHost.gameCode, displayName: 'Guest' })).status, 200);
assert.equal((await post('/api/lobby/start', { token: coopHost.token })).status, 200);
const allowed = await fetch(base + `/api/save?token=${coopHost.token}`);
assert.equal(allowed.status, 200, 'a Co-op seat could not download its save');
const body = (await allowed.json()) as { save: { seed: number } };
assert.equal(typeof body.save.seed, 'number');
});
it('applies a burst of concurrent moves one at a time and leaves the save readable', async () => {
const { host, guest } = await table();
assert.equal((await post('/api/lobby/start', { token: host.token })).status, 200);
const hostPush = await firstPush(`/api/stream?token=${host.token}`);
const actor = hostPush['menu'] !== null ? host : guest;
// Three moves that are legal only in this order, fired together.
const intents = [
{ type: 'localOps.choose', option: 'draw' },
{ type: 'draw.fromHomeOffice' },
{ type: 'draw.end' },
];
const results = await Promise.all(intents.map((intent, i) => post(`/api/intent?token=${actor.token}`, { seq: i + 1, intent })));
assert.ok(results.every((r) => r.status === 200), 'a concurrent move was answered with an error');
const save = JSON.parse(await readFile(join(dataDir, 'games', host.gameId, 'game.json'), 'utf8')) as { history: unknown[] };
assert.ok(save.history.length >= 1, 'no move reached the save');
assert.equal(save.history.length, results.filter((r) => r.json['ok'] === true).length, 'the save and the answers disagree');
});
});
+1 -1
View File
@@ -242,7 +242,7 @@ describe('playerCountAllowed', () => {
describe('game codes', () => { describe('game codes', () => {
it('skips codes the caller marks taken', () => { it('skips codes the caller marks taken', () => {
let calls = 0; let calls = 0;
const code = freshGameCode((c) => { const code = freshGameCode(() => {
calls++; calls++;
return calls < 3; // taken twice, free on the third return calls < 3; // taken twice, free on the third
}); });
+55 -1
View File
@@ -6,7 +6,8 @@ import { join } from 'node:path';
import type { GameConfig } from '../../src/engine/state.ts'; import type { GameConfig } from '../../src/engine/state.ts';
import type { SavedGame } from '../../src/server/session.ts'; import type { SavedGame } from '../../src/server/session.ts';
import { appendTiming, loadGame, writeGame } from '../../src/server/persistence.ts'; import { appendTiming, loadGame, readIndex, upsertIndexEntry, writeGame } from '../../src/server/persistence.ts';
import { writeFile } from 'node:fs/promises';
const config: GameConfig = { const config: GameConfig = {
mode: 'competitive', mode: 'competitive',
@@ -101,4 +102,57 @@ describe('game persistence (Phase 3)', () => {
const timings = JSON.parse(text) as unknown[]; const timings = JSON.parse(text) as unknown[];
assert.equal(timings.length, 2); assert.equal(timings.length, 2);
})); }));
// -- v0.8.4: concurrent writers ---------------------------------------------------------------
it('two hundred concurrent writes to one save leave it valid and never throw (v0.8.4)', () =>
withTempDir(async (dir) => {
/**
* One fixed `.tmp` per path, and no queue: measured at 200 rounds of two concurrent writes,
* every round lost one to `rename` ENOENT and six left the file as invalid JSON. Boot then
* died on it. Unique temp names and a per-path queue are the fix; this is the measurement.
*/
const writes: Promise<void>[] = [];
for (let i = 0; i < 200; i++) {
const grown: SavedGame = { ...saved, history: Array.from({ length: i + 1 }, () => ({ type: 'draw.end' })) };
writes.push(writeGame(dir, grown, '1.2.3'));
}
await Promise.all(writes);
const result = await loadGame(dir);
assert.equal(result.found, true, 'the save is unreadable after concurrent writes');
if (result.found) assert.equal(result.saved.history.length, 200, 'the last write did not win');
await assert.rejects(() => readFile(join(dir, 'game.json.tmp')));
}));
it('two concurrent index upserts both land (v0.8.4)', () =>
withTempDir(async (dir) => {
await Promise.all([
upsertIndexEntry(dir, { gameId: 'a', gameCode: 'AAA-1', status: 'active' }),
upsertIndexEntry(dir, { gameId: 'b', gameCode: 'BBB-2', status: 'lobby' }),
]);
const rows = await readIndex(dir);
assert.deepEqual(rows.map((r) => r.gameId).sort(), ['a', 'b'], 'a concurrent upsert lost a row');
}));
it('two concurrent timing appends both land (v0.8.4)', () =>
withTempDir(async (dir) => {
await Promise.all([
appendTiming(dir, { player: 0, phase: 'localOps', day: 1, stage: 1, startedAt: 1, endedAt: 2 }),
appendTiming(dir, { player: 1, phase: 'localOps', day: 1, stage: 1, startedAt: 2, endedAt: 3 }),
]);
const timings = JSON.parse(await readFile(join(dir, 'turn-timings.json'), 'utf8')) as unknown[];
assert.equal(timings.length, 2);
}));
it('reports a save that is not JSON instead of throwing (v0.8.4)', () =>
withTempDir(async (dir) => {
await writeFile(join(dir, 'game.json'), '{"seed": 42, "hist');
const result = await loadGame(dir);
assert.equal(result.found, false);
if (!result.found) assert.ok(result.corrupt, 'a torn file was reported as merely missing');
await writeFile(join(dir, 'game.json'), '{"seed": 42}');
const shape = await loadGame(dir);
assert.equal(shape.found, false);
if (!shape.found) assert.match(shape.corrupt ?? '', /history/);
}));
}); });
+21
View File
@@ -611,3 +611,24 @@ describe('narration reaches a seat exactly once, by one path (#97)', () => {
assert.deepEqual(third.lines, opening.lines, 'a reconnect is the full log, every time'); assert.deepEqual(third.lines, opening.lines, 'a reconnect is the full log, every time');
}); });
}); });
describe('the intent sequence across a reconnect (v0.8.4)', () => {
it('tells a connecting seat the last seq it had accepted, so a reloaded page continues the count', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const first = session.connect(0 as PlayerIndex);
assert.equal(first.lastSeq, 0, 'a seat that has moved nothing should be told 0');
const actor = (first.menu !== null ? 0 : 1) as PlayerIndex;
const r = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.ok(r.accepted);
// A reload: the client starts its own count from 1 again unless told otherwise.
const again = session.connect(actor);
assert.equal(again.lastSeq, 1, 'the reconnect push does not say where the count stands');
// The repeat the old client would have sent — silently swallowed as a resend.
const repeat = session.intent(actor, 1, { type: 'draw.fromHomeOffice' });
assert.ok(repeat.accepted && repeat.pushes.size === 0, 'seq 1 should still read as an idempotent resend');
// Continuing from lastSeq + 1 is a real move.
const next = session.intent(actor, 2, { type: 'draw.fromHomeOffice' });
assert.ok(next.accepted && next.pushes.size > 0, 'seq 2 was not applied');
});
});
-1
View File
@@ -9,7 +9,6 @@ import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts'; import { pump } from '../src/engine/advance.ts';
import { createGame } from '../src/engine/setup.ts'; import { createGame } from '../src/engine/setup.ts';
import { applyIntent, check, refillDivisionYardIfEmpty } from '../src/engine/apply.ts'; import { applyIntent, check, refillDivisionYardIfEmpty } from '../src/engine/apply.ts';
import { TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts'; import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts';
import type { Intent } from '../src/engine/intents.ts'; import type { Intent } from '../src/engine/intents.ts';
import { connectionsFor, exitsFrom, hasPort, joins, neighbour, opposite, variantsFor } from '../src/engine/track.ts'; import { connectionsFor, exitsFrom, hasPort, joins, neighbour, opposite, variantsFor } from '../src/engine/track.ts';
-1
View File
@@ -31,7 +31,6 @@ const config: GameConfig = {
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
}; };
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] }); const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
const at = (row: number, col: number): GridCoord => ({ row, col });
const straight = (standing: RollingStock[] = []): TrackCard => ({ const straight = (standing: RollingStock[] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' }, geometry: { kind: 'track', geometry: 'straight' },
+39 -13
View File
@@ -8,7 +8,7 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { turnOf } from '../src/engine/state.ts'; import { turnOf } from '../src/engine/state.ts';
import { areaOf, check } from '../src/engine/apply.ts'; import { acceptsCar as acceptsCarOf, areaOf, check } from '../src/engine/apply.ts';
import type { Game } from '../src/web/game.ts'; import type { Game } from '../src/web/game.ts';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { existsSync, readFileSync, readdirSync } from 'node:fs';
@@ -22,7 +22,7 @@ import { cardDescription, cardName, describeIntent, variantLabel } from '../src/
import { variantsFor } from '../src/engine/track.ts'; import { variantsFor } from '../src/engine/track.ts';
import { BOARD_CSS, divisionSvg, officeSvg } from '../src/sim/board-svg.ts'; import { BOARD_CSS, divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import type { DivisionView } from '../src/sim/view.ts'; import type { DivisionView } from '../src/sim/view.ts';
import { ENHANCEMENT_RULES, STAGES_PER_DAY, mainlineProfile } from '../src/engine/content.ts'; import { ENHANCEMENT_RULES, STAGES_PER_DAY, mainlineProfile, trainProfile } from '../src/engine/content.ts';
import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts'; import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
import { TURNCHART_CSS, turnChartHtml } from '../src/sim/turnchart.ts'; import { TURNCHART_CSS, turnChartHtml } from '../src/sim/turnchart.ts';
import { fieldSelectors } from '../src/web/settings-form.ts'; import { fieldSelectors } from '../src/web/settings-form.ts';
@@ -1794,17 +1794,6 @@ describe('the static build', () => {
}; };
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered'); assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
// Specifically the PLAY verb: a hand usually holds cards that can only be discarded, and
// discarding highlights the Department piles rather than the board.
const clickVerb = (el: Record<string, unknown>, verb: string): boolean => {
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
const target = fn
.call(el, 'button.cardact')
.find((n) => (n['dataset'] as Record<string, string>)['verb'] === verb);
if (!target) return false;
(target['onclick'] as (() => void) | null)?.();
return true;
};
/** /**
* TRY EVERY PLAY BUTTON, NOT JUST THE FIRST — a card offering "play" does not necessarily play * TRY EVERY PLAY BUTTON, NOT JUST THE FIRST — a card offering "play" does not necessarily play
* ONTO THE BOARD. * ONTO THE BOARD.
@@ -2459,6 +2448,43 @@ describe('the static build', () => {
} }
}); });
it('never lists a car in "still needs" that the engine would refuse (v0.8.4)', () => {
/**
* `consistNeeds` counted by category on its own and could promise cars `acceptsCar` refuses —
* nothing couples behind a caboose (§A.3), and a card narrows which freight it takes. It asks
* `acceptsCar` per category now, so this holds every line of the panel to the engine's answer,
* and the reverse: a category the engine still takes is never left off.
*/
let panels = 0;
for (const seed of [430, 99, 270861860]) {
const game = newGame(seed);
for (let i = 0; i < 600 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
if (menu.makeUp) {
const tray = game.state.trays.get(menu.makeUp.trayId)!;
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)!;
const needs = menu.makeUp.needs ?? '';
const categories = [
{ name: 'freight', sample: profile.consist.freightTypes?.[0] ?? 'boxcar', re: /boxcar|hopper|reefer|tank|freight/ },
{ name: 'coach', sample: 'coach', re: /coach/ },
{ name: 'caboose', sample: 'caboose', re: /\d caboose/ },
] as const;
for (const c of categories) {
const listed = c.re.test(needs);
const takes = acceptsCarOf(tray, c.sample);
if (listed) assert.ok(takes, `seed ${seed}: the panel lists ${c.name} the engine refuses: "${needs}"`);
const wanted = (c.name === 'coach' ? profile.consist.coach : c.name === 'caboose' ? profile.consist.caboose : profile.consist.freight) > tray.consist.filter((x) => (x.type === 'coach' ? 'coach' : x.type === 'caboose' ? 'caboose' : 'freight') === c.name).length;
if (takes && wanted) assert.ok(listed, `seed ${seed}: the engine still takes a ${c.name} the panel does not list: "${needs}"`);
}
panels++;
}
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
}
assert.ok(panels > 0, 'no seed reached a train being made up');
});
it('keys each make-up car to the yard chip that shows it', () => { it('keys each make-up car to the yard chip that shows it', () => {
// Ten buttons reading "add loaded hopper" when the Division Yard is already on screen showing // Ten buttons reading "add loaded hopper" when the Division Yard is already on screen showing
// exactly those cars by type and load state. The yard is the surface. // exactly those cars by type and load state. The yard is the surface.
+2
View File
@@ -10,6 +10,8 @@
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"exactOptionalPropertyTypes": true, "exactOptionalPropertyTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true, "noEmit": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,