Compare commits

..
4 Commits
Author SHA1 Message Date
Jesse.MarkowitzandClaude Opus 5 7c35e002af v0.4.9e — five of six playtest bugs: one button per train, and a load that has to go somewhere
Gameplay testing on 0.4.9d returned six reports. Five are fixed; the sixth could not be
reproduced and is written up in TODO.md with the two questions that would pin it down.

TWO TRAINS AT ONE PLATFORM ANSWERED TO ONE BUTTON. `porter.board` and `porter.detrain`
carried no tray, so there was one button per platform however many trains stood at it and
the reducer filled the first empty coach on the A/D tracks. `check` and the reducer were not
even asking the same question: `check` skipped a train whose card refuses passenger work and
the reducer did not. Both intents now carry an optional `trayId`, one function resolves the
train and the coach for check/execute/reduce alike, `legal.ts` offers one candidate per train,
and the label names it.

A LOAD COULD BE MADE AND BROKEN WITHOUT GOING ANYWHERE. A Freight House could unload the
boxcar it had just loaded; a platform could detrain the passengers it had just boarded. Full
Revenue at both ends for a movement that never happened. Jesse's rule: a load made anywhere in
an Office Area may not be broken anywhere in that Office Area, ever — it has to be carried to
another district. The load carries the seat that made it (`RollingStock.origin`), stripped by
`pooled` at every yard push. Measured at -0.60 +/- 0.10 Revenue a game (t = -6.1) over 400
paired deals: 78 worse, 3 better, 319 unchanged — free Revenue coming off the board, not a nerf.

THE GROCER'S WAREHOUSE SHIPPED AND THE REFINERY RECEIVED. Both were `flow: 'both'` on the
reading that "Freight House" was a collective term for exactly those two, and therefore what
§9.3 described. The engine has dealt a Freight House CARD since before v0.4.9, so §9.3 names
it and the argument goes. The card set agrees: all three Refinery modifiers grant +1 outbound.
Refinery outbound-only, Grocer's inbound-only, Freight House the one two-way industry — which
leaves exactly the one same-district pairing the rule above refuses.

NOT REPRODUCED: cars left behind when backing up over them. Five layouts tried, including cars
spotted at an industry; every one couples the lot. Three are pinned in `apply.test.ts`. One way
to create such cars was closed anyway — `flyingSwitch` wrote its cut past `carsOn`.

Both published replays that had gone dead were re-recorded; a rules change retires a save, and
`harness.test.ts` is what catches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nbvwWMef8CuEP6t5cgkTv
2026-08-21 23:55:08 -04:00
Jesse f9c4d9fa92 v0.4.9d — three playtest bugs: a train that looked gone, an unload that ignored the pick, a hang that wasn't one
Patched directly onto 0.4.9a rather than the in-progress 0.5.0 line.

A switching train vanished from the board the moment it left the Office. `selectedTrain` in
`officeSvg` (board-svg.ts), which gates whether a card draws a train's crew badge, was only ever
assigned inside the `cell.adTracks !== null` branch — true for the Office card alone. A train
standing anywhere else, which is everywhere it stands while actually being switched, drew no badge
at all. Game state was never affected; confirmed against the reported save with the engine directly.
Fix hoists the assignment out of the guard so it runs for any card with a train on it.

Unloading always took the westmost car, whichever one was picked. `laborer.beginUnload` carried the
player's chosen `carIndex`, and `check()` validated that specific car, but the `unloadBegan` event
it produced carried only the car's type — the reducer that performs the swap re-derived the target
with `industryTrack.cars.findIndex(c => c.loaded)`, which always answers the first loaded car in
track order regardless of what was requested. Fix adds `carIndex` to the event and uses it directly.

A legal decision could render with zero buttons, which looked exactly like a hang. The "where does
this Extra start" decision is titled by `trainCardTitle`, beginning "Making up Extra X22…" — the
same prefix `renderActions()` stripped from the action list on the assumption it only ever belonged
to the separate yard-chip car-placement panel. With no tray yet being filled, that panel is null, so
nothing rendered the Extra's decision either: a legal, correctly-computed option with no button
anywhere on the page. Replaying the reported save found no engine deadlock at any step — the engine
always had a move. Fix matches the exact title of the one group the yard-chip panel covers instead
of a title-prefix regex.

New tests for all three: a train parked on an ordinary facility card must draw its crew badge; three
differently-typed loaded cars, unloading index 2, must leave indices 0 and 1 alone; the existing
softlock regression test now mirrors the real render filter instead of only the raw menu, plus a
deterministic test for the exact reported scenario. 590 tests, 0 failures.
2026-08-20 23:26:39 -04:00
Jesse 3a5a909ec0 v0.4.9a — box art opens full size, a third splash card, and a scoped footer claim
Splash page only: the box art thumbnail opens a full-size lightbox on click, a
new "Play multiplayer" (coming soon) card balances the bottom row to three
cards, and the footer's "runs entirely in your browser" claim is now scoped
to solitaire.
2026-08-20 07:40:58 -04:00
JesseandClaude Sonnet 5 859817d173 v0.4.9 — X,Y coordinates, no-switching can still clear the mainline, Q3 corrected, a clearance bug, three sounds, and the box art
A playtest review of seed 58228926 (day 6), plus one long-standing display complaint and the
first real audio beyond a placeholder.

- Coordinate labels read X,Y everywhere shown to a player, not the internal Y,X storage order.
  Display-only.
- "No switching" now means may not add or drop cars, not "never touch it" — these trains can
  still be moved onto Secondary Track to clear the mainline.
- Q3 corrected: Expedite governs WHERE a train may be left standing, not WHEN it leaves. The
  forced same-Stage departure is gone; a new fault costs 1 Revenue if an expedited train is left
  off the station when a Mainline Phase begins. Resolves "3/4 Express prints a rule it can never
  use" as a side effect.
- evaluateClearance now checks every occupant on a Mainline card before offering a judgment
  call, instead of returning on whichever it found first — found while explaining a playtest
  report, fixed with a regression test.
- Three new synthesised sounds: arrive, depart, crash.
- The splash page shows the box art.

Full detail, measurements and reasoning in CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FvU99NEakShRMg3nN3fHAZ
2026-08-19 23:51:35 -04:00
38 changed files with 4093 additions and 2550 deletions
+330
View File
@@ -19,6 +19,336 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
--- ---
## 0.4.9e — 2026-08-22
Six bugs came back from a gameplay-testing session on 0.4.9d. Five are fixed here; the sixth could not
be reproduced and is written up in `TODO.md` with the two questions that would pin it down. Patched
onto the 0.4.9d line, and applied to the 0.5/0.6 line as the same change.
### Two trains at one platform answered to one button
**REPORTED:** "Operating two trains in a station: the select button does not work. Regardless of which
you pick, it is always one train, not the other."
It did not work because there was nothing for it to do. `porter.board` and `porter.detrain` carried
**no tray at all** — `{ type, at }` and nothing else — so there was one "board passengers at (0,0)"
button however many trains were standing at the platform, and the reducer walked `adOccupancy` and
filled the first empty coach it met. The roster chip the player clicked chose which crew the board
DREW and nothing else. Two independent things were wrong at once:
- `check` asked whether SOME train at the Office had an empty coach, skipping any whose card refuses
passenger work (`refusesPassengers`, `refusesThisOffice`). The reducer did not skip those. So with a
Military train and an ordinary one at the same platform, `check` said yes on behalf of the ordinary
one and the reducer boarded the Military.
- The action list collapses identical labels, and "board passengers at (0,0)" describes both trains —
the same trap that once ate a turnout's second rotation and a Department discard.
Both intents now carry an optional `trayId`, one function (`passengerWork`) resolves which train and
which coach for `check`, `execute` and the reducer alike, `legal.ts` enumerates one candidate per
train standing at the Office, and the label names it: *"board passengers at (0,0) onto Train 9"*.
`trayId` is OPTIONAL for the reason `switch.move`'s `via` is — intents are the canonical record every
save and undo replay against, and absent still means "the first eligible train".
The events carry `trayId` and `coachIndex` rather than leaving the reducer to find them again, which
is the lesson `unloadBegan`'s `carIndex` already taught: a reducer that re-derives the target is a
second implementation of the rule, and it disagreed with the first.
### A load could be made and broken without going anywhere
**REPORTED, twice over:** "Freight House: boxcars loaded cannot be immediately unloaded. In the game
we'll put the chip upside down in the tray to indicate." And: "Passenger stations: passengers just
boarded cannot be immediately unloaded."
They could. A Freight House permits both directions, so the boxcar its own Laborers had just loaded
was standing on its own industry track, loaded, with an empty of that type in the Division Yard and a
free red box — every gate said yes. Passengers were worse: `porter.board` filled a coach and
`porter.detrain` looked for "a loaded coach on a train at the Office", which is the coach that had
just been filled. Full Revenue at both ends of a movement that never happened, for one Porter action.
**Jesse's rule, and it is wider than the report:** freight or passengers loaded anywhere in an Office
Area may not be unloaded anywhere in that same Office Area — not at another facility, not in a later
Stage. A train has to carry them to a different district. So a load carries a stamp naming the SEAT
that made it (`RollingStock.origin`), and the stamp never expires; `laborer.beginUnload` and
`porter.detrain` refuse a car stamped with the district they are standing in, with a rejection code of
its own (`LOADED_IN_THIS_DISTRICT`) because "the car is loaded, the Laborer is free, the boxes are
clear, and the only thing wrong is where it came from" deserves better than "wrong car".
**A seat, not a player**, because Employee Rotation moves players between chairs and the district
stays with the chair. **Undefined, not −1**, for "no origin": the Division Yard opens with loaded cars
and loaded coaches out of the common supply, and those are exactly the inbound traffic a solitaire
district lives on — a sentinel inside `SeatIndex`'s own range is not a sentinel. And `pooled` strips
the stamp at every yard push, because the stamp belongs to the LOAD: a train can retire at a Division
Point with freight still aboard, and that car must not carry a district it left three Days ago into
whatever train is made up from it next.
**Measured: −0.60 ± 0.10 Revenue a game** (t = −6.1) over 400 paired deals — 78 deals worse, 3 better,
319 unchanged. That shape is the point. This is not a nerf spread across the game; it is a narrow
piece of free Revenue coming off the board, and on four deals in five the bot never took it.
The screen's version of the upside-down chip: a car or coach loaded by this district reads *"loaded
boxcar (loaded here)"* on the card, in the tray and in the facility panel.
### The Grocer's Warehouse shipped, and the Refinery received
**REPORTED:** "Grocer's warehouse should be receive only, does not ship anything out." And:
"Refinery: only ships out tanks, does not receive anything."
Both were `flow: 'both'` in `content.ts`, put there deliberately and for a reason that has since
collapsed. `card-reference.md` read: *"'Freight House' is not a card. It is the collective term for a
freight facility that loads and unloads — the Grocer's Warehouse and the Oil Refinery."* If that were
true, §9.3's "Passenger Facilities and Freight Houses permit cars to move each direction" named
exactly those two, and they had to be two-way. But the engine has dealt a `freightHouse` card since
before v0.4.9 — 6 copies, one slot each direction — so §9.3 names it, and the argument evaporates.
The card set says the same thing without needing the rules text. All three Refinery modifiers —
Pipelines, Oil Depot, Viscosity Breakers — grant **+1 outbound**; a two-way Refinery would be the only
industry in the game with no card able to raise one of its two directions.
`StationMaster-Home-Deck-v0.4.5.md` prints "Refinery · Outbound · 1 out / 0 in" and "Grocer's
Warehouse · Inbound · 0 out / 1 in".
So the Refinery ships and the Grocer's receives, and the **Freight House is the one two-way industry**
— which also means the only same-district load-and-unload the district rule above has to refuse is a
Freight House unloading its own work. The two fixes meet exactly where the report said they would.
A consequence worth naming rather than discovering: an **Ice House beside a Grocer's Warehouse is now
a dead card**, its +1 outbound dropped on a direction the host does not have. That is the design, not
an oversight — the Home Deck sheet says so outright, and names the Truck Dock's inbound grant beside
the outbound-only Packing Sheds as the other example. `suppressedGrants` already reports it on the
page. It does mean the v0.4.7 note in `TODO.md` that opened these two facilities up was half wrong,
and it is annotated there rather than deleted: the *machinery* it built (an industry's printed flow is
absolute; drop the grant, never open the direction) is exactly what makes this correction land.
### Not reproduced: cars left behind when backing up over them
**REPORTED:** "When I back up to collect standing cars and, further down the tracks, the caboose, I
get the caboose but the cars remain. I can later drive right through them."
Not found, and not for want of looking. Cars on plain track on the way; cars spotted at an INDUSTRY on
the way (Jesse's own guess at the shape); the train's own cut on the square it is pulling out of; a
stale `standingWest`; an industry locked by MEN AT WORK. Every one couples the lot, and the last
correctly blocks the whole route rather than letting the crew past. Three of them are now pinned in
`apply.test.ts` so the case, when it is found, is somewhere none of them cover.
The reason it is hard to make happen is structural: coupling is mandatory (§A.4) and `exploreMoves`
accumulates what it meets card by card, so a route that reaches the caboose has already met everything
between. `carsOn` is the single answer to "what is standing here", and the movement walk, the sweep in
`carsCoupled` and every renderer all ask it — so cars a train can drive through would have to be cars
that are on screen and not in `carsOn`, and there is no such place.
There was one way to MAKE such a place, and it is closed: `flyingSwitch`'s reducer wrote the cut
straight into `industryTrack`, which is not where `carsOn` looks on a Passenger Facility. `check`
refuses a non-freight target so it never fired, but a trap that needs another rule to stay unsprung is
still a trap; it goes through `carsOn` now.
`TODO.md` carries the two questions that would settle it: was there a second route to the caboose, and
what did the move button say it would couple. The label names every car, so "couples caboose" and
"couples 2 boxcars, caboose" are different bugs — the first is route selection, the second the sweep.
### Also
- **An unload never checks the facility's commodity** — found reading `laborer.beginUnload` for the
district rule, not from play. It gates on `allows.inbound`, a loaded car, a matching empty in the
yard and room in the red box, but never on `facilityCarTypes`, which `freightAgent.stockOutbound`
does check. So a Freight House will unload a hopper. Left alone and logged: the district rule now
refuses the one same-Office pairing that made it easy to reach, and the fix is a rule question about
what an industry will accept, not a one-line guard.
- **Both published replays that had gone dead were re-recorded** (`save-replay.ts 400 --top 3`). A save
is a save from a particular ruleset, so a rules change retires the files that no longer replay —
`harness.test.ts` catches it, which is what that test is for.
## 0.4.9d — 2026-08-21
Three bugs from the same playtest session, patched directly onto 0.4.9a rather than the in-progress
0.5.0 line: a display bug that made a switching train look like it had vanished, an engine bug that
silently ignored which car the player chose to unload, and a UI bug that could render a legal
decision with zero buttons — indistinguishable from a hang.
### A switching train vanished from the board the moment it left the Office
**REPORTED:** Train 3 stood at the Office; dropping the loaded boxcar off the back at the Freight
House made the train "vanish off the face of the earth," regardless of which way it moved
afterward. The game state was never wrong — replaying the reported save
(`docs/station-master-seed116956197-day2.json`) through the engine directly showed `tray3` fully
intact, in the right place, with the right consist, for the rest of the game. Nothing was ever lost
from `state.trays`.
The bug was in `officeSvg` (`board-svg.ts`): `selectedTrain` — the value that decides whether a
card draws a train's crew badge (its label, engine arrow, and cars) — was assigned only inside the
`cell.adTracks !== null` branch, which is true for the Office card and nothing else. A train
standing on any other card, which is every card it stands on while actually being switched, got no
badge at all. It disappeared on the very first move off the Office, not only at the Freight House —
that was simply the first place a player was likely to pause and look. Traced to the Roster Pass
(37b1e5b), which rewrote `CellView.train` into `CellView.trains[]` to show every train sharing the
Office's A/D tracks and, in doing so, nested the general consist-drawing logic inside that
Office-only branch instead of leaving it to run for any card with a train on it.
**Fixed** by hoisting the `selectedTrain` assignment out of the `adTracks !== null` guard so it
runs for every card; the A/D roster-chip loop itself stays inside the guard, since only the Office
has more than one A/D track to draw chips for. New test: a train parked on an ordinary facility
card, `adTracks: null`, now has to draw its crew badge — it did not before this fix.
### Unloading always took the westmost car, whichever one was picked
**REPORTED:** three loaded cars stood at a Freight House; asking to unload the east (right-hand)
car unloaded the west one instead, every time, no matter which car was actually chosen.
`laborer.beginUnload` already carried the player's choice as `carIndex`, and `check()` validated
that specific car — but the event it produced, `unloadBegan`, carried only the car's `carType`, not
which one it was. The reducer that actually performs the swap re-derived the target itself with
`industryTrack.cars.findIndex(c => c.loaded)`, which always answers the first loaded car in track
order regardless of what was requested — so the player's choice was thrown away between `check` and
the reducer, and the westmost loaded car came off every time.
**Fixed** by adding `carIndex` to the `unloadBegan` event and using it directly in the reducer
(`src/engine/apply.ts`, `case 'unloadBegan'`), instead of re-deriving a car by scanning for
`loaded`. New test in `apply.test.ts`: three loaded cars of three different types, asking to unload
index 2 (east) now leaves indices 0 and 1 alone and empties exactly the one requested — it did not
before this fix.
### A legal decision could render with zero buttons — the game looked hung
**REPORTED:** working the Freight House mid-cycle (unloading three cars, then reloading them), the
game "seemed to have hung up. Cannot advance." No trains were on the Division or the Limits.
Replaying the reported save turned up no engine deadlock at any point — every step had a legal
action — but the exact final state (an Extra train due to start, no tray yet being made up) exposed
a UI bug: `newTrain.startExtra`'s action group is titled by `trainCardTitle`, which begins "Making
up Extra X22…" — the same prefix `renderActions()` (`main.ts`) used to strip from the action list on
the assumption it always belonged to the separate yard-chip car-placement panel. With no tray being
filled yet, that panel (`menu.makeUp`) is `null`, so nothing else rendered the Extra's "choose where
it starts" decision either — a legal, present, correctly-computed option with no button anywhere on
the page. Clicking nothing did nothing, which is indistinguishable from a hang.
**Fixed** by matching the exact title of the one group `menu.makeUp` actually covers
(`g.title !== menu.makeUp?.title`) instead of a title-prefix regex, so any other group that happens
to start "Making up …" — however it got that title — stays on screen. The existing softlock
regression test (`never strands the player with a legal move and no way to make it`) now mirrors the
real render filter instead of only checking the raw menu, so this class of bug fails that test
directly; a new deterministic test constructs the exact reported scenario (a pending Extra, no
active make-up) and asserts its group survives the filter.
590 tests, 0 failures.
---
## 0.4.9a — 2026-08-20
Four small tweaks to the splash page (`index.html`, `splash.ts`), no engine changes.
### Box art opens full size on click
The thumbnail beside the title was too small to read. Clicking (or Enter/Space on keyboard focus)
now opens it in a full-size lightbox overlay; click the overlay or press Escape to close.
### A third card balances the bottom row
The doors row was nested beside the box art, two cards wide, leaving the width under the box art
unused. Moved to a full-width row below the hero (three columns at roughly the same card width as
before) and added **Play multiplayer**, modeled on the Play Solitaire card but describing the
not-yet-built feature, with "Coming soon" in place of a link. Its text is shorter than Play
Solitaire's so the row height — and the page's vertical size — doesn't change.
### Footer's "runs in your browser" claim scoped to solitaire
Now reads "solitaire runs entirely in your browser" rather than an unqualified claim that would no
longer hold once multiplayer needs a server.
---
## 0.4.9 — 2026-08-19
A playtest review of seed 58228926 (day 6), plus one long-standing display complaint and the first
real audio beyond a placeholder. Six changes: one display fix, two rules corrections, one engine bug
found while explaining a report rather than reported directly, three new sound cues, and the splash
page's box art.
### Office coordinates read X,Y now, not Y,X
Reported as confusing for anyone reading them the ordinary way: "at game start the office is 0,0;
the western limit should be -1,0, the eastern +1,0; a restaurant due north should be 0,1." The
engine's internal `GridCoord{row,col}` already has exactly that geometry — row increases north, col
increases east (`track.ts:59`) — so this was a **display-only fix**, not a data-model change: every
place a coordinate is shown to a player now prints `(col,row)` instead of `(row,col)`. The on-card
label in `board-svg.ts` (the one everyone sees), the switching-crew tooltip and button label in
`main.ts`, the rejected-option and switching-group text in `game.ts` and `view.ts`, and the
blocked-move text in `narrate.ts`. Internal `Map` keys built from `${row},${col}` are untouched —
nothing reads them but lookup code.
### "No switching" now means no adding or dropping cars — not "never touch it"
Reported from play: a train sitting at the Office with NO SWITCHING printed on its card (the two
expresses, Light Engine, Campaign, Circus, Military) could not be moved onto Secondary Track to
clear the mainline for other traffic, even though nothing on the card says that. `switchingRefusal()`
(`apply.ts`) blocked `switch.move` outright for these six cards; it now only refuses a move that
would COUPLE a fresh car — the same way `rules.dropOnly` was already handled for X13 — so the crew
can still be shunted clear, it just cannot pick up, set out or sort. `switch.dropCars` and
`switch.sortConsist` stay blocked. The switching-moves list (`view.ts`) no longer excludes these
trains, and the card description text was rewritten from "it runs the Division and does not shunt"
to "may not add or drop cars, but may still be moved clear of the mainline."
### Q3, corrected: Expedite is about WHERE a train may be left, not WHEN it leaves
**The reported symptom:** Train 1 arrived at an Office and was forced out at the end of the same
Stage — the read since v0.4.2 — leaving no Local Operations turn to switch it. **The rule question
underneath it:** why should "Expedite" mean a train is rushed out early at all, rather than simply
never being left parked off the station?
Corrected to the latter. An expedited train now arrives and stands exactly like any other train,
released by the ordinary §8.1 "stood a full Stage at the Office" rule — it can be switched normally
in between. Deleted entirely: `arriveAtOffice`'s `departsThisStage` flag and the whole
expedited-departures pass that used to run in `shiftChange`, plus the now-dead `pass: 'mainline' |
'expedite'` parameter on `moveTrain`.
In its place: a new fault. At the top of every Mainline Phase, any expedited train sitting on the
grid but not on the Office square itself — left on Secondary Track to clear a switching move, say —
costs 1 Revenue (`EXPEDITE_FAULT_PENALTY`, `expediteFault`), charged again every Phase it is still
caught there. A train the ordinary rules are legitimately holding AT the station — even one §8.1 is
asking the Superintendent to rule on — is not a fault; only being left elsewhere in the district is.
**This resolves "3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE"** (`TODO.md`) as a side effect: the
Express can now actually reach a Local Operations turn to use its printed "may drop or pick up one
freight car at every location," which the old same-stage departure made structurally impossible.
**Measured (30 fresh bot games, standard length):** the fault fires, but the bot has no strategy for
it yet — one game left Train 4 (3/4 Express) parked off the station from Day 3 Stage 10 to the end of
the game, taking the -1 penalty 26 times. Not an engine bug: the mechanism is working as designed and
correctly reveals that the bot doesn't know to bring an expedited train home before ending a
switching turn. Logged in `TODO.md` under Bot Performance for a future pass.
### `evaluateClearance` now checks every occupant, not just the first one it finds
Found while explaining a second playtest report from the same game: the Superintendent was asked to
rule on a same-direction train instead of being automatically held against an opposite-direction one
also occupying the card. Root cause: the occupant loop (`advance.ts`) returned on whichever occupant
it examined first, in `node.transits` insertion order — correct while a Subdivision held at most one
train, but a card can now legitimately hold two at once, which is exactly what the
Telegraph/Telephone/Radio dispatch exception creates (a facing train dispatched past another sits on
the same card as it). Fixed to two passes: every occupant is checked for an opposite-direction
absolute bar first (subject to the dispatch exception) before any same-direction judgment call is
offered. Pinned with a regression test that fails against the old single-pass code
(`enhancements.test.ts`).
### Three more sounds, synthesised like the rest
`arrive`, `depart` and `crash` join `stage`/`couple`/`drop`/`schedule`/`completed`/`day`/`train` in
`sound.ts` — a train pulling into an Office (decelerating chuffs, a soft bell tap), one highballing
out of one (accelerating chuffs, a falling horn note), and a collision (dissonant tones bent sharply
down over a noise boom and a sub-bass thump), wired to `trainArrived`, `trainHighballed` (Office
departures only, so it doesn't double up with a fresh make-up's "All aboard" or a full division run's
`completed`), and `trainsDestroyed`. Three real WAV clips were sourced for this and considered, but
carried no license information anywhere — no `LICENSE` file, no embedded metadata, no note of where
they came from — so they were not committed; the synthesised versions are good enough to keep as the
real thing rather than a placeholder.
### The splash page has its box art
`docs/StationMasterSplashScreen.png` (1122×1402, 3.0 MB) is now a 560×700 JPEG at 145 KB
(`public/images/`, copied verbatim into `dist/images/` by a small addition to `build-web.ts`) and
sits beside the title, tagline, blurb and both buttons in a side-by-side hero on `index.html`,
stacking to image-above-text under the existing mobile breakpoint.
---
## 0.4.8 — 2026-08-19 ## 0.4.8 — 2026-08-19
Four reports from the same game, all about squares: which ones a card may go on, which one a button Four reports from the same game, all about squares: which ones a card may go on, which one a button
+8
View File
@@ -121,6 +121,14 @@ is the thing this machinery exists to prevent.
it was worth more than the entire freight and passenger economy put together, for traffic nobody it was worth more than the entire freight and passenger economy put together, for traffic nobody
has to work. A seed alone therefore no longer names a game — the settings ride in the URL beside has to work. A seed alone therefore no longer names a game — the settings ride in the URL beside
it, and every save records the rules it was dealt under. it, and every save records the rules it was dealt under.
- **A load may not be broken in the district that made it.** Freight or passengers loaded anywhere in
an Office Area cannot be unloaded anywhere in that same Office Area — not at another facility, not
in a later Stage. A train has to carry them to a different district first. The printed game turns
the chip upside down in the tray; here the load carries the seat that made it (`RollingStock.origin`
in `src/engine/state.ts`) and it never expires. Without it a Freight House could unload the boxcar
its own Laborers had just loaded and a platform could detrain the passengers it had just boarded,
each paying Revenue at both ends for a load that went nowhere: worth **0.60 ± 0.10 Revenue a game**
to the developer bot over 400 paired deals, on 78 of them.
- **A turnout can be laid on top of a card already down.** It upgrades a straight at any rotation, or - **A turnout can be laid on top of a card already down.** It upgrades a straight at any rotation, or
a curve whose arc matches its own diverging leg — both strict port supersets of what they replace, a curve whose arc matches its own diverging leg — both strict port supersets of what they replace,
so an upgrade can never sever an existing join. Without it a district could only hang off track that so an upgrade can never sever an existing join. Without it a district could only hang off track that
+549 -456
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

+30 -13
View File
@@ -42,17 +42,33 @@ Operational Rail wheel icon, an industry track of the stated length, Laborer ico
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | | --- | --- | --- | ---: | ---: | ---: | ---: | ---: |
| Mine Tipple | Hopper | Outbound only | 3 | 3 | — | 4 | 2 | | Mine Tipple | Hopper | Outbound only | 3 | 3 | — | 4 | 2 |
| Produce Shed | Reefer | Outbound only | 2 | 2 | — | 3 | 2 | | Produce Shed | Reefer | Outbound only | 2 | 2 | — | 3 | 2 |
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 | 2 | | Grocer's Warehouse | Boxcar | Inbound only | 2 | — | 2 | 3 | 2 |
| Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4 | 2 | | Oil Refinery | Tank car | Outbound only | 3 | 2 | — | 4 | 2 |
| Power Plant | Hopper | Inbound only | 3 | — | 3 | 4 | 2 | | Power Plant | Hopper | Inbound only | 3 | — | 3 | 4 | 2 |
Directions follow the commodity: coal originates at a Mine Tipple and is consumed at a Power Plant; Directions follow the commodity: coal originates at a Mine Tipple and is consumed at a Power Plant;
produce ships out; a warehouse and a refinery do both. This gives §9 all three of its cases — produce ships out; a warehouse receives. This gives §9 all three of its cases — outbound-only,
outbound-only, inbound-only, and both. inbound-only, and both — with the **Freight House the one card that does both**.
**"Freight House"** (§9.3, Appendix A) is not a card. It is the collective term for a freight facility **The Grocer's Warehouse and the Oil Refinery are ONE-WAY** (corrected v0.4.9e, from gameplay
that loads *and* unloads — the Grocer's Warehouse and the Oil Refinery. §9.3's "Passenger Facilities testing): "Grocer's Warehouse should be receive only, does not ship anything out"; "Refinery: only
and Freight Houses permit cars to move each direction" therefore names exactly those two. ships out tanks, does not receive anything" (Jesse). The Direction column read "Both" for both, on the
reading below that "Freight House" named exactly those two.
**And that reading is wrong.** §9.3/Appendix A's "Passenger Facilities and Freight Houses permit cars
to move each direction" names a CARD: the engine deals a `freightHouse` industry, 6 copies, one
Laborer and one slot each direction (`content.ts`), and has since before v0.4.9. This paragraph used
to read "'Freight House' is not a card — it is the collective term for a freight facility that loads
*and* unloads, the Grocer's Warehouse and the Oil Refinery", and that premise is what made those two
two-way. `StationMaster-Home-Deck-v0.4.5.md` prints them one-way, and the modifier set agrees: all
three Refinery modifiers (Pipelines, Oil Depot, Viscosity Breakers) grant **+1 outbound**, which would
be an odd card set for a facility that receives half the time.
<!-- The Laborer and capacity numbers in the rows above were NOT re-verified against the engine in
this pass — only the Direction column was, which is what playtesting reported on. `content.ts`
gives every industry base 1 out / 1 in / 1 Laborer, so the table's 2s and 3s and the "Throughput"
reasoning built on them are likely stale. Flagged rather than silently rewritten: Laborer counts
are a balance question, not only a docs one. -->
### Throughput — why these Laborer counts ### Throughput — why these Laborer counts
@@ -80,9 +96,10 @@ deliberately slower industries, unable to quite keep up with a dedicated player.
That is why Laborer counts track Outbound capacity: Mine Tipple 3/3, Power Plant 3/3, Produce Shed That is why Laborer counts track Outbound capacity: Mine Tipple 3/3, Power Plant 3/3, Produce Shed
2/2, Grocer's Warehouse 2/2. The numbers are derived from the action budget, not chosen freely. 2/2, Grocer's Warehouse 2/2. The numbers are derived from the action budget, not chosen freely.
The Oil Refinery is the exception at 3 Laborers against 2+2 capacity, and deliberately so: it serves <!-- The paragraph that stood here explained the Oil Refinery's third Laborer as the price of serving
two flows through one three-box pipeline, so its pipeline stays fuller than a one-way facility's and two flows through one pipeline. It serves one flow (v0.4.9e), so the explanation is gone with the
the third Laborer is earning its keep. premise; whether the Laborer count is still right is part of the same unverified block flagged
above and in TODO.md. -->
Even so, Laborers are rarely what limits a player — spotting the empty car and hauling the loaded one Even so, Laborers are rarely what limits a player — spotting the empty car and hauling the loaded one
away both cost switching actions from the same budget. See §7. away both cost switching actions from the same budget. See §7.
@@ -258,12 +275,12 @@ with all boxes full:
| Car | Facility demand | Supply | Headroom | | Car | Facility demand | Supply | Headroom |
| --- | ---: | ---: | --- | | --- | ---: | ---: | --- |
| Hopper | Mine Tipple 3×2 + Power Plant 3×2 = 12 | 12 | exactly met | | Hopper | Mine Tipple 3×2 + Power Plant 3×2 = 12 | 12 | exactly met |
| Tank | Oil Refinery (2+2)×2 = 8 | 8 | exactly met | | Tank | Oil Refinery 2×2 = 4 | 8 | 4 spare (was "exactly met" while the Refinery was two-way) |
| Boxcar | Grocer's (2+2)×2 = 8 | 12 | 4 spare | | Boxcar | Grocer's 2×2 = 4 | 12 | 8 spare (same correction) |
| Reefer | Produce Shed 2×2 = 4 | 8 | 4 spare | | Reefer | Produce Shed 2×2 = 4 | 8 | 4 spare |
| Coach | Terminal 4+4, per Office | 16 | scales with Office count | | Coach | Terminal 4+4, per Office | 16 | scales with Office count |
Hoppers and tank cars are exactly met in the theoretical worst case, which cannot occur in practice — Hoppers are exactly met in the theoretical worst case, which cannot occur in practice —
only 10 freight facility cards exist across a 52-card deck shared by all players, and the §2.2 only 10 freight facility cards exist across a 52-card deck shared by all players, and the §2.2
Classification Yard recycle returns stock to the Division Yard whenever it empties. Both are worth Classification Yard recycle returns stock to the Division Yard whenever it empties. Both are worth
watching in playtesting. watching in playtesting.
+2 -2
View File
@@ -24,8 +24,8 @@ Every defined term, alphabetized for lookup. The core comes from the Definitions
| **Extra Platform** | Modifier card: +1 green and +1 red slot at a Passenger Facility. | §12.5 | | **Extra Platform** | Modifier card: +1 green and +1 red slot at a Passenger Facility. | §12.5 |
| **Extra Train** | A one-and-done train; its card returns to the Salvage Yard on completion. Head-on card image, so the drawing player picks its direction. Numbered with an "X" prefix; the following number gives its seniority, and it yields to the Timetabled train of that number. | §2.3, §8 | | **Extra Train** | A one-and-done train; its card returns to the Salvage Yard on completion. Head-on card image, so the drawing player picks its direction. Numbered with an "X" prefix; the following number gives its seniority, and it yields to the Timetabled train of that number. | §2.3, §8 |
| **Facility** | A business which loads/unloads cargo and freight. | §2.5 | | **Facility** | A business which loads/unloads cargo and freight. | §2.5 |
| **Freight Facility** | Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power Plants. Some allow only outbound, some only inbound, some both. Per-card values in §12.5. | §9, §12.5 | | **Freight Facility** | Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power Plants, Freight Houses. Some allow only outbound, some only inbound, some both. Per-card values in §12.5. | §9, §12.5 |
| **Freight House** | Not a card — the collective term for a freight facility permitting both directions, namely the Grocer's Warehouse and the Oil Refinery. | §9.3, §12.5 | | **Freight House** | A sixth Freight Facility card, dealt like any other industry (`content.ts`) — this entry used to read "not a card, the collective term for the Grocer's Warehouse and the Oil Refinery", which the engine never agreed with. **The only industry that permits both directions**: the Grocer's Warehouse receives and the Oil Refinery ships, one way each (v0.4.9e). | §9.3, §12.5 |
| **Highball** | When a train holding at an Office automatically departs. | §2.4 | | **Highball** | When a train holding at an Office automatically departs. | §2.4 |
| **Home Office** | The primary face-down deck cards are drawn from. 52 cards. | §2.6, §12.1 | | **Home Office** | The primary face-down deck cards are drawn from. 52 cards. | §2.6, §12.1 |
| **Hopper** | Coal rolling stock (brown = loaded, white = empty). | §2.2 | | **Hopper** | Coal rolling stock (brown = loaded, white = empty). | §2.2 |
+7 -2
View File
@@ -670,10 +670,15 @@ balance work was possible.
| --- | --- | --- | ---: | ---: | ---: | ---: | | --- | --- | --- | ---: | ---: | ---: | ---: |
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 | | Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 | | Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 | | Grocer's Warehouse | Boxcar | Inbound | 2 | — | 2 | 3 |
| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 | | Oil Refinery | Tank | Outbound | 3 | 2 | — | 4 |
| Power Plant | Hopper | Inbound | 3 | — | 3 | 4 | | Power Plant | Hopper | Inbound | 3 | — | 3 | 4 |
*Amended v0.4.9e.* The warehouse and the refinery were briefly "Both", on the reading that "Freight
House" was a collective term for exactly those two and therefore what §9.3's "permit cars to move
each direction" described. The Freight House turned out to be a card of its own (v0.5.0), and
playtesting confirmed the one-way reading the sheet always printed.
*Rationale.* Directions follow the commodity and give §9 all three of its stated cases. Bulk *Rationale.* Directions follow the commodity and give §9 all three of its stated cases. Bulk
industries get more Laborers and a 4-car track so the types feel distinct when choosing what to industries get more Laborers and a 4-car track so the types feel distinct when choosing what to
build — but the counts stay moderate because Laborers are **not** the binding constraint (see 10e), build — but the counts stay moderate because Laborers are **not** the binding constraint (see 10e),
+7 -4
View File
@@ -735,8 +735,8 @@ Modifier effects, and track geometries — is catalogued in
| --- | --- | --- | ---: | ---: | ---: | ---: | | --- | --- | --- | ---: | ---: | ---: | ---: |
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 | | Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 | | Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 | | Grocer's Warehouse | Boxcar | Inbound | 2 | — | 2 | 3 |
| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 | | Oil Refinery | Tank | Outbound | 3 | 2 | — | 4 |
| Power Plant | Hopper | Inbound | 3 | — | 3 | 4 | | Power Plant | Hopper | Inbound | 3 | — | 3 | 4 |
| Office | Porters | Green slots | Red slots | | Office | Porters | Green slots | Red slots |
@@ -753,8 +753,11 @@ Modifier effects, and track geometries — is catalogued in
| Extra Platform | +1 green and +1 red slot (passenger) | | Extra Platform | +1 green and +1 red slot (passenger) |
| Section Gang | +1 Laborer or +1 Porter | | Section Gang | +1 Laborer or +1 Porter |
**"Freight House"** (§9.3, Appendix A) is not a card — it is the collective term for a freight **"Freight House"** (§9.3, Appendix A) is a Freight Facility card, dealt like any other industry —
facility that both loads and unloads, namely the Grocer's Warehouse and the Oil Refinery. this used to read "not a card, only the collective term for a facility that both loads and unloads",
which the engine never agreed with. It is the **only** industry that permits both directions: the
Grocer's Warehouse receives and the Oil Refinery ships, one way each (corrected v0.4.9e from gameplay
testing — the "Both" reading was the other half of the same mistake about what "Freight House" meant).
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.4.8", "version": "0.4.9e",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+89 -24
View File
@@ -1,7 +1,7 @@
{ {
"seed": 927523, "seed": 927523,
"title": "12 Revenue · seed 927523", "title": "11 Revenue · seed 927523",
"note": "12 Revenue over 5 Days · 2 train(s) on the timetable · 20 cards down · no collisions · played by developer", "note": "11 Revenue over 5 Days · 2 train(s) on the timetable · 21 cards down · no collisions · played by developer",
"rules": { "rules": {
"startingHand": "threeRandom", "startingHand": "threeRandom",
"revenue": { "revenue": {
@@ -320,21 +320,24 @@
"at": { "at": {
"row": 0, "row": 0,
"col": 0 "col": 0
} },
"trayId": "tray3"
}, },
{ {
"type": "porter.board", "type": "porter.board",
"at": { "at": {
"row": 0, "row": 0,
"col": 0 "col": 0
} },
"trayId": "tray3"
}, },
{ {
"type": "porter.detrain", "type": "porter.detrain",
"at": { "at": {
"row": 0, "row": 0,
"col": 0 "col": 0
} },
"trayId": "tray3"
}, },
{ {
"type": "loadUnload.end" "type": "loadUnload.end"
@@ -589,21 +592,24 @@
"at": { "at": {
"row": 0, "row": 0,
"col": 0 "col": 0
} },
"trayId": "tray2"
}, },
{ {
"type": "porter.board", "type": "porter.board",
"at": { "at": {
"row": 0, "row": 0,
"col": 0 "col": 0
} },
"trayId": "tray2"
}, },
{ {
"type": "porter.detrain", "type": "porter.detrain",
"at": { "at": {
"row": 0, "row": 0,
"col": 0 "col": 0
} },
"trayId": "tray2"
}, },
{ {
"type": "loadUnload.end" "type": "loadUnload.end"
@@ -951,36 +957,38 @@
"at": { "at": {
"row": 0, "row": 0,
"col": 0 "col": 0
} },
"trayId": "tray3"
}, },
{ {
"type": "porter.board", "type": "porter.board",
"at": { "at": {
"row": 0, "row": 0,
"col": 0 "col": 0
} },
}, "trayId": "tray3"
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
}, },
{ {
"type": "loadUnload.end" "type": "loadUnload.end"
}, },
{ {
"type": "localOps.choose", "type": "localOps.choose",
"option": "freightAgent" "option": "draw"
}, },
{ {
"type": "freightAgent.clearInbound", "type": "draw.fromHomeOffice"
"at": { },
"row": 0, {
"col": 0 "type": "card.play",
"cardId": "c162",
"placement": {
"row": -2,
"col": 2
}, },
"index": 0 "variant": 1
},
{
"type": "draw.end"
}, },
{ {
"type": "loadUnload.end" "type": "loadUnload.end"
@@ -1179,6 +1187,12 @@
"carType": "coach", "carType": "coach",
"loaded": false "loaded": false
}, },
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "coach",
"loaded": false
},
{ {
"type": "laborer.advanceLoad", "type": "laborer.advanceLoad",
"at": { "at": {
@@ -1236,7 +1250,58 @@
"option": "switch" "option": "switch"
}, },
{ {
"type": "switch.end" "type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 1
},
"reverse": true
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 0
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 1
},
"reverse": true
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 0
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 1
},
"reverse": true
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 0
},
"reverse": false
}, },
{ {
"type": "laborer.advanceLoad", "type": "laborer.advanceLoad",
+12 -1
View File
@@ -10,7 +10,7 @@
*/ */
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -151,6 +151,17 @@ if (existsSync(replaySrc)) {
} }
writeFileSync(join(replayOut, 'manifest.json'), JSON.stringify(manifest, null, 1)); writeFileSync(join(replayOut, 'manifest.json'), JSON.stringify(manifest, null, 1));
/**
* Static images — the splash artwork and whatever joins it. Copied verbatim, unlike the replays
* above: an image needs no validation, only a place in `dist/` to be served from.
*/
const imageSrc = join(root, 'public/images');
if (existsSync(imageSrc)) {
const imageOut = join(dist, 'images');
mkdirSync(imageOut, { recursive: true });
for (const f of readdirSync(imageSrc)) copyFileSync(join(imageSrc, f), join(imageOut, f));
}
// A tiny note for whoever unzips this later and wonders what it needs. // A tiny note for whoever unzips this later and wonders what it needs.
writeFileSync( writeFileSync(
join(dist, 'README.txt'), join(dist, 'README.txt'),
+84 -94
View File
@@ -18,6 +18,7 @@
import { import {
COLLISION_PENALTY, COLLISION_PENALTY,
EXPEDITE_FAULT_PENALTY,
MAINLINE_PROFILES, MAINLINE_PROFILES,
enhancementRule, enhancementRule,
crossingStages, crossingStages,
@@ -40,7 +41,7 @@ import type { GameEvent } from './events.ts';
import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts'; import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts';
import { legalActions } from './legal.ts'; import { legalActions } from './legal.ts';
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts'; import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
import { coordKey, freshTurns, playerAtSeat, playerLeftOf, subdivisions, totalRevenue, turnOf } from './state.ts'; import { coordKey, freshTurns, playerAtSeat, playerLeftOf, pooled, subdivisions, totalRevenue, turnOf } from './state.ts';
export type AdvanceResult = { export type AdvanceResult = {
events: GameEvent[]; events: GameEvent[];
@@ -307,6 +308,38 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
return d !== 0 ? d : Number(a.trainIsExtra) - Number(b.trainIsExtra); return d !== 0 ? d : Number(a.trainIsExtra) - Number(b.trainIsExtra);
}); });
/**
* Q3 — A STATION MASTER FAULT: an expedited train not at the station when a Mainline Phase begins
* was not kept ready to highball, wherever in the district it has been left — switched onto
* Secondary Track to clear a move, say. Checked against positions as they stand BEFORE this phase
* moves anything, and charged every Phase it is still caught away: the fault is in leaving it
* there, not a one-time slip. A train the ordinary §8.1 rules are holding at the Office itself is
* unaffected — this only bites when the train is not even in the queue to leave.
*/
for (const [, tray] of order) {
if (!isExpedited(tray) || tray.position.at !== 'grid') continue;
const area = areaAtSeat(s, tray.position.seat);
const { coord } = tray.position;
if (coord.row === area.officeCoord.row && coord.col === area.officeCoord.col) continue;
const owner = playerAtSeat(s, tray.position.seat);
const p = s.players[owner];
if (!p) continue;
p.revenue -= EXPEDITE_FAULT_PENALTY;
events.push({
type: 'expediteFault',
player: owner,
trainNumber: tray.trainNumber ?? 0,
where: `(${coord.col},${coord.row})`,
});
events.push({
type: 'revenueChanged',
player: owner,
delta: -EXPEDITE_FAULT_PENALTY,
total: p.revenue,
reason: 'expedited train left off the station',
});
}
for (const [id, tray] of order) { for (const [id, tray] of order) {
if (s.movedThisPhase.has(id)) continue; if (s.movedThisPhase.has(id)) continue;
const where = tray.position; const where = tray.position;
@@ -329,13 +362,13 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
(tray.position.at !== 'grid' || (tray.position.at !== 'grid' ||
where.at !== 'grid' || where.at !== 'grid' ||
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col)); (tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
if (!tray.departsThisStage && stillThere) { if (stillThere) {
tray.stopPointClaimed = true; tray.stopPointClaimed = true;
// The point goes to whoever is SITTING in the district it stopped in. // The point goes to whoever is SITTING in the district it stopped in.
const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0; const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0;
const label = const label =
tray.position.at === 'grid' tray.position.at === 'grid'
? `(${tray.position.coord.row},${tray.position.coord.col})` ? `(${tray.position.coord.col},${tray.position.coord.row})`
: tray.position.at === 'mainline' : tray.position.at === 'mainline'
? `Mainline card ${tray.position.index}` ? `Mainline card ${tray.position.index}`
: `the ${tray.position.side} Division Point`; : `the ${tray.position.side} Division Point`;
@@ -475,14 +508,18 @@ function spendDispatchBonus(
return 0; return 0;
} }
/** Q3 — an expedited train does not spend a Stage standing at the Office. */
/** /**
* Does this train leave in the same Stage it arrives (§7)? * Q3 — is this train subject to the "kept ready" fault (§7)?
*
* Expedite does not change WHEN a train leaves — it is released by the ordinary §8.1 rules like any
* other train, and may be switched normally while it stands. What it means is that it must not be
* held anywhere but the station: a train the player parks on Secondary Track to clear a switching
* move faults if it is not back on the Office square by the next Mainline Phase (`mainlinePhase`).
* *
* Two ways to earn it. `expedite` is printed and permanent. `stopThenExpedite` is the X17 Campaign * Two ways to earn it. `expedite` is printed and permanent. `stopThenExpedite` is the X17 Campaign
* Train — "one turn at station (speeches) then expedite": its FIRST arrival is an ordinary stop while * Train — "one turn at station (speeches) then expedite": its FIRST arrival is an ordinary stop while
* the speeches are made, and every arrival after that is expedited. `speechMade` is set on that first * the speeches are made, and every arrival after that is expedited. `speechMade` is set on that first
* stop, so the train is slow once and fast thereafter. * stop, so the train is exempt once and subject to the fault thereafter.
*/ */
function isExpedited(tray: CrewTray): boolean { function isExpedited(tray: CrewTray): boolean {
const rules = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules; const rules = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules;
@@ -491,18 +528,7 @@ function isExpedited(tray: CrewTray): boolean {
return rules.stopThenExpedite === true && tray.speechMade === true; return rules.stopThenExpedite === true && tray.speechMade === true;
} }
function moveTrain( function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]): MoveOutcome {
s: GameState,
id: TrayId,
tray: CrewTray,
events: GameEvent[],
/**
* Which pass is asking. The Mainline Phase moves every train once; Supervisor Shift then asks the
* Expedited arrivals to go again. The train cannot tell the difference, but the player must — see
* `trainHighballed.why`.
*/
pass: 'mainline' | 'expedite' = 'mainline',
): MoveOutcome {
const dir = step(tray.direction); const dir = step(tray.direction);
if (tray.position.at === 'divisionPoint') { if (tray.position.at === 'divisionPoint') {
@@ -588,10 +614,7 @@ function moveTrain(
trainNumber: tray.trainNumber ?? 0, trainNumber: tray.trainNumber ?? 0,
from: 'the Office', from: 'the Office',
to: 'the Mainline', to: 'the Mainline',
why: why: 'it stood a full Stage at the Office, so §8.1 released it this Mainline Phase',
pass === 'expedite'
? 'its card prints EXPEDITE, so it leaves at the end of the Stage it arrived in — you got the Cargo phase with it, but no Local Operations turn to switch it'
: 'it stood a full Stage at the Office, so §8.1 released it this Mainline Phase',
}); });
return 'moved'; return 'moved';
} }
@@ -751,28 +774,44 @@ function evaluateClearance(
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i }); for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
} }
/**
* TWO PASSES, NOT ONE — an opposite-direction occupant is an absolute bar and has to be checked
* against EVERY occupant before any same-direction judgment call is offered.
*
* A single pass returned on whichever occupant it examined first, in `node.transits` insertion
* order — which is fine while a Subdivision holds at most one train, but the dispatch exception
* below means it now legitimately can hold two: a Telegraph-cleared facing train sits on the same
* card as whatever it was cleared past. Found from a playtest report where the Superintendent was
* asked to rule on a same-direction train instead of being auto-held against an uncleared
* opposite-direction one also on the card — the loop had reached the same-direction occupant
* first simply because it entered the transit list first, and returned before ever looking at
* the other.
*/
for (const { tray: other } of occupants) {
if (other === id) continue;
const otherTray = s.trays.get(other);
if (!otherTray || otherTray.direction === tray.direction) continue;
// §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands.
//
// The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the
// Superintendent can arrange a meet with a facing train: "once a day, when dispatching
// facing trains, add +4/+8/+12 to the other train's number". Without a device there is no
// way to pass the order, so the train simply holds.
if (spendDispatchBonus(s, tray, otherTray, events) > 0) continue;
return 'blocked';
}
for (const { tray: other, onCard } of occupants) { for (const { tray: other, onCard } of occupants) {
if (other === id) continue; if (other === id) continue;
const otherTray = s.trays.get(other); const otherTray = s.trays.get(other);
if (!otherTray) continue; if (!otherTray || otherTray.direction !== tray.direction) continue;
const onNode = s.division.nodes[onCard]; const onNode = s.division.nodes[onCard];
if (otherTray.direction !== tray.direction) {
// §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands.
//
// The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the
// Superintendent can arrange a meet with a facing train: "once a day, when dispatching
// facing trains, add +4/+8/+12 to the other train's number". Without a device there is no
// way to pass the order, so the train simply holds.
if (spendDispatchBonus(s, tray, otherTray, events) > 0) return 'clear';
return 'blocked';
}
// Red Flags — "a stopped train is prevented from being hit; the approaching train is prevented // Red Flags — "a stopped train is prevented from being hit; the approaching train is prevented
// from moving". Flagging is per-train rather than per-card, so it protects one specific train // from moving". Flagging is per-train rather than per-card, so it protects one specific train
// where ABS Signals protects everything on the card. // where ABS Signals protects everything on the card.
// //
// Both of these now read the card the OTHER train is standing on rather than the card being // Both of these read the card the OTHER train is standing on rather than the card being
// entered. They were the same card while this only looked one card ahead; across a Subdivision // entered. They were the same card while this only looked one card ahead; across a Subdivision
// they are not, and the protection belongs where the train it protects actually is. // they are not, and the protection belongs where the train it protects actually is.
if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked'; if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked';
@@ -871,27 +910,9 @@ function arriveAtOffice(
}); });
/** /**
* Q3 — AN EXPEDITED TRAIN DEPARTS THE STAGE IT ARRIVED, BUT NOT BEFORE THE WORK IS DONE. * X17 Campaign Train — the speeches happen at the first Office it reaches, and every arrival after
* * that runs expedited (`isExpedited` reads `speechMade`). Setting it here is idempotent on every
* It used to leave inside this very Mainline Phase — a second `moveTrain` on the spot. That made * later arrival, so it needs no guard against re-firing.
* "departs the Stage it arrives" true and the coaches worthless: Load/Unload runs AFTER Mainline,
* so an expedited train was gone before a single Porter could reach it. Measured over 60 games,
* Train 2 arrived 32 times and stood at an Office during Load/Unload exactly none of them, and
* every coach-carrying express is expedited (1/2 Crack Limited, 5/6 The Sparrow, 19 Military).
* The Crack Limited's own card says "stop at Terminals only", which it could never do.
*
* So it stands here through Load/Unload and leaves at the end of the Stage instead. It still does
* not lay over to the next Stage — the keyword is honoured — and it is still subject to every
* §8.1 condition when it goes, so it can be held.
*/
if (isExpedited(tray)) {
tray.departsThisStage = true;
return 'moved';
}
/**
* X17 Campaign Train — the speeches happen at the first Office it reaches. Recorded AFTER the
* expedite test above, so this arrival is the ordinary stop the card pays for and every later one
* runs expedited.
*/ */
if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopThenExpedite) { if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopThenExpedite) {
tray.speechMade = true; tray.speechMade = true;
@@ -922,9 +943,11 @@ function collide(
consist: [...tray.consist], consist: [...tray.consist],
}); });
// Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification. // Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification.
// `pooled` because a car reaching a yard is back in the common supply: the load's origin stamp
// (state.ts) belongs to the load, not to the car that happened to be carrying it.
for (const car of tray.consist) { for (const car of tray.consist) {
if (car.type === 'caboose') s.yards.divisionYard.push(car); if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car));
else s.yards.classificationYard.push(car); else s.yards.classificationYard.push(pooled(car));
} }
s.trays.delete(id); s.trays.delete(id);
s.freeTrays.push(id); s.freeTrays.push(id);
@@ -993,9 +1016,10 @@ function retireTrain(
side: Direction, side: Direction,
events: GameEvent[], events: GameEvent[],
): void { ): void {
// `pooled` — see `trainsDestroyed` above; a load's origin stamp does not survive the yard.
for (const car of tray.consist) { for (const car of tray.consist) {
if (car.type === 'caboose') s.yards.divisionYard.push(car); if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car));
else s.yards.classificationYard.push(car); else s.yards.classificationYard.push(pooled(car));
} }
s.trays.delete(id); s.trays.delete(id);
s.freeTrays.push(id); s.freeTrays.push(id);
@@ -1018,40 +1042,6 @@ function retireTrain(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult { function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
/**
* EXPEDITED DEPARTURES — the last thing that happens in a Stage (Q3, and `arriveAtOffice`).
*
* Run here rather than as a phase of its own because there is no decision in it: the trains that
* arrived expedited this Stage try to leave, in train-number order like any other movement, and
* §8.1 may still hold them. Being after Load/Unload is the whole point — it is what gives the
* Porters their one Stage with the train.
*/
const expedited = [...s.trays.entries()]
.filter(([, t]) => t.departsThisStage)
.sort(([, a], [, b]) => (a.trainNumber ?? 0) - (b.trainNumber ?? 0));
for (const [id, tray] of expedited) {
tray.departsThisStage = false;
/**
* An expedited train that would need a §8.1 ruling STAYS, and runs normally from then on.
*
* Said in the log, because it is the difference between "this train leaves at the end of the
* Stage" and "this train is still here next turn", and a player watching one Expedited train
* vanish and the next one linger has no way to tell why. Measured on one seed: with ordinary
* traffic running, Train 6 was held this way and collected four Local Operations turns.
*/
if (moveTrain(s, id, tray, events, 'expedite') === 'needsClearance') {
events.push({
type: 'trainHeld',
trainNumber: tray.trainNumber ?? 0,
reason:
'it EXPEDITES and was due out at the end of this Stage, but the Subdivision ahead needs a ' +
'clearance ruling and Supervisor Shift has no Superintendent to give one. It stays, and ' +
'runs like an ordinary train from now on — so you DO get a Local Operations turn with it',
});
continue;
}
}
// §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12. // §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12.
if (s.clock.stage % STAGES_PER_SHIFT === 0) { if (s.clock.stage % STAGES_PER_SHIFT === 0) {
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent); s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
+169 -67
View File
@@ -59,6 +59,7 @@ import {
cutTowards, cutTowards,
isOperationalRail, isOperationalRail,
playerAtSeat, playerAtSeat,
pooled,
railFacingOf, railFacingOf,
seatOf, seatOf,
spaceOn, spaceOn,
@@ -388,17 +389,11 @@ export function canStartLoad(f: Facility): boolean {
} }
/** §9.2 — boarding needs a loaded coach in a green slot and a train with an empty coach. */ /** §9.2 — boarding needs a loaded coach in a green slot and a train with an empty coach. */
export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): boolean { export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord, trayId?: TrayId): boolean {
const f = facilityAt(s, player, at); const f = facilityAt(s, player, at);
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false; if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return false; if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return false;
// §7 — a train whose card refuses passenger work, or which is not booked to stop here, is not a return passengerWork(s, player, 'board', trayId) !== null;
// train these passengers can board however many empty coaches it is carrying.
return trainAtOfficeWith(
s, player,
(c) => c.type === 'coach' && !c.loaded,
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
);
} }
/** /**
@@ -413,29 +408,54 @@ export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): bool
* de-training MINTED a coach: the loaded one went to the red box and a new empty one appeared in the * de-training MINTED a coach: the loaded one went to the red box and a new empty one appeared in the
* train. Measured at 1.29 cars a game created out of nothing across the two inbound paths. * train. Measured at 1.29 cars a game created out of nothing across the two inbound paths.
*/ */
export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord): boolean { export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord, trayId?: TrayId): boolean {
const f = facilityAt(s, player, at); const f = facilityAt(s, player, at);
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false; if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
if (f.inboundBox.length >= f.capacity.inbound) return false; if (f.inboundBox.length >= f.capacity.inbound) return false;
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return false; if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return false;
return trainAtOfficeWith( return passengerWork(s, player, 'detrain', trayId) !== null;
s, player,
(c) => c.type === 'coach' && c.loaded,
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
);
} }
function trainAtOfficeWith( /**
* WHICH TRAIN, AND WHICH COACH ON IT — the one answer `check`, `execute` and the reducer all use.
*
* TWO PLAYTEST BUGS SHARED ONE CAUSE HERE. Reported against v0.4.9d: "operating two trains in a
* station, the select button does not work — regardless of which you pick, it is always one train,
* not the other". `porter.board` carried no tray at all, so `check` asked whether SOME train at the
* Office had an empty coach and the reducer then walked `adOccupancy` and filled the first one it
* found. The two were not even asking the same question: `check` skipped a train whose card refuses
* passenger work and the reducer did not, so a Military train could be boarded as long as some other
* train at the platform was eligible. The intent now names its tray (`intents.ts`) and this is the
* one place that resolves it.
*
* And "passengers just boarded cannot be immediately unloaded": a coach carries the district that
* filled it (`RollingStock.origin`), and a homegrown coach is not a coach these passengers may
* alight from — they have to be carried to a different Office Area first.
*
* `trayId` absent means "any eligible train", which is what every intent recorded before this
* existed meant, so an old save replays unchanged.
*/
function passengerWork(
s: GameState, s: GameState,
player: PlayerIndex, player: PlayerIndex,
pred: (c: RollingStock) => boolean, dir: 'board' | 'detrain',
trayOk: (t: CrewTray) => boolean = () => true, trayId?: TrayId,
): boolean { ): { trayId: TrayId; coachIndex: number } | null {
const area = areaOf(s, player); const area = areaOf(s, player);
return area.adOccupancy.some((id) => { const seat = seatOf(s, player);
const t = s.trays.get(id); const wanted = (c: RollingStock): boolean =>
return !!t && trayOk(t) && t.consist.some(pred); c.type === 'coach' && (dir === 'board' ? !c.loaded : c.loaded && c.origin !== seat);
}); for (const id of area.adOccupancy) {
if (trayId !== undefined && id !== trayId) continue;
const tray = s.trays.get(id);
if (!tray) continue;
// §7 — a train whose card refuses passenger work, or which is not booked to stop here, is not a
// train these passengers can board however many empty coaches it is carrying.
if (refusesPassengers(tray) || refusesThisOffice(s, player, tray)) continue;
const coachIndex = tray.consist.findIndex(wanted);
if (coachIndex >= 0) return { trayId: id, coachIndex };
}
return null;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -486,11 +506,14 @@ function freightBudgetLeft(
} }
/** /**
* Whether a train may do switching work at all (§7). * Whether a train may set out or sort cars (§7).
* *
* Six cards print "no switching" — the two expresses, the Light Engine, the Campaign, Circus and * Six cards print "no switching" — the two expresses, the Light Engine, the Campaign, Circus and
* Military trains. They run the Division; they do not shunt. This covers moving, setting out and * Military trains — which means they may not ADD or DROP cars, not that they may never be touched: a
* sorting alike, because all three are switching. * train held at the Office may still need to clear onto Secondary Track ahead of other traffic. So
* this covers `switch.dropCars` and `switch.sortConsist` only; `switch.move` handles `noSwitching`
* itself, the same way it handles `dropOnly`, because the restriction has to bite on the pick-up a
* move would make, not on the move itself.
*/ */
function switchingRefusal(tray: CrewTray): RejectionCode | null { function switchingRefusal(tray: CrewTray): RejectionCode | null {
return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null; return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null;
@@ -567,12 +590,30 @@ function passengerRefusal(
player: PlayerIndex, player: PlayerIndex,
at: GridCoord, at: GridCoord,
dir: 'board' | 'detrain', dir: 'board' | 'detrain',
trayId?: TrayId,
): RejectionCode { ): RejectionCode {
const area = areaOf(s, player); const area = areaOf(s, player);
const trains = area.adOccupancy.map((id) => s.trays.get(id)).filter((t): t is CrewTray => !!t); const trains = area.adOccupancy
.filter((id) => trayId === undefined || id === trayId)
.map((id) => s.trays.get(id))
.filter((t): t is CrewTray => !!t);
if (trains.length > 0 && trains.every((t) => refusesThisOffice(s, player, t))) return 'NOT_A_TERMINAL'; if (trains.length > 0 && trains.every((t) => refusesThisOffice(s, player, t))) return 'NOT_A_TERMINAL';
if (trains.length > 0 && trains.every(refusesPassengers)) return 'NO_PASSENGER_WORK'; if (trains.length > 0 && trains.every(refusesPassengers)) return 'NO_PASSENGER_WORK';
if (trains.length === 0) return 'NO_TRAIN_AT_OFFICE'; if (trains.length === 0) return 'NO_TRAIN_AT_OFFICE';
/**
* EVERY LOADED COACH ABOARD BOARDED HERE — so the refusal is the district rule, not "no loaded
* coach". Told apart because the two read as opposite situations to a player: one is an empty
* train, the other is a train full of passengers who have not been anywhere yet.
*/
if (
dir === 'detrain' &&
trains.some((t) => t.consist.some((c) => c.type === 'coach' && c.loaded)) &&
trains.every((t) =>
t.consist.every((c) => !(c.type === 'coach' && c.loaded) || c.origin === seatOf(s, player)),
)
) {
return 'LOADED_IN_THIS_DISTRICT';
}
/** /**
* A TRAIN IS STANDING THERE, so say what is actually missing. * A TRAIN IS STANDING THERE, so say what is actually missing.
@@ -626,8 +667,6 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING'; if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
const tray = s.trays.get(i.trayId); const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY'; if (!tray) return 'NO_SUCH_TRAY';
const noSwitch = switchingRefusal(tray);
if (noSwitch) return noSwitch;
const from = trayCoord(s, i.trayId); const from = trayCoord(s, i.trayId);
if (!from) return 'ILLEGAL_MOVE'; if (!from) return 'ILLEGAL_MOVE';
const dests = destinationsFor(s, player, i.trayId, from, i.reverse); const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
@@ -652,6 +691,14 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
*/ */
const fresh = dest.couples.slice(ownCutFor(s, player, i.trayId, i.reverse).length); const fresh = dest.couples.slice(ownCutFor(s, player, i.trayId, i.reverse).length);
if (fresh.length > 0) { if (fresh.length > 0) {
/**
* "NO SWITCHING" MEANS NO ADDING OR DROPPING CARS, not "never move". A no-switching train
* held at the Office may still need to clear onto Secondary Track ahead of other traffic —
* §7 governs coupling, setting out and sorting, and a move that picks nothing up is none of
* those. Handled here, alongside `dropOnly`, rather than as a blanket refusal on the intent:
* the restriction has to bite on the pick-up itself, the same reasoning as the comment above.
*/
if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED';
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED'; if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
if (rules.pickUpEmptiesOnly && fresh.some((c) => c.loaded)) return 'EMPTIES_ONLY'; if (rules.pickUpEmptiesOnly && fresh.some((c) => c.loaded)) return 'EMPTIES_ONLY';
const freight = fresh.filter(isFreight).length; const freight = fresh.filter(isFreight).length;
@@ -973,7 +1020,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (!f) return 'NO_SUCH_FACILITY'; if (!f) return 'NO_SUCH_FACILITY';
if (f.porters < 1) return 'NO_PORTERS_HERE'; if (f.porters < 1) return 'NO_PORTERS_HERE';
if (portersLeft(f) < 1) return 'RESOURCE_SPENT'; if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
return canBoard(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'board'); if (i.trayId !== undefined && !s.trays.has(i.trayId)) return 'NO_SUCH_TRAY';
return canBoard(s, player, i.at, i.trayId) ? null : passengerRefusal(s, player, i.at, 'board', i.trayId);
} }
case 'porter.detrain': { case 'porter.detrain': {
@@ -982,7 +1030,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (!f) return 'NO_SUCH_FACILITY'; if (!f) return 'NO_SUCH_FACILITY';
if (f.porters < 1) return 'NO_PORTERS_HERE'; if (f.porters < 1) return 'NO_PORTERS_HERE';
if (portersLeft(f) < 1) return 'RESOURCE_SPENT'; if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
return canDetrain(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'detrain'); if (i.trayId !== undefined && !s.trays.has(i.trayId)) return 'NO_SUCH_TRAY';
return canDetrain(s, player, i.at, i.trayId) ? null : passengerRefusal(s, player, i.at, 'detrain', i.trayId);
} }
case 'laborer.startLoad': { case 'laborer.startLoad': {
@@ -1018,6 +1067,19 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (laborersLeft(f) < 1) return 'RESOURCE_SPENT'; if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
const car = f.industryTrack.cars[i.carIndex]; const car = f.industryTrack.cars[i.carIndex];
if (!car || !car.loaded) return 'WRONG_CAR_TYPE'; if (!car || !car.loaded) return 'WRONG_CAR_TYPE';
/**
* A LOAD MAY NOT BE BROKEN IN THE DISTRICT THAT MADE IT (Jesse's ruling, v0.4.9e).
*
* Reported from playtesting v0.4.9d: "Freight House: boxcars loaded cannot be immediately
* unloaded." They could — a Freight House permits both directions, so the car its own Laborers
* had just loaded was standing on its own track, loaded, with an empty of that type in the
* yard, and every gate below said yes. Full Revenue at both ends for a load that never moved.
*
* The rule is district-wide and permanent, not "not at this facility" and not "not this
* Stage": the stamp says which Office Area made the load, and it never expires. Traffic runs
* BETWEEN districts, which is what the lockout pairs in `content.ts` exist to force.
*/
if (car.origin === seatOf(s, player)) return 'LOADED_IN_THIS_DISTRICT';
/** /**
* §9.3 — "*Requirements: a load on the industry's track AND AN EMPTY CAR OF THAT TYPE IN THE * §9.3 — "*Requirements: a load on the industry's track AND AN EMPTY CAR OF THAT TYPE IN THE
* DIVISION YARD. The first Laborer replaces the load with an empty car of that type.*" * DIVISION YARD. The first Laborer replaces the load with an empty car of that type.*"
@@ -1609,17 +1671,23 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
* `passengerPerCoach` (`content.ts`). Half a passenger movement is half the work, and the rate * `passengerPerCoach` (`content.ts`). Half a passenger movement is half the work, and the rate
* is named per COACH because a Porter handles exactly one coach per action. * is named per COACH because a Porter handles exactly one coach per action.
*/ */
case 'porter.board': case 'porter.board': {
// `check` has already established there is one; resolving it HERE, once, is what stops the
// reducer from finding a different train than the one the rules were tested against.
const work = passengerWork(s, player, 'board', i.trayId)!;
return [ return [
{ type: 'passengersBoarded', player, at: i.at }, { type: 'passengersBoarded', player, at: i.at, ...work },
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'boarding'), ...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'boarding'),
]; ];
}
case 'porter.detrain': case 'porter.detrain': {
const work = passengerWork(s, player, 'detrain', i.trayId)!;
return [ return [
{ type: 'passengersDetrained', player, at: i.at }, { type: 'passengersDetrained', player, at: i.at, ...work },
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'detraining'), ...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'detraining'),
]; ];
}
case 'laborer.startLoad': { case 'laborer.startLoad': {
const f = facilityAt(s, player, i.at)!; const f = facilityAt(s, player, i.at)!;
@@ -1654,7 +1722,13 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
case 'laborer.beginUnload': { case 'laborer.beginUnload': {
const f = facilityAt(s, player, i.at)!; const f = facilityAt(s, player, i.at)!;
return [ return [
{ type: 'unloadBegan', player, at: i.at, carType: f.industryTrack.cars[i.carIndex]!.type }, {
type: 'unloadBegan',
player,
at: i.at,
carType: f.industryTrack.cars[i.carIndex]!.type,
carIndex: i.carIndex,
},
]; ];
} }
@@ -1942,9 +2016,14 @@ export function reduce(s: GameState, e: GameEvent): void {
const card = area.grid.get(coordKey(e.to)); const card = area.grid.get(coordKey(e.to));
if (tray && card) { if (tray && card) {
tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length); tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length);
const track = card.facility?.industryTrack; // `carsOn` is the one function that knows WHERE cars stand on a given card — an industry
if (track) track.cars.push(...e.stock); // track for a freight facility, the card itself for everything else. Written out longhand
else card.standing.push(...e.stock); // here it was a second copy of that rule, and the copy was wrong for a Passenger Facility:
// it has an `industryTrack` too (an empty one, `setup.ts`), so a cut pushed into an Office
// would have landed somewhere `carsOn` cannot see — cars on the board that no train can
// couple and no walk is blocked by. `check` refuses a non-freight target, so this never
// fired; a trap that needs another rule to stay unsprung is still a trap.
carsOn(card).push(...e.stock);
} }
turnOf(s, e.player).movesRemaining -= 1; turnOf(s, e.player).movesRemaining -= 1;
spendCard(s, e.player, e.cardId); spendCard(s, e.player, e.cardId);
@@ -2035,7 +2114,8 @@ export function reduce(s: GameState, e: GameEvent): void {
const f = facilityAt(s, e.player, e.at)!; const f = facilityAt(s, e.player, e.at)!;
const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded); const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded);
if (idx >= 0) f.inboundBox.splice(idx, 1); if (idx >= 0) f.inboundBox.splice(idx, 1);
s.yards.classificationYard.push(e.stock); // `pooled` — a car back in a yard is back in the common supply, carrying nothing (state.ts).
s.yards.classificationYard.push(pooled(e.stock));
turnOf(s, e.player).freightAgentUsed = true; turnOf(s, e.player).freightAgentUsed = true;
break; break;
} }
@@ -2050,7 +2130,7 @@ export function reduce(s: GameState, e: GameEvent): void {
const idx = box.findIndex((c) => c.type === e.stock.type); const idx = box.findIndex((c) => c.type === e.stock.type);
if (idx >= 0) box.splice(idx, 1); if (idx >= 0) box.splice(idx, 1);
} }
s.yards.classificationYard.push(e.stock); s.yards.classificationYard.push(pooled(e.stock));
turnOf(s, e.player).freightAgentUsed = true; turnOf(s, e.player).freightAgentUsed = true;
break; break;
} }
@@ -2118,19 +2198,28 @@ export function reduce(s: GameState, e: GameEvent): void {
break; break;
} }
/**
* THE TRAIN AND THE COACH THE PLAYER PICKED, not "the first one on the A/D tracks".
*
* This used to walk `adOccupancy` and fill the first empty coach it met, which is why two trains
* standing at one station both answered to whichever chip was clicked (v0.4.9d playtest), and
* why it could fill a coach on a train whose card refuses passenger work — `check` skipped such
* a train and the reducer did not. `e.trayId`/`e.coachIndex` are exactly what `passengerWork`
* resolved for `check`, carried on the event rather than looked up again here.
*/
case 'passengersBoarded': { case 'passengersBoarded': {
const f = facilityAt(s, e.player, e.at)!; const f = facilityAt(s, e.player, e.at)!;
const area = areaOf(s, e.player);
const idx = f.outboundBox.findIndex((c) => c.type === 'coach' && c.loaded); const idx = f.outboundBox.findIndex((c) => c.type === 'coach' && c.loaded);
const loaded = f.outboundBox.splice(idx, 1)[0]!; const loaded = f.outboundBox.splice(idx, 1)[0]!;
for (const id of area.adOccupancy) { const tray = s.trays.get(e.trayId);
const tray = s.trays.get(id); if (tray && tray.consist[e.coachIndex]) {
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && !c.loaded) ?? -1; s.yards.classificationYard.push(pooled(tray.consist[e.coachIndex]!));
if (tray && ci >= 0) { /**
s.yards.classificationYard.push(tray.consist[ci]!); * Stamped with the district that filled it — the chip turned upside down in the tray. These
tray.consist[ci] = loaded; * passengers may not alight anywhere in this Office Area; the train has to carry them to a
break; * different one. See `RollingStock.origin` in state.ts.
} */
tray.consist[e.coachIndex] = { ...loaded, origin: seatOf(s, e.player) };
} }
f.usedThisStage.porters += 1; f.usedThisStage.porters += 1;
break; break;
@@ -2138,21 +2227,18 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'passengersDetrained': { case 'passengersDetrained': {
const f = facilityAt(s, e.player, e.at)!; const f = facilityAt(s, e.player, e.at)!;
const area = areaOf(s, e.player); const tray = s.trays.get(e.trayId);
for (const id of area.adOccupancy) { if (tray && tray.consist[e.coachIndex]) {
const tray = s.trays.get(id); // The empty coach comes OUT OF THE DIVISION YARD, as §9.2 says. It used to be conjured,
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && c.loaded) ?? -1; // which minted a coach on every de-training. Throws now, for the reason in `unloadBegan`.
if (tray && ci >= 0) { const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded);
// The empty coach comes OUT OF THE DIVISION YARD, as §9.2 says. It used to be conjured, if (yi < 0) throw new Error('passengersDetrained: no empty coach in the Division Yard');
// which minted a coach on every de-training. Throws now, for the reason in `unloadBegan`. const empty = s.yards.divisionYard.splice(yi, 1)[0]!;
const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded); refillDivisionYardIfEmpty(s);
if (yi < 0) throw new Error('passengersDetrained: no empty coach in the Division Yard'); // The arriving coach goes into the red box carrying nothing: the journey it was stamped for
const empty = s.yards.divisionYard.splice(yi, 1)[0]!; // is over, and the box feeds straight back to a yard through the Freight Agent.
refillDivisionYardIfEmpty(s); f.inboundBox.push(pooled(tray.consist[e.coachIndex]!));
f.inboundBox.push(tray.consist[ci]!); tray.consist[e.coachIndex] = empty;
tray.consist[ci] = empty;
break;
}
} }
f.usedThisStage.porters += 1; f.usedThisStage.porters += 1;
break; break;
@@ -2188,8 +2274,14 @@ export function reduce(s: GameState, e: GameEvent): void {
workTrack(f)[workTrack(f).length - 1] = null; workTrack(f)[workTrack(f).length - 1] = null;
const ci = f.industryTrack.cars.findIndex((c) => !c.loaded && c.type === e.carType); const ci = f.industryTrack.cars.findIndex((c) => !c.loaded && c.type === e.carType);
if (ci >= 0) { if (ci >= 0) {
s.yards.classificationYard.push(f.industryTrack.cars[ci]!); s.yards.classificationYard.push(pooled(f.industryTrack.cars[ci]!));
f.industryTrack.cars[ci] = { type: e.carType, loaded: true }; /**
* THE LOAD IS STAMPED WITH THE DISTRICT THAT MADE IT — the chip turned upside down in the
* tray. `laborer.beginUnload` refuses a car stamped with the district it is standing in, so
* this load now has to leave the Office Area on a train before anyone can break it. See
* `RollingStock.origin` in state.ts for the rule and why it is a seat.
*/
f.industryTrack.cars[ci] = { type: e.carType, loaded: true, origin: seatOf(s, e.player) };
} }
f.usedThisStage.laborers += 1; f.usedThisStage.laborers += 1;
break; break;
@@ -2197,7 +2289,17 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'unloadBegan': { case 'unloadBegan': {
const f = facilityAt(s, e.player, e.at)!; const f = facilityAt(s, e.player, e.at)!;
const ci = f.industryTrack.cars.findIndex((c) => c.loaded); /**
* THE CAR THE PLAYER PICKED, not merely "a loaded one".
*
* REPORTED: with several loaded cars spotted on one industry track, unloading always took the
* WESTMOST car regardless of which one was chosen. This used to re-derive the target with
* `industryTrack.cars.findIndex(c => c.loaded)`, which returns the same answer — the first
* loaded car in track order — no matter which `carIndex` the intent actually named; `check`
* had already validated that specific car, and the choice was thrown away between there and
* here. `e.carIndex` is exactly what `laborer.beginUnload`'s reducer read off the intent.
*/
const ci = e.carIndex;
if (ci >= 0) { if (ci >= 0) {
/** /**
* §9.3 — the replacement empty comes out of the Division Yard. Conjuring it here is what * §9.3 — the replacement empty comes out of the Division Yard. Conjuring it here is what
+38 -23
View File
@@ -231,42 +231,51 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 6 }, { kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 6 },
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 }, { kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 },
/** /**
* BOTH DIRECTIONS, per the card reference — this was `outbound` and it contradicted the rules. * OUTBOUND ONLY. A Refinery ships oil out and receives nothing; reported from playtesting and
* confirmed by Jesse (v0.4.9e): "only ships out tanks, does not receive anything".
* *
* `card-reference.md`: "Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4", and in prose — "'Freight * It was briefly `flow: 'both'`, on the reading that "'Freight House' is not a card — it is the
* House' is not a card. It is the collective term for a freight facility that loads *and* unloads * collective term for a freight facility that loads *and* unloads, the Grocer's Warehouse and the
* — the Grocer's Warehouse and the Oil Refinery." §9.3's "Passenger Facilities and Freight Houses * Oil Refinery", which made §9.3's "Passenger Facilities and Freight Houses permit cars to move
* permit cars to move each direction" therefore names exactly these two, and the engine had both * each direction" name exactly those two. That premise is dead: `glossary.md` and
* of them one-way. * `rules-v0.2.md` corrected the Freight House to a card of its own, dealt like any other industry,
* so §9.3 names the Freight House and nothing else, and card-reference.md's "Both" column loses
* the only argument it had.
* *
* The consequence was silent: `usableGrant` drops a Modifier's grant on a direction its host * The card set says the same thing on its own. All three Refinery modifiers — Pipelines, Oil
* cannot use, so every +1 inbound beside a Refinery went nowhere. * Depot, Viscosity Breakers — grant `+1 outbound`; a two-way Refinery would be the one industry in
* * the game with no way to raise the direction it is supposed to use half its capacity on.
* The base numbers stay at the engine's own scale (1 per direction it allows) rather than the card * `StationMaster-Home-Deck-v0.4.5.md` prints it "Outbound, 1 out / 0 in".
* reference's 2/2 — every industry here is scaled down the same way, Mine Tipple included, and
* raising one of them alone would be a balance change rather than a correction. Flagged in TODO.
*/ */
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 }, { kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 }, { kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 },
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 }, { kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 },
/** /**
* BOTH DIRECTIONS — see the Refinery above; "Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3". * INBOUND ONLY — the mirror of the Refinery above, and the same correction. Reported from
* playtesting and confirmed by Jesse (v0.4.9e): "Grocer's Warehouse should be receive only, does
* not ship anything out". `StationMaster-Home-Deck-v0.4.5.md` prints it "Inbound, 0 out / 1 in".
* *
* Reported from play: "grocer's warehouse didn't get extra outbound slot for truck dock." It could * THE ICE HOUSE IS THEREFORE A DEAD CARD BESIDE A GROCER'S, and that is the design, not an
* not: the Truck Dock printed +1 outbound at the time and this was `flow: 'inbound'`, so the grant * oversight: `usableGrant` drops a Modifier's grant on a direction its host cannot use, and the
* was dropped on a direction the facility did not have. The same trap still swallows an Ice House * Home Deck sheet says so outright — "a bonus beside a facility that cannot use its direction is
* set beside a Grocer's that has been left one-way. * not usable", naming the Truck Dock's inbound bonus beside the outbound-only Packing Sheds as the
* * other example. The Truck Dock (+1 inbound) and Local Small Groceries (+1 Laborer) are the two
* `TODO.md` had previously recorded this as "checked, and there is no bug" on the reasoning that a * that do work here.
* Grocer's is inbound-only. That premise was the bug.
*/ */
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 }, { kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
]; ];
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */ /** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
export const FREIGHT_PROFILES = INDUSTRY_PROFILES; export const FREIGHT_PROFILES = INDUSTRY_PROFILES;
/** §9.3 — the collective term for an industry that both loads and unloads. */ /**
* §9.3 — "Passenger Facilities and Freight Houses permit cars to move each direction".
*
* ONE CARD ANSWERS TO THIS NOW: the Freight House itself. It was briefly three, while the Refinery
* and the Grocer's Warehouse were also `both` on a reading of the term the glossary has since
* corrected — a Freight House is a card, not a collective noun. Kept as a predicate on `flow`
* rather than a comparison against the kind, because it is the DIRECTION §9.3 is talking about.
*/
export function isFreightHouse(p: IndustryProfile): boolean { export function isFreightHouse(p: IndustryProfile): boolean {
return p.flow === 'both'; return p.flow === 'both';
} }
@@ -1004,6 +1013,12 @@ export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
export const LABORER_ACTIONS_PER_LOAD = 4; export const LABORER_ACTIONS_PER_LOAD = 4;
export const COLLISION_PENALTY = 5; export const COLLISION_PENALTY = 5;
export const COLLISION_FLOOR_PER_DAY = 3; export const COLLISION_FLOOR_PER_DAY = 3;
/**
* Q3 — an expedited train left parked off the Office when a Mainline Phase begins is a Station
* Master fault: the train was not kept ready to highball the moment the Subdivision allowed it.
* Charged every Phase it is caught there, not just once, since the fault is being left that way.
*/
export const EXPEDITE_FAULT_PENALTY = 1;
export function collectiveRevenueFloor(players: number, days: number): number { export function collectiveRevenueFloor(players: number, days: number): number {
return 3 * players * days; return 3 * players * days;
+25 -6
View File
@@ -124,6 +124,13 @@ export type GameEvent =
| { type: 'trainHeld'; trainNumber: number; reason: string } | { type: 'trainHeld'; trainNumber: number; reason: string }
/** A train whose card pays for standing still (X18 Circus) collected on it. */ /** A train whose card pays for standing still (X18 Circus) collected on it. */
| { type: 'trainStoodStill'; trainNumber: number; where: string } | { type: 'trainStoodStill'; trainNumber: number; where: string }
/**
* Q3 — A STATION MASTER FAULT. An expedited train is not to be held anywhere but the station: if
* it is still off the Office square — parked on Secondary Track, say, to clear a switching move —
* when a Mainline Phase begins, that is a failure to keep it ready to highball, and it costs
* Revenue. `where` names the square it was found on. Always followed by a `revenueChanged`.
*/
| { type: 'expediteFault'; player: PlayerIndex; trainNumber: number; where: string }
/** /**
* `why` is the RULE that let it go now, not a restatement of the move. * `why` is the RULE that let it go now, not a restatement of the move.
* *
@@ -134,9 +141,9 @@ export type GameEvent =
*/ */
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string; why: string } | { type: 'trainHighballed'; trainNumber: number; from: string; to: string; why: string }
/** /**
* `expedited` because it changes what the player must do RIGHT NOW. Q3 — an expedited train * `expedited` flags a train that must not be left standing off the station (Q3) — it may be
* departs the same Stage it arrives, so there is no Local Operations turn in which to work it; * switched normally like any other arrival, but it has to be back on the Office square before the
* every other train stands until the next Mainline Phase and can be switched in between. * next Mainline Phase begins, or `expediteFault` fires.
*/ */
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean } | { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean }
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string } | { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
@@ -154,13 +161,25 @@ export type GameEvent =
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId } | { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean } | { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
// -- load / unload // -- load / unload
| { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord } /**
| { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord } * `trayId` and `coachIndex` name the TRAIN and the COACH the Porter worked, rather than leaving the
* reducer to find them again — the same lesson as `unloadBegan`'s `carIndex` below. Re-deriving
* "the first empty coach on the first train at the Office" is how two trains standing at one
* station both answered to one roster chip (v0.4.9d playtest), and how a coach the player had not
* chosen got filled. Required, not optional: an event is a fact, and a fact that has to be looked
* up against live state cannot render standalone in a replay.
*/
| { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord; trayId: TrayId; coachIndex: number }
| { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord; trayId: TrayId; coachIndex: number }
| { type: 'loadStarted'; player: PlayerIndex; at: GridCoord; carType: CarType } | { type: 'loadStarted'; player: PlayerIndex; at: GridCoord; carType: CarType }
| { type: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number } | { type: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number }
| { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType } | { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
| { type: 'loadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType } | { type: 'loadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType } /**
* `carIndex` carries the SPECIFIC car the player picked off the industry track — see the comment
* on `laborer.beginUnload` in `apply.ts` for why the reducer must not re-derive it.
*/
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType; carIndex: number }
// -- consequences // -- consequences
| { type: 'revenueChanged'; player: PlayerIndex; delta: number; total: number; reason: string } | { type: 'revenueChanged'; player: PlayerIndex; delta: number; total: number; reason: string }
| { type: 'phaseEnded'; player: PlayerIndex; phase: string }; | { type: 'phaseEnded'; player: PlayerIndex; phase: string };
+23 -2
View File
@@ -101,8 +101,18 @@ export type Intent =
| { type: 'maneuver.flyingSwitch'; cardId: CardId; trayId: TrayId; count: number; to: GridCoord } | { type: 'maneuver.flyingSwitch'; cardId: CardId; trayId: TrayId; count: number; to: GridCoord }
| { type: 'redFlag.play' } | { type: 'redFlag.play' }
// -- Load/Unload Phase (§9) // -- Load/Unload Phase (§9)
| { type: 'porter.board'; at: GridCoord } /**
| { type: 'porter.detrain'; at: GridCoord } * `trayId` names the train the Porter works — reported from playtesting v0.4.9d as "operating two
* trains in a station, the select button does not work: regardless of which you pick, it is always
* one train, not the other". It was: neither intent carried a train, so the reducer took the first
* one on the A/D tracks and the roster chip the player had clicked changed nothing but the drawing.
*
* OPTIONAL, like `switch.move`'s `via` and for the same reason: intents are the canonical record
* `undo` and every save replay against, and absent means what it has always meant — the first
* eligible train at the Office.
*/
| { type: 'porter.board'; at: GridCoord; trayId?: TrayId }
| { type: 'porter.detrain'; at: GridCoord; trayId?: TrayId }
/** §9.3 — the first Laborer step: Green Loading Slot -> MEN. */ /** §9.3 — the first Laborer step: Green Loading Slot -> MEN. */
| { type: 'laborer.startLoad'; at: GridCoord } | { type: 'laborer.startLoad'; at: GridCoord }
| { type: 'laborer.advanceLoad'; at: GridCoord; box: number } | { type: 'laborer.advanceLoad'; at: GridCoord; box: number }
@@ -199,6 +209,17 @@ export type RejectionCode =
* the Laborers can move it out of the box. * the Laborers can move it out of the box.
*/ */
| 'NO_EMPTY_CAR_SPOTTED' | 'NO_EMPTY_CAR_SPOTTED'
/**
* §9 (Jesse's ruling, v0.4.9e) — freight or passengers loaded anywhere in an Office Area may not
* be unloaded anywhere in that same Office Area. The load has to be carried out of the district by
* a train first; a Freight House may not break the load it just made, and passengers may not
* detrain at the platform they boarded from.
*
* Distinct from the other refusals because the car IS loaded, the Laborer IS free and the boxes
* ARE clear: the only thing wrong with it is where it came from, and a player looking at a loaded
* boxcar standing on their own industry track deserves to be told that rather than "wrong car".
*/
| 'LOADED_IN_THIS_DISTRICT'
| 'NO_PORTERS_HERE' | 'NO_PORTERS_HERE'
| 'NO_PASSENGERS_WAITING' | 'NO_PASSENGERS_WAITING'
| 'NO_EMPTY_COACH' | 'NO_EMPTY_COACH'
+14 -2
View File
@@ -288,9 +288,21 @@ function loadUnloadCandidates(s: GameState, player: PlayerIndex): Intent[] {
const out: Intent[] = []; const out: Intent[] = [];
const area = areaOf(s, player); const area = areaOf(s, player);
/**
* ONE OPTION PER TRAIN STANDING AT THE OFFICE, not one per square.
*
* Reported from playtesting v0.4.9d: "operating two trains in a station, the select button does
* not work — regardless of which you pick, it is always one train, not the other". There was only
* ever ONE `board passengers` button, because the intent carried no train; the roster chip chose
* what the board drew and nothing else. Now each eligible train is its own candidate, and `check`
* filters the ones whose card, consist or passengers rule them out.
*/
const traysHere = area.adOccupancy.filter((id) => s.trays.has(id));
for (const coord of facilityCoords(s, player)) { for (const coord of facilityCoords(s, player)) {
out.push({ type: 'porter.board', at: coord }); for (const trayId of traysHere) {
out.push({ type: 'porter.detrain', at: coord }); out.push({ type: 'porter.board', at: coord, trayId });
out.push({ type: 'porter.detrain', at: coord, trayId });
}
const f = area.grid.get(`${coord.row},${coord.col}`)?.facility; const f = area.grid.get(`${coord.row},${coord.col}`)?.facility;
if (f) { if (f) {
out.push({ type: 'laborer.startLoad', at: coord }); out.push({ type: 'laborer.startLoad', at: coord });
+45 -10
View File
@@ -56,7 +56,47 @@ export function coordKey(c: GridCoord): string {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** §2.2 — a coloured car is loaded, a white car is empty. */ /** §2.2 — a coloured car is loaded, a white car is empty. */
export type RollingStock = { type: CarType; loaded: boolean }; export type RollingStock = {
type: CarType;
loaded: boolean;
/**
* WHICH OFFICE AREA MADE THIS LOAD — the physical game's chip turned upside down in the tray.
*
* Reported from playtesting v0.4.9d as two bugs with one cause: a boxcar loaded at a Freight
* House could be unloaded at that same Freight House on the next Laborer action, and passengers
* who had just boarded could be detrained again before the train turned a wheel. Both paid full
* Revenue at each end for a load that never went anywhere.
*
* Jesse's rule (v0.4.9e): freight or passengers loaded anywhere in an Office Area may not be
* unloaded ANYWHERE in that same Office Area — not at another facility, not in a later Stage.
* They have to be carried by a train to a different Office Area. So the stamp is the SEAT, which
* is what an Office Area belongs to (Employee Rotation moves players between chairs; the district
* stays with the chair), and it never expires.
*
* A SEAT, NOT A PLAYER, and undefined rather than -1 for "no origin": the Division Yard opens with
* loaded cars and loaded coaches that were made up off-Division (`ROLLING_STOCK_SUPPLY`), and
* those are exactly the inbound traffic a solitaire district lives on. A sentinel inside
* `SeatIndex`'s own value range is not a sentinel — see `card.play`'s `node` in intents.ts.
*
* Stripped by `pooled` whenever a car goes back to a yard: the stamp belongs to the LOAD, and a
* car returning to the common supply is carrying nothing.
*/
origin?: SeatIndex;
};
/**
* A car returning to the common pool — the Division or Classification Yard — with its load's origin
* stamp taken off.
*
* Every yard push goes through this. A loaded car CAN reach a yard still loaded (a train retires at
* a Division Point with freight aboard, `advance.ts`), and without this it would carry a stamp from
* a district it left several Days ago into whatever train is made up from it next.
*/
export function pooled(car: RollingStock): RollingStock {
if (car.origin === undefined) return car;
const { origin: _origin, ...rest } = car;
return rest;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Track and Office Area // Track and Office Area
@@ -384,17 +424,12 @@ export type CrewTray = {
* X17 Campaign Train — "one turn at station (speeches) then expedite". * X17 Campaign Train — "one turn at station (speeches) then expedite".
* *
* It makes its speech at the first Office it reaches: that arrival is an ordinary stop, and from * It makes its speech at the first Office it reaches: that arrival is an ordinary stop, and from
* then on the train runs expedited, departing every Office in the Stage it arrives. Recorded on the * then on the train is expedited — it may be switched normally, but it faults (Q3) if it is left
* tray for the same reason as `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs * off the Office square when a Mainline Phase begins. Recorded on the tray for the same reason as
* once, so there is no later visit to hang it on. * `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs once, so there is no later
* visit to hang it on.
*/ */
speechMade?: boolean; speechMade?: boolean;
/**
* Arrived expedited this Stage, so it departs at the END of the Stage rather than laying over to
* the next one (Q3). Set on arrival; cleared when `shiftChange` attempts the departure — after
* Load/Unload, which is what gives the Porters their one Stage with the train.
*/
departsThisStage?: boolean;
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+13 -4
View File
@@ -552,7 +552,8 @@ export function officeSvg(
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`; out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`; // X,Y — east/west then north/south, not the internal row/col storage order.
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.col},${cell.row}</text>`;
/** /**
* THE ROSTER, one chip per A/D track — not one per train. * THE ROSTER, one chip per A/D track — not one per train.
@@ -567,13 +568,21 @@ export function officeSvg(
* Selected train's chip is lit the same amber the crew strip and action buttons already use, * Selected train's chip is lit the same amber the crew strip and action buttons already use,
* and it is the one whose consist is drawn at the rail below. Every occupied chip carries * and it is the one whose consist is drawn at the rail below. Every occupied chip carries
* `data-crew`, so the play page can wire a click to switch which train that is. * `data-crew`, so the play page can wire a click to switch which train that is.
*
* COMPUTED FOR EVERY CARD, NOT JUST THE OFFICE. This used to sit inside the `adTracks !== null`
* branch below, so it was only ever assigned on the Office card — a train standing anywhere else
* in the district (which is everywhere it stands while actually being switched) got no chip at
* all: no label, no engine arrow, and no cars drawn, though the game state never lost it. REPORTED:
* dropping a car at the Freight House made "the train vanish off the face of the earth" — it had
* simply left the one square this code drew a crew on. The Office is the only square that can hold
* more than one train, so `cell.trains[0]` is exactly right everywhere else too.
*/ */
let selectedTrain: CellView['trains'][number] | null = null; const selectedTrain: CellView['trains'][number] | null =
cell.trains.find((t) => t.trayId === selectedTrayId) ?? cell.trains[0] ?? null;
if (cell.adTracks !== null) { if (cell.adTracks !== null) {
const adTracks = cell.adTracks; const adTracks = cell.adTracks;
const gaps = Math.max(0, adTracks - 1); const gaps = Math.max(0, adTracks - 1);
const chipW = Math.min(52, (W - 12 - 4 * gaps) / adTracks); const chipW = Math.min(52, (W - 12 - 4 * gaps) / adTracks);
selectedTrain = cell.trains.find((t) => t.trayId === selectedTrayId) ?? cell.trains[0] ?? null;
for (let i = 0; i < adTracks; i++) { for (let i = 0; i < adTracks; i++) {
const train = cell.trains[i] ?? null; const train = cell.trains[i] ?? null;
const cx = 6 + i * (chipW + 4); const cx = 6 + i * (chipW + 4);
@@ -872,7 +881,7 @@ export function officeSvg(
out += `<g data-ghost="${g.row},${g.col}" transform="translate(${px(g.col)},${py(g.row)})" class="bs-ghost">` + out += `<g data-ghost="${g.row},${g.col}" transform="translate(${px(g.col)},${py(g.row)})" class="bs-ghost">` +
`<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` + `<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` +
`<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` + `<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` +
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${g.row},${g.col}</text>` + `<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${g.col},${g.row}</text>` +
`</g>`; `</g>`;
} }
+36 -33
View File
@@ -17,7 +17,7 @@
import { MAX_CONSIST } from '../engine/content.ts'; import { MAX_CONSIST } from '../engine/content.ts';
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts'; import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
import type { GameState, GridCoord, PlayerIndex, RollingStock, TrayId } from '../engine/state.ts'; import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts'; import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts'; import type { GameEvent } from '../engine/events.ts';
@@ -34,11 +34,20 @@ export function clockTime(stage: number): string {
return CLOCK[stage - 1] ?? `Stage ${stage}`; return CLOCK[stage - 1] ?? `Stage ${stage}`;
} }
export function carLabel(c: RollingStock): string { /**
* `homeSeat` is the district the page is being drawn for. Give it, and a load THIS district made
* says so — the printed game's answer is to turn the chip upside down in the tray, and this is the
* screen's. A load may not be broken in the Office Area that made it (state.ts `RollingStock.origin`),
* so "loaded here" is the difference between a boxcar worth switching and one that has to leave the
* district first. Omit it and the label is what it always was, which is what the replay viewers and
* the history lines want: they describe a board, not a seat's view of one.
*/
export function carLabel(c: RollingStock, homeSeat?: SeatIndex): string {
// A caboose carries the crew, not freight, so "loaded caboose" is nonsense on the page even // A caboose carries the crew, not freight, so "loaded caboose" is nonsense on the page even
// though the supply marks every caboose loaded. Name it plainly. // though the supply marks every caboose loaded. Name it plainly.
if (c.type === 'caboose') return 'caboose'; if (c.type === 'caboose') return 'caboose';
return `${c.loaded ? 'loaded' : 'empty'} ${c.type}`; const label = `${c.loaded ? 'loaded' : 'empty'} ${c.type}`;
return homeSeat !== undefined && c.origin === homeSeat ? `${label} (loaded here)` : label;
} }
export function carsLabel(cars: RollingStock[]): string { export function carsLabel(cars: RollingStock[]): string {
@@ -280,25 +289,25 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
}; };
case 'trainArrived': case 'trainArrived':
/** /**
* AN EXPEDITED TRAIN IS NOT COMING BACK ROUND — AND IS NOT GONE YET EITHER. * AN EXPEDITED ARRIVAL IS AN ORDINARY ARRIVAL, WITH ONE STANDING OBLIGATION.
* *
* This said "it will highball again next Mainline Phase" to every arrival, which is backwards * This used to say the train stands through Cargo and is forced out at the end of the Stage —
* for an Expedited train. Corrected once to "it leaves again this same Mainline Phase", which * true once, and wrong: Q3 means the train must not be PARKED anywhere but the station, not
* was ALSO wrong and in a way that cost the player work: the expedited departure was moved to * that it is rushed out early. It is switched, worked and released exactly like any other
* Supervisor Shift precisely so the Porters and Laborers get their Stage with the train, so * arrival; the only difference is what happens if it is left on Secondary Track when the next
* Cargo IS available and only Local Operations is not. Telling a player there is "no turn in * Mainline Phase begins (`expediteFault`).
* which to work it" talked them out of the one turn they had.
*
* So both branches now name the phases: what you can still do with this train, and when it
* goes. That is the whole of the reported confusion — "some trains seem to be moving before I
* can switch or do other operations on them".
*/ */
return { return {
tone: 'plain', tone: 'plain',
text: e.expedited text: e.expedited
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so it stands through the Cargo phase and departs at the END OF THIS STAGE. Porters and Laborers can work it now; it is normally gone before your next Local Operations turn, so it cannot be switched — unless §8.1 holds it for a ruling, in which case it stays and runs like any other train.` ? `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.`, : `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.`,
}; };
case 'expediteFault':
return {
tone: 'bad',
text: `Train ${e.trainNumber} was left at ${e.where}, off the station, when the Mainline Phase began — an expedited train must be kept ready to highball. Station Master fault.`,
};
case 'trainDiverted': case 'trainDiverted':
return { return {
tone: 'good', tone: 'good',
@@ -582,7 +591,7 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
// the board says it on the card instead. // the board says it on the card instead.
if (b.kind === 'noStopping') continue; if (b.kind === 'noStopping') continue;
out.push({ out.push({
where: `${tray.trainNumber === null ? 'crew' : `Train ${tray.trainNumber}`} → (${b.coord.row},${b.coord.col})`, where: `${tray.trainNumber === null ? 'crew' : `Train ${tray.trainNumber}`} → (${b.coord.col},${b.coord.row})`,
why: b.why, why: b.why,
// Not "stuck": these are the shape of the district and of the other trains in it, which is // Not "stuck": these are the shape of the district and of the other trains in it, which is
// the puzzle rather than a fault. Amber, not red. // the puzzle rather than a fault. Amber, not red.
@@ -602,12 +611,10 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
* said so. * said so.
* *
* Reported again as "passenger trains arrive at my Office and move on before I can load or unload * Reported again as "passenger trains arrive at my Office and move on before I can load or unload
* them", which turned out to be three separate silences. This block only looked at trains carrying * them", which turned out to be two separate silences. This block only looked at trains carrying
* a LOADED coach, so a train arriving to PICK UP said nothing at all. A Whistle Post's Office card * a LOADED coach, so a train arriving to PICK UP said nothing at all. A Whistle Post's Office card
* carries a passenger facility with zero Porters, so the engine answered `RESOURCE_SPENT` — "all * carries a passenger facility with zero Porters, so the engine answered `RESOURCE_SPENT` — "all
* Porters used" — to a player who had used none. And an expedited train leaves at the end of the * Porters used" — to a player who had used none.
* Stage it arrives, so its one Load/Unload phase is now-or-never and nothing said which trains
* those are.
* *
* §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which no panel * §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which no panel
* mentioned at all. * mentioned at all.
@@ -620,8 +627,6 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
.filter((t): t is NonNullable<typeof t> => !!t && t.consist.some((c) => c.type === 'coach')); .filter((t): t is NonNullable<typeof t> => !!t && t.consist.some((c) => c.type === 'coach'));
if (coachTrains.length > 0) { if (coachTrains.length > 0) {
const label = (t: (typeof coachTrains)[number]): string =>
t.trainNumber === null ? 'the local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`;
const say = (why: string, severity: Impediment['severity'] = 'waiting'): void => { const say = (why: string, severity: Impediment['severity'] = 'waiting'): void => {
out.push({ where: 'Office — passengers', why, severity }); out.push({ where: 'Office — passengers', why, severity });
}; };
@@ -658,17 +663,6 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
say('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)'); say('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)');
} }
} }
// NOW OR NEVER. An expedited train leaves at the end of this Stage, so this is the only
// Load/Unload phase it will ever stand for — the exact thing the playtest report was about.
for (const t of coachTrains) {
if (!t.departsThisStage) continue;
say(
`${label(t)} is EXPEDITED — it leaves at the END of this Stage, so this is the only ` +
'Load/Unload phase it will stand for. Work it now or not at all.',
'risk',
);
}
} }
} }
@@ -748,6 +742,15 @@ export function cuesFor(events: readonly GameEvent[]): string[] {
// A train running the whole length of the Division pays every player, and it is the one event // A train running the whole length of the Division pays every player, and it is the one event
// nobody made happen on the turn it lands. It should be heard, not found in the log. // nobody made happen on the turn it lands. It should be heard, not found in the log.
if (e.type === 'trainCompleted') out.push('completed'); if (e.type === 'trainCompleted') out.push('completed');
// A train pulling into an Office. `trainDiverted` (the Yard Office siding) is deliberately not
// included — that one goes straight to a work track without the A/D stop `arrive` depicts.
if (e.type === 'trainArrived') out.push('arrive');
// Only an OFFICE departure, not every highball: a fresh make-up leaving a Division Point already
// sounds `train` ("All aboard"), and a train clearing the whole Division already sounds
// `completed` — this is the one case neither of those covers.
if (e.type === 'trainHighballed' && e.from === 'the Office') out.push('depart');
// §10 — the one event nobody wants to hear and everybody needs to.
if (e.type === 'trainsDestroyed') out.push('crash');
if (e.type === 'stageBegan') { if (e.type === 'stageBegan') {
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening // A Stage BEGINNING is the previous one ending — except the first, which is the game opening
// and has nothing behind it. A Day boundary rings the bell only: sounding both would collide, // and has nothing behind it. A Day boundary rings the bell only: sounding both would collide,
+36 -25
View File
@@ -508,6 +508,7 @@ const FACILITY_NAMES: Record<string, string> = {
function facilityView( function facilityView(
card: { geometry: { kind: string; facility?: string }; facility: unknown; modifiers?: string[] }, card: { geometry: { kind: string; facility?: string }; facility: unknown; modifiers?: string[] },
officeName: string, officeName: string,
viewerSeat: SeatIndex,
): FacilityView | null { ): FacilityView | null {
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility; const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
// Passenger facilities were excluded entirely, so the Office's green and red slots never // Passenger facilities were excluded entirely, so the Office's green and red slots never
@@ -529,7 +530,9 @@ function facilityView(
maw: (f.menAtWork ?? []).map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)), maw: (f.menAtWork ?? []).map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
red: f.inboundBox.map(carLabel), red: f.inboundBox.map(carLabel),
redCap: f.capacity.inbound, redCap: f.capacity.inbound,
track: f.industryTrack.cars.map(carLabel), // Marked when this district made the load: the spotted car is exactly where a player is looking
// when they ask why the Laborer will not unload it.
track: f.industryTrack.cars.map((c) => carLabel(c, viewerSeat)),
laborers: `${laborersLeft(f)}/${f.laborers}`, laborers: `${laborersLeft(f)}/${f.laborers}`,
porters: `${portersLeft(f)}/${f.porters}`, porters: `${portersLeft(f)}/${f.porters}`,
canFinish: canFinishHere(f), canFinish: canFinishHere(f),
@@ -620,7 +623,9 @@ function trainsOnCard(s: GameState, viewerSeat: SeatIndex, key: string): CellVie
out.push({ out.push({
trayId: id, trayId: id,
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`, label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
cars: t.consist.map(carLabel), // A coach filled at THIS Office reads "loaded coach (loaded here)" — those passengers may not
// alight in the district that boarded them, and the tray is where a player looks for that.
cars: t.consist.map((c) => carLabel(c, viewerSeat)),
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)), engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
facing: railFacingOf(t), facing: railFacingOf(t),
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t), what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
@@ -676,9 +681,15 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : ''); return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : '');
} }
/** " onto Train 8", or nothing at all when the intent names no train (an old save, or one train). */
function onto(s: GameState, trayId: string | undefined, joiner: string): string {
return trayId === undefined ? '' : `${joiner}${trainName(s, trayId)}`;
}
/** One readable line for a single intent. */ /** One readable line for a single intent. */
export function describeIntent(s: GameState, i: Intent): string { export function describeIntent(s: GameState, i: Intent): string {
const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`; // X,Y — east/west then north/south, not the internal row/col storage order.
const at = (c: { row: number; col: number }): string => `(${c.col},${c.row})`;
// An intent belongs to whoever is acting, so it is described against THEIR district. // An intent belongs to whoever is acting, so it is described against THEIR district.
// The acting PLAYER, not a seat — `describeIntent` describes an intent against the district of // The acting PLAYER, not a seat — `describeIntent` describes an intent against the district of
// whoever is making it. Named `seat` once, and then used as an `officeAreas` key, which is the // whoever is making it. Named `seat` once, and then used as an `officeAreas` key, which is the
@@ -854,10 +865,16 @@ export function describeIntent(s: GameState, i: Intent): string {
return `advance load in box ${i.box} at ${at(i.at)}`; return `advance load in box ${i.box} at ${at(i.at)}`;
case 'laborer.beginUnload': case 'laborer.beginUnload':
return `begin unloading car ${i.carIndex} at ${at(i.at)}`; return `begin unloading car ${i.carIndex} at ${at(i.at)}`;
/**
* NAME THE TRAIN. The action list drops duplicate labels within a crew, and with two trains
* standing at one station "board passengers at (0,0)" describes both — which is half of why the
* v0.4.9d playtest found that picking a train changed nothing. The intent now carries the tray;
* the label has to say so or the second button is thrown away before the menu sees it.
*/
case 'porter.board': case 'porter.board':
return `board passengers at ${at(i.at)}`; return `board passengers at ${at(i.at)}${onto(s, i.trayId, ' onto ')}`;
case 'porter.detrain': case 'porter.detrain':
return `detrain passengers at ${at(i.at)}`; return `detrain passengers at ${at(i.at)}${onto(s, i.trayId, ' from ')}`;
case 'newTrain.startExtra': { case 'newTrain.startExtra': {
const runs = i.trainNumber % 2 === 0 ? 'east' : 'west'; const runs = i.trainNumber % 2 === 0 ? 'east' : 'west';
if (i.atSeat === null) { if (i.atSeat === null) {
@@ -1006,7 +1023,7 @@ export function snapshot(
else if (g.kind === 'spaceUse') label = prettyKey(g.key); else if (g.kind === 'spaceUse') label = prettyKey(g.key);
else label = geometryLabel(g.geometry); else label = geometryLabel(g.geometry);
const fv = facilityView(card as never, officeProfile(area.tier).name); const fv = facilityView(card as never, officeProfile(area.tier).name, viewerSeat);
if (fv) facilities.push(fv); if (fv) facilities.push(fv);
cells.push({ cells.push({
@@ -1021,7 +1038,7 @@ export function snapshot(
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)), enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
trains: trainsOnCard(s, viewerSeat, key), trains: trainsOnCard(s, viewerSeat, key),
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null, adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
cars: carsOn(card).map(carLabel), cars: carsOn(card).map((c) => carLabel(c, viewerSeat)),
standingWest: card.standingWest, standingWest: card.standingWest,
facility: fv, facility: fv,
}); });
@@ -1229,7 +1246,7 @@ export function snapshot(
? `${t.position.side} Division Point` ? `${t.position.side} Division Point`
: t.position.at === 'mainline' : t.position.at === 'mainline'
? `Mainline card ${t.position.index}` ? `Mainline card ${t.position.index}`
: `Office Area (${t.position.coord.row},${t.position.coord.col})`, : `Office Area (${t.position.coord.col},${t.position.coord.row})`,
})), })),
}; };
} }
@@ -1424,7 +1441,9 @@ export function trainRules(t: {
* DOES rather than that it exists, because the restriction is the whole character of the card: a * DOES rather than that it exists, because the restriction is the whole character of the card: a
* Military train that cannot be worked by Porters plays nothing like a Local. * Military train that cannot be worked by Porters plays nothing like a Local.
*/ */
if (p.rules.noSwitching) parts.push('NO SWITCHING — it runs the Division and does not shunt'); if (p.rules.noSwitching) {
parts.push('NO SWITCHING — may not add or drop cars, but may still be moved clear of the mainline');
}
if (p.rules.terminalsOnly) parts.push('TERMINALS ONLY — Porters may work it at a Terminal and nowhere else'); if (p.rules.terminalsOnly) parts.push('TERMINALS ONLY — Porters may work it at a Terminal and nowhere else');
if (p.rules.coachStaysOnStationTrack) { if (p.rules.coachStaysOnStationTrack) {
// Said "may only be set out at the Office", which reads as a place you can do it. You cannot: // Said "may only be set out at the Office", which reads as a place you can do it. You cannot:
@@ -1446,15 +1465,12 @@ export function trainRules(t: {
} }
if (p.rules.expedite) { if (p.rules.expedite) {
// The single most-asked question in play — "why did that train leave before I could switch it?" // It is released and switched exactly like any other train — the restriction is on where it may
// — so it names the phases rather than the Stage. It stands through Cargo and goes at the end of // be LEFT, not on when it leaves.
// the Stage, which is after the Porters and Laborers but before the next Local Operations.
parts.push( parts.push(
'EXPEDITED — it does not lay over. It stands through the Cargo phase and departs at the END OF ' + 'EXPEDITED — must be kept ready to highball. It works and switches normally, but if it is not ' +
'THE STAGE IT ARRIVES IN, so Porters and Laborers can work it but it is normally gone before ' + 'back on the Office square when the next Mainline Phase begins, that is a Station Master ' +
'your next Local Operations turn and cannot be switched. UNLESS §8.1 wants a clearance ruling ' + 'fault and costs 1 Revenue.',
'at that moment: there is no Superintendent in Supervisor Shift to give one, so the train ' +
'stays and runs like any other from then on.',
); );
} }
if (p.rules.stopEarnsPoint) parts.push('EARNS A POINT for one Stage spent standing still, once'); if (p.rules.stopEarnsPoint) parts.push('EARNS A POINT for one Stage spent standing still, once');
@@ -1736,14 +1752,9 @@ function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] {
const out: Frame['moves'] = []; const out: Frame['moves'] = [];
for (const [id, tray] of s.trays) { for (const [id, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue; if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
/** // A no-switching train may still be moved clear of the mainline (§7) — only coupling, setting
* A crew that may not switch is not a crew you can be switching. // out and sorting are refused, and `check` rejects those the same way it rejects a pick-up X13
* // (dropOnly) is not allowed to make, so this row is not filtered any differently for either.
* `movesFor` is pure track geometry — it answers where a train COULD roll — and six cards print
* "no switching", which `check` enforces and it does not. Offering the Circus Train in a "which
* train are you switching?" row is offering a train whose every move the rules refuse.
*/
if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.noSwitching) continue;
const { to, blocked } = movesFor(s, player, id); const { to, blocked } = movesFor(s, player, id);
out.push({ trayId: id, label: trainName(s, id), from: tray.position.coord, to, blocked }); out.push({ trayId: id, label: trainName(s, id), from: tray.position.coord, to, blocked });
} }
+3 -2
View File
@@ -344,7 +344,7 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
const tray = game.state.trays.get(trayId); const tray = game.state.trays.get(trayId);
const where = const where =
tray?.position.at === 'grid' tray?.position.at === 'grid'
? `, standing at (${tray.position.coord.row}, ${tray.position.coord.col})` ? `, standing at (${tray.position.coord.col}, ${tray.position.coord.row})`
: ''; : '';
groups.push({ groups.push({
kind: prefix, kind: prefix,
@@ -670,7 +670,8 @@ function subjectOf(
node?: number; node?: number;
links: string[]; links: string[];
} | null { } | null {
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`; // X,Y — east/west then north/south, not the internal row/col storage order.
const at = (c: { row: number; col: number }): string => `(${c.col}, ${c.row})`;
/** /**
* ABS Signals is placed on a MAINLINE card, which is not in the Office Area at all — so it names * ABS Signals is placed on a MAINLINE card, which is not in the Office Area at all — so it names
* a Division NODE and carries no coordinate. It used to travel as `{ row: -1, col: node }`, and * a Division NODE and carries no coordinate. It used to travel as `{ row: -1, col: node }`, and
+53 -21
View File
@@ -11,18 +11,28 @@ body{margin:0;background:var(--bg);color:var(--fg);
font:15px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font:15px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px} min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px}
main{width:100%;max-width:760px} main{width:100%;max-width:760px}
.hero{display:flex;gap:24px;align-items:flex-start}
.hero-image{width:230px;flex:none;border-radius:9px;overflow:hidden;border:1px solid var(--line);cursor:zoom-in}
.hero-image img{display:block;width:100%;height:auto}
.hero-content{flex:1;min-width:0}
@media(max-width:640px){.hero{flex-direction:column}.hero-image{width:100%;max-width:280px}}
h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em} h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em}
.tag{color:var(--dim);margin:0;font-size:14px} .tag{color:var(--dim);margin:0;font-size:14px}
.blurb{color:#c6ccd6;margin:16px 0 26px;max-width:66ch} .blurb{color:#c6ccd6;margin:16px 0 0;max-width:66ch}
.rule{height:1px;background:var(--line);margin:22px 0} .rule{height:1px;background:var(--line);margin:22px 0}
.doors{display:grid;grid-template-columns:1fr 1fr;gap:14px} .doors{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-top:26px}
@media(max-width:640px){.doors{grid-template-columns:1fr}} @media(max-width:640px){.doors{grid-template-columns:1fr}}
a.door{display:block;text-decoration:none;color:inherit;background:var(--panel); .door{display:block;text-decoration:none;color:inherit;background:var(--panel);
border:1px solid var(--line);border-radius:9px;padding:18px 18px 16px;transition:.12s} border:1px solid var(--line);border-radius:9px;padding:18px 18px 16px;transition:.12s}
a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)} a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
a.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8} .door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
a.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5} .door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
a.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6} .door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
.door.disabled .go{color:var(--dim)}
.lightbox{position:fixed;inset:0;background:rgba(8,10,13,.92);display:flex;
align-items:center;justify-content:center;padding:32px;z-index:10;cursor:zoom-out}
.lightbox[hidden]{display:none}
.lightbox img{display:block;max-width:90vw;max-height:90vh;border-radius:6px;box-shadow:0 12px 40px rgba(0,0,0,.5)}
/* the rule between title and blurb is drawn as rail, not decorated */ /* the rule between title and blurb is drawn as rail, not decorated */
svg.trackline{width:100%;height:24px;display:block;margin:10px 0 0} svg.trackline{width:100%;height:24px;display:block;margin:10px 0 0}
.rail{stroke:#4a5361;stroke-width:1.6} .rail{stroke:#4a5361;stroke-width:1.6}
@@ -32,24 +42,41 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
</head> </head>
<body> <body>
<main> <main>
<h1>Station Master</h1> <div class="hero">
<p class="tag">TimeTable and Train Order (TT&amp;TO) railroading, 1840&ndash;1950.</p> <div class="hero-image" id="hero-image" tabindex="0" role="button" aria-label="View full-size box art">
<img src="./images/station-master-splash.jpg"
alt="Station Master — TimeTable &amp; Train Order Railroading, 1840&ndash;1950. Box art: two trains meet at a junction beside a train order office."
width="560" height="700">
</div>
<div class="hero-content">
<h1>Station Master</h1>
<p class="tag">TimeTable and Train Order (TT&amp;TO) railroading, 1840&ndash;1950.</p>
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true"> <svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
<line class="rail" x1="0" y1="10" x2="700" y2="10"/> <line class="rail" x1="0" y1="10" x2="700" y2="10"/>
<line class="rail" x1="0" y1="15" x2="700" y2="15"/> <line class="rail" x1="0" y1="15" x2="700" y2="15"/>
<g id="ties"></g> <g id="ties"></g>
</svg> </svg>
<p class="blurb"> <p class="blurb">
As Station Master in your line-side office, you manage the town's railroad connection. You are As Station Master in your line-side office, you manage the town's railroad connection. You are
responsible for all track between your Limits, a district of industries hanging beneath it, and responsible for all track between your Limits, a district of industries hanging beneath it, and
trains that arrive whether or not you are ready for them. Spot the right cars, work the loads, trains that arrive whether or not you are ready for them. Spot the right cars, work the loads,
and get every train away again &mdash; a train with nowhere to stand is a collision, and a and get every train away again &mdash; a train with nowhere to stand is a collision, and a
collision costs more than the freight was worth. collision costs more than the freight was worth.
</p> </p>
</div>
</div>
<div class="doors"> <div class="doors">
<div class="door disabled">
<h2>Play multiplayer</h2>
<p>Play with your friends across the internet. You each run your own office area within the
entire division. You can play a normal game, or choose co-op or cutthroat. This requires
a Station Master server to be running.</p>
<span class="go">Coming soon</span>
</div>
<a class="door" href="./play.html"> <a class="door" href="./play.html">
<h2>Play solitaire</h2> <h2>Play solitaire</h2>
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue. <p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
@@ -71,10 +98,15 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
<footer> <footer>
<span>build <span id="build">__BUILD__</span></span> <span>build <span id="build">__BUILD__</span></span>
<span>runs entirely in your browser &mdash; no server code required</span> <span>solitaire runs entirely in your browser &mdash; no server code required</span>
</footer> </footer>
</main> </main>
<div class="lightbox" id="lightbox" hidden>
<img src="./images/station-master-splash.jpg"
alt="Station Master — TimeTable &amp; Train Order Railroading, 1840&ndash;1950. Box art: two trains meet at a junction beside a train order office.">
</div>
<script type="module" src="./web/splash.js"></script> <script type="module" src="./web/splash.js"></script>
</body> </body>
</html> </html>
+13 -3
View File
@@ -819,8 +819,8 @@ function renderActions(
.map( .map(
(m) => (m) =>
`<button class="act crew${m.trayId === chosen?.trayId ? ' on' : ''}" data-crew="${esc(m.trayId)}"${cellRef(m.from)} ` + `<button class="act crew${m.trayId === chosen?.trayId ? ' on' : ''}" data-crew="${esc(m.trayId)}"${cellRef(m.from)} ` +
`data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.row}, ${m.from.col}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` + `data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.col}, ${m.from.row}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` +
`${esc(m.label)} <span class="dim">(${m.from.row}, ${m.from.col})</span></button>`, `${esc(m.label)} <span class="dim">(${m.from.col}, ${m.from.row})</span></button>`,
) )
.join('') + .join('') +
`</div>`; `</div>`;
@@ -832,9 +832,19 @@ function renderActions(
* Everything about a card in hand now lives on the card, and everything about making up a train * Everything about a card in hand now lives on the card, and everything about making up a train
* lives on the yard chip. What remains is the rest of the turn: the Local Operations choice, * lives on the yard chip. What remains is the rest of the turn: the Local Operations choice,
* drawing, switching moves, the Freight Agent, and finishing. * drawing, switching moves, the Freight Agent, and finishing.
*
* EXCLUDE BY TITLE, NOT BY PREFIX. This used to be `!/^Making up /.test(g.title)`, on the
* assumption only the yard-chip panel's own group is ever titled that way. It is not: "choose
* where Extra X22 starts" is titled by `trainCardTitle`, which also begins "Making up Extra
* X22…", so the regex swallowed it too — and with no tray yet being filled, `menu.makeUp` is
* null, so nothing rendered it anywhere else. REPORTED as the game hanging with the Freight
* House mid-cycle: an Extra came due, the action panel had a legal decision and zero buttons,
* and nothing short of restarting looked like it would ever move again. Matching the exact
* title of the ONE group `menu.makeUp` actually covers leaves every other "Making up …" group,
* however it is titled, on screen where a player can act on it.
*/ */
html += menu.direct html += menu.direct
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && !/^Making up /.test(g.title)) .filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title)
.map( .map(
(g) => (g) =>
`<div class="grp"><h3>${esc(g.title)}</h3>` + `<div class="grp"><h3>${esc(g.title)}</h3>` +
+81 -17
View File
@@ -64,6 +64,38 @@ export function playCue(cue: string): void {
osc.stop(at + dur + 0.02); osc.stop(at + dur + 0.02);
}; };
/**
* A burst of filtered noise — the breath of steam behind a whistle, one chuff of a moving
* locomotive, or the boom of an impact. Shared by `stage` (one long burst) and the chuffs in
* `arrive`/`depart` (several short ones), and the wide-open burst under `crash`.
*/
const noiseBurst = (
at: number,
dur: number,
gain: number,
freq: number,
q: number,
type: BiquadFilterType = 'bandpass',
): void => {
const frames = Math.max(1, Math.floor(c.sampleRate * dur));
const buf = c.createBuffer(1, frames, c.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
const src = c.createBufferSource();
src.buffer = buf;
const filt = c.createBiquadFilter();
filt.type = type;
filt.frequency.value = freq;
filt.Q.value = q;
const amp = c.createGain();
amp.gain.setValueAtTime(0, at);
amp.gain.linearRampToValueAtTime(gain, at + Math.min(0.05, dur / 4));
amp.gain.exponentialRampToValueAtTime(0.0001, at + dur);
src.connect(filt).connect(amp).connect(c.destination);
src.start(at);
src.stop(at + dur + 0.02);
};
const t = c.currentTime + 0.02; const t = c.currentTime + 0.02;
if (cue === 'stage') { if (cue === 'stage') {
@@ -73,23 +105,55 @@ export function playCue(cue: string): void {
for (const fg of [[392, 0.16], [466, 0.13], [587, 0.1], [784, 0.05]]) { for (const fg of [[392, 0.16], [466, 0.13], [587, 0.1], [784, 0.05]]) {
tone(t, fg[0]!, d, fg[1]!, 'sawtooth', fg[0]! * 0.985); tone(t, fg[0]!, d, fg[1]!, 'sawtooth', fg[0]! * 0.985);
} }
const frames = Math.floor(c.sampleRate * d); noiseBurst(t, d, 0.05, 1400, 0.8);
const buf = c.createBuffer(1, Math.max(1, frames), c.sampleRate); return;
const data = buf.getChannelData(0); }
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
const src = c.createBufferSource(); if (cue === 'arrive') {
src.buffer = buf; /**
const band = c.createBiquadFilter(); * A LOCOMOTIVE SLOWING TO A STOP: chuffs (one per wheel revolution) spaced FARTHER apart as
band.type = 'bandpass'; * road speed drops, then a soft two-partial bell tap as it settles — a lighter cousin of the
band.frequency.value = 1400; * `day` bell, not a repeat of it.
band.Q.value = 0.8; */
const amp = c.createGain(); let at = t;
amp.gain.setValueAtTime(0, t); const gaps = [0.13, 0.17, 0.22, 0.28];
amp.gain.linearRampToValueAtTime(0.05, t + 0.05); for (let i = 0; i < gaps.length; i++) {
amp.gain.exponentialRampToValueAtTime(0.0001, t + d); noiseBurst(at, 0.07, 0.11 - i * 0.015, 190, 1.1, 'bandpass');
src.connect(band).connect(amp).connect(c.destination); at += gaps[i]!;
src.start(t); }
src.stop(t + d); for (const fg of [[1046, 0.07], [1568, 0.04]]) {
tone(at + 0.12, fg[0]!, 0.3, fg[1]!, 'sine');
}
return;
}
if (cue === 'depart') {
/**
* THE INVERSE OF `arrive`: chuffs starting slow and pulling closer together as the train
* gathers speed, then one falling, fading horn note — a single call, not `completed`'s pair,
* since this train is only leaving the Office, not clearing the whole Division.
*/
let at = t;
const gaps = [0.22, 0.15, 0.1, 0.07, 0.05];
for (let i = 0; i < gaps.length; i++) {
noiseBurst(at, 0.06, 0.06 + i * 0.012, 210, 1.2, 'bandpass');
at += gaps[i]!;
}
tone(at, 349, 0.45, 0.1, 'sawtooth', 300);
return;
}
if (cue === 'crash') {
/**
* IMPACT, NOT MUSIC: two tones bent sharply downward at a DISSONANT (non-harmonic) interval,
* so the ear reads a collision rather than a chord — `couple`'s clank is consonant on purpose,
* and this is its opposite. A wide-open noise boom and a percussive sub-bass thump give it
* weight under the screech.
*/
tone(t, 311, 0.35, 0.16, 'square', 70);
tone(t, 233, 0.4, 0.13, 'sawtooth', 52);
noiseBurst(t, 0.4, 0.16, 900, 0.4, 'lowpass');
tone(t, 45, 0.3, 0.18, 'sine');
return; return;
} }
+19
View File
@@ -7,3 +7,22 @@ if (ties) {
} }
ties.innerHTML = out; ties.innerHTML = out;
} }
/** Clicking the box art opens it full size, since the thumbnail is too small to read. */
const heroImage = document.getElementById('hero-image');
const lightbox = document.getElementById('lightbox');
if (heroImage && lightbox) {
const open = () => { lightbox.hidden = false; };
const close = () => { lightbox.hidden = true; };
heroImage.addEventListener('click', open);
heroImage.addEventListener('keydown', (e) => {
if (e instanceof KeyboardEvent && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
open();
}
});
lightbox.addEventListener('click', close);
document.addEventListener('keydown', (e) => {
if (e instanceof KeyboardEvent && e.key === 'Escape') close();
});
}
+60 -43
View File
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts'; import { advance, pump } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check } from '../src/engine/apply.ts'; import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import { HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts'; import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts'; import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts'; import { createGame } from '../src/engine/setup.ts';
import { developerBot } from '../src/sim/bot.ts'; import { developerBot } from '../src/sim/bot.ts';
@@ -690,9 +690,16 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
* them — it may be the rules are wrong, or it may be my perception." * them — it may be the rules are wrong, or it may be my perception."
* *
* It was perception, but the log was actively feeding it. Every departure read alike, and the * It was perception, but the log was actively feeding it. Every departure read alike, and the
* arrival line for an Expedited train said there was "no turn in which to work it" — which is * arrival line for an Expedited train said there was "no turn in which to work it" — which was
* false and cost the player the Cargo turn they did have. These pin the two claims the log now * false under the OLD (v0.4.8 and earlier) same-stage-departure reading of Q3, and cost the player
* makes, so a phase-order change cannot leave the narration lying about it. * the Cargo turn they did have.
*
* v0.4.9 CORRECTED Q3 ITSELF, not just the narration: Expedite does not change when a train
* leaves — it is released by the ordinary §8.1 rules exactly like any other train, and may be
* switched normally while it stands. What it means is that the train must not be PARKED anywhere
* but the station; leaving it on Secondary Track when a Mainline Phase begins is a Station Master
* fault (`expediteFault`, `EXPEDITE_FAULT_PENALTY`). These pin that behaviour, so a phase-order
* change cannot silently bring back the old forced departure.
*/ */
/** /**
* Drop one named train at the Western Division Point and follow it, counting the DISTINCT phases * Drop one named train at the Western Division Point and follow it, counting the DISTINCT phases
@@ -704,11 +711,10 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
const s = game(7, { length: 'standard' }); const s = game(7, { length: 'standard' });
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains'; for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
/** /**
* A DIVISION WITH NOTHING ELSE ON IT. Expedite is not absolute: §8.1 can still hold the train, * A DIVISION WITH NOTHING ELSE ON IT. §8.1 can still hold any train, Expedited or not, on a
* and `shiftChange` says so — "an expedited train that would need a ruling simply stays, and * same-direction meet — with the bot's own traffic running, Train 6 was held four Stages and
* runs normally next Stage". With the bot's own traffic running, Train 6 was held four Stages * collected four Local Operations turns. Correct behaviour, but it is traffic noise for what
* and collected four Local Operations turns, which is correct behaviour and the opposite of what * this test is trying to pin, so clearing the timetable isolates the rule from it.
* this test is trying to pin. Clearing the timetable isolates the rule from the traffic.
*/ */
s.timetable = s.timetable.map(() => null); s.timetable = s.timetable.map(() => null);
const id = 'watched'; const id = 'watched';
@@ -779,40 +785,55 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
assert.equal(r.leftIn, 'mainline', 'an ordinary train should leave in a Mainline Phase'); assert.equal(r.leftIn, 'mainline', 'an ordinary train should leave in a Mainline Phase');
}); });
it('gives an Expedited train the Cargo phase but never Local Operations', () => { it('gives an Expedited train a Local Operations turn too, exactly like an ordinary one', () => {
// Train 6 The Sparrow — Expedite. The claim the arrival line makes is precisely this pair: // Train 6 The Sparrow — Expedite. v0.4.9 corrected Q3: Expedite no longer forces a same-stage
// Porters and Laborers can reach it, a switching turn never comes, and it goes at the end of // departure, so an Expedited train is released by the ordinary §8.1 rule and stands for a
// the Stage rather than in a Mainline Phase. // Local Operations turn the same as Train 12 above.
const r = phasesWith(6); const r = phasesWith(6);
assert.equal(r.localOps, 0, 'an Expedited train got a Local Operations turn after all'); assert.ok(r.localOps >= 1, `an Expedited train got ${r.localOps} Local Operations turns`);
assert.ok(r.loadUnload >= 1, 'an Expedited train never stood through a Cargo phase'); assert.ok(r.loadUnload >= 1, 'an Expedited train never stood through a Cargo phase');
assert.equal(r.leftIn, 'shiftChange', 'an Expedited train should leave in Supervisor Shift'); assert.equal(r.leftIn, 'mainline', 'an Expedited train should leave in a Mainline Phase, like any other');
}); });
it('does NOT expedite when §8.1 wants a ruling — it stays and runs normally', () => { it('faults an Expedited train left off the station when a Mainline Phase begins', () => {
/** // What Expedite actually restricts now: not WHEN the train leaves, but WHERE it may be left
* EXPEDITE IS CONDITIONAL, which is most of why it feels arbitrary at the table. // standing in the meantime. Parked on Secondary Track — cleared there to make way for other
* // switching, say — it must not still be there when the next Mainline Phase begins.
* `shiftChange` says so — "an expedited train that would need a ruling simply stays, and runs
* normally next Stage" — and it happens often: driven by the developer bot this was reached by
* ordinary traffic, and Train 6 collected FOUR Local Operations turns instead of none. Built by
* hand here rather than fished out of a bot game, because whether the bot happens to produce a
* meet depends on the deck, and the deck moves.
*
* So the log and the card must not promise that an Expedited train can never be switched.
*/
const s = game(7, { length: 'standard' }); const s = game(7, { length: 'standard' });
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains'; const area = areaOf(s, 0);
const id = 'expedited';
const secondary = { row: area.officeCoord.row, col: area.officeCoord.col + 1 };
s.trays.set(id, {
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: secondary }, movesUsed: 0,
});
// Train 6 (Expedite) standing at the Office, having just arrived: this is the state s.clock.phase = 'mainline';
// `arriveAtOffice` leaves behind when it sets `departsThisStage`. const before = s.players[0]!.revenue;
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'expediteFault' && e.trainNumber === 6),
'no fault was raised for an Expedited train left off the station',
);
assert.equal(
s.players[0]!.revenue,
before - EXPEDITE_FAULT_PENALTY,
'the fault did not cost Revenue',
);
});
it('does not fault an Expedited train legitimately held at the station itself', () => {
// A train sitting on the Office square is not "left" anywhere — including one §8.1 is holding
// for a same-direction meet, which is an ordinary hold, not a Station Master failure.
const s = game(7, { length: 'standard' });
const area = areaOf(s, 0); const area = areaOf(s, 0);
const id = 'expedited'; const id = 'expedited';
s.trays.set(id, { s.trays.set(id, {
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [], id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', facing: 'e', direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0, position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
departsThisStage: true,
}); });
area.adOccupancy.push(id); area.adOccupancy.push(id);
@@ -829,26 +850,22 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' }); node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
} }
s.clock.phase = 'shiftChange'; s.clock.phase = 'mainline';
const before = s.players[0]!.revenue;
const r = advance(s); const r = advance(s);
assert.ok( assert.ok(
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id), r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id),
'the Expedited departure was not put to the Superintendent', 'the departure was not put to the Superintendent',
);
assert.equal(
s.trays.get(id)?.position.at,
'grid',
'the Expedited train left despite needing a ruling nobody could give',
); );
assert.ok( assert.ok(
r.events.some((e) => e.type === 'trainHeld' && /EXPEDITES/.test(e.reason)), !r.events.some((e) => e.type === 'expediteFault'),
'the log does not say why the Expedited train stayed', 'a train properly parked at the station was faulted anyway',
); );
assert.equal( assert.equal(
s.trays.get(id)?.departsThisStage, s.players[0]!.revenue,
false, before,
'it should run as an ordinary train from now on, not retry the expedited departure', 'Revenue changed for a train legitimately held at the station',
); );
}); });
}); });
+325 -3
View File
@@ -685,6 +685,61 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
assert.equal(check(s, 0, { type: 'laborer.startLoad', at: coord }), 'NO_EMPTY_CAR_SPOTTED'); assert.equal(check(s, 0, { type: 'laborer.startLoad', at: coord }), 'NO_EMPTY_CAR_SPOTTED');
}); });
it('unloads the car the player actually picked, not always the westmost one', () => {
// REPORTED FROM PLAY: three loaded cars stood at a Freight House; asking to unload the EAST
// (index 2) car unloaded the WEST one instead, every time, regardless of which car was picked.
// The reducer for `unloadBegan` used to re-derive the target with
// `industryTrack.cars.findIndex(c => c.loaded)`, which always answers the first loaded car in
// track order no matter which `carIndex` the intent actually named.
const s = game();
const coord = at(-1, 0);
addCard(s, coord, {
geometry: { kind: 'facility', facility: 'freightHouse' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: {
kind: 'freight',
subtype: 'freightHouse',
allows: { outbound: true, inbound: true },
outboundBox: [],
inboundBox: [],
capacity: { outbound: 1, inbound: 1 },
menAtWork: [null, null, null],
industryTrack: {
cars: [
{ type: 'boxcar', loaded: true },
{ type: 'hopper', loaded: true },
{ type: 'tank', loaded: true },
],
},
laborers: 3,
porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
s.clock.phase = 'loadUnload';
// An empty replacement of every type standing on the track, so `check` never refuses for a
// reason unrelated to what this test is about.
s.yards.divisionYard.push(
{ type: 'boxcar', loaded: false },
{ type: 'hopper', loaded: false },
{ type: 'tank', loaded: false },
);
const r = applyIntent(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 2 });
assert.ok(r.ok, 'unloading the east (index 2) car should be legal');
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
assert.equal(f.industryTrack.cars[0]!.type, 'boxcar', 'the west car must be untouched');
assert.equal(f.industryTrack.cars[0]!.loaded, true, 'the west car must still be loaded');
assert.equal(f.industryTrack.cars[1]!.type, 'hopper', 'the middle car must be untouched');
assert.equal(f.industryTrack.cars[1]!.loaded, true, 'the middle car must still be loaded');
assert.equal(f.industryTrack.cars[2]!.type, 'tank', 'the east car — the one picked — must still be there');
assert.equal(f.industryTrack.cars[2]!.loaded, false, 'the east car is the one that should be emptied');
});
it('takes four Laborer actions in total for one point', () => { it('takes four Laborer actions in total for one point', () => {
// The asymmetry the whole economy is calibrated against (card-reference.md §2). // The asymmetry the whole economy is calibrated against (card-reference.md §2).
const s = game(); const s = game();
@@ -1229,9 +1284,11 @@ describe("a Modifier grants only what its host's flow can use", () => {
* and saying so is what the panel is for. Losing it FOREVER was the bug: the upgrade applied * and saying so is what the panel is for. Losing it FOREVER was the bug: the upgrade applied
* only the difference between two tiers and knew nothing about what had been discarded. * only the difference between two tiers and knew nothing about what had been discarded.
* *
* This used to be written against an Ice House on a Grocer's Warehouse. That case no longer * This used to be written against an Ice House on a Grocer's Warehouse, which suppresses again
* suppresses anything, because the Grocer's is a both-direction facility — which was the other * now that the Grocer's is inbound-only (v0.4.9e). The Office was chosen instead because the
* half of the same report. * suppression there is TEMPORARY — an upgrade can lift it — and losing the grant forever across
* that upgrade was the bug. A Grocer's never ships, so its Ice House is suppressed permanently
* and tests nothing about the upgrade path.
*/ */
const s = game(); const s = game();
const area = areaOf(s, 0); const area = areaOf(s, 0);
@@ -1840,3 +1897,268 @@ describe('the switching job a player actually does: put a car in a siding, take
); );
}); });
}); });
// ---------------------------------------------------------------------------
/**
* The v0.4.9d playtest, three reports with two causes.
*
* "Freight House: boxcars loaded cannot be immediately unloaded", "passenger stations: passengers
* just boarded cannot be immediately unloaded" — one rule, `RollingStock.origin`. And "operating two
* trains in a station: the select button does not work, regardless of which you pick it is always
* one train, not the other" — the porter intents carrying no tray.
*/
describe('a load may not be broken in the district that made it (v0.4.9e)', () => {
const office = (s: GameState) => areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!.facility!;
/** An Office that can work passengers, with someone waiting and Porters to hand. */
function platform(s: GameState): void {
const f = office(s);
f.allows = { outbound: true, inbound: true };
f.porters = 4;
f.capacity = { outbound: 2, inbound: 2 };
f.outboundBox = [{ type: 'coach', loaded: true }];
s.yards.divisionYard.push({ type: 'coach', loaded: false });
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
}
/** A tray standing on an A/D track at the Office. */
function atOffice(s: GameState, consist: CrewTray['consist']): string {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 0, consist,
direction: 'east', position: { at: 'grid', seat: 0, coord: areaOf(s, 0).officeCoord }, movesUsed: 0,
});
areaOf(s, 0).adOccupancy.push(id);
return id;
}
/** A Freight House with a load staged, an empty spotted, and Laborers enough to finish. */
function freightHouse(s: GameState, coord: GridCoord): void {
areaOf(s, 0).grid.set(coordKey(coord), {
geometry: { kind: 'facility', facility: 'freightHouse' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: {
kind: 'freight', subtype: 'freightHouse',
allows: { outbound: true, inbound: true },
outboundBox: [{ type: 'boxcar', loaded: true }],
inboundBox: [],
capacity: { outbound: 1, inbound: 1 },
menAtWork: [null, null, null],
industryTrack: { cars: [{ type: 'boxcar', loaded: false }] },
laborers: 9, porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [], enhancements: [],
} as TrackCard);
s.yards.divisionYard.push({ type: 'boxcar', loaded: false }, { type: 'boxcar', loaded: false });
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
}
/** Walk a staged load all the way onto the spotted car. */
function finishLoad(s: GameState, coord: GridCoord): void {
applyIntent(s, 0, { type: 'laborer.startLoad', at: coord });
for (const box of [0, 1, 2]) applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box });
}
it('refuses to unload the boxcar the Freight House just loaded', () => {
const s = game();
const coord = at(-1, 0);
freightHouse(s, coord);
finishLoad(s, coord);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
assert.deepEqual(f.industryTrack.cars.map((c) => c.loaded), [true], 'the load never reached the car');
assert.equal(f.industryTrack.cars[0]!.origin, 0, 'the load is not stamped with the district that made it');
assert.equal(
check(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 0 }),
'LOADED_IN_THIS_DISTRICT',
);
// And it is not merely absent from the menu by accident — the menu agrees with `check`.
assert.ok(
!legalActions(s, 0).some((i) => i.type === 'laborer.beginUnload'),
'the unload was still offered',
);
});
it('unloads a load that came from somewhere else', () => {
// The mirror, and the reason the rule is a stamp rather than a per-facility flag: a car made up
// at a Division Point out of the common supply carries no origin, and is exactly the inbound
// traffic a district lives on.
const s = game();
const coord = at(-1, 0);
freightHouse(s, coord);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.industryTrack.cars = [{ type: 'boxcar', loaded: true }];
assert.equal(check(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 0 }), null);
});
it('refuses to detrain the passengers this Office just put aboard', () => {
const s = game();
platform(s);
const tray = atOffice(s, [{ type: 'coach', loaded: false }]);
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord, trayId: tray }).ok);
const coach = s.trays.get(tray)!.consist[0]!;
assert.equal(coach.loaded, true, 'nobody boarded');
assert.equal(coach.origin, 0, 'the coach is not stamped with the Office that filled it');
assert.equal(
check(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }),
'LOADED_IN_THIS_DISTRICT',
);
});
it('detrains passengers who boarded somewhere else', () => {
const s = game();
platform(s);
const tray = atOffice(s, [{ type: 'coach', loaded: true }]);
assert.equal(check(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }), null);
});
it('takes the origin stamp off a coach that reaches the red box', () => {
// The stamp belongs to the LOAD. A coach going into the inbound box has finished its journey and
// heads back to a yard from there; carrying the stamp on would poison the common supply.
const s = game();
platform(s);
const tray = atOffice(s, [{ type: 'coach', loaded: true, origin: 1 }]);
assert.ok(applyIntent(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }).ok);
assert.equal(office(s).inboundBox[0]!.origin, undefined, 'the stamp survived the red box');
});
});
describe('two trains in one station are told apart (v0.4.9e)', () => {
function twoAtOffice(s: GameState): [string, string] {
const area = areaOf(s, 0);
const f = area.grid.get(coordKey(area.officeCoord))!.facility!;
f.allows = { outbound: true, inbound: true };
f.porters = 4;
f.capacity = { outbound: 2, inbound: 2 };
f.outboundBox = [{ type: 'coach', loaded: true }, { type: 'coach', loaded: true }];
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
const ids: string[] = [];
for (let n = 0; n < 2; n++) {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'coach', loaded: false }],
direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
});
area.adOccupancy.push(id);
ids.push(id);
}
return [ids[0]!, ids[1]!];
}
it('offers boarding on each train, not once for the platform', () => {
// REPORTED: "operating two trains in a station, the select button does not work — regardless of
// which you pick, it is always one train, not the other." There was one button, because the
// intent carried no train at all.
const s = game();
const [a, b] = twoAtOffice(s);
const boards = legalActions(s, 0).filter((i) => i.type === 'porter.board');
assert.deepEqual(
boards.map((i) => (i as { trayId?: string }).trayId).sort(),
[a, b].sort(),
'both trains at the platform must be offered',
);
});
it('boards the train the player named, not the first on the A/D tracks', () => {
const s = game();
const [a, b] = twoAtOffice(s);
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord, trayId: b }).ok);
assert.equal(s.trays.get(b)!.consist[0]!.loaded, true, 'the named train did not get the passengers');
assert.equal(s.trays.get(a)!.consist[0]!.loaded, false, 'the other train was filled instead');
});
it('still works for an intent that names no train, so old saves replay', () => {
// `trayId` is optional for the same reason `switch.move`'s `via` is: intents are the canonical
// record every save and every undo replays against.
const s = game();
const [a] = twoAtOffice(s);
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord }).ok);
assert.equal(s.trays.get(a)!.consist[0]!.loaded, true, 'the first eligible train should have taken them');
});
});
/**
* Reported from the v0.4.9d playtest and NOT reproduced: "when I back up to collect standing cars
* and, further down the tracks, the caboose, I get the caboose but the cars remain. I can later
* drive right through them."
*
* Coupling is mandatory (§A.4) and the walk accumulates what it meets card by card, so cars the
* engine can see always couple — which means cars a train can drive through are cars the engine does
* not think are there. Nothing found: `carsOn` is the single answer to "what is standing here" and
* the movement walk, the sweep and every renderer all ask it. These pin the shapes that were tried,
* so if the case is found later it is somewhere none of them cover.
*/
describe('backing up over a cut to something beyond it takes both (v0.4.9d report)', () => {
const boxcar = () => ({ type: 'boxcar' as const, loaded: false });
const caboose = () => ({ type: 'caboose' as const, loaded: false });
/** A Freight House card, with cars spotted on its industry track. */
function industry(cars: TrackCard['standing']): TrackCard {
return {
geometry: { kind: 'facility', facility: 'freightHouse' },
baseOperationalRail: true, standing: [], standingWest: 0,
facility: {
kind: 'freight', subtype: 'freightHouse',
allows: { outbound: true, inbound: true },
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 1 },
menAtWork: [null, null, null], industryTrack: { cars: [...cars] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [], enhancements: [],
} as TrackCard;
}
const empty = (s: GameState, ...coords: GridCoord[]): void => {
for (const c of coords) {
assert.deepEqual(carsOn(areaOf(s, 0).grid.get(coordKey(c))!), [], `cars left standing at (${c.col},${c.row})`);
}
};
it('takes a cut standing on plain track on the way to the caboose', () => {
const s = game();
addCard(s, at(0, 2), straight());
addCard(s, at(0, 1), straight([boxcar(), boxcar()]));
addCard(s, at(0, 0), straight([caboose()]));
const id = placeTray(s, at(0, 2));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
empty(s, at(0, 1), at(0, 0));
});
it('takes cars SPOTTED AT AN INDUSTRY on the way, not just the destination', () => {
// Jesse's best guess at the reported shape. An industry card is plain east-west track carrying a
// facility, and `carsOn` reads its industry track rather than the card — so this is the case
// where the two could have come apart.
const s = game();
addCard(s, at(0, 2), straight());
addCard(s, at(0, 1), industry([boxcar(), boxcar()]));
addCard(s, at(0, 0), straight([caboose()]));
const id = placeTray(s, at(0, 2));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
empty(s, at(0, 1), at(0, 0));
});
it("takes the train's own cut off the square it is standing on as well", () => {
const s = game();
addCard(s, at(0, 1), straight());
addCard(s, at(0, 0), straight([caboose()]));
const id = placeTray(s, at(0, 1), [boxcar(), boxcar()]);
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
// Set the pair out behind the engine, pull forward, then back up past them to the caboose.
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }).ok);
assert.equal(carsOn(areaOf(s, 0).grid.get(coordKey(at(0, 1)))!).length, 2);
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
empty(s, at(0, 1), at(0, 0));
});
});
+56 -19
View File
@@ -468,9 +468,10 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
* Nothing states the rule, which makes it exactly the kind of property a later change to the loop * Nothing states the rule, which makes it exactly the kind of property a later change to the loop
* would break in silence. These pin it. * would break in silence. These pin it.
* *
* It is also what gives the printed **Expedite** rule something to do — an Expedited train departs * v0.4.9 CORRECTED Q3 so **Expedite is not an exception to this any more**: an Expedited train
* at the END of the Stage it arrived, precisely because the general rule forbids it departing at * stops for the Stage exactly like any other and may be switched in the Local Operations turn that
* all. It stands through Load/Unload first, which is what makes its coaches workable. * follows. What Expedite restricts is where the train may be left standing afterwards, not when it
* departs — see `advance.test.ts`'s `expediteFault` coverage for that half.
*/ */
const arriving = (s: GameState, trainNumber: number, isExtra = false): string => { const arriving = (s: GameState, trainNumber: number, isExtra = false): string => {
const id = 'arriving'; const id = 'arriving';
@@ -503,12 +504,14 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
); );
}); });
it('unless its card prints Expedite — but it still stands through Load/Unload first', () => { it('an Expedite train follows the same rule now — no exception any more', () => {
/** /**
* Q3, as revised in v0.4.2. An Expedite train leaves the Stage it arrives, but at the END of that * Q3, as CORRECTED in v0.4.9. Before this, an Expedite train left the Stage it arrived — at the
* Stage rather than inside the Mainline Phase. It used to be gone before Load/Unload ran, which * end of it rather than inside the Mainline Phase (v0.4.2's fix for coaches otherwise never
* made the coaches on every coach-carrying express (1/2, 5/6, 19) permanently unworkable — * reaching Load/Unload) — which made it the one exception to "stops for the Stage". That forced
* measured, Train 2 arrived 32 times in 60 games and stood for Load/Unload in none of them. * departure is gone: Expedite no longer changes WHEN a train leaves, only where it may be left
* standing afterwards (`expediteFault`, pinned in `advance.test.ts`). So it stops for the Stage
* exactly like Train 9 above.
*/ */
const s = game(); const s = game();
assert.equal(trainProfile(3, false)?.rules.expedite, true, 'train 3 should be an Expedite train'); assert.equal(trainProfile(3, false)?.rules.expedite, true, 'train 3 should be an Expedite train');
@@ -518,19 +521,9 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
assert.equal( assert.equal(
s.trays.get(id)!.position.at, 'grid', s.trays.get(id)!.position.at, 'grid',
'an Expedite train left inside the Mainline Phase, before Load/Unload could reach it', 'an Expedite train departed again in the same Stage it arrived from a mainline card',
); );
assert.equal(s.trays.get(id)!.departsThisStage, true, 'it is not flagged to leave at the end of the Stage');
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'it is not standing on an A/D track'); assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'it is not standing on an A/D track');
// Run the end of the Stage directly. Load/Unload waits on the player, and the thing under test
// is the departure pass that follows it — not that phase's own input handling.
s.clock.phase = 'shiftChange';
advance(s);
assert.notEqual(
s.trays.get(id)?.position.at, 'grid',
'an Expedite train should be gone by the end of the Stage it arrived',
);
}); });
}); });
@@ -666,6 +659,50 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => {
assert.deepEqual(s.trays.get('mine')!.position, { at: 'divisionPoint', side: 'west' }); assert.deepEqual(s.trays.get('mine')!.position, { at: 'divisionPoint', side: 'west' });
}); });
it('holds against an uncleared facing train even if a same-direction one is checked first', () => {
// Reproduces a playtest report: the Superintendent was asked to rule on a same-direction train
// instead of being automatically held against an opposite-direction one also occupying the
// card. Root cause — `evaluateClearance` returned on whichever occupant it examined first, in
// `node.transits` insertion order, rather than checking every occupant. Only visible once a
// card can hold two trains at once, which the Telegraph/Telephone/Radio exception is exactly
// what creates: a facing train dispatched past another sits on the same card as it.
const s = game();
const ml = s.division.nodes[1];
assert.equal(ml?.kind, 'mainline');
if (ml?.kind === 'mainline') {
ml.card = 'plains';
// SAME direction, entered the transit list FIRST — what the old code asked about.
s.trays.set('sameDir', {
id: 'sameDir', trainNumber: 3, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
ml.transits.push({ tray: 'sameDir', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
// OPPOSITE direction, entered SECOND, no dispatch device on the board — an absolute bar.
s.trays.set('facing', {
id: 'facing', trainNumber: 5, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
ml.transits.push({ tray: 'facing', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
}
s.trays.set('mine', {
id: 'mine', trainNumber: 9, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
});
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
const r = advance(s);
assert.equal(
s.trays.get('mine')!.position.at, 'divisionPoint',
'the train entered despite an uncleared opposite-direction occupant on the card',
);
assert.ok(
!r.events.some((e) => e.type === 'clearanceRequested'),
'the Superintendent was asked to rule on the same-direction occupant instead of an automatic hold',
);
});
it('lets a junior train win the meet with a Telegraph', () => { it('lets a junior train win the meet with a Telegraph', () => {
// Train 9 against Train 3: +4 makes the other count as 7 — still senior. Radio's +12 wins. // Train 9 against Train 3: +4 makes the other count as 7 — still senior. Radio's +12 wins.
const s = game(); const s = game();
+1
View File
@@ -46,6 +46,7 @@ const KNOWN_UNREDUCED = [
'carPassed', 'carPassed',
'clearanceRequested', 'clearanceRequested',
'dispatchBonusUsed', 'dispatchBonusUsed',
'expediteFault',
'phaseBegan', 'phaseBegan',
'stageBegan', 'stageBegan',
'trainArrived', 'trainArrived',
+10 -3
View File
@@ -52,13 +52,13 @@ const SAMPLES: GameEvent[] = [
{ type: 'carPassed', player: 0, trayId: 't0' }, { type: 'carPassed', player: 0, trayId: 't0' },
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' }, { type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
{ type: 'clearanceGiven', trainId: 't1', allow: false }, { type: 'clearanceGiven', trainId: 't1', allow: false },
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 } }, { type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
{ type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 } }, { type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
{ type: 'loadStarted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' }, { type: 'loadStarted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 }, { type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 },
{ type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' }, { type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'loadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' }, { type: 'loadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'unloadBegan', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' }, { type: 'unloadBegan', player: 0, at: { row: 1, col: 0 }, carType: 'hopper', carIndex: 0 },
{ type: 'revenueChanged', player: 0, delta: 1, total: 3, reason: 'freightLoad' }, { type: 'revenueChanged', player: 0, delta: 1, total: 3, reason: 'freightLoad' },
{ type: 'phaseEnded', player: 0, phase: 'localOps' }, { type: 'phaseEnded', player: 0, phase: 'localOps' },
]; ];
@@ -446,6 +446,13 @@ describe('the replay behaves like the game it is replaying', () => {
assert.ok(kinds.has('couple'), 'coupling a cut never made a sound'); assert.ok(kinds.has('couple'), 'coupling a cut never made a sound');
assert.ok(kinds.has('drop'), 'setting cars out never made a sound'); assert.ok(kinds.has('drop'), 'setting cars out never made a sound');
assert.ok(kinds.has('schedule'), 'the 1D12 that sets a train\'s departure Stage landed silently'); assert.ok(kinds.has('schedule'), 'the 1D12 that sets a train\'s departure Stage landed silently');
assert.ok(kinds.has('arrive'), 'a train pulling into an Office never made a sound');
assert.ok(kinds.has('depart'), 'a train highballing out of an Office never made a sound');
// Collisions are rare — measured 2 in 40 games — so this is the one cue this pool is not
// guaranteed to contain on every stride; it happens to (seeds 96028 and 159380) at the current
// stride and seed count. If this starts failing after either changes, widen the pool rather than
// deleting the assertion — §10 is the one event a player most needs to hear.
assert.ok(kinds.has('crash'), 'a collision never made a sound');
// One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same // One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same
// accounting the live game is held to. The switching cues are counted separately: they follow // accounting the live game is held to. The switching cues are counted separately: they follow
+19 -12
View File
@@ -181,23 +181,30 @@ describe('card catalogue (component 1)', () => {
} }
}); });
it('identifies the both-direction industries the card reference names', () => { it('names the Freight House and nothing else as the two-way industry', () => {
/** /**
* `card-reference.md`: "'Freight House' is not a card. It is the collective term for a freight * §9.3 — "Passenger Facilities and Freight Houses permit cars to move each direction". ONE card
* facility that loads *and* unloads — the Grocer's Warehouse and the Oil Refinery." The table * answers to that.
* agrees: both are "Both", and only the Power Plant is inbound-only.
* *
* The engine had the Refinery as outbound-only and the Grocer's as inbound-only, so §9.3's * This briefly asserted three. `card-reference.md` reads "'Freight House' is not a card. It is
* "Passenger Facilities and Freight Houses permit cars to move each direction" named neither of * the collective term for a freight facility that loads *and* unloads — the Grocer's Warehouse
* them — and every Modifier grant on the missing direction was silently dropped, which is how * and the Oil Refinery", and on that premise the Refinery and the Grocer's were both made
* "grocer's warehouse didn't get extra outbound slot for truck dock" was reported. * `flow: 'both'`. The premise is dead: `glossary.md` and `rules-v0.2.md` corrected the Freight
* House to a card of its own, dealt like any other industry, so §9.3 names it and the table's
* "Both" column loses its only argument.
* *
* `freightHouse` is still in this list because the engine deals it as a CARD, which the rules say * Reported from playtesting v0.4.9d and confirmed by Jesse: the Refinery only ships tanks out,
* it is not. That is a deck-composition question, recorded in TODO.md, not something to quietly * the Grocer's Warehouse only receives. `StationMaster-Home-Deck-v0.4.5.md` prints both that way,
* delete six cards over. * and so does the modifier set — all three Refinery modifiers grant outbound.
*/ */
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind); const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
assert.deepEqual(houses.sort(), ['freightHouse', 'grocersWarehouse', 'refinery']); assert.deepEqual(houses.sort(), ['freightHouse']);
const refinery = FREIGHT_PROFILES.find((f) => f.kind === 'refinery')!;
assert.equal(refinery.flow, 'outbound');
assert.deepEqual([refinery.baseOut, refinery.baseIn], [1, 0]);
const grocers = FREIGHT_PROFILES.find((f) => f.kind === 'grocersWarehouse')!;
assert.equal(grocers.flow, 'inbound');
assert.deepEqual([grocers.baseOut, grocers.baseIn], [0, 1]);
}); });
it('starts every industry at one car out and one loader', () => { it('starts every industry at one car out and one loader', () => {
+12 -9
View File
@@ -345,8 +345,8 @@ describe('end-of-game statistics', () => {
* BOT no longer gets a crew next to an industry: it builds shallow districts and places 2.2 * BOT no longer gets a crew next to an industry: it builds shallow districts and places 2.2
* facilities a game, down from 3.8 when industries could sit on the main. * facilities a game, down from 3.8 when industries could sit on the main.
* *
* That is the bot's district-building weakness, which is the top item in TODO.md, not a rule * That is the bot's district-building weakness, tracked in TODO.md under Bot Performance, not a
* that has become unreachable. Exempted by name so the other forty-odd event checks stay live, * rule that has become unreachable. Exempted by name so the other forty-odd event checks stay live,
* and so removing this line is what proves the bot has been fixed. * and so removing this line is what proves the bot has been fixed.
*/ */
const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch']; const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch'];
@@ -474,8 +474,10 @@ describe('switching accomplishes something (regression)', () => {
* grew faster, which is traffic rather than aimlessness, and there are two new sources of it: * grew faster, which is traffic rather than aimlessness, and there are two new sources of it:
* Extras now start at the Division Point their NUMBER sends them to, so westbound Extras exist * Extras now start at the Division Point their NUMBER sends them to, so westbound Extras exist
* at all (measured 32 west / 29 east across 60 deals, against every single one launching * at all (measured 32 west / 29 east across 60 deals, against every single one launching
* eastbound from the West Division Point before); and the Grocer's Warehouse ships as well as * eastbound from the West Division Point before); and the Grocer's Warehouse briefly shipped as
* receives, so there is more switching worth doing. * well as received, which was more switching worth doing. That second source is gone again in
* v0.4.9e — the Grocer's is inbound-only, as it always was on the sheet — and the ratio still
* clears the floor, so the figure is left where it is rather than re-tuned to one release.
* *
* A crew that shuttles for its own sake would show this ratio climbing while `work` stood still. * A crew that shuttles for its own sake would show this ratio climbing while `work` stood still.
* Logged in TODO.md with the rest of the bot drift rather than quietly absorbed. * Logged in TODO.md with the rest of the bot drift rather than quietly absorbed.
@@ -1070,12 +1072,13 @@ describe('the freight figures count both halves (regression)', () => {
// that on: an unload needs an inbound industry built, reachable, and a loaded car spotted at it, // that on: an unload needs an inbound industry built, reachable, and a loaded car spotted at it,
// and whether the bot manages all three on a given deal is luck, not the thing under test. // and whether the bot manages all three on a given deal is luck, not the thing under test.
/** /**
* FORTY DEALS, up from twelve, and the reason is a rules correction rather than flakiness. * FORTY DEALS, up from twelve, and the reason was a rules correction rather than flakiness.
* *
* The Grocer's Warehouse is a BOTH-direction facility now — `card-reference.md` always said so — * The Grocer's Warehouse was briefly a both-direction facility, so the bot shipped from it as
* where the engine had it inbound-only. So the bot can ship from it as well as receive, and it * well as receiving and deals producing at least one completed unload fell from 12 in 40 to 6 in
* often does: deals producing at least one completed unload went from 12 in 40 to 6 in 40, while * 40. v0.4.9e put it back to inbound-only, which is what the sheet always printed. The wider
* unloads themselves are unharmed (30 completed across the 40 measured after the change). * sample is kept: the precondition it protects — that some deal in the batch actually completes
* an unload — is worth having whichever way the rule goes.
* *
* The subject here is the INSTRUMENT — does `freightUnload` count Revenue earned rather than * The subject here is the INSTRUMENT — does `freightUnload` count Revenue earned rather than
* unloads started — and `unloads > 0` is only the precondition that makes the comparison mean * unloads started — and `unloads > 0` is only the precondition that makes the comparison mean
+13 -3
View File
@@ -76,13 +76,23 @@ const coach = (loaded = false): RollingStock => ({ type: 'coach', loaded });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('§7 — no switching', () => { describe('§7 — no switching', () => {
it('refuses every switching act to a train whose card forbids it', () => { it('still allows a plain move — "no switching" means no adding or dropping cars', () => {
// Six cards print it: both expresses, the Light Engine, Campaign, Circus and Military trains. // Six cards print it: both expresses, the Light Engine, Campaign, Circus and Military trains.
// Moving, setting out and sorting are all switching, so all three are refused. // A train held at the Office may still need to clear onto Secondary Track ahead of other
// traffic, so a move that couples nothing is legal.
const s = game(); const s = game();
switching(s, 1, false, [boxcar()]); switching(s, 1, false, [boxcar()]);
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
});
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), 'NO_SWITCHING'); it('refuses a move that would couple a car, and refuses dropping and sorting outright', () => {
const s = game();
switching(s, 1, false, [boxcar()], [boxcar()]);
assert.equal(
check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }),
'PICKUP_NOT_ALLOWED',
);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'NO_SWITCHING'); assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'NO_SWITCHING');
assert.equal(check(s, 0, { type: 'switch.sortConsist', trayId: 't', order: [0] }), 'NO_SWITCHING'); assert.equal(check(s, 0, { type: 'switch.sortConsist', trayId: 't', order: [0] }), 'NO_SWITCHING');
}); });
+172 -8
View File
@@ -841,7 +841,7 @@ describe('board highlighting', () => {
} }
assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`); assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`); assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree'); assert.ok(sp.label.includes(`(${sp.coord.col}, ${sp.coord.row})`), 'label and coord disagree');
} }
} }
}); });
@@ -1787,7 +1787,19 @@ describe('the static build', () => {
// click-through happens to visit. // click-through happens to visit.
const reachable = (menu: ReturnType<typeof actionMenu>): Set<number> => { const reachable = (menu: ReturnType<typeof actionMenu>): Set<number> => {
const out = new Set<number>(); const out = new Set<number>();
for (const g of menu.direct) for (const a of g.actions) out.add(a.index); /**
* THE SAME EXCLUSION `renderActions()` (main.ts) APPLIES, not the raw menu.
*
* `menu.direct` alone is what the ENGINE offers; `renderActions()` then drops any group whose
* title matches the one `menu.makeUp` already covers, so a group counted here but filtered
* there is exactly the gap that stranded a pending Extra with `menu.makeUp` null and its own
* "Making up Extra X22…" title caught by the OLD broader regex this once was. Mirroring the
* real filter is what makes this test the one that would have caught that.
*/
const onScreen = menu.direct.filter(
(g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title,
);
for (const g of onScreen) for (const a of g.actions) out.add(a.index);
for (const g of menu.placeable) for (const it of g.items) for (const sp of it.spots) out.add(sp.index); for (const g of menu.placeable) for (const it of g.items) for (const sp of it.spots) out.add(sp.index);
for (const h of menu.hand) { for (const h of menu.hand) {
if (h.playNow !== null) out.add(h.playNow); if (h.playNow !== null) out.add(h.playNow);
@@ -1817,6 +1829,37 @@ describe('the static build', () => {
} }
}); });
it('offers "where does this Extra start" on screen, not just in the menu', () => {
// REPORTED: the game "hung" mid-Freight-House-work with nothing to click. `newTrain.startExtra`
// was legal and present in `menu.direct` — the engine was never stuck — but its group is titled
// by `trainCardTitle`, which begins "Making up Extra X22…" exactly like the yard-chip panel's
// OWN group. `renderActions()` used to drop every group titled that way, on the assumption only
// the yard-chip panel used the prefix; with no tray yet being filled (`menu.makeUp` is null,
// since the Extra has not chosen where it starts yet), that group was the only thing offered and
// it vanished from the page — a legal decision with zero buttons.
const game = newGame(430);
game.state.pendingExtras.push(22); // Extra X22 "Pee-Dee" — one caboose, per-diem.
game.state.clock.phase = 'newTrain';
game.state.clock.currentActor = 0;
assert.equal(currentActor(game), 0, 'a decision should be waiting');
const menu = actionMenu(game);
assert.equal(menu.makeUp, null, 'no tray is being filled — the Extra has not started yet');
const extraGroup = menu.direct.find((g) => g.actions.some((a) => menu.options[a.index]?.type === 'newTrain.startExtra'));
assert.ok(extraGroup, 'newTrain.startExtra should appear in some menu.direct group');
assert.match(extraGroup!.title, /^Making up /, 'this is exactly the title shape that used to collide');
// The actual filter `renderActions()` applies (main.ts) — reproduced here for the reason given
// on `reachable()` above: there is no DOM harness in this suite to call the real renderer.
const onScreen = menu.direct.filter(
(g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title,
);
assert.ok(
onScreen.includes(extraGroup!),
'the Extra\'s "choose where it starts" group was filtered out along with the yard-chip panel',
);
});
it('renders every part of the menu it is given', () => { it('renders every part of the menu it is given', () => {
// THE CHECK THAT WOULD HAVE CAUGHT THE SOFTLOCK ABOVE. The option was in the menu all along — // THE CHECK THAT WOULD HAVE CAUGHT THE SOFTLOCK ABOVE. The option was in the menu all along —
// `makeUp.pass` was computed correctly and simply never rendered, so the invariant on the menu // `makeUp.pass` was computed correctly and simply never rendered, so the invariant on the menu
@@ -2054,6 +2097,27 @@ describe('the static build', () => {
} }
}); });
it('draws the crew chip on a train standing away from the Office', () => {
// REPORTED: dropping a loaded boxcar off the back of Train 3 at the Freight House made "the
// train vanish off the face of the earth". The game state was never wrong — `selectedTrain` was
// only ever assigned inside the `cell.adTracks !== null` branch, so a train standing on any card
// OTHER than the Office (which is every card it stands on while actually being switched) got no
// badge at all: no label, no engine arrow, no cars. This is the Freight House from that report —
// an ordinary facility card, `adTracks: null`, one train parked on it mid-switch.
const cell = {
row: 1, col: 0, kind: 'facility', label: 'Freight House', running: false,
enhancements: [], enhancementsWhat: [], adTracks: null,
trains: [{
trayId: 'tray3', label: 'T3', cars: ['empty boxcar'], engineAt: 0, facing: 'w' as const,
what: 'Train 3 "Express"',
}],
cars: [], standingWest: 0, facility: null, links: ['ew'], what: '',
};
const svg = officeSvg([cell as never], 0);
assert.match(svg, /class="bs-crew"/, 'the crew badge is not drawn off the Office square');
assert.match(svg, />T3</, 'the train\'s own label is missing from the board');
});
it('draws a bare cut left-aligned, whatever the card\'s stored standingWest says', () => { it('draws a bare cut left-aligned, whatever the card\'s stored standingWest says', () => {
// "Settled questions" in docs/plans/switching-paths.md — the split only ever predicts something // "Settled questions" in docs/plans/switching-paths.md — the split only ever predicts something
// while a train stands on the card, and an earlier draft of the plan had this backwards. A stale // while a train stands on the card, and an earlier draft of the plan had this backwards. A stale
@@ -3113,18 +3177,118 @@ describe('two crews switching are told apart', () => {
assert.notDeepEqual(f.moves[0]!.from, f.moves[1]!.from); assert.notDeepEqual(f.moves[0]!.from, f.moves[1]!.from);
}); });
it('leaves out a train the rules will not let switch at all', () => { it('still offers a no-switching train as a crew to switch — it may be moved, just not worked', () => {
// `movesFor` is pure track geometry; six cards print "no switching" and `check` enforces it. // v0.4.9 — six cards print "no switching", which means may not couple, set out or sort, not
// Offering the Circus Train in a "which train are you switching?" row offers a train whose // "may never be touched": a train held at the Office may still need to clear onto Secondary
// every move is refused. // Track ahead of other traffic. `check` refuses the coupling itself, not the move, so this row
// is not filtered any differently for a no-switching train than for any other.
const game = twoCrews(); const game = twoCrews();
const s = game.state; const s = game.state;
const circus = [...s.trays.values()][0]!; const circus = [...s.trays.values()][0]!;
circus.trainNumber = 18; circus.trainNumber = 18;
circus.trainIsExtra = true; // X18 Circus Train — noSwitching circus.trainIsExtra = true; // X18 Circus Train — noSwitching
const f = view(game); const f = view(game);
assert.equal(f.moves.length, 1, 'a no-switching train was offered as a crew to switch'); assert.equal(f.moves.length, 2, 'a no-switching train was left out of the crews offered to switch');
assert.equal(f.moves[0]!.label, 'Train 12'); assert.deepEqual(f.moves.map((m) => m.label).sort(), ['Train 12', 'Train X18']);
});
});
describe('two trains at one platform are told apart (v0.4.9e)', () => {
/**
* REPORTED from the v0.4.9d playtest: "operating two trains in a station — the select button does
* not work. Regardless of which you pick, it is always one train, not the other."
*
* `porter.board` and `porter.detrain` carried no tray, so there was ONE button per platform however
* many trains were standing at it, and the reducer filled whichever tray came first out of
* `adOccupancy`. Clicking a roster chip changed what the board drew and nothing else — which is
* exactly what "the select button does not work" describes.
*
* Driven through `actionMenu` rather than `legalActions` because the second half of the failure was
* at this layer: the menu collapses identical labels within a crew, and "board passengers at (0,0)"
* describes both trains.
*/
const twoAtPlatform = (): { game: Game; trays: string[] } => {
const game = newGame(4242);
const s = game.state;
const area = areaOf(s, 0);
const f = area.grid.get(`${area.officeCoord.row},${area.officeCoord.col}`)!.facility!;
// A Station's worth of platform: Porters, slots, and two fares waiting.
f.allows = { outbound: true, inbound: true };
f.porters = 4;
f.capacity = { outbound: 2, inbound: 2 };
f.outboundBox = [{ type: 'coach', loaded: true }, { type: 'coach', loaded: true }];
const trays: string[] = [];
for (const trainNumber of [7, 9]) {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'coach', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: area.officeCoord },
movesUsed: 0,
} as never);
area.adOccupancy.push(id);
trays.push(id);
}
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
return { game, trays };
};
it('offers boarding once per train, with the train named on the button', () => {
const { game } = twoAtPlatform();
const labels = actionMenu(game)
.direct.flatMap((g) => g.actions)
.map((a) => a.label)
.filter((l) => /^board passengers/.test(l));
assert.equal(labels.length, 2, `expected one button per train, got ${JSON.stringify(labels)}`);
assert.ok(labels.some((l) => /Train 7/.test(l)), `no button names Train 7: ${JSON.stringify(labels)}`);
assert.ok(labels.some((l) => /Train 9/.test(l)), `no button names Train 9: ${JSON.stringify(labels)}`);
});
it('boards the train whose button was pressed', () => {
const { game, trays } = twoAtPlatform();
const { options } = actionGroups(game);
const nine = options.findIndex(
(o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[1],
);
assert.ok(nine >= 0, 'no boarding option names the second train');
submit(game, options[nine]!);
assert.equal(game.state.trays.get(trays[1]!)!.consist[0]!.loaded, true, 'Train 9 did not get them');
assert.equal(game.state.trays.get(trays[0]!)!.consist[0]!.loaded, false, 'Train 7 was filled instead');
});
it('will not detrain the passengers it has just put aboard', () => {
// The other half of the same playtest: "passenger stations — passengers just boarded cannot be
// immediately unloaded." They could, for a Porter action and full Revenue, without the train
// moving an inch.
const { game, trays } = twoAtPlatform();
const board = actionGroups(game).options.find(
(o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[0],
)!;
submit(game, board);
const detrains = actionGroups(game).options.filter((o) => o.type === 'porter.detrain');
assert.equal(detrains.length, 0, 'detraining was still offered for passengers who boarded here');
assert.equal(
check(game.state, 0, { type: 'porter.detrain', at: areaOf(game.state, 0).officeCoord, trayId: trays[0]! }),
'LOADED_IN_THIS_DISTRICT',
);
});
it('says on the coach that it was loaded here', () => {
// The printed game turns the chip upside down in the tray; this is the screen's version of that.
const { game, trays } = twoAtPlatform();
submit(game, actionGroups(game).options.find(
(o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[0],
)!);
const f = view(game);
const office = f.cells.find((c) => c.kind === 'office')!;
const train = office.trains.find((t) => t.trayId === trays[0]);
assert.ok(train, 'the boarded train is not on the Office card');
assert.ok(
train!.cars.some((c) => /loaded here/.test(c)),
`the coach does not say where it was loaded: ${JSON.stringify(train!.cars)}`,
);
}); });
}); });