Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c35e002af |
+137
@@ -19,6 +19,143 @@ 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
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
- **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 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
|
||||
|
||||
@@ -396,6 +396,41 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
|
||||
|
||||
Blocked on a decision, not on work.
|
||||
|
||||
- [ ] **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."**
|
||||
Reported against v0.4.9d by a playtester (not Jesse, who forwarded it and could not add detail;
|
||||
his guess was that the cars were spotted at an industry).
|
||||
|
||||
**What was tried, all of which works.** Cars on plain track on the way to the caboose; cars
|
||||
SPOTTED AT AN INDUSTRY on the way; the train's own cut standing on the square it is pulling out
|
||||
of; a stale `standingWest` on the intermediate card; the industry locked by MEN AT WORK (which
|
||||
correctly blocks the whole route rather than letting the crew past). Every one couples the lot.
|
||||
The first three are pinned in `apply.test.ts` — "backing up over a cut to something beyond it
|
||||
takes both" — so if the case is found later it is somewhere none of them cover.
|
||||
|
||||
**Why it is hard to make happen.** 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` (`state.ts`) 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. (One route to one was closed anyway: `flyingSwitch`'s reducer wrote the cut straight into
|
||||
`industryTrack`, which for a Passenger Facility is not where `carsOn` looks. `check` refuses a
|
||||
non-freight target, so it never fired.)
|
||||
|
||||
**The two questions that would settle it**, for whoever has the board: was there a SECOND route
|
||||
to the caboose — a parallel row, or a turnout — so the move could have gone round the cars? And
|
||||
what did the move button say it would couple? The label names every car (`describeIntent`), so a
|
||||
button that read "couples caboose" and one that read "couples 2 boxcars, caboose" are different
|
||||
bugs: the first is route selection, the second is the sweep.
|
||||
|
||||
- [ ] **An unload does not check the facility's commodity.** `laborer.beginUnload` gates on
|
||||
`allows.inbound`, a loaded car, an empty of that type in the Division Yard and room in the red
|
||||
box — but never on `facilityCarTypes(f)`, which `freightAgent.stockOutbound` does check. So a
|
||||
Freight House (boxcars) will unload a hopper. Found reading the code for the v0.4.9e district
|
||||
rule, not from play. Low impact today because the district rule now refuses the only same-Office
|
||||
pairing that made it easy to hit, and because the bot spots matching cars — but it is a rule the
|
||||
engine states in one direction and not the other.
|
||||
|
||||
- [ ] **WHERE THE LOCAL'S COACH STANDS WHILE ITS ENGINE WORKS (§A.4) — now hit in play, still open.**
|
||||
Trains 7/8 print "coach must remain on station track if switching", read as "the coach is never
|
||||
set out". A cut comes off an OUTER end, so a coach on one outer end with the engine on the
|
||||
|
||||
@@ -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 |
|
||||
| Produce Shed | Reefer | Outbound only | 2 | 2 | — | 3 | 2 |
|
||||
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 | 2 |
|
||||
| Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4 | 2 |
|
||||
| Grocer's Warehouse | Boxcar | Inbound only | 2 | — | 2 | 3 | 2 |
|
||||
| Oil Refinery | Tank car | Outbound only | 3 | 2 | — | 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;
|
||||
produce ships out; a warehouse and a refinery do both. This gives §9 all three of its cases —
|
||||
outbound-only, inbound-only, and both.
|
||||
produce ships out; a warehouse receives. This gives §9 all three of its cases — outbound-only,
|
||||
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
|
||||
that loads *and* unloads — the Grocer's Warehouse and the Oil Refinery. §9.3's "Passenger Facilities
|
||||
and Freight Houses permit cars to move each direction" therefore names exactly those two.
|
||||
**The Grocer's Warehouse and the Oil Refinery are ONE-WAY** (corrected v0.4.9e, from gameplay
|
||||
testing): "Grocer's Warehouse should be receive only, does not ship anything out"; "Refinery: only
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
two flows through one three-box pipeline, so its pipeline stays fuller than a one-way facility's and
|
||||
the third Laborer is earning its keep.
|
||||
<!-- The paragraph that stood here explained the Oil Refinery's third Laborer as the price of serving
|
||||
two flows through one pipeline. It serves one flow (v0.4.9e), so the explanation is gone with the
|
||||
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
|
||||
away both cost switching actions from the same budget. See §7.
|
||||
@@ -258,12 +275,12 @@ with all boxes full:
|
||||
| Car | Facility demand | Supply | Headroom |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Hopper | Mine Tipple 3×2 + Power Plant 3×2 = 12 | 12 | exactly met |
|
||||
| Tank | Oil Refinery (2+2)×2 = 8 | 8 | exactly met |
|
||||
| Boxcar | Grocer's (2+2)×2 = 8 | 12 | 4 spare |
|
||||
| Tank | Oil Refinery 2×2 = 4 | 8 | 4 spare (was "exactly met" while the Refinery was two-way) |
|
||||
| Boxcar | Grocer's 2×2 = 4 | 12 | 8 spare (same correction) |
|
||||
| Reefer | Produce Shed 2×2 = 4 | 8 | 4 spare |
|
||||
| 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
|
||||
Classification Yard recycle returns stock to the Division Yard whenever it empties. Both are worth
|
||||
watching in playtesting.
|
||||
|
||||
@@ -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 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 |
|
||||
| **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 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 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** | 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 |
|
||||
| **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 |
|
||||
|
||||
@@ -670,10 +670,15 @@ balance work was possible.
|
||||
| --- | --- | --- | ---: | ---: | ---: | ---: |
|
||||
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
|
||||
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
|
||||
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 |
|
||||
| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 |
|
||||
| Grocer's Warehouse | Boxcar | Inbound | 2 | — | 2 | 3 |
|
||||
| Oil Refinery | Tank | Outbound | 3 | 2 | — | 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
|
||||
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),
|
||||
|
||||
@@ -735,8 +735,8 @@ Modifier effects, and track geometries — is catalogued in
|
||||
| --- | --- | --- | ---: | ---: | ---: | ---: |
|
||||
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
|
||||
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
|
||||
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 |
|
||||
| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 |
|
||||
| Grocer's Warehouse | Boxcar | Inbound | 2 | — | 2 | 3 |
|
||||
| Oil Refinery | Tank | Outbound | 3 | 2 | — | 4 |
|
||||
| Power Plant | Hopper | Inbound | 3 | — | 3 | 4 |
|
||||
|
||||
| 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) |
|
||||
| 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
|
||||
facility that both loads and unloads, namely the Grocer's Warehouse and the Oil Refinery.
|
||||
**"Freight House"** (§9.3, Appendix A) is a Freight Facility card, dealt like any other industry —
|
||||
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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.4.9d",
|
||||
"version": "0.4.9e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"seed": 927523,
|
||||
"title": "12 Revenue · seed 927523",
|
||||
"note": "12 Revenue over 5 Days · 2 train(s) on the timetable · 20 cards down · no collisions · played by developer",
|
||||
"title": "11 Revenue · seed 927523",
|
||||
"note": "11 Revenue over 5 Days · 2 train(s) on the timetable · 21 cards down · no collisions · played by developer",
|
||||
"rules": {
|
||||
"startingHand": "threeRandom",
|
||||
"revenue": {
|
||||
@@ -320,21 +320,24 @@
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
"trayId": "tray3"
|
||||
},
|
||||
{
|
||||
"type": "porter.board",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
"trayId": "tray3"
|
||||
},
|
||||
{
|
||||
"type": "porter.detrain",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
"trayId": "tray3"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
@@ -589,21 +592,24 @@
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
"trayId": "tray2"
|
||||
},
|
||||
{
|
||||
"type": "porter.board",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
"trayId": "tray2"
|
||||
},
|
||||
{
|
||||
"type": "porter.detrain",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
"trayId": "tray2"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
@@ -951,36 +957,38 @@
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
"trayId": "tray3"
|
||||
},
|
||||
{
|
||||
"type": "porter.board",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "porter.detrain",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
"trayId": "tray3"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.clearInbound",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c162",
|
||||
"placement": {
|
||||
"row": -2,
|
||||
"col": 2
|
||||
},
|
||||
"index": 0
|
||||
"variant": 1
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
@@ -1179,6 +1187,12 @@
|
||||
"carType": "coach",
|
||||
"loaded": false
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray3",
|
||||
"carType": "coach",
|
||||
"loaded": false
|
||||
},
|
||||
{
|
||||
"type": "laborer.advanceLoad",
|
||||
"at": {
|
||||
@@ -1236,7 +1250,58 @@
|
||||
"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",
|
||||
|
||||
@@ -41,7 +41,7 @@ import type { GameEvent } from './events.ts';
|
||||
import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts';
|
||||
import { legalActions } from './legal.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 = {
|
||||
events: GameEvent[];
|
||||
@@ -943,9 +943,11 @@ function collide(
|
||||
consist: [...tray.consist],
|
||||
});
|
||||
// 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) {
|
||||
if (car.type === 'caboose') s.yards.divisionYard.push(car);
|
||||
else s.yards.classificationYard.push(car);
|
||||
if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car));
|
||||
else s.yards.classificationYard.push(pooled(car));
|
||||
}
|
||||
s.trays.delete(id);
|
||||
s.freeTrays.push(id);
|
||||
@@ -1014,9 +1016,10 @@ function retireTrain(
|
||||
side: Direction,
|
||||
events: GameEvent[],
|
||||
): void {
|
||||
// `pooled` — see `trainsDestroyed` above; a load's origin stamp does not survive the yard.
|
||||
for (const car of tray.consist) {
|
||||
if (car.type === 'caboose') s.yards.divisionYard.push(car);
|
||||
else s.yards.classificationYard.push(car);
|
||||
if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car));
|
||||
else s.yards.classificationYard.push(pooled(car));
|
||||
}
|
||||
s.trays.delete(id);
|
||||
s.freeTrays.push(id);
|
||||
|
||||
+137
-60
@@ -59,6 +59,7 @@ import {
|
||||
cutTowards,
|
||||
isOperationalRail,
|
||||
playerAtSeat,
|
||||
pooled,
|
||||
railFacingOf,
|
||||
seatOf,
|
||||
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. */
|
||||
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);
|
||||
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) 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
|
||||
// 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),
|
||||
);
|
||||
return passengerWork(s, player, 'board', trayId) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
* 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);
|
||||
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
|
||||
if (f.inboundBox.length >= f.capacity.inbound) return false;
|
||||
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return false;
|
||||
return trainAtOfficeWith(
|
||||
s, player,
|
||||
(c) => c.type === 'coach' && c.loaded,
|
||||
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
|
||||
);
|
||||
return passengerWork(s, player, 'detrain', trayId) !== null;
|
||||
}
|
||||
|
||||
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,
|
||||
player: PlayerIndex,
|
||||
pred: (c: RollingStock) => boolean,
|
||||
trayOk: (t: CrewTray) => boolean = () => true,
|
||||
): boolean {
|
||||
dir: 'board' | 'detrain',
|
||||
trayId?: TrayId,
|
||||
): { trayId: TrayId; coachIndex: number } | null {
|
||||
const area = areaOf(s, player);
|
||||
return area.adOccupancy.some((id) => {
|
||||
const t = s.trays.get(id);
|
||||
return !!t && trayOk(t) && t.consist.some(pred);
|
||||
});
|
||||
const seat = seatOf(s, player);
|
||||
const wanted = (c: RollingStock): boolean =>
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -570,12 +590,30 @@ function passengerRefusal(
|
||||
player: PlayerIndex,
|
||||
at: GridCoord,
|
||||
dir: 'board' | 'detrain',
|
||||
trayId?: TrayId,
|
||||
): RejectionCode {
|
||||
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(refusesPassengers)) return 'NO_PASSENGER_WORK';
|
||||
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.
|
||||
@@ -982,7 +1020,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!f) return 'NO_SUCH_FACILITY';
|
||||
if (f.porters < 1) return 'NO_PORTERS_HERE';
|
||||
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': {
|
||||
@@ -991,7 +1030,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!f) return 'NO_SUCH_FACILITY';
|
||||
if (f.porters < 1) return 'NO_PORTERS_HERE';
|
||||
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': {
|
||||
@@ -1027,6 +1067,19 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
|
||||
const car = f.industryTrack.cars[i.carIndex];
|
||||
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
|
||||
* DIVISION YARD. The first Laborer replaces the load with an empty car of that type.*"
|
||||
@@ -1618,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
|
||||
* 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 [
|
||||
{ type: 'passengersBoarded', player, at: i.at },
|
||||
{ type: 'passengersBoarded', player, at: i.at, ...work },
|
||||
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'boarding'),
|
||||
];
|
||||
}
|
||||
|
||||
case 'porter.detrain':
|
||||
case 'porter.detrain': {
|
||||
const work = passengerWork(s, player, 'detrain', i.trayId)!;
|
||||
return [
|
||||
{ type: 'passengersDetrained', player, at: i.at },
|
||||
{ type: 'passengersDetrained', player, at: i.at, ...work },
|
||||
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'detraining'),
|
||||
];
|
||||
}
|
||||
|
||||
case 'laborer.startLoad': {
|
||||
const f = facilityAt(s, player, i.at)!;
|
||||
@@ -1957,9 +2016,14 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
const card = area.grid.get(coordKey(e.to));
|
||||
if (tray && card) {
|
||||
tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length);
|
||||
const track = card.facility?.industryTrack;
|
||||
if (track) track.cars.push(...e.stock);
|
||||
else card.standing.push(...e.stock);
|
||||
// `carsOn` is the one function that knows WHERE cars stand on a given card — an industry
|
||||
// track for a freight facility, the card itself for everything else. Written out longhand
|
||||
// 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;
|
||||
spendCard(s, e.player, e.cardId);
|
||||
@@ -2050,7 +2114,8 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded);
|
||||
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;
|
||||
break;
|
||||
}
|
||||
@@ -2065,7 +2130,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
const idx = box.findIndex((c) => c.type === e.stock.type);
|
||||
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;
|
||||
break;
|
||||
}
|
||||
@@ -2133,19 +2198,28 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
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': {
|
||||
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 loaded = f.outboundBox.splice(idx, 1)[0]!;
|
||||
for (const id of area.adOccupancy) {
|
||||
const tray = s.trays.get(id);
|
||||
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && !c.loaded) ?? -1;
|
||||
if (tray && ci >= 0) {
|
||||
s.yards.classificationYard.push(tray.consist[ci]!);
|
||||
tray.consist[ci] = loaded;
|
||||
break;
|
||||
}
|
||||
const tray = s.trays.get(e.trayId);
|
||||
if (tray && tray.consist[e.coachIndex]) {
|
||||
s.yards.classificationYard.push(pooled(tray.consist[e.coachIndex]!));
|
||||
/**
|
||||
* Stamped with the district that filled it — the chip turned upside down in the tray. These
|
||||
* passengers may not alight anywhere in this Office Area; the train has to carry them to a
|
||||
* different one. See `RollingStock.origin` in state.ts.
|
||||
*/
|
||||
tray.consist[e.coachIndex] = { ...loaded, origin: seatOf(s, e.player) };
|
||||
}
|
||||
f.usedThisStage.porters += 1;
|
||||
break;
|
||||
@@ -2153,21 +2227,18 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
|
||||
case 'passengersDetrained': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
const area = areaOf(s, e.player);
|
||||
for (const id of area.adOccupancy) {
|
||||
const tray = s.trays.get(id);
|
||||
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && c.loaded) ?? -1;
|
||||
if (tray && ci >= 0) {
|
||||
// The empty coach comes OUT OF THE DIVISION YARD, as §9.2 says. It used to be conjured,
|
||||
// which minted a coach on every de-training. Throws now, for the reason in `unloadBegan`.
|
||||
const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded);
|
||||
if (yi < 0) throw new Error('passengersDetrained: no empty coach in the Division Yard');
|
||||
const empty = s.yards.divisionYard.splice(yi, 1)[0]!;
|
||||
refillDivisionYardIfEmpty(s);
|
||||
f.inboundBox.push(tray.consist[ci]!);
|
||||
tray.consist[ci] = empty;
|
||||
break;
|
||||
}
|
||||
const tray = s.trays.get(e.trayId);
|
||||
if (tray && tray.consist[e.coachIndex]) {
|
||||
// The empty coach comes OUT OF THE DIVISION YARD, as §9.2 says. It used to be conjured,
|
||||
// which minted a coach on every de-training. Throws now, for the reason in `unloadBegan`.
|
||||
const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded);
|
||||
if (yi < 0) throw new Error('passengersDetrained: no empty coach in the Division Yard');
|
||||
const empty = s.yards.divisionYard.splice(yi, 1)[0]!;
|
||||
refillDivisionYardIfEmpty(s);
|
||||
// The arriving coach goes into the red box carrying nothing: the journey it was stamped for
|
||||
// is over, and the box feeds straight back to a yard through the Freight Agent.
|
||||
f.inboundBox.push(pooled(tray.consist[e.coachIndex]!));
|
||||
tray.consist[e.coachIndex] = empty;
|
||||
}
|
||||
f.usedThisStage.porters += 1;
|
||||
break;
|
||||
@@ -2203,8 +2274,14 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
workTrack(f)[workTrack(f).length - 1] = null;
|
||||
const ci = f.industryTrack.cars.findIndex((c) => !c.loaded && c.type === e.carType);
|
||||
if (ci >= 0) {
|
||||
s.yards.classificationYard.push(f.industryTrack.cars[ci]!);
|
||||
f.industryTrack.cars[ci] = { type: e.carType, loaded: true };
|
||||
s.yards.classificationYard.push(pooled(f.industryTrack.cars[ci]!));
|
||||
/**
|
||||
* 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;
|
||||
break;
|
||||
|
||||
+32
-23
@@ -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: '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
|
||||
* 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." §9.3's "Passenger Facilities and Freight Houses
|
||||
* permit cars to move each direction" therefore names exactly these two, and the engine had both
|
||||
* of them one-way.
|
||||
* It was briefly `flow: 'both'`, on the reading that "'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", which made §9.3's "Passenger Facilities and Freight Houses permit cars to move
|
||||
* each direction" name exactly those two. That 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 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
|
||||
* cannot use, so every +1 inbound beside a Refinery went nowhere.
|
||||
*
|
||||
* The base numbers stay at the engine's own scale (1 per direction it allows) rather than the card
|
||||
* 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.
|
||||
* The card set says the same thing on its own. All three Refinery modifiers — Pipelines, Oil
|
||||
* 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.
|
||||
* `StationMaster-Home-Deck-v0.4.5.md` prints it "Outbound, 1 out / 0 in".
|
||||
*/
|
||||
{ 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: '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
|
||||
* not: the Truck Dock printed +1 outbound at the time and this was `flow: 'inbound'`, so the grant
|
||||
* was dropped on a direction the facility did not have. The same trap still swallows an Ice House
|
||||
* set beside a Grocer's that has been left one-way.
|
||||
*
|
||||
* `TODO.md` had previously recorded this as "checked, and there is no bug" on the reasoning that a
|
||||
* Grocer's is inbound-only. That premise was the bug.
|
||||
* THE ICE HOUSE IS THEREFORE A DEAD CARD BESIDE A GROCER'S, and that is the design, not an
|
||||
* oversight: `usableGrant` drops a Modifier's grant on a direction its host cannot use, and the
|
||||
* Home Deck sheet says so outright — "a bonus beside a facility that cannot use its direction is
|
||||
* 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
|
||||
* that do work here.
|
||||
*/
|
||||
{ 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. */
|
||||
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 {
|
||||
return p.flow === 'both';
|
||||
}
|
||||
|
||||
+10
-2
@@ -161,8 +161,16 @@ export type GameEvent =
|
||||
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
|
||||
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
|
||||
// -- 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: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number }
|
||||
| { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||
|
||||
+23
-2
@@ -101,8 +101,18 @@ export type Intent =
|
||||
| { type: 'maneuver.flyingSwitch'; cardId: CardId; trayId: TrayId; count: number; to: GridCoord }
|
||||
| { type: 'redFlag.play' }
|
||||
// -- 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. */
|
||||
| { type: 'laborer.startLoad'; at: GridCoord }
|
||||
| { type: 'laborer.advanceLoad'; at: GridCoord; box: number }
|
||||
@@ -199,6 +209,17 @@ export type RejectionCode =
|
||||
* the Laborers can move it out of the box.
|
||||
*/
|
||||
| '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_PASSENGERS_WAITING'
|
||||
| 'NO_EMPTY_COACH'
|
||||
|
||||
+14
-2
@@ -288,9 +288,21 @@ function loadUnloadCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const out: Intent[] = [];
|
||||
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)) {
|
||||
out.push({ type: 'porter.board', at: coord });
|
||||
out.push({ type: 'porter.detrain', at: coord });
|
||||
for (const trayId of traysHere) {
|
||||
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;
|
||||
if (f) {
|
||||
out.push({ type: 'laborer.startLoad', at: coord });
|
||||
|
||||
+41
-1
@@ -56,7 +56,47 @@ export function coordKey(c: GridCoord): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** §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
|
||||
|
||||
+12
-3
@@ -17,7 +17,7 @@
|
||||
|
||||
import { MAX_CONSIST } from '../engine/content.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 type { GameEvent } from '../engine/events.ts';
|
||||
|
||||
@@ -34,11 +34,20 @@ export function clockTime(stage: number): string {
|
||||
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
|
||||
// though the supply marks every caboose loaded. Name it plainly.
|
||||
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 {
|
||||
|
||||
+22
-6
@@ -508,6 +508,7 @@ const FACILITY_NAMES: Record<string, string> = {
|
||||
function facilityView(
|
||||
card: { geometry: { kind: string; facility?: string }; facility: unknown; modifiers?: string[] },
|
||||
officeName: string,
|
||||
viewerSeat: SeatIndex,
|
||||
): FacilityView | null {
|
||||
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
|
||||
@@ -529,7 +530,9 @@ function facilityView(
|
||||
maw: (f.menAtWork ?? []).map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
|
||||
red: f.inboundBox.map(carLabel),
|
||||
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}`,
|
||||
porters: `${portersLeft(f)}/${f.porters}`,
|
||||
canFinish: canFinishHere(f),
|
||||
@@ -620,7 +623,9 @@ function trainsOnCard(s: GameState, viewerSeat: SeatIndex, key: string): CellVie
|
||||
out.push({
|
||||
trayId: id,
|
||||
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)),
|
||||
facing: railFacingOf(t),
|
||||
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
|
||||
@@ -676,6 +681,11 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
|
||||
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. */
|
||||
export function describeIntent(s: GameState, i: Intent): string {
|
||||
// X,Y — east/west then north/south, not the internal row/col storage order.
|
||||
@@ -855,10 +865,16 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
return `advance load in box ${i.box} at ${at(i.at)}`;
|
||||
case 'laborer.beginUnload':
|
||||
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':
|
||||
return `board passengers at ${at(i.at)}`;
|
||||
return `board passengers at ${at(i.at)}${onto(s, i.trayId, ' onto ')}`;
|
||||
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': {
|
||||
const runs = i.trainNumber % 2 === 0 ? 'east' : 'west';
|
||||
if (i.atSeat === null) {
|
||||
@@ -1007,7 +1023,7 @@ export function snapshot(
|
||||
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
|
||||
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);
|
||||
|
||||
cells.push({
|
||||
@@ -1022,7 +1038,7 @@ export function snapshot(
|
||||
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
|
||||
trains: trainsOnCard(s, viewerSeat, key),
|
||||
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,
|
||||
facility: fv,
|
||||
});
|
||||
|
||||
+270
-3
@@ -1284,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
|
||||
* 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
|
||||
* suppresses anything, because the Grocer's is a both-direction facility — which was the other
|
||||
* half of the same report.
|
||||
* This used to be written against an Ice House on a Grocer's Warehouse, which suppresses again
|
||||
* now that the Grocer's is inbound-only (v0.4.9e). The Office was chosen instead because the
|
||||
* 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 area = areaOf(s, 0);
|
||||
@@ -1895,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));
|
||||
});
|
||||
});
|
||||
|
||||
+2
-2
@@ -52,8 +52,8 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'carPassed', player: 0, trayId: 't0' },
|
||||
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
|
||||
{ type: 'clearanceGiven', trainId: 't1', allow: false },
|
||||
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 } },
|
||||
{ type: 'passengersDetrained', 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 }, trayId: 't0', coachIndex: 0 },
|
||||
{ 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: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
||||
|
||||
+19
-12
@@ -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
|
||||
* facility that loads *and* unloads — the Grocer's Warehouse and the Oil Refinery." The table
|
||||
* agrees: both are "Both", and only the Power Plant is inbound-only.
|
||||
* §9.3 — "Passenger Facilities and Freight Houses permit cars to move each direction". ONE card
|
||||
* answers to that.
|
||||
*
|
||||
* The engine had the Refinery as outbound-only and the Grocer's as inbound-only, so §9.3's
|
||||
* "Passenger Facilities and Freight Houses permit cars to move each direction" named neither of
|
||||
* them — and every Modifier grant on the missing direction was silently dropped, which is how
|
||||
* "grocer's warehouse didn't get extra outbound slot for truck dock" was reported.
|
||||
* This briefly asserted three. `card-reference.md` reads "'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 on that premise the Refinery and the Grocer's were both made
|
||||
* `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
|
||||
* it is not. That is a deck-composition question, recorded in TODO.md, not something to quietly
|
||||
* delete six cards over.
|
||||
* Reported from playtesting v0.4.9d and confirmed by Jesse: the Refinery only ships tanks out,
|
||||
* the Grocer's Warehouse only receives. `StationMaster-Home-Deck-v0.4.5.md` prints both that way,
|
||||
* and so does the modifier set — all three Refinery modifiers grant outbound.
|
||||
*/
|
||||
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', () => {
|
||||
|
||||
+10
-7
@@ -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:
|
||||
* 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
|
||||
* eastbound from the West Division Point before); and the Grocer's Warehouse ships as well as
|
||||
* receives, so there is more switching worth doing.
|
||||
* eastbound from the West Division Point before); and the Grocer's Warehouse briefly shipped as
|
||||
* 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.
|
||||
* 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,
|
||||
// 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 —
|
||||
* where the engine had it inbound-only. So the bot can ship from it as well as receive, and it
|
||||
* often does: deals producing at least one completed unload went from 12 in 40 to 6 in 40, while
|
||||
* unloads themselves are unharmed (30 completed across the 40 measured after the change).
|
||||
* The Grocer's Warehouse was briefly a both-direction facility, so the bot shipped from it as
|
||||
* well as receiving and deals producing at least one completed unload fell from 12 in 40 to 6 in
|
||||
* 40. v0.4.9e put it back to inbound-only, which is what the sheet always printed. The wider
|
||||
* 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
|
||||
* unloads started — and `unloads > 0` is only the precondition that makes the comparison mean
|
||||
|
||||
@@ -3193,6 +3193,105 @@ describe('two crews switching are told apart', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Superintendent ruling names the train it is ruling on', () => {
|
||||
/**
|
||||
* REPORTED from play: "when the Superintendent has to rule on a train to allow or hold, it should
|
||||
|
||||
Reference in New Issue
Block a user