v0.8.5 — housekeeping from the audit, and the playtest line retired
The third release from the audit; nothing a player sees changes. CHANGELOG has the detail. The 0.4.9 playtest line is no longer maintained (Jesse, 2026-09-29): the deploy rule that existed for it is gone and #85 is moot. The table test (#39 #35 #42a #40) is closed — every line of the checklist was met at a table. #46 is done and cannot regrow: the 36 unused declarations are removed and `noUnusedLocals`/`noUnusedParameters` are on; two of them were dead bot functions from rejected candidates the round said it had deleted. The documents no longer teach `trainCapSlack` (a knob that throws), point at `as-built.md` (deleted in 0.8.2), model `officeType` (the engine says `tier`) or describe `collisionOccurred` (never emitted); the README's account of bot flags now matches the bot's. Five playtest saves committed in `docs/` against the repository's own rule are in the ignored `playtests/`. What the audit found and did not fix is written down as TODO #112-#117, each with its reason. #112 is `docs/plans/structure.md`, the proposal for `http.ts`, `main.ts` and `check`. #117 — `/api/save` hands a seat the seed mid-game — waits on a conversation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
e47cd3d400
commit
04ca74c365
@@ -19,6 +19,56 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
## 0.8.4 — 2026-09-29
|
||||||
|
|
||||||
The second release from the audit: the multiplayer transport, server and browser. Every fault here
|
The second release from the audit: the multiplayer transport, server and browser. Every fault here
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ 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
|
||||||
@@ -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,13 @@ 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 now.** The 0.4.9 playtest line was retired on 2026-09-29 (Jesse: "no longer
|
||||||
lines, so deploying from `station-master/` silently replaces the playtesters' build with main's.
|
being maintained"), so `npm run deploy:web` from this directory is the deploy. The public site
|
||||||
|
serves the last 0.4.9h build until that is run once from `main`.
|
||||||
|
- **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 +102,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 +112,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 +122,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 +198,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 +240,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 +436,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 +591,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 +626,85 @@ 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`.
|
||||||
|
|
||||||
|
- [ ] **#117** — **`/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 +2146,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 +2201,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 +2285,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 +2609,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Station Master — Components and Markers
|
# Station Master — Components and Markers
|
||||||
|
|
||||||
**Version 0.8.4** · 2026-09-29
|
**Version 0.8.5** · 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.4** · 2026-09-29
|
**Version 0.8.5** · 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.4** · 2026-09-29
|
**Version 0.8.5** · 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 makes the package a 63 MB `.s9pk` and the site a static upload.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Station Master — Quickstart
|
# Station Master — Quickstart
|
||||||
|
|
||||||
**Version 0.8.4** · 2026-09-29
|
**Version 0.8.5** · 2026-09-29
|
||||||
|
|
||||||
For a player who has never played.
|
For a player who has never played.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Station Master — Rules
|
# Station Master — Rules
|
||||||
|
|
||||||
**Version 0.8.4** · 2026-09-29
|
**Version 0.8.5** · 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.
|
||||||
|
|||||||
@@ -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.4",
|
"version": "0.8.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -326,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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+1
-1
@@ -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,
|
||||||
|
|||||||
+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);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+2
-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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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