diff --git a/CHANGELOG.md b/CHANGELOG.md index f6012d6..d9dcb64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,144 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.6.1 — 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. The same +change ships as **0.4.9e** on the 0.4.9 line, cut from the v0.4.9d commit — the engine files the fixes +touch are identical across the two lines, so the patch applied cleanly both ways. + +### 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.6.0 — 2026-08-21 Three queued items, and the last of them is the one that matters most. diff --git a/README.md b/README.md index 1d6af3c..3684390 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,14 @@ is the thing this machinery exists to prevent. it was worth more than the entire freight and passenger economy put together, for traffic nobody has to work. A seed alone therefore no longer names a game — the settings ride in the URL beside it, and every save records the rules it was dealt under. +- **A load may not be broken in the district that made it.** Freight or passengers loaded anywhere in + an Office Area cannot be unloaded anywhere in that same Office Area — not at another facility, not + in a later Stage. A train has to carry them to a different district first. The printed game turns + the chip upside down in the tray; here the load carries the seat that made it (`RollingStock.origin` + in `src/engine/state.ts`) and it never expires. Without it a Freight House could unload the boxcar + its own Laborers had just loaded and a platform could detrain the passengers it had just boarded, + each paying Revenue at both ends for a load that went nowhere: worth **0.60 ± 0.10 Revenue a game** + to the developer bot over 400 paired deals, on 78 of them. - **A turnout can be laid on top of a card already down.** It upgrades a straight at any rotation, or a curve whose arc matches its own diverging leg — both strict port supersets of what they replace, so an upgrade can never sever an existing join. Without it a district could only hang off track that diff --git a/TODO.md b/TODO.md index 3936a8f..89d490c 100644 --- a/TODO.md +++ b/TODO.md @@ -30,7 +30,24 @@ Queued 2026-08-21, from playing the StartOS build: 6. ~~**Stop every release destroying every game in progress**~~ — done in v0.6.0, by replaying the save rather than comparing version strings. -Nothing else queued at the moment. +Queued 2026-08-22, from playing on StartOS: + +7. **Make "Games in Progress" readable** — nested groups rather than one run-on line per game, plus + sorting by name, start time or last move. Reasoning in Multiplayer below. Small, and it is the + action most used for actual administration. +8. **Give a player a way back into a game after losing their browser** — today a fresh browser is + locked out permanently, even though the server still knows who they are. Reasoning in + Multiplayer below; needs Jesse's call on whether a token in a URL is acceptable. + +Queued 2026-08-22, from a v0.4.9d gameplay-testing report (six bugs, forwarded by Jesse): + +9. ~~**Two trains in one station answer to one button**~~ — done in v0.4.9e / the release below. +10. ~~**A Freight House can unload the boxcar it just loaded; a platform can detrain the passengers + it just boarded**~~ — done in v0.4.9e / the release below. +11. ~~**The Grocer's Warehouse ships and the Refinery receives**~~ — done in v0.4.9e / the release + below: both are one-way again. +12. **NOT REPRODUCED: cars left behind when backing up over them** — see Rules Questions below. The + one report of the six that is still open, and it needs a board from whoever filed it. --- @@ -374,6 +391,79 @@ number until the rules stop moving. Deferred while planning the server; decisions and reasoning are in `docs/architecture/multiplayer.md`. +- [ ] **A LOST SESSION TOKEN LOCKS A PLAYER OUT OF A RUNNING GAME PERMANENTLY.** Raised by Jesse + 2026-08-22: "if I opened a fresh browser window and wanted to resume HOPPER-4607, how would + the server know which player I am and which game I'm trying to get to?" + + **A new tab or window of the SAME browser is fine** — `localStorage` is per-origin and shared + across a profile, so `start()` finds the token and rejoins automatically. **A genuinely fresh + browser is not**: another browser, a private window, another device, or cleared site data. + The token lives only in that one browser, and nothing else will accept an identity claim. + `/api/lobby/join` resolves a code against `lobbies`, and a started game is removed from + `lobbies` at `Lobby.Start`, so typing the game code answers `no open lobby with that code` — + the same answer a typo gets. + + **The server knows exactly who you are and cannot be told.** Each game's `sessions.json` + holds `{ token, gameId, player, displayName }` and survives restarts — read off the box: + `HOPPER-4607: player 0 = Jesse | token df9e04c7…`. Everything needed is on disk; there is no + door. `lobby-and-sessions.md` §1 says "presenting the token IS the rejoin", which was a fair + assumption when a game lasted an afternoon and is a much worse one now that a game survives + an update (v0.6.0) and can sit for weeks. + + **A second, nearer limit: `REMOTE_KEY` is a single `localStorage` key**, so a browser + remembers exactly one multiplayer game. Join a second and the first token is overwritten and + gone, with the same lockout. D13 says one game at a time is expected but "deliberately not + enforced" — the client enforces it by forgetting. + + Three ways out, and the third is the one that fits what is already built: + + 1. **Show the player their own rejoin link** — a URL carrying the token in the fragment, to + copy and keep. No new server state, and the fragment never reaches the server. It is still + a credential in a link, so it lands in history and in whatever they paste it into. + 2. **Rejoin by game code + display name + join secret — do not do this.** Every player holds + the join secret, so any of them could claim another's seat by typing their name. + 3. **An administrator action, "Get Rejoin Link"** — pick a game and a player, get a URL to + send them. Gated by the admin secret, so only whoever runs the box can issue one, and no + player can impersonate another. Fits the existing admin-action pattern exactly. + + **(1) and (3) together**, most likely: the player keeps their own link, and the administrator + can reissue one when they did not. Keying remembered sessions by `gameId` — with a picker + when the browser holds more than one — fixes the single-key limit at the same time. Jesse has + not yet decided whether a token in a URL is acceptable; the alternative is a bare token + pasted into a field, which is uglier and stays out of history. + +- [ ] **The StartOS "Games in Progress" action is one long unreadable run-on per game.** Raised by + Jesse 2026-08-22 after using it against four games. Lives in the WRAPPER repo + (`station-master-startos`, `startos/actions/gamesInProgress.ts`), whose `AGENTS.md` says work + belongs in issues on that repo rather than a `TODO.md` — recorded here because this is where + the project's list actually is; move it if that policy is meant to bind. + + **What he asked for**, taking the current output field by field: a separator between the + players and the Day/Stage line; the phase in parentheses rather than after an em dash + (`Day 1, Stage 1 (Local Ops)`); a separator before "Waiting on"; one after the waiting-on + player and seat, before the start time; and one between the start time and the last-move + time. + + **Why they are all missing at once, most likely.** `describe()` joins its lines with `\n`, + so the intent was one field per line. Every separator Jesse is missing is exactly where a + newline is — which says the StartOS action-result view does not render newlines in a + `single`'s value, and collapses the lot into one line. Worth confirming in the UI before + designing around it, since the whole diagnosis rests on it. + + **The structural fix, better than adding separators.** `ActionResultMember` can itself be a + `group` (`osBindings/ActionResultMember.d.ts` — "a new group of nested values, experienced by + the user as an accordion dropdown"), so groups nest. Each game can be a collapsible group + whose members are individual `single` rows — Players, Position, Waiting on, Started, Last + move — instead of one string. That gives every field its own labelled row, makes the + separator question disappear rather than answering it, and collapses cleanly when there are + many games. Do this rather than punctuating the run-on. + + **Sorting, also asked for**, and worth having once a box holds more than a handful: by game + name, by start time, or by last-move time, ascending or descending. An action's input spec is + built at open time, so a `Value.select` for the field and another for the direction costs + almost nothing — and sorting by last move ascending is how you find the game nobody has + touched, which is the main reason to open this action at all. + - [ ] **Let the game join a call and talk to the table.** Long-term. If the game could join a Zoom, Teams or Jitsi call and post into its chat, it could carry the whole table's shared state without anyone alt-tabbing: the history of actions as they happen, and a prompt when someone @@ -772,6 +862,45 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite --- +## Rules Questions + +- [ ] **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. + +--- + ## Other Doesn't fit the above. @@ -828,6 +957,13 @@ Doesn't fit the above. made for the other three, not an assumption that the same correction applies, since raising or lowering Laborer counts is also a balance question, not only a docs one. + **v0.4.9e narrows it.** The Direction column for the Grocer's Warehouse and the Oil Refinery was + the wrong half of that v0.5.0 pass and has been put back to one-way each, from gameplay testing + and Jesse's confirmation. The *numbers* in those two rows are still the card reference's own + (1 Laborer, 3–4 track) and still unverified against the engine, so this entry stands as written + for all five industries — what changed is only that the two rows the v0.5.0 pass claimed to have + re-verified turn out to have been re-verified against a premise rather than against a card. + --- ## Done, kept for the reasoning @@ -1019,7 +1155,14 @@ Doesn't fit the above. tooltip briefly told players four working cards did nothing, which is worse than the bare label it replaced — `enhancements.test.ts` had passing tests for all four the whole time. - [x] **A modifier's grant can land on a direction its host cannot use, and nothing says so — - corrected in v0.4.7.** The earlier "no bug" verdict below was wrong. The reasoning had been that + corrected in v0.4.7, and the correction was itself half wrong.** *(v0.4.9e: the ORIGINAL verdict + was right about the Grocer's.* A Grocer's Warehouse **is** inbound-only — gameplay testing said + so and Jesse confirmed it — so the Ice House's outbound grant beside one is genuinely dead, the + way the Truck Dock's inbound grant beside the outbound-only Packing Sheds is. What survives from + v0.4.7 is the machinery and the decision behind it: an industry's printed flow is absolute, the + grant is dropped rather than the direction opened, and `suppressedGrants` says so on the page. + What does not survive is opening the two facilities up. Original v0.4.7 note follows.)* + The earlier "no bug" verdict below was wrong. The reasoning had been that a Grocer's Warehouse is inbound-only. It is not — the card reference says "Both" — so the grant was being dropped on a direction the facility should have had. Reported again in play as "grocer's warehouse didn't get extra outbound slot for truck dock". The suppression machinery diff --git a/docs/rules/card-reference.md b/docs/rules/card-reference.md index 712a251..e5f282f 100644 --- a/docs/rules/card-reference.md +++ b/docs/rules/card-reference.md @@ -42,14 +42,14 @@ Operational Rail wheel icon, an industry track of the stated length, Laborer ico | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | | Mine Tipple | Hopper | Outbound only | 3 | 3 | — | 4 | 2 | | Produce Shed | Reefer | Outbound only | 2 | 2 | — | 3 | 2 | -| Grocer's Warehouse | Boxcar | Both | 1 | 1 | 1 | 3 | 3 | -| Oil Refinery | Tank car | Both | 1 | 1 | 1 | 4 | 3 | -| Power Plant | Hopper | Inbound only | 3 | — | 3 | 4 | 2 | +| Grocer's Warehouse | Boxcar or reefer | Inbound only | 1 | — | 1 | 3 | 3 | +| Oil Refinery | Tank car | Outbound only | 1 | 1 | — | 4 | 3 | +| Power Plant | Hopper or tank car | Inbound only | 3 | — | 3 | 4 | 2 | | Freight House | Boxcar | Both | 1 | 1 | 1 | — | 6 | Directions follow the commodity: coal originates at a Mine Tipple and is consumed at a Power Plant; -produce ships out; a warehouse and a refinery do both. This gives §9 all three of its cases — -outbound-only, inbound-only, and both. +produce ships out; a warehouse receives. This gives §9 all three of its cases — outbound-only, +inbound-only, and both — with the **Freight House the one card that does both**. **"Freight House" IS a card** (corrected v0.5.0) — a sixth industry, dealt 6 copies, one Laborer and one slot each direction. An earlier pass here read §9.3/Appendix A's "Passenger Facilities and @@ -57,6 +57,15 @@ Freight Houses permit cars to move each direction" as meaning "Freight House" wa *collective term* for the Grocer's Warehouse and the Oil Refinery, never a card of its own — that reading was wrong; the engine deals it as a real sixth industry (`content.ts`'s `freightHouse` profile) and this table follows the engine. + +**The Grocer's Warehouse and the Oil Refinery are ONE-WAY** (corrected v0.4.9e). The Direction column +read "Both" for both of them, and that was the *other half* of the same mistaken reading: if "Freight +House" named those two, §9.3 had to be describing them, so they had to be two-way. Once the Freight +House is its own card the argument evaporates, and playtesting settled it — "Grocer's Warehouse +should be receive only, does not ship anything out"; "Refinery: only ships out tanks, does not +receive anything" (Jesse). `StationMaster-Home-Deck-v0.4.5.md` prints both that 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. 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. @@ -269,12 +279,12 @@ with all boxes full: | Car | Facility demand | Supply | Headroom | | --- | ---: | ---: | --- | | Hopper | Mine Tipple 3×2 + Power Plant 3×2 = 12 | 12 | exactly met | -| Tank | Oil Refinery (2+2)×2 = 8 | 8 | exactly met | -| Boxcar | Grocer's (2+2)×2 = 8 | 12 | 4 spare | +| Tank | Oil Refinery 2×2 = 4 | 8 | 4 spare (was "exactly met" while the Refinery was two-way) | +| Boxcar | Grocer's 2×2 = 4 | 12 | 8 spare (same correction) | | Reefer | Produce Shed 2×2 = 4 | 8 | 4 spare | | Coach | Terminal 4+4, per Office | 16 | scales with Office count | -Hoppers and tank cars are exactly met in the theoretical worst case, which cannot occur in practice — +Hoppers are exactly met in the theoretical worst case, which cannot occur in practice — only 10 freight facility cards exist across a 52-card deck shared by all players, and the §2.2 Classification Yard recycle returns stock to the Division Yard whenever it empties. Both are worth watching in playtesting. diff --git a/docs/rules/glossary.md b/docs/rules/glossary.md index af4c8f7..530899b 100644 --- a/docs/rules/glossary.md +++ b/docs/rules/glossary.md @@ -25,7 +25,7 @@ Every defined term, alphabetized for lookup. The core comes from the Definitions | **Extra Train** | A one-and-done train; its card returns to the Salvage Yard on completion. Head-on card image, so the drawing player picks its direction. Numbered with an "X" prefix; the following number gives its seniority, and it yields to the Timetabled train of that number. | §2.3, §8 | | **Facility** | A business which loads/unloads cargo and freight. | §2.5 | | **Freight Facility** | Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power Plants, Freight Houses. Some allow only outbound, some only inbound, some both. Per-card values in §12.5. | §9, §12.5 | -| **Freight House** | A sixth Freight Facility card (corrected v0.5.0 — an earlier pass here read it as a collective term for the Grocer's Warehouse and the Oil Refinery rather than a card of its own; it is dealt like any other industry). Permits both directions, the same as a Grocer's Warehouse or Oil Refinery. | §9.3, §12.5 | +| **Freight House** | A sixth Freight Facility card (corrected v0.5.0 — an earlier pass here read it as a collective term for the Grocer's Warehouse and the Oil Refinery rather than a card of its own; it is dealt like any other industry). **The only industry that permits both directions** — the Grocer's Warehouse receives and the Oil Refinery ships, one way each (v0.4.9e). | §9.3, §12.5 | | **Highball** | When a train holding at an Office automatically departs. | §2.4 | | **Home Office** | The primary face-down deck cards are drawn from. 52 cards. | §2.6, §12.1 | | **Hopper** | Coal rolling stock (brown = loaded, white = empty). | §2.2 | diff --git a/docs/rules/open-questions.md b/docs/rules/open-questions.md index dc10ed5..529ac0a 100644 --- a/docs/rules/open-questions.md +++ b/docs/rules/open-questions.md @@ -670,10 +670,15 @@ balance work was possible. | --- | --- | --- | ---: | ---: | ---: | ---: | | Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 | | Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 | -| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 | -| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 | +| Grocer's Warehouse | Boxcar | Inbound | 2 | — | 2 | 3 | +| Oil Refinery | Tank | Outbound | 3 | 2 | — | 4 | | Power Plant | Hopper | Inbound | 3 | — | 3 | 4 | +*Amended v0.4.9e.* The warehouse and the refinery were briefly "Both", on the reading that "Freight +House" was a collective term for exactly those two and therefore what §9.3's "permit cars to move +each direction" described. The Freight House turned out to be a card of its own (v0.5.0), and +playtesting confirmed the one-way reading the sheet always printed. + *Rationale.* Directions follow the commodity and give §9 all three of its stated cases. Bulk industries get more Laborers and a 4-car track so the types feel distinct when choosing what to build — but the counts stay moderate because Laborers are **not** the binding constraint (see 10e), diff --git a/docs/rules/rules-v0.2.md b/docs/rules/rules-v0.2.md index 4dfb8d0..f3190e5 100644 --- a/docs/rules/rules-v0.2.md +++ b/docs/rules/rules-v0.2.md @@ -736,8 +736,8 @@ Modifier effects, and track geometries — is catalogued in | --- | --- | --- | ---: | ---: | ---: | ---: | | Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 | | Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 | -| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 | -| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 | +| Grocer's Warehouse | Boxcar | Inbound | 2 | — | 2 | 3 | +| Oil Refinery | Tank | Outbound | 3 | 2 | — | 4 | | Power Plant | Hopper | Inbound | 3 | — | 3 | 4 | | Office | Porters | Green slots | Red slots | @@ -755,8 +755,10 @@ Modifier effects, and track geometries — is catalogued in | Section Gang | +1 Laborer or +1 Porter | **"Freight House"** (§9.3, Appendix A) is a Freight Facility card (corrected v0.5.0 — previously -read as not a card, only a collective term for a facility that both loads and unloads). It permits -both directions, the same as the Grocer's Warehouse and the Oil Refinery. +read as not a card, only a collective term for a facility that both loads and unloads). 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 playtesting — the "Both" reading was the other half of +the same mistake about what "Freight House" meant). --- diff --git a/package.json b/package.json index f54aa51..e6fd431 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.6.0", + "version": "0.6.1", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/public/replays/seed-967118.json b/public/replays/seed-1323473.json similarity index 57% rename from public/replays/seed-967118.json rename to public/replays/seed-1323473.json index 3a31458..31aeeb4 100644 --- a/public/replays/seed-967118.json +++ b/public/replays/seed-1323473.json @@ -1,7 +1,7 @@ { - "seed": 967118, - "title": "12 Revenue · seed 967118", - "note": "12 Revenue over 5 Days · 1 train(s) on the timetable · 12 cards down · no collisions · played by developer", + "seed": 1323473, + "title": "10 Revenue · seed 1323473", + "note": "10 Revenue over 5 Days · 2 train(s) on the timetable · 22 cards down · no collisions · played by developer", "rules": { "startingHand": "threeRandom", "revenue": { @@ -20,7 +20,11 @@ }, { "type": "card.play", - "cardId": "c187", + "cardId": "c9" + }, + { + "type": "card.play", + "cardId": "c116", "placement": { "row": 0, "col": 1 @@ -29,57 +33,59 @@ }, { "type": "card.play", - "cardId": "c152", - "placement": { - "row": -1, - "col": 1 - }, - "variant": 1 - }, - { - "type": "card.play", - "cardId": "c36", - "placement": { - "row": -1, - "col": 2 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c124", + "cardId": "c179", "placement": { "row": 0, "col": -1 }, "variant": 0 }, + { + "type": "card.play", + "cardId": "c143", + "placement": { + "row": -1, + "col": -1 + }, + "variant": 1 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "caboose", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, { "type": "draw.end" }, @@ -94,8 +100,17 @@ "type": "draw.fromHomeOffice" }, { - "type": "card.play", - "cardId": "c11" + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" }, { "type": "draw.end" @@ -112,7 +127,100 @@ }, { "type": "card.play", - "cardId": "c182", + "cardId": "c121", + "placement": { + "row": -1, + "col": 0 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c89", + "placement": { + "row": -1, + "col": 0 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c28" + }, + { + "type": "card.play", + "cardId": "c7" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c202", "placement": { "row": 0, "col": -2 @@ -132,6 +240,33 @@ { "type": "draw.fromHomeOffice" }, + { + "type": "card.play", + "cardId": "c203", + "placement": { + "row": -1, + "col": 1 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.discard", + "cardId": "c104", + "toSlot": 0 + }, { "type": "draw.end" }, @@ -147,7 +282,148 @@ }, { "type": "card.play", - "cardId": "c115", + "cardId": "c51", + "placement": { + "row": -1, + "col": 2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "caboose", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.discard", + "cardId": "c72", + "toSlot": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.discard", + "cardId": "c22", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.discard", + "cardId": "c16", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 0 + }, + { + "type": "card.discard", + "cardId": "c101", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c130", "placement": { "row": 0, "col": 2 @@ -156,27 +432,405 @@ }, { "type": "card.play", - "cardId": "c164", + "cardId": "c150", "placement": { "row": -1, "col": 3 }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c145", + "placement": { + "row": -2, + "col": 3 + }, "variant": 1 }, { "type": "draw.end" }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "draw.end" + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + }, + "trayId": "tray2" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c129", + "placement": { + "row": 0, + "col": 3 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c151", + "placement": { + "row": -2, + "col": 4 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, { "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "boxcar", - "loaded": false + "trayId": "tray2", + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "caboose", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c176", + "placement": { + "row": 0, + "col": -3 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.discard", + "cardId": "c16", + "toSlot": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 1 + }, + { + "type": "card.discard", + "cardId": "c93", + "toSlot": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" }, { "type": "newTrain.placeCar", "trayId": "tray3", - "carType": "boxcar", - "loaded": false + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c67", + "placement": { + "row": 1, + "col": 0 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.discard", + "cardId": "c102", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + }, + "trayId": "tray3" + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + }, + "trayId": "tray3" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c172", + "placement": { + "row": -1, + "col": -2 + }, + "variant": 1 + }, + { + "type": "card.play", + "cardId": "c40", + "placement": { + "row": -1, + "col": -3 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "tank", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "tank", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "tank", + "loaded": true }, { "type": "newTrain.placeCar", @@ -196,10 +850,10 @@ }, { "type": "card.play", - "cardId": "c185", + "cardId": "c136", "placement": { "row": 0, - "col": 3 + "col": -4 }, "variant": 0 }, @@ -218,10 +872,10 @@ }, { "type": "card.play", - "cardId": "c141", + "cardId": "c118", "placement": { "row": 0, - "col": -3 + "col": 4 }, "variant": 0 }, @@ -231,6 +885,28 @@ { "type": "loadUnload.end" }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c157", + "placement": { + "row": -3, + "col": 4 + }, + "variant": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, { "type": "localOps.choose", "option": "switch" @@ -240,9 +916,9 @@ "trayId": "tray3", "to": { "row": -1, - "col": 1 + "col": -2 }, - "reverse": false + "reverse": true }, { "type": "switch.dropCars", @@ -269,174 +945,24 @@ "count": 1 }, { - "type": "switch.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 2 - }, - "carType": "boxcar" - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 2 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 2 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 }, { "type": "switch.end" }, { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 1 + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "tank", + "loaded": true }, { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 2 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 2 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 1 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": true }, { "type": "laborer.beginUnload", @@ -492,6 +1018,180 @@ { "type": "switch.end" }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + }, + "trayId": "tray2" + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + }, + "trayId": "tray2" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 1 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 1 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 1 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": -1, + "col": 2 + }, + "index": 0 + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 2 + }, + "carIndex": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, { "type": "laborer.advanceLoad", "at": { @@ -515,89 +1215,13 @@ }, "index": 0 }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 2 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 2 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 1 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, { "type": "laborer.beginUnload", "at": { "row": -1, "col": 2 }, - "carIndex": 0 + "carIndex": 2 }, { "type": "loadUnload.end" @@ -627,6 +1251,18 @@ { "type": "switch.end" }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "tank", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": true + }, { "type": "laborer.advanceLoad", "at": { @@ -668,36 +1304,6 @@ }, "index": 0 }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 2 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 2 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 0 - }, { "type": "loadUnload.end" }, @@ -709,12 +1315,20 @@ "type": "switch.end" }, { - "type": "laborer.advanceLoad", + "type": "porter.detrain", "at": { - "row": -1, - "col": 2 + "row": 0, + "col": 0 }, - "box": 1 + "trayId": "tray2" + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + }, + "trayId": "tray2" }, { "type": "loadUnload.end" @@ -724,87 +1338,58 @@ "option": "switch" }, { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 1 }, - "box": 2 + "reverse": false }, { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": 2 + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 0 }, - "carIndex": 0 + "reverse": true }, { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 1 }, - "box": 2 + "reverse": false }, { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 0 }, - "box": 1 + "reverse": true }, { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 1 }, - "box": 0 + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true }, { "type": "loadUnload.end" @@ -816,41 +1401,11 @@ { "type": "freightAgent.clearInbound", "at": { - "row": -1, - "col": 2 + "row": 0, + "col": 0 }, "index": 0 }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 2 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 2 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 0 - }, { "type": "loadUnload.end" }, @@ -861,14 +1416,6 @@ { "type": "switch.end" }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 1 - }, { "type": "loadUnload.end" }, @@ -879,356 +1426,6 @@ { "type": "switch.end" }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": 2 - }, - "carIndex": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 1 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": -1, - "col": 2 - }, - "index": 0 - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 2 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 2 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 1 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": 2 - }, - "carIndex": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 1 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": -1, - "col": 2 - }, - "index": 0 - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 2 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 2 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 1 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": 2 - }, - "carIndex": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 2 - }, - "box": 2 - }, { "type": "loadUnload.end" } diff --git a/public/replays/seed-737467.json b/public/replays/seed-2044102.json similarity index 66% rename from public/replays/seed-737467.json rename to public/replays/seed-2044102.json index fe6ce1d..522851e 100644 --- a/public/replays/seed-737467.json +++ b/public/replays/seed-2044102.json @@ -1,7 +1,7 @@ { - "seed": 737467, - "title": "12 Revenue · seed 737467", - "note": "12 Revenue over 5 Days · 1 train(s) on the timetable · 10 cards down · no collisions · played by developer", + "seed": 2044102, + "title": "9 Revenue · seed 2044102", + "note": "9 Revenue over 5 Days · 1 train(s) on the timetable · 17 cards down · no collisions · played by developer", "rules": { "startingHand": "threeRandom", "revenue": { @@ -11,93 +11,6 @@ } }, "history": [ - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.play", - "cardId": "c23" - }, - { - "type": "card.play", - "cardId": "c32" - }, - { - "type": "card.play", - "cardId": "c120", - "placement": { - "row": 0, - "col": 1 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": 0, - "col": 0 - }, - "carType": "coach" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": 0, - "col": 0 - }, - "carType": "coach" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, { "type": "localOps.choose", "option": "draw" @@ -107,26 +20,7 @@ }, { "type": "card.discard", - "cardId": "c40", - "toSlot": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 0 - }, - { - "type": "card.discard", - "cardId": "c171", + "cardId": "c168", "toSlot": 0 }, { @@ -142,9 +36,46 @@ { "type": "draw.fromHomeOffice" }, + { + "type": "card.discard", + "cardId": "c58", + "toSlot": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 1 + }, + { + "type": "card.discard", + "cardId": "c160", + "toSlot": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, { "type": "card.play", - "cardId": "c87", + "cardId": "c192", "placement": { "row": 0, "col": 1 @@ -164,9 +95,62 @@ { "type": "draw.fromHomeOffice" }, + { + "type": "card.discard", + "cardId": "c79", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, { "type": "card.play", - "cardId": "c139", + "cardId": "c4" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.discard", + "cardId": "c92", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c204", "placement": { "row": 0, "col": -1 @@ -186,46 +170,29 @@ { "type": "draw.fromHomeOffice" }, - { - "type": "card.discard", - "cardId": "c168", - "toSlot": 1 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, { "type": "card.play", - "cardId": "c6" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c137", + "cardId": "c202", "placement": { - "row": 0, + "row": -1, + "col": -1 + }, + "variant": 1 + }, + { + "type": "card.play", + "cardId": "c58", + "placement": { + "row": -1, + "col": 0 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c50", + "placement": { + "row": -1, "col": -2 }, "variant": 0 @@ -258,6 +225,140 @@ { "type": "loadUnload.end" }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c183", + "placement": { + "row": 0, + "col": -2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c29" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c119", + "placement": { + "row": 0, + "col": 2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + }, + "trayId": "tray3" + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + }, + "trayId": "tray3" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, { "type": "localOps.choose", "option": "freightAgent" @@ -281,65 +382,13 @@ "type": "draw.fromHomeOffice" }, { - "type": "card.discard", - "cardId": "c162", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c40", - "toSlot": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 0 - }, - { - "type": "card.discard", - "cardId": "c57", - "toSlot": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 0 - }, - { - "type": "card.discard", - "cardId": "c25", - "toSlot": 0 + "type": "card.play", + "cardId": "c147", + "placement": { + "row": -1, + "col": 1 + }, + "variant": 0 }, { "type": "draw.end" @@ -356,10 +405,90 @@ }, { "type": "card.play", - "cardId": "c193", + "cardId": "c33" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c143", + "placement": { + "row": -1, + "col": -3 + }, + "variant": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c139", "placement": { "row": 0, - "col": 2 + "col": 3 }, "variant": 0 }, @@ -378,7 +507,7 @@ }, { "type": "card.discard", - "cardId": "c89", + "cardId": "c107", "toSlot": 1 }, { @@ -395,28 +524,22 @@ "type": "draw.fromHomeOffice" }, { - "type": "card.discard", - "cardId": "c40", - "toSlot": 2 + "type": "card.play", + "cardId": "c148", + "placement": { + "row": -2, + "col": 1 + }, + "variant": 1 }, { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c57", - "toSlot": 2 + "type": "card.play", + "cardId": "c46", + "placement": { + "row": -2, + "col": 2 + }, + "variant": 0 }, { "type": "draw.end" @@ -430,7 +553,7 @@ { "type": "newTrain.placeCar", "trayId": "tray3", - "carType": "boxcar", + "carType": "coach", "loaded": true }, { @@ -440,37 +563,9 @@ "type": "localOps.choose", "option": "draw" }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c22", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, { "type": "draw.fromHomeOffice" }, - { - "type": "card.play", - "cardId": "c110", - "placement": { - "row": 0, - "col": 3 - }, - "variant": 0 - }, { "type": "draw.end" }, @@ -479,21 +574,24 @@ "at": { "row": 0, "col": 0 - } + }, + "trayId": "tray3" }, { "type": "porter.board", "at": { "row": 0, "col": 0 - } + }, + "trayId": "tray3" }, { "type": "porter.detrain", "at": { "row": 0, "col": 0 - } + }, + "trayId": "tray3" }, { "type": "loadUnload.end" @@ -513,36 +611,6 @@ { "type": "loadUnload.end" }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": 0, - "col": 0 - }, - "index": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": 0, - "col": 0 - }, - "carType": "coach" - }, - { - "type": "loadUnload.end" - }, { "type": "localOps.choose", "option": "draw" @@ -552,52 +620,74 @@ }, { "type": "card.play", - "cardId": "c97", - "node": 1 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c157", - "toSlot": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c21" + "cardId": "c19" }, { "type": "draw.end" }, { "type": "newTrain.startExtra", - "trainNumber": 22, + "trainNumber": 20, "atSeat": null }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "reefer", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "reefer", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": false + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c12" + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.startExtra", + "trainNumber": 13, + "atSeat": null + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "reefer", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "reefer", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "reefer", + "loaded": false + }, { "type": "newTrain.placeCar", "trayId": "tray3", @@ -614,65 +704,9 @@ { "type": "draw.fromHomeOffice" }, - { - "type": "card.discard", - "cardId": "c40", - "toSlot": 1 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 1 - }, - { - "type": "card.discard", - "cardId": "c57", - "toSlot": 1 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 1 - }, - { - "type": "card.discard", - "cardId": "c69", - "toSlot": 1 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, { "type": "card.play", - "cardId": "c136", + "cardId": "c124", "placement": { "row": 0, "col": -3 @@ -683,127 +717,47 @@ "type": "draw.end" }, { - "type": "newTrain.placeCar", + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", "trayId": "tray2", - "carType": "coach", - "loaded": true + "to": { + "row": -1, + "col": -3 + }, + "reverse": true }, { - "type": "newTrain.placeCar", + "type": "switch.dropCars", "trayId": "tray2", - "carType": "boxcar", - "loaded": true + "count": 1 }, { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c36", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c40", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c57", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c62", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": 0, + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": -1, "col": 0 }, - "index": 0 + "reverse": false + }, + { + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 + }, + { + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 + }, + { + "type": "switch.end" }, { "type": "loadUnload.end" @@ -830,174 +784,15 @@ { "type": "freightAgent.stockOutbound", "at": { - "row": 0, + "row": -1, "col": 0 }, - "carType": "coach" + "carType": "reefer" }, { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c36", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c40", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c57", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c62", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c36", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "boxcar", - "loaded": true - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c40", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c57", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "porter.detrain", + "type": "laborer.startLoad", "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "porter.detrain", - "at": { - "row": 0, + "row": -1, "col": 0 } }, @@ -1009,254 +804,362 @@ "option": "freightAgent" }, { - "type": "freightAgent.clearInbound", + "type": "freightAgent.stockOutbound", "at": { - "row": 0, + "row": -1, "col": 0 }, - "index": 0 + "carType": "reefer" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 0 + }, + "box": 0 }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "draw" + "option": "switch" }, { - "type": "draw.fromDepartment", - "slot": 2 + "type": "switch.end" }, { - "type": "card.discard", - "cardId": "c62", - "toSlot": 2 - }, - { - "type": "draw.end" + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 0 + }, + "box": 1 }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "draw" + "option": "switch" }, { - "type": "draw.fromDepartment", - "slot": 2 + "type": "switch.end" }, { - "type": "card.discard", - "cardId": "c36", - "toSlot": 2 - }, - { - "type": "draw.end" + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 0 + }, + "box": 2 }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "draw" + "option": "switch" }, { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c40", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c57", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c62", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c36", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c40", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c57", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c62", - "toSlot": 2 - }, - { - "type": "draw.end" + "type": "switch.end" }, { "type": "newTrain.placeCar", - "trayId": "tray2", + "trayId": "tray3", "carType": "coach", "loaded": false }, { "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "boxcar", - "loaded": true + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "laborer.startLoad", + "at": { + "row": -1, + "col": 0 + } }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "draw" + "option": "freightAgent" }, { - "type": "draw.fromDepartment", - "slot": 2 + "type": "freightAgent.stockOutbound", + "at": { + "row": -1, + "col": 0 + }, + "carType": "reefer" }, { - "type": "card.discard", - "cardId": "c36", - "toSlot": 2 + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + }, + "trayId": "tray3" }, { - "type": "draw.end" + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + }, + "trayId": "tray3" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 0 + }, + "box": 0 }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "draw" + "option": "switch" }, { - "type": "draw.fromDepartment", - "slot": 2 + "type": "switch.end" }, { - "type": "card.discard", - "cardId": "c40", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "porter.board", + "type": "laborer.advanceLoad", "at": { - "row": 0, + "row": -1, "col": 0 - } + }, + "box": 1 }, { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 - } + "type": "loadUnload.end" }, { - "type": "porter.board", + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", "at": { - "row": 0, + "row": -1, "col": 0 - } + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" }, { "type": "loadUnload.end" diff --git a/public/replays/seed-927523.json b/public/replays/seed-927523.json index ebb45b9..58307e9 100644 --- a/public/replays/seed-927523.json +++ b/public/replays/seed-927523.json @@ -1,7 +1,7 @@ { "seed": 927523, - "title": "12 Revenue · seed 927523", - "note": "12 Revenue over 5 Days · 2 train(s) on the timetable · 20 cards down · no collisions · played by developer", + "title": "11 Revenue · seed 927523", + "note": "11 Revenue over 5 Days · 2 train(s) on the timetable · 21 cards down · no collisions · played by developer", "rules": { "startingHand": "threeRandom", "revenue": { @@ -320,21 +320,24 @@ "at": { "row": 0, "col": 0 - } + }, + "trayId": "tray3" }, { "type": "porter.board", "at": { "row": 0, "col": 0 - } + }, + "trayId": "tray3" }, { "type": "porter.detrain", "at": { "row": 0, "col": 0 - } + }, + "trayId": "tray3" }, { "type": "loadUnload.end" @@ -589,21 +592,24 @@ "at": { "row": 0, "col": 0 - } + }, + "trayId": "tray2" }, { "type": "porter.board", "at": { "row": 0, "col": 0 - } + }, + "trayId": "tray2" }, { "type": "porter.detrain", "at": { "row": 0, "col": 0 - } + }, + "trayId": "tray2" }, { "type": "loadUnload.end" @@ -951,36 +957,38 @@ "at": { "row": 0, "col": 0 - } + }, + "trayId": "tray3" }, { "type": "porter.board", "at": { "row": 0, "col": 0 - } - }, - { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 - } + }, + "trayId": "tray3" }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "freightAgent" + "option": "draw" }, { - "type": "freightAgent.clearInbound", - "at": { - "row": 0, - "col": 0 + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c162", + "placement": { + "row": -2, + "col": 2 }, - "index": 0 + "variant": 1 + }, + { + "type": "draw.end" }, { "type": "loadUnload.end" @@ -1179,6 +1187,12 @@ "carType": "coach", "loaded": false }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, { "type": "laborer.advanceLoad", "at": { @@ -1236,7 +1250,58 @@ "option": "switch" }, { - "type": "switch.end" + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 1 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 1 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 1 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": false }, { "type": "laborer.advanceLoad", diff --git a/src/engine/advance.ts b/src/engine/advance.ts index a5eb5f9..f4b9e7e 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -39,7 +39,7 @@ import type { GameEvent } from './events.ts'; import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts'; import { legalActions } from './legal.ts'; import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts'; -import { coordKey, freshTurns, playerAtSeat, playerLeftOf, subdivisions, totalRevenue, turnOf } from './state.ts'; +import { coordKey, freshTurns, playerAtSeat, playerLeftOf, pooled, subdivisions, totalRevenue, turnOf } from './state.ts'; export type AdvanceResult = { events: GameEvent[]; @@ -959,9 +959,11 @@ function collide( consist: [...tray.consist], }); // Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification. + // `pooled` because a car reaching a yard is back in the common supply: the load's origin stamp + // (state.ts) belongs to the load, not to the car that happened to be carrying it. for (const car of tray.consist) { - if (car.type === 'caboose') s.yards.divisionYard.push(car); - else s.yards.classificationYard.push(car); + if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car)); + else s.yards.classificationYard.push(pooled(car)); } s.trays.delete(id); s.freeTrays.push(id); @@ -1031,9 +1033,10 @@ function retireTrain( side: Direction, events: GameEvent[], ): void { + // `pooled` — see `trainsDestroyed` above; a load's origin stamp does not survive the yard. for (const car of tray.consist) { - if (car.type === 'caboose') s.yards.divisionYard.push(car); - else s.yards.classificationYard.push(car); + if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car)); + else s.yards.classificationYard.push(pooled(car)); } s.trays.delete(id); s.freeTrays.push(id); diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 8a45b13..b5af50f 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -59,6 +59,7 @@ import { cutTowards, isOperationalRail, playerAtSeat, + pooled, railFacingOf, seatOf, spaceOn, @@ -388,17 +389,11 @@ export function canStartLoad(f: Facility): boolean { } /** §9.2 — boarding needs a loaded coach in a green slot and a train with an empty coach. */ -export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): boolean { +export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord, trayId?: TrayId): boolean { const f = facilityAt(s, player, at); if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false; if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return false; - // §7 — a train whose card refuses passenger work, or which is not booked to stop here, is not a - // train these passengers can board however many empty coaches it is carrying. - return trainAtOfficeWith( - s, player, - (c) => c.type === 'coach' && !c.loaded, - (t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t), - ); + return passengerWork(s, player, 'board', trayId) !== null; } /** @@ -413,29 +408,54 @@ export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): bool * de-training MINTED a coach: the loaded one went to the red box and a new empty one appeared in the * train. Measured at 1.29 cars a game created out of nothing across the two inbound paths. */ -export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord): boolean { +export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord, trayId?: TrayId): boolean { const f = facilityAt(s, player, at); if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false; if (f.inboundBox.length >= f.capacity.inbound) return false; if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return false; - return trainAtOfficeWith( - s, player, - (c) => c.type === 'coach' && c.loaded, - (t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t), - ); + return passengerWork(s, player, 'detrain', trayId) !== null; } -function trainAtOfficeWith( +/** + * WHICH TRAIN, AND WHICH COACH ON IT — the one answer `check`, `execute` and the reducer all use. + * + * TWO PLAYTEST BUGS SHARED ONE CAUSE HERE. Reported against v0.4.9d: "operating two trains in a + * station, the select button does not work — regardless of which you pick, it is always one train, + * not the other". `porter.board` carried no tray at all, so `check` asked whether SOME train at the + * Office had an empty coach and the reducer then walked `adOccupancy` and filled the first one it + * found. The two were not even asking the same question: `check` skipped a train whose card refuses + * passenger work and the reducer did not, so a Military train could be boarded as long as some other + * train at the platform was eligible. The intent now names its tray (`intents.ts`) and this is the + * one place that resolves it. + * + * And "passengers just boarded cannot be immediately unloaded": a coach carries the district that + * filled it (`RollingStock.origin`), and a homegrown coach is not a coach these passengers may + * alight from — they have to be carried to a different Office Area first. + * + * `trayId` absent means "any eligible train", which is what every intent recorded before this + * existed meant, so an old save replays unchanged. + */ +function passengerWork( s: GameState, player: PlayerIndex, - pred: (c: RollingStock) => boolean, - trayOk: (t: CrewTray) => boolean = () => true, -): boolean { + dir: 'board' | 'detrain', + trayId?: TrayId, +): { trayId: TrayId; coachIndex: number } | null { const area = areaOf(s, player); - return area.adOccupancy.some((id) => { - const t = s.trays.get(id); - return !!t && trayOk(t) && t.consist.some(pred); - }); + const seat = seatOf(s, player); + const wanted = (c: RollingStock): boolean => + c.type === 'coach' && (dir === 'board' ? !c.loaded : c.loaded && c.origin !== seat); + for (const id of area.adOccupancy) { + if (trayId !== undefined && id !== trayId) continue; + const tray = s.trays.get(id); + if (!tray) continue; + // §7 — a train whose card refuses passenger work, or which is not booked to stop here, is not a + // train these passengers can board however many empty coaches it is carrying. + if (refusesPassengers(tray) || refusesThisOffice(s, player, tray)) continue; + const coachIndex = tray.consist.findIndex(wanted); + if (coachIndex >= 0) return { trayId: id, coachIndex }; + } + return null; } // --------------------------------------------------------------------------- @@ -570,12 +590,30 @@ function passengerRefusal( player: PlayerIndex, at: GridCoord, dir: 'board' | 'detrain', + trayId?: TrayId, ): RejectionCode { const area = areaOf(s, player); - const trains = area.adOccupancy.map((id) => s.trays.get(id)).filter((t): t is CrewTray => !!t); + const trains = area.adOccupancy + .filter((id) => trayId === undefined || id === trayId) + .map((id) => s.trays.get(id)) + .filter((t): t is CrewTray => !!t); if (trains.length > 0 && trains.every((t) => refusesThisOffice(s, player, t))) return 'NOT_A_TERMINAL'; if (trains.length > 0 && trains.every(refusesPassengers)) return 'NO_PASSENGER_WORK'; if (trains.length === 0) return 'NO_TRAIN_AT_OFFICE'; + /** + * EVERY LOADED COACH ABOARD BOARDED HERE — so the refusal is the district rule, not "no loaded + * coach". Told apart because the two read as opposite situations to a player: one is an empty + * train, the other is a train full of passengers who have not been anywhere yet. + */ + if ( + dir === 'detrain' && + trains.some((t) => t.consist.some((c) => c.type === 'coach' && c.loaded)) && + trains.every((t) => + t.consist.every((c) => !(c.type === 'coach' && c.loaded) || c.origin === seatOf(s, player)), + ) + ) { + return 'LOADED_IN_THIS_DISTRICT'; + } /** * A TRAIN IS STANDING THERE, so say what is actually missing. @@ -981,7 +1019,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo if (!f) return 'NO_SUCH_FACILITY'; if (f.porters < 1) return 'NO_PORTERS_HERE'; if (portersLeft(f) < 1) return 'RESOURCE_SPENT'; - return canBoard(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'board'); + if (i.trayId !== undefined && !s.trays.has(i.trayId)) return 'NO_SUCH_TRAY'; + return canBoard(s, player, i.at, i.trayId) ? null : passengerRefusal(s, player, i.at, 'board', i.trayId); } case 'porter.detrain': { @@ -990,7 +1029,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo if (!f) return 'NO_SUCH_FACILITY'; if (f.porters < 1) return 'NO_PORTERS_HERE'; if (portersLeft(f) < 1) return 'RESOURCE_SPENT'; - return canDetrain(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'detrain'); + if (i.trayId !== undefined && !s.trays.has(i.trayId)) return 'NO_SUCH_TRAY'; + return canDetrain(s, player, i.at, i.trayId) ? null : passengerRefusal(s, player, i.at, 'detrain', i.trayId); } case 'laborer.startLoad': { @@ -1026,6 +1066,19 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo if (laborersLeft(f) < 1) return 'RESOURCE_SPENT'; const car = f.industryTrack.cars[i.carIndex]; if (!car || !car.loaded) return 'WRONG_CAR_TYPE'; + /** + * A LOAD MAY NOT BE BROKEN IN THE DISTRICT THAT MADE IT (Jesse's ruling, v0.4.9e). + * + * Reported from playtesting v0.4.9d: "Freight House: boxcars loaded cannot be immediately + * unloaded." They could — a Freight House permits both directions, so the car its own Laborers + * had just loaded was standing on its own track, loaded, with an empty of that type in the + * yard, and every gate below said yes. Full Revenue at both ends for a load that never moved. + * + * The rule is district-wide and permanent, not "not at this facility" and not "not this + * Stage": the stamp says which Office Area made the load, and it never expires. Traffic runs + * BETWEEN districts, which is what the lockout pairs in `content.ts` exist to force. + */ + if (car.origin === seatOf(s, player)) return 'LOADED_IN_THIS_DISTRICT'; /** * §9.3 — "*Requirements: a load on the industry's track AND AN EMPTY CAR OF THAT TYPE IN THE * DIVISION YARD. The first Laborer replaces the load with an empty car of that type.*" @@ -1617,17 +1670,23 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { * `passengerPerCoach` (`content.ts`). Half a passenger movement is half the work, and the rate * is named per COACH because a Porter handles exactly one coach per action. */ - case 'porter.board': + case 'porter.board': { + // `check` has already established there is one; resolving it HERE, once, is what stops the + // reducer from finding a different train than the one the rules were tested against. + const work = passengerWork(s, player, 'board', i.trayId)!; return [ - { type: 'passengersBoarded', player, at: i.at }, + { type: 'passengersBoarded', player, at: i.at, ...work }, ...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'boarding'), ]; + } - case 'porter.detrain': + case 'porter.detrain': { + const work = passengerWork(s, player, 'detrain', i.trayId)!; return [ - { type: 'passengersDetrained', player, at: i.at }, + { type: 'passengersDetrained', player, at: i.at, ...work }, ...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'detraining'), ]; + } case 'laborer.startLoad': { const f = facilityAt(s, player, i.at)!; @@ -1956,9 +2015,14 @@ export function reduce(s: GameState, e: GameEvent): void { const card = area.grid.get(coordKey(e.to)); if (tray && card) { tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length); - const track = card.facility?.industryTrack; - if (track) track.cars.push(...e.stock); - else card.standing.push(...e.stock); + // `carsOn` is the one function that knows WHERE cars stand on a given card — an industry + // track for a freight facility, the card itself for everything else. Written out longhand + // here it was a second copy of that rule, and the copy was wrong for a Passenger Facility: + // it has an `industryTrack` too (an empty one, `setup.ts`), so a cut pushed into an Office + // would have landed somewhere `carsOn` cannot see — cars on the board that no train can + // couple and no walk is blocked by. `check` refuses a non-freight target, so this never + // fired; a trap that needs another rule to stay unsprung is still a trap. + carsOn(card).push(...e.stock); } turnOf(s, e.player).movesRemaining -= 1; spendCard(s, e.player, e.cardId); @@ -2049,7 +2113,8 @@ export function reduce(s: GameState, e: GameEvent): void { const f = facilityAt(s, e.player, e.at)!; const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded); if (idx >= 0) f.inboundBox.splice(idx, 1); - s.yards.classificationYard.push(e.stock); + // `pooled` — a car back in a yard is back in the common supply, carrying nothing (state.ts). + s.yards.classificationYard.push(pooled(e.stock)); turnOf(s, e.player).freightAgentUsed = true; break; } @@ -2064,7 +2129,7 @@ export function reduce(s: GameState, e: GameEvent): void { const idx = box.findIndex((c) => c.type === e.stock.type); if (idx >= 0) box.splice(idx, 1); } - s.yards.classificationYard.push(e.stock); + s.yards.classificationYard.push(pooled(e.stock)); turnOf(s, e.player).freightAgentUsed = true; break; } @@ -2132,19 +2197,28 @@ export function reduce(s: GameState, e: GameEvent): void { break; } + /** + * THE TRAIN AND THE COACH THE PLAYER PICKED, not "the first one on the A/D tracks". + * + * This used to walk `adOccupancy` and fill the first empty coach it met, which is why two trains + * standing at one station both answered to whichever chip was clicked (v0.4.9d playtest), and + * why it could fill a coach on a train whose card refuses passenger work — `check` skipped such + * a train and the reducer did not. `e.trayId`/`e.coachIndex` are exactly what `passengerWork` + * resolved for `check`, carried on the event rather than looked up again here. + */ case 'passengersBoarded': { const f = facilityAt(s, e.player, e.at)!; - const area = areaOf(s, e.player); const idx = f.outboundBox.findIndex((c) => c.type === 'coach' && c.loaded); const loaded = f.outboundBox.splice(idx, 1)[0]!; - for (const id of area.adOccupancy) { - const tray = s.trays.get(id); - const ci = tray?.consist.findIndex((c) => c.type === 'coach' && !c.loaded) ?? -1; - if (tray && ci >= 0) { - s.yards.classificationYard.push(tray.consist[ci]!); - tray.consist[ci] = loaded; - break; - } + const tray = s.trays.get(e.trayId); + if (tray && tray.consist[e.coachIndex]) { + s.yards.classificationYard.push(pooled(tray.consist[e.coachIndex]!)); + /** + * Stamped with the district that filled it — the chip turned upside down in the tray. These + * passengers may not alight anywhere in this Office Area; the train has to carry them to a + * different one. See `RollingStock.origin` in state.ts. + */ + tray.consist[e.coachIndex] = { ...loaded, origin: seatOf(s, e.player) }; } f.usedThisStage.porters += 1; break; @@ -2152,21 +2226,18 @@ export function reduce(s: GameState, e: GameEvent): void { case 'passengersDetrained': { const f = facilityAt(s, e.player, e.at)!; - const area = areaOf(s, e.player); - for (const id of area.adOccupancy) { - const tray = s.trays.get(id); - const ci = tray?.consist.findIndex((c) => c.type === 'coach' && c.loaded) ?? -1; - if (tray && ci >= 0) { - // The empty coach comes OUT OF THE DIVISION YARD, as §9.2 says. It used to be conjured, - // which minted a coach on every de-training. Throws now, for the reason in `unloadBegan`. - const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded); - if (yi < 0) throw new Error('passengersDetrained: no empty coach in the Division Yard'); - const empty = s.yards.divisionYard.splice(yi, 1)[0]!; - refillDivisionYardIfEmpty(s); - f.inboundBox.push(tray.consist[ci]!); - tray.consist[ci] = empty; - break; - } + const tray = s.trays.get(e.trayId); + if (tray && tray.consist[e.coachIndex]) { + // The empty coach comes OUT OF THE DIVISION YARD, as §9.2 says. It used to be conjured, + // which minted a coach on every de-training. Throws now, for the reason in `unloadBegan`. + const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded); + if (yi < 0) throw new Error('passengersDetrained: no empty coach in the Division Yard'); + const empty = s.yards.divisionYard.splice(yi, 1)[0]!; + refillDivisionYardIfEmpty(s); + // The arriving coach goes into the red box carrying nothing: the journey it was stamped for + // is over, and the box feeds straight back to a yard through the Freight Agent. + f.inboundBox.push(pooled(tray.consist[e.coachIndex]!)); + tray.consist[e.coachIndex] = empty; } f.usedThisStage.porters += 1; break; @@ -2202,8 +2273,14 @@ export function reduce(s: GameState, e: GameEvent): void { workTrack(f)[workTrack(f).length - 1] = null; const ci = f.industryTrack.cars.findIndex((c) => !c.loaded && c.type === e.carType); if (ci >= 0) { - s.yards.classificationYard.push(f.industryTrack.cars[ci]!); - f.industryTrack.cars[ci] = { type: e.carType, loaded: true }; + s.yards.classificationYard.push(pooled(f.industryTrack.cars[ci]!)); + /** + * THE LOAD IS STAMPED WITH THE DISTRICT THAT MADE IT — the chip turned upside down in the + * tray. `laborer.beginUnload` refuses a car stamped with the district it is standing in, so + * this load now has to leave the Office Area on a train before anyone can break it. See + * `RollingStock.origin` in state.ts for the rule and why it is a seat. + */ + f.industryTrack.cars[ci] = { type: e.carType, loaded: true, origin: seatOf(s, e.player) }; } f.usedThisStage.laborers += 1; break; diff --git a/src/engine/content.ts b/src/engine/content.ts index 08bf686..4a470b1 100644 --- a/src/engine/content.ts +++ b/src/engine/content.ts @@ -231,42 +231,51 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [ { kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 6 }, { kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 }, /** - * BOTH DIRECTIONS, per the card reference — this was `outbound` and it contradicted the rules. + * OUTBOUND ONLY. A Refinery ships oil out and receives nothing; reported from playtesting and + * confirmed by Jesse (v0.4.9e): "only ships out tanks, does not receive anything". * - * `card-reference.md`: "Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4", and in prose — "'Freight - * House' is not a card. It is the collective term for a freight facility that loads *and* unloads - * — the Grocer's Warehouse and the Oil Refinery." §9.3's "Passenger Facilities and Freight Houses - * permit cars to move each direction" therefore names exactly these two, and the engine had both - * of them one-way. + * It was briefly `flow: 'both'`, on the reading that "'Freight House' is not a card — it is the + * collective term for a freight facility that loads *and* unloads, the Grocer's Warehouse and the + * Oil Refinery", which made §9.3's "Passenger Facilities and Freight Houses permit cars to move + * each direction" name exactly those two. That premise is dead: `glossary.md` and + * `rules-v0.2.md` corrected the Freight House to a card of its own, dealt like any other industry, + * so §9.3 names the Freight House and nothing else, and card-reference.md's "Both" column loses + * the only argument it had. * - * The consequence was silent: `usableGrant` drops a Modifier's grant on a direction its host - * cannot use, so every +1 inbound beside a Refinery went nowhere. - * - * The base numbers stay at the engine's own scale (1 per direction it allows) rather than the card - * reference's 2/2 — every industry here is scaled down the same way, Mine Tipple included, and - * raising one of them alone would be a balance change rather than a correction. Flagged in TODO. + * The card set says the same thing on its own. All three Refinery modifiers — Pipelines, Oil + * Depot, Viscosity Breakers — grant `+1 outbound`; a two-way Refinery would be the one industry in + * the game with no way to raise the direction it is supposed to use half its capacity on. + * `StationMaster-Home-Deck-v0.4.5.md` prints it "Outbound, 1 out / 0 in". */ - { kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 }, + { kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 }, { kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 }, { kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 }, /** - * BOTH DIRECTIONS — see the Refinery above; "Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3". + * INBOUND ONLY — the mirror of the Refinery above, and the same correction. Reported from + * playtesting and confirmed by Jesse (v0.4.9e): "Grocer's Warehouse should be receive only, does + * not ship anything out". `StationMaster-Home-Deck-v0.4.5.md` prints it "Inbound, 0 out / 1 in". * - * Reported from play: "grocer's warehouse didn't get extra outbound slot for truck dock." It could - * not: the Truck Dock printed +1 outbound at the time and this was `flow: 'inbound'`, so the grant - * was dropped on a direction the facility did not have. The same trap still swallows an Ice House - * set beside a Grocer's that has been left one-way. - * - * `TODO.md` had previously recorded this as "checked, and there is no bug" on the reasoning that a - * Grocer's is inbound-only. That premise was the bug. + * THE ICE HOUSE IS THEREFORE A DEAD CARD BESIDE A GROCER'S, and that is the design, not an + * oversight: `usableGrant` drops a Modifier's grant on a direction its host cannot use, and the + * Home Deck sheet says so outright — "a bonus beside a facility that cannot use its direction is + * not usable", naming the Truck Dock's inbound bonus beside the outbound-only Packing Sheds as the + * other example. The Truck Dock (+1 inbound) and Local Small Groceries (+1 Laborer) are the two + * that do work here. */ - { kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 }, + { kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 }, ]; /** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */ export const FREIGHT_PROFILES = INDUSTRY_PROFILES; -/** §9.3 — the collective term for an industry that both loads and unloads. */ +/** + * §9.3 — "Passenger Facilities and Freight Houses permit cars to move each direction". + * + * ONE CARD ANSWERS TO THIS NOW: the Freight House itself. It was briefly three, while the Refinery + * and the Grocer's Warehouse were also `both` on a reading of the term the glossary has since + * corrected — a Freight House is a card, not a collective noun. Kept as a predicate on `flow` + * rather than a comparison against the kind, because it is the DIRECTION §9.3 is talking about. + */ export function isFreightHouse(p: IndustryProfile): boolean { return p.flow === 'both'; } diff --git a/src/engine/events.ts b/src/engine/events.ts index 6e8d1ab..65a53ed 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -163,8 +163,16 @@ export type GameEvent = | { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId } | { type: 'clearanceGiven'; trainId: TrayId; allow: boolean } // -- load / unload - | { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord } - | { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord } + /** + * `trayId` and `coachIndex` name the TRAIN and the COACH the Porter worked, rather than leaving the + * reducer to find them again — the same lesson as `unloadBegan`'s `carIndex` below. Re-deriving + * "the first empty coach on the first train at the Office" is how two trains standing at one + * station both answered to one roster chip (v0.4.9d playtest), and how a coach the player had not + * chosen got filled. Required, not optional: an event is a fact, and a fact that has to be looked + * up against live state cannot render standalone in a replay. + */ + | { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord; trayId: TrayId; coachIndex: number } + | { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord; trayId: TrayId; coachIndex: number } | { type: 'loadStarted'; player: PlayerIndex; at: GridCoord; carType: CarType } | { type: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number } | { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType } diff --git a/src/engine/intents.ts b/src/engine/intents.ts index 6210a5a..568c489 100644 --- a/src/engine/intents.ts +++ b/src/engine/intents.ts @@ -101,8 +101,18 @@ export type Intent = | { type: 'maneuver.flyingSwitch'; cardId: CardId; trayId: TrayId; count: number; to: GridCoord } | { type: 'redFlag.play' } // -- Load/Unload Phase (§9) - | { type: 'porter.board'; at: GridCoord } - | { type: 'porter.detrain'; at: GridCoord } + /** + * `trayId` names the train the Porter works — reported from playtesting v0.4.9d as "operating two + * trains in a station, the select button does not work: regardless of which you pick, it is always + * one train, not the other". It was: neither intent carried a train, so the reducer took the first + * one on the A/D tracks and the roster chip the player had clicked changed nothing but the drawing. + * + * OPTIONAL, like `switch.move`'s `via` and for the same reason: intents are the canonical record + * `undo` and every save replay against, and absent means what it has always meant — the first + * eligible train at the Office. + */ + | { type: 'porter.board'; at: GridCoord; trayId?: TrayId } + | { type: 'porter.detrain'; at: GridCoord; trayId?: TrayId } /** §9.3 — the first Laborer step: Green Loading Slot -> MEN. */ | { type: 'laborer.startLoad'; at: GridCoord } | { type: 'laborer.advanceLoad'; at: GridCoord; box: number } @@ -199,6 +209,17 @@ export type RejectionCode = * the Laborers can move it out of the box. */ | 'NO_EMPTY_CAR_SPOTTED' + /** + * §9 (Jesse's ruling, v0.4.9e) — freight or passengers loaded anywhere in an Office Area may not + * be unloaded anywhere in that same Office Area. The load has to be carried out of the district by + * a train first; a Freight House may not break the load it just made, and passengers may not + * detrain at the platform they boarded from. + * + * Distinct from the other refusals because the car IS loaded, the Laborer IS free and the boxes + * ARE clear: the only thing wrong with it is where it came from, and a player looking at a loaded + * boxcar standing on their own industry track deserves to be told that rather than "wrong car". + */ + | 'LOADED_IN_THIS_DISTRICT' | 'NO_PORTERS_HERE' | 'NO_PASSENGERS_WAITING' | 'NO_EMPTY_COACH' diff --git a/src/engine/legal.ts b/src/engine/legal.ts index 2bba97f..e40573e 100644 --- a/src/engine/legal.ts +++ b/src/engine/legal.ts @@ -288,9 +288,21 @@ function loadUnloadCandidates(s: GameState, player: PlayerIndex): Intent[] { const out: Intent[] = []; const area = areaOf(s, player); + /** + * ONE OPTION PER TRAIN STANDING AT THE OFFICE, not one per square. + * + * Reported from playtesting v0.4.9d: "operating two trains in a station, the select button does + * not work — regardless of which you pick, it is always one train, not the other". There was only + * ever ONE `board passengers` button, because the intent carried no train; the roster chip chose + * what the board drew and nothing else. Now each eligible train is its own candidate, and `check` + * filters the ones whose card, consist or passengers rule them out. + */ + const traysHere = area.adOccupancy.filter((id) => s.trays.has(id)); for (const coord of facilityCoords(s, player)) { - out.push({ type: 'porter.board', at: coord }); - out.push({ type: 'porter.detrain', at: coord }); + for (const trayId of traysHere) { + out.push({ type: 'porter.board', at: coord, trayId }); + out.push({ type: 'porter.detrain', at: coord, trayId }); + } const f = area.grid.get(`${coord.row},${coord.col}`)?.facility; if (f) { out.push({ type: 'laborer.startLoad', at: coord }); diff --git a/src/engine/state.ts b/src/engine/state.ts index ff29874..2ca4c78 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -55,7 +55,47 @@ export function coordKey(c: GridCoord): string { // --------------------------------------------------------------------------- /** §2.2 — a coloured car is loaded, a white car is empty. */ -export type RollingStock = { type: CarType; loaded: boolean }; +export type RollingStock = { + type: CarType; + loaded: boolean; + /** + * WHICH OFFICE AREA MADE THIS LOAD — the physical game's chip turned upside down in the tray. + * + * Reported from playtesting v0.4.9d as two bugs with one cause: a boxcar loaded at a Freight + * House could be unloaded at that same Freight House on the next Laborer action, and passengers + * who had just boarded could be detrained again before the train turned a wheel. Both paid full + * Revenue at each end for a load that never went anywhere. + * + * Jesse's rule (v0.4.9e): freight or passengers loaded anywhere in an Office Area may not be + * unloaded ANYWHERE in that same Office Area — not at another facility, not in a later Stage. + * They have to be carried by a train to a different Office Area. So the stamp is the SEAT, which + * is what an Office Area belongs to (Employee Rotation moves players between chairs; the district + * stays with the chair), and it never expires. + * + * A SEAT, NOT A PLAYER, and undefined rather than -1 for "no origin": the Division Yard opens with + * loaded cars and loaded coaches that were made up off-Division (`ROLLING_STOCK_SUPPLY`), and + * those are exactly the inbound traffic a solitaire district lives on. A sentinel inside + * `SeatIndex`'s own value range is not a sentinel — see `card.play`'s `node` in intents.ts. + * + * Stripped by `pooled` whenever a car goes back to a yard: the stamp belongs to the LOAD, and a + * car returning to the common supply is carrying nothing. + */ + origin?: SeatIndex; +}; + +/** + * A car returning to the common pool — the Division or Classification Yard — with its load's origin + * stamp taken off. + * + * Every yard push goes through this. A loaded car CAN reach a yard still loaded (a train retires at + * a Division Point with freight aboard, `advance.ts`), and without this it would carry a stamp from + * a district it left several Days ago into whatever train is made up from it next. + */ +export function pooled(car: RollingStock): RollingStock { + if (car.origin === undefined) return car; + const { origin: _origin, ...rest } = car; + return rest; +} // --------------------------------------------------------------------------- // Track and Office Area diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index 32fb5ab..430993d 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -17,7 +17,7 @@ import { MAX_CONSIST } from '../engine/content.ts'; import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts'; -import type { GameState, GridCoord, PlayerIndex, RollingStock, TrayId } from '../engine/state.ts'; +import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts'; import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts'; import type { GameEvent } from '../engine/events.ts'; @@ -34,11 +34,20 @@ export function clockTime(stage: number): string { return CLOCK[stage - 1] ?? `Stage ${stage}`; } -export function carLabel(c: RollingStock): string { +/** + * `homeSeat` is the district the page is being drawn for. Give it, and a load THIS district made + * says so — the printed game's answer is to turn the chip upside down in the tray, and this is the + * screen's. A load may not be broken in the Office Area that made it (state.ts `RollingStock.origin`), + * so "loaded here" is the difference between a boxcar worth switching and one that has to leave the + * district first. Omit it and the label is what it always was, which is what the replay viewers and + * the history lines want: they describe a board, not a seat's view of one. + */ +export function carLabel(c: RollingStock, homeSeat?: SeatIndex): string { // A caboose carries the crew, not freight, so "loaded caboose" is nonsense on the page even // though the supply marks every caboose loaded. Name it plainly. if (c.type === 'caboose') return 'caboose'; - return `${c.loaded ? 'loaded' : 'empty'} ${c.type}`; + const label = `${c.loaded ? 'loaded' : 'empty'} ${c.type}`; + return homeSeat !== undefined && c.origin === homeSeat ? `${label} (loaded here)` : label; } export function carsLabel(cars: RollingStock[]): string { diff --git a/src/sim/view.ts b/src/sim/view.ts index 3a5172f..16f9bbb 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -560,6 +560,7 @@ const FACILITY_NAMES: Record = { function facilityView( card: { geometry: { kind: string; facility?: string }; facility: unknown; modifiers?: string[] }, officeName: string, + viewerSeat: SeatIndex, ): FacilityView | null { const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility; // Passenger facilities were excluded entirely, so the Office's green and red slots never @@ -581,7 +582,9 @@ function facilityView( maw: (f.menAtWork ?? []).map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)), red: f.inboundBox.map(carLabel), redCap: f.capacity.inbound, - track: f.industryTrack.cars.map(carLabel), + // Marked when this district made the load: the spotted car is exactly where a player is looking + // when they ask why the Laborer will not unload it. + track: f.industryTrack.cars.map((c) => carLabel(c, viewerSeat)), laborers: `${laborersLeft(f)}/${f.laborers}`, porters: `${portersLeft(f)}/${f.porters}`, canFinish: canFinishHere(f), @@ -672,7 +675,9 @@ function trainsOnCard(s: GameState, viewerSeat: SeatIndex, key: string): CellVie out.push({ trayId: id, label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`, - cars: t.consist.map(carLabel), + // A coach filled at THIS Office reads "loaded coach (loaded here)" — those passengers may not + // alight in the district that boarded them, and the tray is where a player looks for that. + cars: t.consist.map((c) => carLabel(c, viewerSeat)), engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)), facing: railFacingOf(t), what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t), @@ -728,6 +733,11 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string { return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : ''); } +/** " onto Train 8", or nothing at all when the intent names no train (an old save, or one train). */ +function onto(s: GameState, trayId: string | undefined, joiner: string): string { + return trayId === undefined ? '' : `${joiner}${trainName(s, trayId)}`; +} + /** One readable line for a single intent. */ export function describeIntent(s: GameState, i: Intent): string { // X,Y — east/west then north/south, not the internal row/col storage order. @@ -907,10 +917,16 @@ export function describeIntent(s: GameState, i: Intent): string { return `advance load in box ${i.box} at ${at(i.at)}`; case 'laborer.beginUnload': return `begin unloading car ${i.carIndex} at ${at(i.at)}`; + /** + * NAME THE TRAIN. The action list drops duplicate labels within a crew, and with two trains + * standing at one station "board passengers at (0,0)" describes both — which is half of why the + * v0.4.9d playtest found that picking a train changed nothing. The intent now carries the tray; + * the label has to say so or the second button is thrown away before the menu sees it. + */ case 'porter.board': - return `board passengers at ${at(i.at)}`; + return `board passengers at ${at(i.at)}${onto(s, i.trayId, ' onto ')}`; case 'porter.detrain': - return `detrain passengers at ${at(i.at)}`; + return `detrain passengers at ${at(i.at)}${onto(s, i.trayId, ' from ')}`; case 'newTrain.startExtra': { const runs = i.trainNumber % 2 === 0 ? 'east' : 'west'; if (i.atSeat === null) { @@ -1059,7 +1075,7 @@ export function snapshot( else if (g.kind === 'spaceUse') label = prettyKey(g.key); else label = geometryLabel(g.geometry); - const fv = facilityView(card as never, officeProfile(area.tier).name); + const fv = facilityView(card as never, officeProfile(area.tier).name, viewerSeat); if (fv) facilities.push(fv); cells.push({ @@ -1074,7 +1090,7 @@ export function snapshot( enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)), trains: trainsOnCard(s, viewerSeat, key), adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null, - cars: carsOn(card).map(carLabel), + cars: carsOn(card).map((c) => carLabel(c, viewerSeat)), standingWest: card.standingWest, facility: fv, }); diff --git a/test/apply.test.ts b/test/apply.test.ts index 015e09c..bfa0bdc 100644 --- a/test/apply.test.ts +++ b/test/apply.test.ts @@ -1286,9 +1286,11 @@ describe("a Modifier grants only what its host's flow can use", () => { * and saying so is what the panel is for. Losing it FOREVER was the bug: the upgrade applied * only the difference between two tiers and knew nothing about what had been discarded. * - * This used to be written against an Ice House on a Grocer's Warehouse. That case no longer - * suppresses anything, because the Grocer's is a both-direction facility — which was the other - * half of the same report. + * This used to be written against an Ice House on a Grocer's Warehouse, which suppresses again + * now that the Grocer's is inbound-only (v0.4.9e). The Office was chosen instead because the + * suppression there is TEMPORARY — an upgrade can lift it — and losing the grant forever across + * that upgrade was the bug. A Grocer's never ships, so its Ice House is suppressed permanently + * and tests nothing about the upgrade path. */ const s = game(); const area = areaOf(s, 0); @@ -1897,3 +1899,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)); + }); +}); diff --git a/test/replay.test.ts b/test/replay.test.ts index b81d085..0a1035e 100644 --- a/test/replay.test.ts +++ b/test/replay.test.ts @@ -57,8 +57,8 @@ const SAMPLES: GameEvent[] = [ { type: 'carPassed', player: 0, trayId: 't0' }, { type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' }, { type: 'clearanceGiven', trainId: 't1', allow: false }, - { type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 } }, - { type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 } }, + { type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 }, + { type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 }, { type: 'loadStarted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' }, { type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 }, { type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' }, diff --git a/test/setup.test.ts b/test/setup.test.ts index 5b7a6c3..c47e303 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -183,23 +183,30 @@ describe('card catalogue (component 1)', () => { } }); - it('identifies the both-direction industries the card reference names', () => { + it('names the Freight House and nothing else as the two-way industry', () => { /** - * `card-reference.md`: "'Freight House' is not a card. It is the collective term for a freight - * facility that loads *and* unloads — the Grocer's Warehouse and the Oil Refinery." The table - * agrees: both are "Both", and only the Power Plant is inbound-only. + * §9.3 — "Passenger Facilities and Freight Houses permit cars to move each direction". ONE card + * answers to that. * - * The engine had the Refinery as outbound-only and the Grocer's as inbound-only, so §9.3's - * "Passenger Facilities and Freight Houses permit cars to move each direction" named neither of - * them — and every Modifier grant on the missing direction was silently dropped, which is how - * "grocer's warehouse didn't get extra outbound slot for truck dock" was reported. + * This briefly asserted three. `card-reference.md` reads "'Freight House' is not a card. It is + * the collective term for a freight facility that loads *and* unloads — the Grocer's Warehouse + * and the Oil Refinery", and on that premise the Refinery and the Grocer's were both made + * `flow: 'both'`. The premise is dead: `glossary.md` and `rules-v0.2.md` corrected the Freight + * House to a card of its own, dealt like any other industry, so §9.3 names it and the table's + * "Both" column loses its only argument. * - * `freightHouse` is still in this list because the engine deals it as a CARD, which the rules say - * it is not. That is a deck-composition question, recorded in TODO.md, not something to quietly - * delete six cards over. + * Reported from playtesting v0.4.9d and confirmed by Jesse: the Refinery only ships tanks out, + * the Grocer's Warehouse only receives. `StationMaster-Home-Deck-v0.4.5.md` prints both that way, + * and so does the modifier set — all three Refinery modifiers grant outbound. */ const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind); - assert.deepEqual(houses.sort(), ['freightHouse', 'grocersWarehouse', 'refinery']); + assert.deepEqual(houses.sort(), ['freightHouse']); + const refinery = FREIGHT_PROFILES.find((f) => f.kind === 'refinery')!; + assert.equal(refinery.flow, 'outbound'); + assert.deepEqual([refinery.baseOut, refinery.baseIn], [1, 0]); + const grocers = FREIGHT_PROFILES.find((f) => f.kind === 'grocersWarehouse')!; + assert.equal(grocers.flow, 'inbound'); + assert.deepEqual([grocers.baseOut, grocers.baseIn], [0, 1]); }); it('starts every industry at one car out and one loader', () => { diff --git a/test/sim.test.ts b/test/sim.test.ts index 6746d8c..b3e02f1 100644 --- a/test/sim.test.ts +++ b/test/sim.test.ts @@ -476,8 +476,10 @@ describe('switching accomplishes something (regression)', () => { * grew faster, which is traffic rather than aimlessness, and there are two new sources of it: * Extras now start at the Division Point their NUMBER sends them to, so westbound Extras exist * at all (measured 32 west / 29 east across 60 deals, against every single one launching - * eastbound from the West Division Point before); and the Grocer's Warehouse ships as well as - * receives, so there is more switching worth doing. + * eastbound from the West Division Point before); and the Grocer's Warehouse briefly shipped as + * well as received, which was more switching worth doing. That second source is gone again in + * v0.4.9e — the Grocer's is inbound-only, as it always was on the sheet — and the ratio still + * clears the floor, so the figure is left where it is rather than re-tuned to one release. * * A crew that shuttles for its own sake would show this ratio climbing while `work` stood still. * Logged in TODO.md with the rest of the bot drift rather than quietly absorbed. @@ -1072,12 +1074,13 @@ describe('the freight figures count both halves (regression)', () => { // that on: an unload needs an inbound industry built, reachable, and a loaded car spotted at it, // and whether the bot manages all three on a given deal is luck, not the thing under test. /** - * FORTY DEALS, up from twelve, and the reason is a rules correction rather than flakiness. + * FORTY DEALS, up from twelve, and the reason was a rules correction rather than flakiness. * - * The Grocer's Warehouse is a BOTH-direction facility now — `card-reference.md` always said so — - * where the engine had it inbound-only. So the bot can ship from it as well as receive, and it - * often does: deals producing at least one completed unload went from 12 in 40 to 6 in 40, while - * unloads themselves are unharmed (30 completed across the 40 measured after the change). + * The Grocer's Warehouse was briefly a both-direction facility, so the bot shipped from it as + * well as receiving and deals producing at least one completed unload fell from 12 in 40 to 6 in + * 40. v0.4.9e put it back to inbound-only, which is what the sheet always printed. The wider + * sample is kept: the precondition it protects — that some deal in the batch actually completes + * an unload — is worth having whichever way the rule goes. * * The subject here is the INSTRUMENT — does `freightUnload` count Revenue earned rather than * unloads started — and `unloads > 0` is only the precondition that makes the comparison mean diff --git a/test/web.test.ts b/test/web.test.ts index eb46baf..a0bb4bb 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -3623,6 +3623,105 @@ describe('two crews switching are told apart', () => { }); }); +describe('two trains at one platform are told apart (v0.4.9e)', () => { + /** + * REPORTED from the v0.4.9d playtest: "operating two trains in a station — the select button does + * not work. Regardless of which you pick, it is always one train, not the other." + * + * `porter.board` and `porter.detrain` carried no tray, so there was ONE button per platform however + * many trains were standing at it, and the reducer filled whichever tray came first out of + * `adOccupancy`. Clicking a roster chip changed what the board drew and nothing else — which is + * exactly what "the select button does not work" describes. + * + * Driven through `actionMenu` rather than `legalActions` because the second half of the failure was + * at this layer: the menu collapses identical labels within a crew, and "board passengers at (0,0)" + * describes both trains. + */ + const twoAtPlatform = (): { game: Game; trays: string[] } => { + const game = newGame(4242); + const s = game.state; + const area = areaOf(s, 0); + const f = area.grid.get(`${area.officeCoord.row},${area.officeCoord.col}`)!.facility!; + // A Station's worth of platform: Porters, slots, and two fares waiting. + f.allows = { outbound: true, inbound: true }; + f.porters = 4; + f.capacity = { outbound: 2, inbound: 2 }; + f.outboundBox = [{ type: 'coach', loaded: true }, { type: 'coach', loaded: true }]; + const trays: string[] = []; + for (const trainNumber of [7, 9]) { + const id = s.freeTrays.pop()!; + s.trays.set(id, { + id, trainNumber, trainIsExtra: false, engineAt: 0, + consist: [{ type: 'coach', loaded: false }], + direction: 'east', facing: 'e', + position: { at: 'grid', seat: 0, coord: area.officeCoord }, + movesUsed: 0, + } as never); + area.adOccupancy.push(id); + trays.push(id); + } + s.clock.phase = 'loadUnload'; + s.clock.currentActor = 0; + return { game, trays }; + }; + + it('offers boarding once per train, with the train named on the button', () => { + const { game } = twoAtPlatform(); + const labels = actionMenu(game) + .direct.flatMap((g) => g.actions) + .map((a) => a.label) + .filter((l) => /^board passengers/.test(l)); + assert.equal(labels.length, 2, `expected one button per train, got ${JSON.stringify(labels)}`); + assert.ok(labels.some((l) => /Train 7/.test(l)), `no button names Train 7: ${JSON.stringify(labels)}`); + assert.ok(labels.some((l) => /Train 9/.test(l)), `no button names Train 9: ${JSON.stringify(labels)}`); + }); + + it('boards the train whose button was pressed', () => { + const { game, trays } = twoAtPlatform(); + const { options } = actionGroups(game); + const nine = options.findIndex( + (o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[1], + ); + assert.ok(nine >= 0, 'no boarding option names the second train'); + submit(game, options[nine]!); + assert.equal(game.state.trays.get(trays[1]!)!.consist[0]!.loaded, true, 'Train 9 did not get them'); + assert.equal(game.state.trays.get(trays[0]!)!.consist[0]!.loaded, false, 'Train 7 was filled instead'); + }); + + it('will not detrain the passengers it has just put aboard', () => { + // The other half of the same playtest: "passenger stations — passengers just boarded cannot be + // immediately unloaded." They could, for a Porter action and full Revenue, without the train + // moving an inch. + const { game, trays } = twoAtPlatform(); + const board = actionGroups(game).options.find( + (o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[0], + )!; + submit(game, board); + const detrains = actionGroups(game).options.filter((o) => o.type === 'porter.detrain'); + assert.equal(detrains.length, 0, 'detraining was still offered for passengers who boarded here'); + assert.equal( + check(game.state, 0, { type: 'porter.detrain', at: areaOf(game.state, 0).officeCoord, trayId: trays[0]! }), + 'LOADED_IN_THIS_DISTRICT', + ); + }); + + it('says on the coach that it was loaded here', () => { + // The printed game turns the chip upside down in the tray; this is the screen's version of that. + const { game, trays } = twoAtPlatform(); + submit(game, actionGroups(game).options.find( + (o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[0], + )!); + const f = view(game); + const office = f.cells.find((c) => c.kind === 'office')!; + const train = office.trains.find((t) => t.trayId === trays[0]); + assert.ok(train, 'the boarded train is not on the Office card'); + assert.ok( + train!.cars.some((c) => /loaded here/.test(c)), + `the coach does not say where it was loaded: ${JSON.stringify(train!.cars)}`, + ); + }); +}); + describe('the Superintendent ruling names the train it is ruling on', () => { /** * REPORTED from play: "when the Superintendent has to rule on a train to allow or hold, it should