Compare commits
+1527
File diff suppressed because it is too large
Load Diff
@@ -35,8 +35,28 @@ deliberately no longer names one: it went stale for six releases.
|
||||
reload; anybody may leave and the host may clear a chair; and the four transient signals that make
|
||||
a game feel alive — sound, the timetable flash, an announcement, the badge on the card you just
|
||||
drew — reach a remote client, which they did not before v0.7.0. What is still open is in `TODO.md`
|
||||
under Multiplayer — chiefly that **a player cannot see what the others did**, and that a lost
|
||||
under Multiplayer — chiefly that a lost
|
||||
session token still locks someone out of a running game from a genuinely fresh browser.
|
||||
- **Watching the table — v0.8.0.** Every accepted move, and every automatic phase that does
|
||||
anything, becomes an ordered **presentation step**: the board replays other people's turns instead
|
||||
of arriving already rearranged. This is what closes "a player cannot see what the others did",
|
||||
which stood open through v0.7.x. A bot's whole switching turn used to land in one push, because
|
||||
`driveBots()` plays it out before the push goes back; now it arrives as a run of steps, the
|
||||
district panel follows whoever is acting, and a `[N behind] … [Skip]` row says how far the board is
|
||||
from the game. Dwell is assigned **by kind** — a switching move holds the screen, turn bookkeeping
|
||||
costs nothing — and is tunable per viewer without a rebuild. Solitaire runs the same path, which is
|
||||
where its automatic phases finally get a visible beat.
|
||||
|
||||
**The caption and the history panel are not the same list**, since 2026-09-17. Switching is logged
|
||||
in full; a move from the middle of a turn writes its line as tone `trace`, so the step still
|
||||
carries it — the board captions the move and earns its dwell, and `dwellForStep` pays nothing for a
|
||||
step that said nothing — while the history panel filters the tone out. What the panel draws is the
|
||||
line saying somebody switched, the FIRST move, work at an **industry**, the Small Yard sort, and a
|
||||
closing summary. The last move rides in that summary rather than being kept in place: nothing knows
|
||||
a move was the last until the turn is over, by which time the line has been written and streamed to
|
||||
every client, so it cannot be revised.
|
||||
**Not yet checked in a browser:** the mechanism is proven server-side against a live SSE stream and
|
||||
the page is proven not to throw, but nobody has watched a bot switch on screen.
|
||||
- **Not built** — the opponent-directed cards (the Action and Space-use categories, held out of every
|
||||
deck until they have an implementation, along with the defensive cards whose only purpose is to
|
||||
answer them), and real audio. No screen offers a control for the opponent cards any more: the
|
||||
@@ -45,7 +65,8 @@ deliberately no longer names one: it went stale for six releases.
|
||||
Balance is *not* where it should be, and this file no longer quotes a figure for it. It used to say
|
||||
"the developer bot averages 7.0 Revenue against a target of 20", which stopped being true the moment
|
||||
the transit rule it names was defaulted to off — that rule was worth ~5.4 of the 7.0, for traffic
|
||||
nobody had to work. Measured at the current defaults the bot means about **zero**.
|
||||
nobody had to work. Measured at the current defaults the bot meant about **zero** until it began
|
||||
planning its switching turns (2026-09-14), which put it near **2.8**.
|
||||
|
||||
The three rates — passenger per coach, freight per load, train per transit — are **settings fixed when
|
||||
the game is dealt**, along with the opening hand and where an Extra may start, so the economy can be
|
||||
@@ -91,7 +112,8 @@ separate thing: it assembles the static SITE into `dist/`.)
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm test # node --test
|
||||
npm test # node --test, everything except the bot simulations — run after every change
|
||||
npm run test:sim # test/sim.test.ts, the bot simulations (~7 min) — run after a bot or balance change
|
||||
npm run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
|
||||
@@ -79,9 +79,9 @@ Not items. Things that are true of every change, and that have gone wrong when s
|
||||
3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79
|
||||
4. **The screen** — #44 #81 #33 #36
|
||||
5. **Replays and saved games** — #14 #47 #48 #49 #50 #51 #52
|
||||
6. **Rules** — #12 #80 #82 #83 #85
|
||||
6. **Rules** — #12 #80 #82 #83 #85 #108
|
||||
7. **Play balance** — #61 #62 #63 #64 #67 #68 #69 #70 #71 #72 #73 #66 #65 #74
|
||||
8. **The bot** — #41 #57 #59 #53 #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
|
||||
10. **Documentation and assets** — #15a #86 #88
|
||||
|
||||
@@ -100,20 +100,119 @@ Everything else in this file waits behind a release; this waits behind an aftern
|
||||
**Test runs WERE made across 0.7.4 through 0.7.9** (Jesse, 2026-09-07) and produced no change
|
||||
requests — the two bugs that did come out of them are Gitea#21 and #22, fixed in v0.7.9.1. So this
|
||||
section is not "nobody has touched it since 0.7.4"; it is the narrower and still-true claim that the
|
||||
specific paths below have not been exercised at a table. **More testing is planned at the end of the
|
||||
0.7.9 series, before 0.8.0 starts** — that is the moment to close these, not a separate errand.
|
||||
specific paths below have not been exercised at a table.
|
||||
|
||||
**The gate moved.** It was "before 0.8.0 starts"; 0.8.0 shipped anyway, through v0.8.0.8, so the
|
||||
session now runs against that build and covers what it added as well. See **Preparing the session**
|
||||
below — written 2026-09-10 because the measurement it rests on is the whole point: **three of the
|
||||
four things this section is named for do not happen by themselves.**
|
||||
|
||||
### Preparing the session
|
||||
|
||||
**MEASURED, 2026-09-10, across ten full competitive games driven to completion.** What a table will
|
||||
meet without trying, and what it will not:
|
||||
|
||||
| interruption | fires in | so |
|
||||
| --- | --- | --- |
|
||||
| Superintendent clearance (§8.1) | **9/10 games** | you will meet it; just play |
|
||||
| a train held at the Limits | 7/10 | ditto |
|
||||
| Extras started and queued | 10/10 | ditto |
|
||||
| collisions | 7/10 | ditto |
|
||||
| Red Flags set / spent | 7/10, 6/10 | ditto |
|
||||
| **the Yard Office offer** | **0/10** | must be set up |
|
||||
| **the Red Flag hold and its prompt** | **0/10** | must be set up |
|
||||
| **extended play (`dayExtended`)** | **0/10** | must be set up |
|
||||
|
||||
Those last three are exactly what #39 and #35 are NAMED for. They are not broken — they are
|
||||
conditional, and the conditions are these, read out of `advance.ts` rather than guessed:
|
||||
|
||||
- **Yard Office** (`advance.ts` ~1290) needs the destination district to contain a card carrying the
|
||||
`yardOffice` **enhancement**, AND an arriving train with **no coach** in its consist, AND a usable
|
||||
route. The bot never builds one, so **somebody has to build a Yard Office and then let a freight
|
||||
train arrive.**
|
||||
- **Red Flag hold** (`advance.ts` ~1232) needs the destination player to be **holding the Red Flags
|
||||
maneuver card**, AND an arrival that would genuinely collide — §8.3's own two ways: no free A/D
|
||||
track, or cars fouling the Running Track. So: **hold that card and let your A/D tracks fill.**
|
||||
- **Extended play** needs the timetable to RUN OUT, which a five-Day game does not do. Deal it with
|
||||
**`days: 1`** — that is exactly what the 2026-08-29 API verification did, and why it got there.
|
||||
|
||||
**What the session needs**
|
||||
|
||||
- **Two people, two browsers, two devices.** #35's remaining gap is specifically what a SECOND
|
||||
player sees while waiting on a first, and whether "waiting on Carol" still reads once Carol has
|
||||
closed her laptop. That cannot be tested alone, and it is the half that has never been done.
|
||||
- **Two games, not one.** A short `days: 1` game to reach the extension vote, and an ordinary game
|
||||
for everything else — with somebody deliberately building a Yard Office and holding Red Flags.
|
||||
- **#42a is separate and takes five minutes**, solitaire, one person: click every field on the setup
|
||||
screen and confirm the dealt game matches what was chosen.
|
||||
|
||||
**The caution this section exists because of.** #35's own Reference entry records that the
|
||||
2026-08-29 verification passed over the HTTP API — **which renders no dialog** — and that is exactly
|
||||
why the v0.7.9 bug survived: the vote sat underneath a modal results dialog whose only control was
|
||||
Close. What was proven was that the SERVER supports extended play, not that a player can reach it.
|
||||
Read that into every "verified on `phoenix.local`" line in this file, and into everything v0.8.0
|
||||
added, all of which is verified by test and simulation and none of it by eye.
|
||||
|
||||
**What v0.8.0 added to this list**, none of it played by a person for a whole game and none with a
|
||||
second human: the watchable board and its ordered steps, the speed control, the pile highlighting and
|
||||
the Home Office deck tile, "Your Move" being put away while catching up, the Day-end collision line,
|
||||
and the Salvage Yard naming its top card.
|
||||
|
||||
### The checklist
|
||||
|
||||
Grouped by what has to be set up, with the item each observation closes. Nothing here needs a
|
||||
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.
|
||||
|
||||
- [ ] 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).
|
||||
- [ ] 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
|
||||
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
|
||||
(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
|
||||
timetable ran out (#35).
|
||||
|
||||
**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
|
||||
interrupts the Mainline Phase and asks a question mid-thought — is it legible, and does it say
|
||||
which train? (#39)
|
||||
- [ ] 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).
|
||||
- [ ] 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,
|
||||
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.
|
||||
- [ ] 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
|
||||
contradict itself (v0.8.0.2).
|
||||
|
||||
**Solitaire, five minutes, alone.**
|
||||
|
||||
- [ ] Click through **every field** on the setup screen and confirm the dealt game matches what was
|
||||
chosen (#42a).
|
||||
|
||||
**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
|
||||
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
|
||||
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
|
||||
interruptions that stop the Mainline Phase and put a question in front of somebody
|
||||
mid-thought**, which is exactly the kind of thing only play reveals. See **Reference · #39**.
|
||||
mid-thought**, which is exactly the kind of thing only play reveals. **Neither of those two
|
||||
happens by itself — 0/10 games. See Preparing the session above for what to set up.** See
|
||||
**Reference · #39**.
|
||||
|
||||
- [ ] **#35** — **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
|
||||
(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
|
||||
laptop, are unanswered. See **Reference · #35**.
|
||||
laptop, are unanswered. **Needs a `days: 1` game — the timetable does not run out in five
|
||||
Days, so extended play fired in 0/10 measured games.** See **Reference · #35**.
|
||||
|
||||
- [ ] **#42a** — **Nobody has clicked through the solitaire setup screen's own fields** and confirmed
|
||||
the dealt game matches what was chosen. It took three attempts to become reachable at all —
|
||||
@@ -128,32 +227,72 @@ specific paths below have not been exercised at a table. **More testing is plann
|
||||
|
||||
## The common board, and watching play happen — Gitea#20
|
||||
|
||||
**This is v0.8.0.** One shared, seatless display of the public game, usable on a TV or in OBS on its
|
||||
own and publishable into the table's Jitsi meeting. The plan is `docs/plans/jitsi-common-board.md`,
|
||||
seven steps, of which 1-4 are the useful release and 5-7 are the Jitsi publisher.
|
||||
One shared, seatless display of the public game, usable on a TV or in OBS on its own and publishable
|
||||
into the table's Jitsi meeting. The plan is `docs/plans/jitsi-common-board.md`, seven steps.
|
||||
|
||||
**THE RELEASE SPLIT, settled with Jesse 2026-09-09. Jesse: "13 is the key. Watching on a TV is the
|
||||
bonus."**
|
||||
|
||||
- **v0.8.0 — #13, #15 and #18: the watchable table.** The step collector, steps on the `Session`
|
||||
interface, **foreign-district rendering**, the client animation queue, pacing by kind, and the
|
||||
behind-counter. **The design is the plan's § v0.8.0**, which supersedes the parts of steps 2-4 it
|
||||
covers. Needs no HTTP work at all.
|
||||
- **v0.8.1 — the seatless board page.** `display.json`, `viewToken`, `/api/display/stream`,
|
||||
`/display.html`, an all-districts layout: most of step 2 and step 3 without its canvas. Cheap once
|
||||
0.8.0 lands, and nothing in it moves #13 forward.
|
||||
- **v0.9.0 — the Jitsi publisher.** Steps 5-7 plus step 3's canvas pipeline. Held off deliberately:
|
||||
it needs Chromium in the image (several hundred MB onto a 63 MB `.s9pk`) and measurement on
|
||||
`phoenix.local`, and the only self-hosted Jitsi available needs an authenticated moderator to open
|
||||
a room, so "waiting for moderator" is the ordinary path here rather than an edge case.
|
||||
|
||||
**The correction that set that split:** `Frame.cells` is ONE district — the viewer's own
|
||||
(`view.ts:510`, from `areaOf(s, viewer)`), exactly as **Reference · #13** already said. So a step
|
||||
stream alone does not answer #13; the data would arrive with nowhere to be drawn. Rendering a
|
||||
district you do not own is the core of 0.8.0, not part of the seatless page. Two other decisions
|
||||
taken with it: **no WebSocket and no new runtime dependency** (SSE down + POST up, the pattern
|
||||
`server/http.ts` already uses), and `protocolVersion` on the wire in 0.8.0.
|
||||
|
||||
**Step 1 is BUILT** — v0.7.9.2 through v0.7.9.5 (#91, #92, #95, #97), with one item struck off
|
||||
rather than implemented (#103). **The plan was reconciled against the code in v0.7.9.8** and now says
|
||||
which of its "current code findings" are history: it had drifted badly enough to send the next reader
|
||||
fixing things twice. Steps 2-7 were never implemented and their findings have NOT been re-verified —
|
||||
check each before building on it. See **Reference · #103**. Items that look
|
||||
like screen polish live here because they need step 4's ordered presentation mechanism and nothing
|
||||
cheaper.
|
||||
rather than implemented (#103). **The plan was reconciled against the code in v0.7.9.8** and again
|
||||
on 2026-09-09, and now says which of its "current code findings" are history: it had drifted badly
|
||||
enough to send the next reader fixing things twice. Steps 2-7 were never implemented; step 4's
|
||||
findings were re-verified 2026-09-09 and steps 5-7's were NOT — check each before building on it.
|
||||
See **Done · 103**. Items that look like screen polish live here because they need step 4's ordered
|
||||
presentation mechanism and nothing cheaper.
|
||||
|
||||
**The design is settled — the plan's § v0.8.0 is the authority.** In outline: public steps animate
|
||||
the board while the existing private Push supplies hand, menu and objective (so there is no new
|
||||
redaction surface); the collector is a shared `sim/` module both `LocalSession.submit()` and
|
||||
`GameSession.submit()` call, so solitaire and multiplayer run one code path; dwell is assigned **by
|
||||
kind** with switching protected at 1s and bookkeeping at zero; and a `[N behind] … [Skip]` row shows
|
||||
the lag, carries #15's caption, and never blocks input. Two measurements that decided it: a
|
||||
switching turn runs to the engine's cap of **6 moves** (bursts of 14, 6, 6, 6 in `seed-1917398`),
|
||||
and dwell-by-kind costs ~40s of animation across a 60-stage game against 3.6 minutes for a flat
|
||||
700ms.
|
||||
|
||||
- [ ] **#13** — I cannot see what the other players did — bots included. **Settled 2026-08-29 as the
|
||||
harder reading**: not log legibility but the ordered, per-action presentation of everyone else's
|
||||
turns. Jesse: "It's not fun to do my turn and have magic happen in the background." This is
|
||||
Gitea#20 step 4 pointed at a player's own screen. See **Reference · #13**.
|
||||
Gitea#20 step 4 pointed at a player's own screen. **DESIGNED 2026-09-09 — the plan's § v0.8.0.**
|
||||
The answer is foreign-district rendering with focus following the actor; without it a step
|
||||
stream has nowhere to draw, because `Frame.cells` is the viewer's district alone. See
|
||||
**Reference · #13**.
|
||||
|
||||
- [ ] **#15** — A "most recent action" line under the status block. The text already exists and is
|
||||
already correct — this is placement, not content. **Decide with #13**: in solitaire "most
|
||||
recent" is the right unit; in multiplayer what you missed is everything that happened while you
|
||||
were WAITING. See **Reference · #15**.
|
||||
were WAITING. **DESIGNED 2026-09-09 — it is the caption in the `[N behind] … [Skip]` row, not a
|
||||
separate line.** The queue IS "everything that happened while you were waiting", which is the
|
||||
unit this entry could not choose. See **Reference · #15**.
|
||||
|
||||
- [ ] **#18** — Give every phase a visible beat. Not a timing problem — `pump` runs every automatic
|
||||
phase before the page renders once, so they are never drawn at all. **A `sleep` fixes nothing;
|
||||
it needs the async stepped pump that Gitea#20 step 4 specifies**, which is why it lives here
|
||||
rather than under The screen. See **Reference · #18**.
|
||||
rather than under The screen. **DESIGNED 2026-09-09 — it is a dwell setting on the shared queue,
|
||||
not a feature.** Phases where nothing happened dwell at ZERO (Jesse: "if nothing happens during
|
||||
a phase then we shouldn't lose time to it"); a flat second per phase is rejected on the same
|
||||
arithmetic this entry already worked out. Solitaire gets it through `LocalSession`, the same
|
||||
path multiplayer gets #13 through. See **Reference · #18**.
|
||||
|
||||
- [ ] **#75** — Let the game join a call and talk to the table — the chat, audio and nudge half of the
|
||||
idea Gitea#20 took the visual half of. Long-term. See **Reference · #75**.
|
||||
@@ -268,6 +407,42 @@ need RAR or Jesse rather than code.**
|
||||
- [ ] **#85** — The 0.4.9 playtest line is behind on a rules ruling, and that was 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
|
||||
2026-09-17, discussed the same day and built. Two sources disagreed: the v0.4.5 card text says
|
||||
a train there reorders "and puts the engine at the nose", `implications.md` says "any order,
|
||||
INCLUDING cars ahead of the engine". The design notes won.
|
||||
|
||||
`switch.sortConsist` gained an optional `engineAt` (absent = the nose, so older saves replay
|
||||
unchanged). The menu did NOT multiply: the engine is a separate short list offered against the
|
||||
consist as it stands, so a four-car train has eight options rather than twenty, and a player
|
||||
wanting both a re-order and an engine move spends two Moves. §8.2 needed no new code —
|
||||
`badlyMadeUp` already holds a broken-backed train, and is deliberately direction-free, so a
|
||||
PUSHING train (whole consist ahead of the engine) is fit to run. The button warns first, by
|
||||
asking that predicate rather than copying it.
|
||||
|
||||
Labels read WEST TO EAST with the engine drawn as the board's own ◀ / ▶ arrow, because "front
|
||||
to back" depends on which way the train points and the board has reversed east-facing consists
|
||||
since v0.8.0. Each says `MADE UP, ready to leave` or `HELD at the Office: <why>`.
|
||||
|
||||
- [ ] **#108** — **The coach ratchet: every coach ends up in the Classification Yard and never comes
|
||||
back.** RULED 2026-09-17 — *the rule stands, the game says so loudly* — and recorded here
|
||||
because the ruling was made on one game's evidence and the balance question behind it is open.
|
||||
|
||||
§9.2 boarding discards the emptied coach into **Classification**; detraining draws a fresh
|
||||
empty **out of the Division Yard**; §2.2 returns Classification only when the Division Yard runs
|
||||
bare. Coaches therefore move one way only. **Measured over `whistle-6945` (3 Days, 539
|
||||
intents):** 16 coaches in the Division Yard at setup, **0 from Day 2 Stage 8 to the end**, 15
|
||||
in Classification — while the Division Yard held steady at 46-47 freight cars, so the refill
|
||||
could not fire. From that point no passenger can board or detrain anywhere on the board, and
|
||||
four of the twelve timetabled trains (1/2 Crack Limited, 5/6 Sparrow) carry nothing but
|
||||
coaches.
|
||||
|
||||
The two changes that would break the ratchet were put up and declined for now: sending the
|
||||
emptied coach back to the **Division** Yard instead of Classification (a one-line change to the
|
||||
boarding reducer), or amending §2.2 to refill when the Division Yard holds no car of a NEEDED
|
||||
type rather than only when bare. **Revisit with a second game's data** — one game cannot tell a
|
||||
rule from a seed.
|
||||
|
||||
---
|
||||
|
||||
## Play balance
|
||||
@@ -328,21 +503,37 @@ v0.7.9's collision-floor change (#61).
|
||||
The developer bot exists to measure the game, not to be a good opponent — so a bot weakness matters
|
||||
when it stops a measurement being trustworthy. **Read #57 before tuning any weights.**
|
||||
|
||||
**Since 2026-09-14 the bot plans its whole switching turn** (`sim/switch-planner.ts`, +2.89 revenue a
|
||||
game), **takes a face-up card only if it could play it** (+1.52), and **since 2026-09-15 lays track by
|
||||
what the district can do afterwards** (`bestValuedLay`, +0.12 over 6400 seeds, run-arounds 9/60 → 22/60). Jesse's goal for it is better decisions in simulated runs AND at a real table, with no
|
||||
non-player advantage — it reads the board, never the deck.
|
||||
|
||||
- [ ] **#104** — Weigh a switching turn against drawing and the Freight Agent. Letting the planned gain
|
||||
gate switching on its own measured nothing (0.1, 0.25) or worse (0.5): `usefulSwitching` already
|
||||
says yes exactly when a plan gains. What would matter is a VALUE for the other two options to
|
||||
compare against, which the bot does not have. See **Reference · #104**.
|
||||
|
||||
- [ ] **#106** — The Extra trap: a full hand of Extras the A/D cap is holding back cannot be discarded,
|
||||
so the next draw forces one into a full Office. All 23 train plays past the cap in 40 games were
|
||||
this. Avoiding the draw measured nothing (−0.03) because it stalled development. See
|
||||
**Reference · #106**.
|
||||
|
||||
- [ ] **#105** — Plan across more than one turn. Jesse is in favour, one turn first to see the impact —
|
||||
which is now measured. Deferred for a conversation, not declined. See **Reference · #105**.
|
||||
|
||||
- [ ] **#41** — The bot never plays Red Flags — zero in 200 games since Gitea#19, and that is deck
|
||||
luck rather than unwillingness. It takes the danger prompt unconditionally; what it never does
|
||||
is plant a flag ON PURPOSE to buy a Stage for switching, which needs it to know it wants time.
|
||||
See **Reference · #41**.
|
||||
|
||||
- [ ] **#57** — The bot's priorities are not the problem — measured across ten heuristic variations.
|
||||
**Read this before tuning weights**; it is the argument that the ceiling is elsewhere. See
|
||||
**Reference · #57**.
|
||||
**Read this before tuning weights**; it is the argument that the ceiling is elsewhere. **Part of
|
||||
"elsewhere" was choosing one Move at a time**: planning the whole switching turn was worth
|
||||
+2.89 (t = 15.8) in the 2026-09-14 bot-tuning round. See **Reference · #57**.
|
||||
|
||||
- [ ] **#59** — The run-around is out of reach of any bot, and the deck is why — measured five ways.
|
||||
See **Reference · #59**.
|
||||
|
||||
- [ ] **#53** — The bot does not know to bring an expedited train back to the station. See **Reference
|
||||
· #53**.
|
||||
|
||||
- [ ] **#54** — The bot cannot spot a car at a stub industry, and the cut-ordering rules made that
|
||||
visible. See **Reference · #54**.
|
||||
|
||||
@@ -391,6 +582,23 @@ What the project says about itself, and what it ships alongside the code.
|
||||
- [ ] **#88** — `card-reference.md`'s industry table may still be stale beyond Grocer's Warehouse and
|
||||
the Oil Refinery. See **Reference · #88**.
|
||||
|
||||
- [ ] **#109** — **Render the published Quickstart instead of serving it as plain text.** v0.8.0.16
|
||||
publishes `docs/StationMaster-Quickstart.md` to `dist/quickstart.md` and links it from the
|
||||
splash page, served as `text/plain` — so a tester reads the guide's tables as rows of pipes and
|
||||
its links do not click. That was the fifteen-minute version, taken deliberately to get the
|
||||
guide in front of testers for this round rather than to leave them without one.
|
||||
|
||||
**Copy the document, do not re-write it.** A hand-written HTML twin drifts from the Markdown on
|
||||
the first edit, which is the whole argument of #15a. The step is a small Markdown-to-HTML
|
||||
converter in `scripts/build-web.ts` writing `quickstart.html` beside the game, styled like the
|
||||
splash page — headings, lists, tables, links and code spans are the whole of what the guide
|
||||
uses. The `.md` MIME entry in `src/server/http.ts` and the two assertions in
|
||||
`test/web.test.ts` (`the Quickstart guide reaches the site`) move to the rendered file with it.
|
||||
|
||||
**Cost:** an afternoon, most of it in the converter's table and list handling. No dependency —
|
||||
a Markdown library would be the only runtime dependency this project has, and the guide uses a
|
||||
small enough subset that it is not worth becoming the first.
|
||||
|
||||
---
|
||||
|
||||
## Reference — measurements, rulings and rejected approaches
|
||||
@@ -1384,6 +1592,12 @@ measuring deck luck rather than reachability, and its comment now says so.
|
||||
|
||||
#### #57 — THE BOT'S PRIORITIES ARE NOT THE PROBLEM — measured.
|
||||
|
||||
**2026-09-14 — confirmed, and one ceiling found.** Reordering priorities still moves nothing; what
|
||||
moved the bot was SEARCH. `sim/switch-planner.ts` plans the whole switching turn against a score of
|
||||
where it ends, and measured +2.89 ± 0.18 (t = 15.79) over 1600 paired seeds — freight loads and
|
||||
unloads 0.22 → 1.53, Cargo phases with a car spotted 5% → 18%. The "8% of Cargo phases" below was a
|
||||
fact about how the bot switched, not only about the deck. See `CHANGELOG.md`, 0.8.0.9.
|
||||
|
||||
**THE BOT'S PRIORITIES ARE NOT THE PROBLEM — measured.** Ten heuristic variations, each paired
|
||||
|
||||
over 400+ seeds. Every reordering of what the bot prefers came out inside the noise; the only
|
||||
@@ -1409,6 +1623,21 @@ prioritised better. What is left is the economy itself, which is a deck question
|
||||
|
||||
#### #59 — THE RUN-AROUND IS OUT OF REACH OF ANY BOT, AND THE DECK IS WHY — measu…
|
||||
|
||||
**2026-09-14 — part of "the deck is why" was the bot's own draw.** It was taking ~32 face-up trains and
|
||||
industries a game it could not play and discarding them again, so the hand rarely held track. Taking
|
||||
only playable cards (now the default) grew districts 14 → 24 cards and run-arounds 3/60 → 9/60. And
|
||||
~13 of the ~16 track pieces a game were being laid by the draw turn's "play what is in hand" fallback
|
||||
at the first legal square, not by `bestTrackLay`; holding them (−0.83) and placing them by
|
||||
`bestTrackLay`'s score (+0.07, noise) both failed, so the next limit is that SCORING — it cannot tell a
|
||||
piece that opens an industry site or advances a run-around from one that fills a square. The deck
|
||||
measurements below were taken before any of this and should be re-read with it in mind.
|
||||
|
||||
**2026-09-15 — the scoring, fixed.** `bestValuedLay` scores the layout a lay leaves (reachable industry
|
||||
sites, a closed run-around, ways off the main) instead of the piece: closed run-arounds in 22 of 60
|
||||
districts against 9, +0.118 ± 0.029 revenue (t = 4.14, 6400 seeds). The run-around is now reachable
|
||||
without changing the deck; what is left is a bot that can USE one, which needs more than one turn of
|
||||
planning (#105).
|
||||
|
||||
**THE RUN-AROUND IS OUT OF REACH OF ANY BOT, AND THE DECK IS WHY — measured, five ways.**
|
||||
|
||||
"Teach the bot to plan across turns" was tried properly and does not work. Every attempt is
|
||||
@@ -1450,6 +1679,8 @@ the end of the game, drawing the fault **26 times**. Not an engine bug — the m
|
||||
exactly as designed — but a clear next bot heuristic: prefer ending a switching turn with any
|
||||
expedited crew back on the Office square, at least once it has finished the work it went out for.
|
||||
|
||||
**CLOSED 2026-09-14** — see **Done · 53**.
|
||||
|
||||
#### #54 — THE BOT CANNOT SPOT A CAR AT A STUB INDUSTRY, and the cut-ordering rul…
|
||||
|
||||
**THE BOT CANNOT SPOT A CAR AT A STUB INDUSTRY, and the cut-ordering rules made that visible.**
|
||||
@@ -1471,6 +1702,12 @@ pass should not read the drop as a deck problem.
|
||||
|
||||
#### #58 — The bot cannot get a crew next to an industry, so Flying Switch never…
|
||||
|
||||
**2026-09-14 — the premise is now a deck fact.** Flying Switch is dealt **0 copies** (not in sheet 5;
|
||||
Jesse, 2026-08-26), so no bot can fire it: across 30 standard games none was ever drawn. The switching
|
||||
planner searches the card by default, so it will be used the day it is dealt again. The reachability
|
||||
sweep's exemption in `sim.test.ts` stays until then.
|
||||
|
||||
|
||||
**The bot cannot get a crew next to an industry, so Flying Switch never fires.** Industries are
|
||||
|
||||
now stub-only and the bot places 2.23 a game (was 3.84), in districts averaging under two rows
|
||||
@@ -1518,6 +1755,114 @@ of zero" over 400 games — but at 400 games the standard error is ±0.33, so a
|
||||
have looked like nothing. They are nearly free to re-run now and at least one may have been
|
||||
discarded wrongly.
|
||||
|
||||
#### #104 — WEIGH SWITCHING AGAINST THE OTHER TWO OPTIONS, not against a threshold.
|
||||
|
||||
Measured 2026-09-14, paired over 400 seeds against the planner: switching only when the planned gain
|
||||
clears a threshold scored −0.33 at 0.5 (t = −5.06), −0.01 at 0.25, +0.06 at 0.1 (t = 1.79). At 0.5 it
|
||||
refuses turns that only collect cars, which feed later deliveries; below that it agrees with
|
||||
`usefulSwitching`. The choice that is still made by a fixed ladder is WHICH of §6's three options a
|
||||
Stage goes to, and the planner can now put a number on one of them. The other two need numbers of
|
||||
their own — what a draw is worth given the hand and the Departments, what stocking a box is worth given
|
||||
the cars spotted — before the three can be compared. Also: planning at every Local Operations decision
|
||||
costs a full search each time, so any version of this has to stay cheap.
|
||||
|
||||
**2026-09-15 — measured the ceiling first: there is almost none.** At 907 real Local Operations choices
|
||||
(75 standard games, seeds outside the usual measurement range), every legal option was tried and the rest
|
||||
of the game played out by today's bot, 4 times each with the HIDDEN parts reshuffled — the Home Office
|
||||
deck order and future rolls — and the same reshuffles for every option, so the comparison is paired.
|
||||
Grouped by the rule that made the choice, the value of each alternative against it:
|
||||
|
||||
| the ladder chose | times | switch instead | draw instead | Freight Agent instead |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| draw — nothing urgent, develop | 494 | +0.08 ± 0.12 | — | +0.02 ± 0.04 |
|
||||
| Freight Agent — feed the pipeline | 147 | +0.07 ± 0.06 | −0.01 ± 0.04 | — |
|
||||
| switch — a train with work at the Office | 108 | — | −0.17 ± 0.10 | −0.25 ± 0.08 |
|
||||
| draw — an Office upgrade in hand | 100 | +0.21 ± 0.16 (6) | — | −0.08 ± 0.06 |
|
||||
| switch — walk the crew home | 47 | — | +0.22 ± 0.14 | −0.15 ± 0.08 |
|
||||
| draw — a train card in hand | 11 | — | — | −0.45 ± 0.24 |
|
||||
|
||||
No rule has an alternative that is significantly better; where the table leans, the ladder is usually
|
||||
the one that is right. So given how the bot plays each option once chosen, the choice itself is close to
|
||||
optimal, and value functions for draw and Freight Agent have little to find. The one lean worth a look if
|
||||
this is reopened is walking a stranded crew home (+0.22, t ≈ 1.6). The rollout tool is analysis only —
|
||||
the bot never sees a rollout.
|
||||
|
||||
#### #106 — THE EXTRA TRAP — why the cap on committed trains still lets an Office overfill.
|
||||
|
||||
**2026-09-15 — re-measured under today's defaults, and most of it is not the Extra trap.** 20 "no free
|
||||
A/D track" collisions in 60 standard games, −1.67 revenue a game. No train was held (§8.2 or clearance) in
|
||||
the Stage before any of them, and only 8 of the 20 trains destroyed were Extras. **Six destroyed a train
|
||||
of the same number as a Second Section run within the previous two Stages — and all 26 Second Sections
|
||||
the bot ran in those games were an accident:** the New Train phase's "no car on offer" fallback takes
|
||||
`options[0]`, and `legalActions` lists `newTrain.secondSection` ahead of the Extra starts, so whenever an
|
||||
Extra was waiting to start the bot doubled the train due out instead. The Office never had an A/D track
|
||||
to spare for one.
|
||||
|
||||
**A RULES QUESTION FOR JESSE, found on the way — not a bot matter.** Q9 (`implications.md`) defines the
|
||||
Second Section as a CARD "played on a train that is due out", and `content.ts` defines `SECOND_SECTION`
|
||||
with 1 copy — but `buildDeck` never deals it, and `check`'s `newTrain.secondSection` asks for no card in
|
||||
hand. So any player may run a Second Section for free on every train due out. Either the card should be
|
||||
dealt and required, or the free action is the intended rule and Q9's wording is stale.
|
||||
|
||||
Also measured and removed: starting an over-cap Extra where its run never reaches the Office (+0.10,
|
||||
t = 1.82) — such a start was on offer at 1 of 25 over-cap starts.
|
||||
|
||||
**The accident is fixed** (2026-09-15, default): the New Train fallback takes a car, a pass or the
|
||||
Extra's start, never `options[0]` — +0.32 ± 0.09 (t = 3.64), collisions 0.24 → 0.19. **Still open under
|
||||
this item:** the forced Extra itself (a full, undiscardable hand of Extras), and the Second Section card
|
||||
question above.
|
||||
|
||||
`choose` removes train-card plays from the options when `trainWouldOverfillTheOffice`, but yields if
|
||||
that would leave nothing legal. It does leave nothing legal in one ordinary position: the hand is over
|
||||
the limit (§6.2 requires reducing it) and every card in it is an Extra, which `keepReason` forbids
|
||||
discarding. Measured 2026-09-14 after the face-up take rule: 23 of 196 train plays in 40 games were past
|
||||
the cap, every one "play what is in hand" with four Extras held, and the worst seeds each lost 3-4
|
||||
collisions to it. Declining the draw option in that position measured −0.03 ± 0.11 — collisions fell
|
||||
0.26 → 0.20 but cards played fell 29.0 → 25.6. Better answers to try: play the Extra at the least
|
||||
dangerous moment rather than the first, count WHEN each committed train is due at the Office instead of
|
||||
how many there are, or keep the hand from filling with Extras in the first place.
|
||||
|
||||
#### #105 — PLAN ACROSS TURNS — the evidence so far, for the conversation.
|
||||
|
||||
For: one-turn planning already reaches most switching work (Cargo phases with a car spotted 5% → 18%),
|
||||
and what it cannot do is exactly what spans a Stage — leave a car on a spur for the next crew, or start
|
||||
a run-around and finish it later. The planner already scores staged wanted cars (+0.2), which is a
|
||||
first, crude step in that direction.
|
||||
|
||||
Against, for now: everything between two switching turns is not the player's — a Mainline Phase, trains
|
||||
arriving, a Load/Unload phase — so a second turn cannot be searched the way the first is without either
|
||||
simulating those phases (arrivals are on the public timetable, but cars on arriving trains are not
|
||||
known) or scoring the position between turns more cleverly. And search cost is already what sets the
|
||||
test suite's running time. Cheapest next step if taken up: a better score for "what the next turn can
|
||||
still reach", not a deeper search.
|
||||
|
||||
**2026-09-15 — the run-around measurement that bears on this.** A candidate that lays track by what the
|
||||
district can do afterwards (`valueLays`) more than doubled closed run-arounds, 9/60 → 22/60, yet moved
|
||||
revenue only +0.14 ± 0.06 (t = 2.58, 1600 seeds). Traced over 40 games: the one-turn planner DOES use
|
||||
the loop — 63 of 131 plans in a district with one end a move on it, 35 run it both ways — but its planned
|
||||
gain per turn is the same with a run-around as without (0.28 against 0.27). A run-around is for putting a
|
||||
train's cars in a different order, which pays off in the turns after; a planner that looks one turn
|
||||
ahead has no way to value it.
|
||||
|
||||
**2026-09-15 — two-turn planning, built and measured: it does not pay.** `planTwoTurns` kept the six best
|
||||
ends of a switching turn, removed the trains that would highball in the Mainline Phase in between (on the
|
||||
Office square and made up — their cars leave with them), reset the Moves, planned the next turn from each,
|
||||
and chose by the position after departures plus 0.8 of what the next turn adds. Nothing hidden is read.
|
||||
|
||||
| version | revenue, 400 paired seeds | what went wrong |
|
||||
| --- | --- | --- |
|
||||
| first | −0.28 ± 0.11 (t = −2.58) | an expedited train left away drew 31 faults in one game: the fault is charged in the gap, which the discounted next turn "recovered"; trains left away 5.3 a game against 3.1 |
|
||||
| with the gap fault charged in full and 0.5 a Stage per train left away | −0.14 ± 0.06 (t = −2.36) | trains still left away 4.8 a game; the "crew must get back to the Office" choice 4.2 a game against 2.7 |
|
||||
|
||||
Why a second turn has so little to find, measured over 30 standard games:
|
||||
- only **49%** of switching turns have the same train in the district at the next Local Operations choice;
|
||||
- **6.1 wanted cars a game** do leave aboard departing trains — but a further switching turn from those
|
||||
exact positions could have spotted only **0.7** of them: most were never deliverable;
|
||||
- the one-turn planner already gains no more with a run-around than without (0.28 against 0.27).
|
||||
And what a second turn COSTS is a Stage: trains left away have to be walked home, and those choices come
|
||||
out of drawing and the Freight Agent (cards played 28.8 → 28.3). A multi-turn bot would have to weigh
|
||||
switching against the other two options — which is #104, not a deeper search.
|
||||
|
||||
### Code health and housekeeping
|
||||
|
||||
#### #46 — tsc --noUnusedLocals finds 29 unused declarations across 14 files, and…
|
||||
@@ -1761,7 +2106,16 @@ where it belongs, and it is still open.
|
||||
Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
|
||||
each group.
|
||||
|
||||
### Shipped through v0.7.9.4, from the queue
|
||||
### Closed in the 2026-09-14 bot-tuning round (unreleased)
|
||||
|
||||
53. ~~**The bot did not know to bring an expedited train back to the station.**~~ — done 2026-09-14,
|
||||
not by a heuristic of its own but as a consequence of planning the switching turn: the planner's
|
||||
score charges an expedited train left away from the Office a full Revenue point, which is what Q3
|
||||
charges. Over 400 paired seeds, 19 games drew expedite faults under the rule ladder and **none**
|
||||
under the planner, worth +1.07 a game (t = 3.83) — the two worst cases had drawn 45 and 42 faults
|
||||
in a single game. See `CHANGELOG.md`, 0.8.0.9.
|
||||
|
||||
### Shipped through v0.7.9.8, from the queue
|
||||
|
||||
Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson;
|
||||
the numbers stay so cross-references above and below still resolve.
|
||||
@@ -1996,11 +2350,13 @@ the numbers stay so cross-references above and below still resolve.
|
||||
`# fail 0`. `pretest` is `tsc --noEmit && node scripts/build-web.ts` now, and the same planted
|
||||
error exits 1 with the tests never running.
|
||||
|
||||
**Why it mattered THIS week rather than generally.** v0.8.0 is steps 2-7 of the common board —
|
||||
a display stream, credentials, persistence, and a Chromium supervisor — which is almost
|
||||
entirely `src/server/` and is exactly the half the test command could not see. Found while
|
||||
answering "anything else before 0.8.0", which is the only reason it was found at all: nothing
|
||||
about a green suite would ever have said so.
|
||||
**Why it mattered THIS week rather than generally.** The next release is steps 2-4 of the
|
||||
common board — a display stream, credentials, persistence and the display-step collector, which
|
||||
is almost entirely `src/server/` and is exactly the half the test command could not see. (The
|
||||
Chromium supervisor was in this list when the entry was written; steps 5-7 became v0.9.0 on
|
||||
2026-09-09, and the point stands without it.) Found while answering "anything else before
|
||||
0.8.0", which is the only reason it was found at all: nothing about a green suite would ever
|
||||
have said so.
|
||||
|
||||
103. ~~**The common-board plan had drifted from the code it is the source for.**~~ — done 2026-09-07
|
||||
in v0.7.9.8. `docs/plans/jitsi-common-board.md` was written 2026-08-27, still said "No
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
# Station Master — Components and Markers
|
||||
|
||||
**Rules implementation reference: v0.4.5**
|
||||
**Scope:** non-card physical components and supplies modeled by the v0.4.5 game. Card-created facilities, workers, deck piles, hand state, timetable state, and other markers are documented with their cards or in the rules book.
|
||||
**Describes the game as built at v0.8.0.16** (2026-09-20). Previously stamped "v0.4.5", the
|
||||
prototype rules edition these references were first written against; the filename keeps that stamp
|
||||
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name.
|
||||
|
||||
**Scope:** non-card physical components and supplies. Card-created facilities, workers, deck piles,
|
||||
hand state, timetable state and other markers are documented with their cards or in the
|
||||
[rules book](StationMaster-Rules-v0.4.5.md).
|
||||
|
||||
The figures below are checked against `ROLLING_STOCK_SUPPLY` and the supply constants in
|
||||
`src/engine/content.ts`. They are physical inventory rather than deck tuning, which is why they are
|
||||
printed here at all — per-category CARD counts are deliberately not published anywhere (TODO #15a).
|
||||
|
||||
## Rolling stock
|
||||
|
||||
@@ -19,6 +28,14 @@ Rolling stock has a type and a load state. In the interface, coloured cars are l
|
||||
|
||||
The engine is not rolling stock and does not count against the four-car Crew Tray limit.
|
||||
|
||||
## Other supplies
|
||||
|
||||
| Component | Count | Note |
|
||||
| --- | ---: | --- |
|
||||
| Crew Trays | players + 3 | Engine and tray are one combined resource; there is no "engine without a tray". |
|
||||
| Whistle Post cards | 4 | Every player starts on one; it is not drawn from the deck. |
|
||||
| Limits signs | 8 | "2N + spares", so relocating one is never a supply question. |
|
||||
|
||||
## The two yards
|
||||
|
||||
### Division Yard
|
||||
@@ -34,7 +51,16 @@ When a train completes a run or is destroyed, its caboose returns to the Divisio
|
||||
|
||||
### Classification Yard
|
||||
|
||||
The Classification Yard collects used rolling stock: cars displaced by boarding passengers, cars cleared from inbound red boxes, cars removed by unjamming a facility, ordinary cars from completed or destroyed trains, and empty cars replaced by a completed outbound freight load. It is not a player-selectable source. It returns to service only when the Division Yard is empty.
|
||||
The Classification Yard collects used rolling stock: cars displaced by boarding passengers, cars
|
||||
cleared from inbound red boxes, cars removed by unjamming a facility, ordinary cars from completed
|
||||
or destroyed trains, and empty cars replaced by a completed outbound freight load. It is not a
|
||||
player-selectable source. It returns to service only when the Division Yard is empty.
|
||||
|
||||
> **This is a one-way ratchet for COACHES, and it decides games.** Boarding sends an emptied coach
|
||||
> here; detraining takes a fresh empty out of the *Division* Yard. Nothing returns a coach to the
|
||||
> Division Yard except the bare-yard refill — and a yard kept topped up with freight cars returning
|
||||
> from industries may never run bare. Measured over one three-Day game, every coach was here by the
|
||||
> middle of Day 2 and stayed. See [Rules](StationMaster-Rules-v0.4.5.md) §4.6.
|
||||
|
||||
## Crew Trays and trains
|
||||
|
||||
@@ -44,7 +70,16 @@ Within a tray, the engine can pull, push, or be between cars while switching. To
|
||||
|
||||
## Fedora
|
||||
|
||||
The **Fedora** is the physical marker for the Superintendent. The player with it resolves following-train clearance decisions and takes the 5-Revenue penalty for a Mainline collision caused by an unsafe clearance. The initial holder is the first player tied for the highest Superintendent setup D12 roll. The Fedora passes to the next seat to the left at each third-stage shift change.
|
||||
The **Fedora** is the physical marker for the Superintendent. The player with it resolves
|
||||
following-train clearance decisions, takes the Yard Office and Red Flag questions, and is the player
|
||||
every round round the table starts with. A Mainline collision is their fault by §10, and costs 5
|
||||
Revenue. The initial holder is the first player tied for the highest Superintendent setup D12 roll.
|
||||
|
||||
It passes at the end of Stages 3, 6, 9 and 12 — every third Stage, at the Supervisor Shift. Since
|
||||
v0.8.0.13 the handover is **announced on screen and written into the history**: it is the one thing
|
||||
in the game that changes hands on the clock rather than because somebody did something, so nobody is
|
||||
watching for it. Note that the Supervisor Shift refreshes every Laborer and Porter *every* Stage
|
||||
while the Fedora moves only every third.
|
||||
|
||||
## D12 and seeded randomness
|
||||
|
||||
|
||||
@@ -1,162 +1,153 @@
|
||||
# Station Master — Home Deck
|
||||
|
||||
**Rules implementation reference: v0.4.5**
|
||||
**Scope:** every card associated with the Home Office deck, including cards catalogued in the source but deliberately excluded from the dealt deck.
|
||||
**Describes the game as built at v0.8.0.16** (2026-09-20). Previously stamped "v0.4.5", the
|
||||
prototype rules edition these references were first written against; the filename keeps that stamp
|
||||
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name.
|
||||
|
||||
## Dealt card catalogue
|
||||
**Scope:** the Home Office deck — how it is dealt, drawn, discarded and reshuffled, and what the
|
||||
rules are for playing each kind of card out of it.
|
||||
|
||||
The live Home Office deck contains **213 cards** in every currently supported mode. The rules for its setup, drawing, discarding, reshuffling, and hand limit are in the rules book, section 4.2.
|
||||
> **Per-card facts live in [`rules/as-built.md`](rules/as-built.md)**, which is GENERATED from
|
||||
> `src/engine/content.ts` and checked by `test/card-reference.test.ts`, so it cannot disagree with
|
||||
> the game. Read it for every card's name, effect, placement and whether its printed effect actually
|
||||
> resolves yet. This document is how the deck WORKS; that one is what is in it.
|
||||
>
|
||||
> **No card counts appear here, deliberately** (TODO #15a, Jesse's call 2026-08-22): counts move
|
||||
> with play balance, so a document printing them is answering a question that has a different answer
|
||||
> after the next retune. Where a count matters it is rendered as a yes/no — whether the deck deals
|
||||
> the card at all — which is a fact about the design. This page used to print a full counts table
|
||||
> and it was wrong for a month before anyone noticed.
|
||||
|
||||
| Active category | Cards |
|
||||
| --- | ---: |
|
||||
| Track | 96 |
|
||||
| Office upgrades | 14 |
|
||||
| Freight facilities | 27 |
|
||||
| Facility modifiers | 23 |
|
||||
| Train cards | 22 |
|
||||
| Enhancements | 18 |
|
||||
| Mainline modifiers | 7 |
|
||||
| Maneuvers | 6 |
|
||||
| **Total dealt** | **213** |
|
||||
## The piles
|
||||
|
||||
The catalogue also contains 12 space-use cards and 10 action cards. All 22 are excluded from every v0.4.5 dealt deck because their opponent-directed play rules are not implemented. They are listed at the end of this document for completeness.
|
||||
- **Home Office deck** — face down. The pile a Draw comes from.
|
||||
- **Three Departments** — face-up discard piles. A discard goes onto one, which is precisely so a
|
||||
rival may take it; a Draw may take the top card of a Department instead of the deck.
|
||||
- **Salvage Yard** — where a played-out card ends up. An Extra's card goes here after its run.
|
||||
|
||||
## Track cards — 96
|
||||
When the Home Office deck runs out it is rebuilt from the Salvage Yard and **all three Departments
|
||||
in full**, reshuffled from the seeded stream. A **spent timetabled train** is not collected — its
|
||||
number is on the timetable and it cannot run twice — but a *discarded* train was never played and
|
||||
is still runnable, so it comes back.
|
||||
|
||||
Track cards are ordinary Home Office cards, not a separate personal supply. A placed card must connect to existing rail. The Running Track is the horizontal row from Limit to Limit; cards there must carry an east–west through route. Placing track on a Limit extends the Running Track and moves that Limit outward.
|
||||
## Hand and turn
|
||||
|
||||
| Card | Copies | Implemented placement facts |
|
||||
| --- | ---: | --- |
|
||||
| Straight track | 32 | East–west Operational Rail. |
|
||||
| Curved track, right | 16 | A 45° curve; can rotate 180°, but cannot flip. Right-hand geometry is fixed to the `ne_sw` diagonal. |
|
||||
| Curved track, left | 16 | A 45° curve; can rotate 180°, but cannot flip. Left-hand geometry is fixed to the `nw_se` diagonal. |
|
||||
| Turnout, right | 16 | East–west through route plus one 45° branch. It may be passed through but is not Operational Rail, so a train cannot end a Move on it. |
|
||||
| Turnout, left | 16 | Same operational rules; opposite fixed diagonal. |
|
||||
| Sharp curved track, right | 0 | Catalogued but not dealt. |
|
||||
| Sharp curved track, left | 0 | Catalogued but not dealt. |
|
||||
The hand limit is **three**, or four while you hold a Red Flag. You may not end a turn over the
|
||||
limit: play a card or discard one to a Department. Some cards cannot be discarded at all — an Extra
|
||||
never can, and a timetabled train cannot when the `discardTimetabled` house rule is off — so a hand
|
||||
of nothing but those has exactly one way forward, which is to play one.
|
||||
|
||||
A turnout may upgrade an existing straight, or a curve whose arc is exactly the turnout’s diverging arc. An upgrade is forbidden if the existing card holds standing cars or an enhancement. All other occupied squares are unavailable.
|
||||
The opening deal is a house-rule choice made when the game is dealt. The default (`threeRandom`) is
|
||||
three cards from one shuffled deck; `threeTrackThreeOther` deals three track and three others from
|
||||
two separately shuffled piles, deliberately over the hand limit, so the first turn is spent choosing
|
||||
which district you can afford to build.
|
||||
|
||||
## Offices — 14
|
||||
Drawing is one of the three Local Operations options — see [Rules](StationMaster-Rules-v0.4.5.md)
|
||||
§4.2. Taking the option lets you draw **and** play or discard within the same turn.
|
||||
|
||||
Every player begins at a Whistle Post, which is not drawn from the deck. Office cards are upgrades and must be played in sequence; they upgrade the existing Office rather than replacing its card or attached track.
|
||||
## Track cards
|
||||
|
||||
| Card | Copies | A/D tracks | Porters | Passenger outbound/inbound slots | Other effect |
|
||||
| --- | ---: | ---: | ---: | ---: | --- |
|
||||
| Depot | 8 | 2 | 1 | 1 / 1 | Becomes a Control Point and Passenger Facility. |
|
||||
| Station | 4 | 3 | 2 | 2 / 2 | Upgrade Depot only; Control Point and Passenger Facility. |
|
||||
| Terminal | 2 | 4 | 3 | 3 / 3 | Upgrade Station only; Control Point and Passenger Facility. |
|
||||
Track cards are ordinary Home Office cards, not a separate personal supply.
|
||||
|
||||
The Whistle Post has one A/D track, no porters, and no passenger slots. An Office upgrade preserves modifiers already applied to it.
|
||||
- A placed card must **connect to existing rail**: at least one neighbour must join it.
|
||||
- The **Running Track** is the row from Limit to Limit. A card placed there must carry an east–west
|
||||
through route, or it breaks the main.
|
||||
- Placing track **on a Limit sign** extends the Running Track and moves that sign outward. The sign
|
||||
is a physical card, so it moves rather than being left stranded mid-track.
|
||||
- **Nothing may be placed outside your Limits** — track, industries and, since v0.8.0.14, Modifiers
|
||||
too. Your district ends at its sign.
|
||||
- Curves and turnouts are printed left- or right-handed. A card may be turned 180° but never flipped
|
||||
over, so its 45° leg never changes diagonal.
|
||||
- A **turnout may upgrade** an existing straight, or a curve whose arc is exactly the turnout's
|
||||
diverging arc. Not if the card holds standing cars or an enhancement. Every other occupied square
|
||||
is unavailable.
|
||||
- A turnout may be **run through but not stopped on**: it is not Operational Rail, so a Move may not
|
||||
end there.
|
||||
|
||||
## Freight facilities — 27
|
||||
## Office cards
|
||||
|
||||
An industry may be placed only on a connected straight stub off the Running Track. Each facility begins with one Laborer and a three-box MEN | AT | WORK freight pipeline. Its industry track has capacity equal to its base outbound plus inbound capacity, with a minimum of one car.
|
||||
Every player begins at a **Whistle Post**, which is not drawn from the deck: one A/D track, no
|
||||
Porters, no passenger slots, and not a Control Point.
|
||||
|
||||
No Office Area may contain a duplicate industry, or both ends of a listed lockout pair.
|
||||
Office cards are **upgrades in strict sequence** — Whistle Post → Depot → Station → Terminal — and
|
||||
each upgrades the Office in place rather than replacing its card or its attached track. Modifiers
|
||||
already beside it are preserved. Each tier adds an A/D track, a Porter, and an outbound and inbound
|
||||
passenger slot; a Depot and above is a Control Point and a Passenger Facility.
|
||||
|
||||
| Card | Copies | Cars handled | Flow | Base boxes | Lockout in same Office Area |
|
||||
| --- | ---: | --- | --- | --- | --- |
|
||||
| Freight House | 6 | Boxcar | Outbound and inbound | 1 out / 1 in | Freight House; Grocer’s Warehouse |
|
||||
| Mine Tipple | 6 | Hopper | Outbound | 1 out / 0 in | Power Plant |
|
||||
| Refinery | 3 | Tank car | Outbound | 1 out / 0 in | Power Plant |
|
||||
| Power Plant | 6 | Hopper or tank car | Inbound | 0 out / 1 in | Mine Tipple; Refinery |
|
||||
| Packing Sheds | 3 | Reefer | Outbound | 1 out / 0 in | Grocer’s Warehouse |
|
||||
| Grocer’s Warehouse | 3 | Boxcar or reefer | Inbound | 0 out / 1 in | Packing Sheds; Freight House |
|
||||
An upgrade takes no placement: the Office is where it already is.
|
||||
|
||||
## Facility modifiers — 23
|
||||
## Freight facilities
|
||||
|
||||
A modifier occupies an empty square adjacent to an eligible facility. It is unique by modifier type within an Office Area. The implementation attaches it permanently to the first eligible adjacent facility found; it does not implement a per-Stage choice when one modifier touches more than one possible facility.
|
||||
An industry is placed on a connected straight **stub off the Running Track** — never on the Running
|
||||
Track itself, and never outside the Limits. A Facility carries its own rails, so placing one places
|
||||
track.
|
||||
|
||||
`+ out` adds green outbound capacity only where the host can load; `+ in` adds red inbound capacity only where the host can unload. A capacity increase at a freight facility also lengthens its industry track by the usable number of added slots. Worker increases always apply.
|
||||
Each begins with one Laborer and a three-box **MEN | AT | WORK** pipeline. No Office Area may hold a
|
||||
duplicate industry, or both ends of a lockout pair — a producer and the consumer of the same
|
||||
commodity cannot be built in one district.
|
||||
|
||||
| Modifier | Copies | Eligible host | Actual grant |
|
||||
| --- | ---: | --- | --- |
|
||||
| Waiting Area | 3 | Any Office | +1 outbound passenger slot; +1 Porter |
|
||||
| Restaurant | 2 | Any Office | +1 outbound passenger slot; +1 Porter |
|
||||
| Hotel | 1 | Any Office | +1 outbound passenger slot; +1 Porter |
|
||||
| Truck Dock | 2 | Freight House, Packing Sheds, Grocer’s Warehouse | +1 **inbound** slot; no Laborer |
|
||||
| Railroad Express Agency | 1 | Freight House | +1 outbound slot; +1 Laborer |
|
||||
| Forklifts | 2 | Freight House, Packing Sheds | +1 outbound slot; +1 Laborer |
|
||||
| Prep Plant | 1 | Mine Tipple | +1 outbound slot; +1 Laborer |
|
||||
| Coal Piles | 1 | Mine Tipple | +1 outbound slot; +1 Laborer |
|
||||
| Conveyor Belts | 1 | Mine Tipple | +1 outbound slot; +1 Laborer |
|
||||
| Pipelines | 1 | Refinery | +1 outbound slot; +1 Laborer |
|
||||
| Oil Depot | 1 | Refinery | +1 outbound slot; +1 Laborer |
|
||||
| Viscosity Breakers | 1 | Refinery | +1 outbound slot; +1 Laborer |
|
||||
| Transmission Lines | 1 | Power Plant | +1 Laborer |
|
||||
| Rotary Dumps | 1 | Power Plant | +1 Laborer |
|
||||
| Steam Turbines | 1 | Power Plant | +1 Laborer |
|
||||
| Ice House | 2 | Packing Sheds or Grocer’s Warehouse | +1 outbound slot; +1 Laborer |
|
||||
| Local Small Groceries | 1 | Grocer’s Warehouse | +1 Laborer |
|
||||
**An industry track holds four cars, like any other card.** It is *not* sized by the industry's box
|
||||
count. That distinction was a real bug: box count is how much WORK an industry can hold, not how
|
||||
much RAIL it has, and conflating the two invented a printed siding no industry card carries.
|
||||
|
||||
A bonus beside a facility that cannot use its direction is not usable and does not create a slot or lengthen the track — an outbound bonus beside an inbound-only facility, or the Truck Dock's inbound bonus beside the outbound-only Packing Sheds, which leaves that card with no effect at all. Likewise, passenger modifiers beside a Whistle Post add Porters but do not create an outbound slot until the Office becomes a Passenger Facility.
|
||||
## Facility modifiers
|
||||
|
||||
## Train cards — 22
|
||||
A Modifier sits on an empty square among the **nine spots around its host Facility** — and, since
|
||||
v0.8.0.14, **inside your Limits**, like everything else. It may not stand in the Running Track row.
|
||||
One of each kind per Office Area.
|
||||
|
||||
Playing a timetabled train card rolls the seeded D12 and places its number in the first open timetable slot at or after the result, wrapping around the 12-slot chart. It then runs at that Stage every Day. An Extra is queued and made up when a Crew Tray becomes available; v0.4.5 automatically launches Extras eastbound from the Western Division Point.
|
||||
**A Modifier adds a BOX, never room for a car.** A Truck Dock beside a Grocer's Warehouse gives it a
|
||||
second red box — somewhere for one more arriving load to be cleared to — and changes nothing about
|
||||
how many cars may be spotted there.
|
||||
|
||||
The listed consist is a maximum, not a minimum: a train may depart with fewer cars, but must not exceed the listed categories, put a car behind a caboose, or leave with the engine buried among cars. A Crew Tray holds no more than four rolling-stock cars.
|
||||
**A grant the host cannot use does nothing**, and the game says so rather than pretending. An
|
||||
outbound bonus beside an inbound-only facility, or the Truck Dock's inbound bonus beside the
|
||||
outbound-only Packing Sheds, is discarded — the latter leaving that card with no effect at all.
|
||||
Passenger modifiers beside a **Whistle Post** add Porters but create no outbound slot until the
|
||||
Office becomes a Passenger Facility; the panel reports that as DORMANT rather than claiming the
|
||||
facility "only receives", which was wrong in both directions.
|
||||
|
||||
> **Changed 2026-08-22 (Gitea#7), Jesse's call:** the coach counts on **1/2 Crack Limited** and
|
||||
> **5/6 The Sparrow** were swapped — the Limited drops from three coaches to two, the Sparrow rises
|
||||
> from two to three. This is a change to the CARDS, not a correction to this table: `Trains3.pdf` and
|
||||
> the transcription in [`rules/implications.md`](rules/implications.md) §5 still show the original
|
||||
> numbers, and are right about what the printed cards said. `src/engine/content.ts` and this table
|
||||
> carry what the game plays.
|
||||
## Train cards
|
||||
|
||||
| Train | Speed | Direction | Listed maximum consist | Implemented special rule |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1/2 Crack Limited | Fast | 1 west / 2 east | **2 coaches** | No switching; passenger work only at Terminals; expedited. |
|
||||
| 3/4 Express | Fast | 3 west / 4 east | 2 freight | May exchange at most one freight car at each grid location during its switching turn; expedited. |
|
||||
| 5/6 The Sparrow | Fast | 5 west / 6 east | **3 coaches** | No switching; expedited. |
|
||||
| 7/8 Local | Slow | 7 west / 8 east | 1 freight, 1 coach | Its coach may not be set out during switching. |
|
||||
| 9/10 Heavy Freight | Slow | 9 west / 10 east | 3 freight, 1 caboose | — |
|
||||
| 11/12 Drag Freight | Slow | 11 west / 12 east | 2 freight, 1 caboose | — |
|
||||
| X13 Appleseed Extra | Slow | Player choice printed; v0.4.5 launches east | 3 empty freight, 1 caboose | May drop cars but cannot pick up. The engine enforces no pickup, but does not enforce the printed empties-only consist restriction at make-up. |
|
||||
| X14 Fruit Growers Express | Fast | Player choice printed; v0.4.5 launches east | 2 reefers, 1 caboose | Expedited. The code enforces reefers-only; the printed extra loaded-reefer pickup is not a separate rule. |
|
||||
| X15 Yard Xfer | Slow | Player choice printed; v0.4.5 launches east | 2 freight, 1 caboose | — |
|
||||
| X16 Light Engine Move | Fast | Player choice printed; v0.4.5 launches east | No cars | No switching. |
|
||||
| X17 Campaign Train | Fast | Player choice printed; v0.4.5 launches east | 1 coach | No switching. First Office arrival lays over for speeches; later arrivals are expedited. |
|
||||
| X18 Circus Train | Slow | Player choice printed; v0.4.5 launches east | 2 freight, 1 coach, 1 caboose | No switching. The first Mainline Phase in which it remains stopped awards its current Office’s player 1 Revenue. |
|
||||
| X19 Military Train | Slow | Player choice printed; v0.4.5 launches east | 1 freight, 2 coaches | No switching; no passenger work; expedited. |
|
||||
| X20 Director’s Private Car | Slow | Player choice printed; v0.4.5 launches east | 2 freight, 1 coach | No passenger work. |
|
||||
| X21 Freight Extra | Slow | Player choice printed; v0.4.5 launches east | 3 freight, 1 caboose | — |
|
||||
| X22 Pee-Dee | Slow | Player choice printed; v0.4.5 launches east | 1 caboose | May pick up empty cars only. |
|
||||
Playing a **timetabled** train rolls the seeded D12 and puts its number in the first open timetable
|
||||
slot at or after the result, wrapping around the 12-slot chart. It then runs at that Stage **every
|
||||
Day**.
|
||||
|
||||
## Enhancements — 18
|
||||
Playing an **Extra** queues it; it is made up when a Crew Tray comes free, and **the player who
|
||||
played the card chooses where it starts and loads it as they choose** (§7). Where it may start is a
|
||||
house rule — `divisionPointsOnly`, `ownOffice`, or `anyOffice` (the default) — and the Interchange is
|
||||
also available, because it is the one Mainline card with a yard. An Extra runs once and its card
|
||||
goes to the Salvage Yard.
|
||||
|
||||
| Card | Copies | Placement | v0.4.5 behavior |
|
||||
| --- | ---: | --- | --- |
|
||||
| Interlocking | 2 | Bare Running Track straight | When the Office is full, an inbound train is held at the Limits instead of colliding. |
|
||||
| Facing Point Locks | 2 | Any card; requires an Interlocking somewhere in the district | Blocks Derail. Derail is unavailable in v0.4.5, so this remains dormant. |
|
||||
| Yard Office | 1 | Bare Secondary Track straight | A coachless inbound train is diverted to this track instead of occupying an A/D track. |
|
||||
| Small Yard | 1 | Bare Secondary Track straight | A train may spend one switching Move here to reorder its entire consist and put the engine at the nose. |
|
||||
| Water Column | 2 | Bare Running Track straight | Removes a Watertower. Watertower cards are unavailable, so this remains dormant. |
|
||||
| Overpass | 1 | Any card | No implemented effect. |
|
||||
| Telegraph | 3 | Bare Running Track straight | Once per Day, the Superintendent may add 4 to an opposing train’s number when that makes a facing clearance safe. |
|
||||
| Telephone | 2 | On a Telegraph | Same dispatch mechanism, +8 once per Day. |
|
||||
| Radio | 2 | On a Telephone | Same dispatch mechanism, +12 once per Day. |
|
||||
| ABS Signals | 2 | Any Mainline card | Prevents rear-end collisions and holds a following train short. |
|
||||
> The v0.4.5 behaviour of launching every Extra eastbound from the Western Division Point was
|
||||
> replaced in v0.6.2. The number no longer decides an Extra's direction; the start does.
|
||||
|
||||
An enhancement requiring a bare straight cannot share that straight with another such enhancement. Telephone and Radio are the explicit stackable chain.
|
||||
The listed consist is a **maximum, not a minimum**. A train may depart with fewer cars, but not with
|
||||
more, not of the wrong category, not with a car behind the caboose, and not with the engine buried
|
||||
among its own cars. A Crew Tray holds four pieces, and a caboose counts toward the four.
|
||||
|
||||
## Maneuvers — 6
|
||||
A train made up short of what its card calls for is reported as such, with what it wanted and why
|
||||
the yard could not supply it — see Rules §4.4.
|
||||
|
||||
| Card | Copies | Behavior |
|
||||
| --- | ---: | --- |
|
||||
| Red Flags | 5 | May be played at any time on a train stopped on a Mainline card. An approaching following train is held instead of moving into it. |
|
||||
| Flying Switch | 1 | During the owner’s switching option, spend one Move to roll a tail-end cut into a track-connected freight facility without moving the engine there. |
|
||||
| Poling | 0 | Catalogued but not dealt; no rule is implemented. |
|
||||
**Per-train consists and printed rules: [`rules/as-built.md`](rules/as-built.md) § Trains.** It
|
||||
carries the `empties only`, `reefers only`, `drop only` and `pick up empties only` restrictions,
|
||||
every one of which the engine enforces.
|
||||
|
||||
## Catalogued but not dealt — 22 opponent-directed cards
|
||||
## Enhancements, Mainline modifiers and Maneuvers
|
||||
|
||||
### Space-use cards — 12
|
||||
- **Enhancements** are played into your district and change what a square does — the **Small Yard**
|
||||
(re-order a consist for one Move), **Interlocking**, **Yard Office**, **ABS Signals** and the rest.
|
||||
`as-built.md` marks each one `live`, `dormantSolo` or `unbuilt`, which is the part only the
|
||||
implementation knows.
|
||||
- **Mainline modifiers** are played onto a Mainline card: the Heavy Grade helpers and Realignment.
|
||||
See [Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md).
|
||||
- **Maneuvers** are held and spent: **Red Flags** and **Flying Switch** have their own actions.
|
||||
Poling is catalogued but its effect is recorded as "TBD in the source", so there is nothing to
|
||||
implement.
|
||||
|
||||
Bean House (1), Flop House (1), Watertower (1), Hobo Jungle (1), Section House (1), City Blocks (4), Engine Shops (1), Tenderloin District (1), and Engineer Cemetery (1) are excluded. Their source descriptions say they consume table space; Hobo Jungle additionally describes vandalism looting a passing boxcar. No placement or effect is available in v0.4.5.
|
||||
## Opponent-directed cards — not dealt
|
||||
|
||||
### Action cards — 10
|
||||
|
||||
Derail (2), Broken Coupler (1), Railroad Crossing (1), Per Diem Inventory (1), Demurrage Charge (1), Customer Complaints (1), Vandalism (1), Hotbox (1), and Outlawed (1) are excluded. Their printed target/effect text is catalogued in the code, but `card.play` rejects the categories as not implemented. Consequently, no card can currently be played on another player.
|
||||
|
||||
There are also two **Facing Point Locks** listed among Mainline modifiers. They are treated as the same grid enhancement as Facing Point Locks above, require an Interlocking, and are included in the active 213-card total.
|
||||
The **Action** and **Space-use** categories are opponent-directed and are **excluded from every
|
||||
dealt deck**, because their play rules are not implemented. They are catalogued in
|
||||
`as-built.md` so the composition is on record, and `check` refuses to play one. This is deliberate:
|
||||
silently accepting them would make a card look playable while doing nothing.
|
||||
|
||||
@@ -1,21 +1,62 @@
|
||||
# Station Master — Mainline Deck
|
||||
|
||||
**Rules implementation reference: v0.4.5**
|
||||
**Scope:** the tarot-sized Mainline cards placed between Offices. This is an implementation reference, not a transcription of earlier prototype rules.
|
||||
**Describes the game as built at v0.8.0.16** (2026-09-20). Previously stamped "v0.4.5", the
|
||||
prototype rules edition these references were first written against; the filename keeps that stamp
|
||||
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name.
|
||||
|
||||
**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.
|
||||
|
||||
> **Per-card numbers live in [`rules/as-built.md`](rules/as-built.md)**, which is GENERATED from
|
||||
> `src/engine/content.ts` and checked by `test/card-reference.test.ts`, so it cannot disagree with
|
||||
> the game. This document explains how the deck is used; that one is the table of record. Where the
|
||||
> two ever differ, as-built is right.
|
||||
|
||||
## How Mainline cards work
|
||||
|
||||
At setup the game places one randomly selected Mainline card between each neighbouring pair of Offices and one beyond each end Office, between it and a Division Point. Thus, a game with *N* players has *N + 1* Mainline cards. The implementation selects types with replacement; it does not deal them from a shuffled finite deck.
|
||||
At setup the game lays one Mainline card between each neighbouring pair of Offices and one beyond
|
||||
each end Office, between it and a Division Point — so a game with *N* players uses **N + 1** cards.
|
||||
They are **dealt from a finite deck without replacement** (since v0.6.2), so no Division can hold
|
||||
two of a card printed once. The two Division Points are the fixed ends of the Division and are not
|
||||
in the deck: `buildDivision` lays them itself.
|
||||
|
||||
A train crossing a Mainline card spends Stages, rather than moving through its printed cells. A 60 mph card costs one Stage and a 30 mph card costs two. A Slow train adds one Stage to every crossing. On Hilly terrain, any train carrying at least one coach uses the passenger rate; a train carrying no coach uses the freight rate. No crossing can take less than one Stage.
|
||||
A card does not belong to either neighbouring Office. It is shared Division.
|
||||
|
||||
The card does not itself determine which player owns the adjacent Office. It is part of the shared Division.
|
||||
### Crossing time is REGIONS, not mph
|
||||
|
||||
## Physical card inventory in `Mainline Cards.pdf`
|
||||
A card is divided into **regions**, and a train advances **one region per Stage**. Crossing time is
|
||||
therefore `regions − startRegion`, and nothing else. **The printed mph is scenery.**
|
||||
|
||||
The supplied PDF has **ten** tarot-sized terrain cards. Plains appears twice; the other terrain types appear once each. It also includes two Division Point cards.
|
||||
This is the part most likely to be remembered wrong, because it used to work the other way: mph set
|
||||
the cost and a Slow train added a Stage to *every* card. It does not. Four things move a train's
|
||||
start region and nothing else does:
|
||||
|
||||
| Physical card | Copies in the PDF |
|
||||
1. **The card's own back region.** The Uncontrolled Siding and the Interchange print a back region
|
||||
that is not part of the road, so a train running through begins past it.
|
||||
2. **A card that prints a Fast and a Slow start — and only Hilly does.** A fast train starts one
|
||||
region along and crosses in 1 Stage; a slow one takes 2. **No other card reads a train's
|
||||
Fast/Slow rating at all.**
|
||||
3. **The permanent Heavy Grade modifiers**, each moving a train one region up the hill.
|
||||
4. **Occupancy**: arriving to find the card occupied can put a train in the siding, a region behind.
|
||||
|
||||
No crossing ever takes less than one Stage.
|
||||
|
||||
### Traffic
|
||||
|
||||
Only **Double Track** lets two trains stand on one card, so a following train is not held behind a
|
||||
slower one. Every other card holds one train at a time. The **Uncontrolled Siding** is not a passing
|
||||
card: arriving to find a train already there puts you in the siding a region behind it — you do not
|
||||
run into it, and it costs you the extra Stage instead.
|
||||
|
||||
Whether a following train may enter an occupied card at all is the Superintendent's ruling (§8.1,
|
||||
Rules §4.5). Getting it wrong is what causes collisions.
|
||||
|
||||
## The deck
|
||||
|
||||
Ten drawable cards — Plains twice, the other eight once each — plus the two Division Point cards,
|
||||
which are not drawn.
|
||||
|
||||
| Card | Copies |
|
||||
| --- | ---: |
|
||||
| Plains | 2 |
|
||||
| Curves | 1 |
|
||||
@@ -26,60 +67,62 @@ The supplied PDF has **ten** tarot-sized terrain cards. Plains appears twice; th
|
||||
| Tunnel | 1 |
|
||||
| Trestle | 1 |
|
||||
| Interchange | 1 |
|
||||
| East Division Point | 1 |
|
||||
| West Division Point | 1 |
|
||||
| East / West Division Point | 1 each, not dealt |
|
||||
|
||||
The PDF art labels this card “Yard”; this reference uses the implementation’s correct name, **Interchange**, to distinguish it from the Division Yard, Classification Yard, Yard Office, and Small Yard.
|
||||
The PDF art labels the Interchange "Yard". This reference uses **Interchange** throughout, to keep
|
||||
it apart from the Division Yard, the Classification Yard, the Yard Office and the Small Yard — five
|
||||
different things.
|
||||
|
||||
## Implemented Mainline card reference
|
||||
**Region counts and entry points per card are in [`rules/as-built.md`](rules/as-built.md).**
|
||||
|
||||
| Card | Implemented crossing time and feature |
|
||||
| --- | --- |
|
||||
| Plains | 60 mph; one Stage for Fast, two for Slow. The implementation has one Plains *type* rather than the PDF’s two physical copies. |
|
||||
| Curves | 30 mph; two Stages for Fast, three for Slow. |
|
||||
| Hilly | Passenger train: 60 mph. Freight-only train: 30 mph. Add one Stage if Slow. |
|
||||
| Heavy Grade | Starts at 30 mph; two Stages for Fast, three for Slow. The card prints “Player sets orientation”; **the game deliberately overrides that and rolls the uphill direction from the seed** — settled in v0.5.0 and re-confirmed 2026-08-23, see the note below. Grade modifiers can reduce the time, to a minimum of one Stage. |
|
||||
| Double Track | 60 mph. Printed capability: trains may pass. The traffic-resolution rule is in Rules §4.5. |
|
||||
| Uncontrolled Siding | 60 mph. Printed capability: trains may pass. The traffic-resolution rule is in Rules §4.5. |
|
||||
| Tunnel | 30 mph. |
|
||||
| Trestle | 60 mph. |
|
||||
| Interchange | 60 mph. A train may be reordered there only through the card’s printed “sort cars” concept; the current engine does **not** provide a Mainline sorting action for it. |
|
||||
## The Interchange, and what it does NOT do
|
||||
|
||||
### PDF/code mismatch — CORRECTED
|
||||
The Interchange prints a car-sorting capability. **It is not implemented, and never has been.** The
|
||||
one thing the card's `sortsCars` flag actually gates is that an **Extra Train may be made up and
|
||||
started here** — it is the Mainline card with a yard, which is why §7 allows it (v0.6.2). An Extra
|
||||
starting here begins in the back region and takes the extra Stage.
|
||||
|
||||
**Was:** `src/engine/content.ts` defines nine `MAINLINE_PROFILES` types: one Plains entry plus the eight other terrain types above. `setup.ts` selected uniformly from that nine-type list, **with replacement**. The second Plains card shown in `Mainline Cards.pdf` was therefore not represented as a duplicate card or as extra Plains weight in setup — and, worse than a weighting error, a Division could be dealt two Interchanges, two Tunnels or two Trestles, none of which the deck contains.
|
||||
Re-ordering a consist is done at a **Small Yard** in an Office Area, for one switching Move. See
|
||||
Rules §4.3.
|
||||
|
||||
**Now:** `MAINLINE_DECK` in `content.ts` is the inventory table above — ten drawable cards, Plains twice and the other eight once each — and `buildDivision` deals from it without replacement. The two Division Point cards are not in that deck: they are the fixed ends of the Division, laid by `buildDivision` itself rather than drawn.
|
||||
|
||||
The Interchange is what forced the correction. §7 lets an Extra be started at the Interchange "if one is on the board" (see `docs/rules/implications.md`, §7), which only reads as a rule if the board can hold at most one.
|
||||
|
||||
The executable state represents East and West Division Points as fixed end nodes, not as card records. They are functionally present at the ends of the Division, but are not represented as the two PDF cards in the deck/state model.
|
||||
> **Corrected 2026-09-20.** Until this pass the card said "Cars may be sorted into any new order
|
||||
> here" — on the board, in the tooltip a player reads, and in the generated reference. A card
|
||||
> advertising an action the game will not offer sends a player hunting for a button that does not
|
||||
> exist. The description now says what the card does.
|
||||
|
||||
## Heavy Grade modifiers
|
||||
|
||||
These cards come from the Home Office deck and are played onto a Mainline card during a player’s Draw option.
|
||||
Home Office cards, played onto a Mainline card during a player's Draw option.
|
||||
|
||||
| Card | Copies | Placement and actual effect |
|
||||
| --- | ---: | --- |
|
||||
| Brakeman | 1 | Heavy Grade only. Reduces a downhill crossing by one Stage. |
|
||||
| Airbrakes | 1 | Heavy Grade only, and Brakeman must already be on that card. Reduces a downhill crossing by one additional Stage. |
|
||||
| Helpers | 1 | Heavy Grade only. Reduces an uphill crossing by one Stage. |
|
||||
| Realignment | 2 | May be played only onto an unoccupied Mainline card. Changes Plains → Double Track, Curves → Plains, Uncontrolled Siding → Double Track, or Trestle → Uncontrolled Siding. It cannot be played on any other card type. |
|
||||
| Card | Placement and effect |
|
||||
| --- | --- |
|
||||
| Brakeman | Heavy Grade only. A **downhill** train starts one region further on. |
|
||||
| Airbrakes | Heavy Grade only, and **Brakeman must already be on that card**. A downhill train starts one region further again. |
|
||||
| Helpers | Heavy Grade only. An **uphill** train starts one region further on. |
|
||||
| Realignment | Only onto an **unoccupied** Mainline card. Plains → Double Track, Curves → Plains, Uncontrolled Siding → Double Track, Trestle → Uncontrolled Siding. No other card may be realigned. |
|
||||
|
||||
For the grade cards, “uphill” should be the direction selected by the player when the card is placed. In v0.4.5 it is the seeded `gradeUp` direction because setup has no player-choice step.
|
||||
|
||||
## What is not implemented
|
||||
|
||||
- There is no player choice or physical placement interaction for Mainline cards; setup deals them automatically from the seeded random stream. There **is** a finite draw pile as of v0.6.2 — the deck above, dealt without replacement, so no Division can hold two of a card printed once.
|
||||
- Interchange is catalogued as a “sort cars” card, but v0.4.5 has no operation that reorders a train on the Interchange. The Small Yard in an Office Area is the implemented sorting mechanism.
|
||||
- Interchange now has one player-facing use: an Extra Train may be **started** there, made up in its yard and highballing onto the Mainline when the Subdivision is clear (§7, v0.6.2). Car sorting remains unimplemented.
|
||||
A Heavy Grade is 3 regions, so it is 3 Stages to climb and 3 to run down before help. Modifiers
|
||||
never reduce a crossing below one Stage.
|
||||
|
||||
## Heavy Grade orientation is settled, not missing
|
||||
|
||||
Heavy Grade orientation is rolled from the seed rather than chosen by a player. **This is a decision, not a gap, and it is not awaiting a player-selection step.**
|
||||
Heavy Grade orientation is **rolled from the seed**, not chosen by a player. **This is a decision,
|
||||
not a gap.**
|
||||
|
||||
The card prints “(Up)” and “Player sets orientation”, which assumes the card has an owner. This one does not: `buildDivision` lays the Division as `DP · Mainline · Office · Mainline · … · DP`, so a Heavy Grade always sits **between two districts**, or beyond an end Division Point next to one — never inside a single player’s own district.
|
||||
The card prints "(Up)" and "Player sets orientation", which assumes the card has an owner. This one
|
||||
does not: the Division is laid `DP · Mainline · Office · Mainline · … · DP`, so a Heavy Grade always
|
||||
sits **between two districts**, or beyond an end Division Point beside one — never inside a single
|
||||
player's district.
|
||||
|
||||
Orientation is not cosmetic: Brakeman and Airbrakes each take a Stage off a train running **downhill**, Helpers takes one off a train running **uphill**, and odd-numbered trains run west while even run east. Turning the card around therefore decides which of those modifier cards are worth anything and which direction of traffic is favoured — permanently, for the whole game. Handing that to one of the two neighbours advantages them over the other, and no player has a fair claim to it.
|
||||
Orientation is not cosmetic. Brakeman and Airbrakes help a train running **downhill**, Helpers helps
|
||||
one running **uphill**, and odd-numbered trains run west while even run east. Turning the card
|
||||
around decides which modifiers are worth anything and which direction of traffic is favoured, for
|
||||
the whole game. Handing that to one of two neighbours advantages them over the other, and neither
|
||||
has a fair claim to it.
|
||||
|
||||
**Re-opened and closed again on 2026-08-23**, when the option of giving the choice to the Superintendent was considered and rejected. Jesse’s call: v0.5.0’s ruling stands. Rolling from the seed is deterministic, roughly even (51/49 east/west over 400 games), identical for solitaire and multiplayer, and keeps setup non-interactive — the game has no setup phase, so the question would have to interrupt play before the first Local Operations, in the minority of games that deal the card at all (20% at one player, rising to 50% at four).
|
||||
**Re-opened and closed again on 2026-08-23**, when giving the choice to the Superintendent was
|
||||
considered and rejected. Jesse's call: v0.5.0's ruling stands. Rolling from the seed is
|
||||
deterministic, roughly even (51/49 east/west over 400 games), identical in solitaire and
|
||||
multiplayer, and keeps setup non-interactive — the game has no setup phase, so the question would
|
||||
have to interrupt play before the first Local Operations, in the minority of games that deal the
|
||||
card at all (20% at one player, rising to 50% at four).
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# Station Master — Quickstart
|
||||
|
||||
**For a tester who has never played. Describes the game as built at v0.8.0.16** (2026-09-20).
|
||||
|
||||
Read this once before you sit down. It is about twenty minutes of reading and will save you an hour
|
||||
of confusion. The deeper references are listed at the end.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the game is
|
||||
|
||||
You are a railroad **Office** — a town — on a shared east–west main line called the **Division**.
|
||||
Everyone's Office sits in a row along it, west to east, with **Mainline cards** between them.
|
||||
|
||||
Two jobs run at once:
|
||||
|
||||
**Your own job, in your district.** Build track. Build industries and a passenger platform. Shunt
|
||||
cars around with a switching crew to get the right car to the right place, so that freight can be
|
||||
loaded and unloaded and passengers can get on and off. Every one of those completed pieces of work
|
||||
pays **Revenue**, which is the score.
|
||||
|
||||
**The shared job, out on the Division.** Scheduled trains run across everybody's territory on a
|
||||
timetable. They arrive at your Office, and you work them. When two trains want the same stretch of
|
||||
track, the player wearing the **Superintendent's Fedora** rules on whether the second may follow the
|
||||
first. Rule wrong and they collide, which costs 5 Revenue and counts against a limit that can end
|
||||
the game.
|
||||
|
||||
The tension the game is built around: **the useful work is local and slow, and the trains are shared
|
||||
and do not wait.**
|
||||
|
||||
---
|
||||
|
||||
## 2. How you win
|
||||
|
||||
The game runs a set number of **Days** — five by default. Each Day is **12 Stages**, which you can
|
||||
think of as two-hour clock periods from midnight.
|
||||
|
||||
At the end of the last Day:
|
||||
|
||||
1. **The table's combined Revenue is checked first**, against a floor of **3 × players × Days**. At
|
||||
three players over five Days that is 45. **Miss it and everybody loses**, however well you
|
||||
personally did. This is the number to watch.
|
||||
2. **Co-op:** meeting the floor is the win, together.
|
||||
3. **Competitive:** meeting the floor puts the game on, and the **highest individual Revenue** wins.
|
||||
4. **Solitaire:** meet the floor by yourself.
|
||||
|
||||
**Collisions can end it early and badly.** Breaching the collision limit stops play at once in a
|
||||
collective loss — the railroad has been declared unsafe. Default limits are 3 in one Day and 5 in
|
||||
the game.
|
||||
|
||||
**Falling short offers another Day** rather than just ending, so a game that misses the floor can be
|
||||
played on. A game stopped by collisions cannot.
|
||||
|
||||
### Where Revenue comes from
|
||||
|
||||
| Work | Pays |
|
||||
| --- | --- |
|
||||
| A passenger boarding at your platform | 1 |
|
||||
| A passenger getting off at your platform | 1 |
|
||||
| Completing an outbound freight load | 1 |
|
||||
| Completing an inbound freight unload | 1 |
|
||||
| A train completing its run across the whole Division | 0 by default, to every player |
|
||||
|
||||
Those rates are set when the game is dealt and can be changed. The default means **your score comes
|
||||
almost entirely from working cars in your own district** — trains passing through pay nothing by
|
||||
themselves.
|
||||
|
||||
---
|
||||
|
||||
## 3. The shape of a Stage
|
||||
|
||||
Every Stage runs five phases in this order. Only two of them are your turn.
|
||||
|
||||
| # | Phase | What happens |
|
||||
| --- | --- | --- |
|
||||
| 1 | **Local Operations** | **Your turn.** Choose ONE of three things (below). Each player in turn, starting with the Superintendent and working eastward. |
|
||||
| 2 | **New Train** | Trains due this Stage are made up from the Division Yard. The table takes turns adding one car each. |
|
||||
| 3 | **Mainline** | Automatic. Trains move, lowest number first. This is where clearance rulings and collisions happen. |
|
||||
| 4 | **Cargo** | **Your turn.** Your Laborers and Porters do their work — the loading, unloading, boarding and detraining that actually pays. |
|
||||
| 5 | **Supervisor Shift** | Automatic. Laborers and Porters refresh; expedited trains depart. Every third Stage the Fedora passes. |
|
||||
|
||||
### Local Operations: you get exactly one of these
|
||||
|
||||
- **Switch** — take a crew and shunt. **Six Moves** (five at night under Reduced Visibility). This is
|
||||
how cars physically get from the yard to an industry and back. Cars you run over are coupled up
|
||||
automatically, so plan the route.
|
||||
- **Draw** — take a card, then play and/or discard. This is how your district gets built: track,
|
||||
industries, Office upgrades, modifiers, train cards.
|
||||
- **Freight Agent** — one clerical act: stock a green outbound box, clear a red inbound box, or
|
||||
unjam a facility.
|
||||
|
||||
**You cannot do two of them in one Stage.** Choosing is most of the game. A Stage spent drawing is a
|
||||
Stage not spent switching.
|
||||
|
||||
> Passengers are **not** the Freight Agent's job. They board and get off in the **Cargo** phase, with
|
||||
> a **Porter**. Reaching for the wrong role and finding nothing there is the single most common new
|
||||
> player mistake, so each role now says on screen what it is for.
|
||||
|
||||
---
|
||||
|
||||
## 4. The screen
|
||||
|
||||
Left column, top to bottom:
|
||||
|
||||
- **The Division — west to east.** The shared main line: every Office and the Mainline cards between
|
||||
them, with trains drawn where they are. **West is always on the left**, and a train's engine is
|
||||
drawn as an arrow (◀ or ▶) showing which way it points.
|
||||
- **Your Office Area.** Your own grid of track cards. This is where switching happens. It folds away
|
||||
outside the phases that change it, unless you pin it open.
|
||||
- **History.** What has happened, most recent first.
|
||||
|
||||
Right column:
|
||||
|
||||
- **Your Move** — the buttons. If it is not your turn this is empty, and the board tells you who is
|
||||
acting.
|
||||
- **Cards in My Hand**, and the **Department decks** — three face-up discard piles anyone may draw
|
||||
the top of.
|
||||
- **The Yards.** The **Division Yard** is the live supply of cars. The **Classification Yard** is
|
||||
where used cars go, and it comes back **only when the Division Yard runs completely bare**.
|
||||
- **Timetable** — who is due out and when.
|
||||
- **Blocked — why nothing is moving.** *Read this panel.* When something will not work, this is
|
||||
where the game explains why, in rules terms.
|
||||
- **Facilities** — the load pipelines at each industry.
|
||||
|
||||
Across the top: your Revenue, the target, the Day and Stage, collisions, and the game code.
|
||||
|
||||
**When other players are acting**, their turns are replayed on your board a step at a time rather
|
||||
than arriving already rearranged, with a `[N behind]` counter, **Pause** and **Skip**. Your own moves
|
||||
are not replayed at you — they are already on your screen.
|
||||
|
||||
---
|
||||
|
||||
## 5. Your first twenty minutes
|
||||
|
||||
Play a **solitaire** game first. It needs no server and nobody else, and it is the same rules.
|
||||
|
||||
1. Open the site, choose **Play solitaire**, accept the defaults, **Deal**.
|
||||
2. **Stage 1 — Draw.** You start on a **Whistle Post** with almost nothing. Play a track card or two
|
||||
to extend your Running Track, and get a **Depot** down as soon as one appears: it is the upgrade
|
||||
that makes you a passenger facility and gives you a second A/D track.
|
||||
3. **Build one industry** on a stub off the main — not on the Running Track itself, which the game
|
||||
will not allow.
|
||||
4. **Play a train card** when you get one. It rolls onto the timetable and then runs at that Stage
|
||||
**every Day**.
|
||||
5. **When a train arrives at your Office**, switch cars to it or from it, and do the paying work in
|
||||
the **Cargo** phase.
|
||||
6. Watch the **Blocked** panel whenever you are stuck. It is usually one missing thing: no empty car
|
||||
spotted, no loaded car in the yard, a full box, a locked industry track.
|
||||
|
||||
Then play a Day or two of multiplayer with bots filling the other seats, to see the table take turns.
|
||||
|
||||
---
|
||||
|
||||
## 6. Things that surprise new players
|
||||
|
||||
- **A turnout cannot be stopped on.** You may run through it; you may not end a Move there.
|
||||
- **Coupling is mandatory.** Run over a standing car and you take it, whether or not you wanted it.
|
||||
- **An industry track locks while a load is on its MEN | AT | WORK boxes.** No train can enter,
|
||||
cross or work there until it clears.
|
||||
- **A modifier adds a BOX, never room for a car.** It raises how much work an industry can hold, not
|
||||
how much rail it has.
|
||||
- **Nothing may be built outside your Limits** — track, industries and modifiers alike. Your district
|
||||
ends at its sign, and the sign moves outward as your Running Track grows.
|
||||
- **A train must be made up to leave.** Engine at one end; if it has a caboose, the caboose at the
|
||||
far end. A train shunted out of shape sits at your Office until you fix it — a **Small Yard** will
|
||||
re-order a consist for one Move, and each option tells you whether the result can leave.
|
||||
- **Passengers may dry up completely.** Coaches move one way — boarding sends the emptied coach to
|
||||
the Classification Yard, and it only comes back when the Division Yard is bare, which may never
|
||||
happen. When it does, the yard panel warns you and passenger trains are made up empty. **This is
|
||||
the rules working as designed**, not a bug; report how it felt, not that it happened.
|
||||
- **Expedited trains leave the same Stage they arrived**, after the Cargo phase. Ordinary ones wait.
|
||||
|
||||
---
|
||||
|
||||
## 7. What to report
|
||||
|
||||
Most useful, in order:
|
||||
|
||||
1. **What you expected versus what happened**, with the Day and Stage. "Day 2 Stage 9, train 5 had
|
||||
no coaches" is worth more than "passengers seem broken".
|
||||
2. **Save the game** (the **Save replay** button) and send the file. A save is the seed and the moves
|
||||
made, so it replays exactly and the bug can be looked at directly.
|
||||
3. **Anything the screen did not explain.** If you had to guess a rule, that is a finding even when
|
||||
the game was right.
|
||||
4. **Anything you went looking for and could not find.**
|
||||
|
||||
Bugs go to the tracker; anything unclear in this guide is also worth saying.
|
||||
|
||||
---
|
||||
|
||||
## 8. Where to read more
|
||||
|
||||
| For | Read |
|
||||
| --- | --- |
|
||||
| The rules in full, with the FAQ | [Rules](StationMaster-Rules-v0.4.5.md) |
|
||||
| Every card, generated from the code | [`rules/as-built.md`](rules/as-built.md) |
|
||||
| How the Home Office deck is dealt and played | [Home deck](StationMaster-Home-Deck-v0.4.5.md) |
|
||||
| The Mainline cards and what they do to a train | [Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md) |
|
||||
| Rolling stock, yards, trays, the Fedora | [Components](StationMaster-Components-v0.4.5.md) |
|
||||
@@ -1,7 +1,15 @@
|
||||
# Station Master — Rules
|
||||
|
||||
**First-draft rules reference for v0.4.5**
|
||||
**Authority:** observed v0.4.5 code paths and tests. Where a card face, prototype document, and executable behavior differ, this document reports executable behavior and marks unimplemented material.
|
||||
**Describes the game as built at v0.8.0.16** (2026-09-20). Previously stamped "v0.4.5", the
|
||||
prototype rules edition this reference was first written against; the filename keeps that stamp
|
||||
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name.
|
||||
|
||||
**Authority:** observed code paths and tests. Where a card face, a prototype document and executable
|
||||
behaviour differ, this document reports **executable behaviour** and marks unimplemented material.
|
||||
Per-card numbers are not repeated here — [`rules/as-built.md`](rules/as-built.md) is generated from
|
||||
`src/engine/content.ts` and is the table of record.
|
||||
|
||||
**New to the game? Start with the [Quickstart](StationMaster-Quickstart.md).**
|
||||
|
||||
## 1. Overview and background
|
||||
|
||||
@@ -19,11 +27,14 @@ This book is divided as follows:
|
||||
6. the implemented multiplayer/engine status; and
|
||||
7. FAQs and implementation limits.
|
||||
|
||||
The companion references are [Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md), [Home deck](StationMaster-Home-Deck-v0.4.5.md), and [components](StationMaster-Components-v0.4.5.md).
|
||||
The companion references are the [Quickstart](StationMaster-Quickstart.md) for a new player,
|
||||
[Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md), [Home deck](StationMaster-Home-Deck-v0.4.5.md),
|
||||
[components](StationMaster-Components-v0.4.5.md), and the generated per-card table
|
||||
[`rules/as-built.md`](rules/as-built.md).
|
||||
|
||||
## 2. Definitions
|
||||
|
||||
| Term | Meaning in v0.4.5 |
|
||||
| Term | Meaning |
|
||||
| --- | --- |
|
||||
| A/D track | An Office arrival/departure capacity. A Whistle Post has 1; Depot, Station, and Terminal have 2, 3, and 4. |
|
||||
| Card location | A square in an Office Area grid. A train may end a switching Move only on Operational Rail. |
|
||||
@@ -43,7 +54,7 @@ The companion references are [Mainline deck](StationMaster-Mainline-Deck-v0.4.5.
|
||||
| Operational Rail | A card on which a train may finish a Move. Turnouts are pass-through only; a locked industry is not usable at all. |
|
||||
| Running Track | The east–west track between an Office Area’s Limits, including its Office. |
|
||||
| Secondary Track | All local rail inside the Limits that is not Running Track. |
|
||||
| Stage | One of twelve turns in a Day. Its phases are Local Operations, New Train, Mainline, Load/Unload, and Shift Change. |
|
||||
| Stage | One of twelve turns in a Day. Its phases are Local Operations, New Train, Mainline, Load/Unload and Shift Change. **On screen the last two are labelled "Cargo" and "Supervisor Shift"** — same phases, the names the interface uses. |
|
||||
| Subdivision | Mainline between Division Points or Control Points. Clearance checks look through the whole next Subdivision. |
|
||||
| Superintendent | The player with the Fedora. The role decides same-direction clearances and rotates every three Stages. |
|
||||
| Timetabled train | A numbered train card scheduled to one of the 12 Stage slots, then due at that slot each Day. Odd numbers go west; even numbers go east. |
|
||||
@@ -52,11 +63,18 @@ The companion references are [Mainline deck](StationMaster-Mainline-Deck-v0.4.5.
|
||||
|
||||
### 3.1 Starting a new game in the shipped client
|
||||
|
||||
The v0.4.5 browser page creates a **one-player solitaire Standard game**. Select **New game**, then choose:
|
||||
The front page has three doors: **Play multiplayer**, **Play solitaire**, and **Browse replays**.
|
||||
|
||||
**Solitaire** runs entirely in your own browser and needs nothing from the server. Select **New
|
||||
game**, then choose:
|
||||
|
||||
1. a numeric seed, or leave it blank for a fresh browser-generated seed;
|
||||
2. a starting hand;
|
||||
3. passenger, freight, and train-transit Revenue rates.
|
||||
3. passenger, freight, and train-transit Revenue rates;
|
||||
4. the number of Days, and the optional rules.
|
||||
|
||||
**Multiplayer** goes to the lobby — see §3.5. It needs the server, because the game is authoritative
|
||||
there rather than in any one browser.
|
||||
|
||||
The browser writes those choices into the URL. A particular game is defined by the **seed plus these house rules**, not the seed alone.
|
||||
|
||||
@@ -87,7 +105,7 @@ In a multi-player engine game, every player receives two seeded D12 rolls:
|
||||
- The **division roll** orders seats from low west to high east; equal results put the lower player index farther east.
|
||||
- The **Superintendent roll** gives the initial Fedora to the first player tied for highest.
|
||||
|
||||
The opening deal starts at the Superintendent’s seat and proceeds left (eastward in the engine’s seat ordering).
|
||||
The opening deal starts at the Superintendent’s seat and proceeds **eastward** — increasing seat index, which is how the Division map draws the table.
|
||||
|
||||
### 3.3 Saving, resuming, and replaying solitaire
|
||||
|
||||
@@ -97,25 +115,61 @@ The browser also stores the current local game and resumes it automatically when
|
||||
|
||||
**Undo is solitaire-only.** It removes the final accepted intent and rebuilds the game from the earlier history. Random outcomes are not rerolled: replaying the same action consumes the same seeded result. Undo can therefore change the player’s decision after seeing an outcome, but cannot fish for a different timetable die roll.
|
||||
|
||||
### 3.4 Engine game modes and endings
|
||||
### 3.4 Game modes and endings
|
||||
|
||||
The engine defines Solitaire, Competitive, and Co-op modes. The browser exposes only Solitaire.
|
||||
Three modes: **Solitaire**, **Competitive** and **Co-op**. All three are playable.
|
||||
|
||||
| Length | Target | Days |
|
||||
| --- | ---: | ---: |
|
||||
| Short | 10 | 3 |
|
||||
| Standard | 20 | 5 |
|
||||
| Campaign | 45 | 10 |
|
||||
**Length is a free `days` count**, not a preset. The old `short`/`standard`/`campaign` presets
|
||||
carried a `target` and were dropped in 2026-08; they survive only as a convenience argument for the
|
||||
simulation tooling, resolving to 3, 5 and 10 Days. The default is 5.
|
||||
|
||||
With `firstToTarget`, competitive mode ends when any individual reaches the target; co-op uses target × player count and total Revenue. With `highestAfterDays`, competitive mode requires the table’s combined Revenue to reach `3 × players × days`; otherwise everyone loses. If that floor is met, the highest individual score wins. Solitaire and co-op win only if their score reaches their target at the end of the length.
|
||||
**How a game ends and who wins:**
|
||||
|
||||
Competitive mode also ends in a collective loss after three collisions in one Day.
|
||||
1. **The timetable runs out** at the end of the last Day. Then:
|
||||
2. **The combined Revenue floor** is checked first — `3 × players × days`. Fall short and
|
||||
**everybody loses**, whatever anyone individually scored.
|
||||
3. **Co-op** wins as a table if the floor is met.
|
||||
4. **Competitive** is won by the **highest individual Revenue** once the floor is met.
|
||||
5. **Collisions end it early.** Breaching the per-Day or total collision limit ends play at once in a
|
||||
collective loss — the railroad has been declared unsafe.
|
||||
|
||||
The configuration contains four optional-rule flags. Only two have engine effects: **Reduced Visibility** gives five rather than six switching Moves in Stages 1, 2, 3, 11, and 12; **Emergency Toolbox** initially sets the Red Flags hand-limit status, allowing four cards. Sister Trains and Employee Rotation are represented in configuration/state design but are not executed by v0.4.5.
|
||||
**Extended play (Gitea#11).** Both days-based endings — running out of timetable, and closing short
|
||||
of the Revenue floor — offer **another Day**, because they are the same event seen twice: the last
|
||||
Day ended, and this is what the books say. A collision ending is **not** extendable, and neither is a
|
||||
collision breach during an extended Day. The official result is frozen when the timetable first ran
|
||||
out, so a railroad declared unsafe on Day 9 does not retract who won on Day 5.
|
||||
|
||||
### 3.5 Multiplayer setup status
|
||||
**Three optional rules, all implemented:**
|
||||
|
||||
There is no multiplayer lobby, room creation flow, remote server, invitation flow, or network session in v0.4.5. The engine can be called with multiple player names (and rejects a Solitaire configuration with more than one), but the delivered page always calls it for one local player. See section 6.
|
||||
| Rule | Effect |
|
||||
| --- | --- |
|
||||
| Reduced Visibility | Five switching Moves instead of six, in Stages 1, 2, 3, 11 and 12 — the night Stages. |
|
||||
| Employee Rotation | Every player moves one chair at the end of each Day. Revenue and the Fedora travel with the player; the Office Areas stay with the seats. |
|
||||
| Emergency Toolbox | Starts every player holding the Red Flags status, so the hand limit opens at four. |
|
||||
|
||||
(There is no "Sister Trains" flag. It appeared in an earlier draft of this document and never in the
|
||||
configuration.)
|
||||
|
||||
### 3.5 Multiplayer setup
|
||||
|
||||
Multiplayer is delivered and is what this package is for. The flow:
|
||||
|
||||
1. **Create or join.** The host creates a game and gets a **game code**; everyone else joins with
|
||||
that code. Seats fill as people arrive, and any seat left empty can be **filled with a bot**.
|
||||
2. **Start.** Once the host starts, that same page is where every player plays their turns and
|
||||
watches the table. There is nothing else to open.
|
||||
3. **The server is authoritative.** The game lives on the server, not in a browser: it survives a
|
||||
page reload, a browser restart and a service update, replaying its intent history to get back to
|
||||
where it was.
|
||||
4. **Your seat is a token in YOUR browser**, scoped to the origin you joined at. A reload finds it
|
||||
and puts you straight back. Clearing site data, a private window, or a different browser does not:
|
||||
the seat is still yours and still on the server, but that browser can no longer prove it is you.
|
||||
An administrator can mint a **single-use recovery link** (StartOS action **Restore a Seat**) that
|
||||
trades a code for the token and expires in 30 minutes.
|
||||
5. **Watching the table.** Other players' turns arrive as an ordered replay rather than as a board
|
||||
that has silently rearranged itself, with a `[N behind]` counter, Pause and Skip.
|
||||
|
||||
Solitaire needs none of this and runs with the page alone.
|
||||
|
||||
## 4. Basic game mechanics
|
||||
|
||||
@@ -124,13 +178,16 @@ There is no multiplayer lobby, room creation flow, remote server, invitation flo
|
||||
Each of 12 Stages follows this sequence:
|
||||
|
||||
```text
|
||||
1. Local Operations — each player, starting with the Superintendent and proceeding left
|
||||
2. New Train — make up due timetabled trains, then queued second sections and Extras
|
||||
3. Mainline — automatic train movement in numeric order
|
||||
4. Load/Unload — each player, starting with the Superintendent and proceeding left
|
||||
5. Shift Change — expedited departures, clocks/workers, and possibly the Fedora
|
||||
1. Local Operations — each player, starting with the Superintendent and proceeding eastward
|
||||
2. New Train — make up due timetabled trains, then queued second sections and Extras
|
||||
3. Mainline — automatic train movement in numeric order
|
||||
4. Load/Unload — each player, same order. On screen: "Cargo"
|
||||
5. Shift Change — expedited departures, workers. On screen: "Supervisor Shift"
|
||||
```
|
||||
|
||||
**"Proceeding left" is seat order, west to east**, which is how the Division map draws it — the
|
||||
screen says "eastward" for that reason, because a table has no shared left.
|
||||
|
||||
At a Shift Change, Laborers and Porters reset. The Fedora moves after Stages 3, 6, 9, and 12. At Day end, dispatch-device use resets, collision count resets, the Day and Stage roll over, and victory is checked.
|
||||
|
||||
### 4.2 Local Operations: choose one option
|
||||
@@ -139,13 +196,19 @@ On a player’s Local Operations turn, choose exactly one available option.
|
||||
|
||||
**Switch.** Select any Crew Tray currently in that player’s Office Area. It gets six Moves, or five under Reduced Visibility on the listed night Stages. A Move travels any connected distance in one direction and must end on Operational Rail. Reversing is a separate Move. A train may pass through a turnout but cannot stop on it. It may not share or pass through another train except through an Office with a free A/D track.
|
||||
|
||||
Standing cars couple automatically when the train reaches them; it may not pass them, and the resulting consist may not exceed four cars. Coupling forward places cars ahead of the engine; coupling while backing places them behind it. Setting out cars does not spend a Move, but the cut must come from an outer end of the consist and may not be left on the Office. A Small Yard can reorder a consist for one Move. Flying Switch spends one Move to roll a tail cut into a connected Freight Facility.
|
||||
Standing cars couple automatically when the train reaches them; it may not pass them, and the resulting consist may not exceed four cars. Coupling forward places cars ahead of the engine; coupling while backing places them behind it. Setting out cars does not spend a Move, but the cut must come from an outer end of the consist and may not be left on the Office. A **Small Yard** re-orders a consist for one Move, and since v0.8.0.14 may also place cars **ahead
|
||||
of the engine** — which is how a cut is set up to be shoved into a facing industry. Each option on
|
||||
the menu shows the train it would build, laid out west to east as the board draws it, and says
|
||||
whether the result may leave the Office or would be held there. Flying Switch spends one Move to
|
||||
roll a tail cut into a connected Freight Facility.
|
||||
|
||||
**Draw.** Take one card from the face-down Home Office or the exposed top of one Department pile. During this option, play eligible cards and/or discard cards to Department piles, then finish at the hand limit. Track, facilities, offices, modifiers, enhancements, and train cards have the placement or scheduling rules in the deck references. Mainline modifiers are played from this option as well.
|
||||
|
||||
**Freight Agent.** Make one of these operations, then the turn ends: stock one green outbound box from a matching loaded Division Yard car; clear one red inbound box to the Classification Yard; unjam one outbound, inbound, or MEN | AT | WORK load to the Classification Yard; or explicitly end without acting. Freight can be stocked only when an unclaimed empty matching car is already spotted at that industry. Passengers may wait in a green Office box without a train present.
|
||||
|
||||
Implementation note: `card.discard` is accepted by the v0.4.5 engine during Local Operations without checking that Draw was chosen. This unusual implementation behavior is not a separate published turn option.
|
||||
Implementation note, still true at v0.8.0.16: `card.discard` is accepted during Local Operations
|
||||
without checking that the Draw option was chosen — unlike `card.play`, which does check. This is an
|
||||
implementation quirk rather than a fourth published turn option.
|
||||
|
||||
### 4.3 Track, switching, and local safety
|
||||
|
||||
@@ -159,18 +222,36 @@ Train-card restrictions also apply while switching. No-switching trains cannot m
|
||||
|
||||
At the Stage shown on the timetable, the engine makes up the matching timetabled train if a Crew Tray is free. It starts at the Division Point appropriate to its direction. A train may receive matching loaded or empty cars from the Division Yard until its listed maximum consist is reached or no suitable car remains. It is permitted to leave under-strength.
|
||||
|
||||
A Second Section order on the due train creates another identical timetabled train behind it when a free tray is available. A played Extra is made up after timetabled trains and Second Sections when a tray is free; v0.4.5 automatically sends every Extra east from the Western Division Point.
|
||||
A Second Section order on the due train creates another identical timetabled train behind it when a
|
||||
free tray is available. A played Extra is made up after timetabled trains and Second Sections when a
|
||||
tray is free, and **the player who played the card chooses where it starts** — either Division
|
||||
Point, the Interchange, or an Office, according to the `extraStart` house rule — and loads it as they
|
||||
choose rather than going round the table.
|
||||
|
||||
A timetabled train's consist is built by the table: **starting with the Superintendent and working
|
||||
eastward, each player adds ONE car**, going round again until the train is full or the Division Yard
|
||||
holds nothing it can take.
|
||||
|
||||
**When the yard can supply nothing, the game says so.** The round is skipped — there is no point
|
||||
asking for a car that cannot be given — and the train is reported as made up short, naming what its
|
||||
card wanted, how many such cars are waiting in the Classification Yard, and how far the Division Yard
|
||||
is from bare. See §4.6 for why that happens to coaches in particular.
|
||||
|
||||
### 4.5 Mainline movement and Office arrival
|
||||
|
||||
Mainline movement is automatic and processes lower train numbers first; a timetabled train outranks an Extra with the same number. A train at a Division Point, at an Office A/D track, or already crossing a Mainline card attempts its applicable movement.
|
||||
|
||||
Crossing time comes from the Mainline card and train speed. On a normal card, a train must check the entire next Subdivision before entering it:
|
||||
**Crossing time is the card's REGIONS**, one per Stage — not its printed mph, which is scenery, and
|
||||
not the train's Fast/Slow rating, which only **Hilly** reads. See the
|
||||
[Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md) reference for what moves a train's entry
|
||||
point. On a normal card, a train must check the entire next Subdivision before entering it:
|
||||
|
||||
- an opposing train normally blocks entry;
|
||||
- a following same-direction train asks the Superintendent to allow or deny clearance;
|
||||
- Red Flags or ABS Signals hold the follower automatically; and
|
||||
- passing cards allow entry without this occupancy check.
|
||||
- **Double Track** — the one card two trains may stand on — allows entry without that check. The
|
||||
**Uncontrolled Siding** is not a passing card: a train arriving to find it occupied takes the
|
||||
siding a region behind, which costs it the extra Stage instead of a collision.
|
||||
|
||||
An Office arrival normally takes a free A/D track. If the Office is full, the inbound train collides and the local Office player loses 5 Revenue; Interlocking instead holds it at the Limits. A coachless inbound train may divert to a Yard Office. Cars fouling the Running Track at the Office also cause a collision.
|
||||
|
||||
@@ -185,7 +266,22 @@ Passenger work occurs during Load/Unload, at a Depot, Station, or Terminal. Each
|
||||
- **Board:** replace an empty coach on an eligible train at the Office with a loaded coach from a green outbound box. The removed empty coach goes to the Classification Yard. Earn configured passenger Revenue.
|
||||
- **Detrain:** replace a loaded coach on an eligible train with an empty coach from the Division Yard, placing the loaded coach into an available red inbound box. Earn configured passenger Revenue.
|
||||
|
||||
Crack Limited trains permit passenger work at Terminals only. Military Train and Director’s Private Car permit no passenger work. A Whistle Post has no Porters.
|
||||
Crack Limited trains permit passenger work at Terminals only. Military Train and Director's Private
|
||||
Car permit no passenger work. A Whistle Post has no Porters.
|
||||
|
||||
> **Coaches travel one way, and it is worth knowing before you plan around passengers.** Boarding
|
||||
> sends the emptied coach to the **Classification** Yard; detraining draws a fresh empty out of the
|
||||
> **Division** Yard; and §2.2 returns the Classification Yard only when the Division Yard runs
|
||||
> completely bare. Measured over one three-Day game: sixteen coaches in the Division Yard at setup,
|
||||
> **none from Day 2 Stage 8 onward**, fifteen piled in Classification while the Division Yard held
|
||||
> steady at 46–47 freight cars and stopped draining — so the refill never fired and no passenger
|
||||
> could board or detrain anywhere for the rest of the game, while trains whose cards call for coaches
|
||||
> were made up empty.
|
||||
>
|
||||
> **This is the rules working as printed and the ruling is that it stands** (Jesse, 2026-09-17, the
|
||||
> same ruling Gitea#2 got: running out is part of the game). What changed is that the game now says
|
||||
> it — the yard panel warns while the shortage lasts, and a train made up short reports why. Whether
|
||||
> the ratchet should be broken is open as TODO #108, to be decided on a second game's evidence.
|
||||
|
||||
### 4.7 Freight work
|
||||
|
||||
@@ -197,43 +293,77 @@ For an **inbound unload**, a matching loaded car must be spotted at an inbound-c
|
||||
|
||||
## 5. Solitaire
|
||||
|
||||
The implemented game is solitaire: one named player, one Office Area, local browser execution, Standard length, highest-after-days victory, and the default house rules unless changed in New Game. The game has no AI opponent. “Multiplayer” gameplay does not occur locally by simulating other players.
|
||||
Solitaire is one named player and one Office Area, running entirely in the browser with no server.
|
||||
It is **not** the only implemented game any more — see §6 — but it is the one that needs nothing but
|
||||
the page.
|
||||
|
||||
Solitaire-specific features are:
|
||||
|
||||
- a local browser save, automatic resume, and JSON download/load;
|
||||
- unlimited step-by-step Undo back through accepted action history; and
|
||||
- **unlimited step-by-step Undo** back through accepted action history — multiplayer has none,
|
||||
because a shared game cannot be rewound under the other players; and
|
||||
- a seed/rules URL suitable for sharing or reproducing a game.
|
||||
|
||||
Automatic phases get a visible beat in solitaire too, so the board plays its own moves out rather
|
||||
than jumping.
|
||||
|
||||
The active deck removes all 22 opponent-directed cards. Therefore, the defensive cards whose only purpose is to answer them (Facing Point Locks and Water Column) can be placed but have no opportunity to fire; Overpass has no effect at all. The game still includes shared-rail mechanics such as Mainline clearance, but with one player no other player can occupy the Division.
|
||||
|
||||
To win the default game, finish Day 5 with at least 20 Revenue. A result below 20 is a loss. Train-transit Revenue defaults to zero, so the default score must principally come from passenger and freight work.
|
||||
To win the default one-player game, finish Day 5 having met the combined Revenue floor —
|
||||
`3 × players × days`, which at one player over five Days is **15**. Below it is a loss, and the game
|
||||
offers you another Day rather than simply ending. Train-transit Revenue defaults to zero, so the
|
||||
score has to come principally from passenger and freight work.
|
||||
|
||||
## 6. Multiplayer
|
||||
|
||||
### 6.1 What the engine supports
|
||||
|
||||
The engine has player, seat, score, Office Area, Director/Division, phase-order, co-op, and competitive-mode data for multiple named players. It deals each player a hand, creates one Office Area per seat, starts acting order at the Superintendent and proceeds left, and models the following multiplayer-specific outcomes:
|
||||
The rules engine has always supported multiple named players; since v0.7 the lobby, server and
|
||||
client around it are delivered too, so this section now describes a game people actually play. The
|
||||
engine has player, seat, score, Office Area, Division, phase-order, co-op and competitive-mode data
|
||||
for multiple named players. It deals each player a hand, creates one Office Area per seat, starts acting order at the Superintendent and proceeds eastward, and models the following multiplayer-specific outcomes:
|
||||
|
||||
- the D12 seating and Superintendent rolls described in section 3;
|
||||
- individual Revenue in competitive play and shared total Revenue in co-op;
|
||||
- a competitive collective loss after three collisions in one Day;
|
||||
- a collective Revenue floor for competitive highest-after-days games; and
|
||||
- a collective loss on breaching the collision limits;
|
||||
- the combined Revenue floor, `3 × players × days`; and
|
||||
- train-transit Revenue awarded to every player, if that revenue setting is nonzero.
|
||||
|
||||
The engine’s setup checks only that there is at least one player and that Solitaire has exactly one player. It does not enforce a maximum player count, although the test and design material exercise two through four players.
|
||||
|
||||
### 6.2 What is not delivered in v0.4.5
|
||||
### 6.2 What IS delivered
|
||||
|
||||
There is no implemented multiplayer game setup for end users: no server, lobby, invitation, room code, player join flow, authoritative remote state, or remote Session. The browser page creates a local single-player session only. Accordingly, there is no supported procedure for resuming a multiplayer game, and no multiplayer Undo.
|
||||
Everything in §3.5: a lobby with game codes, seating, bots filling empty chairs, an authoritative
|
||||
server that survives restarts and updates by replaying its intent history, per-seat reconnection, an
|
||||
administrator's single-use seat-recovery link, and an ordered replay of other players' turns on each
|
||||
player's own screen.
|
||||
|
||||
The 12 space-use cards and 10 action cards are not dealt in competitive or co-op either. The engine rejects attempts to play either category. Thus, **no card can currently be played on another player**. This includes Derail, Broken Coupler, Railroad Crossing, score-penalty cards, Vandalism, Hotbox, Outlawed, and all table-space cards. Facing Point Locks and Water Column are implemented only as dormant defences for these unavailable effects.
|
||||
The package also exposes administrative **actions** on StartOS — list games in progress, get the
|
||||
join secret, manage a game, restore a seat — documented in the wrapper repository rather than here.
|
||||
|
||||
### 6.3 Difference from solitaire, if a multi-player engine session is created
|
||||
### 6.3 What is NOT delivered
|
||||
|
||||
Players have separate local districts, hands, and scores, but they share the Home Office deck, Department piles, yards, timetable, Mainline, and traffic consequences. Turn order is sequential; one current actor acts at a time. The Superintendent role is attached to a player while Offices are attached to fixed seats. Employee Rotation is not active, so players do not actually change seats in v0.4.5.
|
||||
**No card may be played at another player.** The 12 space-use and 10 action cards are excluded from
|
||||
every dealt deck in every mode, and `check` rejects playing one. That includes Derail, Broken
|
||||
Coupler, Railroad Crossing, the score-penalty cards, Vandalism, Hotbox, Outlawed and all
|
||||
table-space cards. Facing Point Locks and Water Column exist only as dormant defences against
|
||||
effects nothing can currently cause, and Overpass has no effect at all.
|
||||
|
||||
The deck is still 213 cards and still excludes opponent-directed content. Therefore multi-player engine mode changes shared traffic, scores, turns, and win/loss evaluation—not card attacks or a remote user experience.
|
||||
**No multiplayer Undo.** A shared game cannot be rewound under the other players.
|
||||
|
||||
### 6.4 How multiplayer differs from solitaire
|
||||
|
||||
Players have separate districts, hands and scores, and share the Home Office deck, the Department
|
||||
piles, the yards, the timetable, the Mainline and every traffic consequence. One player acts at a
|
||||
time.
|
||||
|
||||
**A seat is not a player**, and the distinction is load-bearing. Offices belong to seats; Revenue,
|
||||
hands, the Fedora and identity belong to players. With **Employee Rotation** on, players move one
|
||||
chair at the end of each Day and take their Revenue and the Fedora with them, while the districts
|
||||
stay where they are.
|
||||
|
||||
So multiplayer changes shared traffic, scores, turn order and how the game is won or lost — not card
|
||||
attacks, which do not exist in any mode.
|
||||
|
||||
## 7. Frequently asked questions
|
||||
|
||||
@@ -271,20 +401,42 @@ It may be on Secondary Track rather than the Office, or be badly made up: its en
|
||||
|
||||
### Why did an expedited train leave after passenger/freight work?
|
||||
|
||||
Expedite means it departs in the Stage it arrived, but v0.4.5 waits until Shift Change so it remains present for that Stage’s Load/Unload phase.
|
||||
Expedite means it departs in the Stage it arrived, but the departure waits until Shift Change so the
|
||||
train is still present for that Stage's Load/Unload phase.
|
||||
|
||||
### Can I choose the direction of an Extra or a Heavy Grade?
|
||||
|
||||
Not in v0.4.5. The engine launches Extras eastbound from the Western Division Point. Heavy Grade orientation is seeded automatically at setup.
|
||||
**An Extra, yes** — the player who played the card chooses where it starts and which way it runs, and
|
||||
loads it as they choose (v0.6.2). **A Heavy Grade, no**: orientation is rolled from the seed. That is
|
||||
a decision rather than a gap — the card sits between two districts and belongs to neither, so handing
|
||||
the choice to one neighbour would advantage them permanently. See the Mainline deck reference.
|
||||
|
||||
### Can I use an Interchange to reorder a train?
|
||||
|
||||
No. The card is catalogued with a sorting concept, but there is no implemented Interchange sorting action. Small Yard is the available local sorting mechanism.
|
||||
**No, and the card used to claim otherwise.** Its printed car-sorting has never been implemented; the
|
||||
description was corrected on 2026-09-20 to stop advertising it. What the Interchange actually offers
|
||||
is the one Mainline card with a yard, so an **Extra may be made up and started there**. Re-ordering a
|
||||
consist is done at a **Small Yard** in a district.
|
||||
|
||||
### Can I play attack cards on another player?
|
||||
|
||||
No. All action and space-use cards are excluded from every dealt deck in v0.4.5 and their play is rejected.
|
||||
No. All action and space-use cards are excluded from every dealt deck in every mode, and playing one
|
||||
is rejected.
|
||||
|
||||
### Is multiplayer playable?
|
||||
|
||||
No. Multi-player state and rules-engine support exist, but the lobby, server, remote client, and opponent-directed card mechanics are not implemented.
|
||||
**Yes.** Lobby, game codes, seating, bots, an authoritative server that survives restarts, per-seat
|
||||
reconnection and a replayed view of everyone else's turns are all delivered — see §3.5 and §6. The
|
||||
opponent-directed cards remain unimplemented in every mode, so there are still no card attacks.
|
||||
|
||||
### Why did my passenger train arrive with no coaches?
|
||||
|
||||
Almost certainly the coach ratchet in §4.6: every coach has ended up in the Classification Yard,
|
||||
which comes back only when the Division Yard runs completely bare. The yard panel warns when this
|
||||
has happened, and a train made up short says so in the log.
|
||||
|
||||
### Why is every option in the Small Yard marked "HELD at the Office"?
|
||||
|
||||
Because that train is **already made up**, so every re-order on offer would break it — most often by
|
||||
moving the caboose off the rear, which §8.2 will not let a train depart with. If the train is *not*
|
||||
currently fit to run, at least one option will be marked "MADE UP, ready to leave".
|
||||
|
||||
@@ -36,6 +36,34 @@ and `<ip>:<port>` are both expected — and browser storage is scoped to the ori
|
||||
at one address must come back to that address, or they are a stranger with no token. Say so in the
|
||||
UI at join time rather than letting someone discover it when they cannot get back in.
|
||||
|
||||
**A lost token is recoverable, administratively** (Gitea#33). Everything above makes the token the
|
||||
single point of failure: it lives in one browser's storage, and a cleared profile, a private window or
|
||||
a different browser ends the seat with the game still running and the session still on disk. Seen at a
|
||||
real table — the returning player met an empty lobby while their token sat intact in `sessions.json`,
|
||||
and the only way back was an administrator reading the file off the volume and the player pasting it
|
||||
into a devtools console.
|
||||
|
||||
So there is a supported path, in two halves that are gated differently on purpose:
|
||||
|
||||
```
|
||||
POST /api/games/<id>/claim { player } → { code, expiresAt, … } admin secret
|
||||
POST /api/claim { code } → { token, gameId, player, gameCode }
|
||||
```
|
||||
|
||||
**The link carries the code, never the token** — which is the rule three paragraphs up, applied. A
|
||||
recovery link is exactly the sort of thing that gets pasted into a chat, so what travels in the URL is
|
||||
single-use and expires in thirty minutes (`server/claims.ts`), and the page trades it for the real
|
||||
token over a POST as it loads (`?claim=` in `web/main.ts`, which strips it from the address bar either
|
||||
way). A leaked code is worthless once spent; a leaked token is the seat for the rest of the game.
|
||||
|
||||
**Minting is administrative; spending is not.** Deciding that a particular person has lost a
|
||||
particular seat is a judgement no route can make safely — anyone able to mint their own code could
|
||||
take any chair at the table. Spending needs no secret because the player following the link is the one
|
||||
person in the story who holds none; the code *is* the authorisation, and it is the same shape
|
||||
(unguessable, one-time) as the token it hands back. The codes are held in memory: they are minted on
|
||||
demand and spent within minutes, so a restart dropping them is the right failure, and persisting them
|
||||
would put a credential-equivalent on the volume to solve a problem measured in seconds.
|
||||
|
||||
Real accounts can be layered on later without touching the rules engine, which is exactly why
|
||||
[`overview.md`](overview.md) keeps that boundary sharp.
|
||||
|
||||
|
||||
+35
-21
@@ -20,13 +20,30 @@ These stay as-is. Everything below is derived from them.
|
||||
> placeholder for exactly this material, and the balance measurements in Gap 12 were taken against a
|
||||
> ruleset that does not match the design.
|
||||
|
||||
## For players and testers
|
||||
|
||||
Written to be handed to somebody who is about to play, rather than to somebody building the game.
|
||||
|
||||
| Document | What it is |
|
||||
| --- | --- |
|
||||
| [`StationMaster-Quickstart.md`](StationMaster-Quickstart.md) | **Start here if you have never played.** The point of the game, how a Stage runs, what is on screen, how you win, a first twenty minutes, and what to report. |
|
||||
| [`StationMaster-Rules-v0.4.5.md`](StationMaster-Rules-v0.4.5.md) | **The rules in full**, as the engine actually runs them, with a FAQ. |
|
||||
| [`rules/as-built.md`](rules/as-built.md) | **Every card, GENERATED from `src/engine/content.ts`** and checked by a test, so it cannot disagree with the game. The table of record for per-card facts. |
|
||||
| [`StationMaster-Home-Deck-v0.4.5.md`](StationMaster-Home-Deck-v0.4.5.md) | How the Home Office deck is dealt, drawn and played out. |
|
||||
| [`StationMaster-Mainline-Deck-v0.4.5.md`](StationMaster-Mainline-Deck-v0.4.5.md) | The Mainline cards, how the deck is dealt, and what a card does to a train crossing it. |
|
||||
| [`StationMaster-Components-v0.4.5.md`](StationMaster-Components-v0.4.5.md) | Rolling stock, the two yards, Crew Trays, the Fedora, the D12. |
|
||||
|
||||
The `v0.4.5` in four of those filenames is the **prototype rules edition they were first written
|
||||
against**, not the version they describe — each says at the top which build it is current to. The
|
||||
names are kept because `src/`, `CHANGELOG.md` and `docs/rules/` cite them.
|
||||
|
||||
## Rules
|
||||
|
||||
| Document | What it is |
|
||||
| --- | --- |
|
||||
| [`rules/rules-v0.1.md`](rules/rules-v0.1.md) | Faithful markdown transcription of the PDFs. No corrections. The baseline everything diffs against. |
|
||||
| [`rules/rules-v0.2.md`](rules/rules-v0.2.md) | **The working ruleset.** v0.1 with all ten gaps resolved, each change marked with its gap number. |
|
||||
| [`rules/card-reference.md`](rules/card-reference.md) | What is printed on every card, plus the economy summary. The spec an engine or a print-and-play layout consumes. |
|
||||
| [`rules/card-reference.md`](rules/card-reference.md) | **⚠ SUPERSEDED** — an invented 52-card placeholder, kept for its economy summary and its history. For what is printed on every card, read [`rules/as-built.md`](rules/as-built.md), which is generated from the code. |
|
||||
| [`rules/glossary.md`](rules/glossary.md) | Every defined term, alphabetized. |
|
||||
| [`rules/open-questions.md`](rules/open-questions.md) | All thirteen gaps, each with the options considered, the decision, and the rationale. |
|
||||
| [`rules/implications.md`](rules/implications.md) | **Read this first.** What the four recovered design files (`Deck cards2.xlsx`, `Mainline Cards.pdf`, `Trains3.pdf`, `tracks.png`) change — and which decisions they supersede. |
|
||||
@@ -49,23 +66,20 @@ must do.
|
||||
|
||||
## Current status
|
||||
|
||||
**v0.4.3.** Rules formalized, card faces specified, architecture documented, and the game playable
|
||||
solitaire in a browser. See [`../CHANGELOG.md`](../CHANGELOG.md) for what each version changed and
|
||||
[`../TODO.md`](../TODO.md) for what is open; this section is the shape of the project, not a
|
||||
running tally, because a hand-maintained tally is what drifted last time.
|
||||
**v0.8.0.16.** Rules formalized, card faces specified, architecture documented, and the game
|
||||
playable **solitaire and multiplayer** in a browser against an authoritative server. See
|
||||
[`../CHANGELOG.md`](../CHANGELOG.md) for what each version changed and [`../TODO.md`](../TODO.md) for
|
||||
what is open; this section is the shape of the project, not a running tally, because a
|
||||
hand-maintained tally is what drifted last time.
|
||||
|
||||
**What is built.** The rules engine, the developer bot, the balance harness, the replay viewer and
|
||||
the playable page — components 1–7, 17 and 18 of
|
||||
[`architecture/components.md`](architecture/components.md). A game can be saved, shared, replayed and
|
||||
stepped back through. **493 tests.**
|
||||
**What is built.** The rules engine, the developer bot, the balance harness, the replay viewer, the
|
||||
playable page — and the server: lobby, game codes, seating, bots, per-seat reconnection, persistence
|
||||
by replaying the intent history, and an ordered replay of other players' turns on each player's own
|
||||
screen. It ships as a StartOS package. **999 fast tests and 35 simulation tests.**
|
||||
|
||||
**What is not.** The server. Phases 0 and 1 of
|
||||
[`architecture/multiplayer.md`](architecture/multiplayer.md) landed in v0.4.0 — seat and player are
|
||||
separate, turn state is per player, and the page talks to a `Session` rather than to the engine, so a
|
||||
`RemoteSession` drops in without the page changing. Phase 2 onward is **deliberately held** until the
|
||||
two provisional rules introduced in v0.3.0 have been played at a table: changing a rule after the wire
|
||||
format is live costs far more than changing it before. Also unbuilt: the 22 opponent-directed cards
|
||||
and real audio.
|
||||
**What is not.** The 22 opponent-directed cards — the Action and Space-use categories — are held out
|
||||
of every dealt deck until they have an implementation, along with the two defensive cards whose only
|
||||
purpose is to answer them. Real audio: everything the game plays is synthesised from oscillators.
|
||||
|
||||
**Balance is not where it should be, and no conclusion should be read from the revenue numbers yet.**
|
||||
The rebalance pass is deliberately deferred until the rules stop moving — card counts, industry counts
|
||||
@@ -85,17 +99,17 @@ on that. [`architecture/protocol.md`](architecture/protocol.md) §3 has the reas
|
||||
`test/events.test.ts` pins it.
|
||||
|
||||
**The economy, in one line:** Local Operations actions are the main currency — one per Stage, twelve
|
||||
per Day — but **inbound work bypasses them**, which is where the game's variance comes from. See
|
||||
`card-reference.md` §7.
|
||||
per Day — but **inbound work bypasses them**, which is where the game's variance comes from.
|
||||
|
||||
**Stack: TypeScript**, chosen so the engine runs in both the server and the browser — one
|
||||
implementation of the movement rules, and instant affordances without a round-trip. Node 22 runs
|
||||
TypeScript natively, so there is no build step during development, which also means **erasable syntax
|
||||
only**: no `enum`, no parameter properties, no namespaces.
|
||||
|
||||
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.
|
||||
Running alongside, and independent of all of it: **print-and-play components.**
|
||||
[`rules/as-built.md`](rules/as-built.md) carries every card face as the game actually deals it, so
|
||||
layout and art are the only remaining work before a table playtest — which answers the one question
|
||||
simulation cannot, whether it is fun.
|
||||
|
||||
Run the harness with `node src/sim/harness.ts [games] [length]`.
|
||||
|
||||
|
||||
@@ -1,11 +1,53 @@
|
||||
# Station Master Jitsi Common Board Implementation Plan
|
||||
|
||||
**Status (2026-09-07):** **STEP 1 IS BUILT AND SHIPPED. Steps 2-7 are unimplemented.**
|
||||
**Status (2026-09-09):** **STEP 1 IS BUILT AND SHIPPED. Steps 2-7 are unimplemented.**
|
||||
|
||||
Step 1 landed across four releases rather than one — v0.7.9.2 (the two narration leaks), v0.7.9.4
|
||||
(the projection helpers and the redaction net), v0.7.9.5 (the narration path), and v0.7.9.8 (this
|
||||
reconciliation). One of its items is struck off rather than built; see § Public game projection.
|
||||
|
||||
## THE RELEASE SPLIT — read this before picking up any step
|
||||
|
||||
Settled with Jesse 2026-09-09. The document below was written as one seven-step delivery and its
|
||||
ordering still reads that way, so this section and § v0.8.0 are the authority on what belongs where.
|
||||
|
||||
| Release | What | Why here |
|
||||
| --- | --- | --- |
|
||||
| **v0.8.0** | **TODO #13/#15/#18 — the watchable table.** The step collector, steps on the `Session` interface, foreign-district rendering, the client animation queue, pacing, and the behind-counter. § v0.8.0 is the design. | #13 is the point. *"Watching on a TV is the bonus"* — Jesse, 2026-09-09. |
|
||||
| **v0.8.1** | **The seatless board page.** `display.json`, `viewToken`, `/api/display/stream`, `/display.html`, an all-districts layout. Most of step 2, and step 3 without its canvas. | Cheap once 0.8.0's foreign-district rendering exists; nothing in it moves #13 forward. |
|
||||
| **v0.9.0** | **The Jitsi publisher.** Steps 5-7, plus step 3's canvas capture pipeline. | Needs Chromium in the image and hardware measurement. Held off deliberately. |
|
||||
|
||||
**What 0.8.0 is FOR, in Jesse's words (TODO #13, 2026-08-29):** *"It's not fun to do my turn and have
|
||||
magic happen in the background and then have to figure out what others did."* And 2026-09-09, on what
|
||||
he most wants to watch: *"I definitely want to watch other players struggle with the switching
|
||||
exercises … I don't think reading the switching in the log will be anywhere nearly as interesting as
|
||||
watching the trains actually move on the board."*
|
||||
|
||||
### Decisions taken 2026-09-09 that the text below has NOT been rewritten around
|
||||
|
||||
- **`Frame.cells` is ONE district — the viewer's own** (`view.ts:510`, from `areaOf(s, viewer)`). So a
|
||||
step stream alone does not answer #13: the data would arrive and have nowhere to be drawn.
|
||||
**Rendering a district you do not own is the core of 0.8.0, not part of the seatless page.** This
|
||||
is the correction that set the split above; it was mis-assigned until 2026-09-09.
|
||||
- **No WebSocket, and no new runtime dependency.** The control channel exists only to join the
|
||||
supervisor to the headless agent — a handful of messages per publisher lifetime, on loopback.
|
||||
`package.json` has no `dependencies` key and the wrapper's runtime image copies only
|
||||
`package.json` and `src/` with no `node_modules`, so adding `ws` changes the deployment model
|
||||
rather than adding a dependency. Use SSE down + POST up, the pattern `src/server/http.ts` already
|
||||
implements for players (`/api/stream` + `/api/intent`). The proven Jitsi code ports either way:
|
||||
`engine/communications/CommunicationsClient.ts` in the sibling repo references the control layer
|
||||
in two comments and nothing else. **0.9.0 work.**
|
||||
- **`protocolVersion` is added in 0.8.0.** See § Public game projection.
|
||||
- **0.8.0 needs no HTTP work at all.** The `startServer()` refactor and the project's first HTTP test
|
||||
harness existed to test `/api/display/stream`, which is now 0.8.1. `Push.steps` is built in
|
||||
`session.ts`, and `broadcastGame()` forwards whatever push it is handed, so `http.ts` does not
|
||||
change and the tests land in the existing `test/server/session.test.ts`.
|
||||
- **Solitaire is a special case of multiplayer, not a second implementation** — Jesse's stated design
|
||||
direction, to minimize rule and implementation drift. `src/web/session.ts` already draws that seam:
|
||||
*"the page … does not care whether the rules are being applied a function call away or across a
|
||||
network."* Every mechanism below hangs off `Session`, with `LocalSession` and `RemoteSession` both
|
||||
feeding it. Solitaire therefore gets #18 through the same code path multiplayer gets #13 through.
|
||||
|
||||
**This document has drifted from the code and is no longer the authority on what exists.** It was
|
||||
written on 2026-08-27 against the code of that date, and the "Current code findings" under each step
|
||||
describe faults that were then real — several are now fixed, and reading them as present tense will
|
||||
@@ -13,6 +55,262 @@ send you to fix things twice. Where a step is marked built, `src/sim/view.ts`, `
|
||||
tests named in TODO.md are the authority. Steps 2-7 were never implemented and their findings have
|
||||
NOT been re-verified against the current code; check each before building on it.
|
||||
|
||||
## v0.8.0 — The watchable table
|
||||
|
||||
**This section supersedes the parts of steps 2-4 it covers.** Where it and a step below disagree,
|
||||
this wins; the steps keep the material that is still 0.8.1/0.9.0 work. Designed with Jesse
|
||||
2026-09-09 in conversation; every measurement quoted was taken from the code and the published
|
||||
replays that day.
|
||||
|
||||
### The shape
|
||||
|
||||
One mechanism, four consumers, no branch between solitaire and multiplayer:
|
||||
|
||||
```
|
||||
submit() ──► collector (sim/) ──► DisplayStep
|
||||
│
|
||||
┌───────────────────┴───────────────────┐
|
||||
LocalSession.steps() Push.steps ──► RemoteSession.steps()
|
||||
└───────────────────┬───────────────────┘
|
||||
▼
|
||||
client animation queue (one impl)
|
||||
▼
|
||||
board render · caption · behind-counter · skip
|
||||
```
|
||||
|
||||
### 1. The collector
|
||||
|
||||
> **BUILT 2026-09-09** — `src/sim/display-step.ts`, `test/watchable.test.ts`. Two things below were
|
||||
> wrong in a way worth recording, because both made the job smaller.
|
||||
|
||||
**ONE HOOK, NOT TWO.** This section said to wire two call sites in `GameSession` and let
|
||||
`LocalSession` do its own thing. It does not need to: **`src/server/session.ts` imports `submit`
|
||||
from `src/web/game.ts`**, so solitaire, live multiplayer and every bot turn already funnel through
|
||||
one function. Collecting inside `submit()` covers all three, and *that* is what makes solitaire a
|
||||
special case of multiplayer here rather than a parallel implementation.
|
||||
|
||||
**REPLAY IS INERT FOR FREE.** `fromSave` and `fromMultiplayerSave` rebuild a game with `applyIntent`
|
||||
+ `record` + `drain` directly rather than through `submit`, so a resumed server does not re-emit the
|
||||
whole game as steps. No guard is needed. But the property is load-bearing rather than lucky — move a
|
||||
replay path onto `submit()` and it silently becomes the #97-class bug this section feared — so
|
||||
`test/watchable.test.ts` pins it.
|
||||
|
||||
- One step per accepted intent, **including** automatic work drained behind it. Never one step per
|
||||
`GameEvent` — an intent drains `pump()` work and the event list is not a complete reducer.
|
||||
- **Narration's high-water mark is taken inside `submit()`**, which brackets `record` and `drain` and
|
||||
is therefore the only place that knows what one intent said. Not `sentLines`: that is per-seat and
|
||||
is *mutated* by `linesSince()` as a side effect of building a push.
|
||||
- Capture the frame immediately; never retain a mutable `GameState` reference for later projection,
|
||||
or every retained reference resolves to the final state.
|
||||
- Accumulated on `Game` beside `log`, `cues` and `announced` and drained by `takeSteps()` the way
|
||||
`takeMoment()` drains the rest — the established convention for "the model accumulated something,
|
||||
the view takes it". `pushesForAll()` drains ONCE per broadcast, not per seat.
|
||||
|
||||
### 2. Delivery — on the `Session` interface
|
||||
|
||||
`Session` gains steps. `LocalSession` emits them from its own `submit()`; `RemoteSession` reads them
|
||||
off `Push.steps`. The page consumes one queue and cannot tell which it has.
|
||||
|
||||
`Push` gains `steps?: DisplayStep[]`, following the **`presence` precedent** — `Push.frame` is
|
||||
already optional, and presence went in as a field rather than a second SSE event type for the reason
|
||||
recorded at `http.ts:210`: *"one message shape for the client to parse."*
|
||||
|
||||
`http.ts` does not change. `broadcastGame()` forwards whatever push `session.ts` builds.
|
||||
|
||||
### 3. What a step carries, and what animates
|
||||
|
||||
**Public steps animate the board; the private `Frame` supplies hand, menu and objective.**
|
||||
|
||||
Your hand never changes because somebody else moved. What can change splits cleanly: revenue is
|
||||
already in `PublicFrame` (`players` carries it) so it animates; `menu` and `blocked` are recomputed
|
||||
and arrive with the final coalesced Push, as today. So a step carries a `PublicFrame` delta and
|
||||
**adds no new redaction surface** — it reuses the projection `test/redaction.test.ts` already guards.
|
||||
|
||||
Rejected: emitting N per-seat redacted `Frame`s per intent. It multiplies both the projection work
|
||||
and the redaction test surface, and buys nothing — a seated step would be a `Frame`, which is
|
||||
redacted per seat, so it could not reuse the public delta anyway.
|
||||
|
||||
**Delta the districts per seat, not as one array.** Measured 2026-09-09 over a 300-step 4-player
|
||||
game (`sim/public-delta.ts` now built, `test/public-delta.test.ts` pins reconstruction): a full
|
||||
frame every step is **20.4 KB/step, 6.0 MB** over the game; a `frame-delta`-style whole-array
|
||||
compare is **3.0 MB** (51%); keying by seat is **2.4 MB** (40%), saving a further **657 KB, 22%**
|
||||
over the whole-array form. `sim/frame-delta.ts` hardcodes
|
||||
`BOARD_KEYS = ['cells','facilities','division']` against `Frame`; `PublicFrame` has no top-level
|
||||
`cells`/`facilities` — they live inside `districts[]`, one per seat, which is where nearly all the
|
||||
bytes are. One accepted intent changes one district, so a whole-array comparison resends every other
|
||||
player's board on every step. Keep `deltaFrame`/`applyDelta`'s "null means unchanged" convention;
|
||||
replace the key set.
|
||||
|
||||
**`protocolVersion` goes on the ENVELOPE, not on `PublicFrame`.** The original sketch put it inside
|
||||
the frame; it does not belong there. `PublicFrame`'s property list is an allow-list that
|
||||
`test/redaction.test.ts` enumerates, so a transport concern living in it would have to be declared
|
||||
public *game state*, which it is not. The step is the message; the message carries the version.
|
||||
Built as `DISPLAY_PROTOCOL_VERSION` in `sim/display-step.ts`.
|
||||
|
||||
### 4. Foreign-district rendering — THIS IS #13
|
||||
|
||||
`Frame.cells` is one district, the viewer's own. A step stream without this is data with nowhere to
|
||||
go, so this is the feature rather than a supporting part of it.
|
||||
|
||||
- Render any seat's district from `PublicDistrict`.
|
||||
- **Focus follows the actor** — the district panel shows whoever is acting, theirs while they switch
|
||||
and yours when it is your turn. Same rule the seatless board will use in 0.8.1: acting player's
|
||||
seat, else most recent actor's seat, else seat zero.
|
||||
- **Resolve owner from seat on every frame**, so Employee Rotation relabels a district in place
|
||||
rather than moving it.
|
||||
- `Frame` already carries `whereFrom` — *"Origin of a Move, so the crew's journey is visible rather
|
||||
than a chip teleporting"* — which is the same idea for the viewer's own moves. Extend it, don't
|
||||
invent a second one.
|
||||
|
||||
### 5. Pacing — dwell by kind
|
||||
|
||||
**Measured 2026-09-09, and the measurements decide the model.** From `public/replays/`: ~60 stages
|
||||
per game and ~5 intents per player per stage, so a 4-player table generates **~15 other-player steps
|
||||
per stage**. Burst sizes inside one switching turn, in the switching-heavy seed
|
||||
(`seed-1917398`): **14, 6, 6, 6** — and `trayMoved`'s own narration says *"N of **6** Moves left"*,
|
||||
so six is the engine's cap per crew. Two of the three published replays contain **zero**
|
||||
`switch.move`: bot switching is clustered, not spread.
|
||||
|
||||
So a uniform budget spread over the queue does exactly the wrong thing — it steals time from the
|
||||
6-move switching burst to spend on the 44 `draw.end` and 60 `loadUnload.end`. **Assign dwell by kind
|
||||
and let the total fall out.**
|
||||
|
||||
`sim/pacing.ts` — shared, so the 0.8.1 seatless page paces identically and the TV and the play screen
|
||||
never disagree about how fast the game looks:
|
||||
|
||||
```ts
|
||||
export type StepKind = 'switching' | 'action' | 'bookkeeping';
|
||||
|
||||
/** THE TUNING TABLE. Dwell in ms per kind. Start generous; tune down by playing. */
|
||||
export const DWELL: Record<StepKind, number> = {
|
||||
switching: 1000, // switch.move / dropCars / sortConsist / maneuver.*
|
||||
action: 250, // draw.from* / card.* / newTrain.placeCar / porter.* / laborer.*
|
||||
bookkeeping: 0, // *.end, and any phase where nothing happened
|
||||
};
|
||||
```
|
||||
|
||||
Jesse 2026-09-09: **start switching at 1s and tune down**, and *"make sure that the tuning parameters
|
||||
for the delays are easy to set."* Three levels, deliberately:
|
||||
|
||||
| Level | Where | Reach |
|
||||
| --- | --- | --- |
|
||||
| Committed default | the table above | needs a web rebuild — in the `.s9pk`, a release |
|
||||
| Live per-viewer | `pace` in `Settings` (`localStorage`), a multiplier; **`0` = off** | no rebuild |
|
||||
| Per-session | `?pace=` URL parameter | matches the existing `?seed=` convention; hand two testers different links |
|
||||
|
||||
**Not in game-creation settings.** Jesse, 2026-09-09: *"for now, they should not be in the game
|
||||
creation settings, but we may want to put them there later."* Correct on its own terms — dwell is
|
||||
presentation, not a rule, and `config` rides along in saves and replays. `Settings` already carries
|
||||
the argument for this: *"A save is the seed plus the intents and has to stay portable; none of this
|
||||
belongs in it, and in a multiplayer game two players may reasonably want these set differently."*
|
||||
The migration path is cheap: because the table is shared and the override is one scalar, moving it to
|
||||
game config later means adding a config field that supplies the multiplier's default. The table, the
|
||||
classification and the queue do not change.
|
||||
|
||||
**Pacing is client-side only.** The server emits steps as fast as it likes, which is what keeps
|
||||
Gitea#20's *"do not slow the authoritative game"* true.
|
||||
|
||||
**Arithmetic, because it is the reassuring part:** ~16s of switching plus ~25s of one-shots ≈ **40s
|
||||
of animation across a whole 60-stage game**, against 3.6 minutes for a uniform 700ms. Tiering gives
|
||||
*better* switching visibility for a fifth of the total time. One complete switching exercise is
|
||||
6 × 1s = 6s to watch.
|
||||
|
||||
### 6. The behind-counter, the caption, and skip
|
||||
|
||||
**Jesse's design, 2026-09-09**, and it resolves the "it's your turn but the board is stale" question
|
||||
that had two unattractive answers before it (hold the turn indicator, or show both silently):
|
||||
|
||||
> *"If I saw the counter as I'm watching the board go 17, 16, 15 … and I got impatient, and I could
|
||||
> just click a button and have it skip all the rest."*
|
||||
|
||||
One row, not three additions — the slot between `#turnchart` and `<main>` already holds
|
||||
`#phasenote`, `#announce` and `#presence`, and the palette is spoken for (violet = where you are,
|
||||
amber = clickable, green/red = good/bad):
|
||||
|
||||
```
|
||||
[13 behind] Player Alice moved Train 12 (1,2) → (1,3) via (1,1) [Skip]
|
||||
```
|
||||
|
||||
- The counter **is** `queue.length` — client-side, derived, **zero protocol impact**.
|
||||
- **Count only steps that will dwell.** With bookkeeping at 0ms, a backlog of 17 where 12 are `*.end`
|
||||
would read "17", plummet to 5 instantly, then crawl. Thirteen dwelling steps means thirteen things
|
||||
you are going to watch.
|
||||
- The caption is **#15**. TODO's Reference · #15 asks what the unit should be and concludes that in
|
||||
multiplayer it is *"everything that happened while you were WAITING"* — which is what the queue
|
||||
holds. So #15 is this row, not a separate feature.
|
||||
- **Skip costs the animation and never the information.** Everything skipped is already in the history
|
||||
log. That is what makes the button safe to press without hesitation.
|
||||
- Amber for the button. Self-hides at zero, so solitaire only sees it during an automatic-phase run.
|
||||
- **Input is never blocked.** Any input skips to current. `pace = 0` turns the whole thing off, which
|
||||
is also TODO #18's *"a player who has seen it a hundred times will want it off"* — no second
|
||||
mechanism for it.
|
||||
|
||||
### 7. #18 becomes a dwell setting
|
||||
|
||||
Not a feature. Phases where nothing happened dwell at zero — Jesse, 2026-09-09: *"if nothing happens
|
||||
during a phase then we shouldn't lose time to it"*, and on solitaire, *"you kind of look to see and
|
||||
guess, 'Oh, I guess nothing happened in those phases'"*, which is acceptable. Phases where something
|
||||
happened get a beat through the same queue.
|
||||
|
||||
**A flat second per phase is explicitly rejected**: 5 phases × ~60 stages is about five minutes per
|
||||
game of enforced dwell, most of it spent on phases where nothing happened. TODO's Reference · #18
|
||||
reached the same figure from the other direction (48s/Day).
|
||||
|
||||
Solitaire inherits all of this through `LocalSession` rather than being special-cased.
|
||||
|
||||
### 8. Carried along — switching logs unattributed
|
||||
|
||||
`record()` attributes with `const mine = who !== null && 'player' in e` (`web/game.ts:1188`).
|
||||
**`trayMoved`, `carsCoupled`, `carsDropped` and `consistSorted` carry no `player` field** — measured
|
||||
2026-09-09, and they are the only events in their class that do not. `cardDrawn`, `cardPlayed`,
|
||||
`cardDiscarded`, `carPlacedOnTrain`, `loadStarted`, `loadCompleted`, `flyingSwitch` and
|
||||
`localOpsOptionChosen` all do.
|
||||
|
||||
So switching — the one class of action Jesse most wants to follow — logs unattributed and with
|
||||
`tone: 'plain'` instead of `'act'`, meaning it does not even read as somebody's move:
|
||||
|
||||
```
|
||||
Player Alice chose to switch ← attributed
|
||||
CREW moved (1,2) → (1,3) — 4 of 6 ← whose train?
|
||||
CREW coupled 2 cars ← whose?
|
||||
Player Alice finished Local Operations ← attributed
|
||||
```
|
||||
|
||||
An attributed bracket around unattributed contents. Add `player` to those four events. Fixed here
|
||||
rather than filed, because it is the same feature.
|
||||
|
||||
### Tests
|
||||
|
||||
- Collector: one human intent with no bot response; one human intent followed by several bot intents;
|
||||
consecutive bot turns; bot pending decisions; automatic engine work inside one intent; ordering of
|
||||
narration against frames; sequence continuity; player pushes still coalesced; **no steps emitted
|
||||
during `resumeSession()`**; reconstructed state matching `publicSnapshot()` after the final step.
|
||||
- Public delta: per-seat district deltas reconstruct a frame identical to a fresh full projection.
|
||||
- Redaction: **done 2026-09-09**, by folding the steps into `everythingSeatSees` so that every
|
||||
existing case covers them — the blind draw, the pending decision, Employee Rotation before and
|
||||
after the seating moves, the reconnect and the played-out game — rather than adding one test beside
|
||||
them. Doing it surfaced two false positives in the existing name-based heuristic, neither caused by
|
||||
this feature, and the distinction they forced is worth keeping:
|
||||
- **A card NAME is circumstantial evidence; a card ID is proof.** Ids are searched everywhere. Names
|
||||
are not searched in two places that are legitimately entitled to carry them: lines naming a
|
||||
**face-up pile** (§2.6 — a Department or the Salvage Yard is public, so "Ann discarded Train 6
|
||||
face-up on Department 3" is the record working, and it stays in the log after she takes it back),
|
||||
and the **accumulated step frames**, which are a record of what was public *over time* rather
|
||||
than a view of the position now. What guarantees a step frame is clean is the allow-list test on
|
||||
`PublicFrame`, not a substring search over its history.
|
||||
- The harness also passed `g.log` into `snapshot()` for the Frame's own `lines`, which **production
|
||||
has not done since #97**. Now `[]`, matching `frameFor()`. The log is still audited in full, once.
|
||||
- **Verified by injecting the v0.7.9.2 blind-draw leak and confirming the net still fails** — both
|
||||
the dedicated test and, independently, the new step coverage. A relaxed safety test that has not
|
||||
been shown to still bite is not a safety test.
|
||||
- Pacing: kind classification for every intent type in the `Intent` union, so a new intent cannot
|
||||
land silently in the wrong tier; dwell arithmetic against the table; `pace = 0` produces no dwell.
|
||||
- Queue: step order preserved; skip drains and applies final state; counter counts dwelling steps
|
||||
only; a stopped queue stops its timers.
|
||||
- Attribution: each of the four switching events narrates with the acting player's name and `act`
|
||||
tone.
|
||||
- Solitaire: `LocalSession` produces the same steps for the same intents as `GameSession` does.
|
||||
|
||||
## Summary
|
||||
|
||||
Add a privacy-safe common game board that can be viewed in a browser and published into the game’s Jitsi meeting by a server-managed headless Chromium participant.
|
||||
@@ -29,6 +327,8 @@ The implementation is divided into independently useful stages:
|
||||
6. Supervise one headless Chromium process per published game.
|
||||
7. Integrate lifecycle, configuration, packaging, health, and live verification.
|
||||
|
||||
Steps 1-4 are **v0.8.0** (1 shipped); steps 5-7 are **v0.9.0** — § THE RELEASE SPLIT.
|
||||
|
||||
The browser display remains useful without Jitsi. The public projection and leak fixes improve multiplayer security even if no visual display is deployed.
|
||||
|
||||
## Research incorporated
|
||||
@@ -61,15 +361,20 @@ Introduce dedicated allow-listed types. Do not derive them with `Omit<Frame, ...
|
||||
> **Read those two, not this**, when building steps 2-7. The differences that matter:
|
||||
>
|
||||
> - **The shape is FLAT, not grouped.** There is no `clock`, `config`, `scoring` or `deckCounts`
|
||||
> object. Their contents sit at the top level — `day`, `stage`, `clock` (a time string), `phase`,
|
||||
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentDepth`, `salvage`,
|
||||
> `yards`, `mode`, `days`, `optionalRules`, `houseRules`, `minCombinedRevenue`,
|
||||
> `maxCollisionsPerDay`, `maxCollisionsTotal`, `collisionsToday`, `collisionsTotal`, `status`,
|
||||
> `outcome`, `extraDays`, `extensionVotes`, `official`, `tally`, `openingRolls`, `timetable`,
|
||||
> `timetableWhat`, `trains`, `players`, `division`, `districts`.
|
||||
> - **`protocolVersion` was NOT built** and exists nowhere in the repo. Step 2 is the reconnecting
|
||||
> display stream, which is the first thing that would want one — decide there whether to add it,
|
||||
> rather than assuming it is already on the wire.
|
||||
> object. Their contents sit at the top level. **All 38 properties, which is the same list as
|
||||
> `test/redaction.test.ts`'s allow-list** — `day`, `stage`, `clock` (a time string), `phase`,
|
||||
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentsWhat`,
|
||||
> `departmentDepth`, `salvage`, `yards`, `timetable`, `timetableWhat`, `houseRules`, `mode`,
|
||||
> `optionalRules`, `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`,
|
||||
> `collisionsToday`, `collisionsPrevDay`, `collisionsTotal`, `status`, `outcome`, `extraDays`,
|
||||
> `extensionVotes`,
|
||||
> `official`, `tally`, `players`, `openingRolls`, `trains`, `crewTrays`, `queued`, `division`,
|
||||
> `districts`. The first 36 come from `projectSharedTable`; `division` and `districts` are added
|
||||
> by `PublicFrame` itself.
|
||||
> - **`protocolVersion` was NOT built** and exists nowhere in the repo. **Decided 2026-09-09: add it
|
||||
> in step 2.** `display.json` carries its own `schemaVersion`, and the SSE wire format is a second,
|
||||
> independent contract — parsed in 0.9.0 by a headless agent page that lives for hours, where
|
||||
> `build-web.ts`'s cache-busting does not help.
|
||||
> - **`redFlagHeld` is STRUCK OFF**, not deferred. See below.
|
||||
> - **Fields gained since this was written** that the renderer should know about: `crewTrays` and
|
||||
> `queued` (#98, the Crew Tray pool and the trains waiting for one), and on each district's cells
|
||||
@@ -275,6 +580,13 @@ Cover:
|
||||
Acceptance requires an allow-list review of every `PublicFrame` property. Passing redaction tests alone is insufficient.
|
||||
|
||||
## Step 2 — Display credentials, stream, and persistence
|
||||
> **SPLIT (2026-09-09).** The **display-step stream** part of this step is superseded by § v0.8.0,
|
||||
> which puts steps on the `Session` interface and on `Push.steps` — no endpoint, no credential, and
|
||||
> no change to `http.ts`. What remains here is **v0.8.1**: `display.json`, the `viewToken`, the
|
||||
> `/api/display/stream` SSE endpoint, `/display.html`, the security requirements, and the
|
||||
> `startServer()` refactor plus the first HTTP test harness that testing those needs. `protocolVersion`
|
||||
> and the per-seat public delta moved into 0.8.0. Findings below are from 2026-08-27 and unverified.
|
||||
|
||||
|
||||
### Display metadata
|
||||
|
||||
@@ -305,22 +617,32 @@ For older saves without `display.json`, generate the metadata once on resume and
|
||||
|
||||
### HTTP endpoints
|
||||
|
||||
Add:
|
||||
Add in **0.8.0**:
|
||||
|
||||
- `GET /display.html#token=<viewToken>` — manual common-board page
|
||||
- `GET /api/display/stream?token=<viewToken>` — public-board SSE
|
||||
|
||||
Deferred to **0.9.0** with the publisher (§ THE RELEASE SPLIT):
|
||||
|
||||
- `GET /display-agent.html` — internal headless publisher page
|
||||
- WebSocket upgrade at `/api/display/control` — supervisor/agent control
|
||||
- the supervisor/agent control channel, as **SSE down + POST up**, not a WebSocket upgrade
|
||||
|
||||
Extend the authenticated game/session response with:
|
||||
|
||||
- display URL
|
||||
- Jitsi meeting URL
|
||||
- sanitized publisher state
|
||||
- sanitized publisher state (`'disabled'` until 0.9.0)
|
||||
|
||||
The Jitsi meeting URL joins that response in 0.9.0.
|
||||
|
||||
The browser display reads the fragment token, removes it from the visible address if practical, and supplies it to the SSE request. Fragments keep the credential out of the initial HTTP request and normal server access logs.
|
||||
|
||||
Do not add a publisher-configuration HTTP endpoint. The internal agent receives its meeting configuration and view token over the authenticated control WebSocket.
|
||||
Do not add a publisher-configuration HTTP endpoint. The internal agent receives its meeting configuration and view token over its authenticated control channel.
|
||||
|
||||
**0.8.0 must not presume an upgrade handler exists.** `startServer()` in `src/server/http.ts:169`
|
||||
returns `void` today and there are no HTTP-level tests in the project at all — `test/server/` is
|
||||
lobby, persistence and session only. Step 2 therefore builds the project's first HTTP test harness
|
||||
on top of the `startServer()` refactor, which is a larger opening move than the one line it gets
|
||||
below. TODO #102 is the record of why this half of the codebase was untested until v0.7.9.8.
|
||||
|
||||
### SSE behavior
|
||||
|
||||
@@ -336,6 +658,14 @@ On connection:
|
||||
|
||||
Use a dedicated public-frame delta function rather than the player `Frame` delta. The observed public snapshot grows enough during longer games that full frames for every action would be wasteful.
|
||||
|
||||
**This is new code, not a reuse, and the shape differs from the player delta** (checked 2026-09-09).
|
||||
`src/sim/frame-delta.ts` hardcodes `BOARD_KEYS = ['cells', 'facilities', 'division']` against
|
||||
`Frame`, but `PublicFrame` has no top-level `cells` or `facilities` — they live inside `districts[]`,
|
||||
one entry per seat, which is where nearly all of the bytes are. **Delta `districts` per seat, not as
|
||||
one array**: a single accepted intent changes one district, so a whole-array comparison sends every
|
||||
other player's board again on every step. `deltaFrame`/`applyDelta`'s "null means unchanged, merged
|
||||
against the last full frame the receiver holds" convention is worth keeping; the key set is not.
|
||||
|
||||
If an SSE client is slow or disconnected, close it and let EventSource reconnect to a new reset. Do not keep an unbounded replay buffer.
|
||||
|
||||
### Security requirements
|
||||
@@ -365,6 +695,15 @@ Cover:
|
||||
- display failure not interrupting `/api/intent` or player SSE
|
||||
|
||||
## Step 3 — Reusable common-board renderer
|
||||
> **SPLIT (2026-09-09).** The **canvas** half of this step exists only to feed WebRTC —
|
||||
> `canvas.captureStream(10)` is the only way to hand a rendered board to Jitsi, and DOM cannot be
|
||||
> captured into a `MediaStream`. A TV or an OBS browser source renders HTML directly, and more
|
||||
> crisply. So: the 1280×720 canvas, the 10fps draw loop, `contentHint: 'detail'`, SVG→image
|
||||
> rasterization and the content-keyed image cache are all **v0.9.0**. The **layout** work — an
|
||||
> all-districts seatless page, and adapting the Division/office SVG generators to render without a
|
||||
> private viewer — is **v0.8.1**, and is cheap once 0.8.0's foreign-district rendering exists. The
|
||||
> animation queue and the focused-district rule moved into 0.8.0 (§ v0.8.0 §§ 4, 6).
|
||||
|
||||
|
||||
### Renderer structure
|
||||
|
||||
@@ -447,9 +786,30 @@ Test renderer lifecycle with a fake canvas/image layer:
|
||||
Perform visual review at 1280×720 and as a reduced Jitsi tile. Text and train positions must remain legible without opening a tooltip.
|
||||
|
||||
## Step 4 — Preserve individual human and bot actions
|
||||
> **SUPERSEDED BY § v0.8.0 (2026-09-09).** Read that section, not this one. The collector design here
|
||||
> is broadly right and its "Current code findings" were re-verified 2026-09-09, so both are kept —
|
||||
> but three things changed: the collector lives in `sim/` and is called by `LocalSession` too rather
|
||||
> than being a `GameSession` private; *"Keep player pushes unchanged"* below is **reversed** (that was
|
||||
> the open question, and #13 is the answer); and the mechanism now includes foreign-district
|
||||
> rendering, pacing by kind, and the behind-counter, none of which are described here.
|
||||
|
||||
|
||||
### Current code findings
|
||||
|
||||
> **RE-VERIFIED 2026-09-09.** The three findings below still hold, and the insertion point is as
|
||||
> cheap as they imply: `intent()` (`src/server/session.ts:361`) is synchronous — `check` → `submit`
|
||||
> → `driveBotTurns()` → `pushesForAll()` — and `driveBots()` (`:320`) loops `submit()`. A collector
|
||||
> after each `submit()` is two call sites and needs no async surgery. **Two traps that are not in
|
||||
> the text below and were found by reading the code rather than the plan:**
|
||||
>
|
||||
> - **The collector must be inert during replay.** `resumeSession()` rebuilds a game by replaying
|
||||
> its whole history through `submit()`. Wired naively, every server restart re-emits the entire
|
||||
> game as display steps and burns `nextSequence` in `display.json`. This is the same class of bug
|
||||
> as #97 — a mechanism firing on a path nobody pictured it running on.
|
||||
> - **Narration-per-step needs its own high-water mark.** `sentLines` is per-seat and is *mutated*
|
||||
> by `linesSince()` as a side effect of building a push, so step 2 of "Required changes" cannot
|
||||
> read it. Track an independent mark against `game.log.length`.
|
||||
|
||||
`GameSession.intent()` applies the human intent, runs `driveBots()`, and only then creates player pushes.
|
||||
|
||||
`driveBots()` can call `submit()` many times. Player deltas intentionally collapse those moves into one final state, which is appropriate for gameplay but would make bots appear to teleport through several actions on the common board.
|
||||
@@ -480,6 +840,24 @@ One display step corresponds to one accepted intent, including automatic consequ
|
||||
|
||||
Keep player pushes unchanged: players still receive the final coalesced result after all immediately due bots finish.
|
||||
|
||||
> **REVERSED FOR 0.8.0 — RESOLVED 2026-09-09. See § v0.8.0.**
|
||||
>
|
||||
> The sentence above scoped step 4 to the seatless board. It was the open question of this design and
|
||||
> it now has an answer: **seated players receive steps too**, because #13 is what 0.8.0 is for.
|
||||
>
|
||||
> How the three objections that made it an open question were settled:
|
||||
>
|
||||
> - *A player waiting to act cannot lag a second behind.* Answered by the **behind-counter and skip**
|
||||
> (§ v0.8.0 § 6) rather than by policy — the lag is shown, counted down, and skippable, so the
|
||||
> player decides instead of the design guessing.
|
||||
> - *A step sent to a seat is a `Frame`, and `Frame`s are redacted per seat.* Answered by **not
|
||||
> sending a `Frame`**: public steps animate the board and the existing private Push supplies hand,
|
||||
> menu and objective. Zero new redaction surface.
|
||||
> - *Animating other people's turns makes the game feel slower.* Answered by **dwell by kind** — the
|
||||
> measured cost is ~40s of animation across a 60-stage game, and bookkeeping dwells at zero.
|
||||
>
|
||||
> Player pushes are still coalesced; what changes is that they now also carry `steps`.
|
||||
|
||||
Opening bot moves that occur before any client connects do not need replay. Persist the resulting game state and sequence; a later display receives the final reset.
|
||||
|
||||
### Failure isolation
|
||||
@@ -509,6 +887,14 @@ Cover:
|
||||
|
||||
## Step 5 — Minimal visual-only Jitsi engine
|
||||
|
||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
||||
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
|
||||
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
|
||||
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
|
||||
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
|
||||
> that declares no `hardwareRequirements` today.
|
||||
|
||||
|
||||
### Source strategy
|
||||
|
||||
Port the smallest relevant production patterns from `jitsi-transcription` into Station Master with attribution where required. Do not import the sibling repository at runtime, add it as a submodule, or copy its transcription/audio/chat features.
|
||||
@@ -530,7 +916,12 @@ Pin the known working `lib-jitsi-meet` release:
|
||||
v2192.0.0+d6f3312f
|
||||
```
|
||||
|
||||
Add `ws` for the Node control broker. Do not add Playwright.
|
||||
**Do not add `ws`, and do not add Playwright.** The control channel is SSE down + POST up
|
||||
(§ THE RELEASE SPLIT), so it needs nothing beyond `node:http`, which the server already uses.
|
||||
`lib-jitsi-meet` is browser-side only: vendor the pinned release into `public/` and load it with a
|
||||
`<script>` tag — there is no bundler in this project, `scripts/build-web.ts` runs bare `tsc`. Pin
|
||||
and checksum it; it is a multi-megabyte minified blob entering the repo, which is a decision to
|
||||
take deliberately rather than a build artefact.
|
||||
|
||||
### Agent page
|
||||
|
||||
@@ -547,7 +938,7 @@ Add `ws` for the Node control broker. Do not add Playwright.
|
||||
9. Report normalized lifecycle state over the control channel.
|
||||
10. Leave and stop all tracks on supervisor command or `pagehide`.
|
||||
|
||||
Meeting server, room, XMPP configuration, and view token are delivered over the control WebSocket, not placed in query parameters.
|
||||
Meeting server, room, XMPP configuration, and view token are delivered over the control channel, not placed in query parameters.
|
||||
|
||||
### Jitsi publishing
|
||||
|
||||
@@ -636,6 +1027,14 @@ Port/adapt the sibling repository’s proven tests for:
|
||||
|
||||
## Step 6 — Chromium publisher supervisor
|
||||
|
||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
||||
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
|
||||
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
|
||||
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
|
||||
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
|
||||
> that declares no `hardwareRequirements` today.
|
||||
|
||||
|
||||
### Process model
|
||||
|
||||
Create one Chromium child process per published game.
|
||||
@@ -699,7 +1098,8 @@ The broker must:
|
||||
- allow a newer engine to supersede only the same session
|
||||
- reject client/engine role changes on an established socket
|
||||
- clear pending commands when an engine disconnects
|
||||
- reconnect the agent-side WebSocket every two seconds until stopped
|
||||
- reconnect the agent-side control stream every two seconds until stopped — note that an
|
||||
`EventSource` does this by itself, which is part of why the transport changed
|
||||
|
||||
Public frames remain on SSE and never traverse this control protocol.
|
||||
|
||||
@@ -760,6 +1160,14 @@ Use fake child processes and fake control sockets to test:
|
||||
|
||||
## Step 7 — Configuration, lifecycle, packaging, and observability
|
||||
|
||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
||||
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
|
||||
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
|
||||
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
|
||||
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
|
||||
> that declares no `hardwareRequirements` today.
|
||||
|
||||
|
||||
### Configuration
|
||||
|
||||
Support:
|
||||
@@ -844,8 +1252,10 @@ Exclude all tokens, full control frames, player private state, query strings, an
|
||||
|
||||
Add runtime dependencies:
|
||||
|
||||
- the pinned `lib-jitsi-meet` release
|
||||
- `ws`
|
||||
- the pinned `lib-jitsi-meet` release, vendored as a browser asset rather than an npm runtime import
|
||||
|
||||
**No Node runtime dependency is added.** `package.json` has no `dependencies` key and the wrapper's
|
||||
runtime image copies only `package.json` and `src/` — see § THE RELEASE SPLIT. Keep it that way.
|
||||
|
||||
Add Chromium and `tini` to the Station Master runtime image or companion StartOS packaging repository.
|
||||
|
||||
@@ -912,7 +1322,7 @@ Capture:
|
||||
|
||||
- raw display SSE
|
||||
- rendered canvas screenshots
|
||||
- control WebSocket messages
|
||||
- control-channel messages
|
||||
- Jitsi network destinations
|
||||
- server logs
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ card prints are what it costs to cross. Where a train *enters* is what the rules
|
||||
train on Hilly, a Heavy Grade with Helpers, or a card whose back region is a siding rather than
|
||||
part of the road all change the entry point rather than the card's length.
|
||||
|
||||
| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Sorts cars |
|
||||
| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Extra may start |
|
||||
| --- | ---: | ---: | --- | :---: | :---: |
|
||||
| Plains | 1 | 0 | — | — | — |
|
||||
| Curves | 2 | 0 | — | — | — |
|
||||
@@ -84,15 +84,15 @@ part of the road all change the entry point rather than the card's length.
|
||||
The Mainline deck is 10 cards; the two Division Points are the fixed ends of
|
||||
the Division and are not dealt. What each card does, in the words the game uses on screen:
|
||||
|
||||
- **Plains** — 1 region — one Stage each. · 1 Stage for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Curves** — 2 regions — one Stage each. · 2 Stages for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Plains** — 1 region — one Stage each. · 1 Stage for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Curves** — 2 regions — one Stage each. · 2 Stages for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Hilly** — 2 regions — one Stage each. · This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses in 1 Stage, a slow one in 2 Stages. No other card cares which it is. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Heavy Grade** — 3 regions — one Stage each. · A grade, climbing eastward. 3 Stages to climb it and 3 Stages to run down, before help. Helpers start an UPHILL train a region further on; Brakeman does the same DOWNHILL and Airbrakes another again, and Airbrakes cannot be played without Brakeman. Never less than one Stage. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Double Track** — 1 region — one Stage each. · 1 Stage for every train — the printed speed is scenery. · TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held behind a slower one.
|
||||
- **Double Track** — 1 region — one Stage each. · 1 Stage for every train. · TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held behind a slower one.
|
||||
- **Uncontrolled Siding** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region behind it. You are not in the same place, so you do not run into it; it costs you the extra Stage instead. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Tunnel** — 2 regions — one Stage each. · 2 Stages for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Trestle** — 1 region — one Stage each. · 1 Stage for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Interchange** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · An Extra beginning its run here starts in the back region and takes the extra Stage. · One train at a time — anything following has to wait for it to clear. · Cars may be sorted into any new order here.
|
||||
- **Tunnel** — 2 regions — one Stage each. · 2 Stages for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Trestle** — 1 region — one Stage each. · 1 Stage for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Interchange** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · An Extra beginning its run here starts in the back region and takes the extra Stage. · One train at a time — anything following has to wait for it to clear. · This is the one Mainline card with a yard, so an Extra Train may be made up and started here. Its printed car-sorting is NOT implemented — a consist is re-ordered at a Small Yard in a district.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.7.9.8",
|
||||
"version": "0.8.0.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
@@ -10,7 +10,9 @@
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"pretest": "tsc --noEmit && node scripts/build-web.ts",
|
||||
"test": "node --test test/*.test.ts test/**/*.test.ts",
|
||||
"test": "node --test $(ls test/*.test.ts test/**/*.test.ts | grep -v '^test/sim.test.ts$')",
|
||||
"pretest:sim": "tsc --noEmit",
|
||||
"test:sim": "node --test test/sim.test.ts",
|
||||
"build:web": "node scripts/build-web.ts",
|
||||
"serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
|
||||
"deploy:web": "node scripts/deploy-web.ts",
|
||||
|
||||
@@ -126,7 +126,7 @@ w('card prints are what it costs to cross. Where a train *enters* is what the ru
|
||||
w('train on Hilly, a Heavy Grade with Helpers, or a card whose back region is a siding rather than');
|
||||
w('part of the road all change the entry point rather than the card\'s length.');
|
||||
w();
|
||||
w('| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Sorts cars |');
|
||||
w('| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Extra may start |');
|
||||
w('| --- | ---: | ---: | --- | :---: | :---: |');
|
||||
for (const m of MAINLINE_PROFILES) {
|
||||
const ss = m.speedStarts ? `${m.speedStarts.fast} / ${m.speedStarts.slow}` : '—';
|
||||
|
||||
+31
-4
@@ -70,11 +70,17 @@ function buildStamp(): string {
|
||||
* and v0.7.6's fix to it both shipped correctly to `phoenix.local` and neither reached the browser
|
||||
* that asked for them (Jesse, twice, 2026-08-29 — "setup did not work").
|
||||
*
|
||||
* The version plus the build's own timestamp is always distinct, needs nothing from the
|
||||
* environment, and stays honest: two builds of the same commit ARE two deploys, and a cache key
|
||||
* that says so costs one refetch, while one that lies costs a release nobody receives.
|
||||
* A marker plus the build's own timestamp is always distinct, needs nothing from the environment,
|
||||
* and stays honest: two builds of the same commit ARE two deploys, and a cache key that says so
|
||||
* costs one refetch, while one that lies costs a release nobody receives.
|
||||
*
|
||||
* NOT THE VERSION, which is what this used to lead with. The stamp below already begins with
|
||||
* `v${pkg.version}`, so on exactly the builds that take this path — every `.s9pk`, which has no
|
||||
* `.git` — the header read "v0.8.0.10 · 0.8.0.10-mfq2p1 · …" and the version appeared twice
|
||||
* (Jesse, playtest 2026-09-16). The timestamp alone carries the uniqueness; the version is
|
||||
* already said once, properly, at the front.
|
||||
*/
|
||||
let git = `${pkg.version}-${Date.now().toString(36)}`;
|
||||
let git = `nogit-${Date.now().toString(36)}`;
|
||||
try {
|
||||
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
|
||||
.toString()
|
||||
@@ -182,6 +188,27 @@ if (existsSync(imageSrc)) {
|
||||
for (const f of readdirSync(imageSrc)) copyFileSync(join(imageSrc, f), join(imageOut, f));
|
||||
}
|
||||
|
||||
/**
|
||||
* The Quickstart, published beside the game so a tester can reach it from the box.
|
||||
*
|
||||
* COPIED, NOT RE-WRITTEN. `docs/StationMaster-Quickstart.md` is the one copy; a hand-written HTML
|
||||
* twin would drift from it on the first edit, which is the whole lesson of TODO #15a and of the
|
||||
* 2026-09-20 documentation pass that found four references a month out of date.
|
||||
*
|
||||
* SERVED AS PLAIN TEXT for now, which is honest rather than good: tables render as pipes and the
|
||||
* links do not click. Rendering it into a styled page needs a small Markdown converter and is
|
||||
* filed as TODO #109 — this is the fifteen-minute version that gets the guide in front of testers
|
||||
* for this round rather than leaving them without one.
|
||||
*/
|
||||
const guideSrc = join(root, 'docs/StationMaster-Quickstart.md');
|
||||
if (existsSync(guideSrc)) {
|
||||
copyFileSync(guideSrc, join(dist, 'quickstart.md'));
|
||||
} else {
|
||||
// Loud rather than silent: a missing guide is a broken link on the splash page, and the build is
|
||||
// the only place that can still notice.
|
||||
console.error('WARNING: docs/StationMaster-Quickstart.md is missing — the splash link will 404');
|
||||
}
|
||||
|
||||
// A tiny note for whoever unzips this later and wonders what it needs.
|
||||
writeFileSync(
|
||||
join(dist, 'README.txt'),
|
||||
|
||||
+87
-4
@@ -31,12 +31,13 @@ import {
|
||||
houseRules,
|
||||
officeProfile,
|
||||
mainlineProfile,
|
||||
consistSize,
|
||||
} from './content.ts';
|
||||
import type { Direction, MainlineEntry, MainlineKind } from './content.ts';
|
||||
import type { CarType, Direction, MainlineEntry, MainlineKind } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
||||
// the legality test cannot disagree about which train is being assembled.
|
||||
import { areaAtSeat, areaOf, occupancyFor, trainNeedingCars } from './apply.ts';
|
||||
import { acceptsCar, areaAtSeat, areaOf, isBeingMadeUp, occupancyFor, trainNeedingCars } from './apply.ts';
|
||||
import { legalActions } from './legal.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, GridCoord, Outcome, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||
import { cloneTally, coordKey, freshTurns, isExtendable, playerAtSeat, playerLeftOf, pooled, railFacingOf, subdivisions, totalRevenue, turnOf } from './state.ts';
|
||||
@@ -94,6 +95,18 @@ function nodeIndexOfOffice(s: GameState, seat: SeatIndex): number {
|
||||
|
||||
const step = (d: Direction): number => (d === 'east' ? 1 : -1);
|
||||
|
||||
/** The Division node a train is standing on — its Office, its Mainline card, or its Division Point. */
|
||||
function nodeIndexOfTray(s: GameState, tray: CrewTray): number | null {
|
||||
const at = tray.position;
|
||||
if (at.at === 'grid') return nodeIndexOfOffice(s, at.seat);
|
||||
if (at.at === 'mainline') return at.index;
|
||||
if (at.at === 'divisionPoint') {
|
||||
const side = at.side;
|
||||
return s.division.nodes.findIndex((n) => n.kind === 'divisionPoint' && n.side === side);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// advance
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -381,6 +394,45 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
return { events, needsInput: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* SAY SO WHEN A TRAIN GOT NOTHING, before the round is over and the train runs (playtest,
|
||||
* 2026-09-16: "train 5, the sparrow, has no coaches, which seems strange").
|
||||
*
|
||||
* `trainNeedingCars` returns null both when every consist is full and when the Division Yard holds
|
||||
* nothing a short train will take — the same answer for "done" and for "cannot be done" — so the
|
||||
* phase moved on in silence and the only trace was a MADE UP line promising "now taking cars". The
|
||||
* Sparrow calls for three coaches and left empty twice in one game.
|
||||
*
|
||||
* REPORTED HERE RATHER THAN AT THE MADE-UP MOMENT, because a train made up early in the round can
|
||||
* still be filled by a later placement; only once the round has nothing left to offer is the
|
||||
* shortfall a fact. This is reached exactly once per Stage — the next line enters the Mainline
|
||||
* Phase — so the report cannot repeat.
|
||||
*/
|
||||
for (const tray of s.trays.values()) {
|
||||
if (!isBeingMadeUp(tray) || tray.trainNumber === null) continue;
|
||||
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
|
||||
if (!profile) continue;
|
||||
const category = (t: CarType): 'freight' | 'coach' | 'caboose' =>
|
||||
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
|
||||
// What the card still wants: asked of `acceptsCar` per category, so a full category and a
|
||||
// category barred by the card's own rules answer the same way here as they do to a player.
|
||||
const missing = (['freight', 'coach', 'caboose'] as const).filter((cat) => {
|
||||
const sample: CarType = cat === 'coach' ? 'coach' : cat === 'caboose' ? 'caboose' : 'boxcar';
|
||||
return acceptsCar(tray, sample);
|
||||
});
|
||||
if (missing.length === 0) continue;
|
||||
events.push({
|
||||
type: 'makeUpShort',
|
||||
trainNumber: tray.trainNumber,
|
||||
isExtra: tray.trainIsExtra,
|
||||
placed: tray.consist.length,
|
||||
wanted: consistSize(profile.consist),
|
||||
missing: [...missing],
|
||||
waiting: s.yards.classificationYard.filter((c) => missing.includes(category(c.type))).length,
|
||||
divisionYardHolds: s.yards.divisionYard.length,
|
||||
});
|
||||
}
|
||||
|
||||
return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false };
|
||||
}
|
||||
|
||||
@@ -522,14 +574,17 @@ type MoveOutcome = 'moved' | 'held' | 'needsClearance';
|
||||
*
|
||||
* This is reachable purely through switching. A train arrives made up, and only comes apart because
|
||||
* the player took cars onto the nose or picked up a cut in a run-around.
|
||||
*
|
||||
* Exported for the switching planner (`sim/switch-planner.ts`), which has to know whether a plan
|
||||
* leaves a train unable to run — and must ask this rule rather than keep a copy of it.
|
||||
*/
|
||||
function badlyMadeUp(tray: CrewTray): string | null {
|
||||
export function badlyMadeUp(tray: CrewTray): string | null {
|
||||
const n = tray.consist.length;
|
||||
if (n === 0) return null;
|
||||
const pulling = tray.engineAt === 0;
|
||||
const pushing = tray.engineAt === n;
|
||||
if (!pulling && !pushing) {
|
||||
return `not made up — the engine is buried in the train, ${tray.engineAt} car(s) ahead of it`;
|
||||
return `not made up — the engine is buried in the train, ${tray.engineAt} car${tray.engineAt === 1 ? '' : 's'} ahead of it`;
|
||||
}
|
||||
const caboose = tray.consist.findIndex((c) => c.type === 'caboose');
|
||||
if (caboose === -1) return null;
|
||||
@@ -1064,10 +1119,27 @@ function evaluateClearance(
|
||||
* constrained. Each Office upgrade to a Control Point splits one in two and buys capacity.
|
||||
*/
|
||||
const subdivision = subdivisions(s).find((group) => group.includes(targetIndex)) ?? [targetIndex];
|
||||
/**
|
||||
* ONLY WHAT IS AHEAD (Gitea#26). §8.1 asks about a train the considered train would FOLLOW, and one
|
||||
* moving TOWARDS it — both of which are ahead of it. A Subdivision runs the length of every Whistle
|
||||
* Post between two Control Points, so it can hold a train BEHIND the one departing: in the reported
|
||||
* game X15 highballed west from an Office while X18, also westbound, was still crossing the card to its
|
||||
* east. Counting X18 put a meaningless ruling to the Superintendent; holding X15 kept the Whistle Post's
|
||||
* one A/D track full, and X18 arrived into it and was destroyed. A train behind and moving away is no
|
||||
* threat at all.
|
||||
*
|
||||
* "Behind" is strictly behind the card the departing train stands on. A train on that same card is still
|
||||
* counted, exactly as before: which of two trains sharing a card is in front is `entryConflict`'s region
|
||||
* question, and this is not the place to answer it.
|
||||
*/
|
||||
const from = nodeIndexOfTray(s, tray);
|
||||
const behind = (onCard: number): boolean =>
|
||||
from !== null && from >= 0 && (tray.direction === 'east' ? onCard < from : onCard > from);
|
||||
const occupants: { tray: TrayId; onCard: number }[] = [];
|
||||
for (const i of subdivision) {
|
||||
const n = s.division.nodes[i];
|
||||
if (!n || n.kind !== 'mainline') continue;
|
||||
if (behind(i)) continue;
|
||||
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
|
||||
}
|
||||
|
||||
@@ -1452,6 +1524,7 @@ function arriveAtOffice(
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
consist: tray.consist.map((c) => ({ ...c })),
|
||||
office: officeProfile(area.tier).name,
|
||||
owner: playerAtSeat(s, seat),
|
||||
expedited: isExpedited(tray),
|
||||
});
|
||||
|
||||
@@ -1623,6 +1696,13 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
|
||||
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
|
||||
events.push({ type: 'actorChanged', player: s.clock.superintendent });
|
||||
/**
|
||||
* SAID OUT LOUD, as well as recorded. `actorChanged` is turn bookkeeping and the log discards it,
|
||||
* so this — the one time in three Stages that it means the Fedora moved — had no line anywhere
|
||||
* (playtest, 2026-09-16). Emitted alongside rather than instead: `actorChanged` still carries the
|
||||
* cursor, and anything reading it keeps working.
|
||||
*/
|
||||
events.push({ type: 'superintendentChanged', player: s.clock.superintendent, stage: s.clock.stage });
|
||||
}
|
||||
|
||||
// §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase.
|
||||
@@ -1638,6 +1718,9 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
|
||||
s.clock.day += 1;
|
||||
s.clock.stage = 1;
|
||||
// Captured BEFORE the reset: the Day-end dialog reports the Day that just finished, and it is
|
||||
// drawn from the frame this rollover produces. See `collisionsPrevDay` in `state.ts`.
|
||||
s.collisionsPrevDay = s.collisionsToday;
|
||||
s.collisionsToday = 0;
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
|
||||
rotateSeats(s, events);
|
||||
|
||||
+208
-30
@@ -974,6 +974,16 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
const seen = new Set(i.order);
|
||||
if (seen.size !== i.order.length) return 'CONSIST_ORDER';
|
||||
if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER';
|
||||
/**
|
||||
* The engine may finish anywhere in the train, including with cars ahead of it (Jesse,
|
||||
* 2026-09-17). `engineAt` indexes the SORTED consist, so `consist.length` is legal and means
|
||||
* the engine on the tail with everything ahead of it — the shoving case a Small Yard exists to
|
||||
* set up. Refused outside that range rather than clamped: a clamp would silently build a
|
||||
* different train from the one the player asked for.
|
||||
*/
|
||||
if (i.engineAt !== undefined && (i.engineAt < 0 || i.engineAt > tray.consist.length)) {
|
||||
return 'CONSIST_ORDER';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1426,17 +1436,30 @@ function checkPlay(
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
|
||||
/**
|
||||
* NOT ON THE RUNNING TRACK ROW — AND NOT BOUNDED BY THE LIMITS EITHER. Jesse's call, both
|
||||
* halves.
|
||||
* NEITHER ON THE RUNNING TRACK ROW NOR OUTSIDE THE LIMITS — Jesse's call, both halves, the
|
||||
* second REVERSED on 2026-09-17 after a Day 3 playtest.
|
||||
*
|
||||
* A Modifier is not track (§9), so unlike a siding it may hang outside the Limits: a Facility
|
||||
* standing at the limit has three of its nine spots out there, and refusing them would make
|
||||
* the card unplayable exactly where the district ends. What it may NOT do is stand in the row
|
||||
* the Running Track grows along. Inside the Limits that row is always full, so this bites only
|
||||
* beyond the sign — which is the ground the main extends onto, and a Modifier parked there
|
||||
* would block your own sign from moving outward (§2.1) with nothing on screen to warn you.
|
||||
* It used to read the other way: a Modifier is not track (§9), so unlike a siding it could
|
||||
* hang outside the Limits, because a Facility standing at the limit has three of its nine
|
||||
* spots out there and refusing them would make the card unplayable exactly where the district
|
||||
* ends. What that argument missed is what the board then shows — Transmission Lines at (-2,4)
|
||||
* with the sign at column 3 — which reads as building outside your own territory, and §8.1 and
|
||||
* §10 both reason about what lies inside a player's Limits.
|
||||
*
|
||||
* THE UNPLAYABLE CASE WAS CHECKED ON THE REPORTED MOVE, not assumed away: the Power Plant was
|
||||
* at (-1,3) against a sign at column 3, and (-2,2) and (-2,3) were free, legal and inside. Six
|
||||
* of the nine spots survive a Facility at the limit, and the sign moves outward as the Running
|
||||
* Track grows (§2.1, Gap 4a), so the ground arrives with the district.
|
||||
*
|
||||
* The Running Track row stays barred for its own reason: inside the Limits that row is always
|
||||
* full, so it bit only beyond the sign, where a Modifier would block the sign from moving
|
||||
* outward with nothing on screen to warn you. That ground is now out of bounds anyway, which
|
||||
* makes this the narrower rule rather than a redundant one — the row is barred INSIDE the
|
||||
* Limits too, where a square can fall vacant if the main is rebuilt around it.
|
||||
*/
|
||||
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
|
||||
// §2.1 — a district's cards belong inside its own sign, Modifiers included since 2026-09-17.
|
||||
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
|
||||
// One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses.
|
||||
if (hasModifierInArea(area, card.kind.modifier)) return 'FACILITY_LOCKED';
|
||||
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
|
||||
@@ -1508,17 +1531,48 @@ export function selectDestination(
|
||||
return chosen ?? atTo[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* ROUTES, WALKED ONCE PER POSITION.
|
||||
*
|
||||
* A route walk (`reachableDestinations`) was a third of all simulation time, and most of it was the
|
||||
* same walk repeated: `legal.ts` walks a tray's routes to list its moves, then `check` walks them again
|
||||
* for every one of those moves, and `applyIntent` walks the chosen one a third time in `execute`.
|
||||
* Profiled 2026-09-14 with inlining off: `reachableDestinations` 34% inclusive, garbage collection 34%.
|
||||
*
|
||||
* So while one position is being examined — a legal-action listing, or the check and execute of one
|
||||
* intent — a walk is kept and reused. Both scopes read the state and never write it, the key names
|
||||
* everything the walk depends on besides that state, and the cache is keyed to the state OBJECT and
|
||||
* cleared when the scope ends, so a hit returns exactly what a fresh walk would have. Nothing may
|
||||
* mutate a returned route; nothing does.
|
||||
*/
|
||||
let routeCache: { state: GameState; routes: Map<string, MoveDestination[]> } | null = null;
|
||||
|
||||
export function withRouteCache<T>(s: GameState, fn: () => T): T {
|
||||
if (routeCache) return fn();
|
||||
routeCache = { state: s, routes: new Map() };
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
routeCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
function destinationsFor(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
trayId: TrayId,
|
||||
from: GridCoord,
|
||||
reverse: boolean,
|
||||
) {
|
||||
): MoveDestination[] {
|
||||
const cache = routeCache?.state === s ? routeCache.routes : null;
|
||||
const key = cache ? `${player}|${trayId}|${from.row},${from.col}|${reverse ? 1 : 0}` : '';
|
||||
const hit = cache?.get(key);
|
||||
if (hit) return hit;
|
||||
|
||||
const tray = s.trays.get(trayId)!;
|
||||
const facing = facingPort(s, trayId);
|
||||
const exit: Port = reverse ? reversePort(s, player, from, facing) : facing;
|
||||
return reachableDestinations(
|
||||
const routes = reachableDestinations(
|
||||
{
|
||||
area: areaOf(s, player),
|
||||
occupancy: occupancyFor(s, player, trayId),
|
||||
@@ -1528,6 +1582,8 @@ function destinationsFor(
|
||||
from,
|
||||
exit,
|
||||
);
|
||||
cache?.set(key, routes);
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1642,10 +1698,14 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
const events: GameEvent[] = [
|
||||
{
|
||||
type: 'trayMoved',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
from,
|
||||
to: i.to,
|
||||
movesRemaining: turnOf(s, player).movesRemaining - 1,
|
||||
// "3 of 6" was written with the 6 hardcoded in the narrator, which is wrong on a night
|
||||
// Stage under Reduced Visibility, where a turn gets five. The turn knows; the event carries.
|
||||
movesAllowed: turnOf(s, player).movesAllowed,
|
||||
/**
|
||||
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND — AND A CURVE IS NOT A STRAIGHT.
|
||||
*
|
||||
@@ -1706,6 +1766,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
// decides which car is next to come off.
|
||||
events.push({
|
||||
type: 'carsCoupled',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
at: i.to,
|
||||
stock: dest.couples,
|
||||
@@ -1726,7 +1787,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
const stock = i.fromNose
|
||||
? tray.consist.slice(0, i.count)
|
||||
: tray.consist.slice(tray.consist.length - i.count);
|
||||
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
|
||||
return [{ type: 'carsDropped', player, trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
|
||||
}
|
||||
|
||||
case 'switch.sortConsist': {
|
||||
@@ -1735,15 +1796,34 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
return [
|
||||
{
|
||||
type: 'consistSorted',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
at: here,
|
||||
before: tray.consist.map((c) => ({ ...c })),
|
||||
after: i.order.map((n) => ({ ...tray.consist[n]! })),
|
||||
// Absent means the nose, which is what every sort did before 2026-09-17 — so an older save
|
||||
// replays to exactly the train it built.
|
||||
engineAt: i.engineAt ?? 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'switch.end':
|
||||
/**
|
||||
* The closing summary comes BEFORE `phaseEnded`, so the history reads as the turn ending rather
|
||||
* than as a postscript to it. Split out of the shared case below for that one line.
|
||||
*/
|
||||
case 'switch.end': {
|
||||
const turn = turnOf(s, player);
|
||||
const ended: GameEvent = {
|
||||
type: 'switchingEnded',
|
||||
player,
|
||||
movesUsed: turn.movesAllowed - turn.movesRemaining,
|
||||
movesAllowed: turn.movesAllowed,
|
||||
...(turn.lastMove ? { lastMove: turn.lastMove } : {}),
|
||||
};
|
||||
return [ended, { type: 'phaseEnded', player, phase: 'localOps' }];
|
||||
}
|
||||
|
||||
case 'draw.end':
|
||||
case 'freightAgent.end':
|
||||
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
|
||||
@@ -1853,7 +1933,15 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
? REALIGNMENTS.find((r) => r.from === node.card)?.to
|
||||
: undefined;
|
||||
return [
|
||||
{ type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) },
|
||||
{
|
||||
type: 'mainlineModified',
|
||||
player,
|
||||
cardId: i.cardId,
|
||||
node: i.node,
|
||||
key,
|
||||
...(node?.kind === 'mainline' ? { from: node.card } : {}),
|
||||
...(became ? { became } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1912,18 +2000,41 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
];
|
||||
}
|
||||
|
||||
case 'newTrain.placeCar':
|
||||
/**
|
||||
* THE TRAIN'S NUMBER RIDES ALONG (playtest, 2026-09-16: "I did not see anything in the history
|
||||
* about making up train 10 and how each person added each car to it").
|
||||
*
|
||||
* It was all there — a MADE UP line and one line per car — but every one of those lines read
|
||||
* "the train being made up", so a player scanning the history for train 10 found nothing under
|
||||
* that name. The tray id is no use to a reader and the narrator has no state to look it up in,
|
||||
* so the number travels with the event, exactly as `owner` does on `trainArrived`.
|
||||
*/
|
||||
case 'newTrain.placeCar': {
|
||||
const placeTray = s.trays.get(i.trayId);
|
||||
return [
|
||||
{
|
||||
type: 'carPlacedOnTrain',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
stock: { type: i.carType, loaded: i.loaded },
|
||||
trainNumber: placeTray?.trainNumber ?? null,
|
||||
isExtra: placeTray?.trainIsExtra ?? false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'newTrain.passCar':
|
||||
return [{ type: 'carPassed', player, trayId: i.trayId }];
|
||||
case 'newTrain.passCar': {
|
||||
const passTray = s.trays.get(i.trayId);
|
||||
return [
|
||||
{
|
||||
type: 'carPassed',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
trainNumber: passTray?.trainNumber ?? null,
|
||||
isExtra: passTray?.trainIsExtra ?? false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'newTrain.secondSection':
|
||||
return [{ type: 'secondSectionOrdered', player, trainNumber: i.trainNumber }];
|
||||
@@ -2097,7 +2208,10 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
}
|
||||
// Only the player sitting in this district can be switching this tray, so the Moves come off
|
||||
// their turn. The event carries no player of its own.
|
||||
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
|
||||
const mover = turnOf(s, playerAtSeat(s, seat));
|
||||
mover.movesRemaining = e.movesRemaining;
|
||||
// Where the crew was left, for the line that closes the turn — see `switchingEnded`.
|
||||
mover.lastMove = { trayId: e.trayId, to: e.to };
|
||||
|
||||
const area = areaAtSeat(s, seat);
|
||||
|
||||
@@ -2175,9 +2289,18 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'consistSorted': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
tray.consist = e.after.map((c) => ({ ...c }));
|
||||
// A Small Yard re-makes the train, and putting the engine back on the nose is the whole reason
|
||||
// to use one: §8.2 will not let a train leave the Office with cars in front of its engine.
|
||||
tray.engineAt = 0;
|
||||
/**
|
||||
* WHERE THE SORT PUT THE ENGINE — 0 on every sort before 2026-09-17, and on most of them
|
||||
* since, because putting the engine back on the nose is what a Small Yard is usually for:
|
||||
* §8.2 will not let a train leave the Office with cars in front of its engine.
|
||||
*
|
||||
* It is no longer forced. The design source (`implications.md`) has always said a train here
|
||||
* "may sort itself into any order, INCLUDING cars ahead of the engine", against a v0.4.5 card
|
||||
* text that says the engine ends at the nose; Jesse settled it for the source. A numbered
|
||||
* train left nose-loaded is held at the Office by §8.2 until it sorts again — see
|
||||
* `departureRefusal`.
|
||||
*/
|
||||
tray.engineAt = e.engineAt;
|
||||
// "Spends one move in the yard" — the sort costs a Move.
|
||||
const sorter = turnOf(s, playerAtSeat(s, trayySeat(tray)));
|
||||
sorter.movesRemaining = Math.max(0, sorter.movesRemaining - 1);
|
||||
@@ -2239,7 +2362,8 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
// Everything swept comes back as ONE pile, then §4.6-4.7's opening is re-run: three cards
|
||||
// turned face up as the Departments, the rest face down as the Home Office deck. The
|
||||
// Departments start one deep again, exactly as at setup.
|
||||
s.decks.salvageYard = [];
|
||||
// The spent trains stay where they are; everything else in the Yard has just been swept up.
|
||||
s.decks.salvageYard = s.decks.salvageYard.filter((id) => isSpentTimetabledTrain(s, id));
|
||||
s.decks.departments = [[], [], []];
|
||||
const order = [...e.order];
|
||||
for (const pile of s.decks.departments) {
|
||||
@@ -2470,7 +2594,15 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'trainScheduled':
|
||||
s.timetable[e.slot] = e.trainNumber;
|
||||
s.rngState = e.rngState;
|
||||
s.decks.salvageYard.push(`train-${e.trainNumber}`);
|
||||
/**
|
||||
* THE CARD IS ALREADY IN THE SALVAGE YARD — `cardPlayed` put it there, by its real id.
|
||||
*
|
||||
* This used to push a second, SYNTHETIC `train-<number>` beside it, so scheduling four trains
|
||||
* left eight entries in a pile holding four cards. Nothing ever read that id: it inflated the
|
||||
* pile's depth, it displayed as "a card" because no such card exists, and
|
||||
* `reshuffleIfDepleted` would have swept it into the draw deck to be drawn as an id with
|
||||
* nothing behind it. Removed 2026-09-10 (Gitea#23).
|
||||
*/
|
||||
break;
|
||||
|
||||
case 'carPlacedOnTrain': {
|
||||
@@ -2690,7 +2822,8 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
* index is out of range, which `check` reports rather than silently defaulting — a wrong
|
||||
* orientation is a different card, not a detail.
|
||||
*/
|
||||
function protoCard(
|
||||
/** Exported for the same reason as `extendLimitsIfNeeded`: the bot builds the card a lay would place exactly as the reducer does. */
|
||||
export function protoCard(
|
||||
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
|
||||
variant: number | undefined,
|
||||
): TrackCard | null {
|
||||
@@ -2927,9 +3060,34 @@ function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
|
||||
* that has genuinely used every card ends on `DECK_EMPTY` rather than reshuffling an empty pile.
|
||||
* Cards played onto the board are NOT recovered: they are on the table, which is where they belong.
|
||||
*/
|
||||
/**
|
||||
* §6.2, AND THE RULING THAT SETTLES IT — Jesse, 2026-09-10 (Gitea#23).
|
||||
*
|
||||
* "Once you've played a regularly scheduled train and it's in the salvage deck, that train is
|
||||
* already on the timetable. It does not make sense to put that back into a reshuffled home deck to
|
||||
* get played again. By contrast, a regularly scheduled train that's in a discard pile could
|
||||
* potentially get reused later, and so should have that capability. Extras run one time and then
|
||||
* they're done — if they are in the Salvage deck, they should get shuffled back in so that they
|
||||
* could get run again."
|
||||
*
|
||||
* So the test is WHERE the card is, not only what it is. A timetabled train in the SALVAGE YARD was
|
||||
* played: its number is on the timetable and cannot be scheduled twice, so the card is spent and
|
||||
* stays out. The same card sitting in a DEPARTMENT was discarded, never played, and its slot is
|
||||
* still open — so it comes back with everything else. An Extra is a single run rather than a
|
||||
* standing slot, so a played one is free to be run again.
|
||||
*/
|
||||
function isSpentTimetabledTrain(s: GameState, id: CardId): boolean {
|
||||
return s.cards.get(id)?.kind.kind === 'timetabledTrain';
|
||||
}
|
||||
|
||||
function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null {
|
||||
if (s.decks.homeOffice.length > taking) return null;
|
||||
const collected = [...s.decks.salvageYard, ...s.decks.departments.flat()];
|
||||
const collected = [
|
||||
// The Salvage Yard, less the trains whose slots are already filled — see above.
|
||||
...s.decks.salvageYard.filter((id) => !isSpentTimetabledTrain(s, id)),
|
||||
// Every Department in full: a discarded train was never played, so it is still runnable.
|
||||
...s.decks.departments.flat(),
|
||||
];
|
||||
if (collected.length === 0) return null;
|
||||
const rng = createRng(s.rngState);
|
||||
return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() };
|
||||
@@ -3056,7 +3214,8 @@ function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKin
|
||||
* §8.1 and §10 both reason about "the track between the train and the Limits", Interlocking holds
|
||||
* an arrival AT the Limits, and running past a player's Limits is what makes a collision his fault.
|
||||
*/
|
||||
function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
|
||||
/** Exported so the bot can score a lay on a copy of the district by the engine's own rule, not a copy of it. */
|
||||
export function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
|
||||
if (placed.row !== area.runningRow) return;
|
||||
|
||||
if (placed.col <= area.limitsWest.col) {
|
||||
@@ -3250,16 +3409,35 @@ function limitsCard(): TrackCard {
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
|
||||
const code = check(s, player, i);
|
||||
if (code) return { ok: false, code, message: `${i.type} rejected: ${code}` };
|
||||
/**
|
||||
* THE FIRST HALF OF `applyIntent`: decide, without changing anything.
|
||||
*
|
||||
* `check` and `execute` read the same unchanged position, so its routes are walked once between them
|
||||
* (`withRouteCache`). Never writes `s`. Split out for a caller that decides many intents against ONE
|
||||
* position and applies each to a COPY of it — the switching planner — which can then share that
|
||||
* position's routes across every candidate instead of re-walking them on each copy.
|
||||
*/
|
||||
export function prepareIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
|
||||
const prepared = withRouteCache(s, (): { code: RejectionCode } | { events: GameEvent[] } => {
|
||||
const code = check(s, player, i);
|
||||
return code ? { code } : { events: execute(s, player, i) };
|
||||
});
|
||||
if ('code' in prepared) return { ok: false, code: prepared.code, message: `${i.type} rejected: ${prepared.code}` };
|
||||
return { ok: true, events: prepared.events };
|
||||
}
|
||||
|
||||
const events = execute(s, player, i);
|
||||
/** THE SECOND HALF: fold events `prepareIntent` produced into a state equal to the one it read. */
|
||||
export function commitEvents(s: GameState, events: readonly GameEvent[]): void {
|
||||
for (const e of events) reduce(s, e);
|
||||
// Gitea#16 — the intent half of the fold; `advance` does the phase driver's half. See `tally.ts`
|
||||
// for why it cannot simply live inside `reduce`.
|
||||
for (const e of events) tallyEvent(s, e);
|
||||
return { ok: true, events };
|
||||
}
|
||||
|
||||
export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
|
||||
const r = prepareIntent(s, player, i);
|
||||
if (r.ok) commitEvents(s, r.events);
|
||||
return r;
|
||||
}
|
||||
|
||||
export { isOperationalRail, destinationsFor };
|
||||
|
||||
+44
-3
@@ -589,7 +589,20 @@ export type MainlineProfile = {
|
||||
speedStarts?: { fast: number; slow: number };
|
||||
/** Double Track: "Trains may pass". */
|
||||
trainsMayPass: boolean;
|
||||
/** Interchange: "Sort cars in new order". */
|
||||
/**
|
||||
* Interchange only. The card prints "Sort cars in new order" — **and that is not what this flag
|
||||
* does**, which is why it is worth spelling out where the field is declared.
|
||||
*
|
||||
* The printed sorting has never been implemented: nothing reads this to permit a sort, and a
|
||||
* consist is re-ordered at a Small Yard in a district (`switch.sortConsist`). What this actually
|
||||
* marks is the one Mainline card with a Yard Limit, and therefore the one an Extra may be made up
|
||||
* and started on (`apply.ts` § resolveExtraStart, `legal.ts`).
|
||||
*
|
||||
* Named for the printed text, and kept that way deliberately — renaming it would lose the link to
|
||||
* the card face — but the name has already misled once: `mainlineDescription` grew a sentence
|
||||
* telling players cars could be sorted here, which reached the board and the generated card
|
||||
* reference before it was caught on 2026-09-20.
|
||||
*/
|
||||
sortsCars: boolean;
|
||||
/** Named entry points printed on the card; some are unlocked by modifier cards. */
|
||||
entryPoints: readonly string[];
|
||||
@@ -772,7 +785,17 @@ export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'ea
|
||||
`${stages(run({}))}.`,
|
||||
);
|
||||
} else {
|
||||
parts.push(`${stages(run({}))} for every train — the printed speed is scenery.`);
|
||||
/**
|
||||
* NOT A WORD ABOUT SPEED HERE — Jesse, playtest 2026-09-16.
|
||||
*
|
||||
* This read "the printed speed is scenery", which sent a player hunting the card for a number
|
||||
* that is not drawn on it. The first rewrite said "fast or slow alike", which is true but raises
|
||||
* the question on thirteen cards in order to answer it. **Exactly one card reads FAST/SLOW**:
|
||||
* Hilly, the only profile with `speedStarts` (see the note above it). So the explanation belongs
|
||||
* on that card, where the branch above already gives it, and everywhere else says nothing —
|
||||
* silence is the honest answer when the rating genuinely does not apply.
|
||||
*/
|
||||
parts.push(`${stages(run({}))} for every train.`);
|
||||
}
|
||||
|
||||
if (kind === 'uncontrolledSiding') {
|
||||
@@ -795,7 +818,25 @@ export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'ea
|
||||
parts.push('One train at a time — anything following has to wait for it to clear.');
|
||||
}
|
||||
|
||||
if (p.sortsCars) parts.push('Cars may be sorted into any new order here.');
|
||||
/**
|
||||
* WHAT THE INTERCHANGE ACTUALLY DOES, which is not what it prints.
|
||||
*
|
||||
* This said "Cars may be sorted into any new order here." — the printed capability, shown to
|
||||
* players on the board (`view.ts` renders this as a Mainline card's `what`) and printed in the
|
||||
* generated card reference. It is not implemented and never has been: nothing reads `sortsCars`
|
||||
* to permit a sort. Its one live use is identifying the card an Extra may be made up on, because
|
||||
* the Interchange is the Mainline card with a yard (`apply.ts` § resolveExtraStart).
|
||||
*
|
||||
* Found while bringing the reference documents up to date, 2026-09-20. A card that advertises an
|
||||
* action the game will not offer is worse than one that says nothing — a player goes looking for
|
||||
* a button that does not exist and concludes the game is broken.
|
||||
*/
|
||||
if (p.sortsCars) {
|
||||
parts.push(
|
||||
'This is the one Mainline card with a yard, so an Extra Train may be made up and started here. ' +
|
||||
'Its printed car-sorting is NOT implemented — a consist is re-ordered at a Small Yard in a district.',
|
||||
);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
|
||||
+89
-7
@@ -28,6 +28,15 @@ export type GameEvent =
|
||||
| { type: 'stageBegan'; day: number; stage: number }
|
||||
/** Employee Rotation (Appendix B) — every player has moved one chair left for the new Day. */
|
||||
| { type: 'seatsRotated'; day: number; seating: PlayerIndex[] }
|
||||
/**
|
||||
* §5 — the Fedora passed, at the end of Stage 3, 6, 9 or 12.
|
||||
*
|
||||
* ITS OWN EVENT RATHER THAN THE `actorChanged` THIS USED TO RIDE ON. That one is turn bookkeeping,
|
||||
* fired every time the cursor moves, and `record()` drops it on the floor as noise — so the one
|
||||
* moment it carried that a player actually needed to see went past in silence. Reported from the
|
||||
* table (2026-09-16): the Supervisor Shift appears in the history and the handover never does.
|
||||
*/
|
||||
| { type: 'superintendentChanged'; player: PlayerIndex; stage: number }
|
||||
| { type: 'phaseBegan'; phase: string }
|
||||
| { type: 'actorChanged'; player: PlayerIndex | null }
|
||||
// -- local operations
|
||||
@@ -37,9 +46,10 @@ export type GameEvent =
|
||||
* more than one legal route to `to`, so the history can say which one ran rather than leaving a
|
||||
* choice the player made invisible in their own log.
|
||||
*/
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||
| { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; movesAllowed: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||
| {
|
||||
type: 'carsCoupled';
|
||||
player: PlayerIndex;
|
||||
trayId: TrayId;
|
||||
at: GridCoord;
|
||||
stock: RollingStock[];
|
||||
@@ -71,8 +81,40 @@ export type GameEvent =
|
||||
*/
|
||||
recoupled?: { at: GridCoord; stock: RollingStock[] };
|
||||
}
|
||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| { type: 'carsDropped'; player: PlayerIndex; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||
| {
|
||||
type: 'consistSorted';
|
||||
player: PlayerIndex;
|
||||
trayId: TrayId;
|
||||
at: GridCoord;
|
||||
before: RollingStock[];
|
||||
after: RollingStock[];
|
||||
/**
|
||||
* Where the engine ends up in `after`, counted as an index into it — 0 is the nose.
|
||||
*
|
||||
* The Small Yard used to put the engine back on the front unconditionally, which is what the
|
||||
* v0.4.5 card text says ("reorder its entire consist and put the engine at the nose").
|
||||
* `implications.md` records the design source saying the opposite — "may sort itself into any
|
||||
* order, INCLUDING cars ahead of the engine" — and Jesse settled it that way on 2026-09-17.
|
||||
*/
|
||||
engineAt: number;
|
||||
}
|
||||
/**
|
||||
* A player finished their switching turn: what it cost, and where the crew was left.
|
||||
*
|
||||
* The history panel keeps a switching turn's FIRST move and drops the ones in the middle, so the
|
||||
* closing line is where "and it ended up here" has to come from. It cannot be recovered by
|
||||
* revealing the last `trayMoved` after the fact: the log streams to clients as it is written
|
||||
* (`server/session.ts` § linesSince), and nobody knows a move was the last one until the turn is
|
||||
* already over and that line has been sent.
|
||||
*/
|
||||
| {
|
||||
type: 'switchingEnded';
|
||||
player: PlayerIndex;
|
||||
movesUsed: number;
|
||||
movesAllowed: number;
|
||||
lastMove?: { trayId: TrayId; to: GridCoord };
|
||||
}
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
/**
|
||||
* §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
|
||||
@@ -86,7 +128,13 @@ export type GameEvent =
|
||||
| { type: 'deckReshuffled'; order: CardId[]; rngState: number }
|
||||
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
|
||||
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
|
||||
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
|
||||
/**
|
||||
* `from` is the card's kind BEFORE the change, carried so the log can say what was realigned
|
||||
* rather than only what it turned into (playtest, 2026-09-15: "it should state that the mainline
|
||||
* card 3 curves was converted to plains"). Events are derived by replaying a save, never stored,
|
||||
* so widening one strands nothing on disk.
|
||||
*/
|
||||
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; from?: string; became?: string }
|
||||
/** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */
|
||||
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction }
|
||||
/** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */
|
||||
@@ -152,7 +200,13 @@ export type GameEvent =
|
||||
* switched normally like any other arrival, but it has to be back on the Office square before the
|
||||
* next Mainline Phase begins, or `expediteFault` fires.
|
||||
*/
|
||||
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean }
|
||||
/**
|
||||
* `owner` is WHOSE Office it reached — the district's player, not whoever is acting. The Mainline
|
||||
* Phase has no actor, so nothing else in the line could name the seat, and the narration said only
|
||||
* "ARRIVED at the Whistle Post" — every seat's Office has a tier, and at a four-seat table three of
|
||||
* them are somebody else's (Jesse, playtest 2026-09-16).
|
||||
*/
|
||||
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; owner: PlayerIndex; expedited: boolean }
|
||||
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
|
||||
/**
|
||||
* The train ran the length of the Division and left it. `side` is the Division Point it left by,
|
||||
@@ -173,8 +227,36 @@ export type GameEvent =
|
||||
at: ExtraStart;
|
||||
direction: Direction;
|
||||
}
|
||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
|
||||
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
|
||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock; trainNumber: number | null; isExtra: boolean }
|
||||
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId; trainNumber: number | null; isExtra: boolean }
|
||||
/**
|
||||
* A train was made up and the round could give it NOTHING THE CARD CALLS FOR — the Division Yard
|
||||
* holds no car of a category it still wants (§7, §8.2 "may depart with fewer").
|
||||
*
|
||||
* ITS OWN EVENT BECAUSE THE SILENCE WAS THE BUG (playtest, 2026-09-16: "train 5, the sparrow, has
|
||||
* no coaches, which seems strange"). `trainNeedingCars` returns null in exactly this case, so the
|
||||
* phase never stops, nobody is asked for a car, and the only trace was a MADE UP line promising
|
||||
* "now taking cars" with nothing after it. The train then ran the whole Division empty.
|
||||
*
|
||||
* CARRIES WHY, not just that. The shortage is a standing condition rather than a moment — §2.2
|
||||
* returns the Classification Yard only when the Division Yard runs bare — so the counts that
|
||||
* explain it have to travel with the event: what is still wanted, how many such cars are waiting
|
||||
* in Classification, and how far the Division Yard is from empty.
|
||||
*/
|
||||
| {
|
||||
type: 'makeUpShort';
|
||||
trainNumber: number;
|
||||
isExtra: boolean;
|
||||
/** How many cars it got, out of what the card calls for. */
|
||||
placed: number;
|
||||
wanted: number;
|
||||
/** The categories the card still wants and the Division Yard cannot supply. */
|
||||
missing: ('freight' | 'coach' | 'caboose')[];
|
||||
/** Cars of those categories sitting in the Classification Yard. */
|
||||
waiting: number;
|
||||
/** §2.2 — Classification comes back only when this reaches zero. */
|
||||
divisionYardHolds: number;
|
||||
}
|
||||
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
|
||||
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
|
||||
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
|
||||
|
||||
+13
-1
@@ -47,7 +47,19 @@ export type Intent =
|
||||
* order, including cars in front of the engine". This is the designed answer to §A.3's
|
||||
* come-off-in-seated-order constraint, which is what makes facing-point work possible.
|
||||
*/
|
||||
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[] }
|
||||
/**
|
||||
* §Enhancements, Small Yard — one Move to re-make a train standing on the yard.
|
||||
*
|
||||
* `order` is a permutation of the current consist, nose first. `engineAt` is where the LOCOMOTIVE
|
||||
* ends up in it: 0 puts it back on the front, which is what the v0.4.5 card text describes and
|
||||
* what this action did unconditionally until 2026-09-17. `implications.md` records the design
|
||||
* source saying a train here "may sort itself into any order, including cars ahead of the engine",
|
||||
* and Jesse ruled that way — so it is a number now, and a train left nose-loaded is one §8.2 will
|
||||
* not let out of the Office until it is sorted again.
|
||||
*
|
||||
* Optional, defaulting to 0, so every save written before this replays exactly as it did.
|
||||
*/
|
||||
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[]; engineAt?: number }
|
||||
| { type: 'switch.end' }
|
||||
// -- draw (§6.2)
|
||||
| { type: 'draw.fromHomeOffice' }
|
||||
|
||||
+138
-58
@@ -14,7 +14,7 @@
|
||||
|
||||
import type { CarType, Hand, TrackGeometry } from './content.ts';
|
||||
import { enhancementRule, mainlineProfile } from './content.ts';
|
||||
import { check, areaOf, destinationsFor } from './apply.ts';
|
||||
import { check, areaOf, destinationsFor, withRouteCache } from './apply.ts';
|
||||
import type { Intent } from './intents.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
|
||||
import { coordKey, seatOf } from './state.ts';
|
||||
@@ -53,7 +53,142 @@ const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 't
|
||||
|
||||
/** Every intent `player` may legally submit right now. */
|
||||
export function legalActions(s: GameState, player: PlayerIndex): Intent[] {
|
||||
return candidates(s, player).filter((i) => check(s, player, i) === null);
|
||||
// One position, examined many times over: its routes are walked once (`withRouteCache`).
|
||||
return withRouteCache(s, () => candidates(s, player).filter((i) => check(s, player, i) === null));
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.1 — the switching half of the Local Operations candidates, in the order `legalActions` offers
|
||||
* them. Split out so the switching planner (`sim/switch-planner.ts`) can ask for just these without
|
||||
* `check` running over every draw and Freight Agent candidate at each of the thousands of positions it
|
||||
* tries — that was about a quarter of all planning time. Still no rules here: `check` decides.
|
||||
*/
|
||||
function switchCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const out: Intent[] = [];
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
const dests = destinationsFor(s, player, trayId, from, reverse);
|
||||
// Grouped by destination square so `distinguishingVia` only ever compares routes that are
|
||||
// actually racing for the same button — two routes to DIFFERENT squares need no `via` to
|
||||
// tell apart, `to` already does that.
|
||||
const byCoord = new Map<string, MoveDestination[]>();
|
||||
for (const d of dests) {
|
||||
const k = coordKey(d.coord);
|
||||
(byCoord.get(k) ?? byCoord.set(k, []).get(k)!).push(d);
|
||||
}
|
||||
for (const group of byCoord.values()) {
|
||||
for (const d of group) {
|
||||
const via = group.length > 1 ? distinguishingVia(d, group) : undefined;
|
||||
out.push({ type: 'switch.move', trayId, to: d.coord, reverse, ...(via ? { via } : {}) });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let n = 1; n <= tray.consist.length; n++) {
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n });
|
||||
// Off the nose as well as the tail — the only way to get cars back off the front of a train
|
||||
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
|
||||
}
|
||||
/**
|
||||
* Small Yard: enumerating every permutation would explode, so offer the useful ones — bringing
|
||||
* each car to the droppable end, plus a full reversal. `check` validates any order, so a UI may
|
||||
* submit an arbitrary permutation.
|
||||
*
|
||||
* NOTHING THAT RE-ORDERS NOTHING. Bringing the LAST car to the end is the identity, and a
|
||||
* two-car train's reversal repeats its only real option — so the menu carried a move that spent
|
||||
* one of six Moves to leave the train exactly as it was, beside a duplicate of the move next to
|
||||
* it. Both were invisible while the labels were index lists (playtest, 2026-09-17); both are
|
||||
* plainly wrong once the label reads as a train. Filtered by the ORDER rather than by the case
|
||||
* that produced it, so a new generator cannot reintroduce either.
|
||||
*/
|
||||
const n = tray.consist.length;
|
||||
const identity = [...Array(n).keys()];
|
||||
if (n > 1) {
|
||||
const orders: number[][] = [];
|
||||
for (let k = 0; k < n; k++) {
|
||||
const order = identity.filter((x) => x !== k);
|
||||
order.push(k);
|
||||
orders.push(order);
|
||||
}
|
||||
orders.push([...identity].reverse());
|
||||
// Nothing that re-orders nothing: the current train is the one thing on offer that costs a
|
||||
// Move and changes the board not at all. Keyed by (order, engine position) together, since
|
||||
// since 2026-09-17 the same car order at a different engine position is a different train.
|
||||
const seen = new Set<string>([`${identity.join(',')}|${tray.engineAt}`]);
|
||||
const offer = (order: number[], engineAt: number): void => {
|
||||
const key = `${order.join(',')}|${engineAt}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order, engineAt });
|
||||
};
|
||||
// The car orders, each leaving the engine on the nose — the Small Yard's ordinary use.
|
||||
for (const order of orders) offer(order, 0);
|
||||
/**
|
||||
* A MADE-UP ORDER IS ALWAYS AMONG THESE, which is worth saying because it looks as though it
|
||||
* might not be (Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the
|
||||
* back").
|
||||
*
|
||||
* A yard sort serves two errands — pulling one car out to an end so it can be spotted, and
|
||||
* putting the train back together to leave — and the orders above are written for the first.
|
||||
* They cover the second as a by-product: "bring car k to the tail" is enumerated for EVERY car,
|
||||
* so bringing the CABOOSE to the tail is always one of them, and with the engine on the nose
|
||||
* that is a train §8.2 will let out of the Office.
|
||||
*
|
||||
* An explicit "make it up to leave" option was written here and deleted: it produced exactly
|
||||
* the k-is-the-caboose order and was dropped by the dedupe every time. The one case where no
|
||||
* made-up order appears is a train that is ALREADY made up, where such an option would be the
|
||||
* identity — and the labels say which is which, so a player can see that every offer would
|
||||
* break a train that is currently fit to run.
|
||||
*/
|
||||
}
|
||||
/**
|
||||
* WHERE THE ENGINE GOES, as its own short list rather than multiplied through the one above
|
||||
* (Jesse's call, 2026-09-17: "a separate engine control").
|
||||
*
|
||||
* Offering every car order at every engine position is the honest enumeration and it is
|
||||
* unreadable: a four-car consist would go from four options to twenty, which is the labelling
|
||||
* problem that prompted all of this. So the engine positions are offered against the consist AS
|
||||
* IT STANDS — pick an order, or pick where the engine sits, each one Move. A player who wants
|
||||
* both spends two, which is the same price the yard charges for any second sort.
|
||||
*
|
||||
* OFFERED FOR A ONE-CAR TRAIN TOO, unlike the car orders: a single car ahead of the engine or
|
||||
* behind it is exactly the difference between shoving it into a facing industry and pulling it.
|
||||
*/
|
||||
if (n >= 1) {
|
||||
const seenEngine = new Set<string>([`${identity.join(',')}|${tray.engineAt}`]);
|
||||
for (let k = 0; k <= n; k++) {
|
||||
const key = `${identity.join(',')}|${k}`;
|
||||
if (seenEngine.has(key)) continue;
|
||||
seenEngine.add(key);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order: identity, engineAt: k });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
const k = s.cards.get(cardId)?.kind;
|
||||
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
|
||||
for (let count = 1; count <= tray.consist.length; count++) {
|
||||
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to: d.coord });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push({ type: 'switch.end' });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The switching intents `player` may legally submit right now — exactly `legalActions`' switching subset. */
|
||||
export function legalSwitchingActions(s: GameState, player: PlayerIndex): Intent[] {
|
||||
return withRouteCache(s, () => switchCandidates(s, player).filter((i) => check(s, player, i) === null));
|
||||
}
|
||||
|
||||
export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean {
|
||||
@@ -138,62 +273,7 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const area = areaOf(s, player);
|
||||
|
||||
// -- switch (§6.1)
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
const dests = destinationsFor(s, player, trayId, from, reverse);
|
||||
// Grouped by destination square so `distinguishingVia` only ever compares routes that are
|
||||
// actually racing for the same button — two routes to DIFFERENT squares need no `via` to
|
||||
// tell apart, `to` already does that.
|
||||
const byCoord = new Map<string, MoveDestination[]>();
|
||||
for (const d of dests) {
|
||||
const k = coordKey(d.coord);
|
||||
(byCoord.get(k) ?? byCoord.set(k, []).get(k)!).push(d);
|
||||
}
|
||||
for (const group of byCoord.values()) {
|
||||
for (const d of group) {
|
||||
const via = group.length > 1 ? distinguishingVia(d, group) : undefined;
|
||||
out.push({ type: 'switch.move', trayId, to: d.coord, reverse, ...(via ? { via } : {}) });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let n = 1; n <= tray.consist.length; n++) {
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n });
|
||||
// Off the nose as well as the tail — the only way to get cars back off the front of a train
|
||||
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
|
||||
}
|
||||
// Small Yard: enumerating every permutation would explode, so offer the useful ones —
|
||||
// bringing each car to the droppable end, plus a full reversal. `check` validates any order,
|
||||
// so a UI may submit an arbitrary permutation.
|
||||
const n = tray.consist.length;
|
||||
if (n > 1) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
const order = [...Array(n).keys()].filter((x) => x !== k);
|
||||
order.push(k);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order });
|
||||
}
|
||||
out.push({ type: 'switch.sortConsist', trayId, order: [...Array(n).keys()].reverse() });
|
||||
}
|
||||
}
|
||||
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
const k = s.cards.get(cardId)?.kind;
|
||||
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
|
||||
for (let count = 1; count <= tray.consist.length; count++) {
|
||||
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to: d.coord });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push({ type: 'switch.end' });
|
||||
out.push(...switchCandidates(s, player));
|
||||
|
||||
// -- draw (§6.2)
|
||||
out.push({ type: 'draw.fromHomeOffice' });
|
||||
|
||||
@@ -431,6 +431,7 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
|
||||
movedThisPhase: new Set(),
|
||||
collisionsToday: 0,
|
||||
collisionsPrevDay: 0,
|
||||
collisionsTotal: 0,
|
||||
status: 'active',
|
||||
outcome: null,
|
||||
|
||||
@@ -875,6 +875,25 @@ export type FinalReport = {
|
||||
export type TurnState = {
|
||||
option: 'switch' | 'draw' | 'freightAgent' | null;
|
||||
movesRemaining: number;
|
||||
/**
|
||||
* What `movesRemaining` started at this Stage — six, or five under Reduced Visibility at night.
|
||||
*
|
||||
* CARRIED RATHER THAN ASSUMED. Every reader of `movesRemaining` that wanted to say "3 of 6" had
|
||||
* hardcoded the 6, which is simply wrong on a night Stage, and the only other way to recover it is
|
||||
* to re-derive `movesForStage` outside the phase driver that owns it. It also makes "is this the
|
||||
* FIRST move of the turn?" a comparison rather than a guess, which is what the history panel needs
|
||||
* to keep the opening move of a switching turn and drop the ones in the middle.
|
||||
*/
|
||||
movesAllowed: number;
|
||||
/**
|
||||
* The last square this player's crew moved to this Stage, and which crew it was.
|
||||
*
|
||||
* Switching ends with a summary line, and "where did the train end up" is the half of it a player
|
||||
* actually wants. It cannot be recovered from the log: the line naming the last move is written
|
||||
* before anyone knows it was the last, and the log streams to clients as it is written, so a line
|
||||
* already sent cannot be revised afterwards.
|
||||
*/
|
||||
lastMove?: { trayId: TrayId; to: GridCoord };
|
||||
drawnThisTurn: boolean;
|
||||
freightAgentUsed: boolean;
|
||||
/**
|
||||
@@ -1057,6 +1076,7 @@ export function freshTurn(moves: number): TurnState {
|
||||
return {
|
||||
option: null,
|
||||
movesRemaining: moves,
|
||||
movesAllowed: moves,
|
||||
drawnThisTurn: false,
|
||||
freightAgentUsed: false,
|
||||
freightWorked: {},
|
||||
@@ -1119,6 +1139,20 @@ export type GameState = {
|
||||
movedThisPhase: Set<TrayId>;
|
||||
/** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */
|
||||
collisionsToday: number;
|
||||
/**
|
||||
* What `collisionsToday` held for the Day that just ENDED — captured at the rollover, immediately
|
||||
* before the reset.
|
||||
*
|
||||
* The Day-end dialog exists to report the Day that finished, and it is drawn from the frame AFTER
|
||||
* the rollover, because that is the frame whose `day` went up. So it read `collisionsToday` as 0 no
|
||||
* matter what had happened: Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it —
|
||||
* "it shows a total of two collisions, but zero today ... that does seem to be a contradiction".
|
||||
*
|
||||
* NOT DERIVABLE ON THE CLIENT. A Day turns over inside the phases that run themselves, so in
|
||||
* multiplayer the push that reports the new Day is the same push that reports the reset — a client
|
||||
* may never see the ended Day's final count to remember it.
|
||||
*/
|
||||
collisionsPrevDay: number;
|
||||
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
|
||||
collisionsTotal: number;
|
||||
/**
|
||||
|
||||
+47
-17
@@ -377,7 +377,7 @@ export function reachableDestinations(
|
||||
start: GridCoord,
|
||||
initialExit: Port,
|
||||
): MoveDestination[] {
|
||||
return exploreMoves(ctx, start, initialExit).destinations;
|
||||
return exploreMoves(ctx, start, initialExit, false).destinations;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -434,12 +434,19 @@ export function exploreMoves(
|
||||
ctx: MoveContext,
|
||||
start: GridCoord,
|
||||
initialExit: Port,
|
||||
/**
|
||||
* False when only the destinations are wanted (`reachableDestinations`, every legality check): the
|
||||
* rejections are then not recorded at all. They never change a destination, and building them was
|
||||
* pure allocation on the hottest path in the engine.
|
||||
*/
|
||||
collectBlocks = true,
|
||||
): { destinations: MoveDestination[]; blocked: MoveBlock[] } {
|
||||
const { area, occupancy } = ctx;
|
||||
const results: MoveDestination[] = [];
|
||||
const blocked: MoveBlock[] = [];
|
||||
const noted = new Set<string>();
|
||||
const block = (coord: GridCoord, kind: MoveBlockKind, why: string): void => {
|
||||
if (!collectBlocks) return;
|
||||
const k = coordKey(coord);
|
||||
if (noted.has(k)) return;
|
||||
noted.add(k);
|
||||
@@ -459,10 +466,25 @@ export function exploreMoves(
|
||||
couples: RollingStock[];
|
||||
/** Coord key each entry in `couples` came off, aligned by index — see `routeOutcomeKey`. */
|
||||
origins: string[];
|
||||
/** Cards visited on THIS route, start included. A per-path set, not a global one — see the
|
||||
* module doc comment on `MAX_ENUMERATED_FRONTIER` for why a global one would forbid the very
|
||||
* routes this walk exists to find. */
|
||||
visited: Set<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Has THIS route already used `to`? Per-path, not global — see the doc comment on
|
||||
* `MAX_ENUMERATED_FRONTIER` for why a global set would forbid the very routes this walk exists to
|
||||
* find.
|
||||
*
|
||||
* Read off the route's own `path` instead of a Set copied at every step, which was a large share of
|
||||
* the walk's garbage. It answers exactly as that Set did: the start square, then every square
|
||||
* enqueued along the route AFTER the first hop, including this node's own — the first hop's square
|
||||
* was never added, and `path[0]` is that square, so the scan begins at 1.
|
||||
*/
|
||||
const onRoute = (node: Frontier, to: GridCoord): boolean => {
|
||||
if (sameCoord(to, start)) return true;
|
||||
if (node.path.length > 0 && sameCoord(to, node.coord)) return true;
|
||||
for (let k = 1; k < node.path.length; k++) {
|
||||
if (sameCoord(node.path[k]!.coord, to)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// The very first hop is checked here because `start`'s card is not itself enqueued; every later
|
||||
@@ -493,13 +515,14 @@ export function exploreMoves(
|
||||
path: [],
|
||||
couples: ownCut,
|
||||
origins: ownCut.map(() => startKey),
|
||||
visited: new Set([startKey]),
|
||||
},
|
||||
];
|
||||
let enumerated = 1;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift()!;
|
||||
// FIFO by index rather than `shift()`, which re-packs the array on every pop. Same order.
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const node = queue[head++]!;
|
||||
const card = cardAt(area, node.coord);
|
||||
if (!card) continue;
|
||||
|
||||
@@ -592,17 +615,15 @@ export function exploreMoves(
|
||||
// direction; it never says without repeating ground, but a train cannot occupy the same
|
||||
// track twice at once either). Per-path, not global — a DIFFERENT route may legitimately
|
||||
// pass through a card this one already used.
|
||||
const toKey = coordKey(to);
|
||||
if (node.visited.has(toKey)) continue;
|
||||
if (onRoute(node, to)) continue;
|
||||
if (enumerated >= MAX_ENUMERATED_FRONTIER) break;
|
||||
enumerated++;
|
||||
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
|
||||
const visited = new Set(node.visited);
|
||||
visited.add(toKey);
|
||||
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins, visited });
|
||||
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins });
|
||||
}
|
||||
}
|
||||
|
||||
if (!collectBlocks) return { destinations: results, blocked };
|
||||
// A card that turned out to be reachable after all is not a blocker: the walk may meet a square
|
||||
// from a bad angle first and a good one later.
|
||||
const reached = new Set(results.map((r) => coordKey(r.coord)));
|
||||
@@ -663,10 +684,19 @@ export function carriesThroughTrack(card: TrackCard): boolean {
|
||||
* buildable column and break §11.3's promise that both Secondary rows, and the nine-spot Modifier
|
||||
* neighbourhood, are usable from the first Stage.
|
||||
*
|
||||
* MODIFIERS ARE NOT SUBJECT TO THIS, and are not track: §9 places one on any of the nine spots
|
||||
* around a Facility, and a Facility standing at the limit has three of its nine outside them.
|
||||
* Jesse's call. `check` bars them from the Running Track ROW instead, which is the ground the main
|
||||
* grows onto.
|
||||
* MODIFIERS ARE SUBJECT TO THIS TOO, since 2026-09-17 — REVERSING an earlier call of Jesse's that
|
||||
* exempted them. The exemption reasoned that §9 places a Modifier on any of the nine spots around a
|
||||
* Facility, so a Facility standing at the limit has three of its nine outside them and bounding the
|
||||
* card would make it unplayable exactly where a district ends. Play showed the cost of that the
|
||||
* other way round: a Transmission Lines card went down at (-2,4) with the sign at column 3, which
|
||||
* reads at the table as building outside your own territory, and §8.1 and §10 both reason about
|
||||
* what is inside a player's Limits.
|
||||
*
|
||||
* THE FEARED CASE DID NOT ARISE, and was measured on the move that prompted the change rather than
|
||||
* argued: the Power Plant sat at (-1,3) against a sign at 3, and (-2,2) and (-2,3) were both free,
|
||||
* legal and inside. A Facility at the limit keeps six of its nine spots, and the Limits move outward
|
||||
* as the Running Track grows (§2.1, Gap 4a), so the ground for a Modifier arrives with the district.
|
||||
* `check` bars them from the Running Track ROW as well, which is the ground the main grows onto.
|
||||
*/
|
||||
export function withinLimits(area: OfficeArea, coord: GridCoord): boolean {
|
||||
return coord.col >= area.limitsWest.col && coord.col <= area.limitsEast.col;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* SEAT RECOVERY CODES — Gitea#33.
|
||||
*
|
||||
* A session token is the only identity the game has (`lobby-and-sessions.md` §1) and it lives in
|
||||
* exactly one place the player controls: their browser's `localStorage`, scoped to the origin they
|
||||
* joined at. Lose that — a different browser, a cleared profile, a private window — and the seat is
|
||||
* unreachable, because there is nothing else on the server that will accept a claim to it. Seen at a
|
||||
* real table on 2026-09-16: the joining player came back to an empty lobby while their token sat
|
||||
* intact in `sessions.json`, and the only way in was an administrator reading the file off the data
|
||||
* volume and the player pasting it into a devtools console.
|
||||
*
|
||||
* THE CODE IS NOT THE TOKEN, AND THAT IS THE WHOLE POINT. §1 says to keep the token out of URLs so it
|
||||
* is not shoulder-surfed or pasted into a chat — and a recovery link is exactly the kind of thing
|
||||
* that gets pasted into a chat. So an administrator mints a SHORT-LIVED, SINGLE-USE code, the player
|
||||
* opens a link carrying that, and the page trades it for the real token over the same connection it
|
||||
* would have used anyway. A code that leaks after it is spent is worth nothing; a token that leaks is
|
||||
* worth the seat for the rest of the game.
|
||||
*
|
||||
* PURE ON PURPOSE, like `lobby.ts` beside it: no sockets, no filesystem, no clock of its own. `now`
|
||||
* is passed in so expiry is testable without faking timers, which is the only reason this file can be
|
||||
* tested at all — nothing in this repo stands an HTTP server up to make requests against it.
|
||||
*
|
||||
* IN MEMORY, NOT ON DISK, which is a deliberate limit rather than an oversight. A restart drops every
|
||||
* outstanding code, and that is the right failure: the codes are minted on demand and spent within
|
||||
* minutes, the administrator is by definition present, and persisting them would put a credential-
|
||||
* equivalent on the volume to solve a problem measured in seconds.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Long enough to walk to the other room and read it out; short enough that a link left in a chat
|
||||
* window is useless by the time anyone scrolls back to it.
|
||||
*/
|
||||
export const CLAIM_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
export type ClaimStore = {
|
||||
/** Mint a code for one seat's token. Returns the code and when it stops working. */
|
||||
mint(token: string, gameId: string, now: number, ttlMs?: number): { code: string; expiresAt: number };
|
||||
/**
|
||||
* Spend a code. Returns the seat it names, or null when the code is unknown, already spent or
|
||||
* expired — deliberately one answer for all three, so a caller cannot probe which it was.
|
||||
*/
|
||||
redeem(code: string, now: number): { token: string; gameId: string } | null;
|
||||
/** Outstanding, unexpired codes. For tests and for anything that wants to report the store's size. */
|
||||
outstanding(now: number): number;
|
||||
};
|
||||
|
||||
export function createClaimStore(): ClaimStore {
|
||||
const claims = new Map<string, { token: string; gameId: string; expiresAt: number }>();
|
||||
|
||||
/** Expiry is lazy: there is no timer to own, start, stop or leak across a server's lifetime. */
|
||||
const prune = (now: number): void => {
|
||||
for (const [code, claim] of claims) if (claim.expiresAt <= now) claims.delete(code);
|
||||
};
|
||||
|
||||
return {
|
||||
mint(token, gameId, now, ttlMs = CLAIM_TTL_MS) {
|
||||
prune(now);
|
||||
// The same primitive the session tokens themselves use (`lobby.ts`), for the same reason: it
|
||||
// has to be unguessable, and inventing a second scheme here would be inventing a weaker one.
|
||||
const code = randomUUID();
|
||||
const expiresAt = now + ttlMs;
|
||||
claims.set(code, { token, gameId, expiresAt });
|
||||
return { code, expiresAt };
|
||||
},
|
||||
|
||||
redeem(code, now) {
|
||||
prune(now);
|
||||
const claim = claims.get(code);
|
||||
if (!claim) return null;
|
||||
// SINGLE USE. Deleted before the caller can do anything with it, so two browsers racing on the
|
||||
// same link cannot both be seated — and a link that stays in someone's history is spent.
|
||||
claims.delete(code);
|
||||
return { token: claim.token, gameId: claim.gameId };
|
||||
},
|
||||
|
||||
outstanding(now) {
|
||||
prune(now);
|
||||
return claims.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
+116
-2
@@ -37,6 +37,7 @@ import {
|
||||
writeLobby,
|
||||
writeSessions,
|
||||
} from './persistence.ts';
|
||||
import { createClaimStore } from './claims.ts';
|
||||
import { createSession } from './session.ts';
|
||||
import type { GameSession, Push } from './session.ts';
|
||||
import {
|
||||
@@ -84,6 +85,14 @@ const MIME: Record<string, string> = {
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
/**
|
||||
* The Quickstart guide, published by `build-web.ts` as `quickstart.md`.
|
||||
*
|
||||
* text/plain ON PURPOSE. The fallback below is `application/octet-stream`, which makes a browser
|
||||
* DOWNLOAD the file instead of showing it — so without this line the splash page's "read the
|
||||
* guide" link hands a tester a file to save rather than a page to read.
|
||||
*/
|
||||
'.md': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
const HEARTBEAT_MS = 20_000;
|
||||
@@ -170,6 +179,15 @@ export function startServer(opts: ServerOptions): void {
|
||||
const games = opts.initialGames;
|
||||
const lobbies = opts.initialLobbies;
|
||||
const sessions = opts.initialSessions;
|
||||
/**
|
||||
* Outstanding seat recovery codes — Gitea#33, `claims.ts`.
|
||||
*
|
||||
* In memory and not on the volume, deliberately: a code is minted on demand and spent within
|
||||
* minutes with the administrator standing right there, so a restart dropping them all is the right
|
||||
* failure. Persisting them would put a credential-equivalent on disk to solve a problem measured
|
||||
* in seconds.
|
||||
*/
|
||||
const claims = createClaimStore();
|
||||
const gameCodes = new Map<string, string>(); // gameCode -> gameId, for /api/lobby/join
|
||||
for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId);
|
||||
|
||||
@@ -322,6 +340,17 @@ export function startServer(opts: ServerOptions): void {
|
||||
gameId,
|
||||
gameCode: codes.get(gameId) ?? null,
|
||||
state: 'running' as const,
|
||||
/**
|
||||
* WHICH SEATS A PERSON IS SITTING IN — Gitea#33.
|
||||
*
|
||||
* `playerNames` cannot answer it: a bot's name is just a name, and telling the two apart
|
||||
* by matching "Bot 1" would be guessing at a label. `sessions` holds humans and only
|
||||
* humans, so this is the fact rather than an inference — and it is what lets the seat
|
||||
* recovery action offer real players instead of chairs no token was ever issued for.
|
||||
*/
|
||||
seatedPlayers: [...sessions.values()]
|
||||
.filter((s) => s.gameId === gameId)
|
||||
.map((s) => s.player),
|
||||
...g.summary(),
|
||||
}));
|
||||
// A lobby has no game to summarize yet — it is reported as what it is, so an
|
||||
@@ -340,14 +369,48 @@ export function startServer(opts: ServerOptions): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const match = /^\/api\/games\/([^/]+)(\/save)?$/.exec(url.pathname);
|
||||
const match = /^\/api\/games\/([^/]+)(\/save|\/claim)?$/.exec(url.pathname);
|
||||
const gameId = match?.[1];
|
||||
// Compared explicitly rather than tested for truthiness: with two suffixes in the group, a
|
||||
// bare `match?.[2]` would let a GET on `/claim` fall into the `/save` branch below.
|
||||
const suffix = match?.[2];
|
||||
if (!gameId) {
|
||||
sendJson(res, 404, { error: 'no such route' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (match?.[2] && req.method === 'GET') {
|
||||
/**
|
||||
* MINT A SEAT RECOVERY CODE FOR ONE PLAYER — Gitea#33.
|
||||
*
|
||||
* The token is the only identity this game has and it lives in one browser's `localStorage`;
|
||||
* lose it and the seat is unreachable, because nothing else here will accept a claim to it.
|
||||
* This is the supported way back, and it is administrative on purpose: whoever runs the
|
||||
* server decides that a particular player has lost their seat, which is a judgement no
|
||||
* automated route can make safely.
|
||||
*
|
||||
* IT HANDS BACK A CODE, NOT THE TOKEN. §1 says keep the token out of URLs, and the code is
|
||||
* going into one. Short-lived and single-use (`claims.ts`), so a link left in a chat window
|
||||
* is worth nothing by the time anyone finds it.
|
||||
*/
|
||||
if (suffix === '/claim' && req.method === 'POST') {
|
||||
const body = (await readJson(req)) as { player?: number };
|
||||
const ps = [...sessions.values()].find((s) => s.gameId === gameId && s.player === body.player);
|
||||
if (!ps) {
|
||||
sendJson(res, 404, { error: 'no such seat' });
|
||||
return;
|
||||
}
|
||||
const { code, expiresAt } = claims.mint(ps.token, gameId, Date.now());
|
||||
sendJson(res, 200, {
|
||||
code,
|
||||
expiresAt,
|
||||
player: ps.player,
|
||||
displayName: ps.displayName,
|
||||
gameCode: codes.get(gameId) ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (suffix === '/save' && req.method === 'GET') {
|
||||
const session = games.get(gameId);
|
||||
if (!session) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
@@ -656,6 +719,57 @@ export function startServer(opts: ServerOptions): void {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* SPEND A SEAT RECOVERY CODE — Gitea#33, the other half of `/api/games/<id>/claim`.
|
||||
*
|
||||
* NOT GATED BY THE ADMIN SECRET, and it must not be: the player following the link is the one
|
||||
* person in this story who holds no secret at all. The code IS the authorisation — unguessable,
|
||||
* single-use and short-lived — which is the same shape as the session token it hands back, and
|
||||
* why minting one is the administrative act rather than spending one.
|
||||
*
|
||||
* The token travels in the response BODY of a POST, never in a URL (`lobby-and-sessions.md`
|
||||
* §1). One answer for unknown, spent and expired codes, so this cannot be used to probe which.
|
||||
*/
|
||||
if (url.pathname === '/api/claim' && req.method === 'POST') {
|
||||
const body = (await readJson(req)) as { code?: string };
|
||||
const claimed = typeof body.code === 'string' ? claims.redeem(body.code, Date.now()) : null;
|
||||
const ps = claimed ? sessions.get(claimed.token) : undefined;
|
||||
const live = ps ? games.get(ps.gameId) : undefined;
|
||||
if (!claimed || !ps || !live) {
|
||||
sendJson(res, 404, { error: 'no such claim' });
|
||||
return;
|
||||
}
|
||||
const codes = new Map((await readIndex(opts.dataDir)).map((e) => [e.gameId, e.gameCode]));
|
||||
sendJson(res, 200, {
|
||||
token: ps.token,
|
||||
gameId: ps.gameId,
|
||||
player: ps.player,
|
||||
gameCode: codes.get(ps.gameId) ?? '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* THIS SEAT'S OWN GAME, AS A SAVE (playtest, 2026-09-15: "most of the time, I want to go ahead and
|
||||
* just save it as a JSON file in my Downloads folder").
|
||||
*
|
||||
* The administrative export at `/api/games/<id>/save` is gated on the admin secret, which a player
|
||||
* does not have and should not need: a save is the seed and the moves, and every one of those moves
|
||||
* is already on this player's screen. So the seat's own session token is the gate, exactly as it is
|
||||
* for `/api/stream` and `/api/intent` — it proves which game and which chair, and nothing else is
|
||||
* disclosed. The page turns the JSON into a file (`main.ts`'s `downloadSave`).
|
||||
*/
|
||||
if (url.pathname === '/api/save' && req.method === 'GET') {
|
||||
const ps = sessions.get(url.searchParams.get('token') ?? '');
|
||||
const session = ps ? games.get(ps.gameId) : undefined;
|
||||
if (!ps || !session) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { gameId: ps.gameId, save: session.exportSave() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/stream' && req.method === 'GET') {
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
const ps = sessions.get(token);
|
||||
|
||||
+55
-5
@@ -26,8 +26,10 @@ import { actionMenu, currentActor, fromMultiplayerSave, isOutOfTurn, newMultipla
|
||||
import type { Game, Menu } from '../web/game.ts';
|
||||
import { deltaFrame } from '../sim/frame-delta.ts';
|
||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||
import { snapshot, seatLabel } from '../sim/view.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
import { publicSnapshot, snapshot, seatLabel } from '../sim/view.ts';
|
||||
import type { Frame, PublicFrame } from '../sim/view.ts';
|
||||
import { takeSteps } from '../sim/display-step.ts';
|
||||
import type { DisplayStep } from '../sim/display-step.ts';
|
||||
import { developerBot } from '../sim/bot.ts';
|
||||
|
||||
export type Push = {
|
||||
@@ -69,6 +71,29 @@ export type Push = {
|
||||
scheduled?: number | null;
|
||||
announcement?: string | null;
|
||||
justDrawn?: string | null;
|
||||
/**
|
||||
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13.
|
||||
*
|
||||
* A field on `Push` rather than a second SSE event type, following `presence`'s precedent and for
|
||||
* its stated reason (`http.ts`): one message shape for the client to parse. `http.ts` therefore
|
||||
* needs no change at all — `broadcastGame` forwards whatever this file builds.
|
||||
*
|
||||
* IDENTICAL IN EVERY SEAT'S PUSH, because a step carries the PUBLIC board and nothing else. A
|
||||
* player's own hand, menu and objective are not animated: they arrive on the same push, already
|
||||
* coalesced, exactly as they always have. That is what keeps the redaction surface at zero new
|
||||
* area — `test/redaction.test.ts` guards the projection these are built from.
|
||||
*/
|
||||
steps?: DisplayStep[];
|
||||
/**
|
||||
* The public board to start a step queue from — sent on a CONNECT, never on an update.
|
||||
*
|
||||
* Steps carry deltas against one chain shared by the whole table, so a client that has just
|
||||
* arrived (or come back) has nothing to merge the next delta onto and `applyPublicDelta` would
|
||||
* rightly throw. This is that baseline: the exact frame the chain has reached, so the next step
|
||||
* lands on it. A reconnecting client resets rather than replaying what it missed — the history
|
||||
* panel is what carries the words, and it is already sent whole on connect (#97).
|
||||
*/
|
||||
publicReset?: PublicFrame;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -211,11 +236,12 @@ function buildSession(
|
||||
return { cues, scheduled, announcement };
|
||||
}
|
||||
|
||||
function pushFor(seat: PlayerIndex, moment: Moment | null): Push {
|
||||
function pushFor(seat: PlayerIndex, moment: Moment | null, steps: DisplayStep[] = []): Push {
|
||||
const frame = frameFor(seat);
|
||||
const delta = deltaFrame(lastFrame.get(seat) ?? null, frame);
|
||||
lastFrame.set(seat, frame);
|
||||
const push: Push = { frame: delta, menu: menuFor(seat), lines: linesSince(seat) };
|
||||
if (steps.length > 0) push.steps = steps;
|
||||
if (moment) {
|
||||
if (moment.cues.length > 0) push.cues = moment.cues;
|
||||
if (moment.scheduled !== null) push.scheduled = moment.scheduled;
|
||||
@@ -228,9 +254,13 @@ function buildSession(
|
||||
|
||||
function pushesForAll(): Map<PlayerIndex, Push> {
|
||||
const moment = takeMoment();
|
||||
// Drained ONCE for the whole broadcast, not per seat: the steps are public and identical, and
|
||||
// `takeSteps` empties the collector, so draining inside the loop would give them to seat 0 and
|
||||
// an empty list to everybody else.
|
||||
const steps = takeSteps(game.display);
|
||||
const out = new Map<PlayerIndex, Push>();
|
||||
for (let seat = 0; seat < playerNames.length; seat++) {
|
||||
out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex, moment));
|
||||
out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex, moment, steps));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -341,6 +371,19 @@ function buildSession(
|
||||
// here rather than fired at the first client to arrive. (It also stops `game.cues` growing without
|
||||
// bound on a server, which nothing was draining before this.)
|
||||
takeMoment();
|
||||
/**
|
||||
* THE PRESENTATION STEPS THOSE TURNS PRODUCED GO WITH THEM (v0.8.0).
|
||||
*
|
||||
* Left in the collector they would be delivered on the FIRST broadcast after somebody connects —
|
||||
* but that client's `publicReset` is the board as it stands AFTER these very moves, so replaying
|
||||
* them onto it would draw positions the game had already left. The plan says as much: opening bot
|
||||
* moves need no replay, and a later display simply receives the final reset.
|
||||
*
|
||||
* This is the only moment the collector holds anything outside an intent. `pushesForAll()` drains
|
||||
* it synchronously at the end of every `intent()`, so between moves it is always empty — which is
|
||||
* what makes dropping here safe rather than a race with a seat that has not been sent them yet.
|
||||
*/
|
||||
takeSteps(game.display);
|
||||
|
||||
return {
|
||||
playerCount: playerNames.length,
|
||||
@@ -355,7 +398,14 @@ function buildSession(
|
||||
// blank history panel mid-game, with the server holding the whole log. `Push.lines` on a
|
||||
// connect IS the history, which is what lets the Frame stop carrying a second copy.
|
||||
sentLines.delete(seat);
|
||||
return pushFor(seat, null);
|
||||
const push = pushFor(seat, null);
|
||||
/**
|
||||
* The baseline for this client's step queue (v0.8.0). `game.display.last` is the exact frame
|
||||
* the shared delta chain has reached, so the next step merges onto it; before any step has
|
||||
* been collected there is no chain yet and a fresh projection is the same thing.
|
||||
*/
|
||||
push.publicReset = game.display.last ?? publicSnapshot(game.state);
|
||||
return push;
|
||||
},
|
||||
|
||||
intent(seat, seq, i) {
|
||||
|
||||
+56
-8
@@ -42,6 +42,12 @@ export type DivisionRoster = {
|
||||
actor: number | null;
|
||||
/** The player this map is being drawn for. */
|
||||
viewer: number;
|
||||
/**
|
||||
* Division nodes to flash — a Mainline card that has just become a different card (Realignment).
|
||||
* Playtest, 2026-09-15: the log said a card had been converted and the map said nothing, so the one
|
||||
* play that changes the Division itself was invisible on the map of it.
|
||||
*/
|
||||
flash?: readonly number[];
|
||||
};
|
||||
|
||||
export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string {
|
||||
@@ -131,9 +137,13 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
region?: number;
|
||||
direction?: string;
|
||||
stagesLeft?: number;
|
||||
/** Being made up at a Division Point right now, so the map can mark the train you are loading. */
|
||||
beingMadeUp?: boolean;
|
||||
}[];
|
||||
cap: number | null;
|
||||
tip: string;
|
||||
/** Set on a card that has just changed under the players' feet — drawn with a brief pulse. */
|
||||
flash?: boolean;
|
||||
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
|
||||
seat: number | null;
|
||||
/** Set on an Office cell when a roster was supplied: whose district this is. */
|
||||
@@ -168,7 +178,11 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
cells.push({ ...c, x: 0, y: 0 });
|
||||
};
|
||||
|
||||
// The node's own index, so a cell can be matched against `roster.flash`. `continue` below skips the
|
||||
// rest of the body, never this.
|
||||
let nodeIndex = -1;
|
||||
for (const n of nodes) {
|
||||
nodeIndex++;
|
||||
if (n.kind === 'office') {
|
||||
const cap = n.capacity;
|
||||
const ad = n.trains.flat();
|
||||
@@ -261,6 +275,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
push({
|
||||
kind: dp ? 'dp' : 'ml',
|
||||
label: n.label,
|
||||
...(roster?.flash?.includes(nodeIndex) ? { flash: true } : {}),
|
||||
sub: n.capacity === null
|
||||
? 'no limit — trains queue'
|
||||
: [free, inYard.length > 0 ? `${inYard.length} in the yard` : ''].filter(Boolean).join(' · '),
|
||||
@@ -356,7 +371,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
|
||||
cells.forEach((c) => {
|
||||
const full = c.cap !== null && c.trains.length >= c.cap;
|
||||
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`;
|
||||
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}${c.flash ? ' bs-changed' : ''}" data-tip="${esc(c.tip)}">`;
|
||||
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
|
||||
/**
|
||||
* WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather
|
||||
@@ -502,13 +517,18 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
|
||||
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
|
||||
const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`;
|
||||
// THE TRAIN THE MAKE-UP PANEL IS TALKING ABOUT. Amber, because that is what the rest of the
|
||||
// page uses for "this is the thing you are acting on" (Jesse, playtest 2026-09-16).
|
||||
const building = t.beingMadeUp === true;
|
||||
const inRegion = c.regions > 1 && typeof t.region === 'number';
|
||||
const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : '';
|
||||
const stages =
|
||||
typeof t.stagesLeft === 'number'
|
||||
? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card`
|
||||
: '';
|
||||
out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
|
||||
out += `<g class="bs-train${building ? ' bs-building' : ''}" data-tip="${esc(t.label)}${
|
||||
building ? ' \u2014 BEING MADE UP NOW: add cars from the Division Yard' : ''
|
||||
} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
|
||||
cars.length ? ` (${loaded} loaded)` : ''
|
||||
}${inRegion ? ` \u00b7 region ${(t.region ?? 0) + 1} of ${c.regions}, counted west to east${dir}` : ''}${esc(stages)}${
|
||||
// What the card prints. A train on the Mainline is exactly where "why did that leave without
|
||||
@@ -669,7 +689,17 @@ export function officeSvg(
|
||||
const c0 = Math.min(...cols);
|
||||
const c1 = Math.max(...cols);
|
||||
const width = (c1 - c0 + 1) * (W + PAD);
|
||||
const height = (r1 - r0 + 1) * (H + PAD) + 4;
|
||||
/**
|
||||
* A BAND BENEATH THE BOTTOM ROW FOR THE LIMITS LABELS, and only when there are labels to put in it.
|
||||
*
|
||||
* The bottom card's lower edge lands at `height - 7`, and the label's baseline was `height - 4` —
|
||||
* so its 8px glyphs spanned `height - 12` to `height - 4` and the card's own border ran straight
|
||||
* through the middle of the word (Jesse, playtest 2026-09-16: *"the text is split by the bottom
|
||||
* border of the limits card… it should be printed directly beneath the card"*). Raising the text
|
||||
* instead would have pushed it onto the card, over the rails; the room has to be made below.
|
||||
*/
|
||||
const limitBand = limits ? 14 : 0;
|
||||
const height = (r1 - r0 + 1) * (H + PAD) + 4 + limitBand;
|
||||
|
||||
// Screen position of a card. Rows count DOWN from the top row, so the Running Track sits highest
|
||||
// and the district hangs beneath it, as the rules describe it.
|
||||
@@ -1205,7 +1235,9 @@ export function officeSvg(
|
||||
if (limits) {
|
||||
const edge = (x: number, side: string): string =>
|
||||
`<line class="bs-limitline" x1="${x}" y1="0" x2="${x}" y2="${height}"/>` +
|
||||
`<text class="bs-limitlab" x="${x + (side === 'w' ? 4 : -4)}" y="${height - 4}" ` +
|
||||
// Baseline inside the band below the cards: the glyphs run from `height - 19` to `height - 11`
|
||||
// and the bottom row's edge is at `height - 21`, so the whole word clears the card border.
|
||||
`<text class="bs-limitlab" x="${x + (side === 'w' ? 4 : -4)}" y="${height - 11}" ` +
|
||||
`text-anchor="${side === 'w' ? 'start' : 'end'}">LIMITS</text>`;
|
||||
out += edge(px(limits.west) - PAD / 2, 'w') + edge(px(limits.east) + W + PAD / 2, 'e');
|
||||
}
|
||||
@@ -1248,9 +1280,18 @@ export const BOARD_CSS = `
|
||||
and leave at the other, and a seated layout must not be read as a ring. */
|
||||
.bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round}
|
||||
.bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em}
|
||||
/* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the
|
||||
train is measured against, not something to look at instead of the train. */
|
||||
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
|
||||
/* A card that has just BECOME a different card (Realignment). The same amber the rest of the page
|
||||
spends on "it is happening here", pulsing only while the step that did it is on screen — so the
|
||||
change is seen on the map rather than only read in the log. */
|
||||
.bs-dcell.bs-changed rect{stroke:#e0a060;stroke-width:2.4;animation:bs-changed-pulse 1.1s ease-in-out infinite}
|
||||
@keyframes bs-changed-pulse{0%,100%{stroke-opacity:1}50%{stroke-opacity:.35}}
|
||||
@media (prefers-reduced-motion: reduce){.bs-dcell.bs-changed rect{animation:none}}
|
||||
/* The vertical bars a Mainline card is divided into (§2.1). They are the ruler the train is measured
|
||||
against, not something to look at instead of the train — but they were drawn so faint they could
|
||||
not be made out at all (Jesse, playtest 2026-09-16: "the dividing line is barely visible"). A
|
||||
ruler you cannot read is not restraint, so this is lifted to the tie colour and given a longer
|
||||
dash: still quieter than the rail, and now actually there. */
|
||||
.bs-region{stroke:#98a3b2;stroke-width:1.6;stroke-dasharray:4 2}
|
||||
/* #94 — the one red mark on the Division map, so it reads as a stop rather than as decoration. */
|
||||
.bs-flag line{stroke:#9aa3b0;stroke-width:1.6}
|
||||
.bs-flag polygon{fill:#d2453f;stroke:#7d211d;stroke-width:0.8}
|
||||
@@ -1283,6 +1324,10 @@ export const BOARD_CSS = `
|
||||
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.85)}
|
||||
.bs-slot.bs-car-cab.bs-loaded{fill:rgba(192,90,90,.85)}
|
||||
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
|
||||
/* The train the New Train phase is loading, in the page's action amber, so the make-up panel on the
|
||||
right and the train on the map at the top left are visibly the same subject. */
|
||||
.bs-train.bs-building rect{fill:#4a3a1c;stroke:#c8912f;stroke-width:2}
|
||||
.bs-train.bs-building .bs-tlab{fill:#f2d49a}
|
||||
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
|
||||
/* Each car in the train, in the order it is seated. Loaded is solid, empty is hollow, and the
|
||||
engine is the one that carries the arrow — which is what makes "reverse" mean something. */
|
||||
@@ -1329,7 +1374,10 @@ export const BOARD_CSS = `
|
||||
.bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace}
|
||||
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
||||
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
|
||||
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
||||
/* stroke:none (Gitea#24). A name takes the class \`bs-turn\` while it is that player's move, and \`.bs-turn\` is
|
||||
also the turn ARROW's rule, which strokes its shape 2.4px grey. Declared after it, this keeps that
|
||||
outline off the letters, which it smeared into an unreadable blur. */
|
||||
.bs-name{fill:#e6e9ee;stroke:none;font:600 11px ui-monospace,monospace}
|
||||
.bs-name.bs-you{fill:#5aa9e6}
|
||||
/* Their move — wins over .bs-you when both apply, because whose turn it is changes every few
|
||||
seconds and which railroad is yours never does.
|
||||
|
||||
+238
-16
@@ -22,6 +22,9 @@
|
||||
import {
|
||||
applyIntent,
|
||||
areaOf,
|
||||
extendLimitsIfNeeded,
|
||||
isLockedOut,
|
||||
protoCard,
|
||||
canAdvanceLoad,
|
||||
destinationsFor,
|
||||
facilityCarTypes,
|
||||
@@ -29,14 +32,15 @@ import {
|
||||
ownCutFor,
|
||||
} from '../engine/apply.ts';
|
||||
import { MAX_CONSIST, nextOfficeTier, officeProfile } from '../engine/content.ts';
|
||||
import type { CarType, Hand, TrackGeometry } from '../engine/content.ts';
|
||||
import type { CarType, FreightKind, Hand, TrackGeometry } from '../engine/content.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
||||
import { canPlaceAt, connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { actingPlayer, coordKey, turnOf } from '../engine/state.ts';
|
||||
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||
import { planSwitchingTurn, switchFingerprint } from './switch-planner.ts';
|
||||
|
||||
export type BotPolicy = {
|
||||
name: string;
|
||||
@@ -106,7 +110,8 @@ function because(reason: string, intent: Intent): Intent {
|
||||
* Every flag here turns something OFF. That is the opposite of how this started — the tweaks were
|
||||
* candidates to switch on — and it is the right shape once a candidate has been adopted: what a
|
||||
* measured heuristic needs afterwards is a way to ask "is this still worth it?" when the deck or
|
||||
* the rules move under it. Both of these were worth about +1.5 revenue together when adopted; if a
|
||||
* the rules move under it. The first two were worth about +1.5 revenue together when adopted, and
|
||||
* planning the switching turn (`noPlanSwitching`) +2.89 on its own; if a
|
||||
* rebalance changes the economy, that is a claim to re-test rather than to assume.
|
||||
*
|
||||
* The candidates that did NOT survive are gone rather than left switched off: preferring coaches at
|
||||
@@ -121,9 +126,78 @@ export type BotTweaks = {
|
||||
noTrainCap?: boolean;
|
||||
/** Draw whenever nothing is urgent, as the bot did before it preferred operating. */
|
||||
noOperateFirst?: boolean;
|
||||
|
||||
/**
|
||||
* Choose switching Moves one at a time from the rule ladder, as the bot did before it planned the
|
||||
* whole turn (`switch-planner.ts`). Measured at adoption, 2026-09-14: planning was worth
|
||||
* +2.89 ± 0.18 revenue a game (t = 15.79) over 1600 paired seeds, 733 better against 21 worse.
|
||||
*/
|
||||
noPlanSwitching?: boolean;
|
||||
/**
|
||||
* Take a face-up train or industry card whether or not it could be played, as the bot did before
|
||||
* 2026-09-14. It then took 20.1 trains and 11.9 industries a game off the Departments and discarded
|
||||
* 20.2 and 11.8, retaking the same card 28.8 times a game. Asking first measured +1.52 ± 0.10
|
||||
* (t = 15.59) over 1600 paired seeds — this ablation was worse on 880 of them and better on 211.
|
||||
*/
|
||||
noPlayableTakes?: boolean;
|
||||
/**
|
||||
* Choose where track goes by `bestTrackLay`'s piece rules and the fallback's first legal square, as the
|
||||
* bot did before 2026-09-15, instead of by what the district can DO afterwards (`bestValuedLay`).
|
||||
* Scoring the layout measured +0.118 ± 0.029 (t = 4.14) over 6400 paired seeds, and closed run-arounds
|
||||
* in 22 of 60 districts against 9.
|
||||
*/
|
||||
noValueLays?: boolean;
|
||||
/**
|
||||
* Let the New Train phase's fallback take `options[0]`, as the bot did before 2026-09-15. Because
|
||||
* `legalActions` lists `newTrain.secondSection` ahead of the Extra starts, all 26 Second Sections the
|
||||
* bot ran in 60 games were that accident, and 6 of the 20 collisions followed one. Taking a car, a pass
|
||||
* or the Extra's start instead measured +0.32 ± 0.09 (t = 3.64) over 400 paired seeds.
|
||||
*/
|
||||
noDeliberateNewTrain?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A switching turn planned once and then played a step per decision.
|
||||
*
|
||||
* Keyed by the tweaks object, because that is what one policy owns — the server shares a single
|
||||
* `developerBot` across every bot seat, so the plan inside it is kept per player. Each step is
|
||||
* submitted only while the position still matches the fingerprint the plan expected there; anything
|
||||
* else replans. A switching turn has no randomness, so in practice a plan is made once a turn.
|
||||
*/
|
||||
type ActivePlan = { steps: Intent[]; keys: string[]; next: number; summary: string };
|
||||
const activePlans = new WeakMap<BotTweaks, Map<PlayerIndex, ActivePlan>>();
|
||||
|
||||
function plannedSwitch(s: GameState, player: PlayerIndex, options: Intent[], tweaks: BotTweaks): Intent | null {
|
||||
let mine = activePlans.get(tweaks);
|
||||
if (!mine) activePlans.set(tweaks, (mine = new Map()));
|
||||
const here = switchFingerprint(s, player);
|
||||
let active = mine.get(player);
|
||||
if (!active || active.keys[active.next] !== here) {
|
||||
const p = planSwitchingTurn(s, player);
|
||||
active = {
|
||||
steps: p.steps,
|
||||
keys: p.keys,
|
||||
next: 0,
|
||||
summary:
|
||||
`position ${p.rootScore.toFixed(2)} → ${p.score.toFixed(2)} over ${p.expanded} positions` +
|
||||
(p.complete ? '' : ', search budget reached'),
|
||||
};
|
||||
mine.set(player, active);
|
||||
}
|
||||
if (active.next >= active.steps.length) {
|
||||
mine.delete(player);
|
||||
const end = options.find((i) => i.type === 'switch.end');
|
||||
return end ? because(`planned switching turn complete — ${active.summary}`, end) : null;
|
||||
}
|
||||
const want = JSON.stringify(active.steps[active.next]);
|
||||
const match = options.find((i) => JSON.stringify(i) === want);
|
||||
if (!match) {
|
||||
mine.delete(player);
|
||||
return null;
|
||||
}
|
||||
active.next++;
|
||||
return because(`step ${active.next} of ${active.steps.length} of a planned switching turn — ${active.summary}`, match);
|
||||
}
|
||||
|
||||
/** The bot as it plays today. Every knob off. */
|
||||
export const developerBot: BotPolicy = makeDeveloperBot({});
|
||||
|
||||
@@ -209,6 +283,13 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
|
||||
);
|
||||
if (match) return because(`the ${w.loaded ? 'loaded' : 'empty'} ${w.type} is what a facility is short of`, match);
|
||||
}
|
||||
if (!tweaks.noDeliberateNewTrain) {
|
||||
const move =
|
||||
pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar', 'newTrain.startExtra') ??
|
||||
options.find((i) => i.type !== 'newTrain.secondSection' && i.type !== 'maneuver.redFlags') ??
|
||||
options[0]!;
|
||||
return because('no car on offer is one our facilities need', move);
|
||||
}
|
||||
return because('no car on offer is one our facilities need', pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!);
|
||||
}
|
||||
|
||||
@@ -438,7 +519,7 @@ function topOfDepartment(s: GameState, slot: number): string | undefined {
|
||||
* In a competitive game the same call reads the other way round — burying a card a rival wants is an
|
||||
* attack — which is why the choice belongs to the discarding player and not to the rules.
|
||||
*/
|
||||
function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
|
||||
function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[], tweaks: BotTweaks): Intent | null {
|
||||
let best: Intent | null = null;
|
||||
let bestScore = -Infinity;
|
||||
for (const i of options) {
|
||||
@@ -448,7 +529,7 @@ function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Inte
|
||||
// two showing whatever they happened to start with. Measured over 100 games — spreading 2.87
|
||||
// revenue, concentrating on the deepest 2.67, indifferent 2.67.
|
||||
const top = topOfDepartment(s, i.toSlot);
|
||||
const wanted = isWorthTaking(s, player, i.toSlot);
|
||||
const wanted = isWorthTaking(s, player, i.toSlot, tweaks);
|
||||
const depth = s.decks.departments[i.toSlot]?.length ?? 0;
|
||||
const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5;
|
||||
if (score > bestScore) {
|
||||
@@ -460,10 +541,39 @@ function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Inte
|
||||
}
|
||||
|
||||
/** A face-up card worth spending the draw on rather than gambling on the deck. */
|
||||
function isWorthTaking(s: GameState, player: PlayerIndex, slot: number): boolean {
|
||||
return takingRank(s, player, slot) > 0;
|
||||
function isWorthTaking(s: GameState, player: PlayerIndex, slot: number, tweaks: BotTweaks): boolean {
|
||||
return takingRank(s, player, slot, tweaks) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Could an industry of this kind be laid anywhere right now? Asked of the engine's own placement
|
||||
* rule (`canPlaceAt`) and lockout (`isLockedOut`) rather than a copy: an industry is plain east-west
|
||||
* track, so the only squares worth asking about are empty ones east or west of a card already down.
|
||||
*/
|
||||
function industrySiteExists(s: GameState, player: PlayerIndex, kind: FreightKind): boolean {
|
||||
const area = areaOf(s, player);
|
||||
if (isLockedOut(area, kind)) return false;
|
||||
const probe = {
|
||||
geometry: { kind: 'facility', facility: kind },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
} as unknown as TrackCard;
|
||||
for (const key of area.grid.keys()) {
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
for (const dc of [1, -1]) {
|
||||
const at = { row: row!, col: col! + dc };
|
||||
if (at.row === area.runningRow || area.grid.has(coordKey(at))) continue;
|
||||
if (canPlaceAt(area, at, probe)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* HOW BADLY the face-up card is wanted. 0 means not worth the draw.
|
||||
*
|
||||
@@ -471,14 +581,18 @@ function isWorthTaking(s: GameState, player: PlayerIndex, slot: number): boolean
|
||||
* happened to be scanned first — a coin flip on the card that decides whether the district ever
|
||||
* becomes a Passenger Facility at all.
|
||||
*/
|
||||
function takingRank(s: GameState, player: PlayerIndex, slot: number): number {
|
||||
function takingRank(s: GameState, player: PlayerIndex, slot: number, tweaks: BotTweaks): number {
|
||||
const id = topOfDepartment(s, slot);
|
||||
if (!id) return 0;
|
||||
const k = s.cards.get(id)?.kind;
|
||||
if (!k) return 0;
|
||||
if (k.kind === 'office') return nextOfficeTier(areaOf(s, player).tier) === k.tier ? 3 : 0;
|
||||
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return 2;
|
||||
if (k.kind === 'freightFacility') return 1;
|
||||
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') {
|
||||
return !tweaks.noPlayableTakes && trainWouldOverfillTheOffice(s, player, tweaks) ? 0 : 2;
|
||||
}
|
||||
if (k.kind === 'freightFacility') {
|
||||
return !tweaks.noPlayableTakes && !industrySiteExists(s, player, k.facility) ? 0 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -741,6 +855,104 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]):
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHAT A DISTRICT'S TRACK IS WORTH FOR WHAT IT LETS HAPPEN NEXT — the default since 2026-09-15;
|
||||
* `noValueLays` turns it off.
|
||||
*
|
||||
* `bestTrackLay` scores the PIECE — its shape and where it sits — and cannot tell one that opens an
|
||||
* industry site or closes a run-around from one that merely fills a square. This scores the LAYOUT the
|
||||
* piece would leave, so a lay is worth the difference it makes. Every term is something the rules turn
|
||||
* into play: a site is somewhere a held industry can go; a run-around lets a crew pass its own cars
|
||||
* (§A.5); a way off the main is the only road to either; a Running Track straight is what Interlocking
|
||||
* needs. The weights are a starting point to measure, not a result.
|
||||
*/
|
||||
function layoutValue(area: OfficeArea): number {
|
||||
let v = 0;
|
||||
const reachable = reachableOffMain(area);
|
||||
v += Math.min(reachable.size, 12) * 0.2;
|
||||
|
||||
let ways = 0;
|
||||
let loops = 0;
|
||||
for (const side of SIDES) {
|
||||
for (const col of waysOff(area, side)) {
|
||||
ways++;
|
||||
if (descendFrom(area, col, side).rejoins.size > 0) loops++;
|
||||
}
|
||||
}
|
||||
v += [0, 1.5, 2, 2.5][Math.min(ways, 3)]!;
|
||||
if (loops > 0) v += 6 + Math.min(loops - 1, 1) * 2;
|
||||
|
||||
// Squares an industry could legally be laid on, joined to track a crew can reach.
|
||||
const probe = protoCard({ kind: 'freightFacility', facility: 'mineTipple' }, 0)!;
|
||||
const tried = new Set<string>();
|
||||
let sites = 0;
|
||||
for (const key of reachable) {
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
for (const dc of [1, -1]) {
|
||||
const at = { row: row!, col: col! + dc };
|
||||
const k = coordKey(at);
|
||||
if (tried.has(k) || area.grid.has(k) || at.row === area.runningRow) continue;
|
||||
tried.add(k);
|
||||
if (canPlaceAt(area, at, probe)) sites++;
|
||||
}
|
||||
}
|
||||
v += [0, 2, 3, 3.5][Math.min(sites, 3)]!;
|
||||
|
||||
let mainStraight = false;
|
||||
for (const [key, card] of area.grid) {
|
||||
if (Number(key.split(',')[0]) !== area.runningRow || card.geometry.kind !== 'track') continue;
|
||||
if (card.geometry.geometry === 'straight') mainStraight = true;
|
||||
// A turnout on the main whose leg joins nothing is a hole in the Running Track with no road behind it.
|
||||
if (card.geometry.geometry === 'turnout') {
|
||||
const col = Number(key.split(',')[1]);
|
||||
for (const side of SIDES) {
|
||||
if (!hasPort(card, legPort(side))) continue;
|
||||
const beyond = area.grid.get(`${area.runningRow + side},${col}`);
|
||||
if (!beyond || !joins(card, legPort(side), beyond)) v -= 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mainStraight) v += 1.5;
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* The track lay worth most by `layoutValue`, placed on a copy of the district exactly as the reducer
|
||||
* places it (`protoCard`, `extendLimitsIfNeeded`). With `mustBuild`, only a lay that gains something is
|
||||
* offered, which is the slot `bestTrackLay` fills; without it, the best of whatever is legal, which is
|
||||
* the slot the "play what is in hand" fallback fills. Ties go to the square nearer the Office.
|
||||
*/
|
||||
function bestValuedLay(s: GameState, player: PlayerIndex, options: Intent[], mustBuild: boolean): Intent | null {
|
||||
const area = areaOf(s, player);
|
||||
const base = layoutValue(area);
|
||||
let best: Intent | null = null;
|
||||
let bestScore = -Infinity;
|
||||
for (const i of options) {
|
||||
if (i.type !== 'card.play' || i.placement === undefined) continue;
|
||||
const kind = s.cards.get(i.cardId)?.kind;
|
||||
if (kind?.kind !== 'track') continue;
|
||||
const built = protoCard(kind, i.variant);
|
||||
if (!built) continue;
|
||||
const after: OfficeArea = {
|
||||
...area,
|
||||
grid: new Map(area.grid),
|
||||
limitsWest: { ...area.limitsWest },
|
||||
limitsEast: { ...area.limitsEast },
|
||||
};
|
||||
after.grid.set(coordKey(i.placement), built);
|
||||
extendLimitsIfNeeded(after, i.placement);
|
||||
const gain = layoutValue(after) - base;
|
||||
if (mustBuild && gain <= 0.1) continue;
|
||||
const distance = Math.abs(i.placement.row - area.officeCoord.row) * 2 + Math.abs(i.placement.col - area.officeCoord.col);
|
||||
const score = gain - distance * 0.01;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
|
||||
const area = areaOf(s, player);
|
||||
|
||||
@@ -1213,7 +1425,7 @@ function followThrough(
|
||||
if (!turnOf(s, player).drawnThisTurn) {
|
||||
const piles = options.filter(
|
||||
(i): i is Extract<Intent, { type: 'draw.fromDepartment' }> =>
|
||||
i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot),
|
||||
i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot, tweaks),
|
||||
);
|
||||
// Best-ranked pile rather than the first that qualifies: an Office card and a train card
|
||||
// both "qualify", and only one of them stops the collisions.
|
||||
@@ -1221,7 +1433,7 @@ function followThrough(
|
||||
// and a train card are face up together 1.6 decisions a game — but ranking them is what the
|
||||
// ranking function is for, and a coin flip on the card that decides whether the district
|
||||
// ever becomes a Passenger Facility is not worth keeping for its own sake.
|
||||
const useful = piles.sort((a, b) => takingRank(s, player, b.slot) - takingRank(s, player, a.slot))[0];
|
||||
const useful = piles.sort((a, b) => takingRank(s, player, b.slot, tweaks) - takingRank(s, player, a.slot, tweaks))[0];
|
||||
if (useful) return because('a face-up card is worth more than a blind draw right now', useful);
|
||||
const blind = options.find((i) => i.type === 'draw.fromHomeOffice');
|
||||
if (blind) return because('no face-up card is worth taking — gamble on the deck', blind);
|
||||
@@ -1282,7 +1494,7 @@ function followThrough(
|
||||
// STRAIGHTS that Enhancements require, and no Freight Facility has anywhere to go until a
|
||||
// district exists. Measured with track absent, the hand held a playable Enhancement on 4,778
|
||||
// turns and could legally place one on 33.
|
||||
const track = bestTrackLay(s, player, options);
|
||||
const track = !tweaks.noValueLays ? bestValuedLay(s, player, options, true) : bestTrackLay(s, player, options);
|
||||
if (track) return because('lay track — nothing else creates the straights Enhancements need or the spurs freight needs', track);
|
||||
|
||||
// Then real development: a card actually laid into the grid. Freight facilities are scored —
|
||||
@@ -1307,17 +1519,27 @@ function followThrough(
|
||||
s.cards.get(i.cardId)?.kind.kind !== 'track',
|
||||
);
|
||||
if (placed) return because('develop the district with a card that goes on the board', placed);
|
||||
const play = options.find((i) => i.type === 'card.play');
|
||||
const play = !tweaks.noValueLays
|
||||
? options.find((i) => i.type === 'card.play' && s.cards.get(i.cardId)?.kind.kind !== 'track') ??
|
||||
bestValuedLay(s, player, options, false)
|
||||
: options.find((i) => i.type === 'card.play');
|
||||
if (play) return because('play what is in hand', play);
|
||||
const end = options.find((i) => i.type === 'draw.end');
|
||||
if (end) return because('nothing in hand can be played anywhere legal', end);
|
||||
return because(
|
||||
'nothing playable — discard onto the Department whose face-up card is least worth keeping reachable',
|
||||
bestDiscard(s, player, options) ?? pickFirst(options, 'card.discard') ?? options[0]!,
|
||||
bestDiscard(s, player, options, tweaks) ?? pickFirst(options, 'card.discard') ?? options[0]!,
|
||||
);
|
||||
}
|
||||
|
||||
case 'switch': {
|
||||
// The planner decides the whole turn; the rules below are its fallback if the position is ever
|
||||
// not the one it planned for, and the whole of switching under `noPlanSwitching`.
|
||||
if (!tweaks.noPlanSwitching) {
|
||||
const planned = plannedSwitch(s, player, options, tweaks);
|
||||
if (planned) return planned;
|
||||
}
|
||||
|
||||
/**
|
||||
* A MOVE THAT DRAGS THE CREW'S OWN CUT BACK ON IS A WASTED MOVE, so take those off the table
|
||||
* before any heuristic gets to choose one.
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@
|
||||
* Run with:
|
||||
* node src/sim/compare.ts 1600 noTrainCap=1 — what the A/D cap is worth today
|
||||
* node src/sim/compare.ts 1600 noOperateFirst=1 — what operating before drawing is worth
|
||||
* node src/sim/compare.ts 1600 noPlanSwitching=1 — what planning the switching turn is worth
|
||||
*
|
||||
* The flags are ABLATIONS: they turn off heuristics the bot already plays, so a negative delta is
|
||||
* the heuristic earning its place. That is what a measured bot needs going forward — the question
|
||||
@@ -221,7 +222,7 @@ export function formatPaired(r: PairedResult): string {
|
||||
* against itself and report a confident zero, which is the most expensive way this tool could fail.
|
||||
*/
|
||||
export const NUMERIC_TWEAKS = new Set<string>([]);
|
||||
export const BOOLEAN_TWEAKS = new Set(['noTrainCap', 'noOperateFirst']);
|
||||
export const BOOLEAN_TWEAKS = new Set(['noTrainCap', 'noOperateFirst', 'noPlanSwitching', 'noPlayableTakes', 'noValueLays', 'noDeliberateNewTrain']);
|
||||
|
||||
export function parseTweaks(args: string[]): BotTweaks {
|
||||
const tweaks: Record<string, number | boolean> = {};
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* THE DISPLAY-STEP COLLECTOR — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 1-3.
|
||||
*
|
||||
* One ordered, watchable presentation step per accepted intent, so a player can see what everyone
|
||||
* else did instead of finding the board already rearranged. TODO #13: *"It's not fun to do my turn
|
||||
* and have magic happen in the background and then have to figure out what others did."*
|
||||
*
|
||||
* ONE HOOK, NOT TWO. The design anticipated wiring this into `GameSession.intent()` and
|
||||
* `GameSession.driveBots()` separately, with `LocalSession` doing its own thing for solitaire. It
|
||||
* does not need to: `src/server/session.ts` imports `submit` from `src/web/game.ts`, so solitaire,
|
||||
* live multiplayer and every bot turn already funnel through ONE function. Collecting there is what
|
||||
* makes solitaire a special case of multiplayer rather than a second implementation, which is the
|
||||
* standing design direction for this codebase.
|
||||
*
|
||||
* AND REPLAY IS INERT FOR FREE. `fromSave` and `fromMultiplayerSave` rebuild a game by calling
|
||||
* `applyIntent` + `record` + `drain` directly rather than `submit`, so a resumed server or a rebuilt
|
||||
* undo does NOT re-emit the whole game as steps. That was expected to need an explicit guard — the
|
||||
* plan calls it out as the same class of bug as #97, a mechanism firing on a path nobody pictured
|
||||
* it running on. It needs none, but the property is load-bearing: **if a replay path is ever moved
|
||||
* onto `submit()`, this becomes a real bug**, and `test/watchable.test.ts` pins it.
|
||||
*
|
||||
* WHAT A STEP IS. One accepted intent, or ONE AUTOMATIC PHASE — never one per `GameEvent`, because
|
||||
* the event list is not a complete reducer and a receiver could not rebuild state from it. It gets a
|
||||
* projected frame instead.
|
||||
*
|
||||
* PHASES EARN THEIR OWN STEPS, and that is TODO #18. `pump()` runs every automatic phase between one
|
||||
* click and the next and `drain()` records the whole batch at once, so New Train, the Mainline and
|
||||
* the shift change "look like they are being skipped entirely" — trains cross the Division in one
|
||||
* jump. Folding them into the triggering intent's step reproduces exactly that. So `submit()` steps
|
||||
* `advance()` one call at a time instead, and collects a step for each phase that actually DID
|
||||
* something. A phase that did nothing adds no narration and therefore produces no step at all, which
|
||||
* is Jesse's own rule (2026-09-09): "if nothing happens during a phase then we shouldn't lose time
|
||||
* to it."
|
||||
*
|
||||
* `drain()` is deliberately NOT changed. Replay, undo and `fromSave` all use it, and the
|
||||
* replay-inertness property below depends on their staying off this path. The stepped version lives
|
||||
* in `submit()` and makes the same `advance()` calls in the same order, so the resulting state is
|
||||
* identical — only the collection differs.
|
||||
*/
|
||||
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { GameState, PlayerIndex, SeatIndex } from '../engine/state.ts';
|
||||
import { seatOf } from '../engine/state.ts';
|
||||
import { publicSnapshot } from './view.ts';
|
||||
import type { PublicFrame } from './view.ts';
|
||||
import { deltaPublicFrame } from './public-delta.ts';
|
||||
import type { PublicFrameDelta } from './public-delta.ts';
|
||||
|
||||
/**
|
||||
* The wire format's version, on the ENVELOPE rather than on the projection.
|
||||
*
|
||||
* The plan's original sketch put `protocolVersion` inside `PublicFrame`. It does not belong there:
|
||||
* `PublicFrame` is a projection of the game and its property list is an allow-list that
|
||||
* `test/redaction.test.ts` enumerates, so a transport concern living in it would have to be
|
||||
* allow-listed as public game state, which it is not. The step is the message; the message carries
|
||||
* the version.
|
||||
*/
|
||||
export const DISPLAY_PROTOCOL_VERSION = 1;
|
||||
|
||||
/** What produced a step: somebody's intent, or the Division advancing a phase by itself. */
|
||||
export type StepCause = Intent['type'] | 'phase';
|
||||
|
||||
/** One watchable thing that happened, in order. */
|
||||
export type DisplayStep = {
|
||||
protocolVersion: typeof DISPLAY_PROTOCOL_VERSION;
|
||||
/** Monotonic per game. 0.8.1's reconnecting display stream needs it to detect a gap; a queue only needs the order. */
|
||||
seq: number;
|
||||
/**
|
||||
* Who acted — NULL for an automatic phase, which nobody did.
|
||||
*
|
||||
* Both are carried because Employee Rotation makes "which seat" and "which player" different
|
||||
* questions.
|
||||
*/
|
||||
player: PlayerIndex | null;
|
||||
seat: SeatIndex | null;
|
||||
/** What caused it — the input to pacing's kind classification. */
|
||||
cause: StepCause;
|
||||
/** The public board after this intent and everything it drained, against the previous step. */
|
||||
frame: PublicFrameDelta;
|
||||
/** The narration this intent added, in order, including any phase lines drained behind it. */
|
||||
lines: { text: string; tone: string }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-game collector state.
|
||||
*
|
||||
* Held on `Game` beside `log`, `cues` and `announced` and drained the same way, which is the
|
||||
* established convention in this codebase for "the model accumulated something, the view takes it".
|
||||
*/
|
||||
export type DisplayCollector = {
|
||||
/** Undrained steps, oldest first. */
|
||||
steps: DisplayStep[];
|
||||
/** The last public frame a step was built against, so the next delta has something to diff. */
|
||||
last: PublicFrame | null;
|
||||
/** Next sequence number to assign. */
|
||||
seq: number;
|
||||
};
|
||||
|
||||
export function newCollector(): DisplayCollector {
|
||||
return { steps: [], last: null, seq: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one accepted intent as a step.
|
||||
*
|
||||
* Called from `submit()` AFTER `record()` and `drain()`, so `state` is the position the intent
|
||||
* finally produced and `lines` is everything it caused to be said. The frame is projected
|
||||
* immediately and never from a retained `GameState` reference — a retained reference would resolve
|
||||
* to the FINAL state of a whole bot run, which is exactly the teleporting this exists to prevent.
|
||||
*/
|
||||
export function collectStep(
|
||||
collector: DisplayCollector,
|
||||
state: GameState,
|
||||
player: PlayerIndex | null,
|
||||
cause: StepCause,
|
||||
lines: { text: string; tone: string }[],
|
||||
): void {
|
||||
const next = publicSnapshot(state);
|
||||
collector.steps.push({
|
||||
protocolVersion: DISPLAY_PROTOCOL_VERSION,
|
||||
seq: collector.seq++,
|
||||
player,
|
||||
seat: player === null ? null : seatOf(state, player),
|
||||
cause,
|
||||
frame: deltaPublicFrame(collector.last, next),
|
||||
lines,
|
||||
});
|
||||
collector.last = next;
|
||||
}
|
||||
|
||||
/** Take everything collected so far, leaving the collector empty — `takeMoment()`'s pattern. */
|
||||
export function takeSteps(collector: DisplayCollector): DisplayStep[] {
|
||||
return collector.steps.splice(0, collector.steps.length);
|
||||
}
|
||||
+213
-30
@@ -15,11 +15,13 @@
|
||||
* panel cannot drift from the rules.
|
||||
*/
|
||||
|
||||
import { MAX_CONSIST, crewTrayCount } from '../engine/content.ts';
|
||||
import { MAINLINE_PROFILES, MAX_CONSIST, crewTrayCount } from '../engine/content.ts';
|
||||
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
|
||||
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import { badlyMadeUp } from '../engine/advance.ts';
|
||||
import type { CrewTray } from '../engine/state.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small formatters
|
||||
@@ -50,6 +52,23 @@ export function carLabel(c: RollingStock, homeSeat?: SeatIndex): string {
|
||||
return homeSeat !== undefined && c.origin === homeSeat ? `${label} (loaded here)` : label;
|
||||
}
|
||||
|
||||
/**
|
||||
* "a loaded boxcar", "an empty tank" — the article the word actually takes.
|
||||
*
|
||||
* The make-up line hard-coded "a" and produced "a empty tank" at the table. Vowel-initial is the
|
||||
* whole rule here: every car word is ordinary English ('empty', 'loaded', and the car types), so
|
||||
* there is no 'an hour' case to special-case and inventing one would be the more fragile choice.
|
||||
*/
|
||||
export function indefinite(label: string): string {
|
||||
return `${/^[aeiou]/i.test(label) ? 'an' : 'a'} ${label}`;
|
||||
}
|
||||
|
||||
/** "Train 10" / "Extra X18" — one spelling of a train's name for every line that mentions one. */
|
||||
export function trainLabel(trainNumber: number | null, isExtra: boolean): string {
|
||||
if (trainNumber === null) return 'the local crew';
|
||||
return isExtra ? `Extra X${trainNumber}` : `Train ${trainNumber}`;
|
||||
}
|
||||
|
||||
export function carsLabel(cars: RollingStock[]): string {
|
||||
if (cars.length === 0) return 'nothing';
|
||||
return cars.map(carLabel).join(', ');
|
||||
@@ -108,11 +127,22 @@ export type NarrateContext = {
|
||||
* events has no roster, and "Player 2" is a truthful fallback rather than a broken one.
|
||||
*/
|
||||
playerName?: (player: PlayerIndex) => string;
|
||||
/**
|
||||
* Names the Facility standing on one of a player's squares, or null where there is none.
|
||||
*
|
||||
* Switching lines used to give the bare coordinate — "Set out a loaded hopper at (-1,1)" — which
|
||||
* is the grid's own notation and means nothing at a table where people are looking at cards. The
|
||||
* industry is the whole point of the move, so it is what the line should say.
|
||||
*/
|
||||
facilityAt?: (player: PlayerIndex, at: GridCoord) => string | null;
|
||||
};
|
||||
|
||||
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
|
||||
const train = (id: TrayId): string => ctx.trainName?.(id) ?? String(id);
|
||||
// The industry on a square when there is one, and the coordinate when there is not — a crew works
|
||||
// plain track too, and "at nowhere" would be worse than the notation.
|
||||
const place = (player: PlayerIndex, c: GridCoord): string => ctx.facilityAt?.(player, c) ?? at(c);
|
||||
|
||||
switch (e.type) {
|
||||
// -- clock
|
||||
@@ -127,6 +157,18 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
.map((p) => ctx.playerName?.(p) ?? `Player ${p + 1}`)
|
||||
.join(' → ')}`,
|
||||
};
|
||||
case 'superintendentChanged':
|
||||
/**
|
||||
* The Fedora is the only thing in the game that changes hands on a clock rather than because
|
||||
* somebody did something, so it is the one handover nobody at the table watches happen.
|
||||
*/
|
||||
return {
|
||||
tone: 'clock',
|
||||
text:
|
||||
`SUPERINTENDENT — the Fedora passes to ${ctx.playerName?.(e.player) ?? 'the next player'} ` +
|
||||
`at the end of Stage ${e.stage}. They rule on clearances, take the Yard Office and Red Flag ` +
|
||||
`questions, and every round that goes round the table now starts with them.`,
|
||||
};
|
||||
case 'phaseBegan':
|
||||
// Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
|
||||
// the log read as one undifferentiated column and you could not see where a phase began.
|
||||
@@ -134,7 +176,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
case 'actorChanged':
|
||||
return {
|
||||
tone: 'quiet',
|
||||
text: e.player === null ? 'No player acts — automatic phase' : `Player ${e.player} to act`,
|
||||
text: e.player === null ? 'No player acts — automatic phase' : 'to act',
|
||||
};
|
||||
|
||||
// -- local operations
|
||||
@@ -155,7 +197,11 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.to,
|
||||
text: `CREW moved ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
||||
// NO TUTORIAL TAIL. "The crew chip on the grid carries the whole train with it" was appended
|
||||
// to EVERY move — six times a turn, and the opener (`localOpsOptionChosen`) already says it
|
||||
// once. It also pushed the useful half of the line out of the caption row, which shows one
|
||||
// step at a time and is the place a player reads a move as it happens.
|
||||
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${place(e.player, e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of ${e.movesAllowed} Moves left`,
|
||||
};
|
||||
case 'carsCoupled': {
|
||||
/**
|
||||
@@ -167,22 +213,59 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
const own = e.recoupled?.stock.length ?? 0;
|
||||
const found = e.stock.length - own;
|
||||
const parts: string[] = [];
|
||||
if (own > 0) parts.push(`picked its own ${carsLabel(e.recoupled!.stock)} back up off ${at(e.recoupled!.at)} on the way out`);
|
||||
if (found > 0) parts.push(`coupled ${carsLabel(e.stock.slice(own))} standing on the line`);
|
||||
// `a loaded tank` for one, a bare list for several — "took loaded tank" reads as a telegram.
|
||||
const some = (cars: RollingStock[]): string =>
|
||||
cars.length === 1 ? indefinite(carLabel(cars[0]!)) : carsLabel(cars);
|
||||
if (own > 0) parts.push(`picked its own ${some(e.recoupled!.stock)} back up off ${at(e.recoupled!.at)} on the way out`);
|
||||
if (found > 0) parts.push(`took ${some(e.stock.slice(own))} standing there`);
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text:
|
||||
`Coupled ${e.stock.length} car(s) at ${at(e.at)} ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
|
||||
// "1 car(s)" was the plural of a machine. The count is already implied by the cars named
|
||||
// in `parts`, so the sentence leads with where and which end instead.
|
||||
`Coupled at ${place(e.player, e.at)}, ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
|
||||
` — ${parts.join(', and ')}`,
|
||||
};
|
||||
}
|
||||
case 'consistSorted':
|
||||
case 'consistSorted': {
|
||||
/**
|
||||
* WHERE THE ENGINE ENDED UP, and whether the train can still run.
|
||||
*
|
||||
* ASKED OF `badlyMadeUp` RATHER THAN RE-DECIDED HERE, which matters because the obvious guess
|
||||
* is wrong: §8.2 is enforced direction-free, so a train with its WHOLE consist ahead of the
|
||||
* engine is a pushing train and perfectly fit to leave. What it may not be is broken-backed,
|
||||
* with the engine buried among its own cars. A copy of that rule in the narrator would have
|
||||
* told a player their pushing train was stranded when it was not.
|
||||
*/
|
||||
const ahead = e.engineAt;
|
||||
const unfit = badlyMadeUp({ consist: e.after, engineAt: e.engineAt } as CrewTray);
|
||||
const where =
|
||||
ahead === 0
|
||||
? 'so the right car is now on the end and can be spotted'
|
||||
: unfit === null
|
||||
? `with the whole consist AHEAD of the engine — it runs as a pushing train`
|
||||
: `with ${ahead} car${ahead === 1 ? '' : 's'} ahead of the engine — ${unfit}, so it is held at the Office until it is sorted again (§8.2)`;
|
||||
return {
|
||||
tone: 'good',
|
||||
where: e.at,
|
||||
text: `SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
|
||||
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], ${where}`,
|
||||
};
|
||||
}
|
||||
case 'switchingEnded': {
|
||||
/**
|
||||
* The line that closes a switching turn, and the only one the history keeps from the middle of
|
||||
* it: what it cost, and where the crew was left standing.
|
||||
*/
|
||||
const used = `${e.movesUsed} of ${e.movesAllowed} Move${e.movesAllowed === 1 ? '' : 's'} used`;
|
||||
if (e.movesUsed === 0) return { tone: 'quiet', text: 'Finished switching without moving a car' };
|
||||
if (!e.lastMove) return { tone: 'plain', text: `Finished switching — ${used}` };
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.lastMove.to,
|
||||
text: `Finished switching — ${used}, leaving ${train(e.lastMove.trayId)} at ${place(e.player, e.lastMove.to)}`,
|
||||
};
|
||||
}
|
||||
case 'carsDropped':
|
||||
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
|
||||
// the train may pull away from cars set out behind it and must couple back up to cars set out
|
||||
@@ -191,7 +274,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text:
|
||||
`Set out ${carsLabel(e.stock)} at ${at(e.at)}, off the ${e.fromNose ? 'NOSE — ahead of the engine, so pulling forward will couple them again' : 'TAIL — behind the engine, so it may pull away and leave them'}`,
|
||||
`Set out ${carsLabel(e.stock)} at ${place(e.player, e.at)}, off the ${e.fromNose ? 'NOSE — ahead of the engine, so pulling forward will couple them again' : 'TAIL — behind the engine, so it may pull away and leave them'}`,
|
||||
};
|
||||
|
||||
// -- cards
|
||||
@@ -211,13 +294,19 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
? `Played ${card(e.cardId)} onto ${at(e.placement)}`
|
||||
: `Played ${card(e.cardId)}`,
|
||||
};
|
||||
case 'mainlineModified':
|
||||
case 'mainlineModified': {
|
||||
// WHICH CARD, NOT JUST WHICH WAY IT WENT. "Mainline card 3 converted to plains" left the reader
|
||||
// to remember what card 3 had been (playtest, 2026-09-15), and the card it WAS is the half that
|
||||
// says what the play was worth.
|
||||
const kindName = (k: string | undefined): string =>
|
||||
MAINLINE_PROFILES.find((m) => m.kind === k)?.name ?? k ?? 'that card';
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: e.became
|
||||
? `Realignment: Mainline card ${e.node} converted to ${e.became}`
|
||||
: `Played ${e.key} on Mainline card ${e.node}`,
|
||||
? `Realignment: Mainline card ${e.node}, ${kindName(e.from)}, converted to ${kindName(e.became)}`
|
||||
: `Played ${e.key} on Mainline card ${e.node}${e.from ? ` (${kindName(e.from)})` : ''}`,
|
||||
};
|
||||
}
|
||||
case 'redFlagSpent':
|
||||
return {
|
||||
tone: 'good',
|
||||
@@ -225,8 +314,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
};
|
||||
case 'redFlagRuled':
|
||||
return e.flag
|
||||
? { tone: 'plain', text: `Player ${e.player} flagged the approaching train` }
|
||||
: { tone: 'plain', text: `Player ${e.player} waved the train through` };
|
||||
? { tone: 'plain', text: 'Flagged the approaching train' }
|
||||
: { tone: 'plain', text: 'Waved the train through' };
|
||||
case 'redFlagsSet':
|
||||
return {
|
||||
tone: 'good',
|
||||
@@ -337,12 +426,25 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
* arrival; the only difference is what happens if it is left on Secondary Track when the next
|
||||
* Mainline Phase begins (`expediteFault`).
|
||||
*/
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: e.expedited
|
||||
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so keep it on the Office square: parked anywhere else in the district when the next Mainline Phase begins costs a Revenue point. It works and switches normally in the meantime.`
|
||||
: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands here for the rest of this Stage. You can work it in Cargo now, switch it in the NEXT Stage's Local Operations, and it departs in that Stage's Mainline Phase.`,
|
||||
};
|
||||
/**
|
||||
* WHOSE OFFICE, AND WHOSE TRAIN TO WORK — Jesse, playtest 2026-09-16.
|
||||
*
|
||||
* This said "ARRIVED at the Whistle Post" and then "You can work it in Cargo now". Both halves
|
||||
* are wrong at a table of four: every seat has an Office, so the tier alone does not say which
|
||||
* district the train is standing in, and the reader is usually NOT its Station Master — the
|
||||
* line was telling three players they could work a train they cannot touch.
|
||||
*/
|
||||
{
|
||||
const name = ctx.playerName?.(e.owner) ?? null;
|
||||
const whose = name === null ? `the ${e.office}` : `${name}'s ${e.office}`;
|
||||
const worker = name === null ? 'Its Station Master' : name;
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: e.expedited
|
||||
? `Train ${e.trainNumber} ARRIVED at ${whose} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so it must stay on the Office square: parked anywhere else in that district when the next Mainline Phase begins costs a Revenue point. It works and switches normally in the meantime.`
|
||||
: `Train ${e.trainNumber} ARRIVED at ${whose} carrying ${carsLabel(e.consist)} — it stands there for the rest of this Stage. ${worker} can work it in Cargo now and switch it in the NEXT Stage's Local Operations, and it departs in that Stage's Mainline Phase.`,
|
||||
};
|
||||
}
|
||||
case 'expediteFault':
|
||||
return {
|
||||
tone: 'bad',
|
||||
@@ -396,9 +498,39 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
};
|
||||
}
|
||||
case 'carPlacedOnTrain':
|
||||
return { tone: 'plain', text: `Added a ${carLabel(e.stock)} to the train being made up` };
|
||||
// NAMES THE TRAIN. "the train being made up" was true and useless: a player looking back for
|
||||
// what happened to train 10 found four lines that never said 10 (playtest, 2026-09-16).
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: `Added ${indefinite(carLabel(e.stock))} to ${trainLabel(e.trainNumber, e.isExtra)}`,
|
||||
};
|
||||
case 'carPassed':
|
||||
return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
|
||||
return {
|
||||
tone: 'quiet',
|
||||
text: `Passed on ${trainLabel(e.trainNumber, e.isExtra)} — no suitable car in the Division Yard`,
|
||||
};
|
||||
case 'makeUpShort': {
|
||||
// What it wanted, in the words the card uses, so the line can be checked against the card.
|
||||
const names: Record<'freight' | 'coach' | 'caboose', string> = {
|
||||
freight: 'freight car',
|
||||
coach: 'coach',
|
||||
caboose: 'caboose',
|
||||
};
|
||||
const wants = e.missing.map((m) => names[m]).join(' or ');
|
||||
const got = e.placed === 0 ? 'NO CARS AT ALL' : `only ${e.placed} of the ${e.wanted} its card calls for`;
|
||||
// §2.2 is the whole explanation and it is not guessable from the board: the cars are visible
|
||||
// in the Classification Yard, and why they will not come back is not.
|
||||
const why =
|
||||
e.waiting > 0
|
||||
? ` ${e.waiting} sit in the Classification Yard, which comes back only when the Division Yard is bare — and it still holds ${e.divisionYardHolds} cars.`
|
||||
: ' There are none in the Classification Yard either.';
|
||||
return {
|
||||
tone: 'bad',
|
||||
text:
|
||||
`${trainLabel(e.trainNumber, e.isExtra).toUpperCase()} WAS MADE UP WITH ${got} — the ` +
|
||||
`Division Yard holds no ${wants} it can take, so nobody was asked for one.${why}`,
|
||||
};
|
||||
}
|
||||
case 'dispatchBonusUsed':
|
||||
return {
|
||||
tone: 'good',
|
||||
@@ -416,12 +548,31 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
const wrecked = e.trains
|
||||
.map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`)
|
||||
.join(' and ');
|
||||
/**
|
||||
* WHOSE OFFICE, AND WHO PAYS (playtest, 2026-09-16: "it doesn't say who suffers the revenue
|
||||
* loss… we need to know which player received the penalty and why").
|
||||
*
|
||||
* `player` is the seat at fault, and for everything that happens inside a district that is the
|
||||
* district's owner — so it names the place as well as the payer. A Mainline collision is the
|
||||
* Superintendent's by rule (§10), which is a different sentence: it happened on open road, not
|
||||
* in anybody's Office. The 5 points ride in a separate `revenueChanged`, which is why the line
|
||||
* never mentioned them; a player should not have to add two log entries together.
|
||||
*/
|
||||
const who = ctx.playerName?.(e.player) ?? null;
|
||||
const mainline = e.where === 'the Mainline';
|
||||
const place = who === null || mainline ? e.where : `${who}'s ${e.where.replace(/^the /, '')}`;
|
||||
const cost =
|
||||
who === null
|
||||
? ' 5 Revenue is lost.'
|
||||
: mainline
|
||||
? ` ${who} loses 5 Revenue: §10 makes a Mainline collision the Superintendent's fault.`
|
||||
: ` ${who} loses 5 Revenue — it happened in their district.`;
|
||||
return {
|
||||
tone: 'bad',
|
||||
text:
|
||||
`COLLISION — ${wrecked} destroyed: ${why}. Engines and cabooses go back to the Division ` +
|
||||
`Yard, all other cars to the Classification Yard. A Timetabled train card returns ` +
|
||||
`to its slot and runs again next Day; an Extra is gone for good.`,
|
||||
`COLLISION at ${place} — ${wrecked} destroyed: ${why}.${cost} Engines and cabooses go back ` +
|
||||
`to the Division Yard, all other cars to the Classification Yard. A Timetabled train card ` +
|
||||
`returns to its slot and runs again next Day; an Extra is gone for good.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -496,13 +647,13 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` }
|
||||
: { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
|
||||
case 'phaseEnded':
|
||||
return { tone: 'quiet', text: `Player ${e.player} finished ${phaseLabel(e.phase)}` };
|
||||
return { tone: 'quiet', text: `Finished ${phaseLabel(e.phase)}` };
|
||||
|
||||
// -- §3.3, extended play (Gitea#11)
|
||||
case 'extensionVoted':
|
||||
return e.agree
|
||||
? { tone: 'plain', text: `Player ${e.player} would play one more Day` }
|
||||
: { tone: 'plain', text: `Player ${e.player} called time — the game ends here` };
|
||||
? { tone: 'plain', text: 'Would play one more Day' }
|
||||
: { tone: 'plain', text: 'Called time — the game ends here' };
|
||||
case 'dayExtended':
|
||||
return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` };
|
||||
case 'playConcluded':
|
||||
@@ -511,8 +662,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
// -- §11, the Yard Office (Gitea#5)
|
||||
case 'yardOfficeRuled':
|
||||
return e.take
|
||||
? { tone: 'plain', text: `Player ${e.player} sent ${train(e.trainId)} into the Yard Office` }
|
||||
: { tone: 'plain', text: `Player ${e.player} kept ${train(e.trainId)} at the Train Order Office` };
|
||||
? { tone: 'plain', text: `Sent ${train(e.trainId)} into the Yard Office` }
|
||||
: { tone: 'plain', text: `Kept ${train(e.trainId)} at the Train Order Office` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,6 +792,38 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
* that actually refused rather than a second guess at it.
|
||||
*/
|
||||
if (f.kind === 'passenger') {
|
||||
/**
|
||||
* NOBODY TO PUT ON THE PLATFORM, AND NO WAY TO SEE WHY (playtest, 2026-09-16).
|
||||
*
|
||||
* "He would like to have two passengers waiting in his depot… but the only option he had was
|
||||
* bringing a tank load into the refinery." His Depot had a Restaurant and a Hotel beside it and
|
||||
* three outbound slots — capacity was never the problem. §6.3 stocking takes a LOADED car of
|
||||
* the facility's type out of the Division Yard, and there was not a loaded coach in it: six
|
||||
* were sitting in Classification, which §2.2 returns only when the Division Yard runs bare.
|
||||
*
|
||||
* Jesse's ruling (2026-09-16) is the same one Gitea#2 got: the shortage stays, because running
|
||||
* out is part of the game. What must not stay is the silence — an action with no legal target
|
||||
* is simply absent from the menu, so the player is left to guess whether they misunderstood the
|
||||
* rules or the game is broken.
|
||||
*/
|
||||
if (f.allows.outbound && f.outboundBox.length < f.capacity.outbound) {
|
||||
const loadedCoaches = s.yards.divisionYard.filter((c) => c.type === 'coach' && c.loaded).length;
|
||||
if (loadedCoaches === 0) {
|
||||
const waiting = s.yards.classificationYard.filter((c) => c.type === 'coach' && c.loaded).length;
|
||||
out.push({
|
||||
where: `${name} ${key}`,
|
||||
why:
|
||||
`room for ${f.capacity.outbound - f.outboundBox.length} more passenger` +
|
||||
`${f.capacity.outbound - f.outboundBox.length === 1 ? '' : 's'} to wait, but no loaded ` +
|
||||
`coach in the Division Yard for the Freight Agent to bring over` +
|
||||
(waiting > 0
|
||||
? ` — ${waiting} ${waiting === 1 ? 'is' : 'are'} in the Classification Yard, which comes ` +
|
||||
'back only when the Division Yard is bare'
|
||||
: ''),
|
||||
severity: 'waiting',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (portersLeft(f) > 0) {
|
||||
const coord = uncoordKey(key);
|
||||
// Passengers standing on the platform with nothing carrying them away.
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* HOW LONG EACH STEP IS SHOWN — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 5.
|
||||
*
|
||||
* Shared rather than living in `src/web/`, so the 0.8.1 seatless board paces identically to a
|
||||
* player's own screen. Two views of one game that disagreed about how fast it looks would be worse
|
||||
* than either alone.
|
||||
*
|
||||
* WHY BY KIND RATHER THAN BY BUDGET. The obvious scheme is to give the whole backlog a time budget
|
||||
* and divide it by the queue length. Measured against real games, that does exactly the wrong
|
||||
* thing. From `public/replays/`: ~60 stages per game and ~5 intents per player per stage, so a
|
||||
* four-player table produces ~15 other-player steps per stage — but 44 of a 307-intent game are
|
||||
* `draw.end` and 60 are `loadUnload.end`, bookkeeping nobody wants to watch, while the thing that
|
||||
* is worth watching is rare and clustered. Two of the three published replays contain no
|
||||
* `switch.move` at all; the third has bursts of 14, 6, 6 and 6, and `trayMoved`'s own narration says
|
||||
* "N of 6 Moves left" because six is the engine's cap per crew. So a uniform budget spends the
|
||||
* player's attention on `draw.end` and rushes the switching.
|
||||
*
|
||||
* Assigning dwell by kind and letting the total fall out costs ~40s of animation across a whole
|
||||
* 60-stage game, against ~3.6 minutes for a flat 700ms — better switching visibility for a fifth of
|
||||
* the time. Jesse, 2026-09-09, on what matters: *"I definitely want to watch other players struggle
|
||||
* with the switching exercises … I don't think reading the switching in the log will be anywhere
|
||||
* nearly as interesting as watching the trains actually move on the board."*
|
||||
*
|
||||
* PACING IS CLIENT-SIDE ONLY. The server emits steps as fast as it likes and the client decides how
|
||||
* to show them, which is what keeps Gitea#20's "do not slow the authoritative game" true.
|
||||
*/
|
||||
|
||||
import type { StepCause } from './display-step.ts';
|
||||
|
||||
export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
|
||||
|
||||
/**
|
||||
* THE TUNING TABLE — dwell in milliseconds per kind.
|
||||
*
|
||||
* Start generous and tune down by playing; Jesse, 2026-09-09: *"start at 1s and tune down."* This is
|
||||
* the committed default and changing it needs a web rebuild, which in the `.s9pk` is a release — so
|
||||
* it is deliberately not the only way to change the pacing. A viewer's own `pace` multiplier
|
||||
* (`Settings`, `localStorage`) and a `?pace=` URL parameter both scale these without one, and
|
||||
* `pace = 0` turns the animation off entirely, which is also TODO #18's "a player who has seen it a
|
||||
* hundred times will want it off". **Multipliers above 1 are supported and expected** — Jesse asked
|
||||
* for 2 and 3 explicitly after the first play — up to `MAX_PACE`, and every tier scales together so
|
||||
* their relative weighting survives.
|
||||
*
|
||||
* NOT IN GAME-CREATION SETTINGS, on Jesse's call 2026-09-09: dwell is presentation, not a rule, and
|
||||
* `config` rides along in saves and replays. If it ever moves there, the config field supplies this
|
||||
* table's multiplier — the table, the classification and the queue do not change.
|
||||
*/
|
||||
export const DWELL: Record<StepKind, number> = {
|
||||
/** A train physically moving on the board. The thing worth watching, and protected accordingly. */
|
||||
switching: 1000,
|
||||
/**
|
||||
* A card, a car or a load changing hands somewhere visible — and the announcement of what a
|
||||
* player is about to do.
|
||||
*
|
||||
* WAS 250ms, WHICH WAS WRONG, and wrong in the way that mattered most: an early-game bot turn has
|
||||
* no switching in it at all, so it was six steps of 250ms and 0ms — **750ms for a whole turn**.
|
||||
* Jesse, from the first real play on the test server: *"bot play was way too fast. I briefly saw
|
||||
* that it was the bot's office area then their turn was done."* His instruction had been "start at
|
||||
* 1s and tune down", and that was applied only to switching while this number was invented.
|
||||
*/
|
||||
action: 700,
|
||||
/**
|
||||
* An automatic phase that DID something — TODO #18.
|
||||
*
|
||||
* Only reached when the phase actually narrated: `submit()` collects no step for a phase that
|
||||
* changed nothing, so this is never spent on the empty ones Jesse is content to guess at. Between
|
||||
* an ordinary action and a switching move, because the Mainline phase moves trains the length of
|
||||
* the Division and is the clearest case of "stuff just happened without being able to see how".
|
||||
*/
|
||||
phase: 600,
|
||||
/** Turn and phase bookkeeping. Nothing moved; do not spend the player's attention on it. */
|
||||
bookkeeping: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Which kind an intent is.
|
||||
*
|
||||
* Exhaustive over `Intent['type']` on purpose — a `default` would silently drop a newly added intent
|
||||
* into whatever tier the fallback names, and the failure mode is invisible (a move that never gets
|
||||
* a beat, or bookkeeping that stalls the queue for a second). `test/pacing.test.ts` walks every
|
||||
* member of the union so a new intent cannot land here unclassified.
|
||||
*/
|
||||
export function kindOf(cause: StepCause): StepKind {
|
||||
switch (cause) {
|
||||
// The Division advancing itself — New Train, the Mainline, the shift change (TODO #18).
|
||||
case 'phase':
|
||||
return 'phase';
|
||||
|
||||
// The crew and its train moving, coupling, setting out and re-ordering — §6.1 and Appendix A.
|
||||
case 'switch.move':
|
||||
case 'switch.dropCars':
|
||||
case 'switch.sortConsist':
|
||||
case 'maneuver.flyingSwitch':
|
||||
case 'maneuver.redFlags':
|
||||
return 'switching';
|
||||
|
||||
// Something visible changed hands or position, but no train drove anywhere.
|
||||
case 'card.play':
|
||||
case 'card.discard':
|
||||
case 'draw.fromHomeOffice':
|
||||
case 'draw.fromDepartment':
|
||||
case 'newTrain.placeCar':
|
||||
case 'newTrain.passCar':
|
||||
case 'newTrain.secondSection':
|
||||
case 'newTrain.startExtra':
|
||||
case 'porter.board':
|
||||
case 'porter.detrain':
|
||||
case 'laborer.startLoad':
|
||||
case 'laborer.advanceLoad':
|
||||
case 'laborer.beginUnload':
|
||||
case 'freightAgent.stockOutbound':
|
||||
case 'freightAgent.clearInbound':
|
||||
case 'freightAgent.unjam':
|
||||
case 'mainline.clearance':
|
||||
case 'mainline.modify':
|
||||
case 'mainline.redFlag':
|
||||
case 'mainline.yardOffice':
|
||||
case 'redFlag.play':
|
||||
return 'action';
|
||||
|
||||
/**
|
||||
* `localOps.choose` IS AN ANNOUNCEMENT, NOT BOOKKEEPING — moved out 2026-09-09 after the first
|
||||
* real play. It is the line that reads "Player Bot 1 chose to SWITCH — six Moves to shunt cars
|
||||
* around the yard": the heading for everything that follows, and at zero dwell nobody ever saw
|
||||
* it, so a bot's turn began with no indication of what it was about to do.
|
||||
*/
|
||||
case 'localOps.choose':
|
||||
return 'action';
|
||||
|
||||
// Ending a phase or a turn, and voting. Nothing to see: the consequences were the thing, and
|
||||
// there are more of these than of anything else.
|
||||
case 'loadUnload.end':
|
||||
case 'draw.end':
|
||||
case 'switch.end':
|
||||
case 'freightAgent.end':
|
||||
case 'game.extend':
|
||||
return 'bookkeeping';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The widest multiplier that is a speed rather than a mistake.
|
||||
*
|
||||
* `pace` has no lower surprise — 0 means off — but an unbounded upper one does: `?pace=300` from
|
||||
* somebody typing 3.00, or a corrupt `localStorage` value, would give a switching move a five-minute
|
||||
* dwell and look exactly like a frozen board. Twenty is far past any speed anyone would choose and
|
||||
* well short of unusable.
|
||||
*
|
||||
* RAISED FROM TEN 2026-09-10, because the ceiling turned out not to be theoretical: Jesse played at
|
||||
* 10× — the top of the ladder — and reported it *"still a bit fast, but followable"*. A control whose
|
||||
* slowest setting is not slow enough for the person using it has the wrong ceiling, not the right one
|
||||
* held firmly.
|
||||
*/
|
||||
export const MAX_PACE = 20;
|
||||
|
||||
/**
|
||||
* The speeds the on-screen control offers, slowest last.
|
||||
*
|
||||
* `0` is off: every move is drawn at once, as it was before v0.8.0 — TODO #18's "a player who has
|
||||
* seen it a hundred times will want it off". The ladder runs well past 1 because that is what the
|
||||
* first real play asked for: Jesse reached for 7×, and although the `?pace=` he used never took
|
||||
* effect (the splash replaces the query string, so the play page only ever saw `?lobby`), the wish
|
||||
* was real. Watching a bot shunt cars is the point of this feature, and it is worth as long as it
|
||||
* takes.
|
||||
*/
|
||||
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10, 15, 20] as const;
|
||||
|
||||
/**
|
||||
* How long to show one step, in ms, at a given speed.
|
||||
*
|
||||
* `pace` scales every tier by the same factor, so **the tiers stay in proportion at any speed** — a
|
||||
* switching move outlasts an ordinary action at 0.5× and at 3× alike. That is deliberate: the
|
||||
* relative weighting is the design (a train moving is worth more attention than a card changing
|
||||
* hands), and the multiplier is only how fast the whole thing runs. `0` means do not animate at all.
|
||||
*/
|
||||
export function dwellFor(cause: StepCause, pace = 1): number {
|
||||
return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace)));
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the speed control a PHASE gets — damped, not the full multiplier.
|
||||
*
|
||||
* Phases were pinned at their tabled beat in v0.8.0.3, because scaling them with everything else put
|
||||
* a wall of clock-ticking after a player's own move. That was right about the cost and wrong about
|
||||
* the need: at 10× the caption row goes past faster than the sentence on it can be read. Jesse,
|
||||
* 2026-09-10: *"phases displayed on the upper line go by too quickly still. Should be 4 times as
|
||||
* long — at a guess. Maybe use the speed multiplier for that too?"*
|
||||
*
|
||||
* So they scale, at a third of the rate. That lands exactly on his guess — 10× gives a phase four
|
||||
* times its tabled beat — while leaving 1× untouched, and it stays affordable because phase beats
|
||||
* cluster rather than accumulate: measured over 60 pushes, a push carries **1.0 phase beat on
|
||||
* average and 4 at worst**, so the wait after a move goes to ~2.4s typical and ~10s at its very
|
||||
* worst rather than the minutes a full multiplier would have cost.
|
||||
*
|
||||
* Below 1× it simply follows the multiplier: somebody asking for everything faster means the phases
|
||||
* too.
|
||||
*/
|
||||
function phaseSpeed(pace: number): number {
|
||||
return pace <= 1 ? pace : 1 + (pace - 1) / 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to show one STEP — the form the queue actually uses.
|
||||
*
|
||||
* A step that said nothing gets no dwell, whatever caused it. That is one rule covering two cases
|
||||
* arrived at separately: a phase where nothing happened (Jesse, 2026-09-09 — *"if nothing happens
|
||||
* during a phase then we shouldn't lose time to it"*), and a phase that only handed the turn on,
|
||||
* which changes the board but has nothing on it to look at. Structurally typed so this file does not
|
||||
* have to import `DisplayStep` back from the module that imports `StepCause` from it.
|
||||
*/
|
||||
export function dwellForStep(
|
||||
step: { cause: StepCause; player: number | null; lines: readonly unknown[]; frame: { table: object } },
|
||||
pace = 1,
|
||||
): number {
|
||||
// Off means off, for the clock as much as for anybody's move.
|
||||
if (pace <= 0) return 0;
|
||||
/**
|
||||
* THE SPEED CONTROL IS ABOUT OTHER PEOPLE, NOT ABOUT THE CLOCK.
|
||||
*
|
||||
* A phase keeps its tabled beat at every speed. Measured over 40 turns of a real 3-seat game, the
|
||||
* waiting split almost evenly — 21.0s of other players against 21.0s of phases turning over — so
|
||||
* scaling both put 105 seconds of clock-ticking into a 5× game, all of it after the player's own
|
||||
* move and none of it anything to watch. Jesse, from that game: *"after my turn, when I actually
|
||||
* execute my turn, I'm still subject to that same delay before it moves on. That makes no sense."*
|
||||
*
|
||||
* The phase still gets its beat (TODO #18) — it just does not get longer because somebody wanted
|
||||
* to watch a bot shunt cars.
|
||||
*/
|
||||
const speed = step.player === null ? phaseSpeed(pace) : pace;
|
||||
if (step.lines.length > 0) return dwellFor(step.cause, speed);
|
||||
/**
|
||||
* A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give
|
||||
* every phase a visible beat", for New Train, the Mainline and the shift change.
|
||||
*
|
||||
* Measured, because the obvious rule was wrong twice. "No narration, no dwell" looked right and
|
||||
* silently killed #18: a phase can move trains without saying anything, and those steps were being
|
||||
* flashed past. "Anything that changed the board" is wrong the other way: `submit()` steps
|
||||
* `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on
|
||||
* all of them would cost a quarter of an hour a game.
|
||||
*/
|
||||
const table = step.frame.table as Record<string, unknown>;
|
||||
const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table;
|
||||
return turned ? dwellFor(step.cause, speed) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many steps in a queue are actually going to be WATCHED.
|
||||
*
|
||||
* This is the number the "N behind" counter shows, and it is deliberately not `queue.length`. With
|
||||
* bookkeeping dwelling at zero, a backlog of 17 where 12 are `*.end` would read "17", plummet to 5
|
||||
* the instant it started, and then crawl — which is not the steady countdown the counter is for.
|
||||
* Thirteen dwelling steps means thirteen things you are going to see.
|
||||
*/
|
||||
export function watchableCount(causes: readonly StepCause[], pace = 1): number {
|
||||
return causes.filter((c) => dwellFor(c, pace) > 0).length;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Delta for the SEATLESS public frame — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 3.
|
||||
*
|
||||
* `frame-delta.ts` solves the same-shaped problem for a seated player's `Frame` and does NOT carry
|
||||
* over, which is worth saying plainly because reusing it looks obvious and is wrong. It nulls three
|
||||
* TOP-LEVEL keys — `cells`, `facilities`, `division` — and a `PublicFrame` has only the last of
|
||||
* those. Its `cells` and `facilities` live one level down, inside `districts[]`, one entry per seat,
|
||||
* and that is where nearly all of the bytes are.
|
||||
*
|
||||
* **So the districts are deltaed PER SEAT rather than as one array.** One accepted intent changes
|
||||
* one district; comparing the whole array as a unit would resend every other player's board on
|
||||
* every step, which is exactly the cost this exists to avoid. On a four-player table that is three
|
||||
* boards of waste per step, and a step is emitted for every bot move as well as every human one.
|
||||
*
|
||||
* It is also a TRUE PARTIAL rather than a full frame with holes in it, which is the other place
|
||||
* `frame-delta.ts` does not carry over. See `PublicFrameDelta` below for the measurement that forced
|
||||
* that; in short, most steps change one field and shipping the other thirty-four cost 16.7 MB a game.
|
||||
*
|
||||
* The convention that does carry over, kept identical so a reader of one file can read the other:
|
||||
* an absent or `null` field means "unchanged since the last thing sent to this receiver", and the
|
||||
* receiving side merges against the last full frame it actually holds. A first connect or a
|
||||
* reconnect after a gap sends a full frame instead — the display stream resets rather than replaying
|
||||
* (§ v0.8.0).
|
||||
*
|
||||
* Node-free by design, like `frame-delta.ts`: the server and the browser both import this directly.
|
||||
*/
|
||||
|
||||
import type { CellView, DivisionView, FacilityView, PublicDistrict, PublicFrame } from './view.ts';
|
||||
|
||||
/**
|
||||
* One district with its two heavy fields nulled when unchanged.
|
||||
*
|
||||
* `seat` is the identity and is always present — it is what the receiver matches on. `player` and
|
||||
* `name` are always sent too, and deliberately: Employee Rotation moves players between districts,
|
||||
* so the pairing of seat to player is itself news, and it costs two small fields to never have to
|
||||
* reason about whether a relabelling was missed.
|
||||
*/
|
||||
export type PublicDistrictDelta = Omit<PublicDistrict, 'cells' | 'facilities'> & {
|
||||
cells: CellView[] | null;
|
||||
facilities: FacilityView[] | null;
|
||||
};
|
||||
|
||||
/** The shared-table half of a `PublicFrame` — everything that is not the Division or a district. */
|
||||
type PublicTable = Omit<PublicFrame, 'division' | 'districts'>;
|
||||
|
||||
/**
|
||||
* A `PublicFrame` reduced to WHAT CHANGED.
|
||||
*
|
||||
* **Partial, not a full frame with holes**, and that distinction was measured rather than assumed.
|
||||
* The first version of this spread `...next` and nulled only the board fields, so every step shipped
|
||||
* all 35 top-level properties even when the sole change was whose turn it was. Once TODO #18 gave
|
||||
* automatic phases their own steps, most steps became exactly that — a turn handed on, nothing to
|
||||
* look at — and a full 6-day game cost **19.4 MB**, of which **16.7 MB was those silent steps at
|
||||
* ~11 KB each**. As a partial they are a few dozen bytes.
|
||||
*/
|
||||
export type PublicFrameDelta = {
|
||||
/** Only the shared-table fields whose value differs from the previous frame. */
|
||||
table: Partial<PublicTable>;
|
||||
/** The Division, only when it changed. */
|
||||
division: DivisionView[] | null;
|
||||
/** Only the districts that changed, each carrying only the board fields that changed. */
|
||||
districts: PublicDistrictDelta[];
|
||||
};
|
||||
|
||||
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
const TABLE_KEYS = (frame: PublicFrame): (keyof PublicTable)[] =>
|
||||
(Object.keys(frame) as (keyof PublicFrame)[]).filter(
|
||||
(k): k is keyof PublicTable => k !== 'division' && k !== 'districts',
|
||||
);
|
||||
|
||||
/**
|
||||
* `previous` is the last public frame actually sent to THIS receiver, or `null` for a first connect
|
||||
* or a reset — in which case everything is sent in full.
|
||||
*/
|
||||
export function deltaPublicFrame(previous: PublicFrame | null, next: PublicFrame): PublicFrameDelta {
|
||||
const before = new Map(previous?.districts.map((d) => [d.seat, d]) ?? []);
|
||||
const table: Partial<PublicTable> = {};
|
||||
for (const key of TABLE_KEYS(next)) {
|
||||
if (previous === null || !same(previous[key], next[key])) {
|
||||
(table as Record<string, unknown>)[key] = next[key];
|
||||
}
|
||||
}
|
||||
const districts: PublicDistrictDelta[] = [];
|
||||
for (const d of next.districts) {
|
||||
const was = before.get(d.seat);
|
||||
const cells = was && same(was.cells, d.cells) ? null : d.cells;
|
||||
const facilities = was && same(was.facilities, d.facilities) ? null : d.facilities;
|
||||
// A district with nothing new is left out entirely rather than sent as a row of nulls: on a
|
||||
// four-player table three of them are unchanged on every single step.
|
||||
if (was && cells === null && facilities === null && same(was, d)) continue;
|
||||
districts.push({ ...d, cells, facilities });
|
||||
}
|
||||
return {
|
||||
table,
|
||||
division: previous !== null && same(previous.division, next.division) ? null : next.division,
|
||||
districts,
|
||||
};
|
||||
}
|
||||
|
||||
/** The receiving side: merges a delta back onto the last full public frame this receiver holds. */
|
||||
export function applyPublicDelta(previous: PublicFrame | null, delta: PublicFrameDelta): PublicFrame {
|
||||
const base = previous ?? (delta.table as PublicTable);
|
||||
const merged = { ...base, ...delta.table } as PublicTable;
|
||||
const bySeat = new Map((previous?.districts ?? []).map((d) => [d.seat, d]));
|
||||
for (const d of delta.districts) {
|
||||
const was = bySeat.get(d.seat);
|
||||
bySeat.set(d.seat, {
|
||||
...d,
|
||||
cells: d.cells ?? need(was?.cells, `districts[seat ${d.seat}].cells`),
|
||||
facilities: d.facilities ?? need(was?.facilities, `districts[seat ${d.seat}].facilities`),
|
||||
});
|
||||
}
|
||||
return {
|
||||
...merged,
|
||||
division: delta.division ?? need(previous?.division, 'division'),
|
||||
districts: [...bySeat.values()].sort((a, b) => a.seat - b.seat),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A delta that says "unchanged" against a receiver that has nothing to merge onto is a bug in the
|
||||
* SENDER's bookkeeping, not a recoverable state — it means the two sides disagree about what has
|
||||
* been delivered, and quietly producing a frame with a missing board would put a blank district in
|
||||
* front of a player. `frame-delta.ts` throws in the same situation and for the same reason.
|
||||
*/
|
||||
function need<T>(value: T | undefined, what: string): T {
|
||||
if (value === undefined) {
|
||||
throw new Error(`deltaPublicFrame said "${what}" is unchanged, but there is no previous frame to merge onto`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A face-up or face-down pile a card can move to or from, as the display addresses it. */
|
||||
export type PileKey = 'home' | 'salvage' | `dept${number}`;
|
||||
|
||||
/**
|
||||
* WHICH PILES A STEP MOVED — derived, never sent.
|
||||
*
|
||||
* The receiver already holds the frame before a step and the frame after it, so which pile changed
|
||||
* is a diff rather than something the wire has to carry. That matters twice over: nothing is added
|
||||
* to the protocol, and it cannot drift out of step with the projection the way a hand-maintained
|
||||
* hint would.
|
||||
*
|
||||
* WHY IT IS NEEDED AT ALL. A player watching somebody else draw a card sees seven seconds of an
|
||||
* unchanged board — the step holds the screen, and the only thing that moved is a number in a panel
|
||||
* they were not looking at. Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast
|
||||
* for me to see"*, which was never about duration. Lighting the pile is what tells the eye where.
|
||||
*
|
||||
* WHAT EACH ACTION MOVES, measured across four seeds rather than reasoned about:
|
||||
*
|
||||
* | intent | piles |
|
||||
* | ----------------------- | -------------------------------------------------------- |
|
||||
* | `draw.fromHomeOffice` | `home` — the COUNT only; the card itself stays private |
|
||||
* | `draw.fromDepartment` | that `dept`, and `home` too when the pile refills from it |
|
||||
* | `card.discard` | that `dept` |
|
||||
* | `card.play` | `salvage`, or nothing here when it lands on the board |
|
||||
* | switching, new trains | nothing here — those show on the board itself |
|
||||
*/
|
||||
/**
|
||||
* Mainline cards that became a different card between two public boards — a Realignment, which is the
|
||||
* one play that changes the Division itself.
|
||||
*
|
||||
* Playtest, 2026-09-15: *"is it possible to flash the mainline card when it gets changed by realignment?
|
||||
* This would be more obvious to see what's happening on the map."* Detected the same way `changedPiles`
|
||||
* detects a pile moving — by comparing the two boards the queue already holds — rather than by reading
|
||||
* the event, so the flash lands with the step that shows it and not when the intent arrived.
|
||||
*/
|
||||
export function changedDivisionCards(before: PublicFrame | null, after: PublicFrame): number[] {
|
||||
if (before === null) return [];
|
||||
const out: number[] = [];
|
||||
after.division.forEach((node, i) => {
|
||||
const was = before.division[i];
|
||||
if (was && was.kind === 'ml' && node.kind === 'ml' && was.label !== node.label) out.push(i);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function changedPiles(before: PublicFrame | null, after: PublicFrame): PileKey[] {
|
||||
if (before === null) return [];
|
||||
const out: PileKey[] = [];
|
||||
if (before.deck !== after.deck) out.push('home');
|
||||
after.departmentDepth.forEach((depth, i) => {
|
||||
// The TOP as well as the depth: taking the face-up card and replacing it leaves the count alone
|
||||
// and changes the card everybody can see, which is the half that matters to a watcher.
|
||||
if (before.departmentDepth[i] !== depth || before.departments[i] !== after.departments[i]) {
|
||||
out.push(`dept${i}`);
|
||||
}
|
||||
});
|
||||
if (before.salvage.depth !== after.salvage.depth || before.salvage.top !== after.salvage.top) {
|
||||
out.push('salvage');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -79,6 +79,8 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
const narrateCtx = {
|
||||
cardName: (id: string) => cardName(s, id),
|
||||
trainName: (id: string) => trainName(s, id),
|
||||
// A player index is not a seat index, so the fallback names no number at all — see `web/game.ts`.
|
||||
playerName: (p: number) => s.players[p]?.name ?? 'another player',
|
||||
};
|
||||
|
||||
// Tracks whether a phase did anything, so an empty one can say so rather than ending silently.
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Component 17b — planning a whole switching turn before making the first Move.
|
||||
*
|
||||
* Dev-side, like the rest of the bot. The developer bot's switching branch chooses ONE move at a time
|
||||
* from a ladder of rules, and its own comment names what that cannot do: "a strong player would use
|
||||
* the six Moves to re-order the consist — that is the game's central switching puzzle, and this bot
|
||||
* does not attempt it." This attempts it, for one turn at a time.
|
||||
*
|
||||
* WHY SEARCH IS FAIR HERE. A switching turn draws no card and rolls no die, so trying sequences on a
|
||||
* copy of the game is exactly what a player does by looking at the board. The score below reads only
|
||||
* what a player can see — the district, the cars on the trains, the facilities — and never the deck.
|
||||
*
|
||||
* WHY NOT EVERY SEQUENCE. Measured 2026-09-14 over 30 switching turns from bot games: a median turn
|
||||
* reaches 229 distinct positions, but 11 of 30 passed 20,000, because setting cars out is free and a
|
||||
* crew can leave them in a great many places. So the search keeps the best `beam` positions at each
|
||||
* step and stops at `budget` positions tried. Small turns are searched completely inside that.
|
||||
*
|
||||
* THE SCORE IS OF WHERE THE TURN ENDS, not of what it did, and it starts from Jesse's ruling
|
||||
* (2026-09-14): "players will attempt to deliver / pick up cars even if it delays trains." So a car
|
||||
* put where it can be worked is worth a point, and a train left away from the Office costs a quarter
|
||||
* of one. The weights are a starting point to measure, not a result.
|
||||
*/
|
||||
|
||||
import { areaAtSeat, areaOf, commitEvents, facilityCarTypes, prepareIntent, withRouteCache } from '../engine/apply.ts';
|
||||
import { badlyMadeUp, isExpedited } from '../engine/advance.ts';
|
||||
import { MAX_CONSIST } from '../engine/content.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalSwitchingActions } from '../engine/legal.ts';
|
||||
import { cloneTally, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||
|
||||
export const SWITCH_WEIGHTS = {
|
||||
/** A car standing where its industry can load or unload it — the point of switching. */
|
||||
spot: 1.0,
|
||||
/** The same, past what the industry's boxes can work at once. */
|
||||
spotBeyondCapacity: 0.25,
|
||||
/** A car the industry cannot work, taking room on its track. */
|
||||
junkOnIndustry: -0.5,
|
||||
/** A finished car — loaded at a shipper, emptied at a receiver — still waiting to be lifted. */
|
||||
finishedLeft: -0.15,
|
||||
/** A car on one of this district's trains that some industry here would work. */
|
||||
carriedWanted: 0.35,
|
||||
/** The same car left on ordinary track, where a later turn can fetch it. */
|
||||
stagedWanted: 0.2,
|
||||
/** A coach kept with its train, or parked at the Office where §A.4 allows it. */
|
||||
coachWithTrain: 0.3,
|
||||
/** A coach left anywhere else, where no Porter can work it. */
|
||||
coachStranded: -0.3,
|
||||
/** Anything but a coach standing on the Office square — the next arrival collides (§8.3). */
|
||||
fouling: -3,
|
||||
/** A train that ends the turn away from the Office and so cannot highball next Mainline Phase. */
|
||||
trainAway: -0.25,
|
||||
/** On top of that, an expedited train — Q3 charges a Revenue point every Phase it is away. */
|
||||
expeditedAway: -1.0,
|
||||
/** A train that could not leave even from the Office — engine buried, caboose mid-train (§8.2). */
|
||||
notMadeUp: -0.6,
|
||||
/** Tie-breaks, so equal outcomes prefer the plan that does less. */
|
||||
perMove: -0.02,
|
||||
perSetOut: -0.005,
|
||||
/** A maneuver card spent — Flying Switch — so the planner plays one only when it buys something. */
|
||||
cardSpent: -0.1,
|
||||
} as const;
|
||||
|
||||
export type PlanOptions = {
|
||||
budget: number;
|
||||
beam: number;
|
||||
/**
|
||||
* Search Flying Switch alongside Moves, set-outs and sorts. On by default but UNMEASURED: the card
|
||||
* is dealt 0 copies (Jesse, 2026-08-26), so over 400 paired seeds turning it on changed nothing —
|
||||
* it is here so the planner can use the card the day it is dealt again.
|
||||
*/
|
||||
flyingSwitch?: boolean;
|
||||
};
|
||||
/**
|
||||
* Measured 2026-09-14, paired over 400 seeds against 3000/48: 2000/32 cost −0.02 ± 0.01 (t = −1.68,
|
||||
* inside the noise) at half the time per turn; 1000/24 cost −0.06 ± 0.02 (t = −2.65) for little more.
|
||||
*/
|
||||
export const DEFAULT_PLAN: PlanOptions = { budget: 2000, beam: 32, flyingSwitch: true };
|
||||
|
||||
export type SwitchPlan = {
|
||||
/** The intents to submit, in order. Empty when nothing beats stopping where the crew stands. */
|
||||
steps: Intent[];
|
||||
/** `switchFingerprint` before each step, and after the last — so a caller can tell it is on plan. */
|
||||
keys: string[];
|
||||
rootScore: number;
|
||||
score: number;
|
||||
/** Positions tried. */
|
||||
expanded: number;
|
||||
/** False when the budget ran out before the search did. */
|
||||
complete: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A copy of the game that a switching intent can be applied to without touching the original.
|
||||
*
|
||||
* NOT `structuredClone`, of the state or even of the district. A switching intent writes only the
|
||||
* cars standing on cards, the industry tracks, the district's A/D and held lists, the consist and
|
||||
* position of the trays standing in it, this player's turn and the tally — so exactly those arrays are
|
||||
* copied and everything else is shared by reference. Measured 2026-09-14, deep-cloning the district
|
||||
* was 44% of all planning time.
|
||||
*
|
||||
* `test/switch-planner.test.ts` proves across real games that planning leaves the original
|
||||
* byte-identical — which is what fails first if a reducer ever starts writing somewhere new, or
|
||||
* starts mutating a car or a card in place instead of replacing it.
|
||||
*/
|
||||
export function forkForSwitching(s: GameState, player: PlayerIndex): GameState {
|
||||
const seat = seatOf(s, player);
|
||||
const area = areaAtSeat(s, seat);
|
||||
const grid = new Map<string, TrackCard>();
|
||||
for (const [key, card] of area.grid) {
|
||||
const f = card.facility;
|
||||
grid.set(key, {
|
||||
...card,
|
||||
standing: [...card.standing],
|
||||
facility: f ? { ...f, industryTrack: { cars: [...f.industryTrack.cars] } } : f,
|
||||
});
|
||||
}
|
||||
const officeAreas = new Map(s.officeAreas);
|
||||
officeAreas.set(seat, {
|
||||
...area,
|
||||
grid,
|
||||
adOccupancy: [...area.adOccupancy],
|
||||
heldAtLimits: [...area.heldAtLimits],
|
||||
dispatchUsedToday: [...area.dispatchUsedToday],
|
||||
});
|
||||
const trays = new Map(s.trays);
|
||||
for (const [id, t] of s.trays) {
|
||||
if (t.position.at === 'grid' && t.position.seat === seat) trays.set(id, { ...t, consist: [...t.consist] });
|
||||
}
|
||||
const turns = new Map(s.turns);
|
||||
const turn = s.turns.get(player)!;
|
||||
turns.set(player, { ...turn, freightWorked: { ...turn.freightWorked } });
|
||||
// Flying Switch spends its card (`spendCard`): the hand map is rewritten and the Salvage Yard grows.
|
||||
const decks = { ...s.decks, hands: new Map(s.decks.hands), salvageYard: [...s.decks.salvageYard] };
|
||||
return { ...s, officeAreas, trays, turns, decks, tally: cloneTally(s.tally) };
|
||||
}
|
||||
|
||||
const carList = (xs: readonly RollingStock[]): string =>
|
||||
xs.map((c) => `${c.type}${c.loaded ? '+' : '-'}${c.origin ?? ''}`).join(',');
|
||||
|
||||
/**
|
||||
* Everything a switching intent can change, as a string — two positions with the same fingerprint
|
||||
* are the same position as far as the rest of the turn is concerned. Identical cars are not told
|
||||
* apart, which is right: no intent names a car.
|
||||
*/
|
||||
export function switchFingerprint(s: GameState, player: PlayerIndex): string {
|
||||
const seat = seatOf(s, player);
|
||||
const parts: string[] = [];
|
||||
for (const [id, t] of s.trays) {
|
||||
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
|
||||
const { row, col } = t.position.coord;
|
||||
parts.push(`${id}@${row},${col}/${t.facing}/${t.railFacing ?? ''}/${t.engineAt}:${carList(t.consist)}`);
|
||||
}
|
||||
for (const [key, card] of areaOf(s, player).grid) {
|
||||
const track = card.facility?.kind === 'freight' ? card.facility.industryTrack.cars : null;
|
||||
if (card.standing.length === 0 && card.standingWest === 0 && (track?.length ?? 0) === 0) continue;
|
||||
parts.push(`${key}=${carList(card.standing)}|${card.standingWest}|${track ? carList(track) : ''}`);
|
||||
}
|
||||
const turn = turnOf(s, player);
|
||||
parts.push(`m${turn.movesRemaining}`, JSON.stringify(turn.freightWorked), `h${(s.decks.hands.get(player) ?? []).join(',')}`);
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* §9.3 — an outbound industry loads EMPTY cars of its commodity, an inbound one unloads LOADED ones —
|
||||
* but never a load that was made in this same district (v0.4.9e, `LOADED_IN_THIS_DISTRICT`).
|
||||
*/
|
||||
function works(f: Facility, c: RollingStock, seat: number): boolean {
|
||||
if (f.kind !== 'freight' || !facilityCarTypes(f).includes(c.type)) return false;
|
||||
return (!c.loaded && f.allows.outbound) || (c.loaded && f.allows.inbound && c.origin !== seat);
|
||||
}
|
||||
|
||||
/**
|
||||
* The car an industry has finished with. Only decidable at a one-way industry: at one that both
|
||||
* ships and receives, a loaded car may be a delivery still waiting to be unloaded.
|
||||
*/
|
||||
function finished(f: Facility, c: RollingStock): boolean {
|
||||
if (f.kind !== 'freight' || !facilityCarTypes(f).includes(c.type)) return false;
|
||||
if (f.allows.outbound && !f.allows.inbound) return c.loaded;
|
||||
if (f.allows.inbound && !f.allows.outbound) return !c.loaded;
|
||||
return false;
|
||||
}
|
||||
|
||||
const same = (a: GridCoord, b: GridCoord): boolean => a.row === b.row && a.col === b.col;
|
||||
|
||||
/** How good this district's position is for the rest of the game, in rough Revenue points. */
|
||||
export function evaluateSwitching(s: GameState, player: PlayerIndex): number {
|
||||
const W = SWITCH_WEIGHTS;
|
||||
const area = areaOf(s, player);
|
||||
const seat = seatOf(s, player);
|
||||
const officeKey = coordKey(area.officeCoord);
|
||||
const passengerOffice = area.grid.get(officeKey)?.facility?.kind === 'passenger';
|
||||
let v = 0;
|
||||
|
||||
const withRoom: Facility[] = [];
|
||||
for (const card of area.grid.values()) {
|
||||
const f = card.facility;
|
||||
if (!f || f.kind !== 'freight') continue;
|
||||
if (f.industryTrack.cars.length < MAX_CONSIST) withRoom.push(f);
|
||||
const cap = Math.max(1, f.capacity.outbound + f.capacity.inbound);
|
||||
let working = 0;
|
||||
for (const c of f.industryTrack.cars) {
|
||||
if (works(f, c, seat)) v += ++working <= cap ? W.spot : W.spotBeyondCapacity;
|
||||
else if (finished(f, c)) v += W.finishedLeft;
|
||||
else v += W.junkOnIndustry;
|
||||
}
|
||||
}
|
||||
const wanted = (c: RollingStock): boolean => withRoom.some((f) => works(f, c, seat));
|
||||
|
||||
for (const [key, card] of area.grid) {
|
||||
if (card.facility?.kind === 'freight') continue;
|
||||
const atOffice = key === officeKey;
|
||||
for (const c of card.standing) {
|
||||
if (c.type === 'coach') v += atOffice && passengerOffice ? W.coachWithTrain : W.coachStranded;
|
||||
else if (atOffice) v += W.fouling;
|
||||
else if (wanted(c)) v += W.stagedWanted;
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of s.trays.values()) {
|
||||
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
|
||||
for (const c of t.consist) {
|
||||
if (c.type === 'coach') v += passengerOffice ? W.coachWithTrain : 0;
|
||||
else if (wanted(c)) v += W.carriedWanted;
|
||||
}
|
||||
if (t.trainNumber === null) continue;
|
||||
if (!same(t.position.coord, area.officeCoord)) {
|
||||
v += W.trainAway;
|
||||
if (isExpedited(t)) v += W.expeditedAway;
|
||||
}
|
||||
if (badlyMadeUp(t)) v += W.notMadeUp;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* For ORDERING the beam only, never for choosing the plan: a Move toward an industry changes nothing
|
||||
* the score can see until the car is set out, so without this the beam would drop the approach in
|
||||
* favour of positions that merely look tidy.
|
||||
*/
|
||||
function approach(s: GameState, player: PlayerIndex): number {
|
||||
const area = areaOf(s, player);
|
||||
const seat = seatOf(s, player);
|
||||
const targets: { at: GridCoord; f: Facility }[] = [];
|
||||
for (const [key, card] of area.grid) {
|
||||
const f = card.facility;
|
||||
if (!f || f.kind !== 'freight' || f.industryTrack.cars.length >= MAX_CONSIST) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
targets.push({ at: { row: row!, col: col! }, f });
|
||||
}
|
||||
let bonus = 0;
|
||||
for (const t of s.trays.values()) {
|
||||
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
|
||||
const here = t.position.coord;
|
||||
for (const c of t.consist) {
|
||||
let nearest = Infinity;
|
||||
for (const { at, f } of targets) {
|
||||
if (works(f, c, seat)) nearest = Math.min(nearest, Math.abs(at.row - here.row) + Math.abs(at.col - here.col));
|
||||
}
|
||||
if (nearest !== Infinity) bonus += 0.1 / (1 + nearest);
|
||||
}
|
||||
}
|
||||
return bonus;
|
||||
}
|
||||
|
||||
const SEARCHED = new Set<Intent['type']>(['switch.move', 'switch.dropCars', 'switch.sortConsist']);
|
||||
|
||||
type Node = {
|
||||
s: GameState;
|
||||
steps: Intent[];
|
||||
keys: string[];
|
||||
moves: number;
|
||||
setOuts: number;
|
||||
cards: number;
|
||||
score: number;
|
||||
rank: number;
|
||||
};
|
||||
|
||||
/** The best way found to spend what is left of this switching turn. Never mutates `s`. */
|
||||
export function planSwitchingTurn(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
opts: PlanOptions = DEFAULT_PLAN,
|
||||
): SwitchPlan {
|
||||
const W = SWITCH_WEIGHTS;
|
||||
const scoreOf = (st: GameState, moves: number, setOuts: number, cards: number): number =>
|
||||
evaluateSwitching(st, player) + moves * W.perMove + setOuts * W.perSetOut + cards * W.cardSpent;
|
||||
const searched = (type: Intent['type']): boolean =>
|
||||
SEARCHED.has(type) || (opts.flyingSwitch === true && type === 'maneuver.flyingSwitch');
|
||||
|
||||
const rootKey = switchFingerprint(s, player);
|
||||
const rootScore = scoreOf(s, 0, 0, 0);
|
||||
const root: Node = { s, steps: [], keys: [rootKey], moves: 0, setOuts: 0, cards: 0, score: rootScore, rank: rootScore };
|
||||
let best = root;
|
||||
const seen = new Set([rootKey]);
|
||||
let frontier: Node[] = [root];
|
||||
let expanded = 0;
|
||||
let complete = true;
|
||||
|
||||
search: while (frontier.length > 0) {
|
||||
const next: Node[] = [];
|
||||
for (const node of frontier) {
|
||||
const movesLeft = turnOf(node.s, player).movesRemaining;
|
||||
// Every candidate is decided against THIS position, inside one route cache, and only then applied
|
||||
// to its own copy: deciding on the copy would re-walk routes the listing had just walked.
|
||||
const decided = withRouteCache(node.s, () =>
|
||||
legalSwitchingActions(node.s, player)
|
||||
.filter((i) => searched(i.type) && (i.type === 'switch.dropCars' || movesLeft >= 1))
|
||||
.map((i) => ({ i, r: prepareIntent(node.s, player, i) })),
|
||||
);
|
||||
for (const { i, r } of decided) {
|
||||
if (expanded >= opts.budget) {
|
||||
complete = false;
|
||||
break search;
|
||||
}
|
||||
expanded++;
|
||||
if (!r.ok) continue;
|
||||
const f = forkForSwitching(node.s, player);
|
||||
commitEvents(f, r.events);
|
||||
const key = switchFingerprint(f, player);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const setOut = i.type === 'switch.dropCars';
|
||||
const moves = node.moves + (setOut ? 0 : 1);
|
||||
const setOuts = node.setOuts + (setOut ? 1 : 0);
|
||||
const cards = node.cards + (i.type === 'maneuver.flyingSwitch' ? 1 : 0);
|
||||
const score = scoreOf(f, moves, setOuts, cards);
|
||||
const child: Node = {
|
||||
s: f,
|
||||
steps: [...node.steps, i],
|
||||
keys: [...node.keys, key],
|
||||
moves,
|
||||
setOuts,
|
||||
cards,
|
||||
score,
|
||||
rank: score + approach(f, player),
|
||||
};
|
||||
if (score > best.score + 1e-9) best = child;
|
||||
next.push(child);
|
||||
}
|
||||
}
|
||||
// A stable sort, so equal ranks keep `legalActions` order and the bot stays deterministic.
|
||||
frontier = next.length > opts.beam ? next.sort((a, b) => b.rank - a.rank).slice(0, opts.beam) : next;
|
||||
}
|
||||
|
||||
return { steps: best.steps, keys: best.keys, rootScore, score: best.score, expanded, complete };
|
||||
}
|
||||
+21
-3
@@ -55,7 +55,7 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
|
||||
// Says WHO builds, which is the question this phase actually raises at a table: the round
|
||||
// starts with the Superintendent and works left, one car each, repeating (§7, Gap 9) — not
|
||||
// with whoever played the card. An Extra is the exception: its player loads it as they choose.
|
||||
tip: 'Timetabled trains for this Stage are built: starting with the Superintendent and working left, each player adds ONE car, going round again until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
|
||||
tip: 'Timetabled trains for this Stage are built: each player adds ONE car at a time, starting with the Superintendent and working eastward, repeating until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
|
||||
// a locomotive being made up
|
||||
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
|
||||
},
|
||||
@@ -106,7 +106,25 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
|
||||
* and answers who; `awaiting` says what, because "waiting on Bob" with no more than that is a
|
||||
* game that looks stuck to everyone except Bob.
|
||||
*/
|
||||
const who = actorName ?? 'nobody — the Division is running itself';
|
||||
/**
|
||||
* AN AUTOMATIC PHASE WAITS ON NOBODY, so it says what it is DOING instead of apologising.
|
||||
*
|
||||
* "nobody — the Division is running itself" reached the answer by negation, and left a player
|
||||
* reading a line whose subject was an absence (Jesse, playtest 2026-09-16: it should say "waiting
|
||||
* on <player>", or describe what the engine is doing — "the Division is moving trains during the
|
||||
* mainline phase"). The phase's NAME is already printed on the line directly above this one, so
|
||||
* these describe the work rather than repeating the label.
|
||||
*/
|
||||
const DOING: Record<string, string> = {
|
||||
mainline: 'the Division is moving trains',
|
||||
newTrain: "the Division is building this Stage's trains",
|
||||
loadUnload: 'the Division is working cargo',
|
||||
shiftChange: 'the Division is changing shifts',
|
||||
};
|
||||
// Local Operations always has an actor, so its entry is the fallback rather than a case.
|
||||
const who = actorName ?? DOING[f.phaseKey] ?? 'the Division is running itself';
|
||||
// Only a person is WAITED ON. The Division is not waiting; it is working.
|
||||
const waiting = actorName === null ? '' : 'waiting on ';
|
||||
const asked = f.awaiting
|
||||
? ` <span class="tc-asks">${esc(f.awaiting.asks)} · ${esc(f.awaiting.train)}</span>`
|
||||
: '';
|
||||
@@ -120,7 +138,7 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
|
||||
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
|
||||
`<span class="dim">${esc(f.clock)}</span></div>` +
|
||||
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
|
||||
`<div class="tc-who">waiting on <b>${esc(who)}</b>${asked}</div>` +
|
||||
`<div class="tc-who">${waiting}<b>${esc(who)}</b>${asked}</div>` +
|
||||
// THE FEDORA RIDES AT THE END OF THE PHASE ROW (`TODO.md` #29, Jesse). It sat on its own line
|
||||
// between the phases and everything above them, which put a thing that changes every third
|
||||
// Stage in the middle of the things that change every Stage. The row it belongs beside is the
|
||||
|
||||
+96
-3
@@ -10,12 +10,13 @@
|
||||
* drift into two different pictures of the same board.
|
||||
*/
|
||||
|
||||
import { isExpedited, regionOfTransit } from '../engine/advance.ts';
|
||||
import { badlyMadeUp, isExpedited, regionOfTransit } from '../engine/advance.ts';
|
||||
import {
|
||||
areaAtSeat,
|
||||
areaOf,
|
||||
destinationsFor,
|
||||
facilityCarType,
|
||||
isBeingMadeUp,
|
||||
laborersLeft,
|
||||
movesFor,
|
||||
ownCutFor,
|
||||
@@ -276,6 +277,15 @@ export type TrainChip = {
|
||||
* it belongs in the tooltip, where there is room to say which it is.
|
||||
*/
|
||||
stagesLeft?: number;
|
||||
/**
|
||||
* BEING MADE UP RIGHT NOW — §7's round, one car at a time, at a Division Point.
|
||||
*
|
||||
* The make-up panel names the train and the yard chips load it, and both are in the right-hand
|
||||
* column; the train itself is drawn on the Division strip at the top left, looking exactly like
|
||||
* every other chip on the map. So the two halves of the same activity never pointed at each other
|
||||
* (Jesse, playtest 2026-09-16). Absent rather than false everywhere else, like `region` above.
|
||||
*/
|
||||
beingMadeUp?: true;
|
||||
};
|
||||
/**
|
||||
* One card of a player's Running Track, as the Division sees it.
|
||||
@@ -437,6 +447,8 @@ export type Frame = {
|
||||
maxCollisionsPerDay: number;
|
||||
maxCollisionsTotal: number;
|
||||
collisionsToday: number;
|
||||
/** What the Day that just ended finished on — see `collisionsPrevDay` in `engine/state.ts`. */
|
||||
collisionsPrevDay: number;
|
||||
collisionsTotal: number;
|
||||
status: GameState['status'];
|
||||
outcome: GameState['outcome'];
|
||||
@@ -718,6 +730,26 @@ function suppressedGrants(modifiers: string[], f: Facility): string[] {
|
||||
for (const key of modifiers) {
|
||||
const m = MODIFIER_PROFILES.find((p) => p.kind === key);
|
||||
if (!m) continue;
|
||||
/**
|
||||
* A WHISTLE POST TAKES NOTHING AT ALL, AND SAYING "IT ONLY RECEIVES" WOULD BE A LIE.
|
||||
*
|
||||
* Jesse, playtest 2026-09-16: a Restaurant appeared to do nothing. It does nothing — a Whistle
|
||||
* Post is not a Passenger Facility, so it allows neither direction and has 0 capacity each way;
|
||||
* the engine's `usableGrant` discards the capacity while the porter is granted regardless, which
|
||||
* leaves a porter with nothing to carry. Playing it there stays LEGAL on Jesse's call, so the
|
||||
* card is not wasted — it starts working the moment the Office is upgraded — but the panel has
|
||||
* to say so, or the player is left believing the card is broken.
|
||||
*
|
||||
* Only a Whistle Post can reach this: every freight flow allows at least one direction, and
|
||||
* every Office above the first allows both.
|
||||
*/
|
||||
if (f.kind === 'passenger' && !f.allows.outbound && !f.allows.inbound) {
|
||||
out.push(
|
||||
`${m.name}: DORMANT — a Whistle Post works no passengers at all, so nothing this card ` +
|
||||
`grants is in use yet. It all starts working when the Office is upgraded to a Depot.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (m.addOut > 0 && !f.allows.outbound) {
|
||||
out.push(`${m.name}: +${m.addOut} outbound has no effect here — this facility only receives`);
|
||||
}
|
||||
@@ -1004,8 +1036,65 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
const end = i.fromNose ? 'off the front' : 'off the back';
|
||||
return `set out ${carsLabel(cut)} ${end}`;
|
||||
}
|
||||
case 'switch.sortConsist':
|
||||
return `re-order consist [${i.order.join(',')}]`;
|
||||
case 'switch.sortConsist': {
|
||||
/**
|
||||
* THE TRAIN IT WOULD MAKE, DRAWN THE WAY THE BOARD DRAWS IT.
|
||||
*
|
||||
* This read `re-order consist [1,2,3,0]` — the engine's own array indices offered to a person
|
||||
* — and the option Jesse wanted was the first of five and unidentifiable (playtest,
|
||||
* 2026-09-17). Naming the cars fixed that and left a second ambiguity he caught immediately:
|
||||
* a list "front to back" means nothing at a table looking at a map, because which end is the
|
||||
* front depends on which way the train is pointed.
|
||||
*
|
||||
* SO IT IS LAID OUT WEST TO EAST, exactly as `board-svg.ts` lays the crew strip: the consist
|
||||
* is stored nose first, and a train facing EAST is reversed so its nose lands at the east end
|
||||
* where it actually is. The engine is the same ◀ / ▶ arrow the board uses, seated where it
|
||||
* will be, so "ahead of the engine" and "behind the engine" are read off the picture rather
|
||||
* than asserted in words — and the button and the board cannot disagree.
|
||||
*/
|
||||
const sorting = s.trays.get(i.trayId);
|
||||
if (!sorting) return `re-order consist [${i.order.join(',')}]`;
|
||||
const after = i.order.map((n) => sorting.consist[n]!).filter((c) => c !== undefined);
|
||||
const engineAt = i.engineAt ?? 0;
|
||||
const facing = railFacingOf(sorting);
|
||||
|
||||
const items = after.map((c) => carLabel(c));
|
||||
items.splice(engineAt, 0, facing === 'w' ? '◀ ENGINE' : 'ENGINE ▶');
|
||||
// West on the left, like the map and like the crew strip on the board.
|
||||
const strip = (facing === 'e' ? [...items].reverse() : items).join(' · ');
|
||||
|
||||
/**
|
||||
* WHAT IT WOULD MEAN, from §8.2's own predicate rather than a copy of it: a train with its
|
||||
* whole consist ahead of the engine is a PUSHING train and fit to run, a buried engine is not,
|
||||
* and a caboose has to ride at the end away from the engine. Numbered trains only — a local
|
||||
* crew has no card and never departs, so a departure verdict on one is noise.
|
||||
*/
|
||||
const unfit =
|
||||
sorting.trainNumber === null
|
||||
? null
|
||||
: badlyMadeUp({ ...sorting, consist: after, engineAt });
|
||||
// `badlyMadeUp` leads with "not made up — ", which reads as a stutter in front of HELD. The
|
||||
// reason after it is the part worth showing, so the prefix comes off.
|
||||
const because = unfit?.replace(/^not made up — /, '') ?? '';
|
||||
const verdict =
|
||||
sorting.trainNumber === null
|
||||
? ''
|
||||
: unfit === null
|
||||
? ' · MADE UP, ready to leave'
|
||||
: ` · HELD at the Office: ${because}`;
|
||||
|
||||
// A sort that only moves the engine says which errand it is running, rather than reprinting a
|
||||
// car order that has not changed.
|
||||
const sameOrder = i.order.every((n, at) => n === at);
|
||||
const lead = sameOrder
|
||||
? engineAt === 0
|
||||
? 'pull the engine back to the front'
|
||||
: engineAt === after.length
|
||||
? 'put the whole consist ahead of the engine'
|
||||
: `move the engine behind ${engineAt} car${engineAt === 1 ? '' : 's'}`
|
||||
: 're-order';
|
||||
return `${lead} — west to east: ${strip}${verdict}`;
|
||||
}
|
||||
case 'freightAgent.stockOutbound': {
|
||||
/**
|
||||
* "stock a coach at (0, 0)" reads as putting a CAR on the track, and was reported as exactly
|
||||
@@ -1594,6 +1683,7 @@ export function projectSharedTable(s: GameState) {
|
||||
maxCollisionsPerDay: s.config.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: s.config.maxCollisionsTotal,
|
||||
collisionsToday: s.collisionsToday,
|
||||
collisionsPrevDay: s.collisionsPrevDay,
|
||||
collisionsTotal: s.collisionsTotal,
|
||||
status: s.status,
|
||||
outcome: s.outcome,
|
||||
@@ -2364,6 +2454,9 @@ function trainChip(s: GameState, id: string): TrainChip {
|
||||
engineAt: at,
|
||||
facing: railFacingOf(t),
|
||||
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
|
||||
// Conditional spread, not `beingMadeUp: isBeingMadeUp(t)`: the field is optional-and-true, and
|
||||
// `exactOptionalPropertyTypes` refuses an explicit `false` for it.
|
||||
...(isBeingMadeUp(t) ? { beingMadeUp: true as const } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+231
-7
@@ -23,16 +23,18 @@
|
||||
* folding events does not rebuild a game — `protocol.md` §3.)
|
||||
*/
|
||||
|
||||
import { pump } from '../engine/advance.ts';
|
||||
import { advance, pump } from '../engine/advance.ts';
|
||||
import { applyIntent } from '../engine/apply.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
||||
import type { CardId, GameConfig, GameState, GridCoord, PlayerIndex } from '../engine/state.ts';
|
||||
import { overHandLimit as overHandLimitOf } from '../engine/state.ts';
|
||||
import { playerAtSeat } from '../engine/state.ts';
|
||||
import { cuesFor, narrate } from '../sim/narrate.ts';
|
||||
import { collectStep, newCollector } 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,
|
||||
// which would pull node:fs into a browser bundle.
|
||||
import {
|
||||
@@ -53,6 +55,7 @@ import {
|
||||
LEGACY_HOUSE_RULES,
|
||||
collectiveRevenueFloor,
|
||||
houseRules,
|
||||
industryProfile,
|
||||
mainlineProfile,
|
||||
trainProfile,
|
||||
} from '../engine/content.ts';
|
||||
@@ -276,6 +279,16 @@ export type Game = {
|
||||
* having taken a turn to cause it.
|
||||
*/
|
||||
announced: string | null;
|
||||
/**
|
||||
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. One per accepted intent, so a player can WATCH
|
||||
* what everyone else did rather than find the board already rearranged.
|
||||
*
|
||||
* Accumulated here beside `log`, `cues` and `announced` and drained the same way, because that is
|
||||
* how this file already hands things to whatever is displaying the game. Filled by `submit()`
|
||||
* alone, which is what makes it identical for solitaire and multiplayer and inert during replay —
|
||||
* see `sim/display-step.ts`.
|
||||
*/
|
||||
display: DisplayCollector;
|
||||
};
|
||||
|
||||
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
||||
@@ -311,7 +324,7 @@ export const SOLO_PLAYER = 'Solitaire';
|
||||
|
||||
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
|
||||
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null };
|
||||
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
|
||||
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
|
||||
// first, then let the clock take over.
|
||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||
@@ -330,7 +343,7 @@ export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||
*/
|
||||
export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game {
|
||||
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
|
||||
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null };
|
||||
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
|
||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||
/**
|
||||
* NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1).
|
||||
@@ -651,6 +664,14 @@ export type Menu = {
|
||||
makeUp: {
|
||||
trayId: string;
|
||||
title: string;
|
||||
/**
|
||||
* What the card still wants, after what is already coupled up — "1 boxcar/hopper + 1 caboose".
|
||||
*
|
||||
* The title says what the card CALLS FOR and never changes as cars go on, so a player had to
|
||||
* diff it against the consist drawn on the Division map, in the other column. Null when the
|
||||
* train is complete and only the send-it-out button is left.
|
||||
*/
|
||||
needs: string | null;
|
||||
cars: MakeUpAction[];
|
||||
pass: number | null;
|
||||
/**
|
||||
@@ -791,6 +812,7 @@ export function actionMenu(game: Game, seat: PlayerIndex = 0): Menu {
|
||||
? {
|
||||
trayId: filling,
|
||||
title: consistTitle(game, filling) ?? 'Making up the train',
|
||||
needs: consistNeeds(game, filling),
|
||||
cars: makeUpCars,
|
||||
pass,
|
||||
advice: makeUpAdvice(game, filling, makeUpCars),
|
||||
@@ -943,6 +965,40 @@ function consistTitle(game: Game, trayId: string): string | null {
|
||||
return trainCardTitle(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHAT THE TRAIN STILL WANTS — the card's demand minus what is already on it.
|
||||
*
|
||||
* The heading says "its card calls for 3 boxcar/hopper + 1 caboose" and goes on saying it whether
|
||||
* you have added none or three; the cars themselves are drawn on the Division map, in the other
|
||||
* column. So the one question a player actually has while clicking — what is left? — was the one
|
||||
* thing on screen that had to be worked out by eye, across two panels (Jesse, playtest 2026-09-16).
|
||||
*
|
||||
* BY CATEGORY, exactly as `acceptsCar` counts them, so this cannot promise a car the engine would
|
||||
* then refuse. Null when nothing is outstanding.
|
||||
*/
|
||||
function consistNeeds(game: Game, trayId: string): string | null {
|
||||
const tray = game.state.trays.get(trayId);
|
||||
if (!tray) return null;
|
||||
const p = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
if (!p) return null;
|
||||
|
||||
const cat = (t: string): 'coach' | 'caboose' | 'freight' =>
|
||||
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
|
||||
const have = (k: 'coach' | 'caboose' | 'freight'): number =>
|
||||
tray.consist.filter((c) => cat(c.type) === k).length;
|
||||
|
||||
const parts: string[] = [];
|
||||
const freight = p.consist.freight - have('freight');
|
||||
const coach = p.consist.coach - have('coach');
|
||||
const caboose = p.consist.caboose - have('caboose');
|
||||
if (freight > 0) {
|
||||
parts.push(`${freight} ${p.consist.freightTypes?.join('/') ?? 'freight'}${p.consist.emptiesOnly ? ' (empties only)' : ''}`);
|
||||
}
|
||||
if (coach > 0) parts.push(`${coach} coach${coach > 1 ? 'es' : ''}`);
|
||||
if (caboose > 0) parts.push(`${caboose} caboose`);
|
||||
return parts.length > 0 ? parts.join(' + ') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Making up Extra X22 “Pee-Dee”: its card calls for 1 caboose — Per-diem train…"
|
||||
*
|
||||
@@ -1105,11 +1161,69 @@ export function submit(game: Game, intent: Intent, as: PlayerIndex | null = null
|
||||
return false;
|
||||
}
|
||||
game.history.push(intent);
|
||||
/**
|
||||
* THE HIGH-WATER MARK FOR THIS STEP'S NARRATION (v0.8.0).
|
||||
*
|
||||
* Taken here rather than read from `session.ts`'s `sentLines`, which is per-seat and is MUTATED
|
||||
* by `linesSince()` as a side effect of building a push — so it cannot answer "what did this one
|
||||
* intent say?". `submit` brackets the whole thing, `record` and `drain` below are the only things
|
||||
* that append, and the slice after them is exactly this intent's narration including whatever
|
||||
* automatic phases it drained.
|
||||
*/
|
||||
const saidFrom = game.log.length;
|
||||
record(game, result.events, actor);
|
||||
drain(game);
|
||||
collectStep(game.display, game.state, actor, intent.type, game.log.slice(saidFrom));
|
||||
drainStepping(game);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `drain()`'s STEPPED TWIN — TODO #18, and the reason this is not just `drain(game)`.
|
||||
*
|
||||
* `pump()` runs every automatic phase between one click and the next and `drain()` records the whole
|
||||
* batch at once, so New Train, the Mainline and the shift change are never drawn at all: trains
|
||||
* cross the Division in a single jump. Stepping `advance()` one call at a time and collecting after
|
||||
* each is what gives those phases a visible beat, which is exactly what TODO Reference · #18 says is
|
||||
* needed — *"a minimum dwell time on its own therefore fixes nothing"*.
|
||||
*
|
||||
* IDENTICAL BEHAVIOUR TO `drain()`, deliberately. The same `advance()` calls in the same order
|
||||
* produce the same state; `record()` is called per phase rather than per batch, which is equivalent
|
||||
* because `cuesFor` is a pure per-event map with no cross-event state and `record`'s other outputs
|
||||
* (`scheduled`, `justDrawn`, `announced`) are last-wins in event order either way.
|
||||
*
|
||||
* A PHASE THAT DID NOTHING PRODUCES NO STEP. Jesse, 2026-09-09: *"if nothing happens during a phase
|
||||
* then we shouldn't lose time to it."* Narrating nothing is the test for that — an empty phase adds
|
||||
* no lines, so it is skipped rather than given a dwell to sit through.
|
||||
*
|
||||
* `drain()` itself is untouched, and must stay that way: `fromSave`, `fromMultiplayerSave` and
|
||||
* `undo` all use it, and the collector staying off those paths is what keeps a replay from
|
||||
* re-emitting a whole game as steps.
|
||||
*/
|
||||
function drainStepping(game: Game): void {
|
||||
for (let i = 0; i < 10_000; i++) {
|
||||
const from = game.log.length;
|
||||
const r = advance(game.state);
|
||||
record(game, r.events);
|
||||
/**
|
||||
* THE TEST IS THE EVENT LIST, NOT THE LOG — and getting that wrong drifted the board.
|
||||
*
|
||||
* `record()` deliberately drops `actorChanged` before narrating, so a phase whose only effect is
|
||||
* handing the turn to the next player grows no lines at all. Collecting only when the log grew
|
||||
* therefore skipped those, and the last step's frame was then a position behind the real one:
|
||||
* the animated board ended a turn out of step with the game (`actor: 2` where the game said 1).
|
||||
*
|
||||
* A step whose narration is empty still carries the board. It simply costs no time to show —
|
||||
* `dwellForStep` gives a silent step a dwell of zero — which is the same rule that collapses an
|
||||
* empty phase, arrived at from the other direction.
|
||||
*/
|
||||
if (r.events.length > 0) {
|
||||
collectStep(game.display, game.state, null, 'phase', game.log.slice(from));
|
||||
}
|
||||
if (r.needsInput || game.state.status === 'finished') return;
|
||||
}
|
||||
throw new Error('phase driver failed to settle — probable infinite loop');
|
||||
}
|
||||
|
||||
/**
|
||||
* Which cards in hand can be played RIGHT NOW, in hand order.
|
||||
*
|
||||
@@ -1156,6 +1270,81 @@ function uncapitalise(text: string): string {
|
||||
return /^[A-Z][a-z]/.test(text) ? text.charAt(0).toLowerCase() + text.slice(1) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Facility on one of a player's squares, or null — for naming the place a switching line is
|
||||
* about. Shared by the narrator and the filter below, so both agree on what counts as an industry.
|
||||
*/
|
||||
function facilityOn(game: Game, player: PlayerIndex, at: GridCoord): string | null {
|
||||
const card = areaOf(game.state, player).grid.get(`${at.row},${at.col}`);
|
||||
const f = card?.facility;
|
||||
// The name printed on the card, not the internal key: `industryProfile` is the one place that
|
||||
// knows "grocersWarehouse" reads as "Grocer's Warehouse".
|
||||
if (f) return f.subtype === 'office' ? 'the Office' : `the ${industryProfile(f.subtype).name}`;
|
||||
/**
|
||||
* A SMALL YARD IS A PLACE TOO, though it is an enhancement on a plain card rather than a Facility.
|
||||
*
|
||||
* It is the one square in a district a crew goes to ON PURPOSE without working an industry — the
|
||||
* whole point of the trip is to arrive there and re-make the train — so "leaving Train 10 at
|
||||
* (-1,1)" was the one line most in need of a name. Only this enhancement: the others change what a
|
||||
* square DOES without being somewhere a player aims a crew at.
|
||||
*/
|
||||
return card?.enhancements.includes('smallYard') ? 'the Small Yard' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HOW MUCH SWITCHING REACHES THE HISTORY PANEL (Jesse, 2026-09-17).
|
||||
*
|
||||
* All of it did. A six-Move turn wrote a line per move — "Moved Train 10 (0,3) → (-1,-3) — 4 of 6
|
||||
* Moves left" — plus one per mandatory coupling, so two players shunting filled the panel with
|
||||
* coordinates and pushed everything else off the top. His ruling, asked as a question from the
|
||||
* table: a line saying somebody switched, the cars they set out at or picked up from an INDUSTRY,
|
||||
* and the Small Yard sort. Not every move, and not every coupling.
|
||||
*
|
||||
* WHAT STAYS, and why each one earns its line: `localOpsOptionChosen` already says who is switching
|
||||
* and is left alone; work at an industry is the point of switching and changes what can be loaded
|
||||
* next; and `consistSorted` spends a Move and changes what the train can do. A plain move along
|
||||
* one's own track changes nothing anybody needs to read back.
|
||||
*
|
||||
* THE LINE IS STILL WRITTEN, MARKED `trace`, AND THAT IS NOT A DETAIL. Dropping these events on the
|
||||
* floor was the first attempt and the suite caught it: `dwellForStep` gives a step NO dwell when it
|
||||
* produced no narration, so a switching move with no line became a silent step and the board stopped
|
||||
* replaying switching altogether — it would have snapped through the very thing v0.8.0 was built to
|
||||
* let the table watch. The line still rides with its display step and still captions the board as
|
||||
* the move goes up; only the history panel skips it.
|
||||
*
|
||||
* THE REPLAY VIEWER IS UNAFFECTED for the same reason, and it renders through `narrate` directly.
|
||||
*/
|
||||
function inHistory(game: Game, e: GameEvent): boolean {
|
||||
const industry = (player: PlayerIndex, ...coords: GridCoord[]): boolean =>
|
||||
coords.some((c) => facilityOn(game, player, c) !== null);
|
||||
switch (e.type) {
|
||||
/**
|
||||
* THE FIRST MOVE OF A TURN IS KEPT (Jesse, 2026-09-17: "also keep the first and last move").
|
||||
*
|
||||
* It says a crew set off and from where, which is the half of "somebody switched" that the
|
||||
* opener does not carry. The LAST move cannot be kept the same way — nothing knows a move was
|
||||
* the last until the turn is over, and by then the line has already been written and streamed to
|
||||
* every client (`server/session.ts` § linesSince), so it cannot be revised. `switchingEnded`
|
||||
* carries it instead, as the line that closes the turn.
|
||||
*
|
||||
* Recognised by the MOVE COUNT rather than by tracking state: the first move of a turn is the
|
||||
* one that leaves `movesAllowed - 1` behind it, which the event now carries so this holds on a
|
||||
* five-Move night Stage too.
|
||||
*/
|
||||
case 'trayMoved':
|
||||
return e.movesRemaining === e.movesAllowed - 1;
|
||||
// Coupling is mandatory when a crew runs over cars (§A.4), so most of these happen to a player
|
||||
// rather than being chosen. The ones worth reading are where cars left or joined an industry —
|
||||
// `from` names the cards the cars were actually lifted off, which is where they had been spotted.
|
||||
case 'carsCoupled':
|
||||
return industry(e.player, e.at, ...e.from);
|
||||
case 'carsDropped':
|
||||
return industry(e.player, e.at);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
|
||||
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
|
||||
for (const e of events) {
|
||||
@@ -1164,6 +1353,14 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
const n = narrate(e, {
|
||||
cardName: (id) => cardName(game.state, id),
|
||||
trainName: (id) => trainName(game.state, id),
|
||||
facilityAt: (player, at) => facilityOn(game, player, at),
|
||||
// Whose district a train reached is not the actor — the Mainline Phase has none — so the
|
||||
// narration resolves the name itself rather than being prefixed with one by the code below.
|
||||
// NO NUMBER IN THE FALLBACK. This is a PLAYER index, and a player is not a seat — seats rotate
|
||||
// under Employee Rotation, which is why `seatOf` exists — so "Seat 3" here would be a wrong
|
||||
// number dressed as a right one, and `session.test.ts` rightly refuses any raw index shown to
|
||||
// a person. Every caller passes real names; an unnamed player is anonymous rather than mislabelled.
|
||||
playerName: (p) => game.state.players[p]?.name ?? 'another player',
|
||||
});
|
||||
/**
|
||||
* A BLIND DRAW IS PUBLIC; WHICH CARD CAME UP IS NOT (Gitea#20 step 1).
|
||||
@@ -1185,9 +1382,24 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
const said = blindDraw ? 'Drew a card from the Home Office deck' : n.text;
|
||||
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
|
||||
// one seat. Only events the player caused are attributed; the Division running itself is not.
|
||||
/**
|
||||
* A RULING IS MADE AS SUPERINTENDENT, NOT AS YOURSELF (playtest, 2026-09-15: "maybe it could say
|
||||
* 'Superintendent Player Tom', so it's clear they got the move because they're Superintendent").
|
||||
* These three are the only moves a player makes out of turn, by holding the office: §8.1's
|
||||
* clearance, §11's Yard Office offer and §Q's Red Flag prompt. `clearanceGiven` carries no
|
||||
* player at all — the office made it, whoever holds it — so the actor is what names it.
|
||||
*/
|
||||
const RULINGS = ['clearanceGiven', 'yardOfficeRuled', 'redFlagRuled'];
|
||||
const ruling = RULINGS.includes(e.type) && who !== null;
|
||||
const mine = who !== null && 'player' in e;
|
||||
const text = mine ? `Player ${who} ${uncapitalise(said)}` : said;
|
||||
game.log.push({ text, tone: mine ? 'act' : n.tone });
|
||||
const text = ruling
|
||||
? `Superintendent Player ${who} ${uncapitalise(said)}`
|
||||
: mine
|
||||
? `Player ${who} ${uncapitalise(said)}`
|
||||
: said;
|
||||
// `trace` is a tone the history panel does not draw — see `inHistory`. The line exists so the
|
||||
// step that caused it has narration to caption the board with, and a dwell to be watched for.
|
||||
game.log.push({ text, tone: inHistory(game, e) ? (mine || ruling ? 'act' : n.tone) : 'trace' });
|
||||
|
||||
}
|
||||
game.cues.push(...cuesFor(events));
|
||||
@@ -1204,6 +1416,18 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
`Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` +
|
||||
`${e.side === 'east' ? 'Eastern' : 'Western'} Division Point. All players get 1 Revenue.`;
|
||||
}
|
||||
/**
|
||||
* THE FEDORA MOVING IS ANNOUNCED, NOT JUST LOGGED (playtest, 2026-09-16).
|
||||
*
|
||||
* It is the one thing in the game that changes hands on the clock rather than because somebody
|
||||
* did something, so nobody is watching for it — and it decides who rules on clearances and who
|
||||
* every round starts with. A line in the history is where you find it afterwards; this is what
|
||||
* tells the table as it happens, the same treatment a completed run already gets.
|
||||
*/
|
||||
if (e.type === 'superintendentChanged') {
|
||||
const name = game.state.players[e.player]?.name ?? 'the next player';
|
||||
game.announced = `${name} is now the Superintendent — the Fedora passed at the end of Stage ${e.stage}.`;
|
||||
}
|
||||
}
|
||||
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
||||
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
||||
|
||||
@@ -27,6 +27,11 @@ h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em}
|
||||
a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
|
||||
.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
|
||||
.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
|
||||
/* Not a fourth door: reading the guide is not a way to play, and giving it equal weight in the
|
||||
grid would say it is. A line under the doors, where somebody who does not know what to click
|
||||
will already be looking. */
|
||||
.newhere{margin:16px 2px 0;color:var(--dim);font-size:13px;line-height:1.55}
|
||||
.newhere a{color:#9fb6d8}
|
||||
.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
|
||||
.door.disabled .go{color:var(--dim)}
|
||||
a.door.disabled{pointer-events:none}
|
||||
@@ -96,6 +101,11 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="newhere">New to Station Master?
|
||||
<a href="./quickstart.md">Read the Quickstart guide</a> — what the game is, how you win,
|
||||
how a Stage runs, what is on the screen, and a first twenty minutes. About twenty minutes to
|
||||
read, and it will save you an hour of guessing.</p>
|
||||
|
||||
<div class="rule"></div>
|
||||
|
||||
<footer>
|
||||
|
||||
+2
-1
@@ -107,7 +107,8 @@ function explain(code: unknown, fallback: string): string {
|
||||
return messages[key] ?? (key !== '' ? key : fallback);
|
||||
}
|
||||
|
||||
async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record<string, unknown> }> {
|
||||
/** Exported for `main.ts`'s seat-recovery path (Gitea#33), so there is one JSON POST on this page. */
|
||||
export async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record<string, unknown> }> {
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
+800
-92
File diff suppressed because it is too large
Load Diff
+96
-11
@@ -52,25 +52,61 @@ export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
|
||||
* Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it
|
||||
* is out of reach, and choosing where to discard is choosing what to put there.
|
||||
*/
|
||||
export function pilesHtml(f: Frame): string {
|
||||
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => {
|
||||
export function pilesHtml(f: Frame, lit: readonly string[] = []): string {
|
||||
const pile = (
|
||||
key: string,
|
||||
label: string,
|
||||
top: string,
|
||||
depth: number,
|
||||
why: string,
|
||||
extra = '',
|
||||
slot = -1,
|
||||
faceDown = false,
|
||||
): string => {
|
||||
const tip = [why, extra].filter(Boolean).join(' · ');
|
||||
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
|
||||
// page binds a click to it, and only while a card is waiting to be discarded — so the replay
|
||||
// viewer draws exactly the same markup and nothing there is clickable.
|
||||
const target = slot >= 0 ? ` data-dept="${slot}"` : '';
|
||||
// `lit` marks the pile the move being watched just touched — see `changedPiles`.
|
||||
const cls = `handcard${faceDown ? ' facedown' : ''}${lit.includes(key) ? ' pilelit' : ''}`;
|
||||
return (
|
||||
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
||||
`<div class="${cls}"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
||||
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
|
||||
`<b>${esc(top)}</b></div>`
|
||||
);
|
||||
};
|
||||
return (
|
||||
/**
|
||||
* THE HOME OFFICE DECK, which the screen had never drawn.
|
||||
*
|
||||
* `f.deck` has carried the face-down count since the Frame existed and nothing read it — the
|
||||
* exact shape of display gap `test/display-gaps.test.ts` was written to sweep for, surviving in
|
||||
* the panel that draws every OTHER pile. Asked for by Jesse 2026-09-10 for a second reason: a
|
||||
* player drawing from it is the commonest move nobody can see, so it needs somewhere to flash.
|
||||
*
|
||||
* FIRST, because that is the order a card travels: out of here, into a hand, then onto a
|
||||
* Department or the Salvage Yard. Face down, so the card slot says so rather than naming a card
|
||||
* — the whole point of this pile is that nobody knows what is on top.
|
||||
*/
|
||||
pile(
|
||||
'home',
|
||||
'Home Office',
|
||||
'face down',
|
||||
f.deck,
|
||||
'The draw deck. Face down — nobody sees what is on top, and a card drawn from here is private ' +
|
||||
'to whoever drew it. When it runs out, the Salvage Yard and the Departments are swept back ' +
|
||||
'into it.',
|
||||
'',
|
||||
-1,
|
||||
true,
|
||||
) +
|
||||
f.departments
|
||||
.map((d, i) => {
|
||||
const depth = f.departmentDepth[i] ?? 0;
|
||||
const under = depth - 1;
|
||||
return pile(
|
||||
`dept${i}`,
|
||||
`Dept ${i + 1}`,
|
||||
d,
|
||||
depth,
|
||||
@@ -81,6 +117,7 @@ export function pilesHtml(f: Frame): string {
|
||||
})
|
||||
.join('') +
|
||||
pile(
|
||||
'salvage',
|
||||
'Salvage',
|
||||
f.salvage.top,
|
||||
f.salvage.depth,
|
||||
@@ -180,7 +217,7 @@ export function dayEndHtml(f: Frame): string {
|
||||
ahead +
|
||||
standingsHtml(f) +
|
||||
targetHtml(f) +
|
||||
collisionsHtml(f)
|
||||
collisionsHtml(f, ended)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,13 +275,28 @@ function targetHtml(f: Frame): string {
|
||||
* its config and enforces neither, so reporting a collision budget there would put a rule on
|
||||
* screen that this game does not have.
|
||||
*/
|
||||
function collisionsHtml(f: Frame): string {
|
||||
function collisionsHtml(f: Frame, endedDay?: number): string {
|
||||
const scoredOnCollisions =
|
||||
(f.mode === 'competitive' || f.mode === 'coop') &&
|
||||
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
||||
return scoredOnCollisions
|
||||
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
||||
: '';
|
||||
if (!scoredOnCollisions) return '';
|
||||
/**
|
||||
* "TODAY" IS THE WRONG WORD IN A DAY-END DIALOG, and it read as a contradiction.
|
||||
*
|
||||
* That dialog is drawn from the frame whose `day` went UP — which is the same frame in which
|
||||
* `collisionsToday` was reset — so it reported 0 however many there had been. Jesse, 2026-09-09,
|
||||
* at the end of a Day 1 with two collisions in it: "it shows a total of two collisions, but zero
|
||||
* today ... that does seem to be a contradiction."
|
||||
*
|
||||
* So when the caller knows which Day just ended it says so by name, and reads the count captured at
|
||||
* the rollover. The end-of-game results screen passes nothing and keeps "today", where the Day has
|
||||
* not turned over and the word is accurate.
|
||||
*/
|
||||
const [count, when] =
|
||||
endedDay === undefined
|
||||
? [f.collisionsToday, 'today']
|
||||
: [f.collisionsPrevDay, `on Day ${endedDay}`];
|
||||
return `<p>Collisions: <b>${count}</b> ${when}, <b>${f.collisionsTotal}</b> in all.</p>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -661,6 +713,30 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
||||
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
|
||||
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
|
||||
.handcard.unplayable{color:#7d8794;border-color:#39424e}
|
||||
/* THE HOME OFFICE DECK. Face down, so its card slot names no card — it says so instead, in the
|
||||
dimmed voice the rest of the panel uses for "nothing to read here". */
|
||||
.handcard.facedown > b{color:#6f7885;font-style:italic;font-weight:400}
|
||||
/* THE PILE A WATCHED MOVE JUST TOUCHED (v0.8.1).
|
||||
A STATE, NOT A FLASH, and that is the whole point. The .tt-slot.fresh rule above animates for a fixed
|
||||
1.5s, which is right for a die roll nobody is waiting on — but a step can hold the screen for
|
||||
seven seconds at 10x, so a fixed animation would be over long before the pause it belongs to and
|
||||
the player would be back to staring at an unchanged board. The flash-in marks the moment; the lit
|
||||
border and background stay for exactly as long as the step is up, because the class is on the
|
||||
element only while that step is the one being shown. */
|
||||
.handcard.pilelit{border-color:#8fd6a0;background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.55);
|
||||
animation:pilepulse 1.15s ease-in-out infinite}
|
||||
/* A PULSE FOR THE WHOLE DWELL, not one flash at the start. Measured: at 10x a pile stays lit for
|
||||
just under seven seconds, so the highlight was never brief — but a single 0.45s flash-in and a
|
||||
dark green fill were easy to miss entirely while watching the district. Jesse: "caught one flash
|
||||
deck light up for just a very brief moment, but couldn't see that with what bot was doing in
|
||||
office area and history and catch up area all at same time." Something still moving keeps drawing
|
||||
the eye for as long as the move is up; a state that settles stops asking to be looked at. */
|
||||
@keyframes pilepulse{0%,100%{background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.45)}
|
||||
50%{background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0,0 0 22px rgba(143,214,160,.85)}}
|
||||
/* Motion is the point here, so the reduced-motion fallback has to be loud in a different way rather
|
||||
than simply not moving: a solid ring and a brighter fill, held. */
|
||||
@media(prefers-reduced-motion:reduce){
|
||||
.handcard.pilelit{animation:none;background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0}}
|
||||
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
|
||||
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
|
||||
/* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that
|
||||
@@ -701,9 +777,18 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
||||
/* And the piles that are NOT targets step back while a discard is being aimed, so the three that
|
||||
are stand out from the Salvage Yard beside them. */
|
||||
.cardrow.aiming .handcard:not(.target){opacity:.4}
|
||||
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;
|
||||
outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)}
|
||||
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)}
|
||||
/* A CAR YOU MAY ADD IS AN ACTION, SO IT WEARS THE ACTION COLOUR — Jesse, playtest 2026-09-16: the
|
||||
highlight was "just a small bold and basically the same color as everything else", which is the
|
||||
whole difficulty with making the yard chip the button. #c8912f is the border colour that the
|
||||
action buttons themselves use, so a clickable car looks like every other thing inviting a click,
|
||||
rather than like a number that happens to be outlined.
|
||||
|
||||
THE LOADED/EMPTY TEXT COLOURS ARE LEFT ALONE. Green and blue-grey are what say which of the two
|
||||
numbers is which; the amber answers "may I click this", which is a different question, and
|
||||
painting over the first to answer the second would cost real information. */
|
||||
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;font-weight:700;
|
||||
outline:2px solid #c8912f;background:rgba(200,145,47,.20);box-shadow:0 0 0 2px rgba(200,145,47,.16)}
|
||||
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(200,145,47,.38)}
|
||||
/* Twelve Stages across, so a Day is one glance. The current Stage is lit, Stages already gone are
|
||||
dimmed, and a slot the die has just filled flashes once. */
|
||||
.tt{display:flex;gap:3px;flex-wrap:wrap}
|
||||
|
||||
+53
-1
@@ -114,6 +114,18 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||
.lb-seat:last-child{border-bottom:none}
|
||||
.lb-seat .who{flex:1}
|
||||
#presence{color:#e0b060;font-size:12px;padding:0 14px;empty-cells:hide}
|
||||
/* WHAT YOU ARE WATCHING — v0.8.0, TODO #13/#15. An IN-FLOW row rather than a floating banner like
|
||||
#phasenote and #announce: those announce a moment and fade, this one stands for as long as the
|
||||
board is behind and has a button you have to be able to hit. Amber on the button because amber
|
||||
already means clickable everywhere else on this page; the row itself stays quiet so it does not
|
||||
compete with the three banners it sits under. */
|
||||
#watching{display:flex;align-items:center;gap:10px;padding:4px 14px;font-size:12px;color:#9aa0b4}
|
||||
#watching[hidden]{display:none}
|
||||
#watching-what{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0;
|
||||
background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap}
|
||||
#watching-skip,#watching-pause{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
|
||||
#watching-who{color:#c9cee0;font-weight:700}
|
||||
#presence:empty{display:none}
|
||||
/* division strip */
|
||||
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
|
||||
@@ -172,6 +184,14 @@ button.cardact.discard{color:#d6b48a}
|
||||
.scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px;
|
||||
background:rgba(40,140,60,.22);border:1px solid #2f6b47;color:#bfe8cd;font-weight:600}
|
||||
.makeup-note{font-size:11px;margin:0 0 5px}
|
||||
/* WHAT THE TRAIN STILL WANTS. Deliberately NOT amber: amber means "you can click this" everywhere
|
||||
else on this page, and this line is the REASON for clicking rather than a thing to click — the
|
||||
cars in the Division Yard are. Brighter than the note beneath it, because it answers the question
|
||||
the player actually has while looking at it. `done` goes green like `.scheduled`: a complete
|
||||
consist is good news, not an instruction. */
|
||||
.makeup-needs{font-size:12px;margin:0 0 5px;color:#e6e9ee}
|
||||
.makeup-needs b{color:#f2e6cf}
|
||||
.makeup-needs.done{color:#bfe8cd}
|
||||
/* WHICH TRAIN AM I SWITCHING. A row of crews rather than a stacked list — they are alternatives,
|
||||
and the chosen one is the crew whose squares the board is drawing, so it wears the same violet
|
||||
"you are here" the rest of the page uses. */
|
||||
@@ -193,6 +213,9 @@ button.act.crew.on{border-color:var(--now);background:rgba(185,140,240,.18);colo
|
||||
/* actions */
|
||||
#actions{max-height:none}
|
||||
.grp{margin-bottom:6px}
|
||||
/* What a role is for, under its heading — a sentence a player reads once and stops re-asking. Sized
|
||||
below the buttons so it explains without competing with the thing being chosen. */
|
||||
.grp .scope{margin:2px 0 4px;font-size:11.5px;line-height:1.35}
|
||||
/* THESE ARE THE THINGS YOU CAN DO. An action carried the same grey border as every other panel
|
||||
on the page, so the one region that is clickable did not look it. Amber border and a lit face,
|
||||
used nowhere else, so "this is a move" is answered before the label is read. */
|
||||
@@ -861,6 +884,14 @@ ul.blocked li{padding:2px 0}
|
||||
<span class="zoom" title="Zoom the Division map and your Office Area. Both already scroll — this only changes their size.">
|
||||
<button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
|
||||
</span>
|
||||
<!-- HOW FAST OTHER PLAYERS' TURNS PLAY BACK — v0.8.0.3, TODO #13.
|
||||
A CONTROL RATHER THAN ONLY A URL PARAMETER. `?pace=` shipped first and is unreachable through
|
||||
the front door: `index.html`'s two doors are `play.html?lobby` and `play.html?solitaire`, so
|
||||
arriving from the splash REPLACES the query string and any pace with it. Jesse played a whole
|
||||
game believing he was at 7x when he was at 1x. -->
|
||||
<span class="zoom" title="How long another player's or a bot's move is held on screen before the next one. Your own moves are never delayed — only theirs. Starts at 1×, which holds a switching move for one second; the slowest setting, 20×, holds it for twenty. Off draws every move at once.">
|
||||
<button id="paceslower" aria-label="Slower playback">−</button><span id="pacelabel">1×</span><button id="pacefaster" aria-label="Faster playback">+</button>
|
||||
</span>
|
||||
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
||||
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
||||
<button id="newgame" title="Set up a fresh game — the seed, the table, the opening hand and what the three economies pay. Opens the same screen a new solitaire game starts from, with your current rules filled in; your game in progress is kept until you press Deal, and Continue puts it straight back.">New game</button>
|
||||
@@ -890,14 +921,35 @@ ul.blocked li{padding:2px 0}
|
||||
collapsed whenever everyone connected is still connected — a `LocalSession` never fills it. -->
|
||||
<div id="presence"></div>
|
||||
|
||||
<!-- WHAT YOU ARE WATCHING, and how far behind the board is — v0.8.0, TODO #13/#15.
|
||||
One row rather than three additions: the countdown, the caption naming the action being shown,
|
||||
and Skip. Empty and collapsed whenever the board is level with the game, which in solitaire is
|
||||
almost always. -->
|
||||
<div id="watching" hidden>
|
||||
<!-- SKIP FIRST, on the left. It sat on the far right and a player's eye is on the countdown, not at
|
||||
the other end of the row — Jesse, 2026-09-09: "the skip button should be on the far left, in
|
||||
front of where it says [the count], so it's always close to where people are looking." -->
|
||||
<button id="watching-skip" class="ghost" type="button" title="Stop animating and jump the board to where the game actually is. Nothing is lost — every line is already in the History panel.">Skip</button>
|
||||
<!-- PAUSE BESIDE SKIP, not instead of it: they are opposite answers to "that went past too fast".
|
||||
Skip gives up the animation to reach the game; Pause holds the board on the step being shown
|
||||
for as long as you want to look at it, and gives the step back the dwell it still had. -->
|
||||
<button id="watching-pause" class="ghost" type="button" title="Hold the board on the move being shown. Nothing is lost and nothing is hurried — press again to carry on from the same step.">Pause</button>
|
||||
<span id="watching-behind" class="wbehind"></span>
|
||||
<span id="watching-what"></span>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div>
|
||||
<section><h2>The Division — west to east</h2><div id="division"></div>
|
||||
<p class="ng-note" id="seating-chain"></p></section>
|
||||
<section id="district">
|
||||
<h2>Your Office Area
|
||||
<h2><span id="districtwho">Your Office Area</span>
|
||||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
|
||||
<!-- LOOK AT ANOTHER PLAYER'S OFFICE AREA. Filled by `renderDistrict` with one button per
|
||||
opponent, and collapsed at a table of one. The look is read-only and lasts a single
|
||||
render on purpose — see `peekPlayer` in `main.ts`. -->
|
||||
<span id="districtpeek" class="seg" role="group" aria-label="Look at another player's Office Area"></span>
|
||||
</h2>
|
||||
<div id="districtsummary" class="dim"></div>
|
||||
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
||||
|
||||
+6
-1
@@ -56,7 +56,12 @@ type Step = {
|
||||
function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
|
||||
const s = createGame({ id: `replay-${save.seed}`, seed: save.seed, config: SOLO_CONFIG, playerNames: ['player'] });
|
||||
const steps: Step[] = [];
|
||||
const ctx = { cardName: (id: string) => cardName(s, id), trainName: (id: string) => trainName(s, id) };
|
||||
const ctx = {
|
||||
cardName: (id: string) => cardName(s, id),
|
||||
trainName: (id: string) => trainName(s, id),
|
||||
// A player index is not a seat index, so the fallback names no number at all — see `game.ts`.
|
||||
playerName: (p: number) => s.players[p]?.name ?? 'another player',
|
||||
};
|
||||
const push = (events: ReturnType<typeof pump>): void => {
|
||||
const lines = events
|
||||
.filter((e) => e.type !== 'actorChanged')
|
||||
|
||||
+70
-1
@@ -16,7 +16,10 @@
|
||||
*/
|
||||
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
import type { Frame, PublicFrame } from '../sim/view.ts';
|
||||
import { publicSnapshot } from '../sim/view.ts';
|
||||
import { takeSteps } from '../sim/display-step.ts';
|
||||
import type { DisplayStep } from '../sim/display-step.ts';
|
||||
import type { PlayerIndex } from '../engine/state.ts';
|
||||
import { applyDelta } from '../sim/frame-delta.ts';
|
||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||
@@ -105,6 +108,28 @@ export type Session = {
|
||||
* starts — which is exactly when "is everyone here?" is the question.
|
||||
*/
|
||||
presence(): { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
||||
/**
|
||||
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. What everyone else did, in order, so it can be
|
||||
* WATCHED rather than discovered.
|
||||
*
|
||||
* On the interface rather than on `LocalSession`, which is the whole point: solitaire drains its
|
||||
* own collector and a remote session reads the same steps off `Push.steps`, so the page animates
|
||||
* one queue and cannot tell which it has. That is what makes TODO #18 (solitaire's phases flying
|
||||
* past) and TODO #13 (multiplayer's invisible turns) the same code path.
|
||||
*
|
||||
* NOT `steps()` — `LocalSession.steps()` already exists and counts submitted intents for the Undo
|
||||
* button. Different thing entirely, hence the longer name.
|
||||
*/
|
||||
takeDisplaySteps(): DisplayStep[];
|
||||
/**
|
||||
* A public frame to start the queue from, once — draining, and non-null only when the queue must
|
||||
* be RESET rather than advanced.
|
||||
*
|
||||
* Steps carry deltas against a chain, so a client with no baseline cannot merge the next one. That
|
||||
* happens on a first connect, on a reconnect, and locally after an undo or a restore — all of
|
||||
* which rebuild from scratch. A reset means "throw away what is queued and draw this".
|
||||
*/
|
||||
takeDisplayReset(): PublicFrame | null;
|
||||
/**
|
||||
* Stop listening, for good.
|
||||
*
|
||||
@@ -145,6 +170,12 @@ export type LocalSession = Session & {
|
||||
*/
|
||||
export function createLocalSession(seed: number, options?: NewGameOptions): LocalSession {
|
||||
let game: Game = options ? newGame(seed, configWith(options)) : newGame(seed);
|
||||
/**
|
||||
* The baseline the step queue starts from. Set here, and again whenever the game is REPLACED —
|
||||
* `undo` and `restore` rebuild by replaying history, which (by design) collects no steps, so the
|
||||
* queue has to be told to start over rather than left holding a chain that no longer continues.
|
||||
*/
|
||||
let pendingReset: PublicFrame | null = publicSnapshot(game.state);
|
||||
const listeners = new Set<() => void>();
|
||||
const changed = (): void => {
|
||||
for (const fn of [...listeners]) fn();
|
||||
@@ -186,6 +217,14 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
||||
},
|
||||
justDrawn: () => game.justDrawn,
|
||||
presence: () => [],
|
||||
// Solitaire's own steps, from the same collector `submit()` fills for every seat of a
|
||||
// multiplayer game. No separate code path — see `sim/display-step.ts`.
|
||||
takeDisplaySteps: () => takeSteps(game.display),
|
||||
takeDisplayReset: () => {
|
||||
const reset = pendingReset;
|
||||
pendingReset = null;
|
||||
return reset;
|
||||
},
|
||||
|
||||
seed: () => game.seed,
|
||||
save: () => toSave(game),
|
||||
@@ -199,6 +238,8 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
||||
back.scheduled = null;
|
||||
back.justDrawn = null;
|
||||
back.announced = null;
|
||||
// The rebuilt game has an empty collector and a chain that starts over, so the queue must too.
|
||||
pendingReset = publicSnapshot(back.state);
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
@@ -207,6 +248,7 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
||||
// Restoring replays the whole history and re-records every draw; none of it is news.
|
||||
game.justDrawn = null;
|
||||
game.announced = null;
|
||||
pendingReset = publicSnapshot(game.state);
|
||||
changed();
|
||||
},
|
||||
};
|
||||
@@ -220,6 +262,10 @@ type Push = {
|
||||
lines: { text: string; tone: string }[];
|
||||
/** One entry for a change; every other seat at once on the connect push. */
|
||||
presence?: { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
||||
/** Ordered presentation steps — v0.8.0, identical in every seat's push because they are public. */
|
||||
steps?: DisplayStep[];
|
||||
/** The baseline for the step queue, sent on a connect only. */
|
||||
publicReset?: PublicFrame;
|
||||
/**
|
||||
* THE FOUR TRANSIENT SIGNALS, added 2026-08-23.
|
||||
*
|
||||
@@ -270,6 +316,8 @@ export function createRemoteSession(
|
||||
let menu: Menu | null = null;
|
||||
let lines: { text: string; tone: string }[] = [];
|
||||
const presence = new Map<PlayerIndex, { connected: boolean; seen: boolean }>();
|
||||
let displaySteps: DisplayStep[] = [];
|
||||
let displayReset: PublicFrame | null = null;
|
||||
let cues: string[] = [];
|
||||
let scheduled: number | null = null;
|
||||
let announcement: string | null = null;
|
||||
@@ -324,6 +372,21 @@ export function createRemoteSession(
|
||||
if (push.announcement !== undefined && push.announcement !== null) announcement = push.announcement;
|
||||
// Persists until another draw replaces it, matching the local session's own `justDrawn`.
|
||||
if (push.justDrawn !== undefined) justDrawnCard = push.justDrawn;
|
||||
/**
|
||||
* A RESET DISCARDS WHAT WAS QUEUED, rather than arriving alongside it.
|
||||
*
|
||||
* `publicReset` comes on a connect, which is also a RECONNECT — and a reconnecting client's
|
||||
* queue holds steps whose deltas chain off a baseline the server has since moved past. Merging
|
||||
* them onto the new baseline would draw a board that never existed. The history panel is what
|
||||
* carries what was missed; the animation does not replay it (§ v0.8.0).
|
||||
*/
|
||||
if (push.publicReset) {
|
||||
displayReset = push.publicReset;
|
||||
displaySteps = [];
|
||||
}
|
||||
// Accumulated, like cues: two pushes can land between two renders and every step is one thing
|
||||
// that happened.
|
||||
if (push.steps) displaySteps = [...displaySteps, ...push.steps];
|
||||
changed();
|
||||
};
|
||||
|
||||
@@ -376,6 +439,12 @@ export function createRemoteSession(
|
||||
},
|
||||
justDrawn: () => justDrawnCard,
|
||||
presence: () => [...presence].map(([seat, p]) => ({ seat, connected: p.connected, seen: p.seen })),
|
||||
takeDisplaySteps: () => displaySteps.splice(0, displaySteps.length),
|
||||
takeDisplayReset: () => {
|
||||
const reset = displayReset;
|
||||
displayReset = null;
|
||||
return reset;
|
||||
},
|
||||
close() {
|
||||
// `reportedGone` first: closing the stream fires `onerror`, and this is a deliberate exit, not
|
||||
// a game that vanished — `onGone` must not be called and land the page in "that game is no
|
||||
|
||||
@@ -40,6 +40,29 @@ if (heroImage && lightbox) {
|
||||
* other way costs a click and a lobby that says it cannot reach a server — which is legible, and
|
||||
* recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it.
|
||||
*/
|
||||
/**
|
||||
* CARRY `?pace=` THROUGH THE DOORS — v0.8.0.3.
|
||||
*
|
||||
* Both doors are static hrefs that REPLACE the query string (`play.html?lobby`,
|
||||
* `play.html?solitaire`), so a `pace` typed on this page was silently dropped on the way in: Jesse
|
||||
* played a whole game believing he was at 7× when the play page had only ever seen `?lobby`. The
|
||||
* durable answer is the speed control on the play screen, which persists per viewer — this keeps the
|
||||
* URL lever honest for handing two playtesters different speeds, which is the only thing it was ever
|
||||
* for.
|
||||
*/
|
||||
try {
|
||||
const pace = new URLSearchParams(location.search).get('pace');
|
||||
if (pace !== null) {
|
||||
for (const door of Array.from(document.querySelectorAll('a.door'))) {
|
||||
const href = door.getAttribute('href');
|
||||
// Only the doors into the game, and only ones that have not been disabled above.
|
||||
if (href?.startsWith('./play.html?')) door.setAttribute('href', `${href}&pace=${encodeURIComponent(pace)}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A door that keeps its own href is the status quo, not a broken page.
|
||||
}
|
||||
|
||||
const mpDoor = document.getElementById('door-multiplayer');
|
||||
if (mpDoor) {
|
||||
const close = (): void => {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* THE ANIMATION QUEUE — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 4-6.
|
||||
*
|
||||
* Holds the public board the screen is currently showing, which is not always the board the game is
|
||||
* actually on. Steps arrive faster than a person can follow — a bot's whole switching turn lands in
|
||||
* ONE push, because `driveBots()` plays it out before the push goes back — so this is what turns a
|
||||
* burst into something watchable. TODO #13.
|
||||
*
|
||||
* TRANSPORT-AGNOSTIC ON PURPOSE. It takes `DisplayStep`s and does not care whether they came from
|
||||
* the engine in this tab or off an SSE stream, which is what lets solitaire (#18: phases that are
|
||||
* never drawn) and multiplayer (#13: turns you never see) run one implementation. Nothing here
|
||||
* imports the DOM either, so it is testable without one.
|
||||
*
|
||||
* NO TIMERS OF ITS OWN. The caller drives it with `advance(now)` from whatever loop it already has
|
||||
* — a `requestAnimationFrame`, a test's fake clock. A queue that owned a `setInterval` would need
|
||||
* starting, stopping and cleaning up on every game replacement, and would be untestable without
|
||||
* faking timers.
|
||||
*/
|
||||
|
||||
import type { PublicFrame } from '../sim/view.ts';
|
||||
import type { DisplayStep } from '../sim/display-step.ts';
|
||||
import { applyPublicDelta, changedDivisionCards, changedPiles } from '../sim/public-delta.ts';
|
||||
import type { PileKey } from '../sim/public-delta.ts';
|
||||
import { dwellForStep } from '../sim/pacing.ts';
|
||||
|
||||
export type StepQueue = {
|
||||
/** Throw away what is queued and show this board — a first connect, a reconnect, an undo. */
|
||||
reset(frame: PublicFrame): void;
|
||||
/** Queue steps to be shown in order. */
|
||||
push(steps: readonly DisplayStep[]): void;
|
||||
/**
|
||||
* Show as much as `now` allows. Returns true if the displayed board changed, so a caller can skip
|
||||
* a redraw when nothing did.
|
||||
*/
|
||||
advance(now: number): boolean;
|
||||
/** Show everything immediately. Returns true if anything was skipped. */
|
||||
skip(): boolean;
|
||||
/** The board to draw, or null before any reset has arrived. */
|
||||
current(): PublicFrame | null;
|
||||
/**
|
||||
* How many queued steps the player is still going to WATCH — the number the "N behind" counter
|
||||
* shows. Not the queue length: see `watchableCount` in `sim/pacing.ts`.
|
||||
*/
|
||||
behind(): number;
|
||||
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
|
||||
showing(): DisplayStep | null;
|
||||
/**
|
||||
* The piles the step now on screen moved, for the display to light.
|
||||
*
|
||||
* Here because this is the only place that holds both the frame before a step and the frame after
|
||||
* it — deriving it anywhere else would mean keeping a second copy of the board in step.
|
||||
*/
|
||||
lit(): readonly PileKey[];
|
||||
/** True while there is anything left to show. */
|
||||
busy(): boolean;
|
||||
/**
|
||||
* How many narrated lines belong to steps NOT yet shown.
|
||||
*
|
||||
* The log and the board are two different moments while the queue is behind: a push carries its
|
||||
* narration and its steps together, so every line of a bot's turn is in the history panel before the
|
||||
* board has drawn a single move of it (playtest, 2026-09-15: *"is it possible to stall history so it
|
||||
* stays in sync with the number behind?"*). Those lines are the TAIL of the log — they arrived last —
|
||||
* so the caller holds back exactly this many and reveals each as its step goes up.
|
||||
*/
|
||||
pendingLines(): number;
|
||||
/** Division nodes whose card changed in the step now on screen, for the map to flash. */
|
||||
flashing(): readonly number[];
|
||||
/**
|
||||
* HOLD THE PLAYBACK ON THE STEP NOW SHOWING — Jesse, playtest 2026-09-16, asking for a pause
|
||||
* beside Skip.
|
||||
*
|
||||
* Skip is the only control the row has had, and it is one-way and total: the way to look harder at
|
||||
* a move that just went past was to not be too slow about it. Pause is the opposite lever — the
|
||||
* board stops where it is and nothing is consumed, so a player can read the caption, look at the
|
||||
* district and then carry on from exactly that step.
|
||||
*
|
||||
* TAKES `now` BECAUSE THE QUEUE OWNS NO CLOCK (see the note at the top of this file). The dwell
|
||||
* still owing is preserved across the hold rather than being spent while nobody was watching:
|
||||
* `resume` pushes the deadline out by however long the pause lasted, so a step paused with 200ms
|
||||
* left resumes with 200ms left instead of vanishing on the next frame.
|
||||
*
|
||||
* Returns false when there is nothing to hold, or nothing being held.
|
||||
*/
|
||||
pause(now: number): boolean;
|
||||
resume(now: number): boolean;
|
||||
paused(): boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* WHOSE MOVE THE SCREEN IS SHOWING (Gitea#25).
|
||||
*
|
||||
* The game and the board on screen are two different moments. The server plays every bot move the
|
||||
* instant a human's turn ends (`driveBots`), so the LIVE game is nearly always waiting on the human —
|
||||
* while this queue is still replaying the bots, step by step. The turn chart and the Division map's
|
||||
* move marker read the live actor, so a table of one person and three bots said "waiting on" that
|
||||
* person throughout, against a playback row naming the bot actually moving.
|
||||
*
|
||||
* While the queue is behind or still showing a step, the answer is that step's player — `null` for an
|
||||
* automatic phase, which is "the Division is running itself". Otherwise it is the live actor, and
|
||||
* `replaying` is false so a caller can keep live-only detail, such as a ruling the game is waiting on,
|
||||
* off a screen that has not caught up with it yet.
|
||||
*/
|
||||
export function actorOnScreen(
|
||||
queue: Pick<StepQueue, 'behind' | 'busy' | 'showing'>,
|
||||
live: number | null,
|
||||
): { actor: number | null; replaying: boolean } {
|
||||
if (queue.behind() === 0 && !queue.busy()) return { actor: live, replaying: false };
|
||||
const shown = queue.showing();
|
||||
return shown === null ? { actor: live, replaying: false } : { actor: shown.player, replaying: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* `pace` is read on every step rather than captured, so changing the setting takes effect at once.
|
||||
*
|
||||
* `viewer` says which seat is watching, so THIS PLAYER'S OWN MOVES COST NO TIME. They are already on
|
||||
* screen: a seated player's own board is drawn from their authoritative `Frame`, not from the queue,
|
||||
* so holding their click for a dwell shows them nothing and delays the thing they actually want to
|
||||
* watch — the 700ms before a bot's turn starts animating is 700ms of their own move being replayed
|
||||
* at them. The step is still APPLIED, because the delta chain runs through it.
|
||||
*
|
||||
* Automatic phases have no player and are unaffected, which is what keeps TODO #18 working in
|
||||
* solitaire where every intent is the viewer's own.
|
||||
*/
|
||||
export function createStepQueue(
|
||||
pace: () => number = () => 1,
|
||||
viewer: () => number | null = () => null,
|
||||
): StepQueue {
|
||||
let shown: PublicFrame | null = null;
|
||||
let last: DisplayStep | null = null;
|
||||
let litPiles: readonly PileKey[] = [];
|
||||
let flashedCards: readonly number[] = [];
|
||||
let pending: DisplayStep[] = [];
|
||||
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
||||
let dueAt: number | null = null;
|
||||
/** When the player pressed Pause, so `resume` can give the current step back the time it had. */
|
||||
let pausedAt: number | null = null;
|
||||
|
||||
/** How long this step holds the screen — zero for the viewer's own moves; see above. */
|
||||
const dwell = (step: DisplayStep): number =>
|
||||
step.player !== null && step.player === viewer() ? 0 : dwellForStep(step, pace());
|
||||
|
||||
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
|
||||
const show = (step: DisplayStep): void => {
|
||||
const before = shown;
|
||||
shown = applyPublicDelta(shown, step.frame);
|
||||
last = step;
|
||||
/**
|
||||
* NOT FOR YOUR OWN MOVES. You drew that card; you do not need the deck flashed at you. Same rule
|
||||
* that gives your own steps no dwell — the display is for watching everybody else.
|
||||
*/
|
||||
litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown);
|
||||
// A Realignment changes the Division under everyone, so it is flashed for the player who did it
|
||||
// too — unlike a pile, which only tells the drawer what they already know.
|
||||
flashedCards = changedDivisionCards(before, shown);
|
||||
};
|
||||
|
||||
return {
|
||||
reset(frame) {
|
||||
shown = frame;
|
||||
pending = [];
|
||||
dueAt = null;
|
||||
// A reconnect, an undo or a fresh deal replaces the board outright; a hold on the playback that
|
||||
// no longer exists would leave the row stuck reading "paused" with nothing behind it.
|
||||
pausedAt = null;
|
||||
// Nothing was watched arriving at this board, so nothing on it is lit.
|
||||
litPiles = [];
|
||||
flashedCards = [];
|
||||
// `last` deliberately survives: a reconnect should not blank the caption line, and the
|
||||
// sentence describing the most recent action is still true.
|
||||
},
|
||||
|
||||
push(steps) {
|
||||
pending.push(...steps);
|
||||
},
|
||||
|
||||
advance(now) {
|
||||
// Held. Nothing is shown and, crucially, nothing is CONSUMED — `dueAt` is left where it was
|
||||
// and `resume` moves it, so the hold costs the current step none of its dwell.
|
||||
if (pausedAt !== null) return false;
|
||||
if (pending.length === 0) {
|
||||
// The LAST step of a burst still owes its dwell. Clearing `dueAt` here reported the queue
|
||||
// idle the instant that step was shown, which snapped the district panel home before anyone
|
||||
// could look at it — see `busy()`.
|
||||
if (dueAt !== null && now >= dueAt) dueAt = null;
|
||||
return false;
|
||||
}
|
||||
// First step of a burst: show it immediately rather than waiting out a dwell for a board the
|
||||
// player has not been shown yet.
|
||||
if (dueAt === null) {
|
||||
const first = pending.shift()!;
|
||||
show(first);
|
||||
dueAt = now + dwell(first);
|
||||
return true;
|
||||
}
|
||||
let drew = false;
|
||||
/**
|
||||
* A LOOP, not a single step. A dwell of zero means "do not spend the player's attention on
|
||||
* this" — bookkeeping, and phases where nothing happened (TODO #18) — so a run of them must
|
||||
* collapse within one call instead of costing a frame each. The board still passes through
|
||||
* every state in order; nobody is shown a state that never existed.
|
||||
*/
|
||||
while (pending.length > 0 && now >= dueAt) {
|
||||
const next = pending.shift()!;
|
||||
show(next);
|
||||
dueAt = dueAt + dwell(next);
|
||||
drew = true;
|
||||
}
|
||||
if (pending.length === 0 && now >= dueAt) dueAt = null;
|
||||
return drew;
|
||||
},
|
||||
|
||||
skip() {
|
||||
// Skipping while held is a decision to stop watching, so it also lifts the hold — otherwise the
|
||||
// board would jump to the game and then sit there paused, with a Resume button that does nothing.
|
||||
pausedAt = null;
|
||||
if (pending.length === 0) return false;
|
||||
for (const step of pending) show(step);
|
||||
pending = [];
|
||||
dueAt = null;
|
||||
return true;
|
||||
},
|
||||
|
||||
pause(now) {
|
||||
if (pausedAt !== null) return false;
|
||||
// Nothing on screen owes any time and nothing is queued: there is no playback to hold.
|
||||
if (pending.length === 0 && dueAt === null) return false;
|
||||
pausedAt = now;
|
||||
return true;
|
||||
},
|
||||
|
||||
resume(now) {
|
||||
if (pausedAt === null) return false;
|
||||
// Give the step on screen back exactly the dwell it was holding when the player pressed Pause.
|
||||
if (dueAt !== null) dueAt += now - pausedAt;
|
||||
pausedAt = null;
|
||||
return true;
|
||||
},
|
||||
|
||||
paused: () => pausedAt !== null,
|
||||
|
||||
current: () => shown,
|
||||
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
||||
showing: () => last,
|
||||
lit: () => litPiles,
|
||||
/**
|
||||
* STILL SHOWING SOMETHING, not just still holding something back.
|
||||
*
|
||||
* This was `pending.length > 0`, which went false the moment the last step of a burst was
|
||||
* shown — so the animation loop stopped and the district panel snapped back to the viewer's own
|
||||
* board without that step ever being visible. Reported from real play: "I briefly saw that it was
|
||||
* the bot's office area, then their turn was done and it pointed back to my office area."
|
||||
*
|
||||
* `dueAt` is non-null exactly while the step on screen has time left, so the two together mean
|
||||
* "there is more to come, or what is up has not had its moment yet".
|
||||
*/
|
||||
pendingLines: () => pending.reduce((n, s) => n + s.lines.length, 0),
|
||||
flashing: () => flashedCards,
|
||||
busy: () => pending.length > 0 || dueAt !== null,
|
||||
};
|
||||
}
|
||||
+191
-2
@@ -7,7 +7,7 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { advance, badlyMadeUp, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf, check, isBeingMadeUp } from '../src/engine/apply.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';
|
||||
@@ -1141,7 +1141,12 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
|
||||
// A train ahead of it in the same Subdivision, running the SAME way — §8.1's fourth condition,
|
||||
// which is the Superintendent's call rather than an absolute bar.
|
||||
const ahead = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||
//
|
||||
// AHEAD MEANS EAST OF THE OFFICE for this eastbound train. This used to take the FIRST Mainline card
|
||||
// in the Division, which is west of the Office — behind the train — and still expected a ruling,
|
||||
// which is exactly the fault Gitea#26 reported. The card is now one the train would actually follow.
|
||||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const ahead = s.division.nodes.findIndex((n, i) => i > office && n.kind === 'mainline');
|
||||
const node = s.division.nodes[ahead];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('ahead', {
|
||||
@@ -1511,3 +1516,187 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
|
||||
assert.equal(check(s, 0, at), 'NO_EXTRA_PENDING');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('§8.1 counts only trains AHEAD of the one departing (Gitea#26)', () => {
|
||||
/**
|
||||
* REPORTED from playtesting v0.8.0.9: two westbound Extras, X15 at an Office and X18 still crossing
|
||||
* the card to its EAST. The Superintendent was asked to rule on X15 against X18 — a train behind it —
|
||||
* and holding X15 kept the Whistle Post's only A/D track full, so X18 arrived into it and was
|
||||
* destroyed. Reproduced by replaying the exported save; the positions below are that situation in a
|
||||
* one-seat Division, where every Office is a Whistle Post and the Subdivision spans them all.
|
||||
*/
|
||||
const setup = (occupant: { direction: 'east' | 'west'; side: 'east' | 'west'; number: number }) => {
|
||||
const s = game(7, { days: 5 });
|
||||
const area = areaOf(s, 0);
|
||||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
s.trays.set('departing', {
|
||||
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
|
||||
direction: 'west', facing: 'w',
|
||||
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
});
|
||||
area.adOccupancy.push('departing');
|
||||
const card = s.division.nodes.findIndex((n, i) =>
|
||||
n.kind === 'mainline' && (occupant.side === 'east' ? i > office : i < office));
|
||||
const node = s.division.nodes[card];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('other', {
|
||||
id: 'other', trainNumber: occupant.number, trainIsExtra: true, engineAt: 0, consist: [],
|
||||
direction: occupant.direction, facing: occupant.direction === 'east' ? 'e' : 'w',
|
||||
position: { at: 'mainline', index: card }, movesUsed: 0,
|
||||
});
|
||||
if (node?.kind === 'mainline') {
|
||||
node.transits.push({ tray: 'other', stagesRemaining: 2, stagesTotal: 2, direction: occupant.direction });
|
||||
}
|
||||
s.clock.phase = 'mainline';
|
||||
return s;
|
||||
};
|
||||
|
||||
it('does not put a same-direction train BEHIND the departing one to the Superintendent', () => {
|
||||
const s = setup({ direction: 'west', side: 'east', number: 18 });
|
||||
const r = advance(s);
|
||||
assert.ok(!r.events.some((e) => e.type === 'clearanceRequested'), 'a train behind was put to the Superintendent');
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
|
||||
'the departing train did not highball with nothing ahead of it',
|
||||
);
|
||||
assert.ok(!r.events.some((e) => e.type === 'trainsDestroyed'), 'a train was destroyed');
|
||||
});
|
||||
|
||||
it('does not bar a departure over an opposite-direction train BEHIND it, which is moving away', () => {
|
||||
const s = setup({ direction: 'east', side: 'east', number: 18 });
|
||||
const r = advance(s);
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
|
||||
'a train moving away behind it held the departure',
|
||||
);
|
||||
});
|
||||
|
||||
it('still puts a same-direction train AHEAD to the Superintendent', () => {
|
||||
const s = setup({ direction: 'west', side: 'west', number: 18 });
|
||||
const r = advance(s);
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === 'departing'),
|
||||
'a train the departing one would follow was not put to the Superintendent',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* §7's make-up round is silent about what it could NOT do, and that silence was reported from a
|
||||
* table (2026-09-16): "train 5, the sparrow, has no coaches, which seems strange."
|
||||
*
|
||||
* The Sparrow's card calls for three coaches and nothing else. Every coach in that game had ended
|
||||
* up in the Classification Yard, which §2.2 returns only when the Division Yard runs bare — and the
|
||||
* Division Yard still held 46 freight cars, so it never would. `trainNeedingCars` therefore answered
|
||||
* "nothing to ask for" exactly as it answers "everything is full", the phase moved on, and the train
|
||||
* ran the length of the Division empty with no line anywhere saying why.
|
||||
*/
|
||||
describe('a train the Division Yard cannot supply says so', () => {
|
||||
/** Puts one train in the yard-filling state, with a yard holding only `types`. */
|
||||
const readyToFill = (yard: { type: string; loaded: boolean }[]) => {
|
||||
const s = game();
|
||||
// Stage 1's train is 1/2, the Crack Limited — coaches only, which is the shape that goes short.
|
||||
s.timetable = Array(STAGES_PER_DAY).fill(null);
|
||||
s.timetable[0] = 1;
|
||||
s.yards.divisionYard = yard as never;
|
||||
s.yards.classificationYard = [{ type: 'coach', loaded: false }] as never;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'newTrain';
|
||||
return s;
|
||||
};
|
||||
|
||||
it('reports the shortfall, with the cars, the waiting pile and why it will not come back', () => {
|
||||
// A yard of pure freight: nothing the Crack Limited will take.
|
||||
const s = readyToFill([
|
||||
{ type: 'boxcar', loaded: true },
|
||||
{ type: 'hopper', loaded: false },
|
||||
]);
|
||||
const { events } = advance(s);
|
||||
const short = events.find((e) => e.type === 'makeUpShort');
|
||||
assert.ok(short, 'a train that could be given nothing reported nothing');
|
||||
assert.equal(short.type === 'makeUpShort' && short.placed, 0);
|
||||
assert.ok(
|
||||
short.type === 'makeUpShort' && short.missing.includes('coach'),
|
||||
'the report did not name the category the yard could not supply',
|
||||
);
|
||||
assert.ok(
|
||||
short.type === 'makeUpShort' && short.waiting === 1,
|
||||
'the report did not count the cars waiting in the Classification Yard',
|
||||
);
|
||||
assert.ok(
|
||||
short.type === 'makeUpShort' && short.divisionYardHolds === 2,
|
||||
'the report did not say how far the Division Yard is from bare, which is what §2.2 turns on',
|
||||
);
|
||||
});
|
||||
|
||||
it('says nothing when the round can still be asked for a car', () => {
|
||||
// The same train, with coaches available: the phase must STOP for them rather than report.
|
||||
const s = readyToFill([
|
||||
{ type: 'coach', loaded: true },
|
||||
{ type: 'coach', loaded: true },
|
||||
]);
|
||||
const { events, needsInput } = advance(s);
|
||||
assert.equal(needsInput, true, 'the phase should be waiting for a car to be placed');
|
||||
assert.equal(
|
||||
events.some((e) => e.type === 'makeUpShort'),
|
||||
false,
|
||||
'a train that can still be filled was reported short',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* §8.2 and the Small Yard's nose sort, together — Jesse's ruling of 2026-09-17 was "any train may
|
||||
* sort to the nose, and it is held if it cannot depart".
|
||||
*
|
||||
* The holding half already existed and needed no new code, which is worth pinning precisely because
|
||||
* it is easy to assume otherwise: `badlyMadeUp` is deliberately DIRECTION-FREE, so a train with its
|
||||
* whole consist ahead of the engine is a PUSHING train and fit to run. What §8.2 refuses is a
|
||||
* broken-backed train — the engine buried among its own cars — and a caboose anywhere but the end
|
||||
* away from the engine.
|
||||
*/
|
||||
describe('a train sorted to the nose is judged by §8.2, not by where the engine is', () => {
|
||||
const tray = (consist: { type: string; loaded: boolean }[], engineAt: number) =>
|
||||
({ consist, engineAt }) as unknown as CrewTray;
|
||||
|
||||
it('lets a pushing train run — the whole consist ahead of the engine is made up', () => {
|
||||
const pushing = tray([{ type: 'caboose', loaded: true }, { type: 'boxcar', loaded: true }], 2);
|
||||
assert.equal(
|
||||
badlyMadeUp(pushing),
|
||||
null,
|
||||
'a pushing train was refused; §8.2 is enforced direction-free on purpose',
|
||||
);
|
||||
});
|
||||
|
||||
it('holds a train whose engine is buried among its own cars', () => {
|
||||
const buried = tray(
|
||||
[{ type: 'boxcar', loaded: true }, { type: 'hopper', loaded: true }, { type: 'caboose', loaded: true }],
|
||||
1,
|
||||
);
|
||||
const why = badlyMadeUp(buried);
|
||||
assert.ok(why !== null, 'a broken-backed train was allowed to leave the Office');
|
||||
assert.match(why, /engine is buried/, `the hold did not say why: ${why}`);
|
||||
});
|
||||
|
||||
it('holds a train whose caboose is not at the end away from the engine', () => {
|
||||
// Exactly the arrangement asked for at the table: caboose second from the rear.
|
||||
const wanted = tray(
|
||||
[
|
||||
{ type: 'hopper', loaded: true },
|
||||
{ type: 'tank', loaded: false },
|
||||
{ type: 'caboose', loaded: true },
|
||||
{ type: 'boxcar', loaded: true },
|
||||
],
|
||||
0,
|
||||
);
|
||||
const why = badlyMadeUp(wanted);
|
||||
assert.ok(why !== null, 'a train with its caboose mid-consist was allowed to leave');
|
||||
assert.match(why, /caboose must be at the rear/, `the hold did not say why: ${why}`);
|
||||
});
|
||||
});
|
||||
|
||||
+92
-13
@@ -159,6 +159,60 @@ describe('Local Operations: drawing (§6.2)', () => {
|
||||
assert.notEqual(s.decks.departments[1]![0], target, 'refilled with the same card');
|
||||
});
|
||||
|
||||
it('a PLAYED timetabled train never comes back, but a discarded one does — Gitea#23', () => {
|
||||
/**
|
||||
* Jesse's ruling, 2026-09-10: *"Once you've played a regularly scheduled train and it's in the
|
||||
* salvage deck, that train is already on the timetable. It does not make sense to put that back
|
||||
* into a reshuffled home deck to get played again. By contrast, a regularly scheduled train
|
||||
* that's in a discard pile could potentially get reused later, and so should have that
|
||||
* capability. Extras run one time and then they're done — if they are in the Salvage deck, they
|
||||
* should get shuffled back in so that they could get run again."*
|
||||
*
|
||||
* So the test is WHERE the card is, not only what it is: the same card is spent in the Salvage
|
||||
* Yard and still runnable in a Department. That is what this pins, because it is the kind of rule
|
||||
* a later tidy-up would happily "simplify" into filtering by card kind everywhere.
|
||||
*/
|
||||
const s = game();
|
||||
const kindOfCard = (id: string): string => s.cards.get(id)?.kind.kind ?? '?';
|
||||
const pool = [...s.decks.homeOffice];
|
||||
const trains = pool.filter((id) => kindOfCard(id) === 'timetabledTrain');
|
||||
const extras = pool.filter((id) => kindOfCard(id) === 'extraTrain');
|
||||
const others = pool.filter((id) => !['timetabledTrain', 'extraTrain'].includes(kindOfCard(id)));
|
||||
assert.ok(trains.length >= 2 && extras.length >= 1 && others.length >= 5, 'the deal lacks the cards this needs');
|
||||
|
||||
const spentTrain = trains[0]!; // played: in the Salvage Yard, its slot taken
|
||||
const discardedTrain = trains[1]!; // never played: sitting in a Department
|
||||
const playedExtra = extras[0]!; // a single run, free to run again
|
||||
|
||||
s.decks.salvageYard = [spentTrain, playedExtra, ...others.slice(0, 3)];
|
||||
s.decks.departments = [[discardedTrain], [others[3]!], [others[4]!]];
|
||||
s.decks.homeOffice = [others[5]!];
|
||||
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
|
||||
assert.ok(r.ok);
|
||||
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted');
|
||||
|
||||
const recovered = new Set([...s.decks.homeOffice, ...s.decks.departments.flat()]);
|
||||
const hands = new Set([...s.decks.hands.values()].flat());
|
||||
|
||||
// THE RULING, both halves.
|
||||
assert.ok(!recovered.has(spentTrain), 'a played timetabled train was shuffled back in');
|
||||
assert.ok(!hands.has(spentTrain), 'a played timetabled train was dealt back into a hand');
|
||||
assert.ok(
|
||||
s.decks.salvageYard.includes(spentTrain),
|
||||
'a played timetabled train should stay in the Salvage Yard, not vanish',
|
||||
);
|
||||
assert.ok(
|
||||
recovered.has(discardedTrain) || hands.has(discardedTrain),
|
||||
'a DISCARDED timetabled train must come back — it was never played, so its slot is open',
|
||||
);
|
||||
assert.ok(
|
||||
recovered.has(playedExtra) || hands.has(playedExtra),
|
||||
'a played Extra must come back — an Extra is one run, not a standing slot',
|
||||
);
|
||||
});
|
||||
|
||||
it('reshuffles the Salvage Yard and Departments back in when the deck runs out', () => {
|
||||
// §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards
|
||||
// from the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office
|
||||
@@ -182,7 +236,11 @@ describe('Local Operations: drawing (§6.2)', () => {
|
||||
assert.ok(r.ok);
|
||||
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted');
|
||||
|
||||
assert.equal(s.decks.salvageYard.length, 0, 'the Salvage Yard must be swept');
|
||||
// Swept EXCEPT the trains whose slots are already on the timetable — see the ruling test below.
|
||||
assert.ok(
|
||||
s.decks.salvageYard.every((id) => s.cards.get(id)?.kind.kind === 'timetabledTrain'),
|
||||
'the Salvage Yard must be swept apart from spent timetabled trains',
|
||||
);
|
||||
assert.ok(s.decks.homeOffice.length > 0, 'the deck must be re-established');
|
||||
assert.ok(
|
||||
s.decks.departments.every((p) => p.length === 1),
|
||||
@@ -1303,6 +1361,10 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
||||
const under = district(s);
|
||||
buildFacility(s, 'packingSheds', under);
|
||||
const cardId = modifierOf(s, 'iceHouse');
|
||||
// `district` leaves the host ON the sign's column, which since 2026-09-17 puts its three eastern
|
||||
// spots outside the Limits. The question here is whether DIAGONALS are offered at all, so the
|
||||
// sign goes out one more column and the host keeps all nine.
|
||||
areaOf(s, 0).limitsEast = at(areaOf(s, 0).runningRow, under.col + 1);
|
||||
|
||||
const offered = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined)
|
||||
@@ -1317,13 +1379,18 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: southEast }), null);
|
||||
});
|
||||
|
||||
it('lets a Modifier hang outside the Limits, but never in the Running Track row', () => {
|
||||
it('keeps a Modifier inside the Limits, and out of the Running Track row', () => {
|
||||
/**
|
||||
* Jesse's call, both halves. A Modifier is not track (§9), so a host standing at the limit keeps
|
||||
* all nine of its spots — refusing the outer three would make the card unplayable exactly where
|
||||
* the district ends. The Running Track ROW is the exception: inside the Limits that row is
|
||||
* always full, so this bites only beyond the sign, and that is the ground the main grows onto —
|
||||
* a Modifier parked there would block the player's own sign from moving outward (§2.1).
|
||||
* Jesse's call, both halves — and the FIRST half reversed on 2026-09-17 after a Day 3 playtest
|
||||
* put Transmission Lines at (-2,4) with the sign at column 3. It used to read the other way: a
|
||||
* Modifier is not track (§9), so a host at the limit kept all nine of its spots, because
|
||||
* refusing the outer three looked like it would make the card unplayable where the district
|
||||
* ends. What decided it was the board — a card standing outside your own sign, in territory
|
||||
* §8.1 and §10 reason about — and a count of what is actually lost: six of the nine spots
|
||||
* survive, and the sign moves outward as the Running Track grows (§2.1, Gap 4a).
|
||||
*
|
||||
* The Running Track ROW stays barred for its own reason: it is the ground the main grows onto,
|
||||
* and a Modifier parked there would block the player's own sign from moving outward.
|
||||
*/
|
||||
const s = game();
|
||||
const under = district(s);
|
||||
@@ -1332,9 +1399,16 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
||||
const area = areaOf(s, 0);
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, area.limitsEast.col + 1) }),
|
||||
'OUTSIDE_LIMITS',
|
||||
'a Modifier was allowed to stand outside the district it belongs to',
|
||||
);
|
||||
// The spot inside the sign, beside the same host, is the one a player actually has — free,
|
||||
// adjacent, and on the sign's own column, which `withinLimits` includes.
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row - 1, under.col) }),
|
||||
null,
|
||||
'a Modifier beside a host at the limit was refused the spot outside it',
|
||||
'a Modifier was refused a free, connected spot inside the Limits',
|
||||
);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, under.col + 1) }),
|
||||
@@ -1367,6 +1441,11 @@ describe("a Modifier grants only what its host's flow can use", () => {
|
||||
const area = areaOf(s, 0);
|
||||
const at = { row: -1, col: 4 };
|
||||
area.grid.set(coordKey(at), withFacility(kind, out, into) as never);
|
||||
// This test is about what a Modifier GRANTS, not about where it may stand, and it arranges a
|
||||
// host well east of the opening sign. Modifiers have been bounded by the Limits since
|
||||
// 2026-09-17, so the district has to reach the square the fixture uses or every case here would
|
||||
// fail as OUTSIDE_LIMITS and prove nothing about flow.
|
||||
area.limitsEast = { row: area.runningRow, col: at.col + 2 };
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
|
||||
const card = [...s.cards.entries()].find(
|
||||
@@ -1679,7 +1758,7 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi
|
||||
const build = (toNose: boolean): string[] => {
|
||||
const s = game();
|
||||
const id = placeTray(s, at(0, 0), [car('boxcar')] as never);
|
||||
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose });
|
||||
reduce(s, { type: 'carsCoupled', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose });
|
||||
return s.trays.get(id)!.consist.map((c) => c.type);
|
||||
};
|
||||
assert.deepEqual(build(true), ['hopper', 'boxcar'], 'running forward takes cars on the nose');
|
||||
@@ -1694,11 +1773,11 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi
|
||||
const tray = s.trays.get(id)!;
|
||||
tray.engineAt = 0;
|
||||
|
||||
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose: true });
|
||||
reduce(s, { type: 'carsCoupled', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose: true });
|
||||
assert.equal(tray.engineAt, 1, 'the engine should now have a car ahead of it');
|
||||
assert.deepEqual(tray.consist.map((c) => c.type), ['hopper', 'boxcar']);
|
||||
|
||||
reduce(s, { type: 'carsDropped', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, fromNose: true });
|
||||
reduce(s, { type: 'carsDropped', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, fromNose: true });
|
||||
assert.equal(tray.engineAt, 0, 'setting out the nose cars puts the engine back in front');
|
||||
assert.deepEqual(tray.consist.map((c) => c.type), ['boxcar']);
|
||||
});
|
||||
@@ -1823,7 +1902,7 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
|
||||
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
|
||||
*/
|
||||
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
|
||||
({ type: 'trayMoved', trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
|
||||
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, movesAllowed: 6, facing }) as const;
|
||||
|
||||
it('carries the east-west sense across north-south track', () => {
|
||||
const s = game();
|
||||
|
||||
@@ -12,6 +12,9 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { describeIntent } from '../src/sim/view.ts';
|
||||
import { badlyMadeUp } from '../src/engine/advance.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackArc, TrackCard } from '../src/engine/state.ts';
|
||||
import { carsOn, coordKey, turnOf } from '../src/engine/state.ts';
|
||||
@@ -461,3 +464,161 @@ describe('a 45° leg is part of the west-to-east row, not outside it (Gitea#17)'
|
||||
assert.deepEqual(standingAt(s, at(1, 0)), [], 'the cars should have come off the card');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The Small Yard's re-order menu, reported from Day 3 of the 2026-09-17 playtest.
|
||||
*
|
||||
* Jesse had train 10 on his Small Yard — nose first, `[loaded boxcar, loaded hopper, empty tank,
|
||||
* caboose]` — wanted the boxcar on the tail, and could not tell which button did it: every option
|
||||
* read `re-order consist [1,2,3,0]`, the engine's own array indices offered to a person. The move he
|
||||
* wanted was the FIRST of the five. One of the other four re-ordered nothing at all and would still
|
||||
* have spent one of his six Moves.
|
||||
*/
|
||||
describe('the Small Yard says what each re-order would build', () => {
|
||||
const smallYard = (standing: RollingStock[] = []): TrackCard => ({
|
||||
...straight(standing),
|
||||
enhancements: ['smallYard'],
|
||||
});
|
||||
|
||||
/** Train 10's consist as it actually stood, on a card carrying a Small Yard. */
|
||||
const onTheYard = (): { s: GameState; trayId: string } => {
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 1), smallYard());
|
||||
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true), car('tank'), car('caboose', true)], 'e');
|
||||
switching(s);
|
||||
return { s, trayId };
|
||||
};
|
||||
|
||||
it('never offers a sort that changes nothing', () => {
|
||||
/**
|
||||
* THE PAIR IS WHAT COUNTS, since the engine position became part of a sort (2026-09-17). The
|
||||
* identity car order is now a perfectly good option when it moves the ENGINE — that is the whole
|
||||
* of the separate engine control — so what must never be offered is the pair that reproduces the
|
||||
* train already standing there, and no pair may appear twice.
|
||||
*/
|
||||
const { s, trayId } = onTheYard();
|
||||
const tray = s.trays.get(trayId)!;
|
||||
const offered = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => {
|
||||
const sort = i as { order: number[]; engineAt?: number };
|
||||
return `${sort.order.join(',')}|${sort.engineAt ?? 0}`;
|
||||
});
|
||||
assert.ok(offered.length > 0, 'no sort was offered at all, so this proved nothing');
|
||||
const unchanged = `${[...tray.consist.keys()].join(',')}|${tray.engineAt}`;
|
||||
assert.ok(
|
||||
!offered.includes(unchanged),
|
||||
'the menu offered the train as it already stands — a Move spent to change nothing',
|
||||
);
|
||||
assert.equal(new Set(offered).size, offered.length, 'the menu offered the same sort twice');
|
||||
});
|
||||
|
||||
it('offers the engine every position, including ahead of its own cars', () => {
|
||||
// Jesse's ruling, 2026-09-17, following `implications.md` against the v0.4.5 card text: a train
|
||||
// in a Small Yard "may sort itself into any order, INCLUDING cars ahead of the engine".
|
||||
const { s, trayId } = onTheYard();
|
||||
const n = s.trays.get(trayId)!.consist.length;
|
||||
const positions = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => (i as { engineAt?: number }).engineAt ?? 0);
|
||||
for (let k = 1; k <= n; k++) {
|
||||
assert.ok(positions.includes(k), `the engine was never offered position ${k} of ${n}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('always offers a made-up order to a train that is not in one', () => {
|
||||
/**
|
||||
* Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the back… sorting all
|
||||
* the cars back in, ready to leave the station."
|
||||
*
|
||||
* A train with its caboose mid-consist is one §8.2 will not let out of the Office, so at least
|
||||
* one offer has to put it right. It comes out of the ordinary curated set — "bring car k to the
|
||||
* tail" is enumerated for every car, and the caboose is one of them.
|
||||
*/
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 1), smallYard());
|
||||
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('caboose', true), car('hopper', true)], 'e');
|
||||
const tray = s.trays.get(trayId)!;
|
||||
tray.trainNumber = 10;
|
||||
switching(s);
|
||||
|
||||
const fit = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.filter((i) => {
|
||||
const sort = i as { order: number[]; engineAt?: number };
|
||||
const after = sort.order.map((n) => tray.consist[n]!);
|
||||
return badlyMadeUp({ ...tray, consist: after, engineAt: sort.engineAt ?? 0 }) === null;
|
||||
});
|
||||
assert.ok(
|
||||
fit.length > 0,
|
||||
'a train that cannot leave the Office was offered no sort that would make it up',
|
||||
);
|
||||
// And the one that does it says so on the button, rather than leaving it to be discovered.
|
||||
assert.ok(
|
||||
fit.some((i) => describeIntent(s, i).includes('MADE UP, ready to leave')),
|
||||
'the sort that makes the train up does not say so',
|
||||
);
|
||||
});
|
||||
|
||||
it('lays the train out west to east, with the engine where it will be', () => {
|
||||
/**
|
||||
* "You specify above that the order is front to back, but on the screen, if it's eastbound or
|
||||
* westbound, it may look different" — Jesse, 2026-09-17.
|
||||
*
|
||||
* `board-svg.ts` draws the crew strip west on the left, reversing a consist for an east-facing
|
||||
* train so its nose lands at the east end. The button has to read the same way or it describes a
|
||||
* different train from the one on the board.
|
||||
*/
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 1), smallYard());
|
||||
// Engine points WEST: the consist is stored nose first, so west to east reads engine first.
|
||||
const west = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true)], 'w');
|
||||
switching(s);
|
||||
const westLabel = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => describeIntent(s, i))
|
||||
.find((l) => l.startsWith('re-order'));
|
||||
assert.ok(westLabel, 'no re-order was offered for the west-facing train');
|
||||
assert.match(westLabel, /west to east: ◀ ENGINE · /, `a west-facing engine was not drawn leading: ${westLabel}`);
|
||||
|
||||
// The same train pointing EAST puts the engine at the far end of the same sentence.
|
||||
s.trays.get(west)!.railFacing = 'e';
|
||||
s.trays.get(west)!.facing = 'e';
|
||||
const eastLabel = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => describeIntent(s, i))
|
||||
.find((l) => l.startsWith('re-order'));
|
||||
assert.ok(eastLabel, 'no re-order was offered for the east-facing train');
|
||||
assert.match(eastLabel, / · ENGINE ▶($| ·)/, `an east-facing engine was not drawn at the east end: ${eastLabel}`);
|
||||
});
|
||||
|
||||
it('labels each option with the train it would make, not with array indices', () => {
|
||||
const { s } = onTheYard();
|
||||
const labels = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => describeIntent(s, i));
|
||||
|
||||
assert.ok(
|
||||
labels.every((l) => !/\[\d(,\d)*\]/.test(l)),
|
||||
`a re-order option still reads as a permutation: ${labels.find((l) => /\[\d(,\d)*\]/.test(l))}`,
|
||||
);
|
||||
/**
|
||||
* The one Jesse wanted, written the way the board draws it: this crew faces EAST, so the strip
|
||||
* runs west to east and the engine sits at the east end with the boxcar now furthest west.
|
||||
*/
|
||||
assert.ok(
|
||||
labels.includes('re-order — west to east: loaded boxcar · caboose · empty tank · loaded hopper · ENGINE ▶'),
|
||||
`the move that puts the boxcar on the tail was not offered in words — got: ${labels.join(' | ')}`,
|
||||
);
|
||||
// And the engine's own positions read as what they do, not as an index.
|
||||
assert.ok(
|
||||
labels.some((l) => l.startsWith('put the whole consist ahead of the engine —')),
|
||||
`the shoving sort was not offered in words — got: ${labels.join(' | ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,12 @@ const KNOWN_UNREDUCED = [
|
||||
'clearanceRequested',
|
||||
'dispatchBonusUsed',
|
||||
'expediteFault',
|
||||
/**
|
||||
* The New Train Phase's report that it could give a train nothing (playtest, 2026-09-16, the
|
||||
* Sparrow running empty). Emitted by the phase driver after the make-up round has nothing left to
|
||||
* offer, so it describes rather than reduces, like every entry on this list.
|
||||
*/
|
||||
'makeUpShort',
|
||||
'phaseBegan',
|
||||
/**
|
||||
* §Q, Red Flags (Gitea#19). The flag comes down inside the phase driver as it stops a train, so
|
||||
@@ -61,6 +67,18 @@ const KNOWN_UNREDUCED = [
|
||||
// the pattern every entry on this list follows.
|
||||
'seatsRotated',
|
||||
'stageBegan',
|
||||
/**
|
||||
* §5's Fedora handover, emitted by `shiftChange` on the same mutate-then-describe path as its
|
||||
* neighbours here: the clock moves the Superintendent and then says so. Added 2026-09-16 because
|
||||
* riding on `actorChanged` meant the log dropped it as turn bookkeeping.
|
||||
*/
|
||||
'superintendentChanged',
|
||||
/**
|
||||
* The line that closes a switching turn. Emitted by `switch.end` beside the `phaseEnded` that
|
||||
* actually ends the turn, and reduces to nothing itself: it reports what the Moves were spent on
|
||||
* and where the crew was left, both of which the state already holds.
|
||||
*/
|
||||
'switchingEnded',
|
||||
'trainArrived',
|
||||
'trainCompleted',
|
||||
'trainDiverted',
|
||||
|
||||
@@ -24,12 +24,13 @@ import { impediments, narrate } from '../src/sim/narrate.ts';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { actionMenu } from '../src/web/game.ts';
|
||||
import { newCollector } from '../src/sim/display-step.ts';
|
||||
import type { Game } from '../src/web/game.ts';
|
||||
|
||||
/** The thin wrapper `actionMenu` expects, built directly around an already-created multi-player state
|
||||
* — `newGame` (game.ts) hardcodes one player, so it cannot construct this for a multi-seat game. */
|
||||
const wrap = (s: GameState): Game =>
|
||||
({ state: s, seed: s.seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null });
|
||||
({ state: s, seed: s.seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() });
|
||||
|
||||
const competitive: GameConfig = {
|
||||
mode: 'competitive',
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* DWELL BY KIND — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 5.
|
||||
*
|
||||
* The classification is exhaustive over `Intent['type']` at COMPILE time: `kindOf` declares a
|
||||
* `StepKind` return and has no `default`, so a new intent breaks the build rather than landing
|
||||
* silently in a fallback tier. These tests add the part the compiler cannot do — they read the
|
||||
* intent union out of the source, so the guard survives someone later adding a `default:` that
|
||||
* would swallow the very thing the exhaustiveness was protecting.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { DWELL, MAX_PACE, PACE_LEVELS, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts';
|
||||
import type { StepKind } from '../src/sim/pacing.ts';
|
||||
import type { Intent } from '../src/engine/intents.ts';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
/** Every `type: '…'` literal in the Intent union, read from the source rather than hand-listed. */
|
||||
function declaredIntents(): string[] {
|
||||
const src = readFileSync(join(root, 'src/engine/intents.ts'), 'utf8');
|
||||
return [...new Set([...src.matchAll(/type: '([a-zA-Z.]+)'/g)].map((m) => m[1]!))].sort();
|
||||
}
|
||||
|
||||
const KINDS: StepKind[] = ['switching', 'action', 'phase', 'bookkeeping'];
|
||||
|
||||
describe('pacing — dwell by kind', () => {
|
||||
it('classifies every intent the engine declares', () => {
|
||||
const declared = declaredIntents();
|
||||
assert.ok(declared.length > 25, `only found ${declared.length} intents — the parse is wrong`);
|
||||
for (const intent of declared) {
|
||||
const kind = kindOf(intent as Intent['type']);
|
||||
assert.ok(
|
||||
KINDS.includes(kind),
|
||||
`${intent} classified as "${kind}", which is not a StepKind — a default case has crept in`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('protects switching and collapses bookkeeping', () => {
|
||||
// The two ends of the measured argument: a switching move is the thing worth watching, and
|
||||
// `*.end` bookkeeping is over half of a real game's intents.
|
||||
assert.equal(kindOf('switch.move'), 'switching');
|
||||
assert.equal(kindOf('switch.dropCars'), 'switching');
|
||||
assert.equal(kindOf('switch.sortConsist'), 'switching');
|
||||
assert.equal(kindOf('draw.end'), 'bookkeeping');
|
||||
assert.equal(kindOf('loadUnload.end'), 'bookkeeping');
|
||||
assert.equal(kindOf('switch.end'), 'bookkeeping');
|
||||
/**
|
||||
* `localOps.choose` IS AN ANNOUNCEMENT, not bookkeeping — moved 2026-09-09 after the first real
|
||||
* play on the test server. It is the line reading "Player Bot 1 chose to SWITCH", the heading for
|
||||
* everything that follows, and at zero dwell a bot's turn began with no sign of what it was about
|
||||
* to do.
|
||||
*/
|
||||
assert.equal(kindOf('localOps.choose'), 'action');
|
||||
|
||||
assert.ok(DWELL.switching > DWELL.action, 'switching must outrank an ordinary action');
|
||||
assert.equal(DWELL.bookkeeping, 0, 'bookkeeping must cost the player no time at all');
|
||||
});
|
||||
|
||||
it('starts switching at a full second, per the 2026-09-09 decision', () => {
|
||||
// Jesse: "start at 1s and tune down". Pinned so a later tune is a deliberate edit rather than
|
||||
// a drift, and so the number in the plan and the number in the code cannot disagree.
|
||||
assert.equal(DWELL.switching, 1000);
|
||||
assert.equal(dwellFor('switch.move'), 1000);
|
||||
});
|
||||
|
||||
it('supports multipliers above 1, and keeps the tiers in proportion at every speed', () => {
|
||||
/**
|
||||
* Jesse, 2026-09-09, after the first play: keep switching and ordinary actions at DIFFERENT
|
||||
* delays, and support 2.0 and 3.0 as well as 1.5. So this pins both halves — that the larger
|
||||
* multipliers work at all, and that scaling never flattens the tiers into each other, since the
|
||||
* relative weighting is the design and the multiplier is only how fast it runs.
|
||||
*/
|
||||
for (const pace of [0.5, 1, 1.5, 2, 3]) {
|
||||
assert.equal(dwellFor('switch.move', pace), Math.round(DWELL.switching * pace));
|
||||
assert.equal(dwellFor('card.play', pace), Math.round(DWELL.action * pace));
|
||||
assert.ok(
|
||||
dwellFor('switch.move', pace) > dwellFor('card.play', pace),
|
||||
`at ${pace}x a switching move no longer outlasts an ordinary action`,
|
||||
);
|
||||
assert.equal(dwellFor('draw.end', pace), 0, 'bookkeeping stays free at every speed');
|
||||
}
|
||||
// A whole switching exercise at 3x is slow on purpose, and still not absurd.
|
||||
assert.equal(dwellFor('switch.move', 3) * 6, 18_000);
|
||||
|
||||
// And a typo cannot freeze the board: ?pace=300 from somebody meaning 3.00.
|
||||
assert.equal(dwellFor('switch.move', 300), DWELL.switching * MAX_PACE);
|
||||
assert.equal(dwellFor('switch.move', MAX_PACE + 5), dwellFor('switch.move', MAX_PACE));
|
||||
});
|
||||
|
||||
it('scales with the viewer\'s pace, and 0 turns it off', () => {
|
||||
assert.equal(dwellFor('switch.move', 1), 1000);
|
||||
assert.equal(dwellFor('switch.move', 0.5), 500);
|
||||
assert.equal(dwellFor('switch.move', 2), 2000);
|
||||
// TODO #18's "a player who has seen it a hundred times will want it off" — no second mechanism.
|
||||
for (const intent of declaredIntents()) {
|
||||
assert.equal(dwellFor(intent as Intent['type'], 0), 0, `${intent} still dwells at pace 0`);
|
||||
}
|
||||
// A negative pace is a corrupt preference, not a request to run time backwards.
|
||||
assert.equal(dwellFor('switch.move', -3), 0);
|
||||
});
|
||||
|
||||
it('counts only the steps a player will actually watch', () => {
|
||||
/**
|
||||
* The counter's whole point. A backlog of 17 where 12 are bookkeeping must read "5", not "17"
|
||||
* followed by an instant plummet to 5 — the countdown is meant to be steady enough to decide
|
||||
* whether to press Skip.
|
||||
*/
|
||||
const queue: Intent['type'][] = [
|
||||
...Array<Intent['type']>(12).fill('draw.end'),
|
||||
...Array<Intent['type']>(5).fill('switch.move'),
|
||||
];
|
||||
assert.equal(queue.length, 17);
|
||||
assert.equal(watchableCount(queue), 5);
|
||||
assert.equal(watchableCount(queue, 0), 0, 'with animation off, nothing is behind');
|
||||
});
|
||||
|
||||
it('offers speeds a player actually reached for, and none the code would clamp', () => {
|
||||
/**
|
||||
* Jesse played a whole game believing he was at 7× and was in fact at 1×: `?pace=` shipped as the
|
||||
* only lever, and `index.html`'s doors are `play.html?lobby` / `play.html?solitaire`, so arriving
|
||||
* from the splash REPLACES the query string. Hence a real control on the play screen, and hence
|
||||
* this ladder — which must reach the speeds people ask for and must not offer one that
|
||||
* `dwellFor` would silently clamp.
|
||||
*/
|
||||
assert.equal(PACE_LEVELS[0], 0, 'off must be the first rung — #18 wants it turned off');
|
||||
assert.ok(PACE_LEVELS.includes(1), 'the default must be on the ladder');
|
||||
assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name');
|
||||
/**
|
||||
* The ceiling is not theoretical. Jesse played at 10× — the top of the ladder as it then was —
|
||||
* and called it "still a bit fast, but followable", so the ladder has to go past the speed
|
||||
* somebody actually reached for and found insufficient.
|
||||
*/
|
||||
assert.ok(PACE_LEVELS.some((p) => p > 10), 'the ladder must go beyond the speed that was too fast');
|
||||
for (const p of PACE_LEVELS) {
|
||||
assert.ok(p <= MAX_PACE, `${p}x is past MAX_PACE, so the control would lie about it`);
|
||||
assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p));
|
||||
}
|
||||
// Strictly increasing, so stepping the control always changes the speed.
|
||||
for (let i = 1; i < PACE_LEVELS.length; i++) {
|
||||
assert.ok(PACE_LEVELS[i]! > PACE_LEVELS[i - 1]!, 'the ladder must be strictly increasing');
|
||||
}
|
||||
// The slowest rung has to be slow enough to be worth having: six switching moves at the top of
|
||||
// the ladder is a full minute, which is the "watch them struggle" case.
|
||||
const slowest = dwellFor('switch.move', PACE_LEVELS[PACE_LEVELS.length - 1]!) * 6;
|
||||
assert.ok(slowest >= 60_000, `the slowest a switching turn can be watched is ${slowest}ms`);
|
||||
});
|
||||
|
||||
it('a silent step beats only when the clock turns over — TODO #18', () => {
|
||||
/**
|
||||
* Both obvious rules were wrong, so both are pinned. "No narration, no dwell" flashed past
|
||||
* phases that moved trains without saying so, killing the very thing #18 asks for. "Anything
|
||||
* that changed the board" beat on every turn hand-off — `submit()` steps `advance()` about 4.6
|
||||
* times per intent — which came to a quarter of an hour a game.
|
||||
*/
|
||||
const silent = { cause: 'phase' as const, player: null, lines: [] as string[] };
|
||||
assert.equal(dwellForStep({ ...silent, frame: { table: { actor: 2 } } }), 0, 'a turn hand-off shows nothing');
|
||||
assert.equal(dwellForStep({ ...silent, frame: { table: {} } }), 0, 'a step that changed nothing shows nothing');
|
||||
assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), DWELL.phase);
|
||||
assert.equal(dwellForStep({ ...silent, frame: { table: { stage: 4 } } }), DWELL.phase);
|
||||
// Narration always earns the dwell of whatever caused it, clock or no clock.
|
||||
assert.equal(
|
||||
dwellForStep({ cause: 'switch.move', player: 1, lines: ['moved'], frame: { table: {} } }),
|
||||
DWELL.switching,
|
||||
);
|
||||
});
|
||||
|
||||
it('the speed control stretches the clock at a THIRD of the rate it stretches people', () => {
|
||||
/**
|
||||
* Two complaints, one from each direction, and the answer is between them.
|
||||
*
|
||||
* v0.8.0.3, from a 5× game: *"after my turn … I'm still subject to that same delay before it
|
||||
* moves on. That makes no sense."* — phases were scaling with everything else and walling off a
|
||||
* player's own turn. So they were pinned at their tabled beat.
|
||||
*
|
||||
* v0.8.0.7, from a 10× game: *"phases displayed on the upper line go by too quickly still.
|
||||
* Should be 4 times as long — at a guess."* — pinned was too short to read the caption.
|
||||
*
|
||||
* Damped scaling satisfies both: 1× unchanged, 10× lands exactly on the four-times guess, and
|
||||
* the cost stays bounded because phase beats cluster rather than accumulate.
|
||||
*/
|
||||
const phase = { cause: 'phase' as const, player: null, lines: ['New Train'], frame: { table: { phase: 'newTrain' } } };
|
||||
const theirs = { cause: 'switch.move' as const, player: 1, lines: ['moved'], frame: { table: {} } };
|
||||
|
||||
assert.equal(dwellForStep(phase, 1), DWELL.phase, '1x must be exactly the tabled beat');
|
||||
assert.equal(dwellForStep(phase, 10), DWELL.phase * 4, '10x must be four times it, as asked for');
|
||||
|
||||
for (const pace of [2, 3, 5, 7, 10, 15, 20]) {
|
||||
const p = dwellForStep(phase, pace);
|
||||
const t = dwellForStep(theirs, pace);
|
||||
assert.ok(p > DWELL.phase, `a phase must grow at ${pace}x`);
|
||||
assert.ok(
|
||||
p < DWELL.phase * pace,
|
||||
`a phase must grow SLOWER than the multiplier at ${pace}x, or the clock walls off the turn`,
|
||||
);
|
||||
assert.ok(t > p, `somebody's move must still outlast a phase beat at ${pace}x`);
|
||||
}
|
||||
// Off still means off, for the clock as much as for anybody's move; and below 1x the clock
|
||||
// follows the multiplier straight, because "faster" should mean everything.
|
||||
assert.equal(dwellForStep(phase, 0), 0);
|
||||
assert.equal(dwellForStep(theirs, 0), 0);
|
||||
assert.equal(dwellForStep(phase, 0.5), DWELL.phase * 0.5);
|
||||
});
|
||||
|
||||
it('a real switching turn is watchable in a few seconds, not tens of them', () => {
|
||||
// Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary
|
||||
// case for one crew: the announcement, six moves, and an end that shows nothing.
|
||||
const turn: Intent['type'][] = [
|
||||
'localOps.choose',
|
||||
...Array<Intent['type']>(6).fill('switch.move'),
|
||||
'switch.end',
|
||||
];
|
||||
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
|
||||
assert.equal(total, DWELL.action + 6 * DWELL.switching);
|
||||
assert.ok(total > 5_000 && total < 10_000, `a switching turn takes ${total}ms to watch`);
|
||||
assert.equal(watchableCount(turn), 7, 'the six moves and the announcement; not the end');
|
||||
});
|
||||
|
||||
it("a bot's ordinary turn is followable, which is what the first real play was not", () => {
|
||||
/**
|
||||
* MEASURED FROM A REAL GAME, then pinned. Jesse, after installing v0.8.0 on the test server:
|
||||
* *"bot play was way too fast. I briefly saw that it was the bot's office area then their turn
|
||||
* was done."* This is the shape that turn actually had — no switching in it at all, because
|
||||
* switching is not legal until there is track down — and under the original values it came to
|
||||
* 750ms for the whole thing.
|
||||
*/
|
||||
const turn: Intent['type'][] = [
|
||||
'localOps.choose',
|
||||
'draw.fromHomeOffice',
|
||||
'card.play',
|
||||
'draw.end',
|
||||
'localOps.choose',
|
||||
'freightAgent.stockOutbound',
|
||||
];
|
||||
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
|
||||
assert.ok(total >= 3_000, `an ordinary bot turn is only ${total}ms — too fast to follow`);
|
||||
assert.equal(watchableCount(turn), 5, 'only the turn-ending bookkeeping is free');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* THE SEATLESS PUBLIC DELTA — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 3.
|
||||
*
|
||||
* The property that matters is RECONSTRUCTION: a receiver that started from one full frame and
|
||||
* merged every delta since must hold exactly what a fresh `publicSnapshot()` would give it. A delta
|
||||
* scheme that is merely smaller is worthless if the two sides drift, and the drift would show up as
|
||||
* a board that is subtly wrong rather than as an error.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { pump } from '../src/engine/advance.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
|
||||
import { applyIntent } from '../src/engine/apply.ts';
|
||||
import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts';
|
||||
import type { PublicFrame } from '../src/sim/view.ts';
|
||||
import { applyPublicDelta, changedPiles, deltaPublicFrame } from '../src/sim/public-delta.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'competitive',
|
||||
days: 5,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
|
||||
function newState(seed: number, players = 3, rotation = false): GameState {
|
||||
const s = createGame({
|
||||
id: `delta-${seed}`,
|
||||
seed,
|
||||
config: rotation
|
||||
? { ...config, optionalRules: { ...config.optionalRules, employeeRotation: true } }
|
||||
: config,
|
||||
playerNames: Array.from({ length: players }, (_, i) => `p${i}`),
|
||||
});
|
||||
pump(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Plays one legal action, preferring a switch move so districts actually change between frames. */
|
||||
function step(s: GameState, actor: PlayerIndex): boolean {
|
||||
const options = legalActions(s, actor);
|
||||
if (options.length === 0) return false;
|
||||
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||
const chosen = move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!;
|
||||
const r = applyIntent(s, actor, chosen);
|
||||
if (!r.ok) return false;
|
||||
pump(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
describe('public frame delta', () => {
|
||||
it('reconstructs exactly what a fresh projection produces, over a long chain', () => {
|
||||
for (const seed of [1917398, 4242]) {
|
||||
const s = newState(seed);
|
||||
let sent: PublicFrame | null = null;
|
||||
let held: PublicFrame | null = null;
|
||||
let steps = 0;
|
||||
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const actor = currentActorOfState(s);
|
||||
if (actor === null) break;
|
||||
if (!step(s, actor)) break;
|
||||
|
||||
const next = publicSnapshot(s);
|
||||
const delta = deltaPublicFrame(sent, next);
|
||||
held = applyPublicDelta(held, delta);
|
||||
sent = next;
|
||||
steps++;
|
||||
|
||||
assert.deepEqual(
|
||||
held,
|
||||
next,
|
||||
`merged frame drifted from a fresh projection at step ${steps} (seed ${seed})`,
|
||||
);
|
||||
}
|
||||
assert.ok(steps > 20, `only ${steps} steps for seed ${seed} — the chain proved little`);
|
||||
}
|
||||
});
|
||||
|
||||
it('sends a district board only when that district changed', () => {
|
||||
const s = newState(1917398);
|
||||
const first = publicSnapshot(s);
|
||||
// Nothing has moved, so a delta against an identical frame must null every board.
|
||||
const idle = deltaPublicFrame(first, publicSnapshot(s));
|
||||
assert.equal(idle.division, null, 'the Division was unchanged and must not be resent');
|
||||
assert.equal(idle.districts.length, 0, 'an unchanged district must be omitted, not sent as nulls');
|
||||
assert.deepEqual(idle.table, {}, 'an unchanged table must send no fields at all');
|
||||
|
||||
// Now move one player. Only that seat's board may be sent — this is the whole point of keying
|
||||
// the delta by seat rather than comparing `districts` as one array.
|
||||
let moved: PublicIndexed | null = null;
|
||||
for (let i = 0; i < 200 && moved === null; i++) {
|
||||
const actor = currentActorOfState(s);
|
||||
if (actor === null) break;
|
||||
const before = publicSnapshot(s);
|
||||
if (!step(s, actor)) break;
|
||||
const after = publicSnapshot(s);
|
||||
const changed = after.districts.filter(
|
||||
(d) => JSON.stringify(d.cells) !== JSON.stringify(before.districts.find((b) => b.seat === d.seat)?.cells),
|
||||
);
|
||||
if (changed.length === 1) moved = { seat: changed[0]!.seat, before, after };
|
||||
}
|
||||
assert.ok(moved !== null, 'no single-district change occurred, so this test proved nothing');
|
||||
|
||||
const delta = deltaPublicFrame(moved.before, moved.after);
|
||||
assert.equal(delta.districts.length, 1, 'only the district that changed may be sent');
|
||||
assert.equal(delta.districts[0]!.seat, moved.seat);
|
||||
assert.notEqual(delta.districts[0]!.cells, null, 'the district that changed must carry its board');
|
||||
});
|
||||
|
||||
it('a step that changes one field sends one field — the reason this is a partial', () => {
|
||||
/**
|
||||
* MEASURED, not assumed. The first version spread the whole frame and nulled only the boards, so
|
||||
* a step whose sole change was whose turn it is still shipped all 35 top-level properties. Once
|
||||
* TODO #18 gave automatic phases their own steps, most steps became exactly that, and a full game
|
||||
* cost 19.4 MB of which 16.7 MB was those. This is the guard against that returning.
|
||||
*/
|
||||
const s = newState(1917398);
|
||||
const before = publicSnapshot(s);
|
||||
const full = JSON.stringify(deltaPublicFrame(null, before)).length;
|
||||
|
||||
// Hand the turn on without touching a board, which is what an automatic phase mostly does.
|
||||
const after = { ...before, actor: ((before.actor ?? 0) + 1) as PlayerIndex };
|
||||
const delta = deltaPublicFrame(before, after);
|
||||
assert.deepEqual(Object.keys(delta.table), ['actor'], 'only the field that changed may be sent');
|
||||
assert.equal(delta.districts.length, 0);
|
||||
assert.equal(delta.division, null);
|
||||
|
||||
const size = JSON.stringify(delta).length;
|
||||
assert.ok(size < 120, `a one-field delta serialised to ${size} bytes`);
|
||||
assert.ok(size * 100 < full, `a one-field delta (${size}B) is not much smaller than a full frame (${full}B)`);
|
||||
});
|
||||
|
||||
it('always carries seat, player and name, so Employee Rotation cannot be missed', () => {
|
||||
// Rotation moves players between districts, so the seat→player pairing is itself news. Those
|
||||
// fields are small and are never nulled; the boards they label are what the delta saves.
|
||||
const s = newState(777, 3, true);
|
||||
const a = publicSnapshot(s);
|
||||
// A full frame carries every district, each labelled — that is what a receiver matches on later.
|
||||
const full = deltaPublicFrame(null, a);
|
||||
assert.equal(full.districts.length, a.districts.length);
|
||||
for (const d of full.districts) {
|
||||
assert.equal(typeof d.seat, 'number');
|
||||
assert.equal(typeof d.player, 'number');
|
||||
assert.ok(typeof d.name === 'string' && d.name.length > 0, 'every district must stay labelled');
|
||||
}
|
||||
// And a district sent at all always carries its labels, even when only its board moved: rotation
|
||||
// makes the seat→player pairing news in its own right.
|
||||
const rotated = { ...a, districts: a.districts.map((d, i) => (i === 0 ? { ...d, player: ((d.player + 1) % 3) as PlayerIndex } : d)) };
|
||||
const delta = deltaPublicFrame(a, rotated);
|
||||
assert.equal(delta.districts.length, 1, 'a relabelled district must be sent even with no board change');
|
||||
assert.equal(typeof delta.districts[0]!.player, 'number');
|
||||
});
|
||||
|
||||
it('a first frame is sent whole', () => {
|
||||
const s = newState(4242);
|
||||
const full = deltaPublicFrame(null, publicSnapshot(s));
|
||||
assert.notEqual(full.division, null);
|
||||
for (const d of full.districts) {
|
||||
assert.notEqual(d.cells, null, `seat ${d.seat} must be sent in full on a first frame`);
|
||||
assert.notEqual(d.facilities, null);
|
||||
}
|
||||
// And it merges with no previous frame at all.
|
||||
assert.deepEqual(applyPublicDelta(null, full), publicSnapshot(s));
|
||||
});
|
||||
|
||||
it('refuses to merge an "unchanged" board it has nothing to merge onto', () => {
|
||||
// A sender whose bookkeeping has drifted would otherwise hand a player a blank district.
|
||||
const s = newState(4242);
|
||||
const a = publicSnapshot(s);
|
||||
const unchanged = deltaPublicFrame(a, publicSnapshot(s));
|
||||
assert.throws(() => applyPublicDelta(null, unchanged), /no previous frame to merge onto/);
|
||||
});
|
||||
});
|
||||
|
||||
type PublicIndexed = { seat: number; before: PublicFrame; after: PublicFrame };
|
||||
|
||||
describe('which piles a step moved', () => {
|
||||
/**
|
||||
* MEASURED FROM REAL PLAY, then pinned. The table in `changedPiles` claims what each action moves,
|
||||
* and a claim in a comment is worth nothing unless something checks it — so this drives real games
|
||||
* and asserts the mapping holds, action by action.
|
||||
*/
|
||||
it('maps each action to the piles it actually touches', () => {
|
||||
const seen = new Map<string, Set<string>>();
|
||||
/**
|
||||
* TWO PASSES, because a single driver cannot reach every case. Left to itself the bot almost
|
||||
* never takes a Department card, and a driver that prefers one then never draws from the deck —
|
||||
* so each preference is played out separately and the assertions below require BOTH to have
|
||||
* been observed rather than passing on whichever happened to occur.
|
||||
*/
|
||||
for (const prefer of ['draw.fromDepartment', 'draw.fromHomeOffice'] as const) {
|
||||
for (const seed of [1917398, 191056, 4242]) {
|
||||
const s = newState(seed);
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const actor = currentActorOfState(s);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(s, actor);
|
||||
if (options.length === 0) break;
|
||||
const chosen =
|
||||
options.find((o) => o.type === prefer) ??
|
||||
options.find((o) => o.type === 'card.discard') ??
|
||||
options[i % options.length]!;
|
||||
const before = publicSnapshot(s);
|
||||
const r = applyIntent(s, actor, chosen);
|
||||
if (!r.ok) break;
|
||||
pump(s);
|
||||
const piles = changedPiles(before, publicSnapshot(s)).map((p) => p.replace(/dept\d/, 'dept'));
|
||||
if (!seen.has(chosen.type)) seen.set(chosen.type, new Set());
|
||||
for (const p of piles) seen.get(chosen.type)!.add(p);
|
||||
if (piles.length === 0) seen.get(chosen.type)!.add('(none)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const of = (t: string): Set<string> => seen.get(t) ?? new Set();
|
||||
// NOT VACUOUS: the four cases the mapping is actually about must all have happened.
|
||||
for (const needed of ['draw.fromHomeOffice', 'draw.fromDepartment', 'card.discard', 'card.play']) {
|
||||
assert.ok(of(needed).size > 0, `${needed} never occurred, so its rule proved nothing`);
|
||||
}
|
||||
|
||||
// A HOME OFFICE DRAW MOVES THE COUNT AND NOTHING ELSE ON A PILE. The card is private; the deck
|
||||
// getting shorter is not, and it is the only thing a watcher can be shown.
|
||||
assert.deepEqual([...of('draw.fromHomeOffice')].sort(), ['home']);
|
||||
// A DEPARTMENT DRAW touches that Department, and sometimes the deck too — the pile refills from
|
||||
// it. Both are public, so both may light.
|
||||
for (const p of of('draw.fromDepartment')) {
|
||||
assert.ok(p === 'dept' || p === 'home', `a Department draw moved "${p}"`);
|
||||
}
|
||||
assert.ok(of('draw.fromDepartment').has('dept'), 'a Department draw must light its Department');
|
||||
// A DISCARD lands face up on a Department, and which one is public.
|
||||
assert.deepEqual([...of('card.discard')].sort(), ['dept']);
|
||||
// A PLAYED CARD that does not stay on the board lands face up in the Salvage Yard.
|
||||
assert.ok(of('card.play').has('salvage'), 'a played card must be able to light the Salvage Yard');
|
||||
// ENDING A PHASE moves no card anywhere, so nothing should light for it.
|
||||
for (const quiet of ['draw.end', 'loadUnload.end', 'switch.end', 'localOps.choose']) {
|
||||
if (of(quiet).size > 0) assert.deepEqual([...of(quiet)], ['(none)'], `${quiet} lit a pile`);
|
||||
}
|
||||
});
|
||||
|
||||
it('lights nothing without a previous frame to compare against', () => {
|
||||
// A reset has nothing to have watched arriving, so nothing on it is lit.
|
||||
const s = newState(4242);
|
||||
assert.deepEqual(changedPiles(null, publicSnapshot(s)), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a Mainline card that changed under the players (Gitea#28)', () => {
|
||||
it('names the node a Realignment converted, and nothing else', async () => {
|
||||
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
|
||||
const { REALIGNMENTS } = await import('../src/engine/content.ts');
|
||||
|
||||
const s = newState(4242);
|
||||
const before = publicSnapshot(s);
|
||||
|
||||
// Realignment converts a card to another kind (`content.ts`'s table). Applied to the state directly:
|
||||
// what is being tested is the DETECTOR, not the play that reaches it — which needs the card in hand,
|
||||
// the draw option taken and no train on the card.
|
||||
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline' && REALIGNMENTS.some((r) => r.from === n.card));
|
||||
assert.ok(at >= 0, 'no Mainline card in this Division can be realigned at all');
|
||||
const node = s.division.nodes[at];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
if (node?.kind === 'mainline') {
|
||||
node.card = REALIGNMENTS.find((r) => r.from === node.card)!.to;
|
||||
}
|
||||
const after = publicSnapshot(s);
|
||||
|
||||
assert.deepEqual(changedDivisionCards(before, after), [at], 'the realigned card was not the one reported');
|
||||
assert.deepEqual(changedDivisionCards(after, after), [], 'an unchanged Division reported a change');
|
||||
assert.deepEqual(changedDivisionCards(null, after), [], 'a first board has nothing to compare against');
|
||||
});
|
||||
|
||||
it('says nothing when only the trains on a card moved', async () => {
|
||||
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
|
||||
const s = newState(1917398);
|
||||
const before = publicSnapshot(s);
|
||||
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||
const node = s.division.nodes[at];
|
||||
if (node?.kind === 'mainline') {
|
||||
node.transits.push({ tray: 'tray1', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||||
}
|
||||
assert.deepEqual(changedDivisionCards(before, publicSnapshot(s)), [], 'a train arriving flashed the card');
|
||||
});
|
||||
});
|
||||
+104
-13
@@ -244,11 +244,67 @@ describe('redaction — the shared narration log never carries a seat\'s secrets
|
||||
describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
const names = ['Ann', 'Bob', 'Cy'];
|
||||
|
||||
/** Everything one seat can see, as one string: their Frame, the public board, and their lines. */
|
||||
const everythingSeatSees = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): string =>
|
||||
JSON.stringify(snapshot(g.state, g.log, null, null, null, false, seat)) +
|
||||
'\n' + JSON.stringify(publicSnapshot(g.state)) +
|
||||
'\n' + g.log.map((l) => l.text).join('\n');
|
||||
/**
|
||||
* Everything one seat can see, split into the two halves the checks below treat differently.
|
||||
*
|
||||
* `structural` is the machine-readable state: their Frame, the public board, and the frame of every
|
||||
* presentation step they are sent (v0.8.0, TODO #13). `narration` is what the table was TOLD.
|
||||
*
|
||||
* Steps are folded in here rather than given a test of their own so every case below covers them:
|
||||
* the blind draw, the pending decision, Employee Rotation before and after the seating moves, and
|
||||
* the played-out game. Their `lines` are a slice of `g.log` by construction, so the log covers the
|
||||
* narration half of a step and does not need to be searched twice.
|
||||
*/
|
||||
const everythingSeatSees = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): {
|
||||
structural: string;
|
||||
history: string;
|
||||
narration: string[];
|
||||
} => ({
|
||||
/**
|
||||
* `[]` for the Frame's own lines, MATCHING PRODUCTION. `frameFor()` (`server/session.ts`) has
|
||||
* passed no log since #97 — narration goes out incrementally through `Push.lines` instead — so
|
||||
* embedding it here audits a path that no longer exists, and worse, it puts the whole log inside
|
||||
* `structural` where the face-up-pile rule below cannot reach it. The log is audited in full as
|
||||
* `narration`; this is a de-duplication, not a relaxation.
|
||||
*/
|
||||
structural:
|
||||
JSON.stringify(snapshot(g.state, [], null, null, null, false, seat)) +
|
||||
'\n' + JSON.stringify(publicSnapshot(g.state)),
|
||||
/**
|
||||
* THE STEP FRAMES ARE A RECORD OF WHAT WAS PUBLIC OVER TIME, not a view of the position now —
|
||||
* so they get the PRECISE check and not the fuzzy one, for the same reason the face-up-pile
|
||||
* lines do.
|
||||
*
|
||||
* Every one is built by `deltaPublicFrame` over `publicSnapshot`, which the allow-list test at
|
||||
* the bottom of this file pins property by property; that is what guarantees a step frame is
|
||||
* clean. Searching their accumulation for a card NAME asks "was this ever public?" and answers
|
||||
* a question nobody was posing: Train 6 sat face-up in a Department at step 40 and is in Ann's
|
||||
* hand at step 120, and both facts are correct. A card ID is different — narration never renders
|
||||
* one and no public field carries an opponent's, so finding one anywhere is still proof.
|
||||
*/
|
||||
history: JSON.stringify(g.display.steps.map((step) => step.frame)),
|
||||
narration: g.log.map((l) => l.text),
|
||||
});
|
||||
|
||||
/**
|
||||
* A FACE-UP PILE IS ALLOWED TO NAME THE CARD ON IT, and the log is history rather than a view.
|
||||
*
|
||||
* §2.6: the three Department piles and the Salvage Yard are face up, "so players can audit
|
||||
* discards" — a discard goes onto one precisely so a rival can take it. So "Player Ann discarded
|
||||
* Train 6 face-up on top of Department 3" is the record working, and it stays in the log after Ann
|
||||
* takes the card back into her hand. The name-based check below would otherwise read that historical
|
||||
* line as proof of what Ann is holding NOW, which is how it reported a leak against correct code on
|
||||
* seed 1917398.
|
||||
*
|
||||
* These lines are excluded from the NAME check only. The card-id check and the seed check still run
|
||||
* over them, because those are precise: an id is unique, so finding one is proof, and narration
|
||||
* never renders a raw id.
|
||||
*
|
||||
* **This does not weaken the blind-draw detection**, which is the leak this whole net was built
|
||||
* for (v0.7.9.2, "Red Flags"): a blind draw names the HOME OFFICE DECK, which is face down and
|
||||
* matches nothing here.
|
||||
*/
|
||||
const namesAFaceUpPile = (line: string): boolean => /Department|Salvage/i.test(line);
|
||||
|
||||
/**
|
||||
* Every secret belonging to somebody OTHER than `seat`: their card ids, and the names those ids
|
||||
@@ -265,8 +321,14 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
* This is what caught the blind-draw leak in v0.7.9.2: "Red Flags" was in exactly one hand, and it
|
||||
* was in the log.
|
||||
*/
|
||||
const secretsOfOthers = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): { what: string; value: string }[] => {
|
||||
const out: { what: string; value: string }[] = [];
|
||||
const secretsOfOthers = (
|
||||
g: ReturnType<typeof newMultiplayerGame>,
|
||||
seat: PlayerIndex,
|
||||
): { what: string; value: string; precise: boolean }[] => {
|
||||
// `precise` marks evidence that is proof on its own — a card id is unique, so finding one
|
||||
// anywhere is a leak. A NAME is circumstantial and is searched over a narrower string; see
|
||||
// `namesAFaceUpPile`.
|
||||
const out: { what: string; value: string; precise: boolean }[] = [];
|
||||
// How many cards in the whole game carry each name, and how many of those are in a given hand.
|
||||
const totalByName = new Map<string, number>();
|
||||
for (const id of g.state.cards.keys()) {
|
||||
@@ -282,10 +344,10 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
heldByName.set(n, (heldByName.get(n) ?? 0) + 1);
|
||||
}
|
||||
for (const id of hand) {
|
||||
out.push({ what: `${p.name}'s card id`, value: id });
|
||||
out.push({ what: `${p.name}'s card id`, value: id, precise: true });
|
||||
const name = cardName(g.state, id);
|
||||
if (totalByName.get(name) === heldByName.get(name)) {
|
||||
out.push({ what: `${p.name}'s card name, unique to their hand`, value: name });
|
||||
out.push({ what: `${p.name}'s card name, unique to their hand`, value: name, precise: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,15 +357,19 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
/** Runs the whole net over one state, and says which state failed if it does. */
|
||||
const audit = (g: ReturnType<typeof newMultiplayerGame>, where: string): void => {
|
||||
for (const seat of g.state.players.map((p) => p.index)) {
|
||||
const seen = everythingSeatSees(g, seat);
|
||||
for (const { what, value } of secretsOfOthers(g, seat)) {
|
||||
const { structural, history, narration } = everythingSeatSees(g, seat);
|
||||
const everything = structural + '\n' + history + '\n' + narration.join('\n');
|
||||
// Names are fuzzy evidence, so they are searched everywhere EXCEPT the lines a face-up pile
|
||||
// is entitled to name a card on. Ids are precise and are searched everywhere.
|
||||
const forNames = structural + '\n' + narration.filter((l) => !namesAFaceUpPile(l)).join('\n');
|
||||
for (const { what, value, precise } of secretsOfOthers(g, seat)) {
|
||||
assert.ok(
|
||||
!seen.includes(value),
|
||||
!(precise ? everything : forNames).includes(value),
|
||||
`${where}: seat ${seat} can see ${what} ("${value}")`,
|
||||
);
|
||||
}
|
||||
// The seed is the whole future of the deal and must not reach a seat by any route.
|
||||
assert.ok(!seen.includes(String(g.seed)), `${where}: seat ${seat} can see the seed ${g.seed}`);
|
||||
assert.ok(!everything.includes(String(g.seed)), `${where}: seat ${seat} can see the seed ${g.seed}`);
|
||||
}
|
||||
// And the spectator board, which has no seat and is therefore entitled to nothing private.
|
||||
const pub = JSON.stringify(publicSnapshot(g.state));
|
||||
@@ -346,6 +412,28 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
audit(g, 'after a blind draw');
|
||||
});
|
||||
|
||||
it('the net actually sees the presentation steps it claims to cover (v0.8.0)', () => {
|
||||
/**
|
||||
* Guards the COVERAGE, not the code. `everythingSeatSees` folds `display.steps` into the string
|
||||
* every case above is audited against — which is worth nothing if that array is empty in
|
||||
* practice. So: play a real game, and assert both that steps accumulated and that the audited
|
||||
* string contains them.
|
||||
*/
|
||||
const g = newMultiplayerGame(1917398, config, names);
|
||||
play(g, 120);
|
||||
assert.ok(g.display.steps.length > 20, `only ${g.display.steps.length} steps — the net covers little`);
|
||||
const { history, narration } = everythingSeatSees(g, 0 as PlayerIndex);
|
||||
assert.ok(
|
||||
history.includes(JSON.stringify(g.display.steps.map((step) => step.frame))),
|
||||
'the audited string does not actually contain the step frames',
|
||||
);
|
||||
// And a step's own narration is a slice of the log, so the log half covers it.
|
||||
const fromSteps = g.display.steps.flatMap((step) => step.lines.map((l) => l.text));
|
||||
assert.ok(fromSteps.length > 0, 'the steps carried no narration to cover');
|
||||
assert.ok(fromSteps.every((t) => narration.includes(t)), 'a step said something the log did not');
|
||||
audit(g, 'a played game with presentation steps');
|
||||
});
|
||||
|
||||
it('mid-game, with real hands and a built board', () => {
|
||||
// A DISTINCTIVE seed, deliberately. Seed 7 makes the seed check meaningless — "7" is in "Train
|
||||
// 7", in every coordinate and in half the numbers on the board — so it reported a leak that was
|
||||
@@ -436,6 +524,9 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
// The rules the game was dealt under, and the score.
|
||||
'houseRules', 'mode', 'optionalRules', 'days', 'minCombinedRevenue',
|
||||
'maxCollisionsPerDay', 'maxCollisionsTotal', 'collisionsToday', 'collisionsTotal',
|
||||
// What the Day that just ended finished on. Public for the same reason the running counts are:
|
||||
// a collision happens on the Mainline in front of everybody.
|
||||
'collisionsPrevDay',
|
||||
'status', 'outcome', 'extraDays', 'extensionVotes', 'official', 'tally',
|
||||
// Names, seats, revenue and HAND SIZE — never hand contents.
|
||||
'players',
|
||||
|
||||
+99
-11
@@ -6,6 +6,9 @@
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { pump } from '../src/engine/advance.ts';
|
||||
@@ -41,10 +44,14 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'stageBegan', day: 1, stage: 7 },
|
||||
{ type: 'phaseBegan', phase: 'mainline' },
|
||||
{ type: 'actorChanged', player: 0 },
|
||||
// Sampled rather than left to swell the unsampled count: this sentence is one a player reads at
|
||||
// the table every third Stage, so its text is worth exercising.
|
||||
{ type: 'superintendentChanged', player: 1, stage: 6 },
|
||||
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
||||
{ type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
||||
{ type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
||||
{ type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
|
||||
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5, movesAllowed: 6 },
|
||||
{ type: 'switchingEnded', player: 0, movesUsed: 3, movesAllowed: 6, lastMove: { trayId: 't0', to: { row: 0, col: 1 } } },
|
||||
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
||||
{ type: 'carsDropped', player: 0, trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
|
||||
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
|
||||
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
|
||||
{ type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' },
|
||||
@@ -55,8 +62,18 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'inboundCleared', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 },
|
||||
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false } },
|
||||
{ type: 'carPassed', player: 0, trayId: 't0' },
|
||||
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false }, trainNumber: 10, isExtra: false },
|
||||
{ type: 'carPassed', player: 0, trayId: 't0', trainNumber: 10, isExtra: false },
|
||||
{
|
||||
type: 'makeUpShort',
|
||||
trainNumber: 5,
|
||||
isExtra: false,
|
||||
placed: 0,
|
||||
wanted: 3,
|
||||
missing: ['coach'],
|
||||
waiting: 14,
|
||||
divisionYardHolds: 46,
|
||||
},
|
||||
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
|
||||
{ type: 'clearanceGiven', trainId: 't1', allow: false },
|
||||
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
|
||||
@@ -72,12 +89,56 @@ const SAMPLES: GameEvent[] = [
|
||||
|
||||
describe('narration', () => {
|
||||
it('covers every event type the engine can emit', () => {
|
||||
// Guards against a new event type slipping in unnarrated.
|
||||
const covered = new Set(SAMPLES.map((e) => e.type));
|
||||
const declared = new Set<string>();
|
||||
for (const e of SAMPLES) declared.add(e.type);
|
||||
assert.equal(covered.size, 30, 'sample list is out of step with GameEvent');
|
||||
assert.equal(declared.size, 30);
|
||||
/**
|
||||
* THIS TEST USED TO BUILD BOTH SETS FROM `SAMPLES` and compare them to each other, so it could
|
||||
* only ever assert that the sample list had 30 distinct entries — the one thing it could not
|
||||
* detect was the thing its comment promised, a new `GameEvent` slipping in unnarrated. Fixed
|
||||
* 2026-09-09 while adding the v0.8.0 step collector, which made the event union load-bearing for
|
||||
* a second reader.
|
||||
*
|
||||
* The union is read out of `src/engine/events.ts` rather than hand-listed, the same way
|
||||
* `test/pacing.test.ts` reads the intent union: a list maintained by hand is a list that goes
|
||||
* stale, which is how this got here.
|
||||
*/
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const declared = new Set(
|
||||
[...readFileSync(join(here, '../src/engine/events.ts'), 'utf8').matchAll(/type: '([a-zA-Z]+)'/g)]
|
||||
.map((m) => m[1]!),
|
||||
);
|
||||
const narrated = new Set(
|
||||
[...readFileSync(join(here, '../src/sim/narrate.ts'), 'utf8').matchAll(/case '([a-zA-Z]+)':/g)]
|
||||
.map((m) => m[1]!),
|
||||
);
|
||||
assert.ok(declared.size > 40, `only ${declared.size} event types parsed — the parse is wrong`);
|
||||
|
||||
// THE INVARIANT THAT MATTERS: an event the engine can emit and `narrate` has no case for falls
|
||||
// through to a placeholder, in front of a player. This is the check the old version promised.
|
||||
const unnarrated = [...declared].filter((t) => !narrated.has(t));
|
||||
assert.deepEqual(unnarrated, [], 'these event types can be emitted and have no narration case');
|
||||
|
||||
const covered = new Set<string>(SAMPLES.map((e) => e.type));
|
||||
const unknown = [...covered].filter((t) => !declared.has(t));
|
||||
assert.deepEqual(unknown, [], 'these samples name an event the engine no longer declares');
|
||||
|
||||
/**
|
||||
* THE KNOWN GAP, PINNED SO IT CANNOT GROW.
|
||||
*
|
||||
* `SAMPLES` exercises the TEXT of 30 of the 55 declared events; the other 25 have a narration
|
||||
* case (checked above) but no sample, so nothing proves their sentence is any good. Found
|
||||
* 2026-09-09 — the old test built both of its sets from `SAMPLES` and compared them to each
|
||||
* other, so it could only ever assert that the sample list had 30 distinct entries, and the one
|
||||
* thing it could not detect was the thing its comment promised.
|
||||
*
|
||||
* Pinned rather than fixed: writing 25 fixtures is a job of its own, and a bad sentence is worth
|
||||
* finding deliberately rather than in a rush. What this does guarantee is that a NEW event type
|
||||
* cannot join the unsampled set silently.
|
||||
*/
|
||||
const unsampled = [...declared].filter((t) => !covered.has(t)).sort();
|
||||
assert.equal(
|
||||
unsampled.length,
|
||||
25,
|
||||
`the unsampled set changed (${unsampled.length}): add a sample for a new event, or update this count`,
|
||||
);
|
||||
});
|
||||
|
||||
it('gives every event a specific, non-empty sentence', () => {
|
||||
@@ -99,6 +160,33 @@ describe('narration', () => {
|
||||
assert.match(loss.text, /COLLISION/i);
|
||||
});
|
||||
|
||||
/**
|
||||
* WHOSE OFFICE, AND WHOSE TRAIN — Jesse, playtest 2026-09-16.
|
||||
*
|
||||
* The line went to every seat reading "ARRIVED at the Whistle Post … You can work it in Cargo now".
|
||||
* At a table of four that is one true sentence and one false one: every seat has an Office, so the
|
||||
* tier alone does not say which district the train is standing in, and three of the four readers
|
||||
* cannot touch it. Constructed here rather than fished out of a game so both halves are pinned
|
||||
* exactly, and the resolver-less path is checked too — the replay viewers pass no names for a
|
||||
* table they do not have.
|
||||
*/
|
||||
it('names WHOSE Office a train reached, and never tells the table they can work it', () => {
|
||||
const named = narrate(
|
||||
{ type: 'trainArrived', trainNumber: 3, consist: [], office: 'Whistle Post', owner: 1, expedited: false },
|
||||
{ playerName: () => 'Tom' },
|
||||
);
|
||||
assert.match(named.text, /Tom's Whistle Post/, `the arrival did not name the Office's owner: ${named.text}`);
|
||||
assert.match(named.text, /Tom can work it/, `the arrival did not say whose train it is to work: ${named.text}`);
|
||||
assert.doesNotMatch(named.text, /\bYou can work it\b/i, `the arrival still addresses every reader: ${named.text}`);
|
||||
|
||||
// No resolver — still English, and still no raw index leaking into a sentence.
|
||||
const anon = narrate({
|
||||
type: 'trainArrived', trainNumber: 3, consist: [], office: 'Whistle Post', owner: 1, expedited: false,
|
||||
});
|
||||
assert.match(anon.text, /at the Whistle Post/, anon.text);
|
||||
assert.doesNotMatch(anon.text, /undefined|\bplayer \d+\b/i, anon.text);
|
||||
});
|
||||
|
||||
it('points at the board cell where something happened', () => {
|
||||
const n = narrate({ type: 'loadCompleted', player: 0, at: { row: 1, col: 2 }, carType: 'hopper' });
|
||||
assert.deepEqual(n.where, { row: 1, col: 2 });
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The engine's speed-ups must not change a single game (2026-09-15).
|
||||
*
|
||||
* `applyIntent` became `prepareIntent` (check and execute, sharing one walk of the position's routes)
|
||||
* followed by `commitEvents` (reduce and tally), so the switching planner can decide every candidate
|
||||
* against one position and apply each to a copy. Two properties hold that together:
|
||||
*
|
||||
* 1. `prepareIntent` never writes the state it reads — including through the route cache it opens.
|
||||
* 2. Preparing on one state and committing to an EQUAL copy lands on exactly what `applyIntent` does.
|
||||
*
|
||||
* Checked at every decision of seeded bot games rather than on hand-built positions, so the intents
|
||||
* exercised are the ones real play submits.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, commitEvents, prepareIntent } from '../src/engine/apply.ts';
|
||||
import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
collectiveRevenueFloor,
|
||||
lengthProfile,
|
||||
} from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||
|
||||
const config = (): GameConfig => {
|
||||
const days = lengthProfile('short').days;
|
||||
return {
|
||||
mode: 'solitaire',
|
||||
days,
|
||||
minCombinedRevenue: collectiveRevenueFloor(1, days),
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
};
|
||||
|
||||
const serialise = (s: GameState): string =>
|
||||
JSON.stringify(s, (_k, v) => (v instanceof Map ? [...v] : v instanceof Set ? [...v] : v));
|
||||
|
||||
describe('applyIntent split into prepareIntent and commitEvents', () => {
|
||||
it('prepares without writing, and committing to a copy matches applying in place', () => {
|
||||
let decisions = 0;
|
||||
let rejectedSeen = 0;
|
||||
const s = createGame({ id: 'split-8919', seed: 8919, config: config(), playerNames: ['bot'] });
|
||||
const policy = {
|
||||
name: 'split-probe',
|
||||
choose(st: GameState, player: number, options: ReturnType<typeof legalActions>) {
|
||||
const chosen = developerBot.choose(st, player, options);
|
||||
if (decisions < 400) {
|
||||
decisions++;
|
||||
const before = serialise(st);
|
||||
const prepared = prepareIntent(st, player, chosen);
|
||||
assert.equal(serialise(st), before, `decision ${decisions}: prepareIntent wrote into the state it read`);
|
||||
assert.ok(prepared.ok, `decision ${decisions}: a legal choice was refused by prepareIntent`);
|
||||
|
||||
const viaCommit = structuredClone(st);
|
||||
const viaApply = structuredClone(st);
|
||||
commitEvents(viaCommit, prepared.events);
|
||||
const applied = applyIntent(viaApply, player, chosen);
|
||||
assert.ok(applied.ok);
|
||||
assert.deepEqual(applied.events, prepared.events, `decision ${decisions}: the two paths produced different events`);
|
||||
assert.equal(serialise(viaCommit), serialise(viaApply), `decision ${decisions}: committing to a copy diverged from applying`);
|
||||
|
||||
// A refused intent must come back refused from both paths, with nothing written.
|
||||
const refused = { type: 'switch.end' } as const;
|
||||
const r = prepareIntent(st, player, refused);
|
||||
if (!r.ok) {
|
||||
rejectedSeen++;
|
||||
assert.equal(serialise(st), before);
|
||||
assert.equal(applyIntent(structuredClone(st), player, refused).ok, false);
|
||||
}
|
||||
}
|
||||
return chosen;
|
||||
},
|
||||
};
|
||||
const r = playGame(s, policy, pump);
|
||||
assert.ok(r.finished, 'the probed game did not finish');
|
||||
assert.ok(decisions > 0, 'no decision was probed');
|
||||
assert.ok(rejectedSeen > 0, 'no refused intent was exercised');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Seat recovery codes — Gitea#33.
|
||||
*
|
||||
* The properties worth pinning are the ones that make a code safe to put in a link: it is spendable
|
||||
* exactly once, it stops working on its own, and a bad code is indistinguishable from a spent one.
|
||||
* `now` is a parameter rather than a clock, so expiry is tested without faking timers.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { CLAIM_TTL_MS, createClaimStore } from '../../src/server/claims.ts';
|
||||
|
||||
describe('seat recovery codes', () => {
|
||||
it('mints a code that names the seat it was minted for', () => {
|
||||
const claims = createClaimStore();
|
||||
const { code, expiresAt } = claims.mint('tok-abc', 'game-1', 1000);
|
||||
assert.equal(expiresAt, 1000 + CLAIM_TTL_MS);
|
||||
assert.deepEqual(claims.redeem(code, 1000), { token: 'tok-abc', gameId: 'game-1' });
|
||||
});
|
||||
|
||||
it('spends a code exactly once — a link in a chat log is worth nothing afterwards', () => {
|
||||
const claims = createClaimStore();
|
||||
const { code } = claims.mint('tok-abc', 'game-1', 0);
|
||||
assert.ok(claims.redeem(code, 1));
|
||||
assert.equal(claims.redeem(code, 2), null, 'the same code was accepted twice');
|
||||
});
|
||||
|
||||
it('stops working once its time is up, without anything having to sweep it', () => {
|
||||
const claims = createClaimStore();
|
||||
const { code } = claims.mint('tok-abc', 'game-1', 0);
|
||||
assert.equal(claims.redeem(code, CLAIM_TTL_MS - 1)?.token, 'tok-abc', 'expired early');
|
||||
const again = claims.mint('tok-abc', 'game-1', 0).code;
|
||||
assert.equal(claims.redeem(again, CLAIM_TTL_MS), null, 'a code outlived its expiry');
|
||||
});
|
||||
|
||||
it('answers the same way for unknown, spent and expired codes', () => {
|
||||
const claims = createClaimStore();
|
||||
const { code } = claims.mint('tok-abc', 'game-1', 0);
|
||||
claims.redeem(code, 1);
|
||||
const expired = claims.mint('tok-abc', 'game-1', 0).code;
|
||||
|
||||
assert.equal(claims.redeem('never-existed', 1), null);
|
||||
assert.equal(claims.redeem(code, 1), null);
|
||||
assert.equal(claims.redeem(expired, CLAIM_TTL_MS + 1), null);
|
||||
});
|
||||
|
||||
it('gives every mint its own code', () => {
|
||||
const claims = createClaimStore();
|
||||
const codes = new Set([0, 1, 2, 3, 4].map(() => claims.mint('tok-abc', 'game-1', 0).code));
|
||||
assert.equal(codes.size, 5, 'two mints produced the same code');
|
||||
});
|
||||
|
||||
it('forgets expired codes rather than accumulating them', () => {
|
||||
const claims = createClaimStore();
|
||||
claims.mint('tok-a', 'game-1', 0);
|
||||
claims.mint('tok-b', 'game-1', 0);
|
||||
assert.equal(claims.outstanding(0), 2);
|
||||
assert.equal(claims.outstanding(CLAIM_TTL_MS), 0, 'expired codes were still being held');
|
||||
});
|
||||
|
||||
it('keeps a short-lived code short-lived when asked for one', () => {
|
||||
const claims = createClaimStore();
|
||||
const { code, expiresAt } = claims.mint('tok-abc', 'game-1', 500, 60_000);
|
||||
assert.equal(expiresAt, 60_500);
|
||||
assert.equal(claims.redeem(code, 60_500), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* THE ANIMATION QUEUE — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 5-6.
|
||||
*
|
||||
* Driven against REAL steps from a real game rather than hand-built fixtures, because the properties
|
||||
* that matter are about what actual play produces: a bot's whole switching turn arriving in one
|
||||
* burst, and a backlog that is mostly bookkeeping.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import type { GameConfig } from '../src/engine/state.ts';
|
||||
import { currentActor, newMultiplayerGame, submit } from '../src/web/game.ts';
|
||||
import { publicSnapshot } from '../src/sim/view.ts';
|
||||
import { takeSteps } from '../src/sim/display-step.ts';
|
||||
import type { DisplayStep } from '../src/sim/display-step.ts';
|
||||
import { actorOnScreen, createStepQueue } from '../src/web/step-queue.ts';
|
||||
import { DWELL } from '../src/sim/pacing.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'competitive',
|
||||
days: 5,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Plays a real game and returns its steps, preferring switch moves so a burst actually occurs.
|
||||
*
|
||||
* 400 moves, not 120: switching is not legal until there is track laid and a train in the district,
|
||||
* and on this seed the first `switch.move` is at move 144. A shorter run produces a queue with no
|
||||
* switching in it at all, which would make the pacing assertions here vacuous.
|
||||
*/
|
||||
function realSteps(seed: number, moves: number): { steps: DisplayStep[]; final: ReturnType<typeof publicSnapshot> } {
|
||||
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||
takeSteps(game.display);
|
||||
const steps: DisplayStep[] = [];
|
||||
for (let i = 0; i < moves; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||
steps.push(...takeSteps(game.display));
|
||||
}
|
||||
return { steps, final: publicSnapshot(game.state) };
|
||||
}
|
||||
|
||||
/** The baseline a queue starts from, matching what a connect push carries. */
|
||||
function baseline(seed: number): ReturnType<typeof publicSnapshot> {
|
||||
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||
return publicSnapshot(game.state);
|
||||
}
|
||||
|
||||
describe('the step queue', () => {
|
||||
it('shows the whole burst in order and lands on the real board', () => {
|
||||
const { steps, final } = realSteps(1917398, 400);
|
||||
assert.ok(steps.length > 30, `only ${steps.length} steps — this proved little`);
|
||||
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
|
||||
// Run a clock forward until it settles, in 50ms ticks like a render loop would.
|
||||
let now = 0;
|
||||
for (let i = 0; i < 20_000 && q.busy(); i++) {
|
||||
q.advance(now);
|
||||
now += 50;
|
||||
}
|
||||
assert.equal(q.busy(), false, 'the queue never drained');
|
||||
assert.deepEqual(q.current(), final, 'the animated board did not land on the real one');
|
||||
assert.equal(q.showing()?.seq, steps[steps.length - 1]!.seq, 'the caption is not on the last step');
|
||||
});
|
||||
|
||||
it('a burst of switching takes real time, and bookkeeping takes none', () => {
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
|
||||
// Only the bookkeeping: it must all collapse into a single advance.
|
||||
// `.end` only: `localOps.choose` became an announcement worth watching after the first real play.
|
||||
const bookkeeping = steps.filter((s) => s.cause.endsWith('.end'));
|
||||
assert.ok(bookkeeping.length > 10, 'not enough bookkeeping steps to prove the collapse');
|
||||
q.push(bookkeeping);
|
||||
q.advance(0);
|
||||
q.advance(0);
|
||||
assert.equal(q.busy(), false, `${bookkeeping.length} bookkeeping steps should cost no time at all`);
|
||||
|
||||
// And switching: each one must hold the screen.
|
||||
const switching = steps.filter((s) => s.cause.startsWith('switch.') && s.cause !== 'switch.end');
|
||||
assert.ok(switching.length >= 6, `only ${switching.length} switching steps found`);
|
||||
const q2 = createStepQueue();
|
||||
q2.reset(baseline(1917398));
|
||||
q2.push(switching.slice(0, 6));
|
||||
q2.advance(0);
|
||||
assert.equal(q2.behind(), 5, 'the first is shown at once; five are still to watch');
|
||||
q2.advance(DWELL.switching - 1);
|
||||
assert.equal(q2.behind(), 5, 'a switching move must not be replaced early');
|
||||
q2.advance(DWELL.switching);
|
||||
assert.equal(q2.behind(), 4, 'and must be replaced once its dwell is up');
|
||||
});
|
||||
|
||||
/**
|
||||
* PAUSE — Jesse, playtest 2026-09-16, asking for one beside Skip.
|
||||
*
|
||||
* The property that matters is not "it stops", which any flag gives you. It is that a hold COSTS
|
||||
* THE STEP NOTHING: a move paused half-way through its dwell has to resume with half a dwell left,
|
||||
* or pausing to look at something would punish you by throwing the rest of it away the moment you
|
||||
* let go. That is the whole assertion below, measured against `DWELL.switching` rather than a
|
||||
* hand-picked number so it follows the tuning table.
|
||||
*/
|
||||
it('pause holds the board where it is, and resume gives the step back the dwell it had left', () => {
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const switching = steps.filter((s) => s.cause.startsWith('switch.') && s.cause !== 'switch.end');
|
||||
assert.ok(switching.length >= 6, `only ${switching.length} switching steps found`);
|
||||
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(switching.slice(0, 6));
|
||||
q.advance(0);
|
||||
assert.equal(q.behind(), 5, 'the first is shown at once; five are still to watch');
|
||||
|
||||
// Held half-way through the first move's dwell, and left held for ten times that long.
|
||||
const half = DWELL.switching / 2;
|
||||
assert.equal(q.pause(half), true);
|
||||
assert.equal(q.paused(), true);
|
||||
const held = q.showing()?.seq;
|
||||
q.advance(half + 10_000);
|
||||
assert.equal(q.behind(), 5, 'a held queue consumed a step');
|
||||
assert.equal(q.showing()?.seq, held, 'the board moved while it was supposed to be held');
|
||||
assert.equal(q.busy(), true, 'a held queue must still read busy, or the render loop stops');
|
||||
|
||||
// Resumed, the move still owes exactly the half-dwell it had left — no more, and no less.
|
||||
const at = half + 10_000;
|
||||
assert.equal(q.resume(at), true);
|
||||
assert.equal(q.paused(), false);
|
||||
q.advance(at + half - 1);
|
||||
assert.equal(q.behind(), 5, 'the step was robbed of time it was owed while held');
|
||||
q.advance(at + half);
|
||||
assert.equal(q.behind(), 4, 'and it never gave way once the rest of its dwell was up');
|
||||
});
|
||||
|
||||
it('refuses to hold an idle queue, and Skip lifts a hold rather than leaving it stuck', () => {
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
assert.equal(q.pause(0), false, 'an idle queue has no playback to hold');
|
||||
assert.equal(q.paused(), false);
|
||||
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
q.push(steps);
|
||||
q.advance(0);
|
||||
assert.equal(q.pause(10), true);
|
||||
assert.equal(q.skip(), true, 'Skip must still work while held');
|
||||
assert.equal(q.paused(), false, 'Skip left the queue held, with a Resume that does nothing');
|
||||
assert.equal(q.busy(), false);
|
||||
});
|
||||
|
||||
it('counts only what will be watched, so the countdown is steady', () => {
|
||||
// The counter's whole purpose: a backlog of mostly-bookkeeping must not read as a huge number
|
||||
// that collapses the instant it starts.
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
const behind = q.behind();
|
||||
assert.ok(behind > 0 && behind < steps.length, `behind ${behind} of ${steps.length} queued`);
|
||||
|
||||
q.advance(0);
|
||||
let ticks = 0;
|
||||
let previous = q.behind();
|
||||
let now = 0;
|
||||
while (q.busy() && ticks++ < 20_000) {
|
||||
now += 50;
|
||||
q.advance(now);
|
||||
const nowBehind = q.behind();
|
||||
assert.ok(nowBehind <= previous, 'the counter must never go up while draining');
|
||||
previous = nowBehind;
|
||||
}
|
||||
assert.equal(q.behind(), 0);
|
||||
});
|
||||
|
||||
it('skip jumps to the real board without losing a single state on the way', () => {
|
||||
const { steps, final } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
q.advance(0);
|
||||
|
||||
assert.equal(q.skip(), true, 'there was a backlog to skip');
|
||||
assert.equal(q.busy(), false);
|
||||
assert.equal(q.behind(), 0);
|
||||
// Skip applies every delta rather than jumping the chain, so the board is exact.
|
||||
assert.deepEqual(q.current(), final, 'skipping produced a board the game was never in');
|
||||
assert.equal(q.skip(), false, 'skipping an empty queue changes nothing');
|
||||
});
|
||||
|
||||
it('pace 0 turns animation off entirely — TODO #18', () => {
|
||||
const { steps, final } = realSteps(1917398, 400);
|
||||
const q = createStepQueue(() => 0);
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
// One advance at a single instant must consume everything: nothing dwells at all.
|
||||
q.advance(0);
|
||||
q.advance(0);
|
||||
assert.equal(q.busy(), false, 'with animation off, nothing may be left waiting');
|
||||
assert.equal(q.behind(), 0, 'nothing is "behind" when nothing is being animated');
|
||||
assert.deepEqual(q.current(), final);
|
||||
});
|
||||
|
||||
it('pace scales the wait without changing the order', () => {
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const switching = steps.filter((s) => s.cause.startsWith('switch.') && s.cause !== 'switch.end').slice(0, 3);
|
||||
assert.equal(switching.length, 3);
|
||||
|
||||
const half = createStepQueue(() => 0.5);
|
||||
half.reset(baseline(1917398));
|
||||
half.push(switching);
|
||||
half.advance(0);
|
||||
half.advance(DWELL.switching / 2);
|
||||
assert.equal(half.behind(), 1, 'at half pace, half the dwell should have advanced one step');
|
||||
});
|
||||
|
||||
it('holds the LAST step of a burst for its dwell — the v0.8.0 snap-back bug', () => {
|
||||
/**
|
||||
* REGRESSION. `busy()` was `pending.length > 0`, so the instant the final step of a burst was
|
||||
* shown the queue reported idle: the animation loop stopped and the district panel snapped back
|
||||
* to the viewer's own board without that step ever being looked at. Jesse, from the first real
|
||||
* play on the test server: *"I briefly saw that it was the bot's office area then their turn was
|
||||
* done and it pointed back to my office area"*, and the countdown row appeared "very briefly".
|
||||
*
|
||||
* The panel follows `busy()`, so this is the property that keeps somebody else's board on screen
|
||||
* for as long as their move is being shown.
|
||||
*/
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const one = steps.filter((s) => s.cause === 'switch.move').slice(0, 1);
|
||||
assert.equal(one.length, 1);
|
||||
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(one);
|
||||
|
||||
q.advance(0);
|
||||
assert.equal(q.behind(), 0, 'nothing is queued behind it');
|
||||
assert.equal(q.busy(), true, 'but it is still being shown, so the queue is not idle');
|
||||
|
||||
q.advance(DWELL.switching - 1);
|
||||
assert.equal(q.busy(), true, 'still inside its dwell');
|
||||
|
||||
q.advance(DWELL.switching);
|
||||
assert.equal(q.busy(), false, 'and idle only once its moment has passed');
|
||||
});
|
||||
|
||||
it("does not spend time replaying the viewer's own moves", () => {
|
||||
// A seated player's own board is drawn from their authoritative Frame, so they have already seen
|
||||
// their own click. Holding it delays the thing they wanted to watch — a bot's turn.
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const mine = steps.filter((s) => s.player === 0 && s.cause === 'switch.move').slice(0, 3);
|
||||
assert.equal(mine.length, 3, 'need three of seat 0\'s own moves');
|
||||
|
||||
const asSeat0 = createStepQueue(() => 1, () => 0);
|
||||
asSeat0.reset(baseline(1917398));
|
||||
asSeat0.push(mine);
|
||||
// Twice at the same instant: the first call shows the head of the burst, the second collapses the
|
||||
// zero-dwell run behind it. In the page that is two animation frames, ~16ms apart.
|
||||
asSeat0.advance(0);
|
||||
asSeat0.advance(0);
|
||||
assert.equal(asSeat0.busy(), false, "the viewer's own moves must cost no time at all");
|
||||
assert.equal(asSeat0.behind(), 0, 'and must never be counted as something to wait for');
|
||||
|
||||
// The same steps seen by somebody else are worth watching.
|
||||
const asSpectator = createStepQueue(() => 1, () => 1);
|
||||
asSpectator.reset(baseline(1917398));
|
||||
asSpectator.push(mine);
|
||||
asSpectator.advance(0);
|
||||
assert.equal(asSpectator.busy(), true, "another seat's moves are worth showing");
|
||||
assert.equal(asSpectator.behind(), 2);
|
||||
});
|
||||
|
||||
it('a reset discards the backlog rather than merging it onto a new baseline', () => {
|
||||
/**
|
||||
* A reconnecting client holds steps whose deltas chain off a baseline the server has moved past.
|
||||
* Merging them onto the new one would draw a board that never existed — and `applyPublicDelta`
|
||||
* would throw the moment a "null means unchanged" field had nothing to merge onto.
|
||||
*/
|
||||
const { steps, final } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps.slice(0, 10));
|
||||
q.advance(0);
|
||||
assert.ok(q.busy());
|
||||
|
||||
q.reset(final);
|
||||
assert.equal(q.busy(), false, 'a reset must empty the queue');
|
||||
assert.equal(q.behind(), 0);
|
||||
assert.deepEqual(q.current(), final);
|
||||
// And the caption survives: a reconnect should not blank the "what just happened" line.
|
||||
assert.ok(q.showing() !== null, 'the caption should survive a reset');
|
||||
});
|
||||
|
||||
it('can always be emptied, so a player is never stranded behind it', () => {
|
||||
/**
|
||||
* "Your Move" is put away while the board is catching up (v0.8.0.6), which makes `busy()` the
|
||||
* thing standing between a player and their own turn. So the ways it can be cleared matter more
|
||||
* than they did: `skip()` must always work, from any state, including one where the clock has
|
||||
* never advanced — which is exactly the situation a page with no `requestAnimationFrame` is in,
|
||||
* and how this was found.
|
||||
*/
|
||||
const { steps, final } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
// Never advanced at all: no frame has been shown, and the queue is full.
|
||||
assert.equal(q.busy(), true);
|
||||
assert.equal(q.skip(), true, 'a never-advanced queue must still be skippable');
|
||||
assert.equal(q.busy(), false, 'and must be idle afterwards, or the player stays locked out');
|
||||
assert.deepEqual(q.current(), final);
|
||||
});
|
||||
|
||||
it('draws nothing before a reset has arrived', () => {
|
||||
const q = createStepQueue();
|
||||
assert.equal(q.current(), null);
|
||||
assert.equal(q.advance(0), false);
|
||||
assert.equal(q.behind(), 0);
|
||||
assert.equal(q.showing(), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('whose move the screen is showing (Gitea#25)', () => {
|
||||
const step = (player: number | null) => ({ player }) as DisplayStep;
|
||||
const queue = (behind: number, busy: boolean, showing: DisplayStep | null) => ({
|
||||
behind: () => behind,
|
||||
busy: () => busy,
|
||||
showing: () => showing,
|
||||
});
|
||||
|
||||
it('names the live actor once the board has caught up', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(0, false, step(2)), 0), { actor: 0, replaying: false });
|
||||
});
|
||||
|
||||
it('names the player of the step on screen while the board is behind — not the live actor', () => {
|
||||
// One human (seat 0) against bots: the live game already waits on seat 0 while bot 2's moves replay.
|
||||
assert.deepEqual(actorOnScreen(queue(3, true, step(2)), 0), { actor: 2, replaying: true });
|
||||
});
|
||||
|
||||
it('keeps naming the last step while it is still on screen, after the counter reaches zero', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(0, true, step(1)), 0), { actor: 1, replaying: true });
|
||||
});
|
||||
|
||||
it('names nobody for an automatic phase being shown', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(2, true, step(null)), 0), { actor: null, replaying: true });
|
||||
});
|
||||
|
||||
it('falls back to the live actor before any step has been shown', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(1, true, null), 3), { actor: 3, replaying: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the log is held back with the board, and a changed card flashes (playtest, 2026-09-15)', () => {
|
||||
it('owes exactly the lines of the steps not yet shown', () => {
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
assert.equal(q.pendingLines(), 0, 'an empty queue holds nothing back');
|
||||
|
||||
q.push(steps);
|
||||
const owed = steps.reduce((n, s) => n + s.lines.length, 0);
|
||||
assert.equal(q.pendingLines(), owed, 'every queued step still owes its lines');
|
||||
|
||||
// Drive the clock as a render loop would; the debt falls monotonically and ends at nothing.
|
||||
let now = 0;
|
||||
let last = owed;
|
||||
for (let i = 0; i < 20_000 && q.busy(); i++) {
|
||||
q.advance(now);
|
||||
const left = q.pendingLines();
|
||||
assert.ok(left <= last, 'the held-back count grew while the board caught up');
|
||||
last = left;
|
||||
now += 50;
|
||||
}
|
||||
assert.equal(q.pendingLines(), 0, 'the board caught up but lines were still withheld');
|
||||
});
|
||||
|
||||
it('skipping reveals the whole log at once', () => {
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
q.skip();
|
||||
assert.equal(q.pendingLines(), 0, 'Skip left lines withheld — the history would stay short');
|
||||
});
|
||||
|
||||
it('flashes nothing on an ordinary step', () => {
|
||||
// Realignment is rare in bot play, so this pins the quiet case: the map must not pulse at random.
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps.slice(0, 5));
|
||||
q.advance(0);
|
||||
assert.deepEqual(q.flashing(), [], 'a step that changed no Mainline card flashed one');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* The switching planner (`sim/switch-planner.ts`) — the two properties it cannot be allowed to lose.
|
||||
*
|
||||
* 1. PLANNING TOUCHES NOTHING. The planner applies intents to a partial copy of the game
|
||||
* (`forkForSwitching`) that shares everything a switching intent is not supposed to write. If a
|
||||
* reducer ever starts writing somewhere new, the copy leaks into the real game, and this is where
|
||||
* that shows: the game is serialised before and after planning and must not have changed.
|
||||
* 2. A PLAN IS WHAT THE ENGINE WILL DO. Every step replays through `applyIntent` on a FULL copy, and
|
||||
* lands on the fingerprint the planner promised for it. That is what makes the partial copy
|
||||
* trustworthy, and it is what the bot relies on to know it is still on plan.
|
||||
*
|
||||
* Taken from real seeded bot games rather than hand-built positions, because a district that
|
||||
* satisfies the track geometry by hand tests the builder as much as the planner.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent } from '../src/engine/apply.ts';
|
||||
import { legalActions, legalSwitchingActions } from '../src/engine/legal.ts';
|
||||
import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
MOVES_PER_LOCAL_OPS,
|
||||
collectiveRevenueFloor,
|
||||
lengthProfile,
|
||||
} from '../src/engine/content.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
import { actingPlayer, turnOf } from '../src/engine/state.ts';
|
||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||
import { planSwitchingTurn, switchFingerprint } from '../src/sim/switch-planner.ts';
|
||||
|
||||
const config = (): GameConfig => {
|
||||
const days = lengthProfile('short').days;
|
||||
return {
|
||||
mode: 'solitaire',
|
||||
days,
|
||||
minCombinedRevenue: collectiveRevenueFloor(1, days),
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
};
|
||||
|
||||
const serialise = (s: GameState): string =>
|
||||
JSON.stringify(s, (_k, v) => (v instanceof Map ? [...v] : v instanceof Set ? [...v] : v));
|
||||
|
||||
describe('switching planner', () => {
|
||||
it('asks for switching intents that are exactly the switching subset of legalActions, in order', () => {
|
||||
const SWITCHING = new Set(['switch.move', 'switch.dropCars', 'switch.sortConsist', 'maneuver.flyingSwitch', 'switch.end']);
|
||||
let compared = 0;
|
||||
for (const seed of [1000, 8919]) {
|
||||
const s = createGame({ id: `legal-${seed}`, seed, config: config(), playerNames: ['bot'] });
|
||||
playGame(s, developerBot, pump, 50_000, undefined, (st) => {
|
||||
const p = actingPlayer(st);
|
||||
if (p === null || st.clock.phase !== 'localOps') return;
|
||||
const all = legalActions(st, p).filter((i) => SWITCHING.has(i.type));
|
||||
assert.deepEqual(legalSwitchingActions(st, p), all);
|
||||
compared++;
|
||||
});
|
||||
}
|
||||
assert.ok(compared > 0, 'no Local Operations decision was reached');
|
||||
});
|
||||
|
||||
it('never changes the game it plans for, and every plan replays to the position it promised', () => {
|
||||
let checked = 0;
|
||||
let withSteps = 0;
|
||||
for (const seed of [1000, 8919, 16838]) {
|
||||
const s = createGame({ id: `plan-${seed}`, seed, config: config(), playerNames: ['bot'] });
|
||||
const r = playGame(s, developerBot, pump, 50_000, undefined, (st) => {
|
||||
const p = actingPlayer(st);
|
||||
if (p === null || st.clock.phase !== 'localOps') return;
|
||||
const turn = turnOf(st, p);
|
||||
if (turn.option !== 'switch' || turn.movesRemaining !== MOVES_PER_LOCAL_OPS) return;
|
||||
|
||||
const before = serialise(st);
|
||||
const plan = planSwitchingTurn(st, p, { budget: 400, beam: 16 });
|
||||
assert.equal(serialise(st), before, `seed ${seed}: planning wrote into the real game`);
|
||||
assert.ok(plan.score >= plan.rootScore, 'a plan is never worse than stopping where the crew stands');
|
||||
|
||||
const copy = structuredClone(st);
|
||||
plan.steps.forEach((step, n) => {
|
||||
assert.equal(switchFingerprint(copy, p), plan.keys[n], `seed ${seed}: step ${n} started off plan`);
|
||||
const applied = applyIntent(copy, p, step);
|
||||
assert.ok(applied.ok, `seed ${seed}: step ${n} (${step.type}) was refused by the engine`);
|
||||
});
|
||||
assert.equal(switchFingerprint(copy, p), plan.keys.at(-1), `seed ${seed}: the plan did not end where it said`);
|
||||
|
||||
checked++;
|
||||
if (plan.steps.length > 0) withSteps++;
|
||||
});
|
||||
assert.ok(r.finished, `seed ${seed}: a game with the planner switched on did not finish`);
|
||||
}
|
||||
assert.ok(checked > 0, 'no switching turn was reached, so nothing was tested');
|
||||
assert.ok(withSteps > 0, 'every plan was empty, so replay was never exercised');
|
||||
});
|
||||
});
|
||||
+9
-5
@@ -151,10 +151,14 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
|
||||
|
||||
it('splits Revenue into what was earned and what was given back', () => {
|
||||
// Reconciliation is the real assertion and it holds for any game, earned or not: gained minus
|
||||
// lost IS the score the engine kept. Seed 42 is named because it is one where Revenue actually
|
||||
// lost IS the score the engine kept. Seed 44 is named because it is one where Revenue actually
|
||||
// moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are
|
||||
// being told apart rather than both sitting at zero.
|
||||
for (const seed of [1, 7, 42]) {
|
||||
//
|
||||
// It was seed 42 until v0.8.0.10. That game's collision was the Superintendent holding a train over
|
||||
// one BEHIND it (Gitea#26); with the ruling gone the collision is too, and seed 42 now earns 5 and
|
||||
// loses nothing — a better game and a vacuous test. The seed moved, not the assertion.
|
||||
for (const seed of [1, 7, 44]) {
|
||||
const { state } = playKeepingEvents(seed);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.equal(
|
||||
@@ -163,10 +167,10 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
|
||||
`seed ${seed}: gained minus lost does not reconcile with the score the engine kept`,
|
||||
);
|
||||
}
|
||||
const { state } = playKeepingEvents(42);
|
||||
const { state } = playKeepingEvents(44);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.ok(me.revenueGained > 0, 'seed 42 earned nothing — the gained half is not being counted');
|
||||
assert.ok(me.revenueLost > 0, 'seed 42 lost nothing — the lost half is not being counted');
|
||||
assert.ok(me.revenueGained > 0, 'seed 44 earned nothing — the gained half is not being counted');
|
||||
assert.ok(me.revenueLost > 0, 'seed 44 lost nothing — the lost half is not being counted');
|
||||
});
|
||||
|
||||
it('records a Circus set-up as the one-off it is, not as a streak', () => {
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
/**
|
||||
* THE WATCHABLE TABLE — v0.8.0, Gitea#20 / TODO #13, #15, #18.
|
||||
*
|
||||
* One shared, ordered presentation of everyone else's turns, on a seated player's own screen. The
|
||||
* design is `docs/plans/jitsi-common-board.md` § v0.8.0; this file is its tests.
|
||||
*
|
||||
* Starting with ATTRIBUTION, because the caption row and the history panel both read these lines
|
||||
* and a line that does not say who acted is useless on a screen built to answer "what did they
|
||||
* just do?".
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent, areaOf } from '../src/engine/apply.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import type { GameConfig, PlayerIndex } from '../src/engine/state.ts';
|
||||
import { turnOf } from '../src/engine/state.ts';
|
||||
import { fromMultiplayerSave, newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
|
||||
import { currentActor } from '../src/web/game.ts';
|
||||
import { applyPublicDelta } from '../src/sim/public-delta.ts';
|
||||
import { publicSnapshot } from '../src/sim/view.ts';
|
||||
import type { PublicFrame } from '../src/sim/view.ts';
|
||||
import { takeSteps } from '../src/sim/display-step.ts';
|
||||
import { createSession } from '../src/server/session.ts';
|
||||
import { kindOf } from '../src/sim/pacing.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'competitive',
|
||||
days: 5,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The four events a switching turn is made of. Every one of them used to arrive in the shared log
|
||||
* unattributed: `record()` (`web/game.ts`) prefixes a line with the player's name only when the
|
||||
* event itself carries `player`, and these four were the only events in their class that did not
|
||||
* — `cardDrawn`, `cardPlayed`, `cardDiscarded`, `carPlacedOnTrain`, `loadStarted`, `loadCompleted`,
|
||||
* `flyingSwitch` and `localOpsOptionChosen` all did. So a switching turn read as an attributed
|
||||
* bracket around anonymous contents:
|
||||
*
|
||||
* Player Alice chose to switch ← attributed
|
||||
* CREW moved (1,2) → (1,3) — 4 of 6 ← whose train?
|
||||
* Player Alice finished Local Operations ← attributed
|
||||
*
|
||||
* Measured 2026-09-09 and fixed with the feature that reads them, not filed.
|
||||
*/
|
||||
const SWITCHING_EVENTS = ['trayMoved', 'carsCoupled', 'carsDropped', 'consistSorted'] as const;
|
||||
|
||||
/** How each of those four reads in the log, so the assertions can find them by text. */
|
||||
const SWITCHING_LINE = /^Player .+ (moved (Train |the local crew)|coupled at |set out |used the SMALL YARD)/;
|
||||
|
||||
/**
|
||||
* The two tones a switching line may carry, and why attribution matters in BOTH.
|
||||
*
|
||||
* Since 2026-09-17 a plain move along your own track is written `trace`: the line still exists, so
|
||||
* its display step has narration to caption the board with and a dwell to be watched for, but the
|
||||
* history panel does not draw it (`web/game.ts` § inHistory). What must never happen either way is
|
||||
* the line failing to say whose crew it was — the caption is read by the whole table while the move
|
||||
* goes up, which is if anything the more public of the two places.
|
||||
*/
|
||||
const SWITCHING_TONES = ['act', 'trace'];
|
||||
|
||||
describe('switching is attributed — TODO #13', () => {
|
||||
it('every switching event carries the player who acted', () => {
|
||||
/**
|
||||
* Driven by PREFERRING switch moves rather than taking the first legal action, because bot
|
||||
* switching is clustered rather than spread: two of the three published replays contain no
|
||||
* `switch.move` at all, so a game driven by `options[0]` can finish without ever exercising
|
||||
* this. The counter below then guards against the test passing vacuously.
|
||||
*/
|
||||
let seen = 0;
|
||||
for (const seed of [1917398, 191056, 4242]) {
|
||||
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||
for (let i = 0; i < 800; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||
const chosen = move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!;
|
||||
|
||||
// Read the events this intent produces before applying it for real, so the assertion sees
|
||||
// exactly what `record()` will be handed.
|
||||
const preview = applyIntent(structuredClone(game.state), actor, chosen);
|
||||
if (preview.ok) {
|
||||
for (const e of preview.events) {
|
||||
if ((SWITCHING_EVENTS as readonly string[]).includes(e.type)) {
|
||||
assert.ok(
|
||||
'player' in e,
|
||||
`${e.type} carries no player, so the log cannot say whose crew it was`,
|
||||
);
|
||||
assert.equal(
|
||||
(e as { player: PlayerIndex }).player,
|
||||
actor,
|
||||
`${e.type} names the wrong player`,
|
||||
);
|
||||
seen++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!submit(game, chosen)) break;
|
||||
}
|
||||
}
|
||||
assert.ok(seen > 0, 'no switching event was produced, so this test proved nothing');
|
||||
});
|
||||
|
||||
it('reads as a player action in the log, not as anonymous plain text', () => {
|
||||
let lines = 0;
|
||||
for (const seed of [1917398, 4242]) {
|
||||
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||
for (let i = 0; i < 800; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||
}
|
||||
|
||||
for (const line of game.log) {
|
||||
// The old wording. `uncapitalise` deliberately leaves an acronym alone (`^[A-Z][a-z]` only),
|
||||
// so "CREW moved" and "SMALL YARD —" would have survived the prefix and read as
|
||||
// "Player Alice CREW moved …". Both were reworded to compose.
|
||||
assert.doesNotMatch(
|
||||
line.text,
|
||||
/^CREW moved|^SMALL YARD —/,
|
||||
`an unattributed switching line survived: ${line.text}`,
|
||||
);
|
||||
if (SWITCHING_LINE.test(line.text)) {
|
||||
assert.ok(
|
||||
SWITCHING_TONES.includes(line.tone),
|
||||
`a switching line must read as somebody's move: ${line.text} (tone ${line.tone})`,
|
||||
);
|
||||
lines++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(lines > 0, 'no switching line reached the log, so this test proved nothing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the display-step collector — TODO #13', () => {
|
||||
it('emits one step per accepted intent plus one per automatic phase, in order', () => {
|
||||
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||
let accepted = 0;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||
accepted++;
|
||||
}
|
||||
assert.ok(accepted > 30, `only ${accepted} intents accepted — this proved little`);
|
||||
|
||||
const steps = takeSteps(game.display);
|
||||
/**
|
||||
* TWO KINDS OF STEP SINCE TODO #18: one per accepted intent, and one per automatic phase that
|
||||
* did anything. So the count is no longer `accepted` — but every intent must still have exactly
|
||||
* one step, which is the invariant that matters.
|
||||
*/
|
||||
const byIntent = steps.filter((s) => s.cause !== 'phase');
|
||||
const byPhase = steps.filter((s) => s.cause === 'phase');
|
||||
assert.equal(byIntent.length, accepted, 'one step per accepted intent, no more and no fewer');
|
||||
assert.ok(byPhase.length > 0, 'no phase produced a step — TODO #18 is not being served');
|
||||
steps.forEach((s, i) => {
|
||||
assert.equal(s.seq, i, 'sequence numbers must be dense and in order');
|
||||
assert.equal(s.protocolVersion, 1);
|
||||
assert.ok(kindOf(s.cause), `step ${i} carries a cause pacing cannot classify`);
|
||||
// A phase is nobody's move; an intent is always somebody's.
|
||||
assert.equal(s.player === null, s.cause === 'phase', `step ${i} disagrees about who acted`);
|
||||
assert.equal(s.seat === null, s.cause === 'phase');
|
||||
});
|
||||
assert.equal(takeSteps(game.display).length, 0, 'draining must empty the collector');
|
||||
});
|
||||
|
||||
it('a rejected intent produces no step', () => {
|
||||
const game = newMultiplayerGame(4242, config, ['Alice', 'Bob', 'Carol']);
|
||||
takeSteps(game.display);
|
||||
// Somebody else's turn: refused before the engine is touched, so nothing to present.
|
||||
const notMyTurn = ((currentActor(game) ?? 0) + 1) % 3;
|
||||
assert.equal(submit(game, { type: 'draw.end' }, notMyTurn as PlayerIndex), false);
|
||||
assert.equal(takeSteps(game.display).length, 0, 'a refused intent must not be presented');
|
||||
});
|
||||
|
||||
it('the step deltas reconstruct the public board exactly', () => {
|
||||
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||
let held: PublicFrame | null = null;
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||
for (const s of takeSteps(game.display)) held = applyPublicDelta(held, s.frame);
|
||||
}
|
||||
assert.deepEqual(held, publicSnapshot(game.state), 'the animated board drifted from the real one');
|
||||
});
|
||||
|
||||
/**
|
||||
* THE PROPERTY THAT IS CURRENTLY FREE AND MUST STAY THAT WAY.
|
||||
*
|
||||
* `fromSave`/`fromMultiplayerSave` rebuild a game with `applyIntent` + `record` + `drain` rather
|
||||
* than `submit`, so a resumed server does not re-emit the whole game as steps and burn the
|
||||
* sequence. The plan expected this to need an explicit guard. It does not — but move a replay
|
||||
* path onto `submit()` and it silently becomes a real bug, which is why this is pinned.
|
||||
*/
|
||||
it('replaying a save emits no steps at all', () => {
|
||||
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
if (!submit(game, options[0]!)) break;
|
||||
}
|
||||
assert.ok(game.history.length > 20, 'need a real history to replay');
|
||||
|
||||
const rebuilt = fromMultiplayerSave(game.seed, config, ['Alice', 'Bob', 'Carol'], game.history);
|
||||
assert.equal(
|
||||
rebuilt.game.display.steps.length,
|
||||
0,
|
||||
'a replay re-emitted the whole game as display steps',
|
||||
);
|
||||
assert.equal(rebuilt.game.display.seq, 0, 'a replay burned display sequence numbers');
|
||||
});
|
||||
|
||||
it('solitaire collects the same way multiplayer does', () => {
|
||||
// The standing design direction: solitaire is a special case of multiplayer, not a second
|
||||
// implementation. Both go through one `submit()`, so this needs no separate code path — and
|
||||
// that is exactly what makes TODO #18 fall out of TODO #13's mechanism.
|
||||
const game = newGame(4242);
|
||||
let accepted = 0;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
if (!submit(game, options[0]!)) break;
|
||||
accepted++;
|
||||
}
|
||||
assert.ok(accepted > 10, 'the solitaire game did not get going');
|
||||
const collected = takeSteps(game.display);
|
||||
assert.equal(
|
||||
collected.filter((s) => s.cause !== 'phase').length,
|
||||
accepted,
|
||||
'solitaire must collect a step per intent too',
|
||||
);
|
||||
// And solitaire is where TODO #18 lives — its phases must earn beats on the same path.
|
||||
assert.ok(collected.some((s) => s.cause === 'phase'), 'solitaire got no phase steps');
|
||||
});
|
||||
});
|
||||
|
||||
describe('steps reach a seated player — TODO #13', () => {
|
||||
it('never replays the opening bot turns at the first client to connect', () => {
|
||||
/**
|
||||
* `buildSession` runs `driveBotTurns()` at construction, so with bots ahead of you in the order
|
||||
* the game has already moved before anybody can connect. Those steps must be DROPPED, not
|
||||
* queued: a connecting client's `publicReset` is the board as it stands after those very moves,
|
||||
* so replaying them onto it would draw positions the game had already left.
|
||||
*
|
||||
* Found by review 2026-09-09 rather than by a failing test, which is why this one exists.
|
||||
*/
|
||||
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
|
||||
const push = session.connect(0 as PlayerIndex);
|
||||
assert.ok(push.publicReset, 'a connecting client needs a baseline');
|
||||
assert.equal(push.steps, undefined, 'the connect push must carry no steps at all');
|
||||
|
||||
// And the first real broadcast must carry only what THIS move produced — nothing older.
|
||||
const option = push.menu?.options[0];
|
||||
assert.ok(option, 'seat 0 should have something to do');
|
||||
const r = session.intent(0 as PlayerIndex, 1, option);
|
||||
assert.ok(r.accepted);
|
||||
const steps = [...r.pushes.values()][0]?.steps ?? [];
|
||||
assert.ok(steps.length > 0, 'the move produced no steps');
|
||||
/**
|
||||
* The first step delivered must be THIS seat's move — not a bot's, which is what a replayed
|
||||
* opening turn would look like. The sequence does NOT restart at 0: `takeSteps` empties the
|
||||
* collector without rewinding the counter, so the first thing a client sees may be seq 14. That
|
||||
* is fine and deliberate — what 0.8.1's gap detection needs is monotonic and dense, not
|
||||
* zero-based.
|
||||
*/
|
||||
assert.equal(steps[0]!.player, 0, 'the first delivered step was not the move just made');
|
||||
assert.equal(steps[0]!.cause, option.type);
|
||||
steps.forEach((st, i) => {
|
||||
if (i > 0) assert.equal(st.seq, steps[i - 1]!.seq + 1, 'sequence must stay dense');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('every seat gets the same public steps, and a connect gets a baseline to merge onto', () => {
|
||||
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
|
||||
|
||||
const connected = session.connect(0 as PlayerIndex);
|
||||
assert.ok(connected.publicReset, 'a connecting client needs a baseline for its step queue');
|
||||
|
||||
let seen = 0;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const menu = session.connect(0 as PlayerIndex).menu;
|
||||
const option = menu?.options[0];
|
||||
if (!option) break;
|
||||
const r = session.intent(0 as PlayerIndex, i, option);
|
||||
if (!r.accepted) break;
|
||||
const pushes = [...r.pushes.values()];
|
||||
if (pushes.length === 0) continue;
|
||||
const first = pushes[0]!.steps ?? [];
|
||||
if (first.length === 0) continue;
|
||||
seen += first.length;
|
||||
for (const p of pushes) {
|
||||
assert.deepEqual(p.steps, first, 'every seat must receive the identical public steps');
|
||||
}
|
||||
}
|
||||
assert.ok(seen > 0, 'no steps reached a push, so this proved nothing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Fedora passing is visible (playtest 2026-09-16)', () => {
|
||||
it('names the new Superintendent in the history at the Stage it happens', async () => {
|
||||
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
||||
const { legalActions } = await import('../src/engine/legal.ts');
|
||||
|
||||
/**
|
||||
* It used to ride on `actorChanged`, which `record()` drops as turn bookkeeping — so the one
|
||||
* moment that event meant something never reached a player. Driven far enough to cross a shift
|
||||
* boundary (Stages 3, 6, 9, 12) rather than asserted on a hand-built event, because the point is
|
||||
* that a real game produces the line.
|
||||
*/
|
||||
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||
for (let i = 0; i < 900; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||
if (game.state.clock.stage > 3 || game.state.clock.day > 1) break;
|
||||
}
|
||||
|
||||
const handover = game.log.filter((l) => /SUPERINTENDENT — the Fedora passes to/.test(l.text));
|
||||
assert.ok(handover.length > 0, 'the game crossed a shift change and the log never said so');
|
||||
assert.match(handover[0]!.text, /Alice|Bob|Carol/, 'the handover did not name the new Superintendent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => {
|
||||
it('never names a player twice — no "Player Jesse player 0 finished …"', async () => {
|
||||
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
||||
const { legalActions } = await import('../src/engine/legal.ts');
|
||||
|
||||
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||
}
|
||||
|
||||
assert.ok(game.log.length > 50, 'the game barely ran, so this proved little');
|
||||
for (const line of game.log) {
|
||||
// `record()` prefixes the acting player's NAME; a narration that also named them read
|
||||
// "Player Alice player 0 finished Local Operations" (playtest, 2026-09-15).
|
||||
assert.doesNotMatch(
|
||||
line.text,
|
||||
/\bplayer \d+\b/i,
|
||||
`a line still carries a bare player index: ${line.text}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('attributes a clearance ruling to the office, not to the seat\'s own turn', async () => {
|
||||
const { newMultiplayerGame, drain, submit } = await import('../src/web/game.ts');
|
||||
const { areaOf } = await import('../src/engine/apply.ts');
|
||||
|
||||
const game = newMultiplayerGame(7, config, ['Alice', 'Bob', 'Carol']);
|
||||
const s = game.state;
|
||||
const area = areaOf(s, 0);
|
||||
|
||||
// A westbound train at seat 0's Office, and another westbound AHEAD of it — west of the Office —
|
||||
// which is §8.1's fourth condition and the Superintendent's to rule on (see Gitea#26).
|
||||
s.trays.set('departing', {
|
||||
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
|
||||
direction: 'west', facing: 'w',
|
||||
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
} as never);
|
||||
area.adOccupancy.push('departing');
|
||||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const card = s.division.nodes.findIndex((n, i) => i < office && n.kind === 'mainline');
|
||||
const node = s.division.nodes[card];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'west', facing: 'w', position: { at: 'mainline', index: card }, movesUsed: 0,
|
||||
} as never);
|
||||
if (node?.kind === 'mainline') {
|
||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||
}
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
drain(game);
|
||||
assert.equal(s.clock.pendingDecision?.kind, 'clearance', 'no ruling was called for, so nothing was tested');
|
||||
|
||||
const before = game.log.length;
|
||||
assert.ok(submit(game, { type: 'mainline.clearance', allow: false }, s.clock.superintendent));
|
||||
const said = game.log.slice(before).map((l) => l.text);
|
||||
assert.ok(
|
||||
said.some((text) => text.startsWith('Superintendent Player ')),
|
||||
`a ruling did not read as the office's: ${said.join(' | ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* HOW MUCH SWITCHING REACHES THE HISTORY PANEL — Jesse's ruling, 2026-09-17, asked as a question
|
||||
* from the table: *"Does switching show up in history at all? Should it? … I don't think I want all
|
||||
* six moves showing up… maybe dropping off or picking up cars in industries should be recorded."*
|
||||
*
|
||||
* It all showed up. A six-Move turn wrote a line per move, every one of them a pair of coordinates,
|
||||
* and two players shunting pushed everything else off the panel. What stays is the line saying
|
||||
* somebody switched, work at an INDUSTRY, and the Small Yard sort.
|
||||
*
|
||||
* THE LINE IS STILL WRITTEN, MARKED `trace`. `dwellForStep` gives a step no dwell when it produced
|
||||
* no narration, so dropping these outright stopped the board replaying switching at all — the first
|
||||
* attempt at this did exactly that and the step-queue suite caught it. The tone is the seam: the
|
||||
* caption still has its text, the panel filters the tone out.
|
||||
*/
|
||||
describe('the history panel keeps the switching that matters', () => {
|
||||
const play = (seed: number, steps: number) => {
|
||||
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||
}
|
||||
return game;
|
||||
};
|
||||
|
||||
it('keeps the first move of a turn and traces the ones after it', () => {
|
||||
const game = play(1917398, 800);
|
||||
const moves = game.log.filter((l) => / moved (Train|the local crew)/.test(l.text));
|
||||
assert.ok(moves.length > 0, 'no crew move reached the log, so this proved nothing');
|
||||
|
||||
// The opener of a turn is the move that leaves `movesAllowed - 1` behind it, which the line
|
||||
// prints — "5 of 6 Moves left". Those are drawn; everything after them is caption-only.
|
||||
const opening = moves.filter((l) => / 5 of 6 Moves left/.test(l.text));
|
||||
const later = moves.filter((l) => !/ 5 of 6 Moves left/.test(l.text));
|
||||
assert.ok(opening.length > 0, 'no turn opened with a move, so this proved nothing');
|
||||
assert.ok(later.length > 0, 'no turn made a second move, so this proved nothing');
|
||||
assert.ok(
|
||||
opening.every((l) => l.tone !== 'trace'),
|
||||
`the first move of a switching turn was hidden: ${opening.find((l) => l.tone === 'trace')?.text}`,
|
||||
);
|
||||
assert.ok(
|
||||
later.every((l) => l.tone === 'trace'),
|
||||
`a move from the middle of a turn is still drawn: ${later.find((l) => l.tone !== 'trace')?.text}`,
|
||||
);
|
||||
// Every one keeps its text, because that is what captions the board as the move goes up.
|
||||
assert.ok(moves.every((l) => /^Player /.test(l.text)), 'a trace line lost its attribution');
|
||||
});
|
||||
|
||||
it('closes a switching turn with what it cost and where the crew was left', () => {
|
||||
/**
|
||||
* BUILT, NOT PLAYED — the driver above never submits `switch.end`: it finds a move or another
|
||||
* option every time, so 800 turns produced no closed switching turn at all and the assertion
|
||||
* would have been vacuous.
|
||||
*/
|
||||
const { game, trayId } = crewOnAnIndustry();
|
||||
assert.ok(submit(game, { type: 'switch.move', trayId, to: { row: 1, col: 2 }, reverse: false }));
|
||||
assert.ok(submit(game, { type: 'switch.end' }), 'the turn would not end');
|
||||
|
||||
const closing = game.log.filter((l) => / finished switching/.test(l.text));
|
||||
assert.equal(closing.length, 1, `expected one closing line, got ${closing.length}`);
|
||||
assert.notEqual(closing[0]!.tone, 'trace', 'the closing summary was hidden from the history');
|
||||
assert.match(
|
||||
closing[0]!.text,
|
||||
/1 of 6 Moves used, leaving .* at /,
|
||||
`the closing line did not say what it cost and where the crew was left: ${closing[0]!.text}`,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A crew standing on a Freight House with a loaded boxcar, and plain track to its east.
|
||||
*
|
||||
* BUILT RATHER THAN PLAYED. The bot prefers moves over couplings, so 2400 driven turns across
|
||||
* three seeds produced not one set-out at an industry, and none of them ever ended a switching
|
||||
* turn — a driver that cannot reach the case cannot test it.
|
||||
*/
|
||||
const crewOnAnIndustry = (): { game: ReturnType<typeof newGame>; trayId: string } => {
|
||||
const game = newGame(77);
|
||||
const area = areaOf(game.state, 0);
|
||||
const spot = { row: 1, col: 1 };
|
||||
const track = (over: object = {}) => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
...over,
|
||||
});
|
||||
area.grid.set('1,1', track({
|
||||
geometry: { kind: 'facility', facility: 'freightHouse', axis: 'ew' },
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'freightHouse',
|
||||
allows: { outbound: true, inbound: true },
|
||||
outboundBox: [], inboundBox: [],
|
||||
capacity: { outbound: 1, inbound: 1 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
}) as never);
|
||||
area.grid.set('1,2', track() as never);
|
||||
|
||||
const trayId = game.state.freeTrays.pop()!;
|
||||
game.state.trays.set(trayId, {
|
||||
id: trayId, trainNumber: null, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'boxcar', loaded: true }],
|
||||
direction: 'east', facing: 'e', railFacing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: spot }, movesUsed: 0,
|
||||
} as never);
|
||||
game.state.clock.phase = 'localOps';
|
||||
game.state.clock.currentActor = 0;
|
||||
turnOf(game.state, 0).option = 'switch';
|
||||
return { game, trayId };
|
||||
};
|
||||
|
||||
it('keeps work at an industry, and drops the same move on plain track', () => {
|
||||
const { game, trayId } = crewOnAnIndustry();
|
||||
const plain = { row: 1, col: 2 };
|
||||
assert.ok(submit(game, { type: 'switch.dropCars', trayId, count: 1 }), 'the set-out was refused');
|
||||
const dropped = game.log.filter((l) => /set out/i.test(l.text));
|
||||
assert.equal(dropped.length, 1, `expected one set-out line, got ${dropped.length}`);
|
||||
assert.notEqual(
|
||||
dropped[0]!.tone,
|
||||
'trace',
|
||||
'work at an industry was hidden from the history — it is the point of switching',
|
||||
);
|
||||
assert.match(
|
||||
dropped[0]!.text,
|
||||
/at the Freight House/,
|
||||
`the line named a coordinate instead of the industry: ${dropped[0]!.text}`,
|
||||
);
|
||||
|
||||
// The same crew moving onto ordinary track is the noise this ruling was about.
|
||||
assert.ok(submit(game, { type: 'switch.move', trayId, to: plain, reverse: false }), 'the move was refused');
|
||||
const moved = game.log.filter((l) => / moved the local crew/.test(l.text));
|
||||
assert.equal(moved.length, 1, `expected one move line, got ${moved.length}`);
|
||||
// The FIRST move of a turn is kept, and this crew's first move is this one — so what is being
|
||||
// checked here is that it names the square by what stands on it rather than by its coordinates.
|
||||
assert.match(moved[0]!.text, /→ the Freight House|→ \(1,2\)/, `unexpected move line: ${moved[0]!.text}`);
|
||||
});
|
||||
});
|
||||
+268
-5
@@ -23,7 +23,7 @@ import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import type { DivisionView } from '../src/sim/view.ts';
|
||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||
@@ -1522,6 +1522,15 @@ describe('the static build', () => {
|
||||
attrs,
|
||||
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
/**
|
||||
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
|
||||
* `setAttribute` taught this factory above, learned again on 2026-09-16.
|
||||
*
|
||||
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||
* display name is another player's text and must never be interpolated into markup. Without
|
||||
* this the district panel throws on every render.
|
||||
*/
|
||||
appendChild: () => {},
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||
title: '', returnValue: '', open: false,
|
||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||
@@ -1779,6 +1788,15 @@ describe('the static build', () => {
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||
title: '', returnValue: '', open: false,
|
||||
/**
|
||||
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
|
||||
* `setAttribute` taught these factories in 2026-08-30, learned again on 2026-09-16.
|
||||
*
|
||||
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||
* display name is another player's text and must never be interpolated into markup. Without
|
||||
* this the district panel throws on every render.
|
||||
*/
|
||||
appendChild: () => {},
|
||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||
showModal() {
|
||||
@@ -1894,6 +1912,15 @@ describe('the static build', () => {
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||
title: '', returnValue: '', open: false,
|
||||
/**
|
||||
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
|
||||
* `setAttribute` taught these factories in 2026-08-30, learned again on 2026-09-16.
|
||||
*
|
||||
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||
* display name is another player's text and must never be interpolated into markup. Without
|
||||
* this the district panel throws on every render.
|
||||
*/
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {},
|
||||
showModal() {},
|
||||
close() {},
|
||||
@@ -2641,8 +2668,16 @@ describe('the static build', () => {
|
||||
*/
|
||||
const base = { day: 2, stage: 5, clock: '2:20', phase: 'Mainline', phaseKey: 'mainline' };
|
||||
|
||||
/**
|
||||
* AND AN AUTOMATIC PHASE NAMES THE WORK, NOT AN ABSENCE (playtest, 2026-09-16). It used to read
|
||||
* "waiting on nobody — the Division is running itself": an answer by negation, on a line whose
|
||||
* whole job is to say where the game is. `base` is the Mainline Phase, so this is the case Jesse
|
||||
* described — the Division moving trains with nobody to wait for.
|
||||
*/
|
||||
const idle = turnChartHtml({ ...base, actor: null, awaiting: null }, null, 'Bob');
|
||||
assert.match(idle, /nobody — the Division is running itself/, 'an automatic phase should say so');
|
||||
assert.match(idle, /the Division is moving trains/, 'an automatic phase should say what it is doing');
|
||||
assert.doesNotMatch(idle, /waiting on/, 'nobody is being waited on, so the line must not claim it');
|
||||
assert.doesNotMatch(idle, /nobody/, 'the line still answers by negation');
|
||||
|
||||
for (const [asks, train] of [
|
||||
['a clearance ruling', 'Train 4'],
|
||||
@@ -2672,10 +2707,15 @@ describe('the static build', () => {
|
||||
it('names the Superintendent at a table, and stays quiet about it in solitaire', () => {
|
||||
/**
|
||||
* REPORTED BY JESSE 2026-08-23, playing two-player on StartOS: seat 1 played a train card and
|
||||
* seat 2 was asked to build the train. The engine was right — §7 makes a consist up "starting
|
||||
* with the Superintendent and working left" — but nothing on the board said who the
|
||||
* seat 2 was asked to build the train. The engine was right — §7 makes a consist up starting
|
||||
* with the Superintendent and working EASTWARD — but nothing on the board said who the
|
||||
* Superintendent WAS, so the question could not be answered from the screen. The Frame has
|
||||
* carried `superintendent` since v0.4.0 and only the standalone replay ever drew it.
|
||||
*
|
||||
* The rule text said "working left" until 2026-09-16. It is the same rule — `playerLeftOf` is
|
||||
* increasing seat index — but "left" describes a table nobody is looking at, while the map on
|
||||
* screen runs west to east, so at a real three-player game it read as plainly wrong: the second
|
||||
* car went to the player sitting to the EAST. The word changed; the order did not.
|
||||
*/
|
||||
const frame = { day: 1, stage: 4, clock: '2:00', phase: 'New Train', phaseKey: 'newTrain', actor: 1 };
|
||||
const table = turnChartHtml(frame, 'Bob', 'Bob');
|
||||
@@ -2689,7 +2729,7 @@ describe('the static build', () => {
|
||||
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/starting with the Superintendent and working left/,
|
||||
/starting with the Superintendent and working eastward/,
|
||||
'the New Train pill does not say whose turn the make-up round starts on',
|
||||
);
|
||||
});
|
||||
@@ -2767,6 +2807,21 @@ describe('the static build', () => {
|
||||
assert.ok(fallback !== '', 'the no-git fallback moved and this test cannot see it any more');
|
||||
assert.doesNotMatch(fallback, /^'nogit'$|^"nogit"$/, 'the no-git fallback is a constant again');
|
||||
assert.match(fallback, /Date\.now\(\)/, 'the no-git fallback carries nothing that varies per build');
|
||||
/**
|
||||
* AND IT MUST NOT LEAD WITH THE VERSION — which is what fixing the constant first reached for.
|
||||
*
|
||||
* The stamp is `v${pkg.version} · ${git} · ${when}Z`, so a fallback of `${pkg.version}-${…}`
|
||||
* spends the version twice, and does it on precisely the builds that take this path: every
|
||||
* `.s9pk`, because the Dockerfile copies the tree in without `.git`. Reported from play —
|
||||
* Jesse, 2026-09-16: *"the version number is in the header twice."* The uniqueness this test
|
||||
* exists to defend comes from the timestamp, not from the version, so the two requirements do
|
||||
* not compete.
|
||||
*/
|
||||
assert.doesNotMatch(
|
||||
fallback,
|
||||
/pkg\.version/,
|
||||
'the no-git fallback repeats the version the stamp already prints in front of it',
|
||||
);
|
||||
});
|
||||
|
||||
it('lets a build-tagged URL be cached and nothing else', () => {
|
||||
@@ -3614,6 +3669,108 @@ describe('the Day rolling over says so (Gitea#10)', () => {
|
||||
assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`);
|
||||
});
|
||||
|
||||
it('draws the Home Office deck, face down, with its count', () => {
|
||||
/**
|
||||
* `f.deck` has carried the face-down count since the Frame existed and NOTHING drew it — the
|
||||
* exact display gap `test/display-gaps.test.ts` sweeps for, surviving in the panel that draws
|
||||
* every other pile. Asked for by Jesse 2026-09-10, who also wanted somewhere for a draw to
|
||||
* flash: taking a card off this deck is the commonest move nobody can see.
|
||||
*/
|
||||
const s = createEngineGame({
|
||||
id: 'piles',
|
||||
seed: 5,
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 60,
|
||||
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Joe', 'Bot 1'],
|
||||
});
|
||||
const f = snapshot(s, [], null);
|
||||
assert.ok(f.deck > 0, 'the deal should leave cards in the Home Office deck');
|
||||
|
||||
const html = pilesHtml(f);
|
||||
assert.ok(html.includes('Home Office'), `no Home Office pile:\n${html}`);
|
||||
assert.ok(html.includes(`>${f.deck}<`), 'the face-down count is not shown');
|
||||
// Face down means the card slot must NOT name a card — that is the whole point of the pile.
|
||||
assert.ok(html.includes('facedown'), 'the Home Office pile is not marked face down');
|
||||
assert.ok(html.includes('face down'), 'the card slot should say so rather than naming a card');
|
||||
// It comes first: a card travels out of here, then onto a Department or the Salvage Yard.
|
||||
assert.ok(
|
||||
html.indexOf('Home Office') < html.indexOf('Dept 1'),
|
||||
'the draw deck should be read before the piles cards land on',
|
||||
);
|
||||
});
|
||||
|
||||
it('lights only the pile a watched move touched', () => {
|
||||
const s = createEngineGame({
|
||||
id: 'piles2',
|
||||
seed: 5,
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 60,
|
||||
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Joe', 'Bot 1'],
|
||||
});
|
||||
const f = snapshot(s, [], null);
|
||||
|
||||
assert.equal(pilesHtml(f).includes('pilelit'), false, 'nothing is lit when nothing was watched');
|
||||
|
||||
const home = pilesHtml(f, ['home']);
|
||||
assert.equal((home.match(/pilelit/g) ?? []).length, 1, 'exactly one pile should light');
|
||||
assert.ok(
|
||||
home.indexOf('pilelit') < home.indexOf('Dept 1'),
|
||||
'a Home Office draw must light the Home Office pile, not a Department',
|
||||
);
|
||||
|
||||
const dept2 = pilesHtml(f, ['dept1']);
|
||||
assert.equal((dept2.match(/pilelit/g) ?? []).length, 1);
|
||||
assert.ok(dept2.indexOf('Dept 2') > dept2.indexOf('Dept 1'), 'order sanity');
|
||||
// Two piles can move at once — a Department draw that refills from the deck.
|
||||
assert.equal((pilesHtml(f, ['home', 'dept0']).match(/pilelit/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
it('reports the ENDED Day\'s collisions, not the fresh Day\'s zero', () => {
|
||||
/**
|
||||
* Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it: *"It shows a total of two
|
||||
* collisions, but zero today. Since we just finished day one, that does seem to be a
|
||||
* contradiction."*
|
||||
*
|
||||
* The cause is a one-line ordering fact: `advance.ts` increments the Day and then zeroes
|
||||
* `collisionsToday`, and this dialog is drawn from the frame whose Day went UP — so it read the
|
||||
* fresh Day's zero and printed it beside a running total that could not agree with it. The count
|
||||
* is captured at the rollover now, and the dialog names the Day rather than saying "today".
|
||||
*/
|
||||
const s = createEngineGame({
|
||||
id: 'collide',
|
||||
seed: 5,
|
||||
config: {
|
||||
mode: 'competitive',
|
||||
days: 5,
|
||||
minCombinedRevenue: 60,
|
||||
maxCollisionsPerDay: 3,
|
||||
maxCollisionsTotal: 10,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Joe', 'Bot 1'],
|
||||
});
|
||||
// The state as the rollover out of Day 1 leaves it: two collisions happened, `today` is reset.
|
||||
s.clock.day = 2;
|
||||
s.collisionsPrevDay = 2;
|
||||
s.collisionsToday = 0;
|
||||
s.collisionsTotal = 2;
|
||||
|
||||
const html = dayEndHtml(snapshot(s, [], null));
|
||||
assert.ok(html.includes('Day 1 has ended'), `wrong Day named:\n${html}`);
|
||||
assert.ok(html.includes('<b>2</b> on Day 1'), `the ended Day's collisions are wrong:\n${html}`);
|
||||
assert.ok(html.includes('<b>2</b> in all'), `the running total is wrong:\n${html}`);
|
||||
assert.doesNotMatch(html, /<b>0<\/b> today/, `still reporting the fresh Day's zero:\n${html}`);
|
||||
// The contradiction itself: a Day-end dialog must never claim fewer in all than on that Day.
|
||||
assert.doesNotMatch(html, /<b>0<\/b> on Day 1/, 'reported no collisions on a Day that had two');
|
||||
});
|
||||
|
||||
it('counts the last Day as the last Day rather than promising more', () => {
|
||||
const html = dayEndHtml(frameAt(6));
|
||||
assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed');
|
||||
@@ -4045,6 +4202,16 @@ describe('the lobby screen', () => {
|
||||
addEventListener: () => {}, showModal: () => {}, close: () => {}, focus: () => {},
|
||||
querySelectorAll: (sel: string) => matching(sel),
|
||||
querySelector: (sel: string) => matching(sel)[0] ?? null,
|
||||
/**
|
||||
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the same
|
||||
* lesson `setAttribute` taught this factory in 2026-08-30, learned again on 2026-09-16.
|
||||
*
|
||||
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||
* display name is another player's text and must never be interpolated into markup. Without
|
||||
* this the district panel threw on every render, which took out 21 tests across three suites
|
||||
* — and the thing it was hiding was a control nothing had ever exercised.
|
||||
*/
|
||||
appendChild: () => {},
|
||||
};
|
||||
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
|
||||
return node;
|
||||
@@ -4103,6 +4270,30 @@ describe('the lobby screen', () => {
|
||||
const chosen = (groups: Record<string, { value: string; checked: boolean }[]>, name: string): string | undefined =>
|
||||
groups[name]!.find((r) => r.checked)?.value;
|
||||
|
||||
/**
|
||||
* A SEAT RECOVERY LINK — Gitea#33.
|
||||
*
|
||||
* The properties that make this safe to hand round are the ones worth pinning: the page trades the
|
||||
* CODE for the token (so no token is ever in a URL), and it does not keep the code afterwards. The
|
||||
* store's own single-use and expiry rules are proven in `test/server/claims.test.ts`; this is the
|
||||
* client half, which is the part that could silently stop asking.
|
||||
*/
|
||||
it('trades a ?claim= code for a seat, and does not leave the code in the address bar', async () => {
|
||||
const { sent } = await open('?claim=code-123', {
|
||||
'/api/claim': { token: 'tok-restored', gameId: 'game-9', player: 1, gameCode: 'WHISTLE-6945' },
|
||||
});
|
||||
|
||||
const claim = sent.find((r) => r.url.includes('/api/claim'));
|
||||
assert.ok(claim, 'the page never redeemed the code');
|
||||
assert.deepEqual(claim.body, { code: 'code-123' }, 'the code was not sent as the request body');
|
||||
// The token must never travel in a URL (`lobby-and-sessions.md` §1) — it comes back in the
|
||||
// response, and the only thing that went out was the one-time code.
|
||||
assert.ok(
|
||||
!sent.some((r) => r.url.includes('tok-restored')),
|
||||
'a session token appeared in a request URL',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens on the join door, with the create form behind it', async () => {
|
||||
// Somebody who was handed a code used to have to scroll past the entire create form to find the
|
||||
// box to type it into.
|
||||
@@ -4418,6 +4609,9 @@ describe('the solitaire setup screen', () => {
|
||||
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
|
||||
checked: false, disabled: false, hidden: false, className: '',
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
||||
// The Office Area's per-seat buttons are created and appended rather than interpolated, so a
|
||||
// node that cannot be appended to throws on every render — see the note in the other factory.
|
||||
appendChild: () => {},
|
||||
addEventListener: (type: string, fn: () => void) =>
|
||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||
showModal: () => void ((node as { open: boolean }).open = true),
|
||||
@@ -5286,3 +5480,72 @@ describe('the Superintendent ruling names the train it is ruling on', () => {
|
||||
assert.match(title, /Train 4/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* §2.2 and §9.2 together strand every coach in the Classification Yard, and the board does not say
|
||||
* so — Jesse's ruling of 2026-09-17 is that the rule stands and the game says it loudly.
|
||||
*
|
||||
* Tested on the FRAME rather than the DOM, because the counts are what the warning is derived from
|
||||
* and they are what could go wrong: the panel asks for a per-type count the Frame already carries.
|
||||
*/
|
||||
describe('a Division Yard with no coaches is a reportable condition', () => {
|
||||
it('carries per-type counts for both yards, so the panel can tell coaches from cars', () => {
|
||||
const game = newGame(4242);
|
||||
game.state.yards.divisionYard = [{ type: 'boxcar', loaded: true }, { type: 'hopper', loaded: false }] as never;
|
||||
game.state.yards.classificationYard = [
|
||||
{ type: 'coach', loaded: false },
|
||||
{ type: 'coach', loaded: true },
|
||||
] as never;
|
||||
const f = view(game);
|
||||
|
||||
const coachesInDivision = f.yards.division.find((c) => c.type === 'coach');
|
||||
assert.equal(coachesInDivision, undefined, 'the fixture put no coach in the Division Yard');
|
||||
assert.notEqual(f.yards.divisionTotal, 0, 'a yard holding freight is not bare, which is the whole point');
|
||||
|
||||
const waiting = f.yards.classification.find((c) => c.type === 'coach');
|
||||
assert.ok(waiting, 'the coaches in Classification were not reported by type');
|
||||
assert.equal(waiting.loaded + waiting.empty, 2, 'the waiting coaches were miscounted');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The Quickstart is published beside the game, so a tester on the box can reach it.
|
||||
*
|
||||
* WHY THIS IS A TEST. The link on the splash page is a plain href to a file the BUILD copies out of
|
||||
* `docs/`. Nothing else connects the two: rename the document, or move it, and the build quietly
|
||||
* publishes nothing while the splash page keeps offering a link that 404s. Neither `tsc` nor any
|
||||
* other test would notice — the whole failure lives between a file name and a string.
|
||||
*/
|
||||
describe('the Quickstart guide reaches the site', () => {
|
||||
it('is published into dist and linked from the splash page', () => {
|
||||
const guide = join(dist, 'quickstart.md');
|
||||
assert.ok(existsSync(guide), 'the build did not publish quickstart.md');
|
||||
|
||||
const text = readFileSync(guide, 'utf8');
|
||||
assert.match(text, /^# Station Master — Quickstart/, 'quickstart.md is not the guide');
|
||||
assert.match(
|
||||
text,
|
||||
/Describes the game as built at v/,
|
||||
'the guide does not say which build it describes',
|
||||
);
|
||||
|
||||
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
|
||||
assert.match(splash, /href="\.\/quickstart\.md"/, 'the splash page does not link the guide');
|
||||
});
|
||||
|
||||
it('is served as text rather than handed over as a download', () => {
|
||||
/**
|
||||
* The server's MIME fallback is `application/octet-stream`, which a browser downloads instead of
|
||||
* displaying — so the link would hand a tester a file to save rather than a page to read. The
|
||||
* table is read straight out of the source: asserting on a copy of it would pass while the real
|
||||
* one was wrong.
|
||||
*/
|
||||
const http = readFileSync(join(root, 'src/server/http.ts'), 'utf8');
|
||||
const table = http.slice(http.indexOf('const MIME'), http.indexOf('const HEARTBEAT_MS'));
|
||||
assert.match(table, /'\.md':\s*'text\/plain/, 'a .md file would be served as a download');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user