diff --git a/CHANGELOG.md b/CHANGELOG.md index c25a3c3..727e310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,71 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie ## Unreleased +## 0.4.2 — 2026-08-13 + +### A completed run pays the whole table + +The departure Revenue used to pay 1 to the Office a train left — "it cleared YOUR section". On a +five-Office railroad that paid five separate times for one train, and paid most to whichever Office it +happened to pass first. It now pays **once, when the train runs off the end of the Division, and it +pays every player**: getting a train the length of the railroad is the shared achievement, and every +Office it crossed had to clear it to happen. + +The log says so in one line — *"Train 5 has completed its run, leaving via the Eastern Division Point +carrying 3 coaches. All players get 1 Revenue."* — and because nobody took a turn to cause it, it is +also announced on screen and given a sound of its own: two long horn notes falling away, the second +lower and quieter. It is the only cue in the game that is not somebody's action. + +Solitaire is nearly unmoved, 7.0 → 7.3 mean over 200 games, because one player's departures and +completions run at almost the same rate. **A multi-player game is a completely different shape** and +needs measuring once there is one. + +### Expedited passenger trains could never be worked, and now can + +**Reported from play: "passenger trains arrive at my Office and move on before I can load or unload."** +They did. Every coach-carrying express prints Expedite — 1/2 Crack Limited with three coaches, 5/6 The +Sparrow with two, 19 Military with two — and Expedite was implemented as a second move inside the same +Mainline Phase. Load/Unload runs *after* Mainline, so the train was always gone first. Measured over 60 +games: **Train 2 arrived 32 times and stood for a Load/Unload phase in none of them.** Trains 4 and 6 +likewise zero. The Crack Limited's own card says "stop at Terminals only", which it could never do. + +An expedited train now stands through Load/Unload and departs at the **end** of the Stage instead. Q3's +"departs the Stage it arrives" is still literally true — it does not lay over — and §8.1 still applies, +so it can be held. Coach trains standing for Load/Unload went **116 → 229** across the same 60 games; +Train 2 from 0 to 35, Train 6 from 0 to 17. + +The cost is real and is logged as drift: a train occupying an A/D track and the Office square for a +Stage is in the crew's way, so switching work fell ~16% (1.76 → 1.48 productive acts over 400 games) +and the worst game went −3 → −9 as Offices fill. That is the change doing what it is supposed to do. + +### Three silences around passenger work, all of which said nothing at all + +The refusal codes never reach a player — the menu simply does not offer an illegal action — so the +Blocked panel is the only thing that can explain a missing button, and it skipped every non-freight +facility. Its passenger section also only looked at trains carrying a *loaded* coach, so a train +arriving to **pick up** produced no line at all. + +Underneath, the engine's own answers were wrong in two ways worth fixing regardless: + +- **A Whistle Post reported `RESOURCE_SPENT`** — "all Porters already used this Stage" — to a player + who had used none. Its Office card carries a passenger facility so that an upgrade is a property + change rather than a card swap, and that facility has `porters: 0`. Now `NO_PORTERS_HERE`, and the + panel says to upgrade the Office. 15 occurrences in 60 games. +- **`NO_TRAIN_AT_OFFICE` was returned while a train was standing at the Office**, as the catch-all for + every other reason. 51 occurrences with a coach train in front of the player: 27 with nobody waiting + to travel, 24 with passengers waiting and every coach already full. Those are now + `NO_PASSENGERS_WAITING`, `NO_EMPTY_COACH`, `NO_LOADED_COACH`, `INBOUND_BOX_FULL` and + `NO_EMPTY_COACH_IN_YARD`, and each has its own line in the panel. + +The panel also now warns, in amber, when the train in front of you is expedited: *"it leaves at the END +of this Stage, so this is the only Load/Unload phase it will stand for."* + +### Replays + +`seed-6257010` died on the Expedite change — it replayed 161 of 335 intents, which on screen looks +exactly like a game that ended early. Re-recorded with `save-replay.ts`; `seed-3334899` survived +untouched and was kept. Four published replays, all verified to replay to their last intent. + ## 0.4.1 — 2026-08-13 ### §4.4's opening D12 now decides who sits where diff --git a/README.md b/README.md index ca674e4..b423f8e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ train into an occupied Subdivision. Get that wrong and two trains meet at speed. ## Status -**v0.4.1 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure, +**v0.4.2 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure, imports nothing outside itself, and never touches `Math.random`, so a static host is all it needs. - **Rules** — specified, with **three open questions** left. Thirteen gaps in the original prototype diff --git a/TODO.md b/TODO.md index 5bdcda6..cfe2a35 100644 --- a/TODO.md +++ b/TODO.md @@ -27,13 +27,26 @@ Ordered within each section by how much it is currently costing us. watch at the table: whether opening with six against a limit of three is a real decision or just bookkeeping, and whether three is the right number of each. - **One Revenue for every train that clears your section.** Worth exactly **+5.40 a game** — - departures run at 5.40 and each pays 1 — taking the bot from 2.7 to 8.0 mean, median 0 → 6.5, - wins 2/200 → 14/200. It more than doubles the score on its own and pays for traffic the player - does not have to work. **This makes the victory-target question live**: 20 over 5 Days is now - reachable largely on traffic, which is either the intent or an argument for raising the target. -- [ ] **BOT DRIFT ACROSS THIS RELEASE — three measurements, all for the rebalance pass.** Recorded - together so the pattern is visible rather than three relaxed thresholds nobody adds up: + **~~One Revenue for every train that clears your section.~~ Now: one Revenue to EVERY player + when a train completes its run.** Jesse's revision in v0.4.2. The first version paid the Office + a train departed, which on a five-Office railroad paid five separate times for one train and + paid most to whoever it passed first. It pays once now, when the train runs off the end of the + Division, and it pays the whole table — getting a train the length of the railroad is the + shared achievement, and every Office it crossed had to clear it. + Solitaire is nearly unmoved (7.0 → 7.3 mean over 200 games) because one player's departures and + completions run at almost the same rate; **in a multi-player game the shape is completely + different** and needs measuring once multiplayer exists — N players × 1 per completed run + against the old N payments per train. **The victory-target question stays live**: 20 over 5 Days + is still reachable largely on traffic, which is either the intent or an argument for raising it. +- [ ] **BOT DRIFT ACROSS THIS RELEASE — four measurements, all for the rebalance pass.** Recorded + together so the pattern is visible rather than four relaxed thresholds nobody adds up: + - **Switching work down ~16%, 1.76 → 1.48 productive acts a game** (400 games), because an + expedited train now stands at the Office for a Stage instead of passing straight through, and + a train on the A/D track and the Office square is in the crew's way. That is the change doing + its job rather than a fault — but it is drift. Collisions also went 0.05 → 0.06 and the worst + game went −3 → −9, same cause: the Office fills up. **Separately, the `work > 2` floor that + caught this had never actually been met** — it read 2.16 at 150 games and 1.76 at 400, so it + was passing on which seeds the sample happened to include. Now 400 games and a floor of 1.2. - **Track laid badly, 7% → 15%** of pieces butting a card that cannot accept them. Forced to shed on turn one, the bot would rather lay a piece than discard it; a player would discard the ones with nowhere good to go. It also means the district-size gain from the new deal is diff --git a/docs/design.md b/docs/design.md index 40d917c..24ccfe9 100644 --- a/docs/design.md +++ b/docs/design.md @@ -49,7 +49,7 @@ must do. ## Current status -**v0.4.1.** Rules formalized, card faces specified, architecture documented, and the game playable +**v0.4.2.** Rules formalized, card faces specified, architecture documented, and the game playable solitaire in a browser. See [`../CHANGELOG.md`](../CHANGELOG.md) for what each version changed and [`../TODO.md`](../TODO.md) for what is open; this section is the shape of the project, not a running tally, because a hand-maintained tally is what drifted last time. diff --git a/package.json b/package.json index b95c6f7..4892da8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.4.1", + "version": "0.4.2", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/public/replays/seed-6257010.json b/public/replays/seed-1980750.json similarity index 72% rename from public/replays/seed-6257010.json rename to public/replays/seed-1980750.json index b711cdd..5ee6881 100644 --- a/public/replays/seed-6257010.json +++ b/public/replays/seed-1980750.json @@ -1,7 +1,7 @@ { - "seed": 6257010, - "title": "28 Revenue · seed 6257010", - "note": "28 Revenue over 5 Days · 3 train(s) on the timetable · 22 cards down · no collisions · played by developer", + "seed": 1980750, + "title": "30 Revenue · seed 1980750", + "note": "30 Revenue over 5 Days · 3 train(s) on the timetable · 10 cards down · no collisions · played by developer", "history": [ { "type": "localOps.choose", @@ -9,337 +9,27 @@ }, { "type": "draw.fromDepartment", - "slot": 1 + "slot": 0 }, { "type": "card.play", - "cardId": "c26" - }, - { - "type": "card.play", - "cardId": "c31" - }, - { - "type": "card.play", - "cardId": "c127", - "placement": { - "row": 0, - "col": 1 - }, - "variant": 0 + "cardId": "c4" }, { "type": "card.play", "cardId": "c132", "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": "card.play", - "cardId": "c181", - "placement": { - "row": 0, - "col": -1 - }, - "variant": 0 - }, - { - "type": "card.play", - "cardId": "c156", - "placement": { - "row": -1, - "col": -1 - }, - "variant": 1 - }, - { - "type": "card.play", - "cardId": "c48", - "placement": { - "row": -1, - "col": 0 - }, - "variant": 0 - }, - { - "type": "card.play", - "cardId": "c41", - "placement": { - "row": -1, "col": 1 }, "variant": 0 }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, { "type": "card.play", - "cardId": "c21" - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "caboose", - "loaded": true - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c159", - "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": "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": "c12" - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "tank", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "tank", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "tank", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "caboose", - "loaded": true - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c87", + "cardId": "c201", "placement": { "row": 0, - "col": 1 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c126", - "placement": { - "row": 0, - "col": -2 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c1" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c164", - "placement": { - "row": -2, - "col": 2 - }, - "variant": 1 - }, - { - "type": "card.play", - "cardId": "c46", - "placement": { - "row": -2, - "col": 3 + "col": -1 }, "variant": 0 }, @@ -347,201 +37,25 @@ "type": "card.play", "cardId": "c165", "placement": { - "row": -2, - "col": 4 + "row": -1, + "col": -1 }, "variant": 1 }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, { "type": "card.play", - "cardId": "c11" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c6" - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": true - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c200", - "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": "c179", + "cardId": "c38", "placement": { "row": -1, - "col": 4 + "col": -2 }, "variant": 0 }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "tank", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "tank", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "caboose", - "loaded": true - }, - { - "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": "c188", + "cardId": "c43", "placement": { - "row": 0, - "col": 3 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c201", - "placement": { - "row": 0, + "row": -1, "col": -3 }, "variant": 0 @@ -549,6 +63,44 @@ { "type": "draw.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": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "draw.end" + }, { "type": "loadUnload.end" }, @@ -561,7 +113,7 @@ }, { "type": "card.discard", - "cardId": "c25", + "cardId": "c106", "toSlot": 0 }, { @@ -579,12 +131,7 @@ }, { "type": "card.play", - "cardId": "c135", - "placement": { - "row": 0, - "col": -4 - }, - "variant": 0 + "cardId": "c25" }, { "type": "draw.end" @@ -594,19 +141,67 @@ }, { "type": "localOps.choose", - "option": "draw" + "option": "freightAgent" }, { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c69", - "placement": { - "row": -2, + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, "col": 0 }, - "variant": 0 + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 1 + }, + { + "type": "card.play", + "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": "card.play", + "cardId": "c163", + "placement": { + "row": -1, + "col": -4 + }, + "variant": 1 }, { "type": "draw.end" @@ -622,23 +217,28 @@ "type": "draw.fromHomeOffice" }, { - "type": "card.discard", - "cardId": "c98", - "toSlot": 1 + "type": "card.play", + "cardId": "c14" }, { "type": "draw.end" }, { "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "tank", + "trayId": "tray3", + "carType": "boxcar", "loaded": false }, { "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", + "trayId": "tray3", + "carType": "boxcar", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "caboose", "loaded": true }, { @@ -653,55 +253,157 @@ }, { "type": "card.play", - "cardId": "c97", - "placement": { + "cardId": "c2" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c10" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": -1, + "col": -1 + }, + "reverse": true + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { "row": 0, + "col": 0 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": -1, "col": -2 }, - "variant": 0 + "reverse": true }, { - "type": "draw.end" + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": -1, + "col": -1 + }, + "reverse": false + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": -1, + "col": -2 + }, + "reverse": true + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": false }, { "type": "newTrain.placeCar", - "trayId": "tray3", + "trayId": "tray2", "carType": "coach", "loaded": true }, { "type": "newTrain.placeCar", - "trayId": "tray3", + "trayId": "tray2", "carType": "coach", "loaded": true }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", - "loaded": false - }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "draw" + "option": "freightAgent" }, { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c73", - "placement": { - "row": -2, - "col": 1 + "type": "freightAgent.stockOutbound", + "at": { + "row": -1, + "col": -2 }, - "variant": 0 + "carType": "boxcar" }, { - "type": "draw.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": "porter.detrain", @@ -717,15 +419,77 @@ "col": 0 } }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 0 + }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "switch" + "option": "freightAgent" }, { - "type": "switch.end" + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "hopper", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "hopper", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "caboose", + "loaded": true + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 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" @@ -739,7 +503,1059 @@ }, { "type": "card.discard", - "cardId": "c111", + "cardId": "c90", + "toSlot": 1 + }, + { + "type": "draw.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": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c76", + "placement": { + "row": 0, + "col": -3 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c181", + "placement": { + "row": 0, + "col": -2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.discard", + "cardId": "c41", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "hopper", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "hopper", + "loaded": false + }, + { + "type": "mainline.clearance", + "allow": false + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": -2 + }, + "carIndex": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray1", + "to": { + "row": -1, + "col": -1 + }, + "reverse": false + }, + { + "type": "switch.dropCars", + "trayId": "tray1", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray1", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c36", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c40", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": true + }, + { + "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": "porter.detrain", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "hopper", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "hopper", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "caboose", + "loaded": true + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 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": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c53", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": -2 + }, + "carIndex": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c41", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": -1, + "col": -1 + }, + "reverse": false + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1, + "fromNose": true + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c36", + "toSlot": 2 + }, + { + "type": "draw.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": "newTrain.placeCar", + "trayId": "tray2", + "carType": "hopper", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "hopper", + "loaded": false + }, + { + "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": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c40", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c53", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c41", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": -2 + }, + "carIndex": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c36", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c40", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "caboose", + "loaded": true + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c53", + "toSlot": 2 + }, + { + "type": "draw.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": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c41", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c36", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c40", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": -2 + }, + "carIndex": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c53", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c41", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 + }, + { + "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": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": false + }, + { + "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": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c40", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c53", "toSlot": 2 }, { @@ -763,6 +1579,14 @@ "carType": "caboose", "loaded": true }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 + }, { "type": "loadUnload.end" }, @@ -771,16 +1595,25 @@ "option": "draw" }, { - "type": "draw.fromHomeOffice" + "type": "draw.fromDepartment", + "slot": 2 }, { "type": "card.discard", - "cardId": "c104", - "toSlot": 0 + "cardId": "c41", + "toSlot": 2 }, { "type": "draw.end" }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, { "type": "loadUnload.end" }, @@ -789,20 +1622,25 @@ "option": "draw" }, { - "type": "draw.fromHomeOffice" + "type": "draw.fromDepartment", + "slot": 2 }, { - "type": "card.play", - "cardId": "c78", - "placement": { - "row": -2, - "col": -1 - }, - "variant": 0 + "type": "card.discard", + "cardId": "c36", + "toSlot": 2 }, { "type": "draw.end" }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": -2 + }, + "carIndex": 0 + }, { "type": "loadUnload.end" }, @@ -811,164 +1649,49 @@ "option": "draw" }, { - "type": "draw.fromHomeOffice" + "type": "draw.fromDepartment", + "slot": 2 }, { - "type": "card.play", - "cardId": "c172", - "placement": { - "row": -1, - "col": -3 - }, - "variant": 1 + "type": "card.discard", + "cardId": "c40", + "toSlot": 2 }, { "type": "draw.end" }, { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.move", - "trayId": "tray3", - "to": { + "type": "laborer.advanceLoad", + "at": { "row": -1, - "col": -3 + "col": -2 }, - "reverse": true - }, - { - "type": "switch.dropCars", - "trayId": "tray3", - "count": 1 - }, - { - "type": "switch.move", - "trayId": "tray3", - "to": { - "row": -1, - "col": 1 - }, - "reverse": false - }, - { - "type": "switch.dropCars", - "trayId": "tray3", - "count": 1 - }, - { - "type": "switch.dropCars", - "trayId": "tray3", - "count": 1 - }, - { - "type": "switch.move", - "trayId": "tray3", - "to": { - "row": -1, - "col": 0 - }, - "reverse": true - }, - { - "type": "switch.end" + "box": 2 }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "freightAgent" + "option": "draw" }, { - "type": "freightAgent.clearInbound", - "at": { - "row": 0, - "col": 0 - }, - "index": 0 + "type": "draw.fromDepartment", + "slot": 2 }, { - "type": "loadUnload.end" + "type": "card.discard", + "cardId": "c53", + "toSlot": 2 }, { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 0 - }, - "carType": "tank" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 0 - }, - "carType": "tank" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 1 - }, - "carType": "boxcar" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "boxcar", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": false - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 1 - } + "type": "draw.end" }, { "type": "laborer.advanceLoad", "at": { "row": -1, - "col": 1 - }, - "box": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 + "col": -2 }, "box": 1 }, @@ -977,146 +1700,25 @@ }, { "type": "localOps.choose", - "option": "freightAgent" + "option": "draw" }, { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 1 - }, - "carType": "boxcar" + "type": "draw.fromDepartment", + "slot": 2 }, { - "type": "newTrain.placeCar", - "trayId": "tray1", - "carType": "coach", - "loaded": false + "type": "card.discard", + "cardId": "c41", + "toSlot": 2 }, { - "type": "newTrain.placeCar", - "trayId": "tray1", - "carType": "coach", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray1", - "carType": "coach", - "loaded": false + "type": "draw.end" }, { "type": "laborer.advanceLoad", "at": { "row": -1, - "col": 1 - }, - "box": 2 - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 1 - } - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 1 - }, - "carType": "boxcar" - }, - { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 2 - }, - { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": 1 - }, - "carIndex": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": 0, - "col": 0 - }, - "index": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 2 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 + "col": -2 }, "box": 0 }, @@ -1131,311 +1733,7 @@ "type": "freightAgent.clearInbound", "at": { "row": -1, - "col": 1 - }, - "index": 0 - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 1 - } - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 1 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 1 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 2 - }, - { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": 1 - }, - "carIndex": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 1 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 0 - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 1 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": -1, - "col": 1 - }, - "index": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": 1 - }, - "carIndex": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 2 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 1 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 1 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 0 - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 1 - } - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": -1, - "col": 1 - }, - "index": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 2 - }, - { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": 1 - }, - "carIndex": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 1 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 2 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": -1, - "col": 1 + "col": -2 }, "index": 0 }, @@ -1448,270 +1746,46 @@ { "type": "newTrain.placeCar", "trayId": "tray2", - "carType": "coach", - "loaded": false - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 1 - } - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 1 + "carType": "reefer", + "loaded": true }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "freightAgent" + "option": "draw" }, { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 1 - }, - "carType": "boxcar" + "type": "draw.fromDepartment", + "slot": 2 }, { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 2 + "type": "card.discard", + "cardId": "c36", + "toSlot": 2 }, { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": 1 - }, - "carIndex": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 2 + "type": "draw.end" }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "freightAgent" + "option": "draw" }, { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 1 - }, - "carType": "boxcar" + "type": "draw.fromDepartment", + "slot": 2 }, { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 1 + "type": "card.discard", + "cardId": "c40", + "toSlot": 2 }, { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 0 - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 1 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": -1, - "col": 1 - }, - "index": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": 1 - }, - "carIndex": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 2 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 1 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 1 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 0 - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 1 - } - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": -1, - "col": 1 - }, - "index": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 1 - }, - "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": "draw.end" }, { "type": "loadUnload.end" diff --git a/public/replays/seed-2242077.json b/public/replays/seed-2242077.json index 6c1ee01..83b92d6 100644 --- a/public/replays/seed-2242077.json +++ b/public/replays/seed-2242077.json @@ -1,7 +1,7 @@ { "seed": 2242077, - "title": "31 Revenue · seed 2242077", - "note": "31 Revenue over 5 Days · 3 train(s) on the timetable · 19 cards down · 6 collision(s) · played by developer", + "title": "30 Revenue · seed 2242077", + "note": "30 Revenue over 5 Days · 3 train(s) on the timetable · 19 cards down · 3 collision(s) · played by developer", "history": [ { "type": "localOps.choose", @@ -514,10 +514,6 @@ }, "carType": "boxcar" }, - { - "type": "mainline.clearance", - "allow": false - }, { "type": "laborer.advanceLoad", "at": { @@ -529,6 +525,10 @@ { "type": "loadUnload.end" }, + { + "type": "mainline.clearance", + "allow": false + }, { "type": "localOps.choose", "option": "draw" @@ -544,10 +544,6 @@ { "type": "draw.end" }, - { - "type": "mainline.clearance", - "allow": false - }, { "type": "laborer.advanceLoad", "at": { @@ -921,10 +917,6 @@ }, "index": 0 }, - { - "type": "mainline.clearance", - "allow": false - }, { "type": "laborer.advanceLoad", "at": { @@ -936,6 +928,10 @@ { "type": "loadUnload.end" }, + { + "type": "mainline.clearance", + "allow": false + }, { "type": "localOps.choose", "option": "switch" @@ -943,10 +939,6 @@ { "type": "switch.end" }, - { - "type": "mainline.clearance", - "allow": false - }, { "type": "laborer.advanceLoad", "at": { @@ -1269,11 +1261,11 @@ "type": "draw.end" }, { - "type": "mainline.clearance", - "allow": false + "type": "loadUnload.end" }, { - "type": "loadUnload.end" + "type": "mainline.clearance", + "allow": false }, { "type": "localOps.choose", @@ -1282,10 +1274,6 @@ { "type": "switch.end" }, - { - "type": "mainline.clearance", - "allow": false - }, { "type": "loadUnload.end" }, @@ -1572,11 +1560,11 @@ "type": "draw.end" }, { - "type": "mainline.clearance", - "allow": false + "type": "loadUnload.end" }, { - "type": "loadUnload.end" + "type": "mainline.clearance", + "allow": false }, { "type": "localOps.choose", @@ -1593,10 +1581,6 @@ { "type": "draw.end" }, - { - "type": "mainline.clearance", - "allow": false - }, { "type": "loadUnload.end" }, diff --git a/src/engine/advance.ts b/src/engine/advance.ts index e3a6646..8899f73 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -339,7 +339,7 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult { (tray.position.at !== 'grid' || where.at !== 'grid' || (tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col)); - if (moved !== 'expedited' && stillThere) { + if (!tray.departsThisStage && stillThere) { tray.stopPointClaimed = true; // The point goes to whoever is SITTING in the district it stopped in. const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0; @@ -364,11 +364,6 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult { } } if (moved === 'needsClearance') return { events, needsInput: true }; - // An expedited train may act twice in one Stage: it arrives and departs (Q3). - if (moved === 'expedited') { - const again = moveTrain(s, id, tray, events); - if (again === 'needsClearance') return { events, needsInput: true }; - } s.movedThisPhase.add(id); } @@ -376,7 +371,7 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult { return { events: [...events, ...enterPhase(s, 'loadUnload')], needsInput: false }; } -type MoveOutcome = 'moved' | 'held' | 'needsClearance' | 'expedited'; +type MoveOutcome = 'moved' | 'held' | 'needsClearance'; /** * §8.2 — why this train is not fit to run, or null if it is. @@ -565,7 +560,6 @@ function moveTrain( from: 'the Office', to: 'the Mainline', }); - awardDeparture(s, seat, tray, events); return 'moved'; } @@ -580,8 +574,7 @@ function moveTrain( */ if (node.kind === 'divisionPoint') { area.adOccupancy = area.adOccupancy.filter((t) => t !== id); - awardDeparture(s, seat, tray, events); - retireTrain(s, id, tray, events); + retireTrain(s, id, tray, node.side, events); return 'moved'; } @@ -663,7 +656,7 @@ function moveTrain( // The train has run the length of the Division and leaves the game (§2.3). dest.holding.push(id); tray.position = { at: 'divisionPoint', side: dest.side }; - retireTrain(s, id, tray, events); + retireTrain(s, id, tray, dest.side, events); return 'moved'; } @@ -844,9 +837,24 @@ function arriveAtOffice( expedited: isExpedited(tray), }); - // Q3 — an expedited train departs in the same Stage it arrived, so it is NOT added to - // movedThisPhase and gets a second chance to move before the phase ends. - if (isExpedited(tray)) return 'expedited'; + /** + * Q3 — AN EXPEDITED TRAIN DEPARTS THE STAGE IT ARRIVED, BUT NOT BEFORE THE WORK IS DONE. + * + * It used to leave inside this very Mainline Phase — a second `moveTrain` on the spot. That made + * "departs the Stage it arrives" true and the coaches worthless: Load/Unload runs AFTER Mainline, + * so an expedited train was gone before a single Porter could reach it. Measured over 60 games, + * Train 2 arrived 32 times and stood at an Office during Load/Unload exactly none of them, and + * every coach-carrying express is expedited (1/2 Crack Limited, 5/6 The Sparrow, 19 Military). + * The Crack Limited's own card says "stop at Terminals only", which it could never do. + * + * So it stands here through Load/Unload and leaves at the end of the Stage instead. It still does + * not lay over to the next Stage — the keyword is honoured — and it is still subject to every + * §8.1 condition when it goes, so it can be held. + */ + if (isExpedited(tray)) { + tray.departsThisStage = true; + return 'moved'; + } /** * X17 Campaign Train — the speeches happen at the first Office it reaches. Recorded AFTER the * expedite test above, so this arrival is the ordinary stop the card pays for and every later one @@ -907,47 +915,47 @@ function collide( } } -/** A completed run frees the crew; Extras go to the Salvage Yard (§2.3, Gap 2c). */ /** - * ONE REVENUE FOR EVERY TRAIN THAT GETS OUT OF YOUR SECTION. + * ONE REVENUE TO EVERY PLAYER WHEN A TRAIN COMPLETES ITS RUN. * - * Jesse's rule. Unlike freight and passengers it pays for traffic the player does not have to work — - * which is the point: keeping the line clear is the Superintendent's job, and until now nothing paid - * for doing it well. It rewards splitting a Subdivision with a Control Point, holding a following - * train rather than gambling on it, and building the A/D capacity to turn arrivals around. + * Jesse's rule, revised. It used to pay 1 to the Office a train departed — "it left YOUR section" — + * which meant a five-Office railroad paid five times for one train and paid the most to whoever the + * train happened to pass first. It pays once now, when the train runs off the end of the Division, + * and it pays EVERYBODY: getting a train the whole length of the railroad is the shared achievement, + * and every Office it crossed had to clear it. * - * Paid on DEPARTURE from the Office, once per train. Not paid again when the train later runs off - * the end of the Division and retires: it left YOUR section when it cleared your Office, and the far - * Division Point belongs to whoever is seated there. + * Unlike freight and passengers it pays for traffic no one has to work, which is the point: keeping + * the line moving is the Superintendent's job, and nothing else paid for doing it well. It rewards + * splitting a Subdivision with a Control Point, holding a following train rather than gambling on + * it, and building the A/D capacity to turn arrivals around. * - * A crew with no train number is a local switching move and not a departure, so it earns nothing. + * A crew with no train number is a local switching move, not a run, so it earns nothing. * - * PROVISIONAL, and worth watching: measured, this is roughly +5 Revenue a game against a mean of - * 2.7, so it more than doubles the score on its own. Flagged in `TODO.md`. + * PROVISIONAL — the previous version was worth about +5 Revenue a game against a mean of 2.7, and + * this one pays far less often. Flagged in `TODO.md`. */ -function awardDeparture( - s: GameState, - seat: SeatIndex, - tray: CrewTray, - events: GameEvent[], -): void { +function awardCompletedRun(s: GameState, tray: CrewTray, events: GameEvent[]): void { if (tray.trainNumber === null) return; - // Paid to whoever OCCUPIES the Office the train left, not to the seat's index. Identical today, - // and the difference is the whole point of the seat/player split. - const owner = playerAtSeat(s, seat); - const p = s.players[owner]; - if (!p) return; - p.revenue += 1; - events.push({ - type: 'revenueChanged', - player: owner, - delta: 1, - total: p.revenue, - reason: 'a train cleared your section', - }); + for (const p of s.players) { + p.revenue += 1; + events.push({ + type: 'revenueChanged', + player: p.index, + delta: 1, + total: p.revenue, + reason: 'a train completed its run', + }); + } } -function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]): void { +/** A completed run frees the crew; Extras go to the Salvage Yard (§2.3, Gap 2c). */ +function retireTrain( + s: GameState, + id: TrayId, + tray: CrewTray, + side: Direction, + events: GameEvent[], +): void { for (const car of tray.consist) { if (car.type === 'caboose') s.yards.divisionYard.push(car); else s.yards.classificationYard.push(car); @@ -961,8 +969,11 @@ function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent events.push({ type: 'trainCompleted', trainNumber: tray.trainNumber ?? 0, + isExtra: tray.trainIsExtra, + side, consist: tray.consist.map((c) => ({ ...c })), }); + awardCompletedRun(s, tray, events); } // --------------------------------------------------------------------------- @@ -970,6 +981,24 @@ function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent // --------------------------------------------------------------------------- function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult { + /** + * EXPEDITED DEPARTURES — the last thing that happens in a Stage (Q3, and `arriveAtOffice`). + * + * Run here rather than as a phase of its own because there is no decision in it: the trains that + * arrived expedited this Stage try to leave, in train-number order like any other movement, and + * §8.1 may still hold them. Being after Load/Unload is the whole point — it is what gives the + * Porters their one Stage with the train. + */ + const expedited = [...s.trays.entries()] + .filter(([, t]) => t.departsThisStage) + .sort(([, a], [, b]) => (a.trainNumber ?? 0) - (b.trainNumber ?? 0)); + for (const [id, tray] of expedited) { + tray.departsThisStage = false; + // A clearance question cannot be asked here — the Superintendent's phase is over — so an + // expedited train that would need a ruling simply stays, and runs normally next Stage. + if (moveTrain(s, id, tray, events) === 'needsClearance') continue; + } + // §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12. if (s.clock.stage % STAGES_PER_SHIFT === 0) { s.clock.superintendent = playerLeftOf(s, s.clock.superintendent); diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 87c1497..b264e10 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -457,12 +457,35 @@ function refusesThisOffice(s: GameState, player: PlayerIndex, tray: CrewTray): b * this works out whether a card is the reason. Without it a Military train standing at the platform * reported "no train at the Office", which is both wrong and unhelpful. */ -function passengerRefusal(s: GameState, player: PlayerIndex): RejectionCode { +function passengerRefusal( + s: GameState, + player: PlayerIndex, + at: GridCoord, + dir: 'board' | 'detrain', +): RejectionCode { const area = areaOf(s, player); const trains = area.adOccupancy.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'; - return 'NO_TRAIN_AT_OFFICE'; + if (trains.length === 0) return 'NO_TRAIN_AT_OFFICE'; + + /** + * A TRAIN IS STANDING THERE, so say what is actually missing. + * + * This used to fall through to `NO_TRAIN_AT_OFFICE` — told to a player looking straight at a train + * on their own A/D track, which reads as a broken game rather than a rule. Measured over 60 + * solitaire games it fired 51 times with a coach train in front of the player: 27 with nobody + * waiting to travel, 24 with passengers waiting and every coach already full. + */ + const f = facilityAt(s, player, at); + if (!f || f.kind !== 'passenger') return 'NO_SUCH_FACILITY'; + if (dir === 'board') { + if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return 'NO_PASSENGERS_WAITING'; + return 'NO_EMPTY_COACH'; + } + if (f.inboundBox.length >= f.capacity.inbound) return 'INBOUND_BOX_FULL'; + if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return 'NO_EMPTY_COACH_IN_YARD'; + return 'NO_LOADED_COACH'; } // --------------------------------------------------------------------------- @@ -790,17 +813,31 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo } // -- Load / Unload -------------------------------------------------------- - case 'porter.board': + /** + * A WHISTLE POST HAS NO PORTERS AT ALL, which is not the same as having used them. + * + * The Office card carries a passenger facility at every tier so that an upgrade is a property + * change rather than a card swap — but a Whistle Post's has `porters: 0`, so this fell into + * `RESOURCE_SPENT`, "all Porters already used this Stage". A player who had used nothing was + * told they had spent it all, when the answer was to upgrade the Office. 15 times in 60 games. + */ + case 'porter.board': { if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE'; - if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY'; - if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT'; - return canBoard(s, player, i.at) ? null : passengerRefusal(s, player); + const f = facilityAt(s, player, i.at); + 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'); + } - case 'porter.detrain': + case 'porter.detrain': { if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE'; - if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY'; - if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT'; - return canDetrain(s, player, i.at) ? null : passengerRefusal(s, player); + const f = facilityAt(s, player, i.at); + 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'); + } case 'laborer.startLoad': { if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE'; diff --git a/src/engine/events.ts b/src/engine/events.ts index 8dd681c..4dbb315 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -107,7 +107,12 @@ export type GameEvent = */ | { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean } | { type: 'trainDiverted'; trainNumber: number; to: string; reason: string } - | { type: 'trainCompleted'; trainNumber: number; consist: RollingStock[] } + /** + * The train ran the length of the Division and left it. `side` is the Division Point it left by, + * which is what the announcement needs — and every player scores 1 for it, so this event is + * always followed by one `revenueChanged` per player. + */ + | { type: 'trainCompleted'; trainNumber: number; isExtra: boolean; side: 'east' | 'west'; consist: RollingStock[] } | { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock } | { type: 'carPassed'; player: PlayerIndex; trayId: TrayId } | { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number } diff --git a/src/engine/intents.ts b/src/engine/intents.ts index 693e7d5..7edf577 100644 --- a/src/engine/intents.ts +++ b/src/engine/intents.ts @@ -161,7 +161,21 @@ export type RejectionCode = | 'NOT_A_GRADE' | 'TRAIN_ON_CARD' | 'CONSIST_ORDER' - | 'NO_TRAIN_AT_OFFICE'; + | 'NO_TRAIN_AT_OFFICE' + /** + * WHY PASSENGER WORK WAS REFUSED, told apart. + * + * All three used to come back as `NO_TRAIN_AT_OFFICE` — said while a train was standing at the + * Office, which reads as a broken game rather than a rule. Measured over 60 solitaire games, the + * fallback fired 51 times with a coach train in front of the player: 27 with nobody waiting to + * travel, 24 with passengers waiting and every coach on the train already full. + */ + | 'NO_PORTERS_HERE' + | 'NO_PASSENGERS_WAITING' + | 'NO_EMPTY_COACH' + | 'NO_LOADED_COACH' + | 'INBOUND_BOX_FULL' + | 'NO_EMPTY_COACH_IN_YARD'; export type Rejection = { code: RejectionCode; message: string }; diff --git a/src/engine/state.ts b/src/engine/state.ts index ad52f24..3f5647b 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -300,6 +300,12 @@ export type CrewTray = { * once, so there is no later visit to hang it on. */ speechMade?: boolean; + /** + * Arrived expedited this Stage, so it departs at the END of the Stage rather than laying over to + * the next one (Q3). Set on arrival; cleared when `shiftChange` attempts the departure — after + * Load/Unload, which is what gives the Porters their one Stage with the train. + */ + departsThisStage?: boolean; }; // --------------------------------------------------------------------------- diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index 614e8b6..d8a634e 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -269,11 +269,13 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { text: `Train ${e.trainNumber} DIVERTED to ${e.to} — ${e.reason}`, }; case 'trainCompleted': + // The one line in the log that is good news for everybody, so it is not 'quiet'. return { - tone: 'quiet', + tone: 'good', text: - `Train ${e.trainNumber} finished its run and left the Division carrying ` + - `${carsLabel(e.consist)} — the crew is free again`, + `Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` + + `${e.side === 'east' ? 'Eastern' : 'Western'} Division Point carrying ${carsLabel(e.consist)}. ` + + `All players get 1 Revenue. The crew is free again.`, }; // -- freight agent @@ -533,42 +535,82 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] } /** - * WHY THE PASSENGERS ARE NOT GETTING OFF. + * WHY THE PASSENGERS ARE NOT MOVING — boarding as well as de-training. * - * Reported: "I can't figure out how to have a train with three passenger coaches unload all three - * at my depot." You cannot — a Depot has ONE Porter and ONE red slot, so it works one coach a - * Stage and then needs a Freight Agent action to clear the box before the next. A Station does two - * and a Terminal three. That is the Office ladder doing its job, and nothing on screen said so. + * Reported first as "I can't figure out how to have a train with three passenger coaches unload + * all three at my depot." You cannot — a Depot has ONE Porter and ONE red slot, so it works one + * coach a Stage and then needs a Freight Agent action to clear the box before the next. A Station + * does two and a Terminal three. That is the Office ladder doing its job, and nothing on screen + * said so. * - * §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which is a - * requirement the engine only started enforcing recently and which no panel mentions at all. + * Reported again as "passenger trains arrive at my Office and move on before I can load or unload + * them", which turned out to be three separate silences. This block only looked at trains carrying + * a LOADED coach, so a train arriving to PICK UP said nothing at all. A Whistle Post's Office card + * carries a passenger facility with zero Porters, so the engine answered `RESOURCE_SPENT` — "all + * Porters used" — to a player who had used none. And an expedited train leaves at the end of the + * Stage it arrives, so its one Load/Unload phase is now-or-never and nothing said which trains + * those are. + * + * §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which no panel + * mentioned at all. */ const office = area.grid.get(coordKey(area.officeCoord)); const pf = office?.facility; if (pf && pf.kind === 'passenger') { - const waiting = area.adOccupancy + const coachTrains = area.adOccupancy .map((id) => s.trays.get(id)) - .filter((t) => t?.consist.some((c) => c.type === 'coach' && c.loaded)); - if (waiting.length > 0) { - const reasons: string[] = []; - if (portersLeft(pf) < 1) { - reasons.push( + .filter((t): t is NonNullable => !!t && t.consist.some((c) => c.type === 'coach')); + + if (coachTrains.length > 0) { + const label = (t: (typeof coachTrains)[number]): string => + t.trainNumber === null ? 'the local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`; + const say = (why: string, severity: Impediment['severity'] = 'waiting'): void => { + out.push({ where: 'Office — passengers', why, severity }); + }; + + const boarding = pf.outboundBox.some((c) => c.type === 'coach' && c.loaded); + const emptyOnTrain = coachTrains.some((t) => t.consist.some((c) => c.type === 'coach' && !c.loaded)); + const loadedOnTrain = coachTrains.some((t) => t.consist.some((c) => c.type === 'coach' && c.loaded)); + + if (pf.porters < 1) { + say( + 'this Office has NO Porters — a Whistle Post is not a Passenger Facility (§9) and cannot ' + + 'work passengers at all. Upgrade it to a Depot or better.', + 'stuck', + ); + } else if (portersLeft(pf) < 1) { + say( `all ${pf.porters} Porter${pf.porters === 1 ? '' : 's'} used this Stage — one works one coach, ` + 'and the Office tier is the Porter count (Depot 1, Station 2, Terminal 3)', ); + } else { + if (boarding && !emptyOnTrain) { + say('passengers are waiting to board, but every coach standing here is already full'); + } + if (!boarding && !loadedOnTrain) { + say('nobody waiting to travel and no loaded coach to set down — stock the platform with a Freight Agent action'); + } + if (loadedOnTrain && pf.inboundBox.length >= pf.capacity.inbound) { + say( + `the red Unloading box is full (${pf.capacity.inbound} slot${pf.capacity.inbound === 1 ? '' : 's'}) — ` + + 'a Freight Agent action clears it, which costs a whole Local Operations turn', + ); + } + if (loadedOnTrain && !s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) { + say('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)'); + } } - if (pf.inboundBox.length >= pf.capacity.inbound) { - reasons.push( - `the red Unloading box is full (${pf.capacity.inbound} slot${pf.capacity.inbound === 1 ? '' : 's'}) — ` + - 'a Freight Agent action clears it, which costs a whole Local Operations turn', + + // NOW OR NEVER. An expedited train leaves at the end of this Stage, so this is the only + // Load/Unload phase it will ever stand for — the exact thing the playtest report was about. + for (const t of coachTrains) { + if (!t.departsThisStage) continue; + say( + `${label(t)} is EXPEDITED — it leaves at the END of this Stage, so this is the only ` + + 'Load/Unload phase it will stand for. Work it now or not at all.', + 'risk', ); } - if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) { - reasons.push('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)'); - } - for (const why of reasons) { - out.push({ where: 'passengers waiting to de-train', why, severity: 'waiting' }); - } } } @@ -645,6 +687,9 @@ export function cuesFor(events: readonly GameEvent[]): string[] { // The 1D12 that sets a train's departure Stage. It is the one roll a player makes and it landed // silently, so the card was gone and the answer to "when does it run?" was a line of history. if (e.type === 'trainScheduled') out.push('schedule'); + // A train running the whole length of the Division pays every player, and it is the one event + // nobody made happen on the turn it lands. It should be heard, not found in the log. + if (e.type === 'trainCompleted') out.push('completed'); if (e.type === 'stageBegan') { // A Stage BEGINNING is the previous one ending — except the first, which is the game opening // and has nothing behind it. A Day boundary rings the bell only: sounding both would collide, diff --git a/src/web/game.ts b/src/web/game.ts index fcc1ca1..0ca0caf 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -116,6 +116,12 @@ export type Game = { * clear it when the card is played — no element carries the class once the card leaves the hand. */ justDrawn: CardId | null; + /** + * A one-line announcement to flash, once. Drained like `scheduled` rather than read like `Frame`, + * because it marks a MOMENT — the only event in the game that scores for everybody without anyone + * having taken a turn to cause it. + */ + announced: string | null; }; /** How each intent kind is introduced in the action list, in the order they should appear. */ @@ -150,7 +156,7 @@ export const SOLO_PLAYER = 'Solitaire'; export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game { const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] }); - const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null }; + const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null }; // A history that opens mid-Stage reads as though something was missed. Say what the game IS // first, then let the clock take over. game.log.push({ text: 'Game Begins', tone: 'start' }); @@ -685,6 +691,15 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul for (const e of events) if (e.type === 'trainScheduled') game.scheduled = e.slot; // Which card just came into hand, so the row can badge it. Last one wins for the same reason. for (const e of events) if (e.type === 'cardDrawn') game.justDrawn = e.cardId; + // A train that ran the whole Division pays EVERY player, and nobody did anything on this turn to + // make it happen — so it is announced rather than left to be found in the log. + for (const e of events) { + if (e.type === 'trainCompleted') { + game.announced = + `Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` + + `${e.side === 'east' ? 'Eastern' : 'Western'} Division Point. All players get 1 Revenue.`; + } + } // Keep the log bounded; the full history lives in `history` and can be replayed. if (game.log.length > 400) game.log.splice(0, game.log.length - 400); } diff --git a/src/web/main.ts b/src/web/main.ts index d0f953d..6d60798 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -459,6 +459,20 @@ function render(): void { } lastPhase = f.phaseKey; + /** + * A train completing its run pays every player and nobody took a turn to cause it, so it is said + * out loud rather than left in the log. Drained, so it shows once and does not re-fire on a redraw. + */ + const announcement = session.takeAnnouncement(); + if (announcement) { + const el = $('announce'); + el.textContent = announcement; + el.className = 'shown'; + window.setTimeout(() => { + if (el.textContent === announcement) el.className = ''; + }, 4200); + } + renderDistrict(f); renderActions(menu, f, justSet); renderUndo(); diff --git a/src/web/play.html b/src/web/play.html index 9cae1a2..01348bd 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -88,6 +88,14 @@ button.cardact.discard{color:#d6b48a} box-shadow:0 6px 22px rgba(0,0,0,.5)} #phasenote.shown{opacity:1;transform:translateX(-50%) translateY(0)} @media(prefers-reduced-motion:reduce){#phasenote{transition:none}} +/* A train completing its run pays EVERY player, so it is announced rather than left to the log — + green like a revenue gain, and its own element so it cannot fight the phase note for the slot. */ +#announce{position:fixed;left:50%;top:14px;transform:translateX(-50%) translateY(-14px);z-index:41; + background:#16301f;border:1px solid #4c9a6a;color:#c8f0d8;border-radius:14px;padding:7px 18px; + font-size:14px;font-weight:700;opacity:0;pointer-events:none;transition:opacity .18s,transform .18s; + box-shadow:0 6px 22px rgba(0,0,0,.5);max-width:min(680px,90vw);text-align:center} +#announce.shown{opacity:1;transform:translateX(-50%) translateY(0)} +@media(prefers-reduced-motion:reduce){#announce{transition:none}} /* The die's answer, said where the player is looking. Green like a revenue gain — it is a good thing that just happened — and it disappears with the next render, as a moment should. */ .scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px; @@ -185,6 +193,7 @@ ul.blocked li{padding:2px 0}
+
diff --git a/src/web/session.ts b/src/web/session.ts index 1a93c1c..921d632 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -86,6 +86,8 @@ export type Session = { takeCues(): string[]; /** The timetable slot the last 1D12 filled, once. Draining. */ takeScheduled(): number | null; + /** A one-line announcement to flash, once. Draining. */ + takeAnnouncement(): string | null; /** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */ justDrawn(): string | null; }; @@ -146,6 +148,11 @@ export function createLocalSession(seed: number, config?: GameConfig): LocalSess game.scheduled = null; return slot; }, + takeAnnouncement: () => { + const text = game.announced; + game.announced = null; + return text; + }, justDrawn: () => game.justDrawn, seed: () => game.seed, @@ -159,6 +166,7 @@ export function createLocalSession(seed: number, config?: GameConfig): LocalSess back.cues.length = 0; back.scheduled = null; back.justDrawn = null; + back.announced = null; changed(); return true; }, @@ -166,6 +174,7 @@ export function createLocalSession(seed: number, config?: GameConfig): LocalSess game = fromSave(save); // Restoring replays the whole history and re-records every draw; none of it is news. game.justDrawn = null; + game.announced = null; changed(); }, }; diff --git a/src/web/sound.ts b/src/web/sound.ts index aec75c3..627d503 100644 --- a/src/web/sound.ts +++ b/src/web/sound.ts @@ -124,6 +124,17 @@ export function playCue(cue: string): void { return; } + if (cue === 'completed') { + // A train clearing the Division and whistling off: two long horn notes falling away, with the + // second quieter and lower so it reads as distance rather than repetition. Longer than any other + // cue on purpose — it is the only one that pays every player, and it should land as an event. + tone(t, 392, 0.55, 0.13, 'sawtooth', 370); + tone(t, 262, 0.55, 0.09, 'triangle', 247); + tone(t + 0.42, 330, 0.75, 0.08, 'sawtooth', 300); + tone(t + 0.42, 220, 0.75, 0.055, 'triangle', 200); + return; + } + if (cue === 'day') { // The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep. for (const at of [t, t + 0.22]) { diff --git a/test/enhancements.test.ts b/test/enhancements.test.ts index 15c70f3..e59d11e 100644 --- a/test/enhancements.test.ts +++ b/test/enhancements.test.ts @@ -332,7 +332,7 @@ describe('ABS Signals amend the collision rule', () => { // --------------------------------------------------------------------------- -describe('one Revenue for every train that clears your section', () => { +describe('one Revenue to EVERY player when a train completes its run', () => { /** A train sitting on the A/D track, made up and ready to highball. */ const readyToLeave = (s: GameState, direction: 'east' | 'west' = 'east'): string => { const id = 'leaving'; @@ -348,35 +348,61 @@ describe('one Revenue for every train that clears your section', () => { return id; }; - it('pays the owner when a train departs onto the Mainline', () => { + it('pays nothing for merely leaving the Office', () => { + /** + * THE RULE CHANGED IN v0.4.2. It used to pay 1 to the Office a train departed, which meant a + * five-Office railroad paid five times for one train and paid most to whoever it happened to + * pass first. Departing is now worth nothing on its own. + */ const s = game(); const before = s.players[0]!.revenue; readyToLeave(s, 'east'); - const r = advance(s); + advance(s); - assert.equal(s.players[0]!.revenue, before + 1, 'clearing a train earned nothing'); - assert.ok( - r.events.some((e) => e.type === 'revenueChanged' && e.reason === 'a train cleared your section'), - 'no revenue event was emitted for the departure', - ); - - // Paid ONCE. Running off the end of the Division later is not a second departure — that far - // Division Point belongs to whoever is seated at it. - const after = s.players[0]!.revenue; - for (let i = 0; i < 8; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; advance(s); } - assert.equal(s.players[0]!.revenue, after, 'the same train was paid for a second time on its way out'); + assert.equal(s.players[0]!.revenue, before, 'departing the Office still paid'); }); - it('pays nothing for a local crew, which is switching rather than departing', () => { + it('pays every player once the train runs off the end of the Division', () => { + const s = game(); + const before = s.players.map((p) => p.revenue); + readyToLeave(s, 'east'); + + // Run the Mainline Phase until the train has crossed the Division and left it. + const seen: typeof s.players[number]['revenue'][] = []; + let completed: { type: string; side?: string } | undefined; + for (let i = 0; i < 20 && !completed; i++) { + s.movedThisPhase = new Set(); + s.clock.phase = 'mainline'; + const r = advance(s); + completed = r.events.find((e) => e.type === 'trainCompleted'); + if (completed) { + assert.equal(completed.side, 'east', 'an eastbound train left by the wrong Division Point'); + assert.ok( + r.events.some((e) => e.type === 'revenueChanged' && e.reason === 'a train completed its run'), + 'no revenue event was emitted for the completed run', + ); + } + seen.push(s.players[0]!.revenue); + } + assert.ok(completed, 'the train never left the Division'); + assert.deepEqual(s.players.map((p) => p.revenue), before.map((r) => r + 1), 'not every player was paid'); + + // Paid ONCE — the train is gone, so there is nothing left to pay again. + const after = s.players.map((p) => p.revenue); + for (let i = 0; i < 8; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; advance(s); } + assert.deepEqual(s.players.map((p) => p.revenue), after, 'the same run was paid twice'); + }); + + it('pays nothing for a local crew, which is switching rather than running', () => { const s = game(); const before = s.players[0]!.revenue; const id = readyToLeave(s, 'east'); s.trays.get(id)!.trainNumber = null; - advance(s); + for (let i = 0; i < 20; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; advance(s); } - assert.equal(s.players[0]!.revenue, before, 'a switching crew was paid as though it had cleared the section'); + assert.equal(s.players[0]!.revenue, before, 'a switching crew was paid as though it had completed a run'); }); }); @@ -391,8 +417,9 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St * Nothing states the rule, which makes it exactly the kind of property a later change to the loop * would break in silence. These pin it. * - * It is also what gives the printed **Expedite** rule something to do — an Expedited train is - * granted a second `moveTrain` in the same phase precisely because the general rule forbids it. + * It is also what gives the printed **Expedite** rule something to do — an Expedited train departs + * at the END of the Stage it arrived, precisely because the general rule forbids it departing at + * all. It stands through Load/Unload first, which is what makes its coaches workable. */ const arriving = (s: GameState, trainNumber: number, isExtra = false): string => { const id = 'arriving'; @@ -425,9 +452,13 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St ); }); - it('unless its card prints Expedite, which exists to override exactly this', () => { - // Q3. Train 3 prints `expedite`, so it arrives and departs in one Stage — still subject to the - // rest of §8.1, which is why the clearance path is re-entered rather than skipped. + it('unless its card prints Expedite — but it still stands through Load/Unload first', () => { + /** + * Q3, as revised in v0.4.2. An Expedite train leaves the Stage it arrives, but at the END of that + * Stage rather than inside the Mainline Phase. It used to be gone before Load/Unload ran, which + * made the coaches on every coach-carrying express (1/2, 5/6, 19) permanently unworkable — + * measured, Train 2 arrived 32 times in 60 games and stood for Load/Unload in none of them. + */ const s = game(); assert.equal(trainProfile(3, false)?.rules.expedite, true, 'train 3 should be an Expedite train'); const id = arriving(s, 3); @@ -435,8 +466,19 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St advance(s); assert.equal( - s.trays.get(id)!.position.at, 'mainline', - 'an Expedite train should leave again the Stage it arrives', + s.trays.get(id)!.position.at, 'grid', + 'an Expedite train left inside the Mainline Phase, before Load/Unload could reach it', + ); + assert.equal(s.trays.get(id)!.departsThisStage, true, 'it is not flagged to leave at the end of the Stage'); + assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'it is not standing on an A/D track'); + + // Run the end of the Stage directly. Load/Unload waits on the player, and the thing under test + // is the departure pass that follows it — not that phase's own input handling. + s.clock.phase = 'shiftChange'; + advance(s); + assert.notEqual( + s.trays.get(id)?.position.at, 'grid', + 'an Expedite train should be gone by the end of the Stage it arrived', ); }); }); diff --git a/test/multiplayer.test.ts b/test/multiplayer.test.ts index d71ccd8..ce2cb30 100644 --- a/test/multiplayer.test.ts +++ b/test/multiplayer.test.ts @@ -543,11 +543,15 @@ describe('the view shows one seat at a time', () => { }); describe('scoring lands on the right seat', () => { - it('pays the departure Revenue to the Office the train left, not to seat 0', () => { + it('pays EVERY player when a train completes its run, and nobody for merely departing', () => { /** - * The rule reads "one Revenue for every train that clears YOUR section", and `awardDeparture` - * takes the owner of the departing Office. With one player that is unfalsifiable: seat 0 is the - * only seat there is. This is the test that says so with three. + * THE RULE CHANGED IN v0.4.2. It used to pay 1 to the Office a train departed — which on a + * three-Office railroad paid three separate times for one train, and paid whichever Office the + * train happened to pass first. It pays once now, when the train runs off the end of the + * Division, and it pays everybody: getting a train the whole length is the shared achievement. + * + * With one player this is unfalsifiable, because "everybody" is one person. This is the test + * that says so with three. */ const s = game(3); pinTerrain(s); @@ -558,10 +562,20 @@ describe('scoring lands on the right seat', () => { s.movedThisPhase = new Set(); advance(s); + assert.deepEqual(s.players.map((p) => p.revenue), before, 'departing the Office paid somebody'); - assert.equal(s.players[2]!.revenue, before[2]! + 1, 'seat 2 was not paid for its own departure'); - assert.equal(s.players[0]!.revenue, before[0]!, 'seat 0 was paid for a train it never ran'); - assert.equal(s.players[1]!.revenue, before[1]!, 'seat 1 was paid for a train it never ran'); + let completed; + for (let i = 0; i < 25 && !completed; i++) { + s.movedThisPhase = new Set(); + s.clock.phase = 'mainline'; + completed = advance(s).events.find((e) => e.type === 'trainCompleted'); + } + assert.ok(completed, 'the train never ran off the end of the Division'); + assert.deepEqual( + s.players.map((p) => p.revenue), + before.map((r) => r + 1), + 'a completed run did not pay every player exactly 1', + ); }); it('keeps each seat’s Office Area to itself', () => { diff --git a/test/sim.test.ts b/test/sim.test.ts index 80c4283..6389a35 100644 --- a/test/sim.test.ts +++ b/test/sim.test.ts @@ -424,24 +424,37 @@ describe('switching accomplishes something (regression)', () => { // So test the thing the old threshold was a proxy for: motion must convert into work. A crew // that shuttles for its own sake shows a high ratio here, however few or many Moves it makes. /** - * 150 games, not 40. At 40 this measured 1.9 productive acts against a floor of 2 and failed, - * while the same bot over 200 games gives 2.6 — the test was under-powered rather than the bot - * broken, and a floor that trips on sample noise trains you to ignore it. Switching is a - * low-count, high-variance activity: a couple of games with a stranded crew move the mean. + * 400 games, not 150 and not 40. This has been under-powered twice: at 40 it measured 1.9 + * against a floor of 2, and the fix was 150 on the strength of a 200-game figure of 2.6. Neither + * number survived. Measured decisively at 400 games the bot does **1.76** productive acts, and + * the 150-game sample was reading 2.16 purely on which seeds it happened to include — so the + * floor of 2 had been passing on sample composition rather than on behaviour, which is the exact + * failure mode the previous comment was written to prevent. + * + * Switching is a low-count, high-variance activity: a couple of games with a stranded crew move + * the mean, so the sample has to be big enough that they cannot. */ const report = simulate({ - games: 150, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot, + games: 400, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot, }); const per = (f: (g: (typeof report.perGame)[number]) => number): number => report.perGame.reduce((n, g) => n + f(g), 0) / report.perGame.length; const moves = per((g) => g.actions.moves); const work = per((g) => g.actions.drops + g.actions.couples); - assert.ok(work > 2, `only ${work.toFixed(1)} productive acts a game — the crew is doing nothing`); + /** + * 1.2, against 1.48 measured. Lowered from 2 in v0.4.2 for two separate reasons, both recorded + * rather than blended: the old floor was never actually met (1.76 at 400 games), and expedited + * trains now stand at the Office for a Stage instead of passing straight through, which costs + * about 16% of the crew's work because a train on the A/D track and the Office square is in the + * way. That second part is the change doing what it is supposed to do, not a fault — but it IS + * drift, and it is logged in `TODO.md` with the rest of it rather than quietly absorbed here. + */ + assert.ok(work > 1.2, `only ${work.toFixed(2)} productive acts a game — the crew is doing nothing`); assert.ok( - moves / work < 8, + moves / work < 9, `${(moves / work).toFixed(1)} Moves per drop or coupling suggests aimless shuttling ` + - `(${moves.toFixed(0)} Moves, ${work.toFixed(1)} productive acts)`, + `(${moves.toFixed(0)} Moves, ${work.toFixed(2)} productive acts)`, ); }); });