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
|
## 0.4.9d — 2026-08-21
|
||||||
|
|
||||||
Three bugs from the same playtest session, patched directly onto 0.4.9a rather than the in-progress
|
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
|
it was worth more than the entire freight and passenger economy put together, for traffic nobody
|
||||||
has to work. A seed alone therefore no longer names a game — the settings ride in the URL beside
|
has to work. A seed alone therefore no longer names a game — the settings ride in the URL beside
|
||||||
it, and every save records the rules it was dealt under.
|
it, and every save records the rules it was dealt under.
|
||||||
|
- **A load may not be broken in the district that made it.** Freight or passengers loaded anywhere in
|
||||||
|
an Office Area cannot be unloaded anywhere in that same Office Area — not at another facility, not
|
||||||
|
in a later Stage. A train has to carry them to a different district first. The printed game turns
|
||||||
|
the chip upside down in the tray; here the load carries the seat that made it (`RollingStock.origin`
|
||||||
|
in `src/engine/state.ts`) and it never expires. Without it a Freight House could unload the boxcar
|
||||||
|
its own Laborers had just loaded and a platform could detrain the passengers it had just boarded,
|
||||||
|
each paying Revenue at both ends for a load that went nowhere: worth **0.60 ± 0.10 Revenue a game**
|
||||||
|
to the developer bot over 400 paired deals, on 78 of them.
|
||||||
- **A turnout can be laid on top of a card already down.** It upgrades a straight at any rotation, or
|
- **A turnout can be laid on top of a card already down.** It upgrades a straight at any rotation, or
|
||||||
a curve whose arc matches its own diverging leg — both strict port supersets of what they replace,
|
a curve whose arc matches its own diverging leg — both strict port supersets of what they replace,
|
||||||
so an upgrade can never sever an existing join. Without it a district could only hang off track that
|
so an upgrade can never sever an existing join. Without it a district could only hang off track that
|
||||||
|
|||||||
@@ -396,6 +396,41 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
|
|||||||
|
|
||||||
Blocked on a decision, not on work.
|
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.**
|
- [ ] **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
|
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
|
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 |
|
| Mine Tipple | Hopper | Outbound only | 3 | 3 | — | 4 | 2 |
|
||||||
| Produce Shed | Reefer | Outbound only | 2 | 2 | — | 3 | 2 |
|
| Produce Shed | Reefer | Outbound only | 2 | 2 | — | 3 | 2 |
|
||||||
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 | 2 |
|
| Grocer's Warehouse | Boxcar | Inbound only | 2 | — | 2 | 3 | 2 |
|
||||||
| Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4 | 2 |
|
| Oil Refinery | Tank car | Outbound only | 3 | 2 | — | 4 | 2 |
|
||||||
| Power Plant | Hopper | Inbound only | 3 | — | 3 | 4 | 2 |
|
| Power Plant | Hopper | Inbound only | 3 | — | 3 | 4 | 2 |
|
||||||
|
|
||||||
Directions follow the commodity: coal originates at a Mine Tipple and is consumed at a Power Plant;
|
Directions follow the commodity: coal originates at a Mine Tipple and is consumed at a Power Plant;
|
||||||
produce ships out; a warehouse and a refinery do both. This gives §9 all three of its cases —
|
produce ships out; a warehouse receives. This gives §9 all three of its cases — outbound-only,
|
||||||
outbound-only, inbound-only, and both.
|
inbound-only, and both — with the **Freight House the one card that does both**.
|
||||||
|
|
||||||
**"Freight House"** (§9.3, Appendix A) is not a card. It is the collective term for a freight facility
|
**The Grocer's Warehouse and the Oil Refinery are ONE-WAY** (corrected v0.4.9e, from gameplay
|
||||||
that loads *and* unloads — the Grocer's Warehouse and the Oil Refinery. §9.3's "Passenger Facilities
|
testing): "Grocer's Warehouse should be receive only, does not ship anything out"; "Refinery: only
|
||||||
and Freight Houses permit cars to move each direction" therefore names exactly those two.
|
ships out tanks, does not receive anything" (Jesse). The Direction column read "Both" for both, on the
|
||||||
|
reading below that "Freight House" named exactly those two.
|
||||||
|
|
||||||
|
**And that reading is wrong.** §9.3/Appendix A's "Passenger Facilities and Freight Houses permit cars
|
||||||
|
to move each direction" names a CARD: the engine deals a `freightHouse` industry, 6 copies, one
|
||||||
|
Laborer and one slot each direction (`content.ts`), and has since before v0.4.9. This paragraph used
|
||||||
|
to read "'Freight House' is not a card — it is the collective term for a freight facility that loads
|
||||||
|
*and* unloads, the Grocer's Warehouse and the Oil Refinery", and that premise is what made those two
|
||||||
|
two-way. `StationMaster-Home-Deck-v0.4.5.md` prints them one-way, and the modifier set agrees: all
|
||||||
|
three Refinery modifiers (Pipelines, Oil Depot, Viscosity Breakers) grant **+1 outbound**, which would
|
||||||
|
be an odd card set for a facility that receives half the time.
|
||||||
|
|
||||||
|
<!-- The Laborer and capacity numbers in the rows above were NOT re-verified against the engine in
|
||||||
|
this pass — only the Direction column was, which is what playtesting reported on. `content.ts`
|
||||||
|
gives every industry base 1 out / 1 in / 1 Laborer, so the table's 2s and 3s and the "Throughput"
|
||||||
|
reasoning built on them are likely stale. Flagged rather than silently rewritten: Laborer counts
|
||||||
|
are a balance question, not only a docs one. -->
|
||||||
|
|
||||||
### Throughput — why these Laborer counts
|
### Throughput — why these Laborer counts
|
||||||
|
|
||||||
@@ -80,9 +96,10 @@ deliberately slower industries, unable to quite keep up with a dedicated player.
|
|||||||
That is why Laborer counts track Outbound capacity: Mine Tipple 3/3, Power Plant 3/3, Produce Shed
|
That is why Laborer counts track Outbound capacity: Mine Tipple 3/3, Power Plant 3/3, Produce Shed
|
||||||
2/2, Grocer's Warehouse 2/2. The numbers are derived from the action budget, not chosen freely.
|
2/2, Grocer's Warehouse 2/2. The numbers are derived from the action budget, not chosen freely.
|
||||||
|
|
||||||
The Oil Refinery is the exception at 3 Laborers against 2+2 capacity, and deliberately so: it serves
|
<!-- The paragraph that stood here explained the Oil Refinery's third Laborer as the price of serving
|
||||||
two flows through one three-box pipeline, so its pipeline stays fuller than a one-way facility's and
|
two flows through one pipeline. It serves one flow (v0.4.9e), so the explanation is gone with the
|
||||||
the third Laborer is earning its keep.
|
premise; whether the Laborer count is still right is part of the same unverified block flagged
|
||||||
|
above and in TODO.md. -->
|
||||||
|
|
||||||
Even so, Laborers are rarely what limits a player — spotting the empty car and hauling the loaded one
|
Even so, Laborers are rarely what limits a player — spotting the empty car and hauling the loaded one
|
||||||
away both cost switching actions from the same budget. See §7.
|
away both cost switching actions from the same budget. See §7.
|
||||||
@@ -258,12 +275,12 @@ with all boxes full:
|
|||||||
| Car | Facility demand | Supply | Headroom |
|
| Car | Facility demand | Supply | Headroom |
|
||||||
| --- | ---: | ---: | --- |
|
| --- | ---: | ---: | --- |
|
||||||
| Hopper | Mine Tipple 3×2 + Power Plant 3×2 = 12 | 12 | exactly met |
|
| Hopper | Mine Tipple 3×2 + Power Plant 3×2 = 12 | 12 | exactly met |
|
||||||
| Tank | Oil Refinery (2+2)×2 = 8 | 8 | exactly met |
|
| Tank | Oil Refinery 2×2 = 4 | 8 | 4 spare (was "exactly met" while the Refinery was two-way) |
|
||||||
| Boxcar | Grocer's (2+2)×2 = 8 | 12 | 4 spare |
|
| Boxcar | Grocer's 2×2 = 4 | 12 | 8 spare (same correction) |
|
||||||
| Reefer | Produce Shed 2×2 = 4 | 8 | 4 spare |
|
| Reefer | Produce Shed 2×2 = 4 | 8 | 4 spare |
|
||||||
| Coach | Terminal 4+4, per Office | 16 | scales with Office count |
|
| Coach | Terminal 4+4, per Office | 16 | scales with Office count |
|
||||||
|
|
||||||
Hoppers and tank cars are exactly met in the theoretical worst case, which cannot occur in practice —
|
Hoppers are exactly met in the theoretical worst case, which cannot occur in practice —
|
||||||
only 10 freight facility cards exist across a 52-card deck shared by all players, and the §2.2
|
only 10 freight facility cards exist across a 52-card deck shared by all players, and the §2.2
|
||||||
Classification Yard recycle returns stock to the Division Yard whenever it empties. Both are worth
|
Classification Yard recycle returns stock to the Division Yard whenever it empties. Both are worth
|
||||||
watching in playtesting.
|
watching in playtesting.
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ Every defined term, alphabetized for lookup. The core comes from the Definitions
|
|||||||
| **Extra Platform** | Modifier card: +1 green and +1 red slot at a Passenger Facility. | §12.5 |
|
| **Extra Platform** | Modifier card: +1 green and +1 red slot at a Passenger Facility. | §12.5 |
|
||||||
| **Extra Train** | A one-and-done train; its card returns to the Salvage Yard on completion. Head-on card image, so the drawing player picks its direction. Numbered with an "X" prefix; the following number gives its seniority, and it yields to the Timetabled train of that number. | §2.3, §8 |
|
| **Extra Train** | A one-and-done train; its card returns to the Salvage Yard on completion. Head-on card image, so the drawing player picks its direction. Numbered with an "X" prefix; the following number gives its seniority, and it yields to the Timetabled train of that number. | §2.3, §8 |
|
||||||
| **Facility** | A business which loads/unloads cargo and freight. | §2.5 |
|
| **Facility** | A business which loads/unloads cargo and freight. | §2.5 |
|
||||||
| **Freight Facility** | Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power Plants. Some allow only outbound, some only inbound, some both. Per-card values in §12.5. | §9, §12.5 |
|
| **Freight Facility** | Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power Plants, Freight Houses. Some allow only outbound, some only inbound, some both. Per-card values in §12.5. | §9, §12.5 |
|
||||||
| **Freight House** | Not a card — the collective term for a freight facility permitting both directions, namely the Grocer's Warehouse and the Oil Refinery. | §9.3, §12.5 |
|
| **Freight House** | A sixth Freight Facility card, dealt like any other industry (`content.ts`) — this entry used to read "not a card, the collective term for the Grocer's Warehouse and the Oil Refinery", which the engine never agreed with. **The only industry that permits both directions**: the Grocer's Warehouse receives and the Oil Refinery ships, one way each (v0.4.9e). | §9.3, §12.5 |
|
||||||
| **Highball** | When a train holding at an Office automatically departs. | §2.4 |
|
| **Highball** | When a train holding at an Office automatically departs. | §2.4 |
|
||||||
| **Home Office** | The primary face-down deck cards are drawn from. 52 cards. | §2.6, §12.1 |
|
| **Home Office** | The primary face-down deck cards are drawn from. 52 cards. | §2.6, §12.1 |
|
||||||
| **Hopper** | Coal rolling stock (brown = loaded, white = empty). | §2.2 |
|
| **Hopper** | Coal rolling stock (brown = loaded, white = empty). | §2.2 |
|
||||||
|
|||||||
@@ -670,10 +670,15 @@ balance work was possible.
|
|||||||
| --- | --- | --- | ---: | ---: | ---: | ---: |
|
| --- | --- | --- | ---: | ---: | ---: | ---: |
|
||||||
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
|
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
|
||||||
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
|
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
|
||||||
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 |
|
| Grocer's Warehouse | Boxcar | Inbound | 2 | — | 2 | 3 |
|
||||||
| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 |
|
| Oil Refinery | Tank | Outbound | 3 | 2 | — | 4 |
|
||||||
| Power Plant | Hopper | Inbound | 3 | — | 3 | 4 |
|
| Power Plant | Hopper | Inbound | 3 | — | 3 | 4 |
|
||||||
|
|
||||||
|
*Amended v0.4.9e.* The warehouse and the refinery were briefly "Both", on the reading that "Freight
|
||||||
|
House" was a collective term for exactly those two and therefore what §9.3's "permit cars to move
|
||||||
|
each direction" described. The Freight House turned out to be a card of its own (v0.5.0), and
|
||||||
|
playtesting confirmed the one-way reading the sheet always printed.
|
||||||
|
|
||||||
*Rationale.* Directions follow the commodity and give §9 all three of its stated cases. Bulk
|
*Rationale.* Directions follow the commodity and give §9 all three of its stated cases. Bulk
|
||||||
industries get more Laborers and a 4-car track so the types feel distinct when choosing what to
|
industries get more Laborers and a 4-car track so the types feel distinct when choosing what to
|
||||||
build — but the counts stay moderate because Laborers are **not** the binding constraint (see 10e),
|
build — but the counts stay moderate because Laborers are **not** the binding constraint (see 10e),
|
||||||
|
|||||||
@@ -735,8 +735,8 @@ Modifier effects, and track geometries — is catalogued in
|
|||||||
| --- | --- | --- | ---: | ---: | ---: | ---: |
|
| --- | --- | --- | ---: | ---: | ---: | ---: |
|
||||||
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
|
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
|
||||||
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
|
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
|
||||||
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 |
|
| Grocer's Warehouse | Boxcar | Inbound | 2 | — | 2 | 3 |
|
||||||
| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 |
|
| Oil Refinery | Tank | Outbound | 3 | 2 | — | 4 |
|
||||||
| Power Plant | Hopper | Inbound | 3 | — | 3 | 4 |
|
| Power Plant | Hopper | Inbound | 3 | — | 3 | 4 |
|
||||||
|
|
||||||
| Office | Porters | Green slots | Red slots |
|
| Office | Porters | Green slots | Red slots |
|
||||||
@@ -753,8 +753,11 @@ Modifier effects, and track geometries — is catalogued in
|
|||||||
| Extra Platform | +1 green and +1 red slot (passenger) |
|
| Extra Platform | +1 green and +1 red slot (passenger) |
|
||||||
| Section Gang | +1 Laborer or +1 Porter |
|
| Section Gang | +1 Laborer or +1 Porter |
|
||||||
|
|
||||||
**"Freight House"** (§9.3, Appendix A) is not a card — it is the collective term for a freight
|
**"Freight House"** (§9.3, Appendix A) is a Freight Facility card, dealt like any other industry —
|
||||||
facility that both loads and unloads, namely the Grocer's Warehouse and the Oil Refinery.
|
this used to read "not a card, only the collective term for a facility that both loads and unloads",
|
||||||
|
which the engine never agreed with. It is the **only** industry that permits both directions: the
|
||||||
|
Grocer's Warehouse receives and the Oil Refinery ships, one way each (corrected v0.4.9e from gameplay
|
||||||
|
testing — the "Both" reading was the other half of the same mistake about what "Freight House" meant).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.4.9d",
|
"version": "0.4.9e",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
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,
|
"seed": 927523,
|
||||||
"title": "12 Revenue · seed 927523",
|
"title": "11 Revenue · seed 927523",
|
||||||
"note": "12 Revenue over 5 Days · 2 train(s) on the timetable · 20 cards down · no collisions · played by developer",
|
"note": "11 Revenue over 5 Days · 2 train(s) on the timetable · 21 cards down · no collisions · played by developer",
|
||||||
"rules": {
|
"rules": {
|
||||||
"startingHand": "threeRandom",
|
"startingHand": "threeRandom",
|
||||||
"revenue": {
|
"revenue": {
|
||||||
@@ -320,21 +320,24 @@
|
|||||||
"at": {
|
"at": {
|
||||||
"row": 0,
|
"row": 0,
|
||||||
"col": 0
|
"col": 0
|
||||||
}
|
},
|
||||||
|
"trayId": "tray3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "porter.board",
|
"type": "porter.board",
|
||||||
"at": {
|
"at": {
|
||||||
"row": 0,
|
"row": 0,
|
||||||
"col": 0
|
"col": 0
|
||||||
}
|
},
|
||||||
|
"trayId": "tray3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "porter.detrain",
|
"type": "porter.detrain",
|
||||||
"at": {
|
"at": {
|
||||||
"row": 0,
|
"row": 0,
|
||||||
"col": 0
|
"col": 0
|
||||||
}
|
},
|
||||||
|
"trayId": "tray3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "loadUnload.end"
|
"type": "loadUnload.end"
|
||||||
@@ -589,21 +592,24 @@
|
|||||||
"at": {
|
"at": {
|
||||||
"row": 0,
|
"row": 0,
|
||||||
"col": 0
|
"col": 0
|
||||||
}
|
},
|
||||||
|
"trayId": "tray2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "porter.board",
|
"type": "porter.board",
|
||||||
"at": {
|
"at": {
|
||||||
"row": 0,
|
"row": 0,
|
||||||
"col": 0
|
"col": 0
|
||||||
}
|
},
|
||||||
|
"trayId": "tray2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "porter.detrain",
|
"type": "porter.detrain",
|
||||||
"at": {
|
"at": {
|
||||||
"row": 0,
|
"row": 0,
|
||||||
"col": 0
|
"col": 0
|
||||||
}
|
},
|
||||||
|
"trayId": "tray2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "loadUnload.end"
|
"type": "loadUnload.end"
|
||||||
@@ -951,36 +957,38 @@
|
|||||||
"at": {
|
"at": {
|
||||||
"row": 0,
|
"row": 0,
|
||||||
"col": 0
|
"col": 0
|
||||||
}
|
},
|
||||||
|
"trayId": "tray3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "porter.board",
|
"type": "porter.board",
|
||||||
"at": {
|
"at": {
|
||||||
"row": 0,
|
"row": 0,
|
||||||
"col": 0
|
"col": 0
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
"trayId": "tray3"
|
||||||
"type": "porter.detrain",
|
|
||||||
"at": {
|
|
||||||
"row": 0,
|
|
||||||
"col": 0
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "loadUnload.end"
|
"type": "loadUnload.end"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "localOps.choose",
|
"type": "localOps.choose",
|
||||||
"option": "freightAgent"
|
"option": "draw"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "freightAgent.clearInbound",
|
"type": "draw.fromHomeOffice"
|
||||||
"at": {
|
|
||||||
"row": 0,
|
|
||||||
"col": 0
|
|
||||||
},
|
},
|
||||||
"index": 0
|
{
|
||||||
|
"type": "card.play",
|
||||||
|
"cardId": "c162",
|
||||||
|
"placement": {
|
||||||
|
"row": -2,
|
||||||
|
"col": 2
|
||||||
|
},
|
||||||
|
"variant": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "draw.end"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "loadUnload.end"
|
"type": "loadUnload.end"
|
||||||
@@ -1179,6 +1187,12 @@
|
|||||||
"carType": "coach",
|
"carType": "coach",
|
||||||
"loaded": false
|
"loaded": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "newTrain.placeCar",
|
||||||
|
"trayId": "tray3",
|
||||||
|
"carType": "coach",
|
||||||
|
"loaded": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "laborer.advanceLoad",
|
"type": "laborer.advanceLoad",
|
||||||
"at": {
|
"at": {
|
||||||
@@ -1236,7 +1250,58 @@
|
|||||||
"option": "switch"
|
"option": "switch"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "switch.end"
|
"type": "switch.move",
|
||||||
|
"trayId": "tray3",
|
||||||
|
"to": {
|
||||||
|
"row": 0,
|
||||||
|
"col": 1
|
||||||
|
},
|
||||||
|
"reverse": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "switch.move",
|
||||||
|
"trayId": "tray3",
|
||||||
|
"to": {
|
||||||
|
"row": 0,
|
||||||
|
"col": 0
|
||||||
|
},
|
||||||
|
"reverse": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "switch.move",
|
||||||
|
"trayId": "tray3",
|
||||||
|
"to": {
|
||||||
|
"row": 0,
|
||||||
|
"col": 1
|
||||||
|
},
|
||||||
|
"reverse": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "switch.move",
|
||||||
|
"trayId": "tray3",
|
||||||
|
"to": {
|
||||||
|
"row": 0,
|
||||||
|
"col": 0
|
||||||
|
},
|
||||||
|
"reverse": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "switch.move",
|
||||||
|
"trayId": "tray3",
|
||||||
|
"to": {
|
||||||
|
"row": 0,
|
||||||
|
"col": 1
|
||||||
|
},
|
||||||
|
"reverse": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "switch.move",
|
||||||
|
"trayId": "tray3",
|
||||||
|
"to": {
|
||||||
|
"row": 0,
|
||||||
|
"col": 0
|
||||||
|
},
|
||||||
|
"reverse": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "laborer.advanceLoad",
|
"type": "laborer.advanceLoad",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import type { GameEvent } from './events.ts';
|
|||||||
import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts';
|
import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts';
|
||||||
import { legalActions } from './legal.ts';
|
import { legalActions } from './legal.ts';
|
||||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||||
import { coordKey, freshTurns, playerAtSeat, playerLeftOf, subdivisions, totalRevenue, turnOf } from './state.ts';
|
import { coordKey, freshTurns, playerAtSeat, playerLeftOf, pooled, subdivisions, totalRevenue, turnOf } from './state.ts';
|
||||||
|
|
||||||
export type AdvanceResult = {
|
export type AdvanceResult = {
|
||||||
events: GameEvent[];
|
events: GameEvent[];
|
||||||
@@ -943,9 +943,11 @@ function collide(
|
|||||||
consist: [...tray.consist],
|
consist: [...tray.consist],
|
||||||
});
|
});
|
||||||
// Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification.
|
// Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification.
|
||||||
|
// `pooled` because a car reaching a yard is back in the common supply: the load's origin stamp
|
||||||
|
// (state.ts) belongs to the load, not to the car that happened to be carrying it.
|
||||||
for (const car of tray.consist) {
|
for (const car of tray.consist) {
|
||||||
if (car.type === 'caboose') s.yards.divisionYard.push(car);
|
if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car));
|
||||||
else s.yards.classificationYard.push(car);
|
else s.yards.classificationYard.push(pooled(car));
|
||||||
}
|
}
|
||||||
s.trays.delete(id);
|
s.trays.delete(id);
|
||||||
s.freeTrays.push(id);
|
s.freeTrays.push(id);
|
||||||
@@ -1014,9 +1016,10 @@ function retireTrain(
|
|||||||
side: Direction,
|
side: Direction,
|
||||||
events: GameEvent[],
|
events: GameEvent[],
|
||||||
): void {
|
): void {
|
||||||
|
// `pooled` — see `trainsDestroyed` above; a load's origin stamp does not survive the yard.
|
||||||
for (const car of tray.consist) {
|
for (const car of tray.consist) {
|
||||||
if (car.type === 'caboose') s.yards.divisionYard.push(car);
|
if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car));
|
||||||
else s.yards.classificationYard.push(car);
|
else s.yards.classificationYard.push(pooled(car));
|
||||||
}
|
}
|
||||||
s.trays.delete(id);
|
s.trays.delete(id);
|
||||||
s.freeTrays.push(id);
|
s.freeTrays.push(id);
|
||||||
|
|||||||
+131
-54
@@ -59,6 +59,7 @@ import {
|
|||||||
cutTowards,
|
cutTowards,
|
||||||
isOperationalRail,
|
isOperationalRail,
|
||||||
playerAtSeat,
|
playerAtSeat,
|
||||||
|
pooled,
|
||||||
railFacingOf,
|
railFacingOf,
|
||||||
seatOf,
|
seatOf,
|
||||||
spaceOn,
|
spaceOn,
|
||||||
@@ -388,17 +389,11 @@ export function canStartLoad(f: Facility): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** §9.2 — boarding needs a loaded coach in a green slot and a train with an empty coach. */
|
/** §9.2 — boarding needs a loaded coach in a green slot and a train with an empty coach. */
|
||||||
export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
|
export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord, trayId?: TrayId): boolean {
|
||||||
const f = facilityAt(s, player, at);
|
const f = facilityAt(s, player, at);
|
||||||
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
|
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
|
||||||
if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return false;
|
if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return false;
|
||||||
// §7 — a train whose card refuses passenger work, or which is not booked to stop here, is not a
|
return passengerWork(s, player, 'board', trayId) !== null;
|
||||||
// train these passengers can board however many empty coaches it is carrying.
|
|
||||||
return trainAtOfficeWith(
|
|
||||||
s, player,
|
|
||||||
(c) => c.type === 'coach' && !c.loaded,
|
|
||||||
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -413,29 +408,54 @@ export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): bool
|
|||||||
* de-training MINTED a coach: the loaded one went to the red box and a new empty one appeared in the
|
* de-training MINTED a coach: the loaded one went to the red box and a new empty one appeared in the
|
||||||
* train. Measured at 1.29 cars a game created out of nothing across the two inbound paths.
|
* train. Measured at 1.29 cars a game created out of nothing across the two inbound paths.
|
||||||
*/
|
*/
|
||||||
export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
|
export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord, trayId?: TrayId): boolean {
|
||||||
const f = facilityAt(s, player, at);
|
const f = facilityAt(s, player, at);
|
||||||
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
|
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
|
||||||
if (f.inboundBox.length >= f.capacity.inbound) return false;
|
if (f.inboundBox.length >= f.capacity.inbound) return false;
|
||||||
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return false;
|
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return false;
|
||||||
return trainAtOfficeWith(
|
return passengerWork(s, player, 'detrain', trayId) !== null;
|
||||||
s, player,
|
|
||||||
(c) => c.type === 'coach' && c.loaded,
|
|
||||||
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function trainAtOfficeWith(
|
/**
|
||||||
|
* WHICH TRAIN, AND WHICH COACH ON IT — the one answer `check`, `execute` and the reducer all use.
|
||||||
|
*
|
||||||
|
* TWO PLAYTEST BUGS SHARED ONE CAUSE HERE. Reported against v0.4.9d: "operating two trains in a
|
||||||
|
* station, the select button does not work — regardless of which you pick, it is always one train,
|
||||||
|
* not the other". `porter.board` carried no tray at all, so `check` asked whether SOME train at the
|
||||||
|
* Office had an empty coach and the reducer then walked `adOccupancy` and filled the first one it
|
||||||
|
* found. The two were not even asking the same question: `check` skipped a train whose card refuses
|
||||||
|
* passenger work and the reducer did not, so a Military train could be boarded as long as some other
|
||||||
|
* train at the platform was eligible. The intent now names its tray (`intents.ts`) and this is the
|
||||||
|
* one place that resolves it.
|
||||||
|
*
|
||||||
|
* And "passengers just boarded cannot be immediately unloaded": a coach carries the district that
|
||||||
|
* filled it (`RollingStock.origin`), and a homegrown coach is not a coach these passengers may
|
||||||
|
* alight from — they have to be carried to a different Office Area first.
|
||||||
|
*
|
||||||
|
* `trayId` absent means "any eligible train", which is what every intent recorded before this
|
||||||
|
* existed meant, so an old save replays unchanged.
|
||||||
|
*/
|
||||||
|
function passengerWork(
|
||||||
s: GameState,
|
s: GameState,
|
||||||
player: PlayerIndex,
|
player: PlayerIndex,
|
||||||
pred: (c: RollingStock) => boolean,
|
dir: 'board' | 'detrain',
|
||||||
trayOk: (t: CrewTray) => boolean = () => true,
|
trayId?: TrayId,
|
||||||
): boolean {
|
): { trayId: TrayId; coachIndex: number } | null {
|
||||||
const area = areaOf(s, player);
|
const area = areaOf(s, player);
|
||||||
return area.adOccupancy.some((id) => {
|
const seat = seatOf(s, player);
|
||||||
const t = s.trays.get(id);
|
const wanted = (c: RollingStock): boolean =>
|
||||||
return !!t && trayOk(t) && t.consist.some(pred);
|
c.type === 'coach' && (dir === 'board' ? !c.loaded : c.loaded && c.origin !== seat);
|
||||||
});
|
for (const id of area.adOccupancy) {
|
||||||
|
if (trayId !== undefined && id !== trayId) continue;
|
||||||
|
const tray = s.trays.get(id);
|
||||||
|
if (!tray) continue;
|
||||||
|
// §7 — a train whose card refuses passenger work, or which is not booked to stop here, is not a
|
||||||
|
// train these passengers can board however many empty coaches it is carrying.
|
||||||
|
if (refusesPassengers(tray) || refusesThisOffice(s, player, tray)) continue;
|
||||||
|
const coachIndex = tray.consist.findIndex(wanted);
|
||||||
|
if (coachIndex >= 0) return { trayId: id, coachIndex };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -570,12 +590,30 @@ function passengerRefusal(
|
|||||||
player: PlayerIndex,
|
player: PlayerIndex,
|
||||||
at: GridCoord,
|
at: GridCoord,
|
||||||
dir: 'board' | 'detrain',
|
dir: 'board' | 'detrain',
|
||||||
|
trayId?: TrayId,
|
||||||
): RejectionCode {
|
): RejectionCode {
|
||||||
const area = areaOf(s, player);
|
const area = areaOf(s, player);
|
||||||
const trains = area.adOccupancy.map((id) => s.trays.get(id)).filter((t): t is CrewTray => !!t);
|
const trains = area.adOccupancy
|
||||||
|
.filter((id) => trayId === undefined || id === trayId)
|
||||||
|
.map((id) => s.trays.get(id))
|
||||||
|
.filter((t): t is CrewTray => !!t);
|
||||||
if (trains.length > 0 && trains.every((t) => refusesThisOffice(s, player, t))) return 'NOT_A_TERMINAL';
|
if (trains.length > 0 && trains.every((t) => refusesThisOffice(s, player, t))) return 'NOT_A_TERMINAL';
|
||||||
if (trains.length > 0 && trains.every(refusesPassengers)) return 'NO_PASSENGER_WORK';
|
if (trains.length > 0 && trains.every(refusesPassengers)) return 'NO_PASSENGER_WORK';
|
||||||
if (trains.length === 0) return 'NO_TRAIN_AT_OFFICE';
|
if (trains.length === 0) return 'NO_TRAIN_AT_OFFICE';
|
||||||
|
/**
|
||||||
|
* EVERY LOADED COACH ABOARD BOARDED HERE — so the refusal is the district rule, not "no loaded
|
||||||
|
* coach". Told apart because the two read as opposite situations to a player: one is an empty
|
||||||
|
* train, the other is a train full of passengers who have not been anywhere yet.
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
dir === 'detrain' &&
|
||||||
|
trains.some((t) => t.consist.some((c) => c.type === 'coach' && c.loaded)) &&
|
||||||
|
trains.every((t) =>
|
||||||
|
t.consist.every((c) => !(c.type === 'coach' && c.loaded) || c.origin === seatOf(s, player)),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'LOADED_IN_THIS_DISTRICT';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A TRAIN IS STANDING THERE, so say what is actually missing.
|
* A TRAIN IS STANDING THERE, so say what is actually missing.
|
||||||
@@ -982,7 +1020,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
if (!f) return 'NO_SUCH_FACILITY';
|
if (!f) return 'NO_SUCH_FACILITY';
|
||||||
if (f.porters < 1) return 'NO_PORTERS_HERE';
|
if (f.porters < 1) return 'NO_PORTERS_HERE';
|
||||||
if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
|
if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
|
||||||
return canBoard(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'board');
|
if (i.trayId !== undefined && !s.trays.has(i.trayId)) return 'NO_SUCH_TRAY';
|
||||||
|
return canBoard(s, player, i.at, i.trayId) ? null : passengerRefusal(s, player, i.at, 'board', i.trayId);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'porter.detrain': {
|
case 'porter.detrain': {
|
||||||
@@ -991,7 +1030,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
if (!f) return 'NO_SUCH_FACILITY';
|
if (!f) return 'NO_SUCH_FACILITY';
|
||||||
if (f.porters < 1) return 'NO_PORTERS_HERE';
|
if (f.porters < 1) return 'NO_PORTERS_HERE';
|
||||||
if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
|
if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
|
||||||
return canDetrain(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'detrain');
|
if (i.trayId !== undefined && !s.trays.has(i.trayId)) return 'NO_SUCH_TRAY';
|
||||||
|
return canDetrain(s, player, i.at, i.trayId) ? null : passengerRefusal(s, player, i.at, 'detrain', i.trayId);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'laborer.startLoad': {
|
case 'laborer.startLoad': {
|
||||||
@@ -1027,6 +1067,19 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
|
if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
|
||||||
const car = f.industryTrack.cars[i.carIndex];
|
const car = f.industryTrack.cars[i.carIndex];
|
||||||
if (!car || !car.loaded) return 'WRONG_CAR_TYPE';
|
if (!car || !car.loaded) return 'WRONG_CAR_TYPE';
|
||||||
|
/**
|
||||||
|
* A LOAD MAY NOT BE BROKEN IN THE DISTRICT THAT MADE IT (Jesse's ruling, v0.4.9e).
|
||||||
|
*
|
||||||
|
* Reported from playtesting v0.4.9d: "Freight House: boxcars loaded cannot be immediately
|
||||||
|
* unloaded." They could — a Freight House permits both directions, so the car its own Laborers
|
||||||
|
* had just loaded was standing on its own track, loaded, with an empty of that type in the
|
||||||
|
* yard, and every gate below said yes. Full Revenue at both ends for a load that never moved.
|
||||||
|
*
|
||||||
|
* The rule is district-wide and permanent, not "not at this facility" and not "not this
|
||||||
|
* Stage": the stamp says which Office Area made the load, and it never expires. Traffic runs
|
||||||
|
* BETWEEN districts, which is what the lockout pairs in `content.ts` exist to force.
|
||||||
|
*/
|
||||||
|
if (car.origin === seatOf(s, player)) return 'LOADED_IN_THIS_DISTRICT';
|
||||||
/**
|
/**
|
||||||
* §9.3 — "*Requirements: a load on the industry's track AND AN EMPTY CAR OF THAT TYPE IN THE
|
* §9.3 — "*Requirements: a load on the industry's track AND AN EMPTY CAR OF THAT TYPE IN THE
|
||||||
* DIVISION YARD. The first Laborer replaces the load with an empty car of that type.*"
|
* DIVISION YARD. The first Laborer replaces the load with an empty car of that type.*"
|
||||||
@@ -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
|
* `passengerPerCoach` (`content.ts`). Half a passenger movement is half the work, and the rate
|
||||||
* is named per COACH because a Porter handles exactly one coach per action.
|
* is named per COACH because a Porter handles exactly one coach per action.
|
||||||
*/
|
*/
|
||||||
case 'porter.board':
|
case 'porter.board': {
|
||||||
|
// `check` has already established there is one; resolving it HERE, once, is what stops the
|
||||||
|
// reducer from finding a different train than the one the rules were tested against.
|
||||||
|
const work = passengerWork(s, player, 'board', i.trayId)!;
|
||||||
return [
|
return [
|
||||||
{ type: 'passengersBoarded', player, at: i.at },
|
{ type: 'passengersBoarded', player, at: i.at, ...work },
|
||||||
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'boarding'),
|
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'boarding'),
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
case 'porter.detrain':
|
case 'porter.detrain': {
|
||||||
|
const work = passengerWork(s, player, 'detrain', i.trayId)!;
|
||||||
return [
|
return [
|
||||||
{ type: 'passengersDetrained', player, at: i.at },
|
{ type: 'passengersDetrained', player, at: i.at, ...work },
|
||||||
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'detraining'),
|
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'detraining'),
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
case 'laborer.startLoad': {
|
case 'laborer.startLoad': {
|
||||||
const f = facilityAt(s, player, i.at)!;
|
const f = facilityAt(s, player, i.at)!;
|
||||||
@@ -1957,9 +2016,14 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
const card = area.grid.get(coordKey(e.to));
|
const card = area.grid.get(coordKey(e.to));
|
||||||
if (tray && card) {
|
if (tray && card) {
|
||||||
tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length);
|
tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length);
|
||||||
const track = card.facility?.industryTrack;
|
// `carsOn` is the one function that knows WHERE cars stand on a given card — an industry
|
||||||
if (track) track.cars.push(...e.stock);
|
// track for a freight facility, the card itself for everything else. Written out longhand
|
||||||
else card.standing.push(...e.stock);
|
// here it was a second copy of that rule, and the copy was wrong for a Passenger Facility:
|
||||||
|
// it has an `industryTrack` too (an empty one, `setup.ts`), so a cut pushed into an Office
|
||||||
|
// would have landed somewhere `carsOn` cannot see — cars on the board that no train can
|
||||||
|
// couple and no walk is blocked by. `check` refuses a non-freight target, so this never
|
||||||
|
// fired; a trap that needs another rule to stay unsprung is still a trap.
|
||||||
|
carsOn(card).push(...e.stock);
|
||||||
}
|
}
|
||||||
turnOf(s, e.player).movesRemaining -= 1;
|
turnOf(s, e.player).movesRemaining -= 1;
|
||||||
spendCard(s, e.player, e.cardId);
|
spendCard(s, e.player, e.cardId);
|
||||||
@@ -2050,7 +2114,8 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
const f = facilityAt(s, e.player, e.at)!;
|
const f = facilityAt(s, e.player, e.at)!;
|
||||||
const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded);
|
const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded);
|
||||||
if (idx >= 0) f.inboundBox.splice(idx, 1);
|
if (idx >= 0) f.inboundBox.splice(idx, 1);
|
||||||
s.yards.classificationYard.push(e.stock);
|
// `pooled` — a car back in a yard is back in the common supply, carrying nothing (state.ts).
|
||||||
|
s.yards.classificationYard.push(pooled(e.stock));
|
||||||
turnOf(s, e.player).freightAgentUsed = true;
|
turnOf(s, e.player).freightAgentUsed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2065,7 +2130,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
const idx = box.findIndex((c) => c.type === e.stock.type);
|
const idx = box.findIndex((c) => c.type === e.stock.type);
|
||||||
if (idx >= 0) box.splice(idx, 1);
|
if (idx >= 0) box.splice(idx, 1);
|
||||||
}
|
}
|
||||||
s.yards.classificationYard.push(e.stock);
|
s.yards.classificationYard.push(pooled(e.stock));
|
||||||
turnOf(s, e.player).freightAgentUsed = true;
|
turnOf(s, e.player).freightAgentUsed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2133,19 +2198,28 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE TRAIN AND THE COACH THE PLAYER PICKED, not "the first one on the A/D tracks".
|
||||||
|
*
|
||||||
|
* This used to walk `adOccupancy` and fill the first empty coach it met, which is why two trains
|
||||||
|
* standing at one station both answered to whichever chip was clicked (v0.4.9d playtest), and
|
||||||
|
* why it could fill a coach on a train whose card refuses passenger work — `check` skipped such
|
||||||
|
* a train and the reducer did not. `e.trayId`/`e.coachIndex` are exactly what `passengerWork`
|
||||||
|
* resolved for `check`, carried on the event rather than looked up again here.
|
||||||
|
*/
|
||||||
case 'passengersBoarded': {
|
case 'passengersBoarded': {
|
||||||
const f = facilityAt(s, e.player, e.at)!;
|
const f = facilityAt(s, e.player, e.at)!;
|
||||||
const area = areaOf(s, e.player);
|
|
||||||
const idx = f.outboundBox.findIndex((c) => c.type === 'coach' && c.loaded);
|
const idx = f.outboundBox.findIndex((c) => c.type === 'coach' && c.loaded);
|
||||||
const loaded = f.outboundBox.splice(idx, 1)[0]!;
|
const loaded = f.outboundBox.splice(idx, 1)[0]!;
|
||||||
for (const id of area.adOccupancy) {
|
const tray = s.trays.get(e.trayId);
|
||||||
const tray = s.trays.get(id);
|
if (tray && tray.consist[e.coachIndex]) {
|
||||||
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && !c.loaded) ?? -1;
|
s.yards.classificationYard.push(pooled(tray.consist[e.coachIndex]!));
|
||||||
if (tray && ci >= 0) {
|
/**
|
||||||
s.yards.classificationYard.push(tray.consist[ci]!);
|
* Stamped with the district that filled it — the chip turned upside down in the tray. These
|
||||||
tray.consist[ci] = loaded;
|
* passengers may not alight anywhere in this Office Area; the train has to carry them to a
|
||||||
break;
|
* different one. See `RollingStock.origin` in state.ts.
|
||||||
}
|
*/
|
||||||
|
tray.consist[e.coachIndex] = { ...loaded, origin: seatOf(s, e.player) };
|
||||||
}
|
}
|
||||||
f.usedThisStage.porters += 1;
|
f.usedThisStage.porters += 1;
|
||||||
break;
|
break;
|
||||||
@@ -2153,21 +2227,18 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
|
|
||||||
case 'passengersDetrained': {
|
case 'passengersDetrained': {
|
||||||
const f = facilityAt(s, e.player, e.at)!;
|
const f = facilityAt(s, e.player, e.at)!;
|
||||||
const area = areaOf(s, e.player);
|
const tray = s.trays.get(e.trayId);
|
||||||
for (const id of area.adOccupancy) {
|
if (tray && tray.consist[e.coachIndex]) {
|
||||||
const tray = s.trays.get(id);
|
|
||||||
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,
|
// 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`.
|
// 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);
|
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');
|
if (yi < 0) throw new Error('passengersDetrained: no empty coach in the Division Yard');
|
||||||
const empty = s.yards.divisionYard.splice(yi, 1)[0]!;
|
const empty = s.yards.divisionYard.splice(yi, 1)[0]!;
|
||||||
refillDivisionYardIfEmpty(s);
|
refillDivisionYardIfEmpty(s);
|
||||||
f.inboundBox.push(tray.consist[ci]!);
|
// The arriving coach goes into the red box carrying nothing: the journey it was stamped for
|
||||||
tray.consist[ci] = empty;
|
// is over, and the box feeds straight back to a yard through the Freight Agent.
|
||||||
break;
|
f.inboundBox.push(pooled(tray.consist[e.coachIndex]!));
|
||||||
}
|
tray.consist[e.coachIndex] = empty;
|
||||||
}
|
}
|
||||||
f.usedThisStage.porters += 1;
|
f.usedThisStage.porters += 1;
|
||||||
break;
|
break;
|
||||||
@@ -2203,8 +2274,14 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
workTrack(f)[workTrack(f).length - 1] = null;
|
workTrack(f)[workTrack(f).length - 1] = null;
|
||||||
const ci = f.industryTrack.cars.findIndex((c) => !c.loaded && c.type === e.carType);
|
const ci = f.industryTrack.cars.findIndex((c) => !c.loaded && c.type === e.carType);
|
||||||
if (ci >= 0) {
|
if (ci >= 0) {
|
||||||
s.yards.classificationYard.push(f.industryTrack.cars[ci]!);
|
s.yards.classificationYard.push(pooled(f.industryTrack.cars[ci]!));
|
||||||
f.industryTrack.cars[ci] = { type: e.carType, loaded: true };
|
/**
|
||||||
|
* THE LOAD IS STAMPED WITH THE DISTRICT THAT MADE IT — the chip turned upside down in the
|
||||||
|
* tray. `laborer.beginUnload` refuses a car stamped with the district it is standing in, so
|
||||||
|
* this load now has to leave the Office Area on a train before anyone can break it. See
|
||||||
|
* `RollingStock.origin` in state.ts for the rule and why it is a seat.
|
||||||
|
*/
|
||||||
|
f.industryTrack.cars[ci] = { type: e.carType, loaded: true, origin: seatOf(s, e.player) };
|
||||||
}
|
}
|
||||||
f.usedThisStage.laborers += 1;
|
f.usedThisStage.laborers += 1;
|
||||||
break;
|
break;
|
||||||
|
|||||||
+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: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 6 },
|
||||||
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 },
|
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 },
|
||||||
/**
|
/**
|
||||||
* BOTH DIRECTIONS, per the card reference — this was `outbound` and it contradicted the rules.
|
* OUTBOUND ONLY. A Refinery ships oil out and receives nothing; reported from playtesting and
|
||||||
|
* confirmed by Jesse (v0.4.9e): "only ships out tanks, does not receive anything".
|
||||||
*
|
*
|
||||||
* `card-reference.md`: "Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4", and in prose — "'Freight
|
* It was briefly `flow: 'both'`, on the reading that "'Freight House' is not a card — it is the
|
||||||
* House' is not a card. It is the collective term for a freight facility that loads *and* unloads
|
* collective term for a freight facility that loads *and* unloads, the Grocer's Warehouse and the
|
||||||
* — the Grocer's Warehouse and the Oil Refinery." §9.3's "Passenger Facilities and Freight Houses
|
* Oil Refinery", which made §9.3's "Passenger Facilities and Freight Houses permit cars to move
|
||||||
* permit cars to move each direction" therefore names exactly these two, and the engine had both
|
* each direction" name exactly those two. That premise is dead: `glossary.md` and
|
||||||
* of them one-way.
|
* `rules-v0.2.md` corrected the Freight House to a card of its own, dealt like any other industry,
|
||||||
|
* so §9.3 names the Freight House and nothing else, and card-reference.md's "Both" column loses
|
||||||
|
* the only argument it had.
|
||||||
*
|
*
|
||||||
* The consequence was silent: `usableGrant` drops a Modifier's grant on a direction its host
|
* The card set says the same thing on its own. All three Refinery modifiers — Pipelines, Oil
|
||||||
* cannot use, so every +1 inbound beside a Refinery went nowhere.
|
* Depot, Viscosity Breakers — grant `+1 outbound`; a two-way Refinery would be the one industry in
|
||||||
*
|
* the game with no way to raise the direction it is supposed to use half its capacity on.
|
||||||
* The base numbers stay at the engine's own scale (1 per direction it allows) rather than the card
|
* `StationMaster-Home-Deck-v0.4.5.md` prints it "Outbound, 1 out / 0 in".
|
||||||
* reference's 2/2 — every industry here is scaled down the same way, Mine Tipple included, and
|
|
||||||
* raising one of them alone would be a balance change rather than a correction. Flagged in TODO.
|
|
||||||
*/
|
*/
|
||||||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
|
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
|
||||||
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 },
|
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 },
|
||||||
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 },
|
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 },
|
||||||
/**
|
/**
|
||||||
* BOTH DIRECTIONS — see the Refinery above; "Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3".
|
* INBOUND ONLY — the mirror of the Refinery above, and the same correction. Reported from
|
||||||
|
* playtesting and confirmed by Jesse (v0.4.9e): "Grocer's Warehouse should be receive only, does
|
||||||
|
* not ship anything out". `StationMaster-Home-Deck-v0.4.5.md` prints it "Inbound, 0 out / 1 in".
|
||||||
*
|
*
|
||||||
* Reported from play: "grocer's warehouse didn't get extra outbound slot for truck dock." It could
|
* THE ICE HOUSE IS THEREFORE A DEAD CARD BESIDE A GROCER'S, and that is the design, not an
|
||||||
* not: the Truck Dock printed +1 outbound at the time and this was `flow: 'inbound'`, so the grant
|
* oversight: `usableGrant` drops a Modifier's grant on a direction its host cannot use, and the
|
||||||
* was dropped on a direction the facility did not have. The same trap still swallows an Ice House
|
* Home Deck sheet says so outright — "a bonus beside a facility that cannot use its direction is
|
||||||
* set beside a Grocer's that has been left one-way.
|
* not usable", naming the Truck Dock's inbound bonus beside the outbound-only Packing Sheds as the
|
||||||
*
|
* other example. The Truck Dock (+1 inbound) and Local Small Groceries (+1 Laborer) are the two
|
||||||
* `TODO.md` had previously recorded this as "checked, and there is no bug" on the reasoning that a
|
* that do work here.
|
||||||
* Grocer's is inbound-only. That premise was the bug.
|
|
||||||
*/
|
*/
|
||||||
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
|
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
|
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
|
||||||
export const FREIGHT_PROFILES = INDUSTRY_PROFILES;
|
export const FREIGHT_PROFILES = INDUSTRY_PROFILES;
|
||||||
|
|
||||||
/** §9.3 — the collective term for an industry that both loads and unloads. */
|
/**
|
||||||
|
* §9.3 — "Passenger Facilities and Freight Houses permit cars to move each direction".
|
||||||
|
*
|
||||||
|
* ONE CARD ANSWERS TO THIS NOW: the Freight House itself. It was briefly three, while the Refinery
|
||||||
|
* and the Grocer's Warehouse were also `both` on a reading of the term the glossary has since
|
||||||
|
* corrected — a Freight House is a card, not a collective noun. Kept as a predicate on `flow`
|
||||||
|
* rather than a comparison against the kind, because it is the DIRECTION §9.3 is talking about.
|
||||||
|
*/
|
||||||
export function isFreightHouse(p: IndustryProfile): boolean {
|
export function isFreightHouse(p: IndustryProfile): boolean {
|
||||||
return p.flow === 'both';
|
return p.flow === 'both';
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -161,8 +161,16 @@ export type GameEvent =
|
|||||||
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
|
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
|
||||||
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
|
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
|
||||||
// -- load / unload
|
// -- load / unload
|
||||||
| { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord }
|
/**
|
||||||
| { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord }
|
* `trayId` and `coachIndex` name the TRAIN and the COACH the Porter worked, rather than leaving the
|
||||||
|
* reducer to find them again — the same lesson as `unloadBegan`'s `carIndex` below. Re-deriving
|
||||||
|
* "the first empty coach on the first train at the Office" is how two trains standing at one
|
||||||
|
* station both answered to one roster chip (v0.4.9d playtest), and how a coach the player had not
|
||||||
|
* chosen got filled. Required, not optional: an event is a fact, and a fact that has to be looked
|
||||||
|
* up against live state cannot render standalone in a replay.
|
||||||
|
*/
|
||||||
|
| { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord; trayId: TrayId; coachIndex: number }
|
||||||
|
| { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord; trayId: TrayId; coachIndex: number }
|
||||||
| { type: 'loadStarted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
| { type: 'loadStarted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||||
| { type: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number }
|
| { type: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number }
|
||||||
| { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
| { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||||
|
|||||||
+23
-2
@@ -101,8 +101,18 @@ export type Intent =
|
|||||||
| { type: 'maneuver.flyingSwitch'; cardId: CardId; trayId: TrayId; count: number; to: GridCoord }
|
| { type: 'maneuver.flyingSwitch'; cardId: CardId; trayId: TrayId; count: number; to: GridCoord }
|
||||||
| { type: 'redFlag.play' }
|
| { type: 'redFlag.play' }
|
||||||
// -- Load/Unload Phase (§9)
|
// -- Load/Unload Phase (§9)
|
||||||
| { type: 'porter.board'; at: GridCoord }
|
/**
|
||||||
| { type: 'porter.detrain'; at: GridCoord }
|
* `trayId` names the train the Porter works — reported from playtesting v0.4.9d as "operating two
|
||||||
|
* trains in a station, the select button does not work: regardless of which you pick, it is always
|
||||||
|
* one train, not the other". It was: neither intent carried a train, so the reducer took the first
|
||||||
|
* one on the A/D tracks and the roster chip the player had clicked changed nothing but the drawing.
|
||||||
|
*
|
||||||
|
* OPTIONAL, like `switch.move`'s `via` and for the same reason: intents are the canonical record
|
||||||
|
* `undo` and every save replay against, and absent means what it has always meant — the first
|
||||||
|
* eligible train at the Office.
|
||||||
|
*/
|
||||||
|
| { type: 'porter.board'; at: GridCoord; trayId?: TrayId }
|
||||||
|
| { type: 'porter.detrain'; at: GridCoord; trayId?: TrayId }
|
||||||
/** §9.3 — the first Laborer step: Green Loading Slot -> MEN. */
|
/** §9.3 — the first Laborer step: Green Loading Slot -> MEN. */
|
||||||
| { type: 'laborer.startLoad'; at: GridCoord }
|
| { type: 'laborer.startLoad'; at: GridCoord }
|
||||||
| { type: 'laborer.advanceLoad'; at: GridCoord; box: number }
|
| { type: 'laborer.advanceLoad'; at: GridCoord; box: number }
|
||||||
@@ -199,6 +209,17 @@ export type RejectionCode =
|
|||||||
* the Laborers can move it out of the box.
|
* the Laborers can move it out of the box.
|
||||||
*/
|
*/
|
||||||
| 'NO_EMPTY_CAR_SPOTTED'
|
| 'NO_EMPTY_CAR_SPOTTED'
|
||||||
|
/**
|
||||||
|
* §9 (Jesse's ruling, v0.4.9e) — freight or passengers loaded anywhere in an Office Area may not
|
||||||
|
* be unloaded anywhere in that same Office Area. The load has to be carried out of the district by
|
||||||
|
* a train first; a Freight House may not break the load it just made, and passengers may not
|
||||||
|
* detrain at the platform they boarded from.
|
||||||
|
*
|
||||||
|
* Distinct from the other refusals because the car IS loaded, the Laborer IS free and the boxes
|
||||||
|
* ARE clear: the only thing wrong with it is where it came from, and a player looking at a loaded
|
||||||
|
* boxcar standing on their own industry track deserves to be told that rather than "wrong car".
|
||||||
|
*/
|
||||||
|
| 'LOADED_IN_THIS_DISTRICT'
|
||||||
| 'NO_PORTERS_HERE'
|
| 'NO_PORTERS_HERE'
|
||||||
| 'NO_PASSENGERS_WAITING'
|
| 'NO_PASSENGERS_WAITING'
|
||||||
| 'NO_EMPTY_COACH'
|
| 'NO_EMPTY_COACH'
|
||||||
|
|||||||
+14
-2
@@ -288,9 +288,21 @@ function loadUnloadCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
|||||||
const out: Intent[] = [];
|
const out: Intent[] = [];
|
||||||
const area = areaOf(s, player);
|
const area = areaOf(s, player);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONE OPTION PER TRAIN STANDING AT THE OFFICE, not one per square.
|
||||||
|
*
|
||||||
|
* Reported from playtesting v0.4.9d: "operating two trains in a station, the select button does
|
||||||
|
* not work — regardless of which you pick, it is always one train, not the other". There was only
|
||||||
|
* ever ONE `board passengers` button, because the intent carried no train; the roster chip chose
|
||||||
|
* what the board drew and nothing else. Now each eligible train is its own candidate, and `check`
|
||||||
|
* filters the ones whose card, consist or passengers rule them out.
|
||||||
|
*/
|
||||||
|
const traysHere = area.adOccupancy.filter((id) => s.trays.has(id));
|
||||||
for (const coord of facilityCoords(s, player)) {
|
for (const coord of facilityCoords(s, player)) {
|
||||||
out.push({ type: 'porter.board', at: coord });
|
for (const trayId of traysHere) {
|
||||||
out.push({ type: 'porter.detrain', at: coord });
|
out.push({ type: 'porter.board', at: coord, trayId });
|
||||||
|
out.push({ type: 'porter.detrain', at: coord, trayId });
|
||||||
|
}
|
||||||
const f = area.grid.get(`${coord.row},${coord.col}`)?.facility;
|
const f = area.grid.get(`${coord.row},${coord.col}`)?.facility;
|
||||||
if (f) {
|
if (f) {
|
||||||
out.push({ type: 'laborer.startLoad', at: coord });
|
out.push({ type: 'laborer.startLoad', at: coord });
|
||||||
|
|||||||
+41
-1
@@ -56,7 +56,47 @@ export function coordKey(c: GridCoord): string {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** §2.2 — a coloured car is loaded, a white car is empty. */
|
/** §2.2 — a coloured car is loaded, a white car is empty. */
|
||||||
export type RollingStock = { type: CarType; loaded: boolean };
|
export type RollingStock = {
|
||||||
|
type: CarType;
|
||||||
|
loaded: boolean;
|
||||||
|
/**
|
||||||
|
* WHICH OFFICE AREA MADE THIS LOAD — the physical game's chip turned upside down in the tray.
|
||||||
|
*
|
||||||
|
* Reported from playtesting v0.4.9d as two bugs with one cause: a boxcar loaded at a Freight
|
||||||
|
* House could be unloaded at that same Freight House on the next Laborer action, and passengers
|
||||||
|
* who had just boarded could be detrained again before the train turned a wheel. Both paid full
|
||||||
|
* Revenue at each end for a load that never went anywhere.
|
||||||
|
*
|
||||||
|
* Jesse's rule (v0.4.9e): freight or passengers loaded anywhere in an Office Area may not be
|
||||||
|
* unloaded ANYWHERE in that same Office Area — not at another facility, not in a later Stage.
|
||||||
|
* They have to be carried by a train to a different Office Area. So the stamp is the SEAT, which
|
||||||
|
* is what an Office Area belongs to (Employee Rotation moves players between chairs; the district
|
||||||
|
* stays with the chair), and it never expires.
|
||||||
|
*
|
||||||
|
* A SEAT, NOT A PLAYER, and undefined rather than -1 for "no origin": the Division Yard opens with
|
||||||
|
* loaded cars and loaded coaches that were made up off-Division (`ROLLING_STOCK_SUPPLY`), and
|
||||||
|
* those are exactly the inbound traffic a solitaire district lives on. A sentinel inside
|
||||||
|
* `SeatIndex`'s own value range is not a sentinel — see `card.play`'s `node` in intents.ts.
|
||||||
|
*
|
||||||
|
* Stripped by `pooled` whenever a car goes back to a yard: the stamp belongs to the LOAD, and a
|
||||||
|
* car returning to the common supply is carrying nothing.
|
||||||
|
*/
|
||||||
|
origin?: SeatIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A car returning to the common pool — the Division or Classification Yard — with its load's origin
|
||||||
|
* stamp taken off.
|
||||||
|
*
|
||||||
|
* Every yard push goes through this. A loaded car CAN reach a yard still loaded (a train retires at
|
||||||
|
* a Division Point with freight aboard, `advance.ts`), and without this it would carry a stamp from
|
||||||
|
* a district it left several Days ago into whatever train is made up from it next.
|
||||||
|
*/
|
||||||
|
export function pooled(car: RollingStock): RollingStock {
|
||||||
|
if (car.origin === undefined) return car;
|
||||||
|
const { origin: _origin, ...rest } = car;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Track and Office Area
|
// Track and Office Area
|
||||||
|
|||||||
+12
-3
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
import { MAX_CONSIST } from '../engine/content.ts';
|
import { MAX_CONSIST } from '../engine/content.ts';
|
||||||
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, TrayId } from '../engine/state.ts';
|
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
|
||||||
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
|
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
|
||||||
import type { GameEvent } from '../engine/events.ts';
|
import type { GameEvent } from '../engine/events.ts';
|
||||||
|
|
||||||
@@ -34,11 +34,20 @@ export function clockTime(stage: number): string {
|
|||||||
return CLOCK[stage - 1] ?? `Stage ${stage}`;
|
return CLOCK[stage - 1] ?? `Stage ${stage}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function carLabel(c: RollingStock): string {
|
/**
|
||||||
|
* `homeSeat` is the district the page is being drawn for. Give it, and a load THIS district made
|
||||||
|
* says so — the printed game's answer is to turn the chip upside down in the tray, and this is the
|
||||||
|
* screen's. A load may not be broken in the Office Area that made it (state.ts `RollingStock.origin`),
|
||||||
|
* so "loaded here" is the difference between a boxcar worth switching and one that has to leave the
|
||||||
|
* district first. Omit it and the label is what it always was, which is what the replay viewers and
|
||||||
|
* the history lines want: they describe a board, not a seat's view of one.
|
||||||
|
*/
|
||||||
|
export function carLabel(c: RollingStock, homeSeat?: SeatIndex): string {
|
||||||
// A caboose carries the crew, not freight, so "loaded caboose" is nonsense on the page even
|
// A caboose carries the crew, not freight, so "loaded caboose" is nonsense on the page even
|
||||||
// though the supply marks every caboose loaded. Name it plainly.
|
// though the supply marks every caboose loaded. Name it plainly.
|
||||||
if (c.type === 'caboose') return 'caboose';
|
if (c.type === 'caboose') return 'caboose';
|
||||||
return `${c.loaded ? 'loaded' : 'empty'} ${c.type}`;
|
const label = `${c.loaded ? 'loaded' : 'empty'} ${c.type}`;
|
||||||
|
return homeSeat !== undefined && c.origin === homeSeat ? `${label} (loaded here)` : label;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function carsLabel(cars: RollingStock[]): string {
|
export function carsLabel(cars: RollingStock[]): string {
|
||||||
|
|||||||
+22
-6
@@ -508,6 +508,7 @@ const FACILITY_NAMES: Record<string, string> = {
|
|||||||
function facilityView(
|
function facilityView(
|
||||||
card: { geometry: { kind: string; facility?: string }; facility: unknown; modifiers?: string[] },
|
card: { geometry: { kind: string; facility?: string }; facility: unknown; modifiers?: string[] },
|
||||||
officeName: string,
|
officeName: string,
|
||||||
|
viewerSeat: SeatIndex,
|
||||||
): FacilityView | null {
|
): FacilityView | null {
|
||||||
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
|
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
|
||||||
// Passenger facilities were excluded entirely, so the Office's green and red slots never
|
// Passenger facilities were excluded entirely, so the Office's green and red slots never
|
||||||
@@ -529,7 +530,9 @@ function facilityView(
|
|||||||
maw: (f.menAtWork ?? []).map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
|
maw: (f.menAtWork ?? []).map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
|
||||||
red: f.inboundBox.map(carLabel),
|
red: f.inboundBox.map(carLabel),
|
||||||
redCap: f.capacity.inbound,
|
redCap: f.capacity.inbound,
|
||||||
track: f.industryTrack.cars.map(carLabel),
|
// Marked when this district made the load: the spotted car is exactly where a player is looking
|
||||||
|
// when they ask why the Laborer will not unload it.
|
||||||
|
track: f.industryTrack.cars.map((c) => carLabel(c, viewerSeat)),
|
||||||
laborers: `${laborersLeft(f)}/${f.laborers}`,
|
laborers: `${laborersLeft(f)}/${f.laborers}`,
|
||||||
porters: `${portersLeft(f)}/${f.porters}`,
|
porters: `${portersLeft(f)}/${f.porters}`,
|
||||||
canFinish: canFinishHere(f),
|
canFinish: canFinishHere(f),
|
||||||
@@ -620,7 +623,9 @@ function trainsOnCard(s: GameState, viewerSeat: SeatIndex, key: string): CellVie
|
|||||||
out.push({
|
out.push({
|
||||||
trayId: id,
|
trayId: id,
|
||||||
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||||
cars: t.consist.map(carLabel),
|
// A coach filled at THIS Office reads "loaded coach (loaded here)" — those passengers may not
|
||||||
|
// alight in the district that boarded them, and the tray is where a player looks for that.
|
||||||
|
cars: t.consist.map((c) => carLabel(c, viewerSeat)),
|
||||||
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
|
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
|
||||||
facing: railFacingOf(t),
|
facing: railFacingOf(t),
|
||||||
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
|
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
|
||||||
@@ -676,6 +681,11 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
|
|||||||
return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : '');
|
return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** " onto Train 8", or nothing at all when the intent names no train (an old save, or one train). */
|
||||||
|
function onto(s: GameState, trayId: string | undefined, joiner: string): string {
|
||||||
|
return trayId === undefined ? '' : `${joiner}${trainName(s, trayId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** One readable line for a single intent. */
|
/** One readable line for a single intent. */
|
||||||
export function describeIntent(s: GameState, i: Intent): string {
|
export function describeIntent(s: GameState, i: Intent): string {
|
||||||
// X,Y — east/west then north/south, not the internal row/col storage order.
|
// 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)}`;
|
return `advance load in box ${i.box} at ${at(i.at)}`;
|
||||||
case 'laborer.beginUnload':
|
case 'laborer.beginUnload':
|
||||||
return `begin unloading car ${i.carIndex} at ${at(i.at)}`;
|
return `begin unloading car ${i.carIndex} at ${at(i.at)}`;
|
||||||
|
/**
|
||||||
|
* NAME THE TRAIN. The action list drops duplicate labels within a crew, and with two trains
|
||||||
|
* standing at one station "board passengers at (0,0)" describes both — which is half of why the
|
||||||
|
* v0.4.9d playtest found that picking a train changed nothing. The intent now carries the tray;
|
||||||
|
* the label has to say so or the second button is thrown away before the menu sees it.
|
||||||
|
*/
|
||||||
case 'porter.board':
|
case 'porter.board':
|
||||||
return `board passengers at ${at(i.at)}`;
|
return `board passengers at ${at(i.at)}${onto(s, i.trayId, ' onto ')}`;
|
||||||
case 'porter.detrain':
|
case 'porter.detrain':
|
||||||
return `detrain passengers at ${at(i.at)}`;
|
return `detrain passengers at ${at(i.at)}${onto(s, i.trayId, ' from ')}`;
|
||||||
case 'newTrain.startExtra': {
|
case 'newTrain.startExtra': {
|
||||||
const runs = i.trainNumber % 2 === 0 ? 'east' : 'west';
|
const runs = i.trainNumber % 2 === 0 ? 'east' : 'west';
|
||||||
if (i.atSeat === null) {
|
if (i.atSeat === null) {
|
||||||
@@ -1007,7 +1023,7 @@ export function snapshot(
|
|||||||
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
|
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
|
||||||
else label = geometryLabel(g.geometry);
|
else label = geometryLabel(g.geometry);
|
||||||
|
|
||||||
const fv = facilityView(card as never, officeProfile(area.tier).name);
|
const fv = facilityView(card as never, officeProfile(area.tier).name, viewerSeat);
|
||||||
if (fv) facilities.push(fv);
|
if (fv) facilities.push(fv);
|
||||||
|
|
||||||
cells.push({
|
cells.push({
|
||||||
@@ -1022,7 +1038,7 @@ export function snapshot(
|
|||||||
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
|
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
|
||||||
trains: trainsOnCard(s, viewerSeat, key),
|
trains: trainsOnCard(s, viewerSeat, key),
|
||||||
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
|
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
|
||||||
cars: carsOn(card).map(carLabel),
|
cars: carsOn(card).map((c) => carLabel(c, viewerSeat)),
|
||||||
standingWest: card.standingWest,
|
standingWest: card.standingWest,
|
||||||
facility: fv,
|
facility: fv,
|
||||||
});
|
});
|
||||||
|
|||||||
+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
|
* and saying so is what the panel is for. Losing it FOREVER was the bug: the upgrade applied
|
||||||
* only the difference between two tiers and knew nothing about what had been discarded.
|
* only the difference between two tiers and knew nothing about what had been discarded.
|
||||||
*
|
*
|
||||||
* This used to be written against an Ice House on a Grocer's Warehouse. That case no longer
|
* This used to be written against an Ice House on a Grocer's Warehouse, which suppresses again
|
||||||
* suppresses anything, because the Grocer's is a both-direction facility — which was the other
|
* now that the Grocer's is inbound-only (v0.4.9e). The Office was chosen instead because the
|
||||||
* half of the same report.
|
* suppression there is TEMPORARY — an upgrade can lift it — and losing the grant forever across
|
||||||
|
* that upgrade was the bug. A Grocer's never ships, so its Ice House is suppressed permanently
|
||||||
|
* and tests nothing about the upgrade path.
|
||||||
*/
|
*/
|
||||||
const s = game();
|
const s = game();
|
||||||
const area = areaOf(s, 0);
|
const area = areaOf(s, 0);
|
||||||
@@ -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: 'carPassed', player: 0, trayId: 't0' },
|
||||||
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
|
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
|
||||||
{ type: 'clearanceGiven', trainId: 't1', allow: false },
|
{ type: 'clearanceGiven', trainId: 't1', allow: false },
|
||||||
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 } },
|
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
|
||||||
{ type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 } },
|
{ type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
|
||||||
{ type: 'loadStarted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
{ type: 'loadStarted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
||||||
{ type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 },
|
{ type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 },
|
||||||
{ type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
{ type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
||||||
|
|||||||
+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
|
* §9.3 — "Passenger Facilities and Freight Houses permit cars to move each direction". ONE card
|
||||||
* facility that loads *and* unloads — the Grocer's Warehouse and the Oil Refinery." The table
|
* answers to that.
|
||||||
* agrees: both are "Both", and only the Power Plant is inbound-only.
|
|
||||||
*
|
*
|
||||||
* The engine had the Refinery as outbound-only and the Grocer's as inbound-only, so §9.3's
|
* This briefly asserted three. `card-reference.md` reads "'Freight House' is not a card. It is
|
||||||
* "Passenger Facilities and Freight Houses permit cars to move each direction" named neither of
|
* the collective term for a freight facility that loads *and* unloads — the Grocer's Warehouse
|
||||||
* them — and every Modifier grant on the missing direction was silently dropped, which is how
|
* and the Oil Refinery", and on that premise the Refinery and the Grocer's were both made
|
||||||
* "grocer's warehouse didn't get extra outbound slot for truck dock" was reported.
|
* `flow: 'both'`. The premise is dead: `glossary.md` and `rules-v0.2.md` corrected the Freight
|
||||||
|
* House to a card of its own, dealt like any other industry, so §9.3 names it and the table's
|
||||||
|
* "Both" column loses its only argument.
|
||||||
*
|
*
|
||||||
* `freightHouse` is still in this list because the engine deals it as a CARD, which the rules say
|
* Reported from playtesting v0.4.9d and confirmed by Jesse: the Refinery only ships tanks out,
|
||||||
* it is not. That is a deck-composition question, recorded in TODO.md, not something to quietly
|
* the Grocer's Warehouse only receives. `StationMaster-Home-Deck-v0.4.5.md` prints both that way,
|
||||||
* delete six cards over.
|
* and so does the modifier set — all three Refinery modifiers grant outbound.
|
||||||
*/
|
*/
|
||||||
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
|
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
|
||||||
assert.deepEqual(houses.sort(), ['freightHouse', 'grocersWarehouse', 'refinery']);
|
assert.deepEqual(houses.sort(), ['freightHouse']);
|
||||||
|
const refinery = FREIGHT_PROFILES.find((f) => f.kind === 'refinery')!;
|
||||||
|
assert.equal(refinery.flow, 'outbound');
|
||||||
|
assert.deepEqual([refinery.baseOut, refinery.baseIn], [1, 0]);
|
||||||
|
const grocers = FREIGHT_PROFILES.find((f) => f.kind === 'grocersWarehouse')!;
|
||||||
|
assert.equal(grocers.flow, 'inbound');
|
||||||
|
assert.deepEqual([grocers.baseOut, grocers.baseIn], [0, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts every industry at one car out and one loader', () => {
|
it('starts every industry at one car out and one loader', () => {
|
||||||
|
|||||||
+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:
|
* grew faster, which is traffic rather than aimlessness, and there are two new sources of it:
|
||||||
* Extras now start at the Division Point their NUMBER sends them to, so westbound Extras exist
|
* Extras now start at the Division Point their NUMBER sends them to, so westbound Extras exist
|
||||||
* at all (measured 32 west / 29 east across 60 deals, against every single one launching
|
* at all (measured 32 west / 29 east across 60 deals, against every single one launching
|
||||||
* eastbound from the West Division Point before); and the Grocer's Warehouse ships as well as
|
* eastbound from the West Division Point before); and the Grocer's Warehouse briefly shipped as
|
||||||
* receives, so there is more switching worth doing.
|
* well as received, which was more switching worth doing. That second source is gone again in
|
||||||
|
* v0.4.9e — the Grocer's is inbound-only, as it always was on the sheet — and the ratio still
|
||||||
|
* clears the floor, so the figure is left where it is rather than re-tuned to one release.
|
||||||
*
|
*
|
||||||
* A crew that shuttles for its own sake would show this ratio climbing while `work` stood still.
|
* A crew that shuttles for its own sake would show this ratio climbing while `work` stood still.
|
||||||
* Logged in TODO.md with the rest of the bot drift rather than quietly absorbed.
|
* Logged in TODO.md with the rest of the bot drift rather than quietly absorbed.
|
||||||
@@ -1070,12 +1072,13 @@ describe('the freight figures count both halves (regression)', () => {
|
|||||||
// that on: an unload needs an inbound industry built, reachable, and a loaded car spotted at it,
|
// that on: an unload needs an inbound industry built, reachable, and a loaded car spotted at it,
|
||||||
// and whether the bot manages all three on a given deal is luck, not the thing under test.
|
// and whether the bot manages all three on a given deal is luck, not the thing under test.
|
||||||
/**
|
/**
|
||||||
* FORTY DEALS, up from twelve, and the reason is a rules correction rather than flakiness.
|
* FORTY DEALS, up from twelve, and the reason was a rules correction rather than flakiness.
|
||||||
*
|
*
|
||||||
* The Grocer's Warehouse is a BOTH-direction facility now — `card-reference.md` always said so —
|
* The Grocer's Warehouse was briefly a both-direction facility, so the bot shipped from it as
|
||||||
* where the engine had it inbound-only. So the bot can ship from it as well as receive, and it
|
* well as receiving and deals producing at least one completed unload fell from 12 in 40 to 6 in
|
||||||
* often does: deals producing at least one completed unload went from 12 in 40 to 6 in 40, while
|
* 40. v0.4.9e put it back to inbound-only, which is what the sheet always printed. The wider
|
||||||
* unloads themselves are unharmed (30 completed across the 40 measured after the change).
|
* sample is kept: the precondition it protects — that some deal in the batch actually completes
|
||||||
|
* an unload — is worth having whichever way the rule goes.
|
||||||
*
|
*
|
||||||
* The subject here is the INSTRUMENT — does `freightUnload` count Revenue earned rather than
|
* The subject here is the INSTRUMENT — does `freightUnload` count Revenue earned rather than
|
||||||
* unloads started — and `unloads > 0` is only the precondition that makes the comparison mean
|
* unloads started — and `unloads > 0` is only the precondition that makes the comparison mean
|
||||||
|
|||||||
@@ -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', () => {
|
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
|
* 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