From 6655e20ea82baa9f3b0da37d4c721ac742d2bc81 Mon Sep 17 00:00:00 2001 From: Jesse Date: Fri, 14 Aug 2026 22:39:50 -0400 Subject: [PATCH] =?UTF-8?q?v0.4.4=20=E2=80=94=20New=20Game=20dialog=20for?= =?UTF-8?q?=20the=20opening=20hand=20and=20the=20three=20revenue=20rates,?= =?UTF-8?q?=20Yard=20renamed=20Interchange,=20and=20two=20movement=20bugs?= =?UTF-8?q?=20behind=20a=20mirrored=20consist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 199 ++ README.md | 30 +- TODO.md | 32 +- docs/rules/implications.md | 4 +- docs/station-master-seed493290760-day2.json | 656 ++++ package.json | 2 +- .../{seed-1022551.json => seed-143542.json} | 2278 ++++++------- .../{seed-1980750.json => seed-286084.json} | 2017 ++++++------ .../{seed-1703585.json => seed-309841.json} | 2827 ++++++++--------- src/engine/advance.ts | 33 +- src/engine/apply.ts | 80 +- src/engine/content.ts | 121 +- src/engine/setup.ts | 80 +- src/engine/state.ts | 46 + src/sim/board-svg.ts | 19 +- src/sim/save-replay.ts | 37 +- src/sim/view.ts | 32 +- src/web/game.ts | 50 +- src/web/main.ts | 146 +- src/web/play.html | 80 +- src/web/session.ts | 14 +- test/advance.test.ts | 70 +- test/apply.test.ts | 183 +- test/enhancements.test.ts | 45 +- test/events.test.ts | 8 +- test/harness.test.ts | 7 +- test/multiplayer.test.ts | 10 +- test/setup.test.ts | 56 +- test/web.test.ts | 190 +- 29 files changed, 5450 insertions(+), 3902 deletions(-) create mode 100644 docs/station-master-seed493290760-day2.json rename public/replays/{seed-1022551.json => seed-143542.json} (76%) rename public/replays/{seed-1980750.json => seed-286084.json} (65%) rename public/replays/{seed-1703585.json => seed-309841.json} (69%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 897e62f..f3b2cfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,205 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie ## Unreleased +## 0.4.4 — 2026-08-14 + +Out of a play session: engines that pointed north, a card whose name collided with five other things, +a New Game dialog that only asked for a seed — and then, chasing a mirrored consist, two movement +bugs that had been there all along. + +Both playtest reports were confirmed against Jesse's own saved game, +`docs/station-master-seed493290760-day2.json` (seed 493290760, two Days, 127 intents), which is kept +as the evidence for what follows. + +### A curve is not a straight, and a train that rounds one knows it + +**Reported:** "a train reversed into a siding and the display of the cars was reversed." + +`facing` is the port the engine would leave by, and a Move recorded it as `opposite(entry)`. That is +the far end of a **straight** and of nothing else: a curve is an arc between two ADJACENT edges, so a +train entering a north-west curve through its west port comes out facing **north**, not east. Every +train that rounded a curve was left facing a port its own card does not have. + +Two things went wrong with that, and only the second was visible: + +- **Movement.** `movesFor` explores from `facing`, and a port the card lacks yields no destinations + at all — so a crew that rounded a curve could only ever back out the way it came. It could not + continue round the corner it had just taken. +- **Display.** The east-west sense the board draws is carried from `facing`, so a curve that had + really turned the engine west could leave the board still drawing it east — the consist mirrored, + which is what was seen. + +The forward case now asks the **card** for its far end (`farPort`). The reverse case is unchanged and +was already right: backing up, the engine trails and points out through the port the train came in +by, whatever the track does underneath — there is a test pinning that so the fix cannot drift into it. + +**The reported moment, out of the save.** Three times in two Days the crew backs off the Running +Track into the curved siding at (1,-2) — a `sw` arc, so entering it from the south leaves the engine +facing south. Intent #109, drawn both ways: + +``` +came from (0,0) office [ew] west cab tnk [>] east +BEFORE the fix west [v] box tnk cab east +AFTER the fix west cab tnk box [>] east +``` + +The engine was on the east end before the move and is on the east end after it — backing up does not +turn a train around. The old renderer flipped the whole strip to nose-left the moment `facing` stopped +being `'e'`, which is precisely the mirroring that was reported. + +### A train leaving a district drops its spur port + +The same family, found while fixing the above. Nothing reset `facing` when a train left an Office, so +a crew that had been shunted onto a north-south spur carried a compass port out onto a Division that +runs east and west — and then into the next Office, whose card has no north or south edge at all. +With neither `facing` nor its opposite on the card, `movesFor` returned nothing in either direction: +**the train arrived at the next Office unable to make a single Move.** It also drew a ▲ on the +Division map, where there is no north to point at. + +A train out there is running one way along an east-west railroad with its engine at one end, so +`enterMainline` now says so. This is the same stale port that made the ▲ on the Division map — the +`railFacing` change above stopped it being *drawn*, and this stops it existing. + +### Departures after Cargo: investigated, not a bug — but one card contradicts itself + +Also reported: "a train left at the end of the Cargo phase — shouldn't it wait for the next Mainline +phase?" It should not, and it did not: **only Expedite trains do this**, which is Q3 working as +agreed. In the save it is **6 The Sparrow**, twice — arriving in Stage 9's Mainline and highballing +in Stage 9's Supervisor Shift, then the same in Stage 10 of Day 2. The one non-Expedite train in the +game, 12 Drag Freight, arrived in Stage 8 and left three Stages later in a **Mainline** phase, exactly +as it should. Measured more widely, over 40 bot games, the split is perfect: every departure with no +Local Operations turn was an Expedite train, and every non-Expedite train got at least one. + +Two things are worth writing down because they read as bugs and are not: + +- **Expedite departs in Supervisor Shift, not in Cargo.** Q3 as recorded says the train "gets a + second `moveTrain` in the same Mainline Phase"; the code deliberately does not, because that had + expedited trains gone before a single Porter could reach them. Supervisor Shift is the last phase + of the Stage, so it is still the Stage the train arrived in — it just looks like "I finished Cargo + and it left", because Supervisor Shift needs no input and runs itself. +- **Expedite costs the train its Local Operations turn**, since Local Ops is phase 1 and the train + arrives in phase 3 and leaves in phase 5. Harmless for four of the five — Crack Limited, The + Sparrow, the Military train and the Light Engine all print "no switching" and would decline it. + +**Except for 3/4 Express**, which prints *"may drop or pick up one freight car at every location"* +and is also Expedite. That budget is spent by coupling and setting out, which happen only in Local +Operations — so the Express has a printed ability it can never use: 31 Office visits in 40 games, 31 +with no turn to use it in. X14 Fruit Growers Express is in the same position, though its extra-reefer +line is a note today with no mechanics behind it. Left alone pending Jesse's call and recorded in +`TODO.md`; it is a rules contradiction on the card, not a fault in the timing. + +### Replays are replaced rather than piled up + +Both fixes change which Moves are legal, so the published replays stopped replaying — correctly, and +the liveness test caught it. Re-recording used to write the new set **beside** the old one, and the +old set is precisely the one whose rules have just moved, so dead files accumulated and the test +failed on them forever. `save-replay.ts` now retires what it replaces (only once a replacement has +verified), and writes the `rules` block into each file so a replay can never again be silently +re-dealt. `harness.test.ts` was passing `{ seed, history }` and dropping `rules` on the floor, which +would have replayed every published file under the pre-dialog defaults whatever it said. + +### The engine points east or west, always + +**Reported:** a crew that turned onto a north-south spur was drawn with a ▲ over it, and the change +of convention was harder to read than no arrow at all. + +`facing` on a Crew Tray is a **port** — 'n', 's', 'e' or 'w' — because movement needs one: a crew +standing on a north-south spur has to be able to leave by 'n' or 's', and the last attempt to derive +east/west from the direction of the run stranded 29 of 62 leftover crews on north-south track with no +legal move. So the port stays exactly as it is, and what changed is the **drawing**. + +A new `railFacing` on the tray carries the east-west sense across north-south track: it updates +whenever `facing` becomes 'e' or 'w' and holds its value in between. That is the railroad's own +convention, where compass north on a branch is still timetable east. It follows the engine around +180° of curves, because a train that runs forward through two curves really has turned around — and +it does *not* move when a train backs up, because a train that backs up has not. + +**It also fixes a bug nobody had reported yet.** Nothing resets `facing` when a train leaves a +district, so a crew that shunted onto a north-south spur and then departed carried its 'n' out onto +the Division map — where there is no north or south — and drew ▲ there too. + +The Frame's `facing` is now typed `'e' | 'w'`, so this is enforced rather than merely observed, and +the Office card lays a north-south crew's consist east-west like every other train instead of pinning +it nose-left. + +### The Yard mainline card is now the Interchange + +Same 60, same "sort cars into any new order", same entry points, same art. What it did not have was a +name of its own: **Division Yard, Classification Yard, Salvage Yard, Yard Office and Small Yard** are +five other things in this game, and none of them is this card. The internal key is renamed with it +(`MainlineKind` `'yard'` → `'interchange'`) so the two cannot drift. The other five are deliberately +untouched — they merely shared a word. + +### New game asks for the rules, not just the seed + +It was a `prompt()` asking for a seed. Two of the three things that decide what kind of game you are +about to play had no way in at all: the opening hand had been changed twice with no way back to the +earlier rule, and the three revenue rates were constants in the source. Balance is the open question +this game has, and settling it means dealing several games at different settings — which needs a +dialog, not a rebuild. + +**Starting hand**, three options, all of which have been the rule at some point: + +- **Three random cards** *(new default)* — the prototype rule. At the hand limit already. +- **Six random cards** — twice the choice, still no guaranteed track. +- **Three random track and three random non-track** — the v0.4.x deal, from two shuffled piles. + +**Revenue**, three rates, each 0–5: + +- **Passenger revenue per coach**, default **1**. Paid when a coach is boarded and again when it is + detrained — both halves of the movement, as it has always worked. +- **Freight revenue per load**, default **1**. Paid when a load is made up outbound and again when it + is broken inbound. +- **Train revenue per transit**, default **0** — *changed from 1*. Paid to every player when a train + runs off the end of the Division. At 1 it was worth ~5.4 Revenue against a bot mean of 7.0: the + railroad was earning most of its money from the one thing nobody has to work for, and the freight + and passenger economies the game is about could not be read through it. + +Zero is a real setting rather than "one, suppressed" — no revenue event is emitted at all, so the +history does not fill with "+0 Revenue" for work that did not pay. + +**A seed no longer names a game**, so the settings ride in the URL beside it +(`?seed=430&hand=sixRandom&passenger=1&freight=1&transit=0`) and the header carries a readout of what +is in force. A playtest note reading "scored 4" is worthless without it. + +**Saves carry the rules they were dealt under.** A save is a seed and a list of intents: replay it +under different rules and it is a different game, and the symptom is not an error but a replay that +quietly stops early — which `TODO.md` records happening twice unnoticed, one of them 42 intents into +360. `Save.rules` is written from now on, and a save without it replays under the pre-dialog rules +(3+3, and 1 per transit) rather than today's defaults, so the three published replays still run. + +**Under the hood.** The settings live on `GameConfig.houseRules` as a partial, resolved in one place +by `houseRules()`, which clamps and rounds — so a hand-edited URL cannot deal a game at 900 Revenue a +coach. They reach the page on the `Frame` rather than off the config, because a remote client holds +no `GameState` and still has to be able to answer "what does a load pay here?"; `createLocalSession` +takes house rules rather than a whole `GameConfig` for the same reason. + +**Exercised, not just compiled.** Ten developer-bot games per setting, solitaire Standard — far too +small a sample to conclude anything about balance, and enough to show the dials are wired to the +game rather than to the dialog: + +| Setting | Dealt | Mean Revenue | +| --- | --- | --- | +| `threeRandom` (default) | 3 | 4.00 | +| `sixRandom` | 6 | 2.00 | +| `threeTrackThreeOther` | 6 | 0.50 | +| pax 1 · frt 1 · **trn 0** (default) | — | 4.00 | +| pax 1 · frt 1 · **trn 1** (the old rule) | — | 9.30 | +| pax 0 · frt 0 · trn 0 | — | −0.20 | +| pax 5 · frt 5 · trn 0 | — | 20.80 | + +The transit rule is worth **+5.30** here against the +5.4 measured over 200 games before it became a +setting, which is the number this change was made to get out from underneath. All zeroes lands just +below zero — collision penalties, with nothing left paying — which is the right shape for an economy +switched off. **Read no balance conclusion from the two random-deal rows**: ten seeds is noise, and +the hand rows are three different games rather than the same game dealt differently. + +**Tests.** 513 passing, six of them driving the dialog through the *emitted bundle* — the failure +mode here is wiring, not logic. Four existing tests had to be re-pinned rather than merely updated: +the opening deal changes the RNG stream, so `advance.test.ts` was relying on whichever mainline card +seed 1 happened to lay down and now pins its own terrain, and the widest action list was re-measured +over five seeds in all three deals (13 under the random deals, 10 under 3+3). + ## 0.4.3 — 2026-08-14 ### A load has to have somewhere to go diff --git a/README.md b/README.md index a638971..0f53c97 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,13 @@ imports nothing outside itself, and never touches `Math.random`, so a static hos a remote one drops in without the page changing — but there is no server, no turn submission and no per-player push), the 22 opponent-directed cards, and real audio. -Balance is *not* where it should be: the developer bot averages 7.0 Revenue against a target of 20 — -of which ~5.4 is the "one Revenue per train that clears your section" rule, so the working freight -and passenger economy is still only ~2. `TODO.md` says why, and says which of it is the bot and which -is the deck. +Balance is *not* where it should be: the developer bot averaged 7.0 Revenue against a target of 20 — +of which ~5.4 was the "one Revenue per train that completes its run" rule, so the working freight and +passenger economy is still only ~2. That rule is now a **setting that defaults to off**, along with +the passenger and freight rates and the opening hand, so the economy can be read on its own and the +alternatives can be played rather than argued about. Nothing in this file or in `TODO.md` has been +re-measured at the new defaults; `TODO.md` says why, and says which of it is the bot and which is the +deck. Versions follow the convention at the top of [`CHANGELOG.md`](CHANGELOG.md): third digit for fixes, second for a set of features, 1.0 for the first release that deserves the name. @@ -106,13 +109,18 @@ is the thing this machinery exists to prevent. - **Never call `Math.random()`.** One ambient random call silently breaks replay. - **Track is a deck card, but the opening district is dealt.** 96 of the 235 cards are track — the largest category — so a district is built from what you draw, and building it costs you the - industry or train you drew instead. The one exception is setup: track is shuffled separately and - each player is dealt **3 track + 3 other**, with the leftover track shuffled back in afterwards. - You therefore open holding six against a limit of three, and the first turn is spent choosing. - Provisional — see `TODO.md`. -- **Clearing the line scores.** Every train highballed out of your Office earns one Revenue, paid - once. It is worth +5.4 a game, more than the entire freight and passenger economy put together, - and it pays for traffic you do not have to work. Also provisional. + industry or train you drew instead. The opening hand is the exception, and it is now **chosen when + the game is dealt**: three random cards (the default, and the prototype rule), six random cards, or + three track and three other from two separately shuffled piles. The last of those is the only one + that guarantees you a district to build; deal six and you open over the limit of three, so the + first turn is spent choosing. See `TODO.md`. +- **What the work pays is a setting too.** Passenger revenue per coach, freight revenue per load and + train revenue per transit each run 0–5 and are fixed when the game is dealt. The first two default + to 1 and pay at both ends of a movement — boarding *and* detraining, loading *and* unloading. The + third pays every player when a train runs off the end of the Division and **defaults to 0**: at 1 + it was worth more than the entire freight and passenger economy put together, for traffic nobody + has to work. A seed alone therefore no longer names a game — the settings ride in the URL beside + it, and every save records the rules it was dealt under. - **A turnout can be laid on top of a card already down.** It upgrades a straight at any rotation, or a curve whose arc matches its own diverging leg — both strict port supersets of what they replace, so an upgrade can never sever an existing join. Without it a district could only hang off track that diff --git a/TODO.md b/TODO.md index 290daa5..efa6922 100644 --- a/TODO.md +++ b/TODO.md @@ -9,6 +9,20 @@ Ordered within each section by how much it is currently costing us. ## Next +- [ ] **3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE — Jesse's call.** The card says *"may drop or pick + up one freight car at every location"* and also prints **Expedite**. Expedite means the train + departs the Stage it arrives (Q3): it arrives in the Mainline phase, stands through Cargo, and + highballs in Supervisor Shift — so it is never on the board during a Local Operations phase, + which is the only phase in which freight is coupled or set out. Measured over 40 bot games: + **31 Office visits, 31 of them with no Local Operations turn.** X14 Fruit Growers Express is in + the same position, though its "may pick up one extra loaded reefer" is only a note today. + + The four options put to Jesse, unchanged: drop Expedite from 3/4 only (the other Expedite + trains all print "no switching" and lose nothing); leave Q3 alone and strike the freight line + from the card; drop Expedite everywhere (it partly exists to relieve Crew Tray scarcity, so + this needs re-measuring); or move the Express's freight budget into the Cargo phase, where an + expedited train does still get a turn. **Nothing is broken** — this is a contradiction between + two lines on one card, and the timing rule itself is behaving exactly as recorded. - [ ] **REBALANCE, once the rules are right — deliberately deferred.** Card counts, industry counts and the track mix all need a pass together, and none of them should move until the rules stop moving. Standing distortions to account for when it happens: offices are doubled (Q12) and @@ -17,8 +31,18 @@ Ordered within each section by how much it is currently costing us. those multipliers exist to relieve. The 8 sharp curves have already been taken out on that argument; offices and industries are the two left. Until then, read no balance conclusion from the revenue numbers; they are a functionality signal only. -- [ ] **REVIEW THE TWO NEW RULES ONCE THEY HAVE BEEN PLAYED — both went in provisional.** Jesse's - call, both implemented and measured, both flagged in `rules-v0.2.md`. +- [ ] **RE-MEASURE THE BOT AT THE NEW DEFAULTS.** Both provisional rules below are now **settings on + the New Game dialog** rather than fixed choices, and the defaults are not what the numbers in + this file were measured under: the opening hand defaults to **three random cards** (the + prototype rule) rather than 3+3, and **train revenue per transit defaults to 0** rather than 1. + That second one is the big move — it was worth ~5.4 of a 7.0 mean, so the bot's revenue should + fall to roughly the working freight-and-passenger economy alone, which is the number this game + has actually been trying to read all along. Every mean, floor and threshold quoted below and in + the tests predates it. The three revenue rates run 0–5, so the useful next step is a sweep + rather than a single re-run. +- [ ] **REVIEW THE TWO NEW RULES ONCE THEY HAVE BEEN PLAYED — both went in provisional, and both are + now selectable rather than fixed.** Jesse's call, both implemented and measured, both flagged + in `rules-v0.2.md`. What follows is what was measured when each was the only option. **The opening deal (3 track + 3 other, from two separately shuffled piles).** It did what it was aimed at, modestly: run-arounds **4/60 → 7/60** and districts **17.9 → 20.3 cards**, with @@ -37,7 +61,9 @@ Ordered within each section by how much it is currently costing us. 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. + is still reachable largely on traffic, which is either the intent or an argument for raising it + — and at the new default of 0 per transit it is not reachable on traffic at all, which is the + first thing a playtest should check. - [ ] **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 diff --git a/docs/rules/implications.md b/docs/rules/implications.md index 5bfd1c0..527cf4e 100644 --- a/docs/rules/implications.md +++ b/docs/rules/implications.md @@ -6,7 +6,7 @@ it into this workspace: | File | What it is | | --- | --- | | `Deck cards2.xlsx` | The **complete card list** — every card, its count, where it may be placed, what it does | -| `Mainline Cards.pdf` | The **Mainline card types** — Plains, Curves, Hilly, Heavy Grade, Double Track, Uncontrolled Siding, Tunnel, Trestle, Yard, and both Division Points | +| `Mainline Cards.pdf` | The **Mainline card types** — Plains, Curves, Hilly, Heavy Grade, Double Track, Uncontrolled Siding, Tunnel, Trestle, Interchange (printed "Yard"), and both Division Points | | `Trains3.pdf` | The **train cards** — all 22, with names, speed class, consist and individual operating rules | | `tracks.png` | **Card art** for track and industry cards | @@ -518,7 +518,7 @@ We modelled a Mainline card as **2 regions, uniform**. The design has **ten dist | Uncontrolled Siding | 60 | trains may pass; separate "no pass" and "passing" starts | | Tunnel | 30 | | | Trestle | 60 | | -| Yard | 60 | **sort cars into any new order**; has a Yard Limit | +| Interchange | 60 | **sort cars into any new order**; has a Yard Limit. Printed "Yard" on the prototype card and renamed after play — it shared a word with the Division Yard, the Classification Yard, the Salvage Yard, the Yard Office and the Small Yard, and is none of them | | East / West Division Point | — | the ends | Each card shows **Start positions** — where a train enters depending on direction, train type, and diff --git a/docs/station-master-seed493290760-day2.json b/docs/station-master-seed493290760-day2.json new file mode 100644 index 0000000..183b8be --- /dev/null +++ b/docs/station-master-seed493290760-day2.json @@ -0,0 +1,656 @@ +{ + "seed": 493290760, + "history": [ + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c138", + "placement": { + "row": 0, + "col": -1 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c144", + "placement": { + "row": 0, + "col": 1 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c11" + }, + { + "type": "card.play", + "cardId": "c24" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 1 + }, + { + "type": "card.play", + "cardId": "c31" + }, + { + "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.fromDepartment", + "slot": 1 + }, + { + "type": "card.play", + "cardId": "c182", + "placement": { + "row": 0, + "col": 1 + }, + "variant": 1 + }, + { + "type": "card.play", + "cardId": "c159", + "placement": { + "row": 1, + "col": 1 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c49", + "placement": { + "row": 1, + "col": 0 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 0 + }, + { + "type": "card.play", + "cardId": "c5" + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "tank", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "tank", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "caboose", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": false + }, + { + "type": "mainline.clearance", + "allow": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 2 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 1, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.end" + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 2 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": true + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 2 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 1, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 2 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": false + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 1, + "col": 0 + }, + "carType": "tank" + }, + { + "type": "laborer.startLoad", + "at": { + "row": 1, + "col": 0 + } + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 0 + }, + { + "type": "card.play", + "cardId": "c194", + "placement": { + "row": 0, + "col": -2 + }, + "variant": 1 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 1, + "col": 0 + }, + "box": 0 + }, + { + "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": 0 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 1, + "col": 0 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 1 + }, + { + "type": "card.play", + "cardId": "c154", + "placement": { + "row": 1, + "col": -2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c102", + "node": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "tank", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "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": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "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": "switch" + }, + { + "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": 2 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 2 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 1, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 2 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 1, + "col": -2 + }, + "reverse": true + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 3 + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 1, + "col": -2 + }, + "reverse": true + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 7827b32..acf6011 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.4.3", + "version": "0.4.4", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/public/replays/seed-1022551.json b/public/replays/seed-143542.json similarity index 76% rename from public/replays/seed-1022551.json rename to public/replays/seed-143542.json index defa6a0..08f98b9 100644 --- a/public/replays/seed-1022551.json +++ b/public/replays/seed-143542.json @@ -1,23 +1,26 @@ { - "seed": 1022551, - "title": "27 Revenue · seed 1022551", - "note": "27 Revenue over 5 Days · 2 train(s) on the timetable · 19 cards down · no collisions · played by developer", + "seed": 143542, + "title": "24 Revenue · seed 143542", + "note": "24 Revenue over 5 Days · 1 train(s) on the timetable · 14 cards down · no collisions · played by developer", + "rules": { + "startingHand": "threeRandom", + "revenue": { + "passengerPerCoach": 1, + "freightPerLoad": 1, + "trainPerTransit": 0 + } + }, "history": [ { "type": "localOps.choose", "option": "draw" }, { - "type": "draw.fromDepartment", - "slot": 0 + "type": "draw.fromHomeOffice" }, { "type": "card.play", - "cardId": "c7" - }, - { - "type": "card.play", - "cardId": "c206", + "cardId": "c123", "placement": { "row": 0, "col": 1 @@ -26,31 +29,13 @@ }, { "type": "card.play", - "cardId": "c179", + "cardId": "c205", "placement": { - "row": -1, - "col": 1 - }, - "variant": 1 - }, - { - "type": "card.play", - "cardId": "c63", - "placement": { - "row": 1, - "col": 0 + "row": 0, + "col": -1 }, "variant": 0 }, - { - "type": "card.play", - "cardId": "c160", - "placement": { - "row": -1, - "col": 0 - }, - "variant": 1 - }, { "type": "draw.end" }, @@ -62,12 +47,24 @@ "option": "draw" }, { - "type": "draw.fromDepartment", - "slot": 0 + "type": "draw.fromHomeOffice" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" }, { "type": "card.play", - "cardId": "c29" + "cardId": "c23" }, { "type": "draw.end" @@ -99,212 +96,27 @@ }, { "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": "c102", - "node": 1 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c111", - "toSlot": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c212", + "cardId": "c195", "placement": { "row": 0, - "col": -1 + "col": 1 }, "variant": 0 }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c71", - "toSlot": 1 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, { "type": "card.play", - "cardId": "c172", + "cardId": "c164", "placement": { "row": -1, - "col": -1 + "col": 1 }, "variant": 1 }, { "type": "card.play", - "cardId": "c36", + "cardId": "c45", "placement": { "row": -1, - "col": -2 - }, - "variant": 0 - }, - { - "type": "card.play", - "cardId": "c73", - "placement": { - "row": 0, - "col": -3 - }, - "variant": 0 - }, - { - "type": "card.play", - "cardId": "c162", - "placement": { - "row": -1, - "col": -3 - }, - "variant": 1 - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "boxcar", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "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": "c9" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c144", - "placement": { - "row": 0, "col": 2 }, "variant": 0 @@ -312,111 +124,6 @@ { "type": "draw.end" }, - { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.move", - "trayId": "tray2", - "to": { - "row": -1, - "col": -1 - }, - "reverse": true - }, - { - "type": "switch.move", - "trayId": "tray2", - "to": { - "row": 0, - "col": 0 - }, - "reverse": false - }, - { - "type": "switch.move", - "trayId": "tray2", - "to": { - "row": -1, - "col": -1 - }, - "reverse": true - }, - { - "type": "switch.move", - "trayId": "tray2", - "to": { - "row": 0, - "col": 0 - }, - "reverse": false - }, - { - "type": "switch.move", - "trayId": "tray2", - "to": { - "row": -1, - "col": -1 - }, - "reverse": true - }, - { - "type": "switch.move", - "trayId": "tray2", - "to": { - "row": 0, - "col": 0 - }, - "reverse": false - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": 0, - "col": 0 - }, - "index": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": 0, - "col": 0 - }, - "carType": "coach" - }, { "type": "loadUnload.end" }, @@ -429,55 +136,7 @@ }, { "type": "card.play", - "cardId": "c191", - "placement": { - "row": 0, - "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": "card.play", - "cardId": "c146", + "cardId": "c76", "placement": { "row": 0, "col": 3 @@ -498,34 +157,27 @@ "type": "draw.fromHomeOffice" }, { - "type": "card.discard", - "cardId": "c48", - "toSlot": 2 + "type": "card.play", + "cardId": "c14" }, { "type": "draw.end" }, { "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "boxcar", + "trayId": "tray3", + "carType": "hopper", "loaded": false }, { "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "boxcar", + "trayId": "tray3", + "carType": "hopper", "loaded": false }, { "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "boxcar", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", + "trayId": "tray3", "carType": "caboose", "loaded": true }, @@ -537,27 +189,320 @@ "option": "draw" }, { - "type": "draw.fromDepartment", - "slot": 2 + "type": "draw.fromHomeOffice" }, { - "type": "card.discard", - "cardId": "c156", - "toSlot": 2 + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c5" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c175", + "placement": { + "row": -1, + "col": -1 + }, + "variant": 1 + }, + { + "type": "draw.end" + }, + { + "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 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": -1, + "col": 2 + }, + "reverse": false + }, + { + "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": true + }, + { + "type": "switch.end" + }, + { + "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": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": -1, + "col": 2 + }, + "carType": "hopper" + }, + { + "type": "laborer.startLoad", + "at": { + "row": -1, + "col": 2 + } + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": -1, + "col": 2 + }, + "carType": "hopper" + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "mainline.clearance", + "allow": false + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "laborer.startLoad", + "at": { + "row": -1, + "col": 2 + } + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c19" }, { "type": "draw.end" }, { "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "boxcar", - "loaded": false + "trayId": "tray2", + "carType": "coach", + "loaded": true }, { "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", + "trayId": "tray2", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", "loaded": true }, { @@ -572,7 +517,29 @@ }, { "type": "card.play", - "cardId": "c208", + "cardId": "c36", + "placement": { + "row": -1, + "col": -2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c72", "placement": { "row": 0, "col": -3 @@ -594,10 +561,10 @@ }, { "type": "card.play", - "cardId": "c142", + "cardId": "c119", "placement": { "row": 0, - "col": -4 + "col": -2 }, "variant": 0 }, @@ -616,7 +583,7 @@ "trayId": "tray2", "to": { "row": -1, - "col": -1 + "col": -2 }, "reverse": true }, @@ -625,6 +592,11 @@ "trayId": "tray2", "count": 1 }, + { + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 + }, { "type": "switch.move", "trayId": "tray2", @@ -644,19 +616,24 @@ "reverse": true }, { - "type": "switch.move", + "type": "switch.dropCars", "trayId": "tray2", - "to": { - "row": -1, - "col": -1 - }, - "reverse": false + "count": 1 }, { "type": "switch.dropCars", "trayId": "tray2", "count": 1 }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 0 + }, + "reverse": false + }, { "type": "switch.move", "trayId": "tray2", @@ -676,11 +653,6 @@ "trayId": "tray2", "count": 1 }, - { - "type": "switch.dropCars", - "trayId": "tray2", - "count": 1 - }, { "type": "switch.move", "trayId": "tray2", @@ -691,26 +663,24 @@ "reverse": false }, { - "type": "loadUnload.end" + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": true }, { - "type": "localOps.choose", - "option": "freightAgent" + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": true }, { - "type": "freightAgent.stockOutbound", + "type": "laborer.beginUnload", "at": { "row": -1, "col": -2 }, - "carType": "boxcar" - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": -2 - } + "carIndex": 0 }, { "type": "laborer.advanceLoad", @@ -718,7 +688,7 @@ "row": -1, "col": -2 }, - "box": 0 + "box": 2 }, { "type": "loadUnload.end" @@ -749,7 +719,7 @@ "row": -1, "col": -2 }, - "box": 2 + "box": 0 }, { "type": "loadUnload.end" @@ -759,12 +729,12 @@ "option": "freightAgent" }, { - "type": "freightAgent.stockOutbound", + "type": "freightAgent.clearInbound", "at": { "row": -1, "col": -2 }, - "carType": "boxcar" + "index": 0 }, { "type": "porter.detrain", @@ -773,13 +743,6 @@ "col": 0 } }, - { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - } - }, { "type": "laborer.startLoad", "at": { @@ -829,62 +792,6 @@ { "type": "loadUnload.end" }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": 0, - "col": 0 - }, - "carType": "coach" - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": -2 - } - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, { "type": "localOps.choose", "option": "draw" @@ -894,7 +801,7 @@ }, { "type": "card.discard", - "cardId": "c26", + "cardId": "c90", "toSlot": 0 }, { @@ -932,28 +839,129 @@ "carType": "boxcar" }, { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "boxcar", - "loaded": true + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 }, { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "boxcar", - "loaded": true + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 0 }, { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "boxcar", - "loaded": true + "type": "loadUnload.end" }, { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "caboose", - "loaded": true + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": -1, + "col": -2 + }, + "index": 0 + }, + { + "type": "laborer.startLoad", + "at": { + "row": -1, + "col": -2 + } + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c117", + "placement": { + "row": 0, + "col": 2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": -2 + }, + "carIndex": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": -1, + "col": -2 + }, + "carType": "boxcar" }, { "type": "laborer.advanceLoad", @@ -986,17 +994,274 @@ }, "index": 0 }, + { + "type": "laborer.startLoad", + "at": { + "row": -1, + "col": -2 + } + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c87", + "placement": { + "row": 0, + "col": 2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, { "type": "newTrain.placeCar", - "trayId": "tray2", + "trayId": "tray3", "carType": "coach", "loaded": true }, { "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "boxcar", - "loaded": true + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": -2 + }, + "carIndex": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 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": 1 + }, + { + "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": "porter.board", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "laborer.startLoad", + "at": { + "row": -1, + "col": -2 + } + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c187", + "placement": { + "row": 0, + "col": 3 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": -2 + }, + "carIndex": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 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": 1 + }, + { + "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", @@ -1051,7 +1316,7 @@ }, { "type": "card.discard", - "cardId": "c85", + "cardId": "c114", "toSlot": 1 }, { @@ -1078,33 +1343,412 @@ }, { "type": "localOps.choose", - "option": "switch" + "option": "freightAgent" }, { - "type": "switch.move", - "trayId": "tray3", - "to": { + "type": "freightAgent.stockOutbound", + "at": { "row": -1, - "col": -1 + "col": -2 }, - "reverse": true + "carType": "boxcar" }, { - "type": "switch.dropCars", - "trayId": "tray3", - "count": 1 + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 }, { - "type": "switch.move", - "trayId": "tray3", - "to": { + "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": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c146", + "placement": { "row": 0, - "col": 0 + "col": -3 }, - "reverse": false + "variant": 0 }, { - "type": "switch.end" + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": -2 + }, + "carIndex": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 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": 1 + }, + { + "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": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c57", + "placement": { + "row": -1, + "col": 3 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": -2 + }, + "carIndex": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 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": 1 + }, + { + "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": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.discard", + "cardId": "c81", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c92", + "placement": { + "row": 0, + "col": -2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": -2 + }, + "carIndex": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c30" + }, + { + "type": "draw.end" }, { "type": "laborer.advanceLoad", @@ -1156,266 +1800,6 @@ { "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": 1 - }, - { - "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": "porter.detrain", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": -2 - } - }, - { - "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": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": 0, - "col": 0 - }, - "carType": "coach" - }, - { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": -2 - }, - "carIndex": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 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": 1 - }, - { - "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": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c147", - "placement": { - "row": 0, - "col": 4 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "boxcar", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "reefer", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "reefer", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "caboose", - "loaded": true - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, { "type": "localOps.choose", "option": "draw" @@ -1425,149 +1809,27 @@ }, { "type": "card.discard", - "cardId": "c48", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "reefer", - "loaded": true - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c61", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c163", - "toSlot": 2 - }, - { - "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": "c31" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c86", - "placement": { - "row": 0, - "col": 2 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c152", + "cardId": "c27", "toSlot": 0 }, { "type": "draw.end" }, { - "type": "porter.detrain", + "type": "laborer.advanceLoad", "at": { - "row": 0, - "col": 0 - } + "row": -1, + "col": -2 + }, + "box": 1 }, { - "type": "porter.board", + "type": "laborer.advanceLoad", "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 - } + "row": -1, + "col": -2 + }, + "box": 0 }, { "type": "loadUnload.end" @@ -1579,148 +1841,21 @@ { "type": "freightAgent.clearInbound", "at": { - "row": 0, - "col": 0 + "row": -1, + "col": -2 }, "index": 0 }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c120", - "placement": { - "row": 0, - "col": 5 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c96", - "placement": { - "row": 0, - "col": 3 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c109", - "placement": { - "row": 0, - "col": -5 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c48", - "toSlot": 1 - }, - { - "type": "draw.end" - }, { "type": "newTrain.placeCar", "trayId": "tray3", - "carType": "reefer", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "reefer", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "reefer", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "caboose", - "loaded": true - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 1 - }, - { - "type": "card.discard", - "cardId": "c61", - "toSlot": 1 - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", "carType": "coach", "loaded": false }, { "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "reefer", + "trayId": "tray3", + "carType": "coach", "loaded": false }, { @@ -1730,36 +1865,13 @@ "type": "localOps.choose", "option": "draw" }, - { - "type": "draw.fromDepartment", - "slot": 1 - }, - { - "type": "card.discard", - "cardId": "c150", - "toSlot": 1 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, { "type": "draw.fromHomeOffice" }, { - "type": "card.play", - "cardId": "c205", - "placement": { - "row": 0, - "col": -5 - }, - "variant": 0 + "type": "card.discard", + "cardId": "c60", + "toSlot": 1 }, { "type": "draw.end" diff --git a/public/replays/seed-1980750.json b/public/replays/seed-286084.json similarity index 65% rename from public/replays/seed-1980750.json rename to public/replays/seed-286084.json index 7a0bb3c..cbb14c9 100644 --- a/public/replays/seed-1980750.json +++ b/public/replays/seed-286084.json @@ -1,23 +1,26 @@ { - "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", + "seed": 286084, + "title": "13 Revenue · seed 286084", + "note": "13 Revenue over 5 Days · 2 train(s) on the timetable · 25 cards down · no collisions · played by developer", + "rules": { + "startingHand": "threeRandom", + "revenue": { + "passengerPerCoach": 1, + "freightPerLoad": 1, + "trainPerTransit": 0 + } + }, "history": [ { "type": "localOps.choose", "option": "draw" }, { - "type": "draw.fromDepartment", - "slot": 0 + "type": "draw.fromHomeOffice" }, { "type": "card.play", - "cardId": "c4" - }, - { - "type": "card.play", - "cardId": "c132", + "cardId": "c130", "placement": { "row": 0, "col": 1 @@ -26,55 +29,25 @@ }, { "type": "card.play", - "cardId": "c201", + "cardId": "c68", + "placement": { + "row": 1, + "col": 0 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c210", "placement": { "row": 0, "col": -1 }, "variant": 0 }, - { - "type": "card.play", - "cardId": "c165", - "placement": { - "row": -1, - "col": -1 - }, - "variant": 1 - }, - { - "type": "card.play", - "cardId": "c38", - "placement": { - "row": -1, - "col": -2 - }, - "variant": 0 - }, - { - "type": "card.play", - "cardId": "c43", - "placement": { - "row": -1, - "col": -3 - }, - "variant": 0 - }, { "type": "draw.end" }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", - "loaded": false - }, { "type": "loadUnload.end" }, @@ -98,6 +71,72 @@ { "type": "draw.fromHomeOffice" }, + { + "type": "card.play", + "cardId": "c184", + "placement": { + "row": 0, + "col": 1 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c153", + "placement": { + "row": -1, + "col": 1 + }, + "variant": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c66", + "placement": { + "row": -1, + "col": 0 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, { "type": "draw.end" }, @@ -113,7 +152,7 @@ }, { "type": "card.discard", - "cardId": "c106", + "cardId": "c71", "toSlot": 0 }, { @@ -131,7 +170,12 @@ }, { "type": "card.play", - "cardId": "c25" + "cardId": "c147", + "placement": { + "row": 0, + "col": -2 + }, + "variant": 0 }, { "type": "draw.end" @@ -139,54 +183,6 @@ { "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.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" @@ -196,10 +192,79 @@ }, { "type": "card.play", - "cardId": "c163", + "cardId": "c4" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.discard", + "cardId": "c70", + "toSlot": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c164", "placement": { "row": -1, - "col": -4 + "col": 2 + }, + "variant": 0 + }, + { + "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": "card.play", + "cardId": "c159", + "placement": { + "row": -2, + "col": 2 }, "variant": 1 }, @@ -218,256 +283,11 @@ }, { "type": "card.play", - "cardId": "c14" + "cardId": "c24" }, { "type": "draw.end" }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "boxcar", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "boxcar", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "caboose", - "loaded": true - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "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 - }, - "reverse": true - }, - { - "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": "tray2", - "carType": "coach", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": true - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -2 - }, - "carType": "boxcar" - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": -2 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -2 - }, - "carType": "boxcar" - }, - { - "type": "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": "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" }, @@ -483,14 +303,6 @@ }, "carType": "coach" }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 2 - }, { "type": "loadUnload.end" }, @@ -499,23 +311,16 @@ "option": "draw" }, { - "type": "draw.fromHomeOffice" + "type": "draw.fromDepartment", + "slot": 2 }, { - "type": "card.discard", - "cardId": "c90", - "toSlot": 1 + "type": "card.play", + "cardId": "c31" }, { "type": "draw.end" }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": -2 - } - }, { "type": "loadUnload.end" }, @@ -524,51 +329,12 @@ "option": "freightAgent" }, { - "type": "freightAgent.end" - }, - { - "type": "laborer.advanceLoad", + "type": "freightAgent.stockOutbound", "at": { - "row": -1, - "col": -2 + "row": 0, + "col": 0 }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 1 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 2 + "carType": "coach" }, { "type": "loadUnload.end" @@ -582,7 +348,337 @@ }, { "type": "card.play", - "cardId": "c76", + "cardId": "c145", + "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": "c97", + "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": "c41", + "placement": { + "row": -2, + "col": 3 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c155", + "placement": { + "row": -2, + "col": 4 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c17" + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "caboose", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c162", + "placement": { + "row": -3, + "col": 4 + }, + "variant": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c3" + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": true + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "boxcar", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "boxcar", + "loaded": false + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.discard", + "cardId": "c101", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c134", + "placement": { + "row": 0, + "col": 3 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c135", "placement": { "row": 0, "col": -3 @@ -592,93 +688,23 @@ { "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" + "option": "draw" }, { - "type": "switch.move", - "trayId": "tray1", - "to": { - "row": -1, - "col": -1 - }, - "reverse": false + "type": "draw.fromHomeOffice" }, { - "type": "switch.dropCars", - "trayId": "tray1", - "count": 1 + "type": "card.discard", + "cardId": "c110", + "toSlot": 0 }, { - "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": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -2 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 1 + "type": "draw.end" }, { "type": "loadUnload.end" @@ -692,10 +718,54 @@ }, { "type": "card.play", - "cardId": "c181", + "cardId": "c143", "placement": { "row": 0, - "col": -2 + "col": -4 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c156", + "placement": { + "row": -3, + "col": 5 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c188", + "placement": { + "row": 0, + "col": 4 }, "variant": 0 }, @@ -704,56 +774,53 @@ }, { "type": "newTrain.placeCar", - "trayId": "tray2", + "trayId": "tray3", "carType": "coach", "loaded": true }, { "type": "newTrain.placeCar", - "trayId": "tray2", + "trayId": "tray3", "carType": "coach", - "loaded": true - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 0 + "loaded": false }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "freightAgent" + "option": "draw" }, { - "type": "freightAgent.clearInbound", - "at": { - "row": -1, - "col": -2 - }, - "index": 0 + "type": "draw.fromHomeOffice" }, { - "type": "laborer.startLoad", + "type": "card.discard", + "cardId": "c83", + "toSlot": 1 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "boxcar", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "boxcar", + "loaded": false + }, + { + "type": "porter.board", "at": { - "row": -1, - "col": -2 + "row": 0, + "col": 0 } }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.end" - }, { "type": "porter.detrain", "at": { @@ -769,12 +836,11 @@ } }, { - "type": "laborer.advanceLoad", + "type": "porter.detrain", "at": { - "row": -1, - "col": -2 - }, - "box": 0 + "row": 0, + "col": 0 + } }, { "type": "loadUnload.end" @@ -791,16 +857,39 @@ }, "index": 0 }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c20" + }, + { + "type": "draw.end" + }, { "type": "newTrain.placeCar", "trayId": "tray3", - "carType": "hopper", + "carType": "boxcar", "loaded": false }, { "type": "newTrain.placeCar", "trayId": "tray3", - "carType": "hopper", + "carType": "boxcar", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", "loaded": false }, { @@ -809,37 +898,6 @@ "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" }, @@ -852,42 +910,29 @@ }, { "type": "card.discard", - "cardId": "c41", + "cardId": "c88", "toSlot": 2 }, { "type": "draw.end" }, - { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": -2 - }, - "carIndex": 0 - }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "freightAgent" + "option": "draw" }, { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -2 - }, - "carType": "boxcar" + "type": "draw.fromHomeOffice" }, { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 2 + "type": "card.discard", + "cardId": "c39", + "toSlot": 0 + }, + { + "type": "draw.end" }, { "type": "loadUnload.end" @@ -901,15 +946,28 @@ "trayId": "tray3", "to": { "row": -1, - "col": -1 + "col": 1 }, "reverse": false }, { "type": "switch.dropCars", "trayId": "tray3", - "count": 1, - "fromNose": true + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": -2, + "col": 3 + }, + "reverse": false + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 }, { "type": "switch.dropCars", @@ -928,11 +986,90 @@ { "type": "switch.end" }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": -2, + "col": 3 + }, + "carType": "boxcar" + }, + { + "type": "laborer.startLoad", + "at": { + "row": -2, + "col": 3 + } + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": -2, + "col": 3 + }, + "carType": "boxcar" + }, { "type": "laborer.advanceLoad", "at": { - "row": -1, - "col": -2 + "row": -2, + "col": 3 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 0 + }, + { + "type": "card.discard", + "cardId": "c55", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -2, + "col": 3 }, "box": 1 }, @@ -945,12 +1082,12 @@ }, { "type": "draw.fromDepartment", - "slot": 2 + "slot": 0 }, { "type": "card.discard", "cardId": "c36", - "toSlot": 2 + "toSlot": 0 }, { "type": "draw.end" @@ -958,43 +1095,41 @@ { "type": "laborer.advanceLoad", "at": { - "row": -1, - "col": -2 + "row": -2, + "col": 3 }, - "box": 0 + "box": 2 }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "freightAgent" + "option": "draw" }, { - "type": "freightAgent.clearInbound", - "at": { - "row": -1, - "col": -2 - }, - "index": 0 + "type": "draw.fromDepartment", + "slot": 0 + }, + { + "type": "card.discard", + "cardId": "c113", + "toSlot": 0 + }, + { + "type": "draw.end" }, { "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "hopper", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "hopper", + "trayId": "tray3", + "carType": "coach", "loaded": false }, { "type": "laborer.startLoad", "at": { - "row": -1, - "col": -2 + "row": -2, + "col": 3 } }, { @@ -1007,11 +1142,23 @@ { "type": "freightAgent.end" }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "boxcar", + "loaded": true + }, { "type": "laborer.advanceLoad", "at": { - "row": -1, - "col": -2 + "row": -2, + "col": 3 }, "box": 0 }, @@ -1028,8 +1175,8 @@ { "type": "laborer.advanceLoad", "at": { - "row": -1, - "col": -2 + "row": -2, + "col": 3 }, "box": 1 }, @@ -1043,23 +1190,11 @@ { "type": "freightAgent.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 + "row": -2, + "col": 3 }, "box": 2 }, @@ -1071,13 +1206,16 @@ "option": "draw" }, { - "type": "draw.fromDepartment", - "slot": 2 + "type": "draw.fromHomeOffice" }, { - "type": "card.discard", - "cardId": "c40", - "toSlot": 2 + "type": "card.play", + "cardId": "c65", + "placement": { + "row": 1, + "col": -1 + }, + "variant": 0 }, { "type": "draw.end" @@ -1085,8 +1223,8 @@ { "type": "laborer.beginUnload", "at": { - "row": -1, - "col": -2 + "row": -2, + "col": 3 }, "carIndex": 0 }, @@ -1100,240 +1238,16 @@ { "type": "freightAgent.stockOutbound", "at": { - "row": -1, - "col": -2 - }, - "carType": "boxcar" - }, - { - "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": "c53", - "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": "c41", - "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.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 1 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.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": "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": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -2 + "row": -2, + "col": 3 }, "carType": "boxcar" }, { "type": "laborer.advanceLoad", "at": { - "row": -1, - "col": -2 + "row": -2, + "col": 3 }, "box": 2 }, @@ -1345,13 +1259,16 @@ "option": "draw" }, { - "type": "draw.fromDepartment", - "slot": 2 + "type": "draw.fromHomeOffice" }, { - "type": "card.discard", - "cardId": "c40", - "toSlot": 2 + "type": "card.play", + "cardId": "c129", + "placement": { + "row": 0, + "col": 5 + }, + "variant": 0 }, { "type": "draw.end" @@ -1359,8 +1276,8 @@ { "type": "laborer.advanceLoad", "at": { - "row": -1, - "col": -2 + "row": -2, + "col": 3 }, "box": 1 }, @@ -1372,34 +1289,34 @@ "option": "draw" }, { - "type": "draw.fromDepartment", - "slot": 2 + "type": "draw.fromHomeOffice" }, { - "type": "card.discard", - "cardId": "c53", - "toSlot": 2 + "type": "card.play", + "cardId": "c182", + "placement": { + "row": -1, + "col": 4 + }, + "variant": 1 + }, + { + "type": "card.play", + "cardId": "c55", + "placement": { + "row": -1, + "col": 3 + }, + "variant": 0 }, { "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 + "row": -2, + "col": 3 }, "box": 0 }, @@ -1413,16 +1330,16 @@ { "type": "freightAgent.clearInbound", "at": { - "row": -1, - "col": -2 + "row": -2, + "col": 3 }, "index": 0 }, { "type": "laborer.startLoad", "at": { - "row": -1, - "col": -2 + "row": -2, + "col": 3 } }, { @@ -1430,205 +1347,16 @@ }, { "type": "localOps.choose", - "option": "draw" + "option": "freightAgent" }, { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c41", - "toSlot": 2 - }, - { - "type": "draw.end" + "type": "freightAgent.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": "c36", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "boxcar", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "boxcar", - "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": "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": 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": "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": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 + "row": -2, + "col": 3 }, "box": 0 }, @@ -1640,62 +1368,89 @@ "option": "freightAgent" }, { - "type": "freightAgent.clearInbound", + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", "at": { - "row": -1, - "col": -2 + "row": -2, + "col": 3 }, - "index": 0 + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -2, + "col": 3 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c161", + "placement": { + "row": -4, + "col": 5 + }, + "variant": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c99", + "placement": { + "row": 0, + "col": 2 + }, + "variant": 0 + }, + { + "type": "draw.end" }, { "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "boxcar", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "reefer", + "trayId": "tray3", + "carType": "hopper", "loaded": true }, { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c53", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c41", - "toSlot": 2 - }, - { - "type": "draw.end" + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "hopper", + "loaded": true }, { "type": "loadUnload.end" diff --git a/public/replays/seed-1703585.json b/public/replays/seed-309841.json similarity index 69% rename from public/replays/seed-1703585.json rename to public/replays/seed-309841.json index f64e70b..85542f9 100644 --- a/public/replays/seed-1703585.json +++ b/public/replays/seed-309841.json @@ -1,46 +1,26 @@ { - "seed": 1703585, - "title": "26 Revenue · seed 1703585", - "note": "26 Revenue over 5 Days · 3 train(s) on the timetable · 27 cards down · 4 collision(s) · played by developer", + "seed": 309841, + "title": "16 Revenue · seed 309841", + "note": "16 Revenue over 5 Days · 1 train(s) on the timetable · 23 cards down · no collisions · played by developer", + "rules": { + "startingHand": "threeRandom", + "revenue": { + "passengerPerCoach": 1, + "freightPerLoad": 1, + "trainPerTransit": 0 + } + }, "history": [ { "type": "localOps.choose", "option": "draw" }, { - "type": "draw.fromDepartment", - "slot": 0 + "type": "draw.fromHomeOffice" }, { "type": "card.play", - "cardId": "c13" - }, - { - "type": "card.play", - "cardId": "c143", - "placement": { - "row": 0, - "col": 1 - }, - "variant": 0 - }, - { - "type": "card.play", - "cardId": "c206", - "placement": { - "row": 0, - "col": -1 - }, - "variant": 0 - }, - { - "type": "card.play", - "cardId": "c166", - "placement": { - "row": -1, - "col": -1 - }, - "variant": 1 + "cardId": "c20" }, { "type": "draw.end" @@ -48,13 +28,19 @@ { "type": "newTrain.placeCar", "trayId": "tray3", - "carType": "reefer", + "carType": "boxcar", "loaded": true }, { "type": "newTrain.placeCar", "trayId": "tray3", - "carType": "reefer", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", "loaded": true }, { @@ -75,134 +61,28 @@ }, { "type": "card.play", - "cardId": "c49", - "placement": { - "row": -1, - "col": -2 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c39", - "placement": { - "row": -1, - "col": -3 - }, - "variant": 0 - }, - { - "type": "card.play", - "cardId": "c72", - "placement": { - "row": 0, - "col": -3 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c92", + "cardId": "c195", "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": "c1" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c25" - }, - { - "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": "c189", + "cardId": "c160", "placement": { - "row": 0, - "col": -2 + "row": -1, + "col": 1 + }, + "variant": 1 + }, + { + "type": "card.play", + "cardId": "c39", + "placement": { + "row": -1, + "col": 2 }, "variant": 0 }, @@ -237,7 +117,7 @@ "cardId": "c186", "placement": { "row": 0, - "col": 2 + "col": -1 }, "variant": 0 }, @@ -247,6 +127,480 @@ { "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 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": -1, + "col": 2 + }, + "reverse": false + }, + { + "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": true + }, + { + "type": "switch.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 2 + }, + "carIndex": 0 + }, + { + "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": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c171", + "placement": { + "row": -1, + "col": 3 + }, + "variant": 1 + }, + { + "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": "c63", + "placement": { + "row": 1, + "col": 0 + }, + "variant": 0 + }, + { + "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.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "draw.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 2 + }, + "carIndex": 0 + }, + { + "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": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c151", + "placement": { + "row": -1, + "col": -1 + }, + "variant": 1 + }, + { + "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": "c119", + "placement": { + "row": -1, + "col": 0 + }, + "variant": 0 + }, + { + "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.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.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": "c32", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 2 + }, + "carIndex": 0 + }, + { + "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": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c102", + "node": 1 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, { "type": "localOps.choose", "option": "draw" @@ -262,6 +616,90 @@ { "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.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 2 + }, { "type": "loadUnload.end" }, @@ -275,121 +713,6 @@ { "type": "card.play", "cardId": "c71", - "placement": { - "row": -2, - "col": -2 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c168", - "placement": { - "row": -1, - "col": -4 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c115", - "toSlot": 0 - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", - "loaded": true - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c82", - "toSlot": 1 - }, - { - "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": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c131", "placement": { "row": 0, "col": 3 @@ -399,6 +722,230 @@ { "type": "draw.end" }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 2 + }, + "carIndex": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 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": 1 + }, + { + "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": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 + }, + { + "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": "c76", + "toSlot": 1 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 2 + }, + "carIndex": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 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": 1 + }, + { + "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": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 2 + }, { "type": "loadUnload.end" }, @@ -411,18 +958,457 @@ }, { "type": "card.play", - "cardId": "c193", + "cardId": "c203", "placement": { + "row": 0, + "col": -2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.beginUnload", + "at": { "row": -1, "col": 2 }, + "carIndex": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 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": 1 + }, + { + "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": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c175", + "placement": { + "row": -1, + "col": -2 + }, "variant": 1 }, { "type": "card.play", - "cardId": "c59", + "cardId": "c159", "placement": { "row": -1, + "col": -3 + }, + "variant": 1 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 2 + }, + "carIndex": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 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": 1 + }, + { + "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": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c207", + "placement": { + "row": 0, + "col": 2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "draw.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 2 + }, + "carIndex": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c7" + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 + }, + { + "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.beginUnload", + "at": { + "row": -1, + "col": 2 + }, + "carIndex": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c183", + "placement": { + "row": 0, + "col": 3 + }, + "variant": 1 + }, + { + "type": "card.play", + "cardId": "c149", + "placement": { + "row": 1, + "col": 3 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c48", + "placement": { + "row": 1, + "col": 2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 + }, + { + "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": "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": "c69", + "placement": { + "row": -2, "col": 1 }, "variant": 0 @@ -442,10 +1428,19 @@ }, { "type": "card.play", - "cardId": "c146", + "cardId": "c141", "placement": { - "row": -1, - "col": 0 + "row": 0, + "col": -3 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c93", + "placement": { + "row": 0, + "col": -3 }, "variant": 0 }, @@ -462,50 +1457,12 @@ { "type": "draw.fromHomeOffice" }, - { - "type": "card.play", - "cardId": "c32" - }, - { - "type": "card.play", - "cardId": "c34" - }, { "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" @@ -515,10 +1472,10 @@ }, { "type": "card.play", - "cardId": "c65", + "cardId": "c165", "placement": { "row": 1, - "col": 0 + "col": 1 }, "variant": 0 }, @@ -528,52 +1485,6 @@ { "type": "loadUnload.end" }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": 0, - "col": 0 - }, - "carType": "coach" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "tank", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "tank", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "caboose", - "loaded": true - }, - { - "type": "loadUnload.end" - }, { "type": "localOps.choose", "option": "draw" @@ -583,9 +1494,9 @@ }, { "type": "card.play", - "cardId": "c46", + "cardId": "c77", "placement": { - "row": -1, + "row": 2, "col": 3 }, "variant": 0 @@ -594,20 +1505,27 @@ "type": "draw.end" }, { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": true + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "draw.end" }, { "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", + "trayId": "tray3", + "carType": "boxcar", "loaded": false }, { "type": "newTrain.placeCar", - "trayId": "tray2", + "trayId": "tray3", "carType": "coach", "loaded": false }, @@ -623,194 +1541,7 @@ }, { "type": "card.play", - "cardId": "c128", - "placement": { - "row": 0, - "col": -3 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c208", - "placement": { - "row": 0, - "col": -4 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "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": 0, - "col": 0 - }, - "reverse": true - }, - { - "type": "switch.move", - "trayId": "tray3", - "to": { - "row": -1, - "col": -2 - }, - "reverse": false - }, - { - "type": "switch.dropCars", - "trayId": "tray3", - "count": 1, - "fromNose": true - }, - { - "type": "switch.move", - "trayId": "tray3", - "to": { - "row": 0, - "col": 0 - }, - "reverse": true - }, - { - "type": "switch.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": 0, - "col": 0 - }, - "index": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c69", - "placement": { - "row": -2, - "col": -3 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c188", + "cardId": "c184", "placement": { "row": 0, "col": 4 @@ -830,738 +1561,14 @@ { "type": "draw.fromHomeOffice" }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c122", - "placement": { - "row": -1, - "col": 4 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c170", - "placement": { - "row": -2, - "col": -4 - }, - "variant": 1 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c176", - "placement": { - "row": -1, - "col": 5 - }, - "variant": 1 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c141", - "placement": { - "row": 0, - "col": 5 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "boxcar", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "boxcar", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "caboose", - "loaded": true - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c8" - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": false - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c161", - "placement": { - "row": -2, - "col": -5 - }, - "variant": 1 - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray1", - "carType": "boxcar", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray1", - "carType": "boxcar", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray1", - "carType": "boxcar", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray1", - "carType": "caboose", - "loaded": true - }, - { - "type": "mainline.clearance", - "allow": false - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "mainline.clearance", - "allow": false - }, - { - "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 - }, - { - "type": "switch.move", - "trayId": "tray3", - "to": { - "row": 0, - "col": 0 - }, - "reverse": true - }, - { - "type": "switch.move", - "trayId": "tray3", - "to": { - "row": -1, - "col": -3 - }, - "reverse": false - }, - { - "type": "switch.dropCars", - "trayId": "tray3", - "count": 2, - "fromNose": 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": true - }, - { - "type": "switch.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": 0, - "col": 0 - }, - "index": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": 0, - "col": 0 - }, - "index": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -3 - }, - "carType": "boxcar" - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": -3 - } - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -3 - }, - "carType": "boxcar" - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": -3 - } - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, { "type": "card.discard", - "cardId": "c85", + "cardId": "c80", "toSlot": 2 }, { "type": "draw.end" }, - { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": -3 - }, - "carIndex": 2 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 2 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 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": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": -1, - "col": -3 - }, - "index": 0 - }, - { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": -3 - }, - "carIndex": 2 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 2 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": -1, - "col": -3 - }, - "index": 0 - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "tank", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "tank", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "caboose", - "loaded": true - }, - { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": -3 - }, - "carIndex": 2 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 2 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": -1, - "col": -3 - }, - "index": 0 - }, - { - "type": "laborer.beginUnload", - "at": { - "row": -1, - "col": -3 - }, - "carIndex": 3 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 2 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": -1, - "col": -3 - }, - "index": 0 - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "tank", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "tank", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "boxcar", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "caboose", - "loaded": true - }, - { - "type": "mainline.clearance", - "allow": false - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -3 - }, - "carType": "boxcar" - }, - { - "type": "mainline.clearance", - "allow": false - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": -3 - } - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 2 - }, { "type": "loadUnload.end" }, @@ -1574,257 +1581,25 @@ "trayId": "tray3", "to": { "row": -1, - "col": -1 + "col": 1 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": -1, + "col": 3 }, "reverse": false }, { "type": "switch.dropCars", "trayId": "tray3", - "count": 1, + "count": 2, "fromNose": true }, - { - "type": "switch.dropCars", - "trayId": "tray3", - "count": 1 - }, - { - "type": "switch.move", - "trayId": "tray3", - "to": { - "row": 0, - "col": 0 - }, - "reverse": true - }, - { - "type": "switch.move", - "trayId": "tray3", - "to": { - "row": -1, - "col": -2 - }, - "reverse": false - }, - { - "type": "switch.dropCars", - "trayId": "tray3", - "count": 1 - }, - { - "type": "switch.move", - "trayId": "tray3", - "to": { - "row": 0, - "col": 0 - }, - "reverse": true - }, - { - "type": "switch.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -2 - }, - "carType": "tank" - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": -2 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -3 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 0 - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": -3 - } - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -3 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 1 - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": -3 - } - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -3 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 2 - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": -3 - } - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 0 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 1 - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -3 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, { "type": "switch.end" }, diff --git a/src/engine/advance.ts b/src/engine/advance.ts index 8899f73..008273f 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -27,6 +27,7 @@ import { STAGES_PER_DAY, STAGES_PER_SHIFT, collectiveRevenueFloor, + houseRules, lengthProfile, officeProfile, REGIONS_PER_MAINLINE_CARD, @@ -422,6 +423,26 @@ function enterMainline( ); node.transits.push({ tray: id, stagesRemaining: stages, stagesTotal: stages, direction: tray.direction }); tray.position = { at: 'mainline', index }; + + /** + * OUT OF THE DISTRICT, AND THE SPUR PORT GOES WITH IT. + * + * `facing` is a port on the card the train is standing on, and switching round a district leaves + * it holding a real compass port — 'n' or 's' off a curve. Nothing cleared it when the train + * departed, so it carried that port out onto a Division that runs east and west, and then into + * the next Office, whose card has no north or south edge at all. + * + * That is not cosmetic. `movesFor` explores from `facing` and from its opposite, and a card with + * neither port yields no destinations either way — so a train that had been shunted onto a spur + * arrived at the next Office **unable to make a single Move**. It also drew a ▲ on the Division + * map, where there is no north to point at. + * + * A train out here is running one way along an east-west railroad with its engine at one end, so + * this is what `facing` means on the Division; there is nothing else it could be. `railFacing` + * follows for the same reason — this IS the east-west sense, freshly known. + */ + tray.facing = tray.direction === 'west' ? 'w' : 'e'; + tray.railFacing = tray.facing; } /** @@ -931,17 +952,21 @@ function collide( * * A crew with no train number is a local switching move, not a run, so it earns nothing. * - * 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`. + * NOW A DIAL, AND OFF BY DEFAULT. At 1 it was worth ~5.4 Revenue against a bot mean of 7.0 — the + * railroad was making most of its money from the one thing no one has to work, and the freight and + * passenger economies it exists to reward could not be read through it. `trainPerTransit` sets the + * rate per player, and 0 (the default) means no event at all rather than a run of "+0" entries. */ function awardCompletedRun(s: GameState, tray: CrewTray, events: GameEvent[]): void { if (tray.trainNumber === null) return; + const rate = houseRules(s.config).revenue.trainPerTransit; + if (rate <= 0) return; for (const p of s.players) { - p.revenue += 1; + p.revenue += rate; events.push({ type: 'revenueChanged', player: p.index, - delta: 1, + delta: rate, total: p.revenue, reason: 'a train completed its run', }); diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 0ae559c..5a19d4d 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -25,6 +25,7 @@ import { industryProfile, mainlineModifierRule, mainlineProfile, + houseRules, modifierProfile, nextOfficeTier, officeProfile, @@ -56,6 +57,7 @@ import { canDropCarsAt, canPlaceAt, carriesThroughTrack, + exitsFrom, exploreMoves, facilityVariants, opposite, @@ -122,6 +124,20 @@ function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupanc }; } +/** + * The port a train that came in through `entry` would carry on out by — read off the CARD. + * + * On a straight that is `opposite(entry)`, which is what this used to assume everywhere. On a curve + * it is the other end of the arc, and the two are never the same: a curve joins ADJACENT edges. + * + * A card reached by a Move has exactly one exit from the port it was entered by — the only card with + * three is a turnout, and a train may not finish a Move on one (§A.1). The fallback is for a caller + * holding a card the walk never validated, and matches the old behaviour rather than throwing. + */ +function farPort(card: TrackCard | undefined, entry: Port): Port { + return (card ? exitsFrom(card, entry)[0] : undefined) ?? opposite(entry); +} + /** A tray's facing, expressed as the port it would leave by going forward. */ function facingPort(s: GameState, trayId: TrayId): Port { const tray = s.trays.get(trayId); @@ -1183,19 +1199,29 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { to: i.to, movesRemaining: turnOf(s, player).movesRemaining - 1, /** - * A TRAIN THAT BACKS UP HAS NOT TURNED AROUND. + * A TRAIN THAT BACKS UP HAS NOT TURNED AROUND — AND A CURVE IS NOT A STRAIGHT. * - * `facing` is which way the ENGINE points, and this set it to the direction of travel on - * every move — so one reverse move silently spun the train about. Everything then read - * "forward" again, and a run-around became pointless: you could change ends for free by - * backing up twice. + * `facing` is which way the ENGINE points, and this once set it to the direction of travel + * on every move — so one reverse move silently spun the train about, and a run-around + * became pointless: you could change ends for free by backing up twice. * - * Running forward the engine leads, so it points the way the train went: `opposite(entry)`. - * Backing up it trails, still pointing the way it came, which is the port it arrived - * through. Both hold around a curve, where the compass heading changes but the engine's - * relationship to its train does not. + * Backing up, the engine TRAILS, still pointing the way it came — out through the port the + * train arrived by. That holds whatever the track does underneath, so it is `dest.entry` + * and nothing else. + * + * Running forward, the engine LEADS, so it points out through the card's far end. That was + * written `opposite(entry)`, which is the far end of a straight and of nothing else: a + * curve is an arc between two ADJACENT edges, so entering a north-west curve through its + * west port leaves the engine facing NORTH, not east. The wrong port was not merely + * cosmetic — `movesFor` explores from `facing`, and a port the card does not have yields + * no destinations at all, so a crew that rounded a curve could only back out the way it + * came. Reported as a consist drawn mirrored, which is the other half of the same bug: the + * east-west sense the board draws is carried from `facing` (`railFacingOf`). + * + * `farPort` asks the CARD. A destination is never a turnout — a train may not finish a + * Move on one (§A.1) — so there is exactly one way out of it. */ - facing: i.reverse ? dest.entry : opposite(dest.entry), + facing: i.reverse ? dest.entry : farPort(areaOf(s, player).grid.get(coordKey(i.to)), dest.entry), }, ]; if (dest.couples.length > 0) { @@ -1419,16 +1445,21 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { }, ]; + /** + * A COACH PAYS AT BOTH ENDS OF ITS JOURNEY — once boarded, once detrained — and each end pays + * `passengerPerCoach` (`content.ts`). Half a passenger movement is half the work, and the rate + * is named per COACH because a Porter handles exactly one coach per action. + */ case 'porter.board': return [ { type: 'passengersBoarded', player, at: i.at }, - { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'boarding' }, + ...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'boarding'), ]; case 'porter.detrain': return [ { type: 'passengersDetrained', player, at: i.at }, - { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'detraining' }, + ...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'detraining'), ]; case 'laborer.startLoad': { @@ -1442,18 +1473,20 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { const load = workTrack(f)[i.box]!; const next = load.dir === 'out' ? i.box + 1 : i.box - 1; + // Like a coach, a load pays at both ends — made up outbound and broken inbound — and each end + // pays `freightPerLoad` (`content.ts`). if (next >= workTrack(f).length) { // Outbound complete: the load goes onto the spotted car (§9.3). return [ { type: 'loadCompleted', player, at: i.at, carType: load.type }, - { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'freightLoad' }, + ...earns(s, player, houseRules(s.config).revenue.freightPerLoad, 'freightLoad'), ]; } if (next < 0) { // Inbound complete: the load reaches the red Unloading box (§9.3). return [ { type: 'unloadCompleted', player, at: i.at, carType: load.type }, - { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'freightUnload' }, + ...earns(s, player, houseRules(s.config).revenue.freightPerLoad, 'freightUnload'), ]; } return [{ type: 'loadAdvanced', player, at: i.at, fromBox: i.box, toBox: next }]; @@ -1481,6 +1514,18 @@ function revenueAfter(s: GameState, player: PlayerIndex, delta: number): number return (s.players[player]?.revenue ?? 0) + delta; } +/** + * A revenue award at this game's rate, or NO EVENT AT ALL when the rate is zero. + * + * Zero is a real setting — it is how you switch one economy off to read the others — and a stream of + * "+0 Revenue" entries in the history panel would be the loudest possible way to say nothing + * happened. The work still happens; it just does not pay. + */ +function earns(s: GameState, player: PlayerIndex, rate: number, reason: string): GameEvent[] { + if (rate <= 0) return []; + return [{ type: 'revenueChanged', player, delta: rate, total: revenueAfter(s, player, rate), reason }]; +} + /** §7 — from the rolled slot, walk down the Timetable column, wrapping at the bottom. */ function findTimetableSlot(s: GameState, from: number): number | null { for (let i = 0; i < s.timetable.length; i++) { @@ -1509,7 +1554,12 @@ export function reduce(s: GameState, e: GameEvent): void { // A tray moving stays in the district it was already in — the seat does not change. const seat = tray.position.at === 'grid' ? tray.position.seat : 0; tray.position = { at: 'grid', seat, coord: e.to }; - if (e.facing) tray.facing = e.facing; + if (e.facing) { + tray.facing = e.facing; + // The east-west sense only exists on east-west track, so it is CARRIED across north-south + // track rather than recomputed there — see `railFacing` in state.ts. + if (e.facing === 'e' || e.facing === 'w') tray.railFacing = e.facing; + } // Only the player sitting in this district can be switching this tray, so the Moves come off // their turn. The event carries no player of its own. turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining; diff --git a/src/engine/content.ts b/src/engine/content.ts index 6d6974e..b21dbc5 100644 --- a/src/engine/content.ts +++ b/src/engine/content.ts @@ -417,7 +417,7 @@ export function consistSize(c: ConsistSpec): number { export type MainlineKind = | 'plains' | 'curves' | 'hilly' | 'heavyGrade' | 'doubleTrack' - | 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'yard'; + | 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'interchange'; /** * Speed as printed. `60` and `30` appear on the cards; Hilly prints P60/F30, and Heavy Grade @@ -437,7 +437,7 @@ export type MainlineProfile = { speed: MainlineSpeed; /** Double Track and Uncontrolled Siding: "Trains may pass". */ trainsMayPass: boolean; - /** Yard: "Sort cars in new order". */ + /** Interchange: "Sort cars in new order". */ sortsCars: boolean; /** Named entry points printed on the card; some are unlocked by modifier cards. */ entryPoints: readonly string[]; @@ -452,7 +452,14 @@ export const MAINLINE_PROFILES: readonly MainlineProfile[] = [ { kind: 'uncontrolledSiding', name: 'Uncontrolled Siding', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['noPass', 'passingTrains'] }, { kind: 'tunnel', name: 'Tunnel', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] }, { kind: 'trestle', name: 'Trestle', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] }, - { kind: 'yard', name: 'Yard', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] }, + /** + * RENAMED FROM "Yard" after play. The card is unchanged — same 60, same "sort cars in new + * order", same entry points, same art — but "Yard" collided with the Division Yard, the + * Classification Yard, the Salvage Yard, the Yard Office and the Small Yard, none of which are + * this. Its own key is renamed with it, so the two never drift apart. Those OTHER yards are + * deliberately left alone: they are different things that merely shared a word. + */ + { kind: 'interchange', name: 'Interchange', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] }, ]; /** @@ -756,13 +763,117 @@ export const STAGES_PER_SHIFT = 3; export const HAND_LIMIT = 3; /** - * The opening deal: 3 track cards and 3 others, from two separately shuffled piles (`setup.ts`). + * The split opening deal: 3 track cards and 3 others, from two separately shuffled piles + * (`setup.ts`). One of the three `StartingHand` options below, not the only one any more. * * Six against a limit of three on purpose — the first turn is spent choosing which district you can - * afford to build. PROVISIONAL, and flagged in `TODO.md` for review after play. + * afford to build. */ export const OPENING_TRACK = 3; export const OPENING_OTHER = 3; + +// --------------------------------------------------------------------------- +// House rules — the settings the New Game dialog offers +// --------------------------------------------------------------------------- + +/** + * WHAT EACH PLAYER OPENS HOLDING. + * + * Three answers, all of which have been the rule at some point, and none of which is obviously + * right — so the game stops guessing and asks whoever deals. + * + * - `threeRandom` — the prototype rule. Three cards off one deck, at the hand limit, no guarantees. + * - `sixRandom` — six off one deck, so the first turn is a discard and the opening is a choice, + * without the track being handed to you. + * - `threeTrackThreeOther` — three of each from separately shuffled piles. Introduced because a + * run-around needs five specific pieces and the bot held a turnout and a matching-hand curve + * together on 0.2% of turns; measured five ways, that was a SUPPLY problem, not a bot weakness. + */ +export type StartingHand = 'threeRandom' | 'sixRandom' | 'threeTrackThreeOther'; + +/** How many cards come off which pile, per `StartingHand`. `any` is dealt from the single deck. */ +export const OPENING_DEALS: Readonly> = { + threeRandom: { any: 3, track: 0, other: 0 }, + sixRandom: { any: 6, track: 0, other: 0 }, + threeTrackThreeOther: { any: 0, track: OPENING_TRACK, other: OPENING_OTHER }, +}; + +/** + * WHAT THE THREE WORKING ECONOMIES PAY. + * + * Balance is the open problem in this game — the developer bot averages 7.0 Revenue against a target + * of 20, of which most came from traffic nobody had to work — and the way to settle it is to play it + * at several settings rather than to keep re-deriving it. So the three rates are dials, set when the + * game is dealt and fixed for its duration. + * + * `passengerPerCoach` and `freightPerLoad` each pay on BOTH halves of their cycle: a coach pays when + * it is boarded and again when it is detrained, a load pays when it is made up outbound and again + * when it is broken inbound. That is what the rates have always done; these scale it. + * + * `trainPerTransit` pays every player, once, when a train runs off the end of the Division — it is + * the shared achievement, and every Office it crossed had to clear it. It defaults to 0 because at 1 + * it was worth ~5.4 of a 7.0 mean: the railroad was earning most of its money from traffic no one + * had to work, which drowned out the freight and passenger economies this game is actually about. + */ +export type RevenueRules = { + passengerPerCoach: number; + freightPerLoad: number; + trainPerTransit: number; +}; + +export type HouseRules = { startingHand: StartingHand; revenue: RevenueRules }; + +/** What a caller may name — any subset, down to none — resolved by `houseRules()`. */ +export type HouseRuleOverrides = { startingHand?: StartingHand; revenue?: Partial }; + +/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */ +export const REVENUE_MIN = 0; +export const REVENUE_MAX = 5; + +export const DEFAULT_HOUSE_RULES: HouseRules = { + startingHand: 'threeRandom', + revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 0 }, +}; + +/** + * THE RULES A SAVE THAT PREDATES THIS SETTING WAS PLAYED UNDER. + * + * A save is a seed and a list of intents, so it only replays under the ruleset that produced it — + * `TODO.md` records two published replays going dead unnoticed when the rules moved, one of them 42 + * intents into 360. Every save written from now on carries its rules; the ones already written do + * not, and this is what they meant. Do not "tidy" it into the defaults above: that silently kills + * the three replays in `public/replays/`. + */ +export const LEGACY_HOUSE_RULES: HouseRules = { + startingHand: 'threeTrackThreeOther', + revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 1 }, +}; + +/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */ +export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRules { + const given = config.houseRules ?? {}; + const rev = given.revenue ?? {}; + const clamp = (n: number | undefined, fallback: number): number => + typeof n === 'number' && Number.isFinite(n) + ? Math.max(REVENUE_MIN, Math.min(REVENUE_MAX, Math.round(n))) + : fallback; + const d = DEFAULT_HOUSE_RULES; + return { + startingHand: given.startingHand ?? d.startingHand, + revenue: { + passengerPerCoach: clamp(rev.passengerPerCoach, d.revenue.passengerPerCoach), + freightPerLoad: clamp(rev.freightPerLoad, d.revenue.freightPerLoad), + trainPerTransit: clamp(rev.trainPerTransit, d.revenue.trainPerTransit), + }, + }; +} + +/** What the dialog calls each option, in the order it offers them. */ +export const STARTING_HAND_LABELS: readonly { value: StartingHand; label: string }[] = [ + { value: 'threeRandom', label: 'Three random cards' }, + { value: 'sixRandom', label: 'Six random cards' }, + { value: 'threeTrackThreeOther', label: 'Three random track and three random non-track cards' }, +]; export const MAX_CONSIST = 4; export const MOVES_PER_LOCAL_OPS = 6; export const MOVES_PER_LOCAL_OPS_NIGHT = 5; diff --git a/src/engine/setup.ts b/src/engine/setup.ts index 6884334..80a7849 100644 --- a/src/engine/setup.ts +++ b/src/engine/setup.ts @@ -16,9 +16,9 @@ import { MOVES_PER_LOCAL_OPS, MODIFIER_PROFILES, OFFICE_PROFILES, - OPENING_OTHER, - OPENING_TRACK, + OPENING_DEALS, MAINLINE_PROFILES, + houseRules, mainlineProfile, TRACK_CARDS, ROLLING_STOCK_SUPPLY, @@ -287,53 +287,59 @@ export function createGame(opts: SetupOptions): GameState { .sort((a, b) => divisionRolls[a]! - divisionRolls[b]! || b - a); /** - * §4.6-4.7 — THE OPENING DEAL, dealt from two piles rather than one. + * §4.6-4.7 — THE OPENING DEAL, in whichever of the three shapes this game was dealt with. * - * Track is shuffled SEPARATELY from everything else and each player is dealt **3 track cards and 3 - * other cards**; the track left over is then shuffled back in and the game runs off one deck as - * before. The player opens holding six against a limit of three, so the first turn is spent + * `threeRandom` and `sixRandom` deal off ONE shuffled deck; `threeTrackThreeOther` shuffles track + * separately and deals three of each, then shuffles the remainder back together so the game runs + * off one deck from the first draw onward either way. `content.ts` says what each option is for. + * + * A player dealt six holds six against a limit of three, on purpose: the first turn is spent * choosing what to keep — draw as usual, then play or discard down to three (§6.2, enforced on - * `draw.end`, which needs no special case for this). + * `draw.end`, which needs no special case for this). A player dealt three is already at the limit. * - * WHY. A run-around needs five specific pieces in a usable order, and the bot held a turnout and a - * matching-hand curve together on **0.2% of turns** — about once every eight games. Measured five - * ways, that is a SUPPLY problem and not a bot weakness: 91 run-arounds per 100 games when track - * was a private 26-piece supply the player chose from, 29 per 100 once it was drawn, 4 per 60 with - * track in the deck. Dealing the opening district as track restores something close to the - * prototype's private supply without reintroducing an unbounded one, and makes the opening a - * decision rather than a wait. - * - * PROVISIONAL — flagged for review once it has been played. See `TODO.md`. + * §4.7 — "starting from the Superintendent, deal each player…", which proceeds round the table and + * is therefore SEAT order, not player order. That is the same in all three shapes. */ + const rules = houseRules(config); + const deal = OPENING_DEALS[rules.startingHand]; const deck = buildDeck(config.mode); const cards = new Map(); for (const c of deck) cards.set(c.id, c); const isTrack = (id: CardId): boolean => cards.get(id)?.kind.kind === 'track'; const ids = deck.map((c) => c.id); - // Shuffled in a fixed order — track first — so the RNG stream is deterministic for a given seed. - const trackPile = rng.shuffle(ids.filter(isTrack)); - const otherPile = rng.shuffle(ids.filter((id) => !isTrack(id))); - - const hands = new Map(); - let trackCursor = 0; - let otherCursor = 0; - // §4.7 — "starting from the Superintendent, deal each player…", which proceeds round the table - // and is therefore seat order, not player order. const superSeat = seating.indexOf(superintendent); - for (let i = 0; i < playerCount; i++) { - const p = seating[(superSeat + i) % playerCount]!; - hands.set(p, [ - ...trackPile.slice(trackCursor, trackCursor + OPENING_TRACK), - ...otherPile.slice(otherCursor, otherCursor + OPENING_OTHER), - ]); - trackCursor += OPENING_TRACK; - otherCursor += OPENING_OTHER; - } + const hands = new Map(); + const dealtTo = (i: number): PlayerIndex => seating[(superSeat + i) % playerCount]!; - // The remainder goes back into ONE deck for the rest of the game — the split is an opening-deal - // device only, so track competes for the draw exactly as before from the first draw onward. - const shuffled = rng.shuffle([...trackPile.slice(trackCursor), ...otherPile.slice(otherCursor)]); + let shuffled: CardId[]; + if (deal.any > 0) { + // One deck, one shuffle, the top cards off it — the prototype's own deal. + const single = rng.shuffle(ids); + let cut = 0; + for (let i = 0; i < playerCount; i++) { + hands.set(dealtTo(i), single.slice(cut, cut + deal.any)); + cut += deal.any; + } + shuffled = single.slice(cut); + } else { + // Shuffled in a fixed order — track first — so the RNG stream is deterministic for a given seed. + const trackPile = rng.shuffle(ids.filter(isTrack)); + const otherPile = rng.shuffle(ids.filter((id) => !isTrack(id))); + let trackCursor = 0; + let otherCursor = 0; + for (let i = 0; i < playerCount; i++) { + hands.set(dealtTo(i), [ + ...trackPile.slice(trackCursor, trackCursor + deal.track), + ...otherPile.slice(otherCursor, otherCursor + deal.other), + ]); + trackCursor += deal.track; + otherCursor += deal.other; + } + // The remainder goes back into ONE deck for the rest of the game — the split is an opening-deal + // device only, so track competes for the draw exactly as before from the first draw onward. + shuffled = rng.shuffle([...trackPile.slice(trackCursor), ...otherPile.slice(otherCursor)]); + } // §4.6-4.7 — three cards turned face up beside the deck. Each is the bottom of a Department pile // that grows as players discard onto it. Taken after the recombination, so a Department slot can diff --git a/src/engine/state.ts b/src/engine/state.ts index 3f5647b..fd0709a 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -13,6 +13,7 @@ import type { MainlineKind, FreightKind, GameLength, + HouseRuleOverrides, ModifierKind, OfficeTier, TrackGeometry, @@ -282,6 +283,28 @@ export type CrewTray = { * without one falls back to `direction`. */ facing?: 'n' | 's' | 'e' | 'w'; + /** + * WHICH WAY THE ENGINE POINTS IN RAILROAD TERMS — east or west, and never anything else. + * + * `facing` is a PORT, because movement needs one: a crew on a north-south spur must be able to + * leave by 'n' or 's'. But a Division runs east and west, and a player reads a train the way a + * railroader does — "the engine is on the west end" — so a ▲ on a crew that had turned onto a + * spur read as a train that had somehow stood itself on end. Worse, nothing resets `facing` when + * a train leaves the district, so a crew that shunted onto a north-south spur and then departed + * carried its 'n' out onto the Division map and drew ▲ there too. + * + * So this is the DISPLAY facing, and it is a separate field because it cannot be derived: on + * north-south track the east-west sense is not in the current port, it is in the last one. It + * holds its value across north-south track and updates whenever `facing` becomes 'e' or 'w' — + * which is exactly the railroad's own convention, where compass north on a branch is still + * timetable east. A train that runs forward through 180° of curves genuinely does come out + * pointing the other way, and this follows it; backing up does not change it, because a train + * that backs up has not turned around. + * + * NOT `direction`: that is the timetable direction of the RUN and does not move when a run-around + * puts the engine on the other end, which is the one thing the arrow exists to show. + */ + railFacing?: 'e' | 'w'; position: NodeRef; movesUsed: number; /** @@ -468,6 +491,14 @@ export type GameConfig = { employeeRotation: boolean; emergencyToolbox: boolean; }; + /** + * The opening deal and the three revenue rates, chosen when the game is dealt (`content.ts`). + * + * Optional and PARTIAL on purpose. Every caller that does not care about them — and most of the + * engine tests do not — gets `DEFAULT_HOUSE_RULES` through `houseRules()`, which is the one place + * a default is written down. A caller that cares names only the dials it is setting. + */ + houseRules?: HouseRuleOverrides; }; export type OutcomeReason = @@ -524,6 +555,21 @@ export function freshTurns(players: number, moves: number): Map): 'e' | 'w' { + if (tray.railFacing) return tray.railFacing; + if (tray.facing === 'e' || tray.facing === 'w') return tray.facing; + return tray.direction === 'west' ? 'w' : 'e'; +} + export function turnOf(s: GameState, player: PlayerIndex): TurnState { const t = s.turns.get(player); if (!t) throw new Error(`no turn state for player ${player}`); diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index 4f4eaa0..327c958 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -77,7 +77,8 @@ export function divisionSvg(nodes: DivisionView[]): string { /** Nose first, no engine — drawn as blocks, loaded solid and empty hollow. */ cars?: string[]; engineAt?: number; - facing?: string; + /** East or west, always — a Division runs east and west and so does its rolling stock. */ + facing?: 'e' | 'w'; region?: number; direction?: string; stagesLeft?: number; @@ -291,7 +292,7 @@ export function divisionSvg(nodes: DivisionView[]): string { */ c.trains.forEach((t, k) => { const cars = t.cars ?? []; - const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc'; + const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6'; const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length; const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`; @@ -704,7 +705,7 @@ export function officeSvg( kind: /^loaded/.test(c) || /caboose/.test(c) ? 'ld' : 'mt', what: c, })); - const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc'; + const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6'; items.splice(t.engineAt, 0, { label: arrow, kind: 'eng', what: 'the engine' }); /** * WEST ON THE LEFT, AND THE NOSE POINTING THE WAY THE ENGINE FACES. @@ -715,15 +716,19 @@ export function officeSvg( * drawn engine-first at the WEST end, which reads as an engine shoving four cars ahead of it. * * The board is a map, so the drawing has to obey the map: reverse the seating order for an - * east-facing train and its nose lands at the east end, where it is. A crew on a north-south - * spur has no left or right to be right about, so it keeps nose-left and its \u25b2/\u25bc says the rest. + * east-facing train and its nose lands at the east end, where it is. + * + * A crew on a north-south spur is drawn east-west like every other train, because `facing` is + * now always east or west (`railFacingOf`). It used to keep its compass port and draw \u25b2 or \u25bc + * with the consist pinned nose-left, which meant the one thing the picture is for \u2014 which end + * the engine is on \u2014 flipped its convention the moment a crew turned a corner. Playtested and + * reported as more confusing than a strip drawn the same way every time. */ const laid = t.facing === 'e' ? [...items].reverse() : items; const cw = 17; const tw = Math.min(W - 8, laid.length * cw + 30); const tx = W / 2 - tw / 2; - const facingWord = - t.facing === 'e' ? 'east' : t.facing === 'w' ? 'west' : t.facing === 'n' ? 'north' : 'south'; + const facingWord = t.facing === 'e' ? 'east' : 'west'; const consistWords = t.cars.length === 0 ? 'no cars' : t.cars.join(', '); out += ` ({ @@ -1579,7 +1591,7 @@ function trainChip(s: GameState, id: string): TrainChip { consist: seated, cars, engineAt: at, - facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'), + facing: railFacingOf(t), }; } diff --git a/src/web/game.ts b/src/web/game.ts index 0ca0caf..39d623e 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -42,8 +42,15 @@ import { trainName, variantLabel, } from '../sim/view.ts'; -import { HAND_LIMIT, mainlineProfile, trainProfile } from '../engine/content.ts'; -import type { Hand, TrackGeometry } from '../engine/content.ts'; +import { + DEFAULT_HOUSE_RULES, + HAND_LIMIT, + LEGACY_HOUSE_RULES, + houseRules, + mainlineProfile, + trainProfile, +} from '../engine/content.ts'; +import type { Hand, HouseRuleOverrides, TrackGeometry } from '../engine/content.ts'; import type { Port } from '../engine/track.ts'; import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts'; import { areaOf, trainNeedingCars } from '../engine/apply.ts'; @@ -59,8 +66,16 @@ export const SOLO_CONFIG: GameConfig = { employeeRotation: false, emergencyToolbox: false, }, + // Spelt out rather than left to fall through, so a save written by the page always names the rules + // it was played under — see `Save.rules`. + houseRules: DEFAULT_HOUSE_RULES, }; +/** The same config with the New Game dialog's answers in it. */ +export function configWith(rules: HouseRuleOverrides): GameConfig { + return { ...SOLO_CONFIG, houseRules: houseRules({ houseRules: rules }) }; +} + /** A group of legal actions of one kind, ready to put on screen. */ export type ActionGroup = { kind: string; @@ -708,10 +723,31 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul // Saving — seed plus intents, replayed // --------------------------------------------------------------------------- -export type Save = { seed: number; history: Intent[] }; +/** + * A save is a seed, the house rules it was dealt under, and the intents submitted. + * + * `rules` is optional because saves written before the New Game dialog existed do not have it, and + * those replay under `LEGACY_HOUSE_RULES` — see `configFor`. Everything written from now on carries + * its rules, so a save can never again be silently re-dealt by a change of default. + */ +export type Save = { seed: number; history: Intent[]; rules?: HouseRuleOverrides }; export function toSave(game: Game): Save { - return { seed: game.seed, history: game.history }; + const rules = game.state.config.houseRules; + return rules ? { seed: game.seed, history: game.history, rules } : { seed: game.seed, history: game.history }; +} + +/** + * THE CONFIG A SAVE MUST BE REPLAYED UNDER, which is not necessarily today's default. + * + * A save is a seed and a list of intents: replay it under different rules and it is a different + * game, and the symptom is not an error but a replay that quietly stops early. `TODO.md` records + * that happening twice unnoticed, once 42 intents into 360. So a save that names its rules gets + * exactly those, and a save that names none is from before the dialog and gets the rules that were + * in force then — never the current defaults. + */ +function configFor(save: Save, config: GameConfig): GameConfig { + return { ...config, houseRules: save.rules ?? LEGACY_HOUSE_RULES }; } /** @@ -736,7 +772,9 @@ export function toSave(game: Game): Save { */ export function undo(game: Game, config: GameConfig = SOLO_CONFIG): Game | null { if (game.history.length === 0) return null; - return fromSave({ seed: game.seed, history: game.history.slice(0, -1) }, config); + // `toSave` first, so the rules this game was dealt under come with it. Rebuilding the save by hand + // here dropped them, and undo re-dealt the game under the defaults instead of its own settings. + return fromSave({ ...toSave(game), history: game.history.slice(0, -1) }, config); } /** @@ -747,7 +785,7 @@ export function undo(game: Game, config: GameConfig = SOLO_CONFIG): Game | null * stops the replay rather than being forced — better a short game than a corrupt one. */ export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game { - const game = newGame(save.seed, config); + const game = newGame(save.seed, configFor(save, config)); for (const intent of save.history) { const actor = currentActor(game); if (actor === null) break; diff --git a/src/web/main.ts b/src/web/main.ts index 6d60798..5bdea45 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -12,7 +12,8 @@ import type { Menu, Save } from './game.ts'; import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts'; import { TOOLTIP_CSS, installTooltips } from './tooltip.ts'; import { playCue } from './sound.ts'; -import { MOVES_PER_LOCAL_OPS } from '../engine/content.ts'; +import { MOVES_PER_LOCAL_OPS, STARTING_HAND_LABELS, houseRules } from '../engine/content.ts'; +import type { HouseRuleOverrides, HouseRules, RevenueRules, StartingHand } from '../engine/content.ts'; import type { LocalSession } from './session.ts'; import { createLocalSession } from './session.ts'; @@ -112,14 +113,73 @@ function renderTurnChart(f: Frame): void { $('turnchart').innerHTML = turnChartHtml(f, actorName); } +/** + * The settings this game was dealt under, beside the seed, because the seed alone does not name it. + * + * Abbreviated to fit a header that must not wrap — `3 cards · 1/1/0` — with the whole of it in the + * tooltip. Written down at all because a playtest note is worthless without it: "scored 4" means one + * thing at 1 Revenue per transit and another at 5. + */ +function renderHouseRules(rules: HouseRules): void { + const { passengerPerCoach: pax, freightPerLoad: frt, trainPerTransit: trn } = rules.revenue; + const short = { threeRandom: '3 cards', sixRandom: '6 cards', threeTrackThreeOther: '3+3 cards' }; + const el = $('houserules'); + el.textContent = `· ${short[rules.startingHand]} · ${pax}/${frt}/${trn}`; + const handWords = STARTING_HAND_LABELS.find((o) => o.value === rules.startingHand)?.label ?? ''; + el.title = + `Opening hand: ${handWords.toLowerCase()}.\n` + + `Passenger revenue per coach: ${pax} (paid on boarding and again on detraining).\n` + + `Freight revenue per load: ${frt} (paid on loading and again on unloading).\n` + + `Train revenue per transit: ${trn} (paid to every player when a train leaves the Division).`; +} + +/** + * THE HOUSE RULES TRAVEL IN THE URL, BESIDE THE SEED. + * + * A seed on its own no longer names a game: `?seed=430` dealt three random cards is a different + * railroad from `?seed=430` dealt three track and three other, and at 0 Revenue per transit it is a + * different economy again. The link has to carry all of it or "same link, same deal" stops being + * true — and the New Game dialog navigates by URL, so this is also how its answers reach `start()`. + * + * Absent parameters mean the DEFAULTS, not the legacy rules: a bare `?seed=430` is a new game at + * today's settings. It is a save with no rules in it that is old (`game.ts`, `configFor`). + */ +const RULE_PARAMS = { passenger: 'passengerPerCoach', freight: 'freightPerLoad', transit: 'trainPerTransit' } as const; + +function rulesFromUrl(params: URLSearchParams): HouseRuleOverrides { + const rules: HouseRuleOverrides = {}; + const hand = params.get('hand'); + if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand; + + const revenue: Partial = {}; + for (const [param, key] of Object.entries(RULE_PARAMS)) { + const raw = params.get(param); + // `houseRules()` clamps and rounds, so anything hand-edited into the URL lands in range rather + // than dealing a game at 900 Revenue a coach. + if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) revenue[key] = Number(raw); + } + if (Object.keys(revenue).length > 0) rules.revenue = revenue; + return rules; +} + +function rulesToUrl(rules: HouseRules, seed: string): string { + const params = new URLSearchParams(); + if (seed !== '') params.set('seed', seed); + params.set('hand', rules.startingHand); + for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key])); + return `?${params}`; +} + function start(): void { const params = new URLSearchParams(location.search); const requested = params.get('seed'); // A seed in the URL makes a game shareable and reproducible: same link, same deal. const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9); - session = createLocalSession(seed); + session = createLocalSession(seed, rulesFromUrl(params)); + // A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see + // `configFor`. That is why the restore happens after the session is built rather than feeding it. const saved = load(); if (saved && requested === null) session.restore(saved); @@ -185,6 +245,7 @@ function render(): void { obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`; obj.className = 'pace'; $('seed').textContent = String(session.seed()); + renderHouseRules(f.houseRules); // -- division $('division').innerHTML = divisionSvg(f.division); @@ -828,35 +889,76 @@ if (saveBtn) saveBtn.onclick = downloadSave; * steps back one action at a time, but nothing brings back a game that has been dealt over, and the * replay download is right beside it. * - * `location.search = ''` rather than a direct re-render, so a `?seed=` in the URL goes too — leaving - * it would deal the same game again and look like the button had done nothing. + * Navigating rather than re-rendering, so a stale `?seed=` in the URL goes too — leaving it would + * deal the same game again and look like the button had done nothing. */ const newBtn = document.getElementById('newgame'); -if (newBtn) { +const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null; +if (newBtn && dlg) { + const field = (id: string): T => document.getElementById(id) as T; + + /** + * ASK FOR ALL THREE, rather than documenting URL parameters in the title bar. + * + * It asked for the seed alone, through `prompt()`. The opening hand and the three revenue rates + * were constants in the source, so trying a variation meant an edit and a rebuild — and balance is + * the open question this game has (`TODO.md`). A dialog is what lets a playtest be a playtest. + * + * The dialog OPENS ON THE RULES IN PLAY rather than on the defaults: dealing a second game to + * compare against the first is the common case, and re-entering four settings each time is how a + * comparison silently stops comparing. + */ newBtn.onclick = () => { const f = session.view(); const day = f.day; const started = f.status === 'active' && (day > 1 || f.stage > 1); if (started && !confirm(`Forget this game (seed ${session.seed()}, Day ${day}) and deal a new one?`)) return; - /** - * ASK FOR THE SEED, rather than documenting a URL parameter in the title bar. - * - * The same deal can be replayed, shared or compared by seed, which is worth offering — it was - * offered as the note "add ?seed=1234 for a set deal", which spent width on the one line that - * must not wrap to explain a thing the button could simply ask. Blank means random. - */ - const asked = prompt('Seed for the new game — leave blank for a random one:', ''); - if (asked === null) return; // cancelled - clearSave(); - const wanted = asked.trim(); - if (wanted === '') { - // No `?seed=`, so `start()` rolls one. Reload rather than re-render, to clear any seed in the URL. - if (location.search === '') location.reload(); - else location.search = ''; - return; + + const current = session.view().houseRules; + field('ng-seed').value = ''; + for (const input of dlg.querySelectorAll('input[name="ng-hand"]')) { + input.checked = input.value === current.startingHand; } - location.search = `?seed=${encodeURIComponent(wanted)}`; + field('ng-passenger').value = String(current.revenue.passengerPerCoach); + field('ng-freight').value = String(current.revenue.freightPerLoad); + field('ng-transit').value = String(current.revenue.trainPerTransit); + dlg.showModal(); }; + + /** + * One handler for every way the dialog can close — the Deal button, the Cancel button, and Esc, + * which `` answers with an empty `returnValue` and no submit event at all. + * + * The answers go into the URL and the page navigates, which is the same path `?seed=` already + * took: `start()` reads them back, so there is exactly one place that turns a URL into a game. + */ + dlg.addEventListener('close', () => { + if (dlg.returnValue !== 'deal') return; + + const asked = field('ng-seed').value.trim(); + // A seed the browser cannot parse is not a reason to refuse to deal — blank and unparseable + // both mean "surprise me", which is what leaving the box alone plainly asks for. + const seed = asked === '' || !Number.isFinite(Number(asked)) ? '' : String(Math.trunc(Number(asked))); + const picked = dlg.querySelector('input[name="ng-hand"]:checked')?.value; + const rules = houseRules({ + houseRules: { + ...(STARTING_HAND_LABELS.some((o) => o.value === picked) ? { startingHand: picked as StartingHand } : {}), + revenue: { + passengerPerCoach: Number(field('ng-passenger').value), + freightPerLoad: Number(field('ng-freight').value), + trainPerTransit: Number(field('ng-transit').value), + }, + }, + }); + + clearSave(); + const next = rulesToUrl(rules, seed); + // Assigning the search string the page ALREADY has does nothing at all, which reads as a button + // that did not work — and it is the common case: deal a random seed, decide it was a bad deal, + // deal another at the same settings. Reload instead, and `start()` rolls a fresh seed. + if (next === location.search) location.reload(); + else location.search = next; + }); } const soundBtn = document.getElementById('sound'); diff --git a/src/web/play.html b/src/web/play.html index 01348bd..77f2232 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -40,6 +40,32 @@ header button:disabled:hover{border-color:#2c333d} .pace.good{background:rgba(40,140,60,.32)} .pace.behind{background:rgba(190,120,40,.28)} .yard.bare{outline:1px dashed #e0a060;outline-offset:3px;border-radius:4px;padding:3px} +/* NEW GAME DIALOG. Native , so Esc closes it and focus is trapped without any of that + being written here. Everything below is only colour and spacing: the browser's own white-on-white + default is unreadable against this page. */ +dialog{background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:9px; + padding:16px 18px;max-width:520px;width:calc(100% - 32px);font:inherit;max-height:86vh;overflow:auto} +dialog::backdrop{background:rgba(0,0,0,.62)} +dialog h3{margin-top:15px} +/* The rationale under each heading, not beside each control — the reasons are sentences, and a + sentence squeezed into a label column wraps into noise. */ +.ng-note{color:var(--dim);font-size:11.5px;margin:0 0 8px;line-height:1.45} +dialog input[type=text],dialog input[type=number]{background:#0f1318;color:var(--fg); + border:1px solid var(--line);border-radius:5px;padding:4px 7px;font:inherit;font-size:13px} +dialog input[type=text]{width:100%} +dialog input[type=number]{width:64px;text-align:right} +dialog input:focus{outline:none;border-color:#4d6fa8} +/* Whole rows, so the click target is the sentence and not the 13px circle beside it. */ +.ng-radio{display:flex;gap:9px;align-items:flex-start;padding:6px 7px;border-radius:5px;cursor:pointer} +.ng-radio:hover{background:#20262e} +.ng-radio input{margin-top:3px;flex:0 0 auto} +.ng-radio .dim{font-size:11.5px} +.ng-num{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:4px 7px} +.ng-buttons{display:flex;justify-content:flex-end;gap:8px;margin:16px 0 0;padding:0} +.ng-buttons button{background:#2a3038;color:inherit;border:1px solid var(--line);border-radius:5px; + padding:5px 14px;cursor:pointer;font:inherit;font-size:13px} +.ng-buttons button:hover{border-color:#4d6fa8} +#ng-deal{background:#31527f;border-color:#4d6fa8} main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start} @media(max-width:1100px){main{grid-template-columns:1fr}} section{background:var(--panel);border:1px solid var(--line);border-radius:7px; @@ -176,10 +202,15 @@ ul.blocked li{padding:2px 0} engine's guess at what your score ought to be were noise on the one line that must not wrap. --> — seed — + + — - + replays __BUILD__ @@ -248,6 +279,53 @@ ul.blocked li{padding:2px 0} + + +
+

New game

+ +

Seed

+

The same seed and the same settings always deal the same railroad, so a game can be shared, compared or replayed. Leave it blank for a random one.

+ + +

Starting hand

+

What each player is dealt before the first turn. The hand limit is three either way — deal six and the first turn is spent choosing which of them to keep.

+ + + + +

Revenue

+

What each piece of work pays, 0 to 5. A coach pays when it is boarded and again when it is detrained; a load pays when it is made up and again when it is broken. Zero switches an economy off so the others can be read.

+ + + +

A transit pays every player, once, when a train runs off the end of the Division — the one thing nobody has to work for. It defaults to 0 for that reason.

+ + + + + +
+
+ diff --git a/src/web/session.ts b/src/web/session.ts index 921d632..c464343 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -17,10 +17,12 @@ import type { Intent } from '../engine/intents.ts'; import type { Frame } from '../sim/view.ts'; -import type { GameConfig, PlayerIndex } from '../engine/state.ts'; +import type { PlayerIndex } from '../engine/state.ts'; +import type { HouseRuleOverrides } from '../engine/content.ts'; import type { Game, Menu, Save } from './game.ts'; import { actionMenu, + configWith, currentActor, fromSave, handPlayable, @@ -112,9 +114,15 @@ export type LocalSession = Session & { * * `Game` is mutated in place by `submit`, so the wrapper keeps a mutable reference rather than * copying — `undo` and `restore` replace the whole game, which is why `game` is a getter. + * + * It takes `rules` rather than a whole `GameConfig` because DEALING is the only thing on the far + * side of this that the page is allowed to decide. A config carries the mode, the victory condition + * and the optional rules — table settings a lobby owns — and handing main.ts a `GameConfig` to build + * meant importing the engine's own defaults into the page, which is the boundary `session.test.ts` + * guards. The seed and the house rules are the two things a player picks when they press New game. */ -export function createLocalSession(seed: number, config?: GameConfig): LocalSession { - let game: Game = config ? newGame(seed, config) : newGame(seed); +export function createLocalSession(seed: number, rules?: HouseRuleOverrides): LocalSession { + let game: Game = rules ? newGame(seed, configWith(rules)) : newGame(seed); const listeners = new Set<() => void>(); const changed = (): void => { for (const fn of [...listeners]) fn(); diff --git a/test/advance.test.ts b/test/advance.test.ts index d0d3892..2bd9ee4 100644 --- a/test/advance.test.ts +++ b/test/advance.test.ts @@ -12,7 +12,8 @@ import { applyIntent, areaOf } from '../src/engine/apply.ts'; import { HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts'; import { legalActions } from '../src/engine/legal.ts'; import { createGame } from '../src/engine/setup.ts'; -import type { GameConfig, GameState } from '../src/engine/state.ts'; +import type { CrewTray, GameConfig, GameState } from '../src/engine/state.ts'; +import { railFacingOf } from '../src/engine/state.ts'; const baseConfig = (over: Partial = {}): GameConfig => ({ mode: 'solitaire', @@ -230,6 +231,14 @@ describe('Mainline Phase (§8)', () => { it('moves trains one region per Stage (§8.2)', () => { const s = game(); + /** + * PIN THE TERRAIN. This used to rely on whatever card the seed happened to lay down, and the + * moment the RNG stream moved — a different opening deal draws a different number of cards + * before the Division is built — seed 1 dealt a 60 card instead of a 30 and the train crossed in + * one Stage. What is under test is that crossing takes the card's time, so the card has to be + * the test's own: Curves is a 30, which is two Stages for a fast train. + */ + for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'curves'; scheduleTrain(s, 1, 2); s.clock.phase = 'newTrain'; pump(s); @@ -612,3 +621,62 @@ describe('X18 Circus Train — a point for standing still', () => { assert.ok(!paidAgain(), 'the Circus Train collected a second time for the same set-up'); }); }); + +// --------------------------------------------------------------------------- + +describe('a train on the Division points the way it is running', () => { + /** + * Nothing reset `facing` when a train left a district, so a crew that had been shunted onto a + * north-south spur carried a compass port — 'n' or 's' — out onto the Division with it. + * + * The Division is east-west, and so is every Office card, so that port exists nowhere the train is + * about to be. `movesFor` explores from `facing` and from its opposite, and a card with neither + * yields nothing at all: the train arrived at the next Office **unable to switch at all**. It also + * drew a ▲ on the Division map, where there is no north. + * + * A train running the Division has its engine at one end of an east-west railroad, so this is not + * a repair applied after the fact — it is the only thing `facing` can mean out there. + */ + const runningOnTheDivision = (spurFacing: 'n' | 's', direction: 'east' | 'west'): CrewTray => { + const s = game(); + // Straight onto the Mainline card west of the first Office, as a departure would. + const index = s.division.nodes.findIndex((n) => n.kind === 'mainline'); + const node = s.division.nodes[index]!; + assert.equal(node.kind, 'mainline'); + const id = 'shunted'; + s.trays.set(id, { + id, trainNumber: 2, trainIsExtra: false, engineAt: 0, consist: [], + direction, + // What a Move round a district leaves behind: a real port on the card it was standing on. + facing: spurFacing, + railFacing: 'w', + position: { at: 'grid', seat: 0, coord: areaOf(s, 0).officeCoord }, + movesUsed: 0, + }); + const tray = s.trays.get(id)!; + // Depart it: the Mainline Phase's own path onto the card. + s.clock.phase = 'mainline'; + s.movedThisPhase = new Set(); + for (let i = 0; i < 3 && tray.position.at === 'grid'; i++) { + s.clock.phase = 'mainline'; + s.movedThisPhase = new Set(); + advance(s); + } + assert.notEqual(tray.position.at, 'grid', 'the train never left the district'); + return tray; + }; + + it('drops the spur port the moment it reaches the Mainline', () => { + for (const spur of ['n', 's'] as const) { + for (const direction of ['east', 'west'] as const) { + const tray = runningOnTheDivision(spur, direction); + assert.equal( + tray.facing, + direction === 'east' ? 'e' : 'w', + `a ${direction}bound train left the district still facing ${spur}`, + ); + assert.equal(railFacingOf(tray), direction === 'east' ? 'e' : 'w'); + } + } + }); +}); diff --git a/test/apply.test.ts b/test/apply.test.ts index 633a930..82c2f5c 100644 --- a/test/apply.test.ts +++ b/test/apply.test.ts @@ -6,13 +6,13 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { applyIntent, check, areaOf, facilityCarTypes, reduce } from '../src/engine/apply.ts'; -import { HAND_LIMIT, INDUSTRY_PROFILES, MOVES_PER_LOCAL_OPS, OPENING_OTHER, OPENING_TRACK } from '../src/engine/content.ts'; +import { applyIntent, check, areaOf, facilityCarTypes, movesFor, reduce } from '../src/engine/apply.ts'; +import { HAND_LIMIT, INDUSTRY_PROFILES, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts'; import type { Intent } from '../src/engine/intents.ts'; import { legalActions } from '../src/engine/legal.ts'; import { createGame } from '../src/engine/setup.ts'; import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts'; -import { coordKey, turnOf } from '../src/engine/state.ts'; +import { coordKey, railFacingOf, turnOf } from '../src/engine/state.ts'; import { cardDescription, snapshot } from '../src/sim/view.ts'; const config: GameConfig = { @@ -115,13 +115,15 @@ describe('Local Operations: drawing (§6.2)', () => { it('draws from the Home Office deck into the hand', () => { const s = game(); const before = s.decks.homeOffice.length; + const dealt = s.decks.hands.get(0)!.length; applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' }); assert.ok(r.ok); assert.equal(s.decks.homeOffice.length, before - 1); - // The opening deal is 3 track + 3 other, so a draw takes the hand to seven — and §6.2's limit - // then has to be played down to three before the turn can end. - assert.equal(s.decks.hands.get(0)!.length, OPENING_TRACK + OPENING_OTHER + 1); + // Measured against the deal rather than against a constant: the opening hand is a house rule + // now (three random, six random, or three-and-three), and none of the three changes what a draw + // does — it adds one card, and §6.2's limit then has to be played down to three. + assert.equal(s.decks.hands.get(0)!.length, dealt + 1); }); it('takes the TOP card from a Department pile, never one buried under it', () => { @@ -239,13 +241,15 @@ describe('Local Operations: drawing (§6.2)', () => { it('will not end the phase over the hand limit', () => { // §6.2 — "must reduce his hand to no more than three cards". const s = game(); + const dealt = s.decks.hands.get(0)!.length; applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); applyIntent(s, 0, { type: 'draw.fromHomeOffice' }); - assert.equal(s.decks.hands.get(0)!.length, OPENING_TRACK + OPENING_OTHER + 1); + assert.equal(s.decks.hands.get(0)!.length, dealt + 1); + assert.ok(dealt + 1 > HAND_LIMIT, 'a draw must put the player over the limit for this to test anything'); assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT'); // And the way down is play or discard — there is no per-turn cap on either, which is what makes - // the six-card opening hand playable in one turn rather than a limit that cannot be met. + // a six-card opening hand playable in one turn rather than a limit that cannot be met. const hand = () => s.decks.hands.get(0)!; while (hand().length > HAND_LIMIT) { const r = applyIntent(s, 0, { type: 'card.discard', cardId: hand()[0]!, toSlot: 0 }); @@ -1252,3 +1256,166 @@ describe('an Office upgrade keeps what Modifiers added (§9)', () => { assert.ok(office().capacity.outbound > base.out, 'and the upgrade still raised the tier'); }); }); + +// --------------------------------------------------------------------------- + +describe('the engine is drawn pointing east or west, whatever track it is standing on', () => { + /** + * Reported from play: a crew that turned onto a north-south spur drew ▲, and the same crew kept + * that ▲ after it departed onto the Division — where there is no north or south at all — because + * nothing resets `facing` when a train leaves a district. A Division runs east and west, and a + * player reads a train the way a railroader does: which END the engine is on. + * + * So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws. + */ + const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') => + ({ type: 'trayMoved', trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const; + + it('carries the east-west sense across north-south track', () => { + const s = game(); + const id = placeTray(s, at(0, 0)); + const tray = s.trays.get(id)!; + + reduce(s, moved(id, 'w')); + assert.equal(railFacingOf(tray), 'w'); + + // Onto a curve, and away up a spur. The port is north; the engine is still on the west end. + reduce(s, moved(id, 'n')); + assert.equal(tray.facing, 'n', 'the movement port must still be the real one'); + assert.equal(railFacingOf(tray), 'w', 'but the drawing must not turn the train on end'); + }); + + it('follows the engine around 180° of curves, because that really is a turn', () => { + // Forward through two curves — west port to north, north to east — and the engine genuinely + // does come out pointing the other way. Held values are held, not frozen. + const s = game(); + const id = placeTray(s, at(0, 0)); + const tray = s.trays.get(id)!; + for (const f of ['w', 'n', 'e'] as const) reduce(s, moved(id, f)); + assert.equal(railFacingOf(tray), 'e'); + }); + + it('falls back to the direction of the run for a tray that has not moved yet', () => { + // A tray placed straight onto the board has no history to carry, and `direction` is the only + // east-west fact about it. + const s = game(); + const tray = s.trays.get(placeTray(s, at(0, 0)))!; + assert.equal(tray.railFacing, undefined); + assert.equal(railFacingOf(tray), 'e', 'placed running east'); + tray.direction = 'west'; + assert.equal(railFacingOf(tray), 'w'); + }); + + it('never reports north or south to the board', () => { + // The view type says 'e' | 'w'; this is the runtime half of that promise, and it is the one a + // north-south spur used to break. + const s = game(); + const id = placeTray(s, at(0, 0), [{ type: 'boxcar', loaded: false }] as never); + addCard(s, at(0, 0), straight()); + reduce(s, moved(id, 's')); + const cell = snapshot(s, [], null).cells.find((c) => c.row === 0 && c.col === 0); + assert.ok(cell?.train, 'the train should be on the card'); + assert.ok(cell.train.facing === 'e' || cell.train.facing === 'w', `got ${cell.train.facing}`); + }); +}); + +// --------------------------------------------------------------------------- + +describe('a train that rounds a curve points where the curve took it', () => { + /** + * REPORTED from play: "a train reversed into a siding and the display of the cars was reversed." + * + * `facing` is the port the engine would leave by, and a move recorded it as `opposite(entry)` — + * which is only the far end of a STRAIGHT. A curve is an arc between two ADJACENT edges: enter a + * north-west curve through its west port and the far end is north, not east. So a train that + * rounded a curve was left facing a port the card does not have. + * + * Two things went wrong with that. Movement: the next Move explores from `facing`, and a port the + * card lacks yields nothing, so the crew could only ever back out the way it came. Display: the + * east-west sense is carried from `facing` (`railFacingOf`), so a curve that turned the engine + * west could leave the board still drawing it east — the consist mirrored, which is what was seen. + */ + const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({ + geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' }, + baseOperationalRail: true, + standing: [], + facility: null, + modifiers: [], + enhancements: [], + }); + + /** + * Runs a crew one Move and reports where the engine ended up pointing. + * + * The card it starts ON matters as much as the one it moves to: a crew facing north or south has + * to be standing on something with that port, and only a curve has one. + */ + const roundIt = ( + from: GridCoord, + to: GridCoord, + origin: TrackCard, + dest: TrackCard, + start: { facing: 'n' | 's' | 'e' | 'w'; railFacing: 'e' | 'w' }, + reverse = false, + ): { facing?: string; drawn: string } => { + const s = game(); + addCard(s, from, origin); + addCard(s, to, dest); + const id = placeTray(s, from, [{ type: 'boxcar', loaded: false }] as never); + const tray = s.trays.get(id)!; + tray.facing = start.facing; + tray.railFacing = start.railFacing; + s.clock.phase = 'localOps'; + s.clock.currentActor = 0; + turnOf(s, 0).option = 'switch'; + + const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to, reverse }); + assert.ok(r.ok, `the move was refused: ${r.ok ? '' : r.code}`); + return { facing: tray.facing, drawn: railFacingOf(tray) }; + }; + + it('leaves by the curve’s own far end, not by the opposite of the way it came in', () => { + // East along the running track into a curve that turns north. The far end is NORTH. + const out = roundIt({ row: 0, col: 0 }, { row: 0, col: 1 }, straight(), curve('nw'), { facing: 'e', railFacing: 'e' }); + assert.equal(out.facing, 'n', 'the engine was left facing a port the curve does not have'); + }); + + it('does not strand the crew on the curve it just rounded', () => { + // The symptom the wrong port produces: `movesFor` explores from `facing`, and a card without + // that port yields nothing at all — so the only move left was backing out the way it came. + const s = game(); + addCard(s, at(0, 0), straight()); + addCard(s, at(0, 1), curve('nw')); // joins north and west + addCard(s, at(1, 1), curve('se')); // joins south and east — the track continues + addCard(s, at(1, 2), straight()); + const id = placeTray(s, at(0, 0), []); + s.trays.get(id)!.facing = 'e'; + s.clock.phase = 'localOps'; + s.clock.currentActor = 0; + turnOf(s, 0).option = 'switch'; + + assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 1), reverse: false }).ok); + const { to } = movesFor(s, 0, id); + assert.ok( + to.some((c) => c.row === 1 && c.col === 1), + 'the crew cannot carry on round the curve — it can only back out the way it came', + ); + }); + + it('turns the DRAWN direction when the curve really does turn the engine round', () => { + // South down a spur into a curve that turns west. The engine genuinely now points west, and the + // board has to say so — this is the mirrored consist that was reported. + const out = roundIt({ row: 1, col: 1 }, { row: 0, col: 1 }, curve('se'), curve('nw'), { facing: 's', railFacing: 'e' }); + assert.equal(out.facing, 'w', 'the curve turned the engine west'); + assert.equal(out.drawn, 'w', 'the board would still have drawn the consist facing east'); + }); + + it('still points back the way it came when it BACKS round a curve', () => { + // Backing up does not turn a train around, whatever the track does underneath it: the engine + // trails, pointing out through the port the train came in by. That is geometry-independent and + // was already right — this is here so the fix above cannot quietly change it. + const out = roundIt({ row: 1, col: 1 }, { row: 0, col: 1 }, curve('se'), curve('nw'), { facing: 'n', railFacing: 'e' }, true); + assert.equal(out.facing, 'n', 'backing up turned the train around'); + assert.equal(out.drawn, 'e', 'backing up changed which end the engine is on'); + }); +}); diff --git a/test/enhancements.test.ts b/test/enhancements.test.ts index e59d11e..f0833ba 100644 --- a/test/enhancements.test.ts +++ b/test/enhancements.test.ts @@ -332,7 +332,20 @@ describe('ABS Signals amend the collision rule', () => { // --------------------------------------------------------------------------- -describe('one Revenue to EVERY player when a train completes its run', () => { +describe('train revenue per transit — paid to EVERY player when a train completes its run', () => { + /** + * `trainPerTransit` is a dial now, and it defaults to ZERO: at 1 it was worth ~5.4 Revenue against + * a bot mean of 7.0, drowning out the freight and passenger economies the game is about. These + * tests are about the rule, so they set the rate; the last one is about the default. + */ + const paying = (rate = 1, seed = 5): GameState => + createGame({ + id: 'g', + seed, + config: { ...config, houseRules: { revenue: { trainPerTransit: rate } } }, + playerNames: ['p'], + }); + /** 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'; @@ -364,7 +377,7 @@ describe('one Revenue to EVERY player when a train completes its run', () => { }); it('pays every player once the train runs off the end of the Division', () => { - const s = game(); + const s = paying(); const before = s.players.map((p) => p.revenue); readyToLeave(s, 'east'); @@ -395,7 +408,7 @@ describe('one Revenue to EVERY player when a train completes its run', () => { }); it('pays nothing for a local crew, which is switching rather than running', () => { - const s = game(); + const s = paying(); const before = s.players[0]!.revenue; const id = readyToLeave(s, 'east'); s.trays.get(id)!.trainNumber = null; @@ -404,6 +417,32 @@ describe('one Revenue to EVERY player when a train completes its run', () => { assert.equal(s.players[0]!.revenue, before, 'a switching crew was paid as though it had completed a run'); }); + + it('pays at the rate the game was dealt with, and nothing at the default of zero', () => { + // Zero is not "one, suppressed": no revenue event is emitted at all, so the history does not + // fill with "+0 Revenue" for work that did not pay. + const run = (rate: number): { revenue: number; events: number } => { + const s = rate === 0 ? game() : paying(rate); + readyToLeave(s, 'east'); + let events = 0; + let done = false; + for (let i = 0; i < 20 && !done; i++) { + s.movedThisPhase = new Set(); + s.clock.phase = 'mainline'; + const r = advance(s); + events += r.events.filter( + (e) => e.type === 'revenueChanged' && e.reason === 'a train completed its run', + ).length; + done = r.events.some((e) => e.type === 'trainCompleted'); + } + assert.ok(done, `the train never left the Division at rate ${rate}`); + return { revenue: s.players[0]!.revenue, events }; + }; + + assert.deepEqual(run(0), { revenue: 0, events: 0 }, 'the default paid for a transit'); + assert.deepEqual(run(1), { revenue: 1, events: 1 }); + assert.deepEqual(run(5), { revenue: 5, events: 1 }, 'the top of the range pays 5 in one event'); + }); }); // --------------------------------------------------------------------------- diff --git a/test/events.test.ts b/test/events.test.ts index 869c1c0..3063104 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -119,10 +119,14 @@ describe('the intents are what reconstructs a game', () => { ); }); - it('carries no state in the save beyond the seed and the intents', () => { + it('carries no POSITION in the save — the seed, the rules dealt under, and the intents', () => { // If anything else ever creeps into `Save`, the claim above weakens: the game would no longer be // reconstructible from decisions alone, and persistence would have a schema to migrate. + // + // `rules` is the one addition, and it is not position: it is the other half of the seed. A seed + // only names a game together with the rules it was dealt under, which is why two published + // replays went dead when the rules moved (`TODO.md`) — the intents were fine, the deal was not. const game = newGame(7); - assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'seed']); + assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'rules', 'seed']); }); }); diff --git a/test/harness.test.ts b/test/harness.test.ts index 07979bd..d15de82 100644 --- a/test/harness.test.ts +++ b/test/harness.test.ts @@ -275,8 +275,13 @@ describe('every published replay actually replays', () => { const save = JSON.parse(readFileSync(join(dir, f), 'utf8')) as { seed: number; history: unknown[]; + rules?: unknown; }; - const back = fromSave({ seed: save.seed, history: save.history as never }); + // The WHOLE save, `rules` included. Rebuilding it from seed and history alone threw away the + // one field that says which ruleset the file was recorded under, so this replayed every + // published file under the pre-dialog defaults no matter what it said — a test that would + // pass a genuinely dead replay the moment the defaults and the file disagreed. + const back = fromSave(save as never); assert.equal( back.history.length, save.history.length, diff --git a/test/multiplayer.test.ts b/test/multiplayer.test.ts index ce2cb30..bef4e87 100644 --- a/test/multiplayer.test.ts +++ b/test/multiplayer.test.ts @@ -552,8 +552,16 @@ describe('scoring lands on the right seat', () => { * * With one player this is unfalsifiable, because "everybody" is one person. This is the test * that says so with three. + * + * The rate is a dial now and defaults to zero, so this names it: what is under test is WHO gets + * paid, not whether the default pays at all (`enhancements.test.ts` covers that). */ - const s = game(3); + const s = createGame({ + id: 'm', + seed: 4242, + config: { ...competitive, houseRules: { revenue: { trainPerTransit: 1 } } }, + playerNames: ['a', 'b', 'c'], + }); pinTerrain(s); const before = s.players.map((p) => p.revenue); diff --git a/test/setup.test.ts b/test/setup.test.ts index 3bf55f0..e6dfdd9 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -28,6 +28,7 @@ import { } from '../src/engine/content.ts'; import { createRng } from '../src/engine/rng.ts'; import { buildDeck, buildRollingStock, createGame } from '../src/engine/setup.ts'; +import type { StartingHand } from '../src/engine/content.ts'; import type { GameConfig } from '../src/engine/state.ts'; import { subdivisions } from '../src/engine/state.ts'; @@ -46,6 +47,15 @@ const solitaireConfig: GameConfig = { const newSolitaireGame = (seed = 1234) => createGame({ id: 'g1', seed, config: solitaireConfig, playerNames: ['Jesse'] }); +/** A game dealt under one named `StartingHand`, for the tests that are about the deal itself. */ +const gameDealtWith = (startingHand: StartingHand, seed = 1234) => + createGame({ + id: 'g1', + seed, + config: { ...solitaireConfig, houseRules: { startingHand } }, + playerNames: ['Jesse'], + }); + // --------------------------------------------------------------------------- describe('card catalogue (component 1)', () => { @@ -380,26 +390,40 @@ describe('game setup (component 2)', () => { assert.equal(area.adOccupancy.length, 0); }); - it('deals three track and three other cards, and starts three Department piles', () => { - // The opening deal comes from two separately shuffled piles, so the district you can build is - // dealt rather than waited for. Six against a hand limit of three is deliberate: the first turn - // is spent choosing which of them to keep. + it('deals three random cards by default, and starts three Department piles', () => { + // The prototype rule, and the New Game dialog's default: three off one deck, already at the hand + // limit, with no guarantee of anything. The other two shapes are below. const g = newSolitaireGame(); + assert.equal(g.decks.hands.get(0)!.length, 3); + + assert.equal(g.decks.departments.length, 3); + assert.ok(g.decks.departments.every((pile) => pile.length === 1), 'each Department starts with one face-up card'); + }); + + it('deals six random cards when that is the rule, over the hand limit on purpose', () => { + // Six against a hand limit of three is deliberate: the first turn is spent choosing which of + // them to keep. What it does NOT do is guarantee track, which is the difference from the split + // deal below. + const g = gameDealtWith('sixRandom'); + assert.equal(g.decks.hands.get(0)!.length, 6); + }); + + it('deals three track and three other cards when that is the rule', () => { + // From two separately shuffled piles, so the district you can build is dealt rather than waited + // for — a run-around needs five specific pieces, and drawing for them took eight games. + const g = gameDealtWith('threeTrackThreeOther'); const hand = g.decks.hands.get(0)!; assert.equal(hand.length, OPENING_TRACK + OPENING_OTHER); const track = hand.filter((id) => g.cards.get(id)!.kind.kind === 'track'); assert.equal(track.length, OPENING_TRACK, 'the opening hand is not three track cards'); assert.equal(hand.length - track.length, OPENING_OTHER, 'the opening hand is not three other cards'); - - assert.equal(g.decks.departments.length, 3); - assert.ok(g.decks.departments.every((pile) => pile.length === 1), 'each Department starts with one face-up card'); }); it('shuffles the leftover track back into one deck for the rest of the game', () => { // The split is an opening-deal device only. If the leftover track stayed out, every draw after // the first turn would be drawn from a deck with no track in it at all. - const g = newSolitaireGame(); + const g = gameDealtWith('threeTrackThreeOther'); const rest = [...g.decks.homeOffice, ...g.decks.departments.flat()]; const track = rest.filter((id) => g.cards.get(id)!.kind.kind === 'track'); assert.equal( @@ -408,6 +432,22 @@ describe('game setup (component 2)', () => { ); }); + it('leaves every card accounted for whichever shape it was dealt in', () => { + // The single-deck path and the two-pile path deal from different piles into the same game; a + // card lost or duplicated by either would be a deck that quietly runs short mid-game. + for (const shape of ['threeRandom', 'sixRandom', 'threeTrackThreeOther'] as const) { + const g = gameDealtWith(shape); + const all = [ + ...g.decks.homeOffice, + ...g.decks.departments.flat(), + ...g.decks.salvageYard, + ...[...g.decks.hands.values()].flat(), + ]; + assert.equal(all.length, SOLITAIRE_DECK_SIZE, `cards lost or duplicated dealing ${shape}`); + assert.equal(new Set(all).size, all.length, `duplicate card dealing ${shape}`); + } + }); + it('accounts for every card exactly once', () => { const g = newSolitaireGame(); const all = [ diff --git a/test/web.test.ts b/test/web.test.ts index 6685e27..976359e 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -11,6 +11,10 @@ import { turnOf } from '../src/engine/state.ts'; import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; +// The REAL one. An earlier test in this file replaces `globalThis.URLSearchParams` with a two-line +// stub, and the dialog needs `set` as well as `get` — reading the global here would hand the page +// whichever stub happened to run last. +import { URLSearchParams as NodeURLSearchParams } from 'node:url'; import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts'; import { variantsFor } from '../src/engine/track.ts'; @@ -1324,8 +1328,23 @@ describe('the static build', () => { // it, and a stub without one throws on load — which is a page that never starts, not a // cosmetic gap. const ownClasses = new Set(); + // The New Game dialog is a native : the page listens for `close` on it at load, and + // opens it with `showModal`. Without these the page throws before it draws anything — which + // is a page that never starts, exactly what this stub exists to catch. + const listeners = new Map void)[]>(); const node: Record = { textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0, + title: '', returnValue: '', open: false, + addEventListener: (type: string, fn: (e?: unknown) => void) => + void listeners.set(type, [...(listeners.get(type) ?? []), fn]), + showModal() { + (node as { open: boolean }).open = true; + }, + close(value?: string) { + (node as { open: boolean; returnValue: string }).open = false; + if (value !== undefined) (node as { returnValue: string }).returnValue = value; + for (const fn of listeners.get('close') ?? []) fn(); + }, classList: { add: (c: string) => void ownClasses.add(c), remove: (c: string) => void ownClasses.delete(c), @@ -1740,10 +1759,11 @@ describe('the static build', () => { const { options } = actionGroups(game); if (options.length === 0 || !submit(game, options[0]!)) break; } - // Raised 8 -> 10: the widest group is Switching, and the opening deal grew districts from 17.9 - // to 20.3 cards, so a crew simply has more squares it can legally reach. That is the list getting - // longer for a good reason rather than the cross-products this test was written to kill. - assert.ok(worst <= 10, `the action list still reaches ${worst} buttons`); + // Raised 8 -> 10 -> 13. The widest group is Switching every time, which is a crew's reachable + // squares — the list getting longer for a good reason rather than the cross-products this test + // was written to kill. Re-measured over five seeds (430, 99, 1234, 880009, 202) in all three + // opening deals: 13 under `threeRandom` and `sixRandom`, 10 under `threeTrackThreeOther`. + assert.ok(worst <= 13, `the action list still reaches ${worst} buttons`); }); it('makes up ONE train at a time, and names that train', () => { @@ -2494,3 +2514,165 @@ describe('the tray is an engine plus its Rolling Stock', () => { } }); }); + +// --------------------------------------------------------------------------- + +describe('the New Game dialog', () => { + /** + * DRIVEN THROUGH THE EMITTED BUNDLE, like the highlight test above, because the thing that can go + * wrong here is wiring rather than logic: an id that does not match the HTML, a handler on the + * wrong element, or a navigation that assigns the search string the page already has and so does + * nothing at all. None of that is visible to a test of `rulesFromUrl` in isolation. + * + * A seed alone stopped naming a game the moment the opening hand and the revenue rates became + * settings, so what this really pins is that all four ride in the URL and come back out. + */ + const load = async (search: string) => { + execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' }); + + const served = new Set( + [...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!), + ); + const els = new Map>(); + /** Enough of an element for the page to start: the dialog's own API, and a settable `value`. */ + const make = (id: string): Record => { + const listeners = new Map void)[]>(); + const radios = ['threeRandom', 'sixRandom', 'threeTrackThreeOther'].map((value) => ({ + value, + checked: false, + })); + let html = ''; + const node: Record = { + id, value: '', textContent: '', title: '', returnValue: '', open: false, + style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0, + classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} }, + radios, + addEventListener: (type: string, fn: () => void) => + void listeners.set(type, [...(listeners.get(type) ?? []), fn]), + showModal: () => void ((node as { open: boolean }).open = true), + close: () => { + (node as { open: boolean }).open = false; + for (const fn of listeners.get('close') ?? []) fn(); + }, + // Only the radio-group selectors the dialog actually uses; anything else is not this + // element's business and answering it with a guess would hide a typo in the real selector. + querySelectorAll: (sel: string) => (sel === 'input[name="ng-hand"]' ? radios : []), + querySelector: (sel: string) => + sel === 'input[name="ng-hand"]:checked' ? (radios.find((r) => r.checked) ?? null) : null, + }; + Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) }); + return node; + }; + + const g = globalThis as Record; + g['document'] = { + getElementById: (id: string) => { + if (!served.has(id)) return null; + if (!els.has(id)) els.set(id, make(id)); + return els.get(id); + }, + createElement: () => make('style'), + addEventListener: () => {}, + body: { appendChild: () => {} }, + head: { appendChild: () => {} }, + }; + const nav: { search: string; reloads: number } = { search, reloads: 0 }; + g['location'] = { + get search() { return nav.search; }, + set search(v: string) { nav.search = v; }, + reload: () => void (nav.reloads += 1), + }; + const store = new Map(); + g['localStorage'] = { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + }; + g['URLSearchParams'] = NodeURLSearchParams; + g['confirm'] = () => true; + + await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}-${Math.random()}`); + return { els, nav }; + }; + + it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => { + // Re-entering four settings for every comparison game is how a comparison silently stops + // comparing. The seed is the one field that clears: the same seed twice is not a second sample. + const { els } = await load('?seed=430&hand=sixRandom&passenger=2&freight=3&transit=4'); + (els.get('newgame')!['onclick'] as () => void)(); + + const dlg = els.get('newgamedlg')!; + assert.equal(dlg['open'], true, 'the New game button did not open the dialog'); + assert.equal(els.get('ng-seed')!['value'], '', 'the seed box kept the last game’s seed'); + assert.equal(els.get('ng-passenger')!['value'], '2'); + assert.equal(els.get('ng-freight')!['value'], '3'); + assert.equal(els.get('ng-transit')!['value'], '4'); + const checked = (dlg['radios'] as { value: string; checked: boolean }[]).filter((r) => r.checked); + assert.deepEqual(checked.map((r) => r.value), ['sixRandom'], 'the opening hand in play was not preselected'); + }); + + it('puts the seed and all three settings into the URL when it deals', async () => { + const { els, nav } = await load('?seed=430'); + (els.get('newgame')!['onclick'] as () => void)(); + + const dlg = els.get('newgamedlg')!; + els.get('ng-seed')!['value'] = '99'; + for (const r of dlg['radios'] as { value: string; checked: boolean }[]) r.checked = r.value === 'threeTrackThreeOther'; + els.get('ng-passenger')!['value'] = '5'; + els.get('ng-freight')!['value'] = '0'; + els.get('ng-transit')!['value'] = '2'; + dlg['returnValue'] = 'deal'; + (dlg['close'] as () => void)(); + + assert.equal(nav.search, '?seed=99&hand=threeTrackThreeOther&passenger=5&freight=0&transit=2'); + }); + + it('deals nothing on cancel, and nothing on Esc', async () => { + // Esc closes a with an empty returnValue and fires no submit at all, so "not deal" has + // to be the test rather than "cancel" — the two arrive identically. + for (const returnValue of ['cancel', '']) { + const { els, nav } = await load('?seed=430'); + (els.get('newgame')!['onclick'] as () => void)(); + const dlg = els.get('newgamedlg')!; + dlg['returnValue'] = returnValue; + (dlg['close'] as () => void)(); + assert.equal(nav.search, '?seed=430', `closing with "${returnValue}" navigated`); + assert.equal(nav.reloads, 0, `closing with "${returnValue}" reloaded`); + } + }); + + it('reloads when the answers are the URL the page already has, so a re-deal is not a no-op', async () => { + // Dealing a random seed, disliking it and dealing again at the same settings produces the same + // search string — and assigning `location.search` the value it already holds does nothing. + const url = '?hand=threeRandom&passenger=1&freight=1&transit=0'; + const { els, nav } = await load(url); + (els.get('newgame')!['onclick'] as () => void)(); + const dlg = els.get('newgamedlg')!; + dlg['returnValue'] = 'deal'; + (dlg['close'] as () => void)(); + + assert.equal(nav.search, url, 'the URL should be unchanged — that is the whole case'); + assert.equal(nav.reloads, 1, 'a re-deal at the same settings did nothing at all'); + }); + + it('deals the game the URL describes, and says so in the header', async () => { + // The other half of the round trip: the dialog wrote those parameters, and this is the page + // reading them back. Without this the two halves can drift and each still pass its own test. + const { els } = await load('?seed=430&hand=sixRandom&passenger=4&freight=2&transit=1'); + assert.match(String(els.get('houserules')!['textContent']), /6 cards.*4\/2\/1/); + assert.match(String(els.get('houserules')!['title']), /six random cards/i); + }); + + it('ignores a seed the browser cannot parse rather than refusing to deal', async () => { + // Blank and unparseable both plainly mean "surprise me"; an error dialog over a typo in an + // optional box is not worth writing. + const { els, nav } = await load('?seed=430'); + (els.get('newgame')!['onclick'] as () => void)(); + const dlg = els.get('newgamedlg')!; + els.get('ng-seed')!['value'] = 'not a number'; + dlg['returnValue'] = 'deal'; + (dlg['close'] as () => void)(); + + assert.equal(nav.search, '?hand=threeRandom&passenger=1&freight=1&transit=0', 'a bad seed was carried into the URL'); + }); +});