Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a29de54ee | ||
|
|
d04f4c2fe4 | ||
|
|
def48201e4 | ||
|
|
04ca74c365 | ||
|
|
e47cd3d400 |
+139
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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()]
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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>/);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user