diff --git a/CHANGELOG.md b/CHANGELOG.md index f698536..85e899a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,167 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.7.2 — 2026-08-26 + +Five issues off the tracker. Two are engine bugs a player hit at the board, two are the design +catching up with rulings from RAR that the code had got wrong or never had, and one is the Division +map being redrawn. The first four ship as **0.4.9h** on the 0.4.9 line; the map does not — it is a +multiplayer redesign, and pushing one of those into a build people are mid-playtest on invalidates +the feedback. + +### A 45° leg is part of the row (Gitea#17) + +**REPORTED:** "Cars were West to East Caboose, Loaded boxcar, Loaded boxcar, Loaded boxcar. After +backing into that square cars were attached to the train Loaded boxcar, Loaded boxcar, Loaded boxcar, +Caboose, Engine." The caboose came back next to the engine instead of at the far end, which also +leaves the train badly made up under §8.2. + +The square was a `sw` CURVE and the train backed in through its SOUTH leg. `standing` runs west to +east, and both places that walk that row asked the PORT which end they were at: `exploreMoves` +reversed the row for an `'e'` entry and nothing else, and `cutTowards` answered "you meet nothing" +for a north or south exit. Neither is a property of the port. A 45° leg leaves through the MIDDLE of +its edge, so its end of the run is whichever end the arc does not reach — a `sw` curve's south leg is +the row's EAST end and an `se` curve's south leg is its WEST end. + +`rowEndAt` answers it from the card, and `cutTowards` is narrowed from `Port` to `'e' | 'w'` so the +type checker forces every caller to resolve rather than leaving a fourth to be found later. + +**THE SECOND HALF WAS LIVE TOO, and is a report we had failed to reproduce.** With `cutTowards` +returning nothing for a leg, a crew standing on a curve pulled out through it and DROVE AWAY LEAVING +ITS OWN CUT STANDING — against §A.4's mandatory coupling. That is "cars left behind when backing up +over them", carried as NOT REPRODUCED since 2026-08-22; the earlier sweep had tried that case only +with an east or west exit. The report's second sentence — "I can later drive right through them" — +is still unexplained and still open. + +### The deck is the sheet (Gitea#14) + +`docs/Deck cards5.xlsx` is in the repo, and every count in the catalogue is now its count. Track is +halved: 16 straights, 8+8 curves, 8+8 turnouts, sharp curves at zero — which is where they already +were, and where sheet 5 independently puts them. + +**BOTH LONG-STANDING MULTIPLIERS COME OUT WITH IT.** The Q12 office doubling (14 → 7) and the Gap 12 +industry tripling (27 → 9) were measured against a deck holding 96 track cards, and halving the track +turns the tripling backwards — the deck keeps dealing industries while the district stays too small +to reach them. Measured over 300 bot games on identical seeds, 96 track with the multipliers / 48 +with them / 48 without: reefer cars set out by a crew **49 / 0 / 39**, mean revenue **−0.20 / +0.22 / ++0.27**. The middle column wipes out the reefer chain completely. The sheet's own density is the best +of the three on both counts. + +Q12's own failure was re-measured rather than assumed: 43 of 100 games now never upgrade off a +Whistle Post, up from 25 — but they average −0.2 revenue against +1.4 for games that do, where the +gap used to be −6.0 against −0.4, and collisions fell from 26 per 100 games to 6. Staying at a +Whistle Post is now common and survivable rather than rare and fatal. + +Everything sheet 5 does not list is dealt ZERO copies rather than deleted, so the design stays +visible and the rules stay implemented: the Telegraph/Telephone/Radio dispatching ladder, Facing +Point Locks, Flying Switch, Section House and Vandalism — all confirmed by Jesse as deliberate +removals — plus Poling and the sharp curves, which were already there. Card for card, **84 rows agree +with the sheet**; the only ones that do not are the ten Safety, Event, Inspection and Space-use cards +it adds that are not built, held out until they are. + +### A rail may stop dead against its neighbour (Gitea#15) + +Filed as "track placements must connect", against a right-hand curve laid with its north leg against +an Ice House and the turnout below pointing at its portless south edge. **RAR reversed it on review:** +the placement is fine, and a stub like that has a use — a siding to park cars on. What he asked to +confirm instead is that no train can traverse the gap. + +It could not, and cannot: `exploreMoves` gates every hop on `joins`, which tests both ports AND that +two 45° legs lie on the same diagonal. That was already true and simply unpinned; `track.test.ts` +now holds it against the reported geometry, including a check that the curve IS reachable from the +side that joins, so the negative test cannot pass on a card that is merely unreachable. + +A per-edge placement check was written and then taken out, along with a matching guard on +`checkTurnoutUpgrade`. Both are documented in place as deliberately absent, because this is exactly +the rule someone will "fix" again. A Modifier is scenery (Jesse): a rail pointing at a building is +fine. + +### Crossing a Mainline card is regions, not miles per hour (Gitea#3) + +**REPORTED:** a train taking two Stages to clear Double Track, which prints 60. RAR, on review: +"Ignore speed signs, they are just graphics. Regions shown on cards indicate how many stages it takes +to cross." + +Two recorded rulings are superseded together — Q1, that the printed 60/30 are crossing time, and Q2, +that a Slow train adds a Stage to every card. Q2 is what produced the report. Cards carry a region +count now and **where a train STARTS is what varies**: Plains 1, Double Track 1, Trestle 1, Curves 2, +Tunnel 2, Heavy Grade 3. Fast/Slow is read on Hilly and nowhere else — and Hilly no longer reads the +consist, which had a fast freight crossing slower than a slow passenger train. The grade modifiers +move the start rather than cutting the clock: Helpers start an uphill train a region on, Brakeman a +downhill one, Airbrakes another again. The Uncontrolled Siding and the Interchange print a back +region that is not road: a train through starts past it, one arriving to find the siding occupied +takes it and runs a region behind, and an Extra beginning its run at an Interchange starts there. + +**THREE THINGS WERE MISSING RATHER THAN WRONG.** With Plains now one region, a following train is in +the same place as the train ahead the moment it enters — and nothing tested that, because the +catch-up check sits inside `stagesRemaining > 1`, which a one-Stage crossing never reaches. That is +the case ABS describes ("the train moving onto the card is instead held back") and it needed +building. ABS was also holding SILENTLY on the Office and Division Point paths, so the one card whose +purpose is preventing a wreck did its job invisibly. And `collide` never released the A/D track, +which did not matter while every collision happened out on the road — a train destroyed as it LEAVES +is still standing at the Office. + +The Uncontrolled Siding was marked "trains may pass", which skipped the collision test altogether and +made the siding do nothing at all. It is `false` now, with the siding entry doing the work. + +Measured: on the Mainline cards of a 3-player Division, a fast train pays ~5.6 Stages against ~5.4 +before and a slow one ~6.0 against ~9.4. **Fast traffic is unchanged; slow traffic is about a third +quicker**, and the gap across a Division collapses from roughly four Stages to under one — so Q2's +recorded consequence, that every Slow train is still on the road when the next Day begins holding its +Crew Tray, no longer holds and `players + 3` is due a re-examination. + +Two effects are recorded in `TODO.md` rather than acted on: freight share fell 8% → 5% and completed +freight loads got scarcer, which runs against the obvious expectation and nobody knows why yet; and +the bot stopped playing Red Flags — offered 4,212 times in 600 games, played 4. + +### The Division is one row (Gitea#18) + +**REPORTED:** "track design should not be horseshoe / square, but a single row… Division map should +not show any office area detail." + +It was laid out around a table — one row for a single seat, two facing rows for two, a horseshoe for +three, a square for four — on the reasoning that players sit around one. Three reports came out of +that, and the one that decided it is that **east stopped being to the right**: a player's east could +be drawn south, west or north depending which lane their district landed in, on a map whose whole job +is saying which way a train is going. `WDP · ML · Office · … · ML · EDP`, left to right, and the +buffer stops simply face outward at the two ends. + +An Office no longer expands into its Running Track either, so the map stops carrying every straight, +turnout and Limits sign of every district — that is the Office map's job, and it draws them properly, +with the rails. It also stops the Division map growing sideways every time somebody lays a card. + +The trains stay, in two registers: those holding an A/D track ON the rail, and crews switching in the +district UNDER it — the distinction drawn as position rather than colour, because that is where those +trains are. Each chip carries its number, a direction arrow and a car count, with the consist and the +train's printed card on the tooltip. Every district cell is four chips two-by-two regardless of tier, +because a Whistle Post with one A/D track can still hold four trains when three of them are crews, +and sizing by occupancy is what "The Roster Pass" fixed. 1,580px wide at four players against 842 +before, drawn at 1:1 so it scrolls rather than shrinking — Jesse's call, "zooming and scrolling +worked fine". + +This supersedes `TODO.md` item 24 outright and makes 19, 20, 21, 22, 25 and 26 irrelevant; item 27 is +fixed. + +### Six test fixtures that pinned a seed and meant "a game like this" + +Every one of them broke on a rules change and none was about the rule that changed: the deck's SIZE +moves the RNG stream, so any count change re-deals every fixture that names a seed. +`mainline-cards` now searches for a Division holding a single-track card, `multiplayer` for a game +that reaches Day 3, `web` clicks every play verb rather than assuming the first goes on the board and +searches for a seed that builds a board, and the `sim` commodity samples were re-measured — a tank is +first set out at game 216 now, unload Revenue at game 46. A seventh, `mainline-cards`' `hand()` +helper, threw when a card was not in the deck, so dealing Flying Switch zero copies took five passing +tests of an unchanged rule with it; it mints one now, which is the point of keeping a row at zero. + +**And one real bug, in a test.** `the game conserves Rolling Stock` went red claiming the engine had +conjured three cars. The engine was right: Gap 2c sends a wreck's cabooses to the Division Yard and +everything else to Classification, destroying nothing. The test subtracted the wreck's consist from +the expected census — a rule the engine does not have — and the branch had never executed, because +none of its six seeds had ever collided. The census is held flat unconditionally now, which is both +the true invariant and stricter than what it replaced. + +--- + ## 0.7.1 — 2026-08-25 Four issues off the Gitea tracker, all of them things a player saw at the board. Two are engine or diff --git a/TODO.md b/TODO.md index 685b4a3..0bd03e5 100644 --- a/TODO.md +++ b/TODO.md @@ -50,8 +50,10 @@ Queued 2026-08-22, from a v0.4.9d gameplay-testing report (six bugs, forwarded b it just boarded**~~ — done in v0.4.9e / the release below. 11. ~~**The Grocer's Warehouse ships and the Refinery receives**~~ — done in v0.4.9e / the release below: both are one-way again. -12. **NOT REPRODUCED: cars left behind when backing up over them** — see Rules Questions below. The - one report of the six that is still open, and it needs a board from whoever filed it. +12. **PARTLY REPRODUCED: cars left behind when backing up over them** — see Rules Questions below. + Half of it turned out to be the second half of Gitea#17 and is fixed (2026-08-26): a train + pulling out through a 45° leg left its own cut standing. The "I can later drive right through + them" half is still unexplained and still needs a board from whoever filed it. Queued 2026-08-22, from the v0.4.9e gameplay-testing report filed as Gitea issues. @@ -127,30 +129,31 @@ Queued 2026-08-22, from a session looking at the screen rather than the rules. A means something different and useful: hide it now, bring it back at the end of the phase. 18. **Give every phase a visible beat.** The automatic phases are not too fast — they are never drawn at all, because `pump` runs them all before the page renders once. -19. **Turn the track art vertical on a Division card laid vertically** — a side lane currently reads - as stacked left-right segments rather than one continuous run. -20. **Run the inter-row connector round the OUTSIDE**, draw it as rail rather than a plain line, and - give the corners an angled piece. -21. **The Division Point captions overflow the map**, and the buffer stops point the wrong way once - the route wraps. -22. **Fill the dead centre of the Division map with the common board** — timetable, yards, the - Department and Salvage decks. Then the map is what everyone shares and the right column is yours. +19. ~~**Turn the track art vertical on a Division card laid vertically**~~ — **SUPERSEDED by + Gitea#18** (Jesse, 2026-08-26). There are no vertical lanes any more: the Division draws as a + single row, west to east. +20. ~~**Run the inter-row connector round the OUTSIDE**~~ — **SUPERSEDED by Gitea#18.** No second + row, so nothing to connect. +21. ~~**The Division Point captions overflow the map**, and the buffer stops point the wrong way once + the route wraps~~ — **SUPERSEDED by Gitea#18.** Nothing wraps; both ends face outward. +22. ~~**Fill the dead centre of the Division map with the common board**~~ — **SUPERSEDED by + Gitea#18.** A row has no centre to fill. 23. **History: newest at the top?** Plus the timestamps question from item 14, which lands here. -24. **Put the viewer's own district at the BOTTOM of the Division map** and wrap the table around - them, so the screen sits you at the table. Needs no new data; the cost is that "west to east" - stops starting where you start reading. +24. ~~**Put the viewer's own district at the BOTTOM of the Division map** and wrap the table around + them~~ — **DEFINITIVELY SUPERSEDED by Gitea#18** (Jesse's word, 2026-08-26). A single row and a + table wrapped around the viewer cannot both hold, and the row wins: being able to rely on east + meaning right is worth more than being seated at the table. -Queued 2026-08-23, from Jesse playing the v0.7.0 build on StartOS. **All three are the same drawing -pass as 19-21 and 24 above, and he asked for them to be discussed together rather than picked off:** +Queued 2026-08-23, from Jesse playing the v0.7.0 build on StartOS. **All three were the same drawing +pass as 19-21 and 24 above, and all three are answered by Gitea#18 rather than fixed:** -25. **The Division map does not draw track geometry at all** — a turnout laid on the Running Track - looks exactly like the straight it replaced, because the only thing that changes is a caption. - Reasoning and the measurement in Display below. -26. **CONFIRMED IN PLAY: the buffer stop points the wrong way** at two players — see item 21, now - reported from a real game rather than read off the code. -27. **CONFIRMED IN PLAY: east is not always to the right.** As a train crosses the Division the route - wraps through the lanes, so a player's east can be drawn south, west or north. See item 20; it is - the same wrap that puts the buffer stop in the wrong place. +25. ~~**The Division map does not draw track geometry at all**~~ — **SUPERSEDED by Gitea#18.** The + Division map stops drawing office-area detail altogether, so there is no Running Track on it to + draw geometry for. The geometry belongs to the Office map, which already draws it. +26. ~~**CONFIRMED IN PLAY: the buffer stop points the wrong way** at two players~~ — **SUPERSEDED by + Gitea#18**, with item 21. +27. ~~**CONFIRMED IN PLAY: east is not always to the right.**~~ — **FIXED OUTRIGHT by Gitea#18**, and + the reason it wins over item 24. One row means east is always to the right. 28. **INVESTIGATE: move the game's settings off the top line and into a card of their own** — and show ALL of them, not the four that fit. Reasoning in Display below. @@ -315,6 +318,18 @@ The replay viewer, the save format, and how a game gets shared. ## Bot Performance +- [ ] **THE BOT WILL NOT PLAY RED FLAGS, and the reachability canary now says so.** Measured over + 600 games (2026-08-26): `maneuver.redFlags` is OFFERED **4,212 times**, first in game 5 — the + rule is live and constantly available — and the bot PLAYS it **4 times**, first in game 252. + It is exempted by name in `sim.test.ts`'s anomaly test alongside `flyingSwitch`, and deleting + that line is what proves this fixed. + + Two changes compounded to make it rarer without breaking anything: Gitea#14 took Red Flags from + 5 copies to the sheet's 3, and Gitea#3 shortened most crossings to a single Stage, so the + window in which a train is STANDING on a Mainline card — the only place the card may be played + — is now usually one Stage wide. + + What the developer bot can and cannot yet do, measured. Every revenue figure below measured before v0.4.7 is low by roughly half a point — see the stub-industry entry — and the rebalance pass should not read that drop as a deck problem. @@ -428,6 +443,30 @@ not read that drop as a deck problem. ## Play Balance +- [ ] **FREIGHT GOT SCARCER WHEN THE MAINLINE WENT ONTO REGIONS, and nobody knows why yet.** + Gitea#3, measured 2026-08-26 across the same 100 games: freight share of gross fell **8% → 5%**, + and completed freight loads went from something a 40-game sample caught reliably to needing + 200 — on `sim.test.ts`'s seeds, 40 games now yield 0 loads, 80 yield 3, 120 yield 10, 200 + yield 21. + + **It runs against the obvious expectation.** The change SPEEDS crossings up, so more trains + should reach more districts, not fewer. Revenue is flat (−0.2 against 0.0) and collisions are + unchanged at 0.1 a game, so nothing is obviously eating the traffic. Candidates worth checking: + trains now clear a district before a crew can work them; the entry-time collision rule + (below) destroying trains at the Office; or simply that faster turnover means fewer trains + standing where freight can be loaded. + +- [ ] **THE CREW TRAY COUNT IS DUE A RE-EXAMINATION, and this is the change that triggers it.** + `players + 3` was set when a Slow train took roughly twice as long to cross as a Fast one, and + Q2's recorded consequence was that "every Slow train is still on the road when the next Day + begins, holding its Crew Tray". Gitea#3 removed the Slow penalty from every card but Hilly. + Measured on the Mainline cards alone, a 3-player Division now costs a fast train ~5.6 Stages + and a slow one ~6.0, against ~5.4 and ~9.4 before: **fast traffic is unchanged, slow traffic is + about a third quicker**, and the gap across a whole Division collapses from roughly four Stages + to less than one. RAR's own closing note on the issue: "been worried about the time it takes to + cross the division. More thunking on this is needed." + + Numbers chosen to fix a measured problem rather than taken from the design. Revisit once the victory target is settled and freight carries its intended share; read no balance conclusion from a revenue number until the rules stop moving. @@ -1178,6 +1217,14 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite ## Display +- [ ] **THE BOARD STILL DOES NOT SAY WHICH WAY A HEAVY GRADE CLIMBS.** RAR, twice: "grade should + tell you which way is up." `gradeUp` is dealt at setup and drives which of Helpers or + Brakeman/Airbrakes can ever pay, and Gitea#3 made it matter more — the modifiers now move a + train's STARTING REGION, so playing the wrong one is three Stages of climb instead of two. The + tooltip says it (`mainlineDescription`); the map does not. Untouched by Gitea#3, which was + about the rules rather than the drawing. + + What is on the screen and where. Split out of Other 2026-08-22; the rules are elsewhere. - [ ] **The log's start marker only works while the whole log fits.** Added 2026-08-23: a multiplayer @@ -1529,17 +1576,31 @@ What is on the screen and where. Split out of Other 2026-08-22; the rules are el ## Rules Questions -- [ ] **NOT REPRODUCED: "when I back up to collect standing cars and, further down the tracks, the +- [ ] **PARTLY REPRODUCED: "when I back up to collect standing cars and, further down the tracks, the caboose, I get the caboose but the cars remain. I can later drive right through them."** Reported against v0.4.9d by a playtester (not Jesse, who forwarded it and could not add detail; his guess was that the cars were spotted at an industry). + **HALF OF IT IS NOW REPRODUCED AND FIXED (2026-08-26), as the second half of Gitea#17.** The + one case the earlier sweep did not try is a train pulling out through a 45° LEG rather than an + east or west port. `cutTowards` answered "you meet nothing" for a north or south exit, so a + crew standing on a curve drove away and left the cut beside it standing — the reported symptom + exactly, and against §A.4's mandatory coupling. `rowEndAt` (`track.ts`) fixes it, and + `cut-ordering.test.ts` pins it. + + **WHAT IS STILL UNEXPLAINED is the second sentence — "I can later drive right through them."** + Nothing found so far accounts for that. A card is swept by `carsOn` whenever a train enters it, + whichever port it enters by, so a later pass over those cars picks them up. Until that half has + a board behind it this stays open: the fix above may be the whole report, or only the part that + happened to be reachable from the code. + **What was tried, all of which works.** Cars on plain track on the way to the caboose; cars SPOTTED AT AN INDUSTRY on the way; the train's own cut standing on the square it is pulling out - of; a stale `standingWest` on the intermediate card; the industry locked by MEN AT WORK (which - correctly blocks the whole route rather than letting the crew past). Every one couples the lot. - The first three are pinned in `apply.test.ts` — "backing up over a cut to something beyond it - takes both" — so if the case is found later it is somewhere none of them cover. + of *through an east or west port*; a stale `standingWest` on the intermediate card; the industry + locked by MEN AT WORK (which correctly blocks the whole route rather than letting the crew + past). Every one couples the lot. The first three are pinned in `apply.test.ts` — "backing up + over a cut to something beyond it takes both" — so if the remaining case is found later it is + somewhere none of them cover. **Why it is hard to make happen.** Coupling is mandatory (§A.4) and `exploreMoves` accumulates what it meets card by card, so a route that reaches the caboose has already met everything @@ -1570,6 +1631,50 @@ What is on the screen and where. Split out of Other 2026-08-22; the rules are el Doesn't fit the above. +- [ ] **THE DECK IS `docs/Deck cards5.xlsx` EXACTLY, BAR TEN CARDS THAT ARE NOT BUILT.** Gitea#14, + 2026-08-26. Card for card, **84 rows agree with the sheet** and the only ones that do not are + the ten it adds that we have never implemented — Cargo Theft, Civic Improvement, Civilian + angel, Delayed Clearance, Flares 2, Robbery, Service Delays, Shipper complaints, Strike, + Union Hall 2: **12 copies**, held out on Jesse's instruction until they are built. Gitea#12 + partly specifies the Inspections among them. + + What landed: track halved; the Q12 office doubling and the Gap 12 industry tripling both + removed; Interlocking 2→1, Water column 2→1, ABS Signals 2→1, Red Flags 5→3. **Everything + sheet 5 does not list is dealt 0 copies rather than deleted** — the Telegraph/Telephone/Radio + dispatching ladder, Facing Point Locks (Enhancement and Mainline both), Flying Switch, Section + House, Vandalism, all confirmed by Jesse as deliberate removals from the design, plus Poling + and the sharp curves which were already there. The rows and their rules stay, so the design + stays visible and each mechanic works the moment it is dealt again. Deck 206 → **121** dealt. + + Measured, 100 games, developer bot: revenue per player **−0.2 → +0.4**, trains scheduled + 1.3 → 1.6, cards played 16.8 → 13.0. Freight share fell 9% → 4%, and part of that is Flying + Switch going to zero — it was a freight mechanic. Worth a look if freight is meant to carry + more. + + **NOT A DISCREPANCY, though it looks like one in a card-by-card diff:** Second Section is on + neither sheet and is not a drawn card here either. It is a New Train phase intent + (`newTrain.secondSection`), and the `copies: 1` on the `SECOND_SECTION` constant is vestigial — + nothing deals it. Worth removing that field so the next diff does not flag it again. + + **The deck reads 40% track against the sheet's 31%**, and the whole of that gap is the ten + held-out cards concentrating everything else. Building them moves the ratio to the sheet's on + its own, which is why the share is held to a loose band in `setup.test.ts` rather than pinned. + +- [ ] **FIVE TEST FIXTURES PINNED A SEED AND MEANT "A GAME LIKE THIS".** All five broke on Gitea#14 + and none of them was about card counts — the deck's SIZE moves the RNG stream, so changing it + re-deals every fixture that names a seed. Fixed in place: `mainline-cards` now searches for a + Division holding a single-track card, `multiplayer` for a game that reaches Day 3, `web` clicks + every play verb rather than assuming the first one goes on the board, and the two `sim` + commodity samples were re-measured (tank is first set out at game **216** now, unload Revenue + at game **46**). The `web` fix is on BOTH lines — it broke on `main` at the next count change, + exactly as predicted. `multiplayer`'s seed search is still playtest-only; port it when + convenient. + + A sixth turned up when the dropped cards went to zero: `mainline-cards`' `hand()` helper threw + if the card it wanted was not in the deck, so zeroing Flying Switch took five passing tests of + an UNCHANGED rule down with it. It mints a card that is no longer dealt now — which is the + point of keeping a row at zero, and the same will hold for the ladder if anyone tests it. + - [ ] **THE 0.4.9 PLAYTEST LINE IS BEHIND ON A RULES RULING, and that was checked rather than assumed.** Recorded 2026-08-23, when Jesse asked whether any of v0.7.0 needed porting to the `playtest` branch. Almost none of it does — that line has no lobby and no server, and the @@ -1577,12 +1682,29 @@ Doesn't fit the above. `configWith` only ever varies the house rules the save already restores. **But one v0.5.0 ruling is a CODE difference the testers do not have.** §A.4, the Local's - coach: `main`'s `apply.ts` reads `if (dropRules.coachStaysOnStationTrack && cut.some(coach) && - !atOffice)`, and `playtest`'s is the same line **without `&& !atOffice`** — so on that build a - coach still may not be set out at the Office. The other two questions the 0.4.9 README calls - open are documentation-only there (Poling is already at 0 copies; Heavy Grade behaves - identically and even deals the Mainline deck without replacement, so the correction applies - word for word). + coach — so on that build a coach still may not be set out at the Office. + + **IT IS THREE SITES, NOT ONE.** This entry named only the first until 2026-08-25, when a full + branch diff found the other two. Porting just the `apply.ts` line would leave the build in a + WORSE state than either line is in today: the coach could be set out at the Office and the next + arriving train would then collide with it. + + 1. `src/engine/apply.ts` — `main` reads `if (dropRules.coachStaysOnStationTrack && + cut.some(coach) && !atOffice)`; `playtest`'s is the same line **without `&& !atOffice`**. + This is the one that refuses the drop. + 2. `src/engine/track.ts` — `canDropCarsAt(area, coord, count, coachesOnly)` takes a fourth + `coachesOnly` parameter on `main` and returns the Office square as droppable when it is set. + `playtest`'s signature has no such parameter and returns `false` for the Office outright. + 3. `src/engine/advance.ts` — the §8.3 "cars fouling the Running Track" check. `main` reads + `officeCard.standing.some((c) => c.type !== 'coach')`, so a coach parked at the Office is + not a hazard to the next arrival; `playtest` reads `officeCard.standing.length > 0`, which + collides with anything standing there. **This one is behavioural and easy to miss** — it is + in a different file from the drop rules and reads as a collision fix rather than a coach one. + + The other two questions the 0.4.9 README calls open are documentation-only there (Poling is + already at 0 copies; Heavy Grade behaves identically — both lines run the same + `rng.nextInt(2)`, re-verified 2026-08-25 — and it even deals the Mainline deck without + replacement, so the correction applies word for word). **Jesse's call, 2026-08-23: do not port it now.** A settled rules change is not a playtest bug fix, and pushing one into the build people are mid-playtest on would invalidate the feedback @@ -1690,6 +1812,32 @@ Doesn't fit the above. ## Done, kept for the reasoning +- **Gitea#15 — a rail may stop dead against its neighbour, and the rule is on MOVEMENT.** + Filed 2026-08-25 as "track placements must connect": a right-hand curve had been laid with its + north leg against an Ice House and the turnout below pointing at its portless south edge, and the + report called that illegal. **RAR reversed it on review (2026-08-26)** — the placement is fine, and + a stub like that has a use, as a siding to park cars on. What he asked to confirm instead is that + no train can traverse the gap. + + It could not, and cannot: `exploreMoves` gates every hop on `joins`, which tests both ports AND + that two 45° legs lie on the same diagonal — never a bare pair of `hasPort` calls. That was already + true; `track.test.ts` now pins it against the reported geometry, including the check that the curve + IS reachable from the side that joins, so the negative test cannot pass on a card that is merely + unreachable. + + **A per-edge placement check was written and then taken out**, along with the matching guard on + `checkTurnoutUpgrade`. Both are documented in place as deliberately absent, because this is exactly + the rule someone will "fix" again. `canPlaceAt` keeps only the weaker requirement it always had: + the piece must touch the network somewhere, which is what stops orphaned track. + + **A Modifier is scenery** (Jesse, 2026-08-26): a rail pointing at a building is fine, so nothing + guards Modifier placement either. Measured before the ruling: 24 of 283 Modifiers across 200 bot + games sit where a neighbour's rail points at them, and that is simply legal. + + The attached save is dead — Gitea#14 took the deck from 206 cards to 121, so its card ids no longer + exist and the replay stops at the first `card.play`. **Every save filed before that deck change is + in the same position**, including Gitea#17's. Reproduce from the geometry, not the file. + - [x] **Put rolling stock back into circulation.** The Classification Yard was write-only — seven writers, no readers — so 37% of all rolling stock left the game by Day 5. Returning it at the Day boundary is **+2.32 ± 0.52 (t = 8.79)**, the largest single change measured on this bot, diff --git a/docs/Deck cards5.xlsx b/docs/Deck cards5.xlsx new file mode 100644 index 0000000..0c116c0 Binary files /dev/null and b/docs/Deck cards5.xlsx differ diff --git a/docs/rules/implications.md b/docs/rules/implications.md index be10170..1b7e4bc 100644 --- a/docs/rules/implications.md +++ b/docs/rules/implications.md @@ -40,8 +40,9 @@ All nine answers are implemented, **170 tests passing**: | Answer | Implemented as | | --- | --- | -| Q1 crossing time | `crossingStages()` — a 60 card takes 1 Stage, a 30 takes 2. Mainline nodes now carry a **terrain type** dealt at setup, and trains count down Stages instead of stepping through regions. The `Region` model is gone. | -| Q2 Fast/Slow | Slow adds one Stage to every card. Hilly reads the consist (any coach = passenger). | +| ~~Q1 crossing time~~ | **SUPERSEDED 2026-08-26 — see Q1a below.** Was: a 60 card takes 1 Stage, a 30 takes 2. | +| ~~Q2 Fast/Slow~~ | **SUPERSEDED 2026-08-26 — see Q1a below.** Was: Slow adds one Stage to every card; Hilly reads the consist. | +| Q1a crossing time | `crossingStages()` — a card costs one Stage per **region printed on it**, and where a train STARTS is what varies. Plains 1, Double Track 1, Trestle 1, Curves 2, Tunnel 2, Heavy Grade 3. The printed mph are scenery. Fast/Slow is read on Hilly and nowhere else. | | Q3 Expedite | An expedited train departs the Stage it arrives — it gets a second `moveTrain` in the same Mainline Phase, still subject to §8.1 clearance. | | Q4 Lockouts | `isLockedOut()` rejects the placement with `FACILITY_LOCKED`. | | Q5 Run-around | Nothing to do — reachability is geometric, so a built bypass already works. | @@ -76,6 +77,39 @@ Crossing time never falls below one Stage — a train cannot cross in no time. there is nothing to build. A test asserts it remains TBD, to stop anyone "fixing" it by inventing an effect; a silent no-op would be worse than a rejection. +**Q1a, answered by RAR 2026-08-26 (Gitea#3), and it replaces Q1 and Q2 together.** + +> "Ignore speed signs. They are just graphics. Regions shown on cards indicate how many stages it +> takes to cross. Plains is 1. Double track is 1, tunnel is 2, curves is 2, heavy grade is 3 unless +> you have help… Some cards say fast / slow. This is an indication that if on the train card, the +> train is listed as fast or slow, that's starting position / how many stages it takes to traverse +> the card. Fast / Slow does not apply to every card — just those that say fast / slow on them. +> Currently this is only hilly." + +What this changes, against what was recorded before: + +- **The printed 60/30 mean nothing.** Q1 read them as crossing time; they are ambiance. +- **Fast/Slow is not a global penalty.** Q2 added a Stage to every card for a Slow train, which is + what made a Slow train take two Stages to clear Double Track — the report that opened the issue. + It now applies on Hilly alone, where a fast train starts in the second region. +- **Hilly no longer reads the consist.** RAR: "I notice that you are basing stages in mainline cards + off coach/non-coach. Actually, all trains are rated as FAST and SLOW." +- **Heavy Grade is three regions, not two**, and the modifiers move the START rather than cutting the + clock: Helpers start an uphill train a region on, Brakeman a downhill one, Airbrakes another again. +- **The Uncontrolled Siding and the Interchange print a back region** that is not part of the road. A + train running through starts past it; a train arriving to find the siding occupied takes it and + runs a region behind, which is what keeps the two apart, and an Extra beginning its run at an + Interchange starts there too. +- **ABS holds a train off the card** rather than letting it collide, on any Mainline card. + +Measured consequence, replacing the one recorded under Q2: on a **3-player Division the Mainline +cards themselves now cost a fast train ~5.6 Stages and a slow train ~6.0**, against ~5.4 and ~9.4 +before. Fast traffic is unchanged; **slow traffic is about a third quicker**, and the Fast/Slow gap +across a whole Division collapses from roughly four Stages to less than one. The Q2 note that "every +Slow train is still on the road when the next Day begins, holding its Crew Tray" no longer holds, so +the `players + 3` tray count is due a re-examination — RAR's own closing worry: "been worried about +the time it takes to cross the division. More thunking on this is needed." + **Q11, answered from the source.** The Heavy Grade card prints **"(Up)"** and **"Player sets orientation"**, so which way it climbs is a property of the placed card, not a fixed compass direction. `DivisionNode.gradeUp` records the direction a train travels when **climbing**; a train diff --git a/package.json b/package.json index 7fbf62c..37818a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.7.1", + "version": "0.7.2", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/public/replays/seed-2242077.json b/public/replays/seed-1846127.json similarity index 70% rename from public/replays/seed-2242077.json rename to public/replays/seed-1846127.json index c18232e..2a04732 100644 --- a/public/replays/seed-2242077.json +++ b/public/replays/seed-1846127.json @@ -1,7 +1,7 @@ { - "seed": 2242077, - "title": "9 Revenue · seed 2242077", - "note": "9 Revenue over 5 Days · 3 train(s) on the timetable · 30 cards down · 6 collision(s) · played by developer", + "seed": 1846127, + "title": "8 Revenue · seed 1846127", + "note": "8 Revenue over 5 Days · 1 train(s) on the timetable · 10 cards down · no collisions · played by developer", "rules": { "startingHand": "threeRandom", "revenue": { @@ -9,7 +9,8 @@ "freightPerLoad": 1, "trainPerTransit": 0 }, - "extraStart": "anyOffice" + "extraStart": "anyOffice", + "discardTimetabled": true }, "history": [ { @@ -17,35 +18,18 @@ "option": "draw" }, { - "type": "draw.fromHomeOffice" + "type": "draw.fromDepartment", + "slot": 0 }, { "type": "card.play", - "cardId": "c138", + "cardId": "c117", "placement": { "row": 0, "col": 1 }, "variant": 0 }, - { - "type": "card.play", - "cardId": "c68", - "placement": { - "row": 1, - "col": 0 - }, - "variant": 0 - }, - { - "type": "card.play", - "cardId": "c199", - "placement": { - "row": 0, - "col": -1 - }, - "variant": 0 - }, { "type": "draw.end" }, @@ -57,385 +41,19 @@ "option": "draw" }, { - "type": "draw.fromHomeOffice" + "type": "draw.fromDepartment", + "slot": 0 }, { "type": "card.play", - "cardId": "c116", - "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": "c67", - "placement": { - "row": -1, - "col": 0 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c110", - "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": "c189", - "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": "c23" - }, - { - "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": "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": "c190", - "placement": { - "row": -1, - "col": -1 - }, - "variant": 1 - }, - { - "type": "card.play", - "cardId": "c37", - "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": "c187", - "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": "c185", - "placement": { - "row": -1, - "col": -3 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c201", - "placement": { - "row": 0, - "col": -4 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c69", - "placement": { - "row": -2, - "col": -1 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c64", - "placement": { - "row": 1, - "col": -1 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": 0, - "col": 0 - }, - "carType": "coach" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c147", - "placement": { - "row": -1, - "col": 3 - }, - "variant": 1 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c12" + "cardId": "c20" }, { "type": "draw.end" }, { "type": "newTrain.startExtra", - "trainNumber": 13, + "trainNumber": 21, "start": { "kind": "divisionPoint", "side": "west" @@ -445,19 +63,19 @@ "type": "newTrain.placeCar", "trayId": "tray3", "carType": "boxcar", - "loaded": false + "loaded": true }, { "type": "newTrain.placeCar", "trayId": "tray3", "carType": "boxcar", - "loaded": false + "loaded": true }, { "type": "newTrain.placeCar", "trayId": "tray3", "carType": "boxcar", - "loaded": false + "loaded": true }, { "type": "newTrain.placeCar", @@ -477,7 +95,7 @@ }, { "type": "card.discard", - "cardId": "c107", + "cardId": "c94", "toSlot": 0 }, { @@ -494,9 +112,8 @@ "type": "draw.fromHomeOffice" }, { - "type": "card.discard", - "cardId": "c61", - "toSlot": 1 + "type": "card.play", + "cardId": "c0" }, { "type": "draw.end" @@ -509,118 +126,19 @@ "option": "draw" }, { - "type": "draw.fromDepartment", - "slot": 1 + "type": "draw.fromHomeOffice" }, { - "type": "card.discard", + "type": "card.play", "cardId": "c40", - "toSlot": 1 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.move", - "trayId": "tray3", - "to": { - "row": -1, - "col": 3 - }, - "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.dropCars", - "trayId": "tray3", - "count": 1 - }, - { - "type": "switch.move", - "trayId": "tray3", - "to": { - "row": 0, + "placement": { + "row": 1, "col": 0 }, - "reverse": false + "variant": 0 }, { - "type": "switch.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -2 - }, - "carType": "boxcar" - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": -2 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -2 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 0 + "type": "draw.end" }, { "type": "loadUnload.end" @@ -630,249 +148,16 @@ "option": "draw" }, { - "type": "draw.fromDepartment", - "slot": 1 + "type": "draw.fromHomeOffice" }, { "type": "card.discard", - "cardId": "c63", + "cardId": "c46", "toSlot": 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": "c178", - "placement": { - "row": 0, - "col": 4 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c159", - "placement": { - "row": -1, - "col": -4 - }, - "variant": 1 - }, - { - "type": "draw.end" - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": -2 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -2 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c192", - "placement": { - "row": -1, - "col": 4 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 1 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c172", - "placement": { - "row": -1, - "col": -5 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c191", - "placement": { - "row": 0, - "col": 5 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": -2 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": -2 - }, - "carType": "boxcar" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c129", - "placement": { - "row": 0, - "col": -5 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": -2 - }, - "box": 1 - }, { "type": "loadUnload.end" }, @@ -885,19 +170,66 @@ }, { "type": "card.discard", - "cardId": "c35", + "cardId": "c29", "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": "c91", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.discard", + "cardId": "c52", + "toSlot": 0 + }, + { + "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" @@ -911,10 +243,10 @@ }, { "type": "card.play", - "cardId": "c44", + "cardId": "c80", "placement": { - "row": -1, - "col": 5 + "row": 0, + "col": -1 }, "variant": 0 }, @@ -933,8 +265,8 @@ }, { "type": "card.discard", - "cardId": "c61", - "toSlot": 0 + "cardId": "c29", + "toSlot": 2 }, { "type": "draw.end" @@ -948,31 +280,12 @@ }, { "type": "draw.fromDepartment", - "slot": 0 + "slot": 2 }, { "type": "card.discard", - "cardId": "c40", - "toSlot": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 0 - }, - { - "type": "card.discard", - "cardId": "c89", - "toSlot": 0 + "cardId": "c95", + "toSlot": 2 }, { "type": "draw.end" @@ -989,12 +302,12 @@ }, { "type": "card.play", - "cardId": "c153", + "cardId": "c120", "placement": { - "row": -2, - "col": -3 + "row": 0, + "col": -2 }, - "variant": 1 + "variant": 0 }, { "type": "draw.end" @@ -1009,6 +322,36 @@ { "type": "draw.fromHomeOffice" }, + { + "type": "card.discard", + "cardId": "c96", + "toSlot": 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.discard", "cardId": "c29", @@ -1025,94 +368,13 @@ "option": "draw" }, { - "type": "draw.fromHomeOffice" + "type": "draw.fromDepartment", + "slot": 1 }, { - "type": "card.play", - "cardId": "c9" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c59", - "placement": { - "row": -1, - "col": 6 - }, - "variant": 0 - }, - { - "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": "c177", - "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": "c134", - "placement": { - "row": 0, - "col": -6 - }, - "variant": 0 + "type": "card.discard", + "cardId": "c71", + "toSlot": 1 }, { "type": "draw.end" @@ -1129,7 +391,7 @@ }, { "type": "card.discard", - "cardId": "c61", + "cardId": "c29", "toSlot": 2 }, { @@ -1148,29 +410,76 @@ }, { "type": "card.discard", - "cardId": "c40", + "cardId": "c58", "toSlot": 2 }, { "type": "draw.end" }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c64", + "node": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c25" + }, + { + "type": "card.play", + "cardId": "c17" + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.startExtra", + "trainNumber": 18, + "start": { + "kind": "divisionPoint", + "side": "west" + } + }, { "type": "newTrain.placeCar", "trayId": "tray3", - "carType": "boxcar", - "loaded": false + "carType": "coach", + "loaded": true }, { "type": "newTrain.placeCar", "trayId": "tray3", "carType": "boxcar", - "loaded": false + "loaded": true }, { "type": "newTrain.placeCar", "trayId": "tray3", "carType": "boxcar", - "loaded": false + "loaded": true }, { "type": "newTrain.placeCar", @@ -1181,72 +490,6 @@ { "type": "loadUnload.end" }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c100", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": true - }, - { - "type": "mainline.clearance", - "allow": false - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c30" - }, - { - "type": "card.play", - "cardId": "c1" - }, - { - "type": "draw.end" - }, - { - "type": "mainline.clearance", - "allow": false - }, - { - "type": "loadUnload.end" - }, { "type": "localOps.choose", "option": "freightAgent" @@ -1260,54 +503,65 @@ "carType": "coach" }, { - "type": "mainline.clearance", - "allow": false - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.move", - "trayId": "tray3", - "to": { - "row": -1, - "col": -4 - }, - "reverse": true - }, - { - "type": "switch.dropCars", - "trayId": "tray3", - "count": 1 - }, - { - "type": "switch.move", - "trayId": "tray3", - "to": { + "type": "porter.detrain", + "at": { "row": 0, "col": 0 }, - "reverse": false + "trayId": "tray3" }, { - "type": "switch.end" + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + }, + "trayId": "tray3" }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "draw" + "option": "freightAgent" }, { - "type": "draw.fromHomeOffice" + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 }, { - "type": "draw.end" + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" }, { "type": "loadUnload.end" @@ -1321,113 +575,51 @@ }, { "type": "card.play", - "cardId": "c164", + "cardId": "c12" + }, + { + "type": "card.play", + "cardId": "c107", "placement": { - "row": -2, - "col": 4 + "row": 0, + "col": 2 }, - "variant": 1 + "variant": 0 }, { "type": "draw.end" }, { - "type": "newTrain.placeCar", - "trayId": "tray1", - "carType": "coach", - "loaded": false + "type": "newTrain.startExtra", + "trainNumber": 13, + "start": { + "kind": "divisionPoint", + "side": "west" + } }, { "type": "newTrain.placeCar", - "trayId": "tray1", - "carType": "coach", - "loaded": false + "trayId": "tray2", + "carType": "boxcar", + "loaded": true }, { - "type": "mainline.clearance", - "allow": false + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", + "loaded": true }, { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 - }, - "trayId": "tray2" + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", + "loaded": true }, { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - }, - "trayId": "tray2" - }, - { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 - }, - "trayId": "tray2" - }, - { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - }, - "trayId": "tray2" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": 0, - "col": 0 - }, - "index": 0 - }, - { - "type": "mainline.clearance", - "allow": false - }, - { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 - }, - "trayId": "tray2" - }, - { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - }, - "trayId": "tray2" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": 0, - "col": 0 - }, - "index": 0 + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "caboose", + "loaded": true }, { "type": "loadUnload.end" @@ -1440,35 +632,765 @@ "type": "draw.fromHomeOffice" }, { - "type": "card.discard", - "cardId": "c61", - "toSlot": 0 + "type": "card.play", + "cardId": "c93", + "placement": { + "row": -1, + "col": 2 + }, + "variant": 1 + }, + { + "type": "card.play", + "cardId": "c29", + "placement": { + "row": -1, + "col": 3 + }, + "variant": 0 }, { "type": "draw.end" }, + { + "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" + "option": "switch" }, { - "type": "draw.fromDepartment", - "slot": 0 + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": -1, + "col": 2 + }, + "reverse": false }, { - "type": "card.discard", - "cardId": "c40", - "toSlot": 0 + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 }, { - "type": "draw.end" + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": -1, + "col": 3 + }, + "reverse": false }, { - "type": "mainline.clearance", - "allow": false + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 + }, + { + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 + }, + { + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 + }, + { + "type": "switch.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 3 + }, + "carIndex": 0 + }, + { + "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": 3 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 3 + }, + "box": 1 + }, + { + "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": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "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.startLoad", + "at": { + "row": -1, + "col": 3 + } + }, + { + "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": 3 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 3 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 3 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 3 + }, + "carIndex": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 3 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 3 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": true + }, + { + "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.startLoad", + "at": { + "row": -1, + "col": 3 + } + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 3 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 3 + }, + "box": 1 + }, + { + "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": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 3 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 3 + }, + "carIndex": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 3 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 3 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "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": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" }, { "type": "loadUnload.end" diff --git a/public/replays/seed-1466015.json b/public/replays/seed-1917398.json similarity index 67% rename from public/replays/seed-1466015.json rename to public/replays/seed-1917398.json index 5c82181..b71e49b 100644 --- a/public/replays/seed-1466015.json +++ b/public/replays/seed-1917398.json @@ -1,7 +1,7 @@ { - "seed": 1466015, - "title": "9 Revenue · seed 1466015", - "note": "9 Revenue over 5 Days · 2 train(s) on the timetable · 10 cards down · no collisions · played by developer", + "seed": 1917398, + "title": "8 Revenue · seed 1917398", + "note": "8 Revenue over 5 Days · 2 train(s) on the timetable · 13 cards down · no collisions · played by developer", "rules": { "startingHand": "threeRandom", "revenue": { @@ -9,7 +9,8 @@ "freightPerLoad": 1, "trainPerTransit": 0 }, - "extraStart": "anyOffice" + "extraStart": "anyOffice", + "discardTimetabled": true }, "history": [ { @@ -17,24 +18,16 @@ "option": "draw" }, { - "type": "draw.fromHomeOffice" + "type": "draw.fromDepartment", + "slot": 2 }, { "type": "card.play", - "cardId": "c13" + "cardId": "c5" }, { "type": "card.play", - "cardId": "c65", - "placement": { - "row": 1, - "col": 0 - }, - "variant": 0 - }, - { - "type": "card.play", - "cardId": "c183", + "cardId": "c106", "placement": { "row": 0, "col": 1 @@ -45,28 +38,95 @@ "type": "draw.end" }, { - "type": "newTrain.startExtra", - "trainNumber": 14, - "start": { - "kind": "divisionPoint", - "side": "west" - } + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false }, { "type": "newTrain.placeCar", "trayId": "tray3", - "carType": "reefer", + "carType": "coach", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.play", + "cardId": "c25" + }, + { + "type": "card.play", + "cardId": "c9" + }, + { + "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.fromDepartment", + "slot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", "loaded": true }, { "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "reefer", + "trayId": "tray2", + "carType": "boxcar", "loaded": true }, { "type": "newTrain.placeCar", - "trayId": "tray3", + "trayId": "tray2", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", "carType": "caboose", "loaded": true }, @@ -82,7 +142,7 @@ }, { "type": "card.play", - "cardId": "c187", + "cardId": "c82", "placement": { "row": 0, "col": -1 @@ -102,178 +162,12 @@ { "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": "c28" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": 0, - "col": 0 - }, - "carType": "coach" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": 0, - "col": 0 - }, - "carType": "coach" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c73", - "toSlot": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c6" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c17" - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.startExtra", - "trainNumber": 18, - "start": { - "kind": "divisionPoint", - "side": "west" - } - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "boxcar", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "boxcar", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "caboose", - "loaded": true - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c113", + "cardId": "c42", "placement": { - "row": 0, - "col": -2 - }, - "variant": 0 - }, - { - "type": "card.play", - "cardId": "c86", - "placement": { - "row": 0, - "col": -2 + "row": 1, + "col": 0 }, "variant": 0 }, @@ -283,68 +177,6 @@ { "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": "c159", - "toSlot": 1 - }, - { - "type": "draw.end" - }, - { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 - }, - "trayId": "tray3" - }, - { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - }, - "trayId": "tray3" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.clearInbound", - "at": { - "row": 0, - "col": 0 - }, - "index": 0 - }, - { - "type": "loadUnload.end" - }, { "type": "localOps.choose", "option": "freightAgent" @@ -367,11 +199,33 @@ { "type": "draw.fromHomeOffice" }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, { "type": "card.play", - "cardId": "c111", + "cardId": "c95", "placement": { - "row": 0, + "row": -1, + "col": 1 + }, + "variant": 1 + }, + { + "type": "card.play", + "cardId": "c37", + "placement": { + "row": -1, "col": 2 }, "variant": 0 @@ -389,12 +243,43 @@ { "type": "draw.fromHomeOffice" }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.discard", + "cardId": "c51", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, { "type": "card.play", - "cardId": "c117", + "cardId": "c88", "placement": { "row": 0, - "col": 3 + "col": -2 }, "variant": 0 }, @@ -413,22 +298,28 @@ }, { "type": "card.discard", - "cardId": "c100", - "toSlot": 2 + "cardId": "c34", + "toSlot": 1 }, { "type": "draw.end" }, { "type": "newTrain.placeCar", - "trayId": "tray3", + "trayId": "tray2", "carType": "coach", "loaded": true }, { "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "boxcar", + "trayId": "tray2", + "carType": "coach", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", "loaded": true }, { @@ -439,29 +330,13 @@ "option": "draw" }, { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c11" - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" + "type": "draw.fromDepartment", + "slot": 1 }, { "type": "card.discard", - "cardId": "c46", - "toSlot": 0 + "cardId": "c92", + "toSlot": 1 }, { "type": "draw.end" @@ -472,7 +347,7 @@ "row": 0, "col": 0 }, - "trayId": "tray3" + "trayId": "tray2" }, { "type": "porter.board", @@ -480,7 +355,7 @@ "row": 0, "col": 0 }, - "trayId": "tray3" + "trayId": "tray2" }, { "type": "loadUnload.end" @@ -513,100 +388,10 @@ "carType": "coach" }, { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 0 - }, - { - "type": "card.discard", - "cardId": "c74", - "toSlot": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c83", - "toSlot": 1 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c140", - "placement": { - "row": 0, - "col": -3 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c46", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c94", - "toSlot": 2 - }, - { - "type": "draw.end" + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true }, { "type": "newTrain.placeCar", @@ -638,7 +423,164 @@ }, { "type": "card.discard", - "cardId": "c24", + "cardId": "c34", + "toSlot": 2 + }, + { + "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.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": 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.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 2 + }, + { + "type": "card.discard", + "cardId": "c65", + "toSlot": 2 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.discard", + "cardId": "c34", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 0 + }, + { + "type": "card.discard", + "cardId": "c96", "toSlot": 0 }, { @@ -656,7 +598,7 @@ }, { "type": "card.discard", - "cardId": "c46", + "cardId": "c34", "toSlot": 1 }, { @@ -675,24 +617,12 @@ }, { "type": "card.discard", - "cardId": "c164", + "cardId": "c93", "toSlot": 1 }, { "type": "draw.end" }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "boxcar", - "loaded": true - }, { "type": "loadUnload.end" }, @@ -705,13 +635,22 @@ }, { "type": "card.play", - "cardId": "c90", + "cardId": "c108", "placement": { "row": 0, "col": 2 }, "variant": 0 }, + { + "type": "card.play", + "cardId": "c34", + "placement": { + "row": -1, + "col": 3 + }, + "variant": 0 + }, { "type": "draw.end" }, @@ -726,20 +665,56 @@ "type": "draw.fromHomeOffice" }, { - "type": "card.discard", - "cardId": "c46", - "toSlot": 2 + "type": "card.play", + "cardId": "c78", + "placement": { + "row": 0, + "col": 3 + }, + "variant": 0 }, { "type": "draw.end" }, { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 + "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": false + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c56", + "placement": { + "row": -2, + "col": 2 }, - "trayId": "tray2" + "variant": 0 + }, + { + "type": "draw.end" }, { "type": "porter.board", @@ -747,7 +722,15 @@ "row": 0, "col": 0 }, - "trayId": "tray2" + "trayId": "tray3" + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + }, + "trayId": "tray3" }, { "type": "loadUnload.end" @@ -767,112 +750,21 @@ { "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": 2 - }, - { - "type": "card.discard", - "cardId": "c53", - "toSlot": 2 + "type": "draw.fromHomeOffice" }, { "type": "draw.end" }, { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c46", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "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": "c46", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "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", @@ -900,13 +792,16 @@ "option": "draw" }, { - "type": "draw.fromDepartment", - "slot": 2 + "type": "draw.fromHomeOffice" }, { - "type": "card.discard", - "cardId": "c46", - "toSlot": 2 + "type": "card.play", + "cardId": "c61", + "placement": { + "row": 0, + "col": -1 + }, + "variant": 0 }, { "type": "draw.end" @@ -916,38 +811,179 @@ }, { "type": "localOps.choose", - "option": "draw" + "option": "switch" }, { - "type": "draw.fromDepartment", - "slot": 2 + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": -1, + "col": 1 + }, + "reverse": false }, { - "type": "card.discard", - "cardId": "c53", - "toSlot": 2 + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 }, { - "type": "draw.end" + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": -1, + "col": 2 + }, + "reverse": false + }, + { + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 + }, + { + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 + }, + { + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 + }, + { + "type": "switch.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 2 + }, + "carIndex": 0 }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "draw" + "option": "switch" }, { - "type": "draw.fromDepartment", - "slot": 2 + "type": "switch.end" }, { - "type": "card.discard", - "cardId": "c46", - "toSlot": 2 + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 2 }, { - "type": "draw.end" + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": -1, + "col": 2 + }, + "index": 0 + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 2 + }, + "carIndex": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" }, { "type": "newTrain.placeCar", @@ -958,46 +994,31 @@ { "type": "newTrain.placeCar", "trayId": "tray3", - "carType": "boxcar", - "loaded": true + "carType": "coach", + "loaded": false + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 0 }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "draw" + "option": "freightAgent" }, { - "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": "c46", - "toSlot": 2 - }, - { - "type": "draw.end" + "type": "freightAgent.clearInbound", + "at": { + "row": -1, + "col": 2 + }, + "index": 0 }, { "type": "porter.board", @@ -1008,346 +1029,363 @@ "trayId": "tray3" }, { - "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": "c46", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "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": "c46", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "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": "c46", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c53", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "boxcar", - "loaded": true - }, - { - "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.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c46", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "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": "c46", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "boxcar", - "loaded": false - }, - { - "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": "c46", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "porter.board", + "type": "laborer.beginUnload", "at": { + "row": -1, + "col": 2 + }, + "carIndex": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { "row": 0, "col": 0 }, - "trayId": "tray2" + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": false + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 2 }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "draw" + "option": "switch" }, { - "type": "draw.fromDepartment", - "slot": 2 + "type": "switch.end" }, { - "type": "card.discard", - "cardId": "c53", - "toSlot": 2 - }, - { - "type": "draw.end" + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 1 }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "draw" + "option": "switch" }, { - "type": "draw.fromDepartment", - "slot": 2 + "type": "switch.end" }, { - "type": "card.discard", - "cardId": "c46", - "toSlot": 2 - }, - { - "type": "draw.end" + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 2 + }, + "box": 0 }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "draw" + "option": "freightAgent" }, { - "type": "draw.fromDepartment", - "slot": 2 - }, - { - "type": "card.discard", - "cardId": "c53", - "toSlot": 2 - }, - { - "type": "draw.end" + "type": "freightAgent.clearInbound", + "at": { + "row": -1, + "col": 2 + }, + "index": 0 }, { "type": "loadUnload.end" }, { "type": "localOps.choose", - "option": "draw" + "option": "switch" }, { - "type": "draw.fromDepartment", - "slot": 2 + "type": "switch.end" }, { - "type": "card.discard", - "cardId": "c46", - "toSlot": 2 + "type": "loadUnload.end" }, { - "type": "draw.end" + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": false + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" }, { "type": "loadUnload.end" diff --git a/public/replays/seed-2044102.json b/public/replays/seed-674115.json similarity index 78% rename from public/replays/seed-2044102.json rename to public/replays/seed-674115.json index 07bfbb0..1bf9171 100644 --- a/public/replays/seed-2044102.json +++ b/public/replays/seed-674115.json @@ -1,7 +1,7 @@ { - "seed": 2044102, - "title": "9 Revenue · seed 2044102", - "note": "9 Revenue over 5 Days · 1 train(s) on the timetable · 18 cards down · 3 collision(s) · played by developer", + "seed": 674115, + "title": "8 Revenue · seed 674115", + "note": "8 Revenue over 5 Days · 2 train(s) on the timetable · 19 cards down · no collisions · played by developer", "rules": { "startingHand": "threeRandom", "revenue": { @@ -9,45 +9,10 @@ "freightPerLoad": 1, "trainPerTransit": 0 }, - "extraStart": "anyOffice" + "extraStart": "anyOffice", + "discardTimetabled": true }, "history": [ - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c168", - "toSlot": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c58", - "toSlot": 1 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, { "type": "localOps.choose", "option": "draw" @@ -57,210 +22,51 @@ "slot": 1 }, { - "type": "card.discard", - "cardId": "c160", - "toSlot": 1 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" + "type": "card.play", + "cardId": "c24" }, { "type": "card.play", - "cardId": "c192", - "placement": { - "row": 0, - "col": 1 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c79", - "toSlot": 2 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" + "cardId": "c27" }, { "type": "card.play", - "cardId": "c4" + "cardId": "c17" }, { "type": "draw.end" }, { - "type": "loadUnload.end" + "type": "newTrain.startExtra", + "trainNumber": 18, + "start": { + "kind": "divisionPoint", + "side": "west" + } }, { - "type": "localOps.choose", - "option": "draw" + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": true }, { - "type": "draw.fromHomeOffice" + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true }, { - "type": "card.discard", - "cardId": "c92", - "toSlot": 0 + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true }, { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c204", - "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": "c202", - "placement": { - "row": -1, - "col": -1 - }, - "variant": 1 - }, - { - "type": "card.play", - "cardId": "c58", - "placement": { - "row": -1, - "col": 0 - }, - "variant": 0 - }, - { - "type": "card.play", - "cardId": "c50", - "placement": { - "row": -1, - "col": -2 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c67", - "placement": { - "row": 1, - "col": 0 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c183", - "placement": { - "row": 0, - "col": -2 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c29" - }, - { - "type": "draw.end" + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "caboose", + "loaded": true }, { "type": "loadUnload.end" @@ -277,61 +83,6 @@ }, "carType": "coach" }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": 0, - "col": 0 - }, - "carType": "coach" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", - "loaded": true - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c119", - "placement": { - "row": 0, - "col": 2 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, { "type": "porter.detrain", "at": { @@ -381,6 +132,53 @@ { "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": 2 + }, + { + "type": "card.play", + "cardId": "c21" + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.startExtra", + "trainNumber": 22, + "start": { + "kind": "divisionPoint", + "side": "west" + } + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "caboose", + "loaded": true + }, + { + "type": "loadUnload.end" + }, { "type": "localOps.choose", "option": "draw" @@ -390,9 +188,9 @@ }, { "type": "card.play", - "cardId": "c147", + "cardId": "c86", "placement": { - "row": -1, + "row": 0, "col": 1 }, "variant": 0 @@ -412,7 +210,12 @@ }, { "type": "card.play", - "cardId": "c33" + "cardId": "c38", + "placement": { + "row": 1, + "col": 0 + }, + "variant": 0 }, { "type": "draw.end" @@ -455,12 +258,158 @@ { "type": "draw.fromHomeOffice" }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, { "type": "card.play", - "cardId": "c143", + "cardId": "c78", + "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": "c20" + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.startExtra", + "trainNumber": 21, + "start": { + "kind": "divisionPoint", + "side": "west" + } + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "caboose", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c61", + "placement": { + "row": 0, + "col": -1 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "mainline.modify", + "cardId": "c69", + "node": 3 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c111", + "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": "c89", "placement": { "row": -1, - "col": -3 + "col": -2 }, "variant": 1 }, @@ -477,6 +426,24 @@ { "type": "draw.fromHomeOffice" }, + { + "type": "card.play", + "cardId": "c30", + "placement": { + "row": -1, + "col": -1 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c44", + "placement": { + "row": -1, + "col": 0 + }, + "variant": 0 + }, { "type": "draw.end" }, @@ -492,7 +459,77 @@ }, { "type": "card.play", - "cardId": "c139", + "cardId": "c8" + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c88", + "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": "card.discard", + "cardId": "c71", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c87", "placement": { "row": 0, "col": 3 @@ -514,7 +551,7 @@ }, { "type": "card.discard", - "cardId": "c107", + "cardId": "c62", "toSlot": 1 }, { @@ -532,80 +569,16 @@ }, { "type": "card.play", - "cardId": "c148", + "cardId": "c118", "placement": { - "row": -2, - "col": 1 - }, - "variant": 1 - }, - { - "type": "card.play", - "cardId": "c46", - "placement": { - "row": -2, - "col": 2 + "row": 0, + "col": -3 }, "variant": 0 }, { "type": "draw.end" }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", - "loaded": true - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", - "loaded": false - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "draw.end" - }, - { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - }, - "trayId": "tray3" - }, - { - "type": "porter.board", - "at": { - "row": 0, - "col": 0 - }, - "trayId": "tray3" - }, - { - "type": "porter.detrain", - "at": { - "row": 0, - "col": 0 - }, - "trayId": "tray3" - }, { "type": "loadUnload.end" }, @@ -618,83 +591,46 @@ }, { "type": "card.play", - "cardId": "c19" - }, - { - "type": "draw.end" - }, - { - "type": "newTrain.startExtra", - "trainNumber": 20, - "start": { - "kind": "divisionPoint", - "side": "west" - } - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "reefer", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "reefer", - "loaded": false - }, - { - "type": "newTrain.placeCar", - "trayId": "tray2", - "carType": "coach", - "loaded": false - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" + "cardId": "c109", + "placement": { + "row": 0, + "col": 1 + }, + "variant": 0 }, { "type": "card.play", - "cardId": "c12" + "cardId": "c91", + "placement": { + "row": -1, + "col": 1 + }, + "variant": 1 }, { "type": "draw.end" }, - { - "type": "newTrain.startExtra", - "trainNumber": 13, - "start": { - "kind": "divisionPoint", - "side": "west" - } - }, { "type": "newTrain.placeCar", - "trayId": "tray1", - "carType": "reefer", + "trayId": "tray3", + "carType": "boxcar", "loaded": false }, { "type": "newTrain.placeCar", - "trayId": "tray1", - "carType": "reefer", + "trayId": "tray3", + "carType": "boxcar", "loaded": false }, { "type": "newTrain.placeCar", - "trayId": "tray1", - "carType": "reefer", + "trayId": "tray3", + "carType": "boxcar", "loaded": false }, { "type": "newTrain.placeCar", - "trayId": "tray1", + "trayId": "tray3", "carType": "caboose", "loaded": true }, @@ -710,86 +646,16 @@ }, { "type": "card.play", - "cardId": "c124", + "cardId": "c116", "placement": { - "row": 0, - "col": -3 + "row": -1, + "col": 2 }, "variant": 0 }, { "type": "draw.end" }, - { - "type": "mainline.clearance", - "allow": false - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "mainline.modify", - "cardId": "c103", - "node": 3 - }, - { - "type": "draw.end" - }, - { - "type": "mainline.clearance", - "allow": false - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.play", - "cardId": "c65", - "placement": { - "row": 1, - "col": -1 - }, - "variant": 0 - }, - { - "type": "draw.end" - }, - { - "type": "mainline.clearance", - "allow": false - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "draw" - }, - { - "type": "draw.fromHomeOffice" - }, - { - "type": "card.discard", - "cardId": "c71", - "toSlot": 2 - }, - { - "type": "draw.end" - }, { "type": "loadUnload.end" }, @@ -799,48 +665,132 @@ }, { "type": "switch.move", - "trayId": "tray1", + "trayId": "tray3", "to": { "row": -1, - "col": -3 + "col": 1 }, "reverse": true }, { "type": "switch.dropCars", - "trayId": "tray1", + "trayId": "tray3", "count": 1 }, { "type": "switch.move", - "trayId": "tray1", + "trayId": "tray3", "to": { - "row": -1, + "row": 0, "col": 0 }, "reverse": false }, - { - "type": "switch.dropCars", - "trayId": "tray1", - "count": 1 - }, - { - "type": "switch.dropCars", - "trayId": "tray1", - "count": 1 - }, - { - "type": "switch.dropCars", - "trayId": "tray1", - "count": 1 - }, { "type": "switch.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": "c81", + "placement": { + "row": 0, + "col": -4 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c16" + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.startExtra", + "trainNumber": 17, + "start": { + "kind": "divisionPoint", + "side": "west" + } + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c9" + }, + { + "type": "draw.end" + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + }, + "trayId": "tray3" + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + }, + "trayId": "tray3" + }, + { + "type": "loadUnload.end" + }, { "type": "localOps.choose", "option": "freightAgent" @@ -863,17 +813,100 @@ { "type": "freightAgent.stockOutbound", "at": { - "row": -1, + "row": 0, "col": 0 }, - "carType": "reefer" + "carType": "coach" }, { - "type": "laborer.startLoad", - "at": { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c97", + "placement": { "row": -1, - "col": 0 - } + "col": -3 + }, + "variant": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c112", + "placement": { + "row": -1, + "col": 3 + }, + "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": "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": "c100", + "placement": { + "row": -1, + "col": 4 + }, + "variant": 1 + }, + { + "type": "draw.end" }, { "type": "loadUnload.end" @@ -884,7 +917,7 @@ }, { "type": "switch.move", - "trayId": "tray2", + "trayId": "tray3", "to": { "row": -1, "col": -3 @@ -893,14 +926,343 @@ }, { "type": "switch.dropCars", - "trayId": "tray2", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": -1, + "col": -1 + }, + "reverse": false + }, + { + "type": "switch.dropCars", + "trayId": "tray3", "count": 1 }, { "type": "switch.dropCars", - "trayId": "tray2", + "trayId": "tray3", "count": 1 }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "caboose", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": -1, + "col": -1 + }, + "carType": "boxcar" + }, + { + "type": "laborer.startLoad", + "at": { + "row": -1, + "col": -1 + } + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": -1, + "col": -1 + }, + "carType": "boxcar" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -1 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -1 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -1 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.startLoad", + "at": { + "row": -1, + "col": -1 + } + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": -1, + "col": -1 + }, + "carType": "boxcar" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -1 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -1 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -1 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.startLoad", + "at": { + "row": -1, + "col": -1 + } + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": -1, + "col": -1 + }, + "carType": "boxcar" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -1 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": -1 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", + "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": -1 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": -1 + }, + "reverse": false + }, { "type": "switch.move", "trayId": "tray2", @@ -908,287 +1270,43 @@ "row": 0, "col": 0 }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": -1 + }, "reverse": false }, { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 0 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 0 - }, - "carType": "reefer" - }, - { - "type": "newTrain.placeCar", - "trayId": "tray3", - "carType": "coach", - "loaded": false - }, - { - "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": 0 - }, - "box": 1 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "porter.board", - "at": { + "type": "switch.move", + "trayId": "tray2", + "to": { "row": 0, "col": 0 }, - "trayId": "tray3" + "reverse": true }, { - "type": "laborer.advanceLoad", - "at": { - "row": -1, + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": -1 + }, + "reverse": false + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, "col": 0 }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 0 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 0 - }, - "carType": "reefer" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 0 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 0 - }, - "box": 1 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 0 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.startLoad", - "at": { - "row": -1, - "col": 0 - } - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "freightAgent" - }, - { - "type": "freightAgent.stockOutbound", - "at": { - "row": -1, - "col": 0 - }, - "carType": "reefer" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 0 - }, - "box": 0 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 0 - }, - "box": 1 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "laborer.advanceLoad", - "at": { - "row": -1, - "col": 0 - }, - "box": 2 - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" - }, - { - "type": "loadUnload.end" - }, - { - "type": "localOps.choose", - "option": "switch" - }, - { - "type": "switch.end" + "reverse": true }, { "type": "loadUnload.end" diff --git a/src/engine/advance.ts b/src/engine/advance.ts index 27b1d5d..034c924 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -23,16 +23,16 @@ import { enhancementRule, crossingStages, trainProfile, + startRegion, MOVES_PER_LOCAL_OPS, MOVES_PER_LOCAL_OPS_NIGHT, STAGES_PER_DAY, STAGES_PER_SHIFT, houseRules, officeProfile, - REGIONS_PER_MAINLINE_CARD, mainlineProfile, } from './content.ts'; -import type { Direction } from './content.ts'; +import type { Direction, MainlineEntry, MainlineKind } from './content.ts'; import type { GameEvent } from './events.ts'; // `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and // the legality test cannot disagree about which train is being assembled. @@ -451,6 +451,104 @@ function badlyMadeUp(tray: CrewTray): string | null { return caboose === rear ? null : 'not made up — the caboose must be at the rear of the train'; } +/** + * WHICH REGION OF A MAINLINE CARD A TRAIN IS STANDING IN (Gitea#3). + * + * A card is `regions` boxes wide and a train advances one per Stage, so what it has LEFT to run says + * where it is: enter with `regions` still to go and you are at the beginning; enter with one to go + * and you are in the last box. + * + * This used to be derived from a single global `REGIONS_PER_MAINLINE_CARD = 2`, with an entry term + * that put a one-Stage train in region 1 of a two-region card — a fast train did not traverse a fast + * card, it appeared at the far half of it. Cards carry their own region count now, so the position + * is simply the count minus what is left. + */ +export function regionOfTransit(card: MainlineKind, stagesRemaining: number): number { + const regions = mainlineProfile(card).regions; + return Math.min(regions - 1, Math.max(0, regions - stagesRemaining)); +} + +/** The entry a train would make onto this card, before occupancy is taken into account. */ +function entryFor( + node: Extract, + tray: CrewTray, + startsAtBack = false, +): MainlineEntry { + const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra); + return { + trainSpeed: profile?.speed ?? 'slow', + direction: tray.direction, + gradeUp: node.gradeUp ?? 'east', + modifiers: node.modifiers ?? [], + ...(startsAtBack ? { startsAtBack: true } : {}), + }; +} + +/** + * THE UNCONTROLLED SIDING RULE (Gitea#3): "if a train already exists when you arrive, you go in the + * second stage back — you are in the siding and are one behind the other train. This prevents a + * collision, since you are not in same exact location." + * + * So arriving at an occupied siding is not a collision and not a hold; it is a different, slower + * entry. Anywhere else this returns false and the ordinary start applies. + */ +function takesTheSiding(node: Extract): boolean { + return node.card === 'uncontrolledSiding' && node.transits.length > 0; +} + +/** + * IS MOVING ONTO THIS CARD A COLLISION? (Gitea#3) + * + * A card can be ONE region wide — Plains, Double Track and Trestle all are — so a following train + * granted clearance arrives in the same region as the train ahead the moment it enters. There was no + * test for that at all: the catch-up check lives inside `stagesRemaining > 1`, which a one-Stage + * crossing never reaches, so entering behind another train on a Plains was silently free. + * + * ABS is the card that answers it, in RAR's words: "This is played on a mainline card to prevent + * collisions. If a collision would normally occur, the train moving onto the card is instead held + * back." Held, not waved through — it tries again next Stage. + * + * The Uncontrolled Siding never conflicts on entry, because `takesTheSiding` has already moved this + * train a region back; that is the whole point of the card. + */ +function entryConflict( + s: GameState, + node: Extract, + id: TrayId, + tray: CrewTray, + events: GameEvent[], + startsAtBack = false, +): 'collided' | 'held' | null { + if (mainlineProfile(node.card).trainsMayPass) return null; + const start = startRegion(node.card, entryFor(node, tray, startsAtBack || takesTheSiding(node))); + const ahead = node.transits.find( + (t) => + t.tray !== id && + t.direction === tray.direction && + regionOfTransit(node.card, t.stagesRemaining) === start, + ); + if (!ahead) return null; + + /** + * A BACKSTOP, not the main path. `evaluateClearance` already refuses to clear a train onto a card + * carrying ABS, so in the ordinary run of things nothing reaches here with signals up. It stays + * because the two rules answer to different questions — clearance looks at the whole Subdivision, + * this looks at one region — and a card that promises no rear-enders should not depend on the + * wider check happening to fire first. + */ + if (node.absSignals) { + events.push({ + type: 'trainHeld', + trainNumber: tray.trainNumber ?? 0, + reason: 'ABS Signals — held short of the train ahead', + }); + return 'held'; + } + // §10 — the Superintendent cleared it into an occupied region, so it is the Superintendent's fault. + collide(s, s.clock.superintendent, [id, ahead.tray], events, 'ran into the train ahead', 'the Mainline'); + return 'collided'; +} + /** Puts a train onto a Mainline card with its crossing time already computed. */ function enterMainline( s: GameState, @@ -458,17 +556,9 @@ function enterMainline( id: TrayId, tray: CrewTray, index: number, + startsAtBack = false, ): void { - const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra); - const carriesPassengers = tray.consist.some((c) => c.type === 'coach'); - const stages = crossingStages( - node.card, - profile?.speed ?? 'slow', - carriesPassengers, - node.modifiers ?? [], - tray.direction, - node.gradeUp ?? 'east', - ); + const stages = crossingStages(node.card, entryFor(node, tray, startsAtBack || takesTheSiding(node))); node.transits.push({ tray: id, stagesRemaining: stages, stagesTotal: stages, direction: tray.direction }); tray.position = { at: 'mainline', index }; // It is running now, so it is no longer being assembled (state.ts). A train at a Division Point @@ -577,6 +667,10 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[] if (clearance === 'blocked') return 'held'; if (clearance === 'ask') return 'needsClearance'; + const conflict = entryConflict(s, node, id, tray, events); + if (conflict === 'held') return 'held'; + if (conflict === 'collided') return 'moved'; + enterMainline(s, node, id, tray, target); const dp = s.division.nodes[dpIndex]; if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id); @@ -636,6 +730,11 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[] if (clearance === 'blocked') return 'held'; if (clearance === 'ask') return 'needsClearance'; + const conflict = entryConflict(s, node, id, tray, events); + if (conflict === 'held') return 'held'; + // The wreck's A/D track is released by `collide` itself, which is why it has to be. + if (conflict === 'collided') return 'moved'; + enterMainline(s, node, id, tray, target); area.adOccupancy = area.adOccupancy.filter((t) => t !== id); events.push({ @@ -696,8 +795,19 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[] } if (clearance === 'ask') return 'needsClearance'; + /** + * AN EXTRA PULLING OUT OF THE INTERCHANGE STARTS IN THE BACK REGION (Gitea#3) — "interchange + * has new extras show up in second region (like uncontrolled siding)", and earlier, "Plains is + * 1 stage for ALL trains. So are interlockings, with a second stage for incoming extras to hold + * at." A train running THROUGH the Interchange starts past that region and crosses in one + * Stage; one that began its run here has the holding region to clear first. + */ + const conflict = entryConflict(s, node, id, tray, events, true); + if (conflict === 'held') return 'held'; + if (conflict === 'collided') return 'moved'; + node.holding = node.holding.filter((t) => t !== id); - enterMainline(s, node, id, tray, index); + enterMainline(s, node, id, tray, index, true); events.push({ type: 'trainHighballed', trainNumber: tray.trainNumber ?? 0, @@ -729,21 +839,22 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[] * * ABS Signals does what it says instead: the follower stops SHORT of the collision and holds. */ - const regionOf = (t: { stagesTotal: number; stagesRemaining: number }): number => { - const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal; - const elapsed = t.stagesTotal - t.stagesRemaining; - return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed)); - }; - // NOT on a card that prints "trains may pass". Double Track and Uncontrolled Siding hold two - // trains because they HAVE two roads, so a train catching another there goes past it — that - // is what the card is for. Without this the mechanic fired 0.41 times a game while the bot - // never once granted clearance, which is the tell: those were all passing cards. + // NOT on a card that prints "trains may pass" — Double Track holds two trains because it HAS + // two roads, so a train catching another there goes past it. That is what the card is for. + // + // The Uncontrolled Siding used to be in that set and no longer is: it keeps two trains apart + // by putting the second one in the siding a region back (`takesTheSiding`), not by letting + // them share a place. Marking it "may pass" skipped this test entirely and made the siding do + // nothing at all. const mayPass = mainlineProfile(node.card).trainsMayPass; - const next = regionOf({ stagesTotal: transit.stagesTotal, stagesRemaining: transit.stagesRemaining - 1 }); + const next = regionOfTransit(node.card, transit.stagesRemaining - 1); const ahead = mayPass ? undefined : node.transits.find( - (t) => t.tray !== id && t.direction === transit.direction && regionOf(t) === next, + (t) => + t.tray !== id && + t.direction === transit.direction && + regionOfTransit(node.card, t.stagesRemaining) === next, ); if (ahead) { @@ -882,10 +993,27 @@ function evaluateClearance( // they are not, and the protection belongs where the train it protects actually is. if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked'; - // ABS Signals — "trains on this card will not rear-end each other; they stop short of a - // collision". With signals in place a following train simply holds, and the Superintendent has - // no judgment call to make. This is the amendment to Gap 2's unconditional collisions. - if (onNode?.kind === 'mainline' && onNode.absSignals) return 'blocked'; + /** + * ABS Signals — "this is played on a mainline card to prevent collisions. If a collision would + * normally occur, the train moving onto the card is instead held back" (RAR, Gitea#3). + * + * With signals in place a following train simply holds and the Superintendent has no judgment + * call to make, which is the amendment to Gap 2's unconditional collisions. It is caught HERE + * rather than at the entry itself, so the train never gets as far as the card. + * + * IT USED TO HOLD SILENTLY. A blocked clearance emits nothing on the Office and Division Point + * paths, so the one card whose entire purpose is to stop a wreck did its job invisibly: the + * train simply did not move, Stage after Stage, with nothing on screen saying why. The card is + * unplayable to reason about without this line. + */ + if (onNode?.kind === 'mainline' && onNode.absSignals) { + events.push({ + type: 'trainHeld', + trainNumber: tray.trainNumber ?? 0, + reason: 'ABS Signals — held short of the train ahead', + }); + return 'blocked'; + } // Same direction — the Superintendent must rule (§8.1, fourth condition). s.clock.pendingDecision = { train: id, occupiedBy: other }; @@ -1041,6 +1169,18 @@ function collide( if (n.holding) n.holding = n.holding.filter((t) => t !== id); if (n.redFlagged) n.redFlagged = n.redFlagged.filter((t) => t !== id); } + /** + * AND OFF THE A/D TRACK, for exactly the same reason as the transit above. + * + * It never mattered while every collision happened to a train already out on the road. Gitea#3 + * adds one that can happen as a train LEAVES — a following train entering an occupied region — + * and that train is still standing at the Office when it dies. Without this its A/D track stays + * marked forever: the Office reads as permanently full, and every later arrival collides against + * a train that no longer exists. + */ + for (const [, area] of s.officeAreas) { + area.adOccupancy = area.adOccupancy.filter((t) => t !== id); + } } if (lost.length > 0) { diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 5362525..039b6fa 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -77,6 +77,7 @@ import { facilityVariants, opposite, reachableDestinations, + rowEndAt, variantsFor, withinLimits, } from './track.ts'; @@ -331,6 +332,15 @@ function checkTurnoutUpgrade(existing: TrackCard, proto: TrackCard): RejectionCo if (existing.standing.length > 0) return 'UPGRADE_OCCUPIED'; if (existing.enhancements.length > 0) return 'UPGRADE_ENHANCED'; + /** + * NOTHING IS ASKED ABOUT THE NEIGHBOURS, deliberately (Gitea#15). + * + * A turnout adds a 45° leg the card underneath did not have, and that leg may well point into an + * occupied square with nothing to meet it. That is legal: RAR ruled (2026-08-26) that a rail may + * stop dead against its neighbour, and an upgrade is no different from laying the piece there in + * the first place. What must hold either way is that no train can cross the gap, which is + * `exploreMoves`' business and is tested in `track.test.ts`. + */ return null; } @@ -962,7 +972,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT'; const on = node.modifiers ?? []; if (on.includes(rule.key)) return 'OPTION_ALREADY_CHOSEN'; - if (rule.gradeOnly && mainlineProfile(node.card).speed.kind !== 'grade') return 'NOT_A_GRADE'; + if (rule.gradeOnly && node.card !== 'heavyGrade') return 'NOT_A_GRADE'; if (rule.requiresOnCard && !on.includes(rule.requiresOnCard)) return 'NOT_CONNECTED'; // "Not while a train is on it" — realigning under a moving train is exactly the situation the // restriction exists to prevent. A train standing in the Interchange's yard counts: it is on @@ -1470,7 +1480,7 @@ export function ownCutFor(s: GameState, player: PlayerIndex, trayId: TrayId, rev const facing = facingPort(s, trayId); const exit: Port = reverse ? reversePort(s, player, here, facing) : facing; const card = areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard(); - return cutTowards(card, carsOn(card), exit); + return cutTowards(card, carsOn(card), rowEndAt(card, exit)); } /** @@ -1588,14 +1598,16 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { */ const grid = areaOf(s, player).grid; const startCard = grid.get(coordKey(from)) ?? emptyCard(); - const startCut = cutTowards(startCard, carsOn(startCard), exitPort); + const startCut = cutTowards(startCard, carsOn(startCard), rowEndAt(startCard, exitPort)); const lifted = [ ...(startCut.length > 0 ? [from] : []), ...dest.path.map((step) => step.coord), i.to, ].filter((c) => carsOn(grid.get(coordKey(c)) ?? emptyCard()).length > 0); const sides = standingSides(startCard, carsOn(startCard)); - const stayed = exitPort === 'e' ? sides.west : exitPort === 'w' ? sides.east : []; + // The OTHER end's cut, which stays behind — so it is the other end of the row, not the + // other port. A 45° leg is an end of the row too (`rowEndAt`, Gitea#17). + const stayed = rowEndAt(startCard, exitPort) === 'e' ? sides.west : sides.east; // §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto // its nose". Running forward the engine meets cars head-on and takes them in front; backing // up, they couple behind. Which end they land on is the whole point of a run-around: it diff --git a/src/engine/content.ts b/src/engine/content.ts index 576b9ef..71c38dd 100644 --- a/src/engine/content.ts +++ b/src/engine/content.ts @@ -72,6 +72,26 @@ export type TrackProfile = { * from `docs/Deck cards2.xlsx`, a fixed document, and stay here as the audit trail for the * transcription — they are not claims about what the game deals today. * + * THE COUNTS BELOW NOW COME FROM `docs/Deck cards5.xlsx` (Gitea#14), which HALVES every track row + * against sheet 2: straight 32 → 16, each curve 16 → 8, each turnout 16 → 8. Track is the only + * section of that sheet whose numbers moved — every station, industry, modifier and train row is + * character-for-character what sheet 2 said — so this is the whole of the deck change it asks for. + * + * Sheet 5 also deals the sharp curves ZERO, which is where they already were: Jesse took them out + * for the reason below, and RAR arrived at the same number independently. Nothing to do, but worth + * recording that the two agree rather than leaving it looking like a coincidence. + * + * IT LANDS ON RAR'S OWN TARGETS, which is the check that matters — the top right of sheet 5 states + * the draw rates he is designing to. Against his denominators (start cards counted for track, only + * the non-track deck counted for trains): he wants track 48/167 = **28.7%** and trains 22/107 = + * **20.6%**; this deck gives 48/170 = **28.2%** and 22/110 = **20.0%**. + * + * THAT MATCH IS PARTLY A CANCELLATION, and whoever retunes next should know it. The engine holds + * ~25 more office and industry cards than the sheet (doubled and tripled, below) and is missing the + * ~33 Safety, Event, Inspection and Space-use cards sheet 5 lists, which Gitea#14 defers. The two + * errors are opposite and nearly equal today. Build the deferred categories and they stop + * cancelling, so the ratios have to be re-measured then rather than assumed to have held. + * * It matters well beyond bookkeeping. Track competes for the draw with industry, trains and * enhancements, so building a district is paid for in cards you did not draw instead — and the * hand-of-three is the real constraint on how fast a railroad grows. @@ -86,9 +106,9 @@ export type TrackProfile = { * published replay still plays. Reordering to look tidy would silently re-deal every saved game. */ export const TRACK_CARDS: readonly TrackProfile[] = [ - { geometry: 'straight', hand: 'none', name: 'Straight track', copiesInDeck: 32, isOperationalRail: true, moveCost: 1 }, - { geometry: 'curved', hand: 'right', name: 'Curved track (right)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 }, - { geometry: 'curved', hand: 'left', name: 'Curved track (left)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 }, + { geometry: 'straight', hand: 'none', name: 'Straight track', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 }, + { geometry: 'curved', hand: 'right', name: 'Curved track (right)', copiesInDeck: 8, isOperationalRail: true, moveCost: 1 }, + { geometry: 'curved', hand: 'left', name: 'Curved track (left)', copiesInDeck: 8, isOperationalRail: true, moveCost: 1 }, /** * SHARP CURVES ARE DEALT ZERO COPIES — Jesse's call, and the same treatment as Poling. * @@ -103,8 +123,8 @@ export const TRACK_CARDS: readonly TrackProfile[] = [ */ { geometry: 'sharpCurved', hand: 'right', name: 'Sharp Curved Track (right)', copiesInDeck: 0, isOperationalRail: true, moveCost: 2 }, { geometry: 'sharpCurved', hand: 'left', name: 'Sharp Curved Track (left)', copiesInDeck: 0, isOperationalRail: true, moveCost: 2 }, - { geometry: 'turnout', hand: 'right', name: 'Turnout (right)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 }, - { geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 }, + { geometry: 'turnout', hand: 'right', name: 'Turnout (right)', copiesInDeck: 8, isOperationalRail: false, moveCost: 1 }, + { geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 8, isOperationalRail: false, moveCost: 1 }, ]; /** Summed from `copiesInDeck` above, never written down — it moves whenever the deck is retuned. */ @@ -143,29 +163,39 @@ export type OfficeProfile = { * passenger modifier cards (Waiting Area, Restaurant, Hotel). */ /** - * Office cards. Every tier's `copiesInDeck` was **doubled** against the recovered design — Q12. + * Office cards, at `docs/Deck cards5.xlsx`'s counts exactly: Depot 4, Station 2, Terminal 1. * - * Players always start at a Whistle Post, which has ONE A/D track, so a second arrival is an - * automatic collision (§8.3, Gap 2a). Measured at the original density, 25 of 100 games never drew - * a Depot and never escaped: they averaged **−6.0** revenue against **−0.4** for games that - * upgraded at least once, and 25 of 26 collisions happened at Whistle Post. + * THE Q12 DOUBLING IS GONE (Gitea#14). Every tier used to be dealt at twice the sheet, to remove a + * 25% chance of an unwinnable opening deal: players always start at a Whistle Post, which has ONE + * A/D track, so a second arrival is an automatic collision (§8.3, Gap 2a), and measured at the + * sheet's density 25 of 100 games never drew a Depot and never escaped — averaging **−6.0** revenue + * against **−0.4** for games that upgraded at least once, with 25 of 26 collisions at a Whistle + * Post. + * + * That measurement was taken against a deck with 96 track cards in it, and the failure it describes + * does not survive the halving of track. RE-MEASURED at the sheet's counts, 100 games: **43 of 100** + * never upgrade off a Whistle Post, up from 25 — but they average **−0.2** revenue against **+1.4** + * for games that do upgrade, where the gap used to be −6.0 against −0.4. Collisions fell from 26 per + * 100 games to **6**, and only 3 of those are in games that never upgraded, against 25 of 26 before. + * + * So staying at a Whistle Post is now common and survivable rather than rare and fatal, which is the + * opposite of the shape Q12 was answering: with fewer trains reaching an Office, a single A/D track + * is seldom contested. The doubling was the blunt instrument its own note called it, and at the + * sheet's deck size it costs more than it buys — see `TRACK_CARDS` for the whole comparison and + * `INDUSTRY_PROFILES` for the other half of the same decision. * * Upgrades are strictly sequential (Gap 3b, no skipping), so Station and Terminal are rarer than - * their raw counts imply — Terminal needs all three cards in order. Station and Terminal were - * doubled with Depot to keep that ladder in proportion rather than making Depot a special case. + * their raw counts imply — Terminal needs all three cards in order. * - * PROVISIONAL — re-evaluate. This was chosen to remove a 25% chance of an unwinnable opening deal, - * not from the recovered design, and it is a blunt instrument: it lifts the whole office ladder and - * dilutes every other category slightly. Revisit once the victory target is settled and freight is - * carrying its intended share; the right answer may instead be fewer Terminals, a cheaper first - * upgrade, or more A/D capacity at Whistle Post. The counts themselves are in the rows below, which - * is the only place they should be read from. + * IF THE OPENING BITES AGAIN, the fix is not to re-double this. The note it replaces already listed + * the better options: fewer Terminals, a cheaper first upgrade, or more A/D capacity at a Whistle + * Post. Any of those answers the collision without diluting every other category to do it. */ export const OFFICE_PROFILES: readonly OfficeProfile[] = [ { tier: 'whistlePost', name: 'Whistle Post', isControlPoint: false, isPassengerFacility: false, adTracks: 1, porters: 0, passengerOut: 0, passengerIn: 0, copiesInDeck: 0 }, - { tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 8 }, - { tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 4 }, - { tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 2 }, + { tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 4 }, + { tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 2 }, + { tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 1 }, ]; export const OFFICE_ORDER: readonly OfficeTier[] = ['whistlePost', 'depot', 'station', 'terminal']; @@ -202,15 +232,24 @@ export type IndustryProfile = { }; /** - * Industry density (Gap 12). The recovered sheet lists 9 industries in a 115-card deck; the - * prototype ran 10 in 52. At the sheet's density a game saw 1.6 Freight Facilities, freight was 10% - * of gross revenue, and `carsCoupled` fired 4 times per 100 games — the freight loop, which is the - * point of the game, effectively never ran. + * Industry density, at `docs/Deck cards5.xlsx`'s counts exactly (Gitea#14). * - * Each industry's `copies` is TRIPLED against the sheet, which restores roughly the prototype's - * ratio while preserving the sheet's proportions exactly: the outbound/inbound balance and the - * lockout structure are unchanged, because every kind scales by the same factor. The multiplier is - * the decision; the resulting totals are in the rows below and move with every retune. + * THE GAP-12 TRIPLING IS GONE. The recovered sheet listed 9 industries in a 115-card deck and the + * prototype ran 10 in 52; at that density a game saw 1.6 Freight Facilities, freight was 10% of + * gross revenue, and `carsCoupled` fired 4 times per 100 games, so the freight loop effectively + * never ran. Tripling every kind restored roughly the prototype's ratio. + * + * ALL OF THAT WAS MEASURED AGAINST A DECK WITH 96 TRACK CARDS. Sheet 5 halves the track, and the + * tripling then works backwards: the deck keeps dealing industries while the district stays too + * small to reach them. Measured over 300 bot games on identical seeds — 96 track with the multiplier + * / 48 track with it / 48 track without — reefer cars set out by a crew went **49 / 0 / 39** and + * mean revenue **−0.20 / +0.22 / +0.27**. The middle column is the tripling meeting the halved + * deck: it wipes out the reefer chain completely. The sheet's own density is the best of the three + * on both counts. + * + * The sheet's proportions were always preserved by the multiplier, since every kind scaled by the + * same factor — so removing it changes the density and nothing else. The outbound/inbound balance + * and the lockout structure below are the sheet's, as they always were. */ /** * LOCKOUTS, from the sheet's "Lockouts" column verbatim: @@ -233,8 +272,8 @@ export type IndustryProfile = { * enforced for every kind in `isLockedOut`, not repeated in each row here. */ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [ - { kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 6 }, - { kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 }, + { kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 2 }, + { kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 2 }, /** * OUTBOUND ONLY. A Refinery ships oil out and receives nothing; reported from playtesting and * confirmed by Jesse (v0.4.9e): "only ships out tanks, does not receive anything". @@ -252,9 +291,9 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [ * the game with no way to raise the direction it is supposed to use half its capacity on. * `StationMaster-Home-Deck-v0.4.5.md` prints it "Outbound, 1 out / 0 in". */ - { kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 }, - { kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 }, - { kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 }, + { kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 1 }, + { kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 2 }, + { kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 1 }, /** * INBOUND ONLY — the mirror of the Refinery above, and the same correction. Reported from * playtesting and confirmed by Jesse (v0.4.9e): "Grocer's Warehouse should be receive only, does @@ -267,7 +306,7 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [ * other example. The Truck Dock (+1 inbound) and Local Small Groceries (+1 Laborer) are the two * that do work here. */ - { kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 }, + { kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 1 }, ]; /** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */ @@ -504,23 +543,35 @@ export type MainlineKind = | 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'interchange'; /** - * Speed as printed. `60` and `30` appear on the cards; Hilly prints P60/F30, and Heavy Grade prints - * "G" — no number at all, plus "Player sets orientation", **which the game deliberately does not do** - * (see `gradeReduction` below, and implications.md §10 Q11). + * THE PRINTED SPEEDS ARE GRAPHICS. RAR, 2026-08-26 (Gitea#3): "please ignore the speed signs I put + * on the cards — those are nothing but scene-setting graphics that mimic the speed you are + * travelling. It's just ambiance, nothing more." * - * WHAT THESE NUMBERS MEAN IS NOT YET SETTLED — see implications.md §10 Q2. Transcribed as data so - * the answer can be applied without re-reading the cards. + * They used to decide everything: a `MainlineSpeed` of 60 meant one Stage and a 30 meant two, plus + * one more for a Slow train. Both rules are gone. **What crosses a card is REGIONS** — the boxes + * printed on it — one per Stage, and where a train STARTS decides how many it has left to run. */ -export type MainlineSpeed = - | { kind: 'uniform'; value: number } - | { kind: 'byTrainType'; passenger: number; freight: number } - | { kind: 'grade' }; - export type MainlineProfile = { kind: MainlineKind; name: string; - speed: MainlineSpeed; - /** Double Track and Uncontrolled Siding: "Trains may pass". */ + /** Regions printed on the card. A train advances one per Stage, so a full run costs `regions`. */ + regions: number; + /** + * The region an ordinary train enters at. Zero on nearly everything — but the Uncontrolled Siding + * and the Interchange print a back region that is a siding or a holding spur rather than part of + * the road, so a train running straight through starts past it and crosses in one Stage. + */ + defaultStart: number; + /** + * Cards that print a FAST and a SLOW start, and the region each begins at. "Some cards say fast / + * slow. This is an indication that if on the train card, the train is listed as fast or slow, + * that's starting position / how many stages it takes to traverse the card. Fast / Slow does not + * apply to every card — just those that say fast / slow on them. Currently this is only hilly." + * + * So the train's rating is read HERE and nowhere else. It used to add a Stage to every card. + */ + speedStarts?: { fast: number; slow: number }; + /** Double Track: "Trains may pass". */ trainsMayPass: boolean; /** Interchange: "Sort cars in new order". */ sortsCars: boolean; @@ -529,30 +580,56 @@ export type MainlineProfile = { }; export const MAINLINE_PROFILES: readonly MainlineProfile[] = [ - { kind: 'plains', name: 'Plains', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] }, - { kind: 'curves', name: 'Curves', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] }, - { kind: 'hilly', name: 'Hilly', speed: { kind: 'byTrainType', passenger: 60, freight: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['passenger', 'freight'] }, + { kind: 'plains', name: 'Plains', regions: 1, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] }, + { kind: 'curves', name: 'Curves', regions: 2, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] }, /** - * `entryPoints` is TRANSCRIBED, NOT READ — nothing anywhere reads this field on any profile, and - * the printed start positions are not modelled: `crossingStages` counts Stages instead. Recorded - * here because implications.md §6 describes the card as having FIVE distinct starts (plain, - * brakemen, airbrakes, plain, helpers) against the four listed, and that discrepancy should be - * settled against `Mainline Cards.pdf` if the starts are ever implemented — not quietly "fixed" - * now, when nothing depends on it either way. + * THE ONLY CARD THAT READS A TRAIN'S FAST/SLOW RATING. A fast train starts in the second region + * and is across in one Stage; a slow one starts at the beginning and takes two. + * + * It used to read the CONSIST instead — any coach aboard made the train "passenger" for this card + * — off the printed P60/F30. RAR corrected that directly: "I notice that you are basing stages in + * mainline cards off coach/non-coach. Actually, all trains are rated as FAST and SLOW." */ - { kind: 'heavyGrade', name: 'Heavy Grade', speed: { kind: 'grade' }, trainsMayPass: false, sortsCars: false, entryPoints: ['start', 'brakemen', 'airbrakes', 'helpers'] }, - { kind: 'doubleTrack', name: 'Double Track', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['start'] }, - { 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: 'hilly', name: 'Hilly', regions: 2, defaultStart: 0, speedStarts: { fast: 1, slow: 0 }, trainsMayPass: false, sortsCars: false, entryPoints: ['fast', 'slow'] }, /** - * 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. + * THREE REGIONS, AND THE MODIFIERS MOVE THE START RATHER THAN CUTTING THE TIME — which comes to + * the same number of Stages and is how the card is actually printed and played. "If you play the + * home deck card 'helpers' against the mainline card heavy grade, it remains there the rest of the + * game and helps all trains going up hill by starting 1 region easier — so 2 to traverse, not 3. + * Other cards help the other direction, similar idea. Airbrakes is an upgrade from brakemen (which + * must be played first)." + * + * So: Helpers moves an UPHILL train up one region; Brakeman moves a DOWNHILL train up one, and + * Airbrakes another on top of it. A fully-equipped grade is one Stage downhill and two up. */ - { kind: 'interchange', name: 'Interchange', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] }, + { kind: 'heavyGrade', name: 'Heavy Grade', regions: 3, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start', 'brakemen', 'airbrakes', 'helpers'] }, + { kind: 'doubleTrack', name: 'Double Track', regions: 1, defaultStart: 0, trainsMayPass: true, sortsCars: false, entryPoints: ['start'] }, + /** + * TWO REGIONS, AND THE BACK ONE IS THE SIDING. A train with the card to itself starts past it and + * crosses in one Stage. "Uncontrolled siding: if a train already exists when you arrive, you go in + * the second stage back (you are in the siding and are one behind the other train). This prevents + * a collision — since you are not in same exact location." + * + * `trainsMayPass` is FALSE here, and used to be true. Two trains fit, but not by passing: the + * second one takes the siding and sits a region behind, which is what keeps them apart. Leaving it + * true skipped the collision test altogether and made the siding do nothing at all. + */ + { kind: 'uncontrolledSiding', name: 'Uncontrolled Siding', regions: 2, defaultStart: 1, trainsMayPass: false, sortsCars: false, entryPoints: ['through', 'siding'] }, + { kind: 'tunnel', name: 'Tunnel', regions: 2, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] }, + { kind: 'trestle', name: 'Trestle', regions: 1, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] }, + /** + * RENAMED FROM "Yard" after play. The card is unchanged — same "sort cars in new order", 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. + * + * TWO REGIONS, the back one a holding spur, exactly as the Uncontrolled Siding: "interchange has + * new extras show up in second region (like uncontrolled siding)", and earlier, "Plains is 1 stage + * for ALL trains. So are interlockings, with a second stage for incoming extras to hold at." A + * train running through crosses in one Stage; an Extra beginning its run here starts at the back. + */ + { kind: 'interchange', name: 'Interchange', regions: 2, defaultStart: 1, trainsMayPass: false, sortsCars: true, entryPoints: ['through', 'extraStart'] }, ]; /** @@ -579,44 +656,63 @@ export const MAINLINE_DECK: readonly MainlineKind[] = [ ]; /** - * How many Stages a train needs to cross a Mainline card. + * WHERE A TRAIN ENTERS A MAINLINE CARD, and therefore how long it takes to cross (Gitea#3). * - * Q1 — the printed 60/30 are miles per hour expressed as crossing time: a 60 card takes one Stage, - * a 30 card takes two. The cells drawn on the cards are decoration. - * Q2 — a Slow train adds one Stage to every card. + * "Regions shown on cards indicate how many stages it takes to cross. Plains is 1. Double track is + * 1, tunnel is 2, curves is 2, heavy grade is 3 unless you have help." A train advances one region + * per Stage, so the whole of crossing time is `regions - startRegion`. * - * Hilly prints P60/F30, so it reads the consist rather than the speed class: a train carrying any - * coach is "passenger" for this purpose. + * FOUR THINGS MOVE THE START, and nothing else does: + * + * 1. the card's own `defaultStart` — the Uncontrolled Siding and the Interchange print a back + * region that is not part of the road, so a train running through begins past it; + * 2. `speedStarts`, on a card that prints a Fast and a Slow start. Only Hilly does; + * 3. the permanent Heavy Grade modifiers, which move a train one region up the hill each; + * 4. `takesSiding` / `startsAtBack`, the two occupancy cases below. + * + * WHAT NO LONGER MOVES IT: the printed mph, which is now scenery, and a train's Fast/Slow rating on + * any card but Hilly. That rating used to add a Stage to EVERY card, which is what made a Slow train + * cross Double Track in two Stages and produced the report this issue opened with. */ -export function crossingStages( - kind: MainlineKind, - trainSpeed: TrainSpeed, - carriesPassengers: boolean, - modifiers: readonly string[] = [], - direction: Direction = 'east', - gradeUp: Direction = 'east', -): number { - const profile = MAINLINE_PROFILES.find((m) => m.kind === kind); - if (!profile) throw new Error(`unknown mainline card: ${kind}`); +export type MainlineEntry = { + trainSpeed: TrainSpeed; + direction: Direction; + gradeUp: Direction; + modifiers: readonly string[]; + /** + * The Uncontrolled Siding with a train already on it: this one takes the siding and sits a region + * behind, which is what keeps them out of the same place. Also the Interchange, where an Extra + * beginning its run starts in the holding region rather than on the road. + */ + startsAtBack?: boolean; +}; - let mph: number; - switch (profile.speed.kind) { - case 'uniform': - mph = profile.speed.value; - break; - case 'byTrainType': - mph = carriesPassengers ? profile.speed.passenger : profile.speed.freight; - break; - case 'grade': - // Heavy Grade has no printed number; the modifier cards are what improve it, so it is a 30 - // until one is placed. - mph = 30; - break; +export function startRegion(kind: MainlineKind, entry: MainlineEntry): number { + const profile = mainlineProfile(kind); + if (entry.startsAtBack) return 0; + + const base = profile.speedStarts + ? profile.speedStarts[entry.trainSpeed] + : profile.defaultStart; + + const climbing = entry.direction === entry.gradeUp; + let help = 0; + if (kind === 'heavyGrade') { + if (climbing) { + if (entry.modifiers.includes('helpers')) help++; + } else { + // Airbrakes is an upgrade on Brakeman and cannot be played without it, so this counts both. + if (entry.modifiers.includes('brakeman')) help++; + if (entry.modifiers.includes('airbrakes')) help++; + } } + // Never past the last region: a card always costs at least one Stage to cross. + return Math.min(profile.regions - 1, base + help); +} - const base = mph >= 60 ? 1 : 2; - const stages = base + (trainSpeed === 'slow' ? 1 : 0); - return Math.max(1, stages - gradeReduction(profile, modifiers, direction, gradeUp)); +/** How many Stages a train needs to cross a Mainline card — the regions it has left to run. */ +export function crossingStages(kind: MainlineKind, entry: MainlineEntry): number { + return mainlineProfile(kind).regions - startRegion(kind, entry); } /** @@ -634,35 +730,50 @@ export function crossingStages( export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'east'): string { const p = mainlineProfile(kind); const stages = (n: number): string => `${n} Stage${n === 1 ? '' : 's'}`; + const run = (entry: Partial): number => + crossingStages(kind, { trainSpeed: 'fast', direction: 'east', gradeUp, modifiers: [], ...entry }); const parts: string[] = []; - if (p.speed.kind === 'byTrainType') { - // Hilly. The split is by CONSIST, not by the train's speed class: anything with a coach on it - // takes the passenger figure. + parts.push(`${p.regions} region${p.regions === 1 ? '' : 's'} — one Stage each.`); + + if (p.speedStarts) { parts.push( - `P${p.speed.passenger} / F${p.speed.freight} — a train carrying ANY coach crosses as a ` + - `${p.speed.passenger} (${stages(crossingStages(kind, 'fast', true))} for a fast train), and a ` + - `freight-only train as a ${p.speed.freight} (${stages(crossingStages(kind, 'fast', false))}). ` + - `A slow train adds one Stage either way.`, + `This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses ` + + `in ${stages(run({ trainSpeed: 'fast' }))}, a slow one in ${stages(run({ trainSpeed: 'slow' }))}. ` + + `No other card cares which it is.`, ); - } else if (p.speed.kind === 'grade') { + } else if (kind === 'heavyGrade') { parts.push( - `A grade, climbing ${gradeUp === 'east' ? 'eastward' : 'westward'}. It crosses as a 30 — ` + - `${stages(crossingStages(kind, 'fast', false, [], gradeUp, gradeUp))} for a fast train, and one ` + - `more for a slow one. Brakeman and Airbrakes each take a Stage off a train running DOWNHILL; ` + - `Helpers takes one off a train running UPHILL. Never below one Stage.`, + `A grade, climbing ${gradeUp === 'east' ? 'eastward' : 'westward'}. ` + + `${stages(run({ direction: gradeUp }))} to climb it and ${stages(run({ direction: gradeUp === 'east' ? 'west' : 'east' }))} to run down, ` + + `before help. Helpers start an UPHILL train a region further on; Brakeman does the same ` + + `DOWNHILL and Airbrakes another again, and Airbrakes cannot be played without Brakeman. ` + + `Never less than one Stage.`, + ); + } else if (p.defaultStart > 0) { + parts.push( + `A train with the card to itself starts past the back region and is across in ` + + `${stages(run({}))}.`, ); } else { + parts.push(`${stages(run({}))} for every train — the printed speed is scenery.`); + } + + if (kind === 'uncontrolledSiding') { parts.push( - `${p.speed.value} — ${stages(crossingStages(kind, 'fast', false))} for a fast train, ` + - `${stages(crossingStages(kind, 'slow', false))} for a slow one.`, + 'UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region ' + + 'behind it. You are not in the same place, so you do not run into it; it costs you the ' + + 'extra Stage instead.', ); } + if (kind === 'interchange') { + parts.push('An Extra beginning its run here starts in the back region and takes the extra Stage.'); + } if (p.trainsMayPass) { parts.push( 'TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held ' + - 'behind a slower one. Only this and the Double Track allow it.', + 'behind a slower one.', ); } else { parts.push('One train at a time — anything following has to wait for it to clear.'); @@ -672,43 +783,7 @@ export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'ea return parts.join(' · '); } -/** - * WHICH WAY THE GRADE CLIMBS, AND WHY NO PLAYER CHOOSES IT. - * - * Q11, answered from the card: Heavy Grade prints "(Up)" and "Player sets orientation", so the climb - * is a property of the PLACED CARD rather than a compass constant. `gradeUp` is the direction a train - * is travelling when it goes UPHILL; a train heading the other way is descending. - * - * **The second half of that print is deliberately overridden.** No player sets it — `setup.ts` rolls - * it from the seed. Settled v0.5.0 and re-confirmed 2026-08-23 after the question was raised again: - * a Heavy Grade always sits BETWEEN two districts (or beyond an end Division Point next to one), - * never inside one player's own, so there is no player with a fair claim to the choice — and the - * choice is not cosmetic, because it decides which of the three modifiers below can ever pay and - * therefore which direction of traffic is favoured, permanently. Giving it to the Superintendent was - * considered and rejected in that re-examination: the office rotates every three Stages, the - * advantage does not. Full reasoning in implications.md §10 Q11. - * - * Each applicable card takes a Stage off, never below one: a train cannot cross in no time. - * Airbrakes only counts when Brakeman is already there, which the placement rule enforces - * (`MAINLINE_MODIFIER_RULES`, `requiresOnCard`). - */ -function gradeReduction( - profile: MainlineProfile, - modifiers: readonly string[], - direction: Direction, - gradeUp: Direction, -): number { - if (profile.speed.kind !== 'grade') return 0; - const downhill = direction !== gradeUp; - let n = 0; - if (downhill) { - if (modifiers.includes('brakeman')) n++; - if (modifiers.includes('airbrakes')) n++; - } else if (modifiers.includes('helpers')) { - n++; - } - return n; -} + export function mainlineProfile(kind: MainlineKind): MainlineProfile { const p = MAINLINE_PROFILES.find((m) => m.kind === kind); @@ -769,7 +844,8 @@ export const SPACE_USE_CARDS: readonly SimpleCard[] = [ { key: 'flopHouse', name: 'Flop house', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' }, { key: 'watertower', name: 'Watertower', copies: 1, placement: 'adjacent to any straight, turnout on Running Track', effect: 'Burns tablespace.' }, { key: 'hoboJungle', name: 'Hobo Jungle', copies: 1, placement: 'adjacent to any straight, turnout, Limit on Running Track', effect: 'Burns tablespace. Vandalism can loot a boxcar passing it.' }, - { key: 'sectionHouse', name: 'Section House', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' }, + // Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26), the same treatment as the ladder. + { key: 'sectionHouse', name: 'Section House', copies: 0, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' }, { key: 'cityBlocks', name: 'City blocks', copies: 4, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' }, { key: 'engineShops', name: 'Engine Shops', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' }, { key: 'tenderloin', name: 'Tenderloin District', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' }, @@ -876,16 +952,32 @@ export function enhancementRule(key: string): EnhancementRule | null { } export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [ - { key: 'interlocking', name: 'Interlocking', copies: 2, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' }, - { key: 'facingPointLocks', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.', answers: 'Derail' }, + { key: 'interlocking', name: 'Interlocking', copies: 1, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' }, + // Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26). It answers Derail, which is itself an + // Event held out until built, so at zero it defends against nothing that can be dealt anyway. + { key: 'facingPointLocks', name: 'Facing Point Locks', copies: 0, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.', answers: 'Derail' }, { key: 'yardOffice', name: 'Yard office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' }, { key: 'smallYard', name: 'Small yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' }, - { key: 'waterColumn', name: 'Water column', copies: 2, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' }, + { key: 'waterColumn', name: 'Water column', copies: 1, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' }, { key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.', answers: 'Railroad crossing' }, - { key: 'telegraph', name: 'Telegraph', copies: 3, placement: 'any Running Track Straight', effect: 'Once a day, when dispatching facing trains, add +4 to the other train’s number.' }, - { key: 'telephone', name: 'Telephone', copies: 2, placement: 'on Telegraph', effect: 'Once a day, add +8 to the other train’s number.' }, - { key: 'radio', name: 'Radio', copies: 2, placement: 'on Telephone', effect: 'Once a day, add +12 to the other train’s number.' }, - { key: 'absSignals', name: 'ABS Signals', copies: 2, placement: 'any Mainline card', effect: 'Trains on this card will not rear-end each other; they stop short of a collision.' }, + /** + * THE DISPATCHING LADDER IS OUT OF THE DECK, at 0 copies rather than deleted — the treatment + * Poling and the sharp curves already get, and for the same reason. + * + * `docs/Deck cards5.xlsx` does not list Telegraph, Telephone or Radio at any count, and **Jesse + * confirmed (2026-08-26) that the removal is deliberate, not a row that failed to carry across** + * from sheet 2. So no copy is dealt, which is what the sheet asks for. + * + * The rows and `ENHANCEMENT_RULES`' `dispatchBonus` chain stay exactly where they are. The rule + * is implemented and tested — `advance.ts` reads the ladder when the Superintendent dispatches + * facing trains, best device first — and deleting working machinery to express a count of zero + * would throw away the only record of how it worked. At zero copies the code is unreachable: no + * card is ever dealt, so nothing ever places one, so the bonus never applies. + */ + { key: 'telegraph', name: 'Telegraph', copies: 0, placement: 'any Running Track Straight', effect: 'Once a day, when dispatching facing trains, add +4 to the other train’s number.' }, + { key: 'telephone', name: 'Telephone', copies: 0, placement: 'on Telegraph', effect: 'Once a day, add +8 to the other train’s number.' }, + { key: 'radio', name: 'Radio', copies: 0, placement: 'on Telephone', effect: 'Once a day, add +12 to the other train’s number.' }, + { key: 'absSignals', name: 'ABS Signals', copies: 1, placement: 'any Mainline card', effect: 'Trains on this card will not rear-end each other; they stop short of a collision.' }, ]; export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [ @@ -893,7 +985,8 @@ export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [ { key: 'airbrakes', name: 'Airbrakes', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage downhill. Brakeman must be in effect.' }, { key: 'helpers', name: 'Helpers', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage uphill.' }, { key: 'realignment', name: 'Realignment', copies: 2, placement: 'a Mainline card', effect: 'Convert one Mainline type to another. Not while a train is on it.' }, - { key: 'facingPointLocksMainline', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Prevents Derail being played on you.', answers: 'Derail' }, + // Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26); see the Enhancement of the same name. + { key: 'facingPointLocksMainline', name: 'Facing Point Locks', copies: 0, placement: 'adjacent to Interlocking', effect: 'Prevents Derail being played on you.', answers: 'Derail' }, ]; /** @@ -904,8 +997,9 @@ export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [ export const SECOND_SECTION = { key: 'secondSection', name: 'Second Section', copies: 1 }; export const MANEUVER_CARDS: readonly SimpleCard[] = [ - { key: 'redFlags', name: 'Red Flags', copies: 5, placement: 'any time', effect: 'A stopped train is prevented from being hit; the approaching train is prevented from moving.' }, - { key: 'flyingSwitch', name: 'Flying Switch', copies: 1, placement: 'any time', effect: 'Break a cut of cars away from behind the engine and roll them into an industry.' }, + { key: 'redFlags', name: 'Red Flags', copies: 3, placement: 'any time', effect: 'A stopped train is prevented from being hit; the approaching train is prevented from moving.' }, + // Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26). The reducer stays; nothing can reach it. + { key: 'flyingSwitch', name: 'Flying Switch', copies: 0, placement: 'any time', effect: 'Break a cut of cars away from behind the engine and roll them into an industry.' }, // POLING IS OUT OF THE DECK, at 0 copies rather than deleted. // // It is the one card whose effect the source records as "TBD", so there is nothing to implement @@ -922,7 +1016,8 @@ export const ACTION_CARDS: readonly SimpleCard[] = [ { key: 'perDiemInventory', name: 'Per Diem inventory', copies: 1, placement: 'another player', effect: 'Lose one point per 2 empty cars on Secondary Tracks.' }, { key: 'demurrageCharge', name: 'Demurrage charge', copies: 1, placement: 'another player', effect: 'Lose one point per 2 loaded freight cars on Secondary Tracks.' }, { key: 'customerComplaints', name: 'Customer complaints', copies: 1, placement: 'another player', effect: 'Lose one point per 2 coaches in loading boxes.' }, - { key: 'vandalism', name: 'Vandalism', copies: 1, placement: 'another player', effect: 'A train passing a Hobo Jungle has a boxcar looted (converted to empty).' }, + // Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26). + { key: 'vandalism', name: 'Vandalism', copies: 0, placement: 'another player', effect: 'A train passing a Hobo Jungle has a boxcar looted (converted to empty).' }, { key: 'hotbox', name: 'Hotbox', copies: 1, placement: 'another player', effect: 'A train just arrived must set one car (chooser’s pick) onto Secondary Track until it departs.' }, { key: 'outlawed', name: 'Outlawed', copies: 1, placement: 'another player', effect: 'A train just arrived may not depart for one turn — the crew’s hours have expired.' }, ]; @@ -972,7 +1067,6 @@ export function crewTrayCount(players: number): number { return players + 3; } -export const REGIONS_PER_MAINLINE_CARD = 2; // provisional, pending §10 Q2 export const STAGES_PER_DAY = 12; export const STAGES_PER_SHIFT = 3; export const HAND_LIMIT = 3; diff --git a/src/engine/setup.ts b/src/engine/setup.ts index 1700677..ba80d5e 100644 --- a/src/engine/setup.ts +++ b/src/engine/setup.ts @@ -246,7 +246,7 @@ function buildDivision(players: number, rng: Rng): DivisionNode[] { if (deck.length === 0) throw new Error('the Mainline deck ran out — too many players for it'); const card = deck.splice(rng.nextInt(deck.length), 1)[0]!; const node: DivisionNode = { kind: 'mainline', card, transits: [] }; - if (mainlineProfile(card).speed.kind === 'grade') { + if (card === 'heavyGrade') { /** * SETTLED, not provisional (v0.5.0, Jesse's call) — this overrides the card's own printed * "Player sets orientation". A Heavy Grade sits on the shared west-to-east chain BETWEEN two diff --git a/src/engine/state.ts b/src/engine/state.ts index 70aec0c..3219923 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -771,22 +771,25 @@ export function standingSides( } /** - * The cut a train would run into if it left this card through `exit` — the cars between it and that - * end of the card. + * The cut a train would run into if it left this card by the `exit` END OF THE ROW — the cars + * between it and that end. Returned in the order the train MEETS them, nearest first, which is what + * `carsCoupled` wants. * - * Only 'e' and 'w' can hold a cut: the array is a west-to-east row, so a train leaving north or - * south off a curve or a spur is not running along it and meets nothing. Returned in the order the - * train MEETS them, nearest first, which is what `carsCoupled` wants. + * `exit` IS AN END OF THE ROW, NOT A PORT. It used to be a raw `Port`, and answered "you meet + * nothing" for north and south on the reasoning that a leg leaving through an edge is not running + * along the west-to-east row. It is: a `sw` curve's south leg IS the east end of that row, so a + * crew standing on the curve pulled out through the leg and drove away leaving the cars beside it + * standing, against §A.4's mandatory coupling (Gitea#17). Callers resolve the leg with `rowEndAt` + * (`track.ts`), which lives there because only the card's arc can say which end a leg is — and the + * narrowed type is what makes every caller do it. */ export function cutTowards( tray: { standingWest?: number | undefined }, cars: readonly RollingStock[], - exit: 'n' | 's' | 'e' | 'w', + exit: 'e' | 'w', ): RollingStock[] { const { west, east } = standingSides(tray, cars); - if (exit === 'e') return east; - if (exit === 'w') return [...west].reverse(); - return []; + return exit === 'e' ? east : [...west].reverse(); } export function turnOf(s: GameState, player: PlayerIndex): TurnState { diff --git a/src/engine/track.ts b/src/engine/track.ts index ec310d5..1d57b12 100644 --- a/src/engine/track.ts +++ b/src/engine/track.ts @@ -188,6 +188,40 @@ export function joins(a: TrackCard, p: Port, b: TrackCard): boolean { return slopeAt(a, p) === slopeAt(b, opposite(p)); } +/** + * WHICH END OF THE WEST-TO-EAST ROW A PORT SITS AT. + * + * `TrackCard.standing` is ordered west to east (§A.3), so whether a train meets the row front to + * back or back to front depends on which end it enters by — and a port is not always at one of + * those two extremes. Every 45° leg leaves through the MIDDLE of its north or south edge, so its + * end of the run is whichever end the arc does NOT reach: a `sw` curve's south leg is the EAST end + * of the row, and an `se` curve's south leg is the WEST end. Same port, opposite answers, which is + * why this has to ask the card rather than read the port. + * + * Gitea#17 is what both callers looked like without it. `exploreMoves` reversed the row for an 'e' + * entry and for nothing else, so backing into a cut through a `sw` curve's south leg coupled it up + * back to front — the caboose came out next to the engine, which §8.2 then calls badly made up. + * `cutTowards` answered "you meet nothing" for a north or south exit, so a crew standing on a curve + * pulled out through the leg and left the cars beside it standing, which §A.4 forbids. + * + * There is no north-south straight anywhere on the printed sheet (see the module comment), so a run + * touching a 45° leg always has an east or west port at its other end and the answer is never + * undefined. A TURNOUT is the one card whose row has three ends rather than two — and it is also + * the one card no cut can ever stand on, since a train may not stop there (§A.1) and so never sets + * anything out there. Its stem answers for it. + */ +export function rowEndAt(card: TrackCard, p: Port): 'e' | 'w' { + if (p === 'e' || p === 'w') return p; + for (const [a, b] of connectionsFor(card)) { + const other = a === p ? b : b === p ? a : null; + if (other === 'e') return 'w'; + if (other === 'w') return 'e'; + } + // Not a card the printed sheet can produce. Reading the leg as the west end leaves the row in the + // order it is stored rather than inventing a reversal on a card nothing knows the shape of. + return 'w'; +} + // --------------------------------------------------------------------------- // Orientation (Gap 11) // --------------------------------------------------------------------------- @@ -450,7 +484,7 @@ export function exploreMoves( * * Ordered nearest-first like every other card's, so it simply seeds the accumulator. */ - const ownCut = cutTowards(startCard, carsOn(startCard), initialExit); + const ownCut = cutTowards(startCard, carsOn(startCard), rowEndAt(startCard, initialExit)); const startKey = coordKey(start); const queue: Frontier[] = [ { @@ -502,12 +536,15 @@ export function exploreMoves( * overfill the tray is illegal, not a move that picks up fewer cars. * * NEAREST FIRST ALONG THE DIRECTION OF TRAVEL. `carsOn` runs west to east, so a train entering - * through the card's EAST port meets them back to front and the row has to be reversed. Without - * this the same parked cut produced an identical consist whichever way it was approached, when - * the two must mirror — which is the difference between a run-around being worth a Move and - * being pointless. + * at the row's EAST end meets them back to front and the row has to be reversed. Without this + * the same parked cut produced an identical consist whichever way it was approached, when the + * two must mirror — which is the difference between a run-around being worth a Move and being + * pointless. + * + * `rowEndAt` rather than `node.entry === 'e'`: a 45° leg is an end of the row too, and which + * end it is depends on the card's arc (Gitea#17). */ - const met = node.entry === 'e' ? [...carsOn(card)].reverse() : carsOn(card); + const met = rowEndAt(card, node.entry) === 'e' ? [...carsOn(card)].reverse() : carsOn(card); const couples = [...node.couples, ...met]; const nodeKey = coordKey(node.coord); const origins = [...node.origins, ...met.map(() => nodeKey)]; @@ -658,6 +695,26 @@ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): // into a stub and cutting the Office off from the Limits. if (coord.row === area.runningRow && !carriesThroughTrack(card)) return false; + /** + * ONE NEIGHBOUR MUST JOIN. THE OTHERS NEED NOT — AND THIS RULE HAS BEEN BOTH WAYS (Gitea#15). + * + * A card may be laid with an exit facing a card that has nothing to meet it. The rail stops dead + * at that edge, and that is legal. + * + * The issue was filed the other way round — "if a card is placed in that space, it MUST connect" — + * against a right-hand curve laid with its north leg against an Ice House and the turnout below it + * pointing at its portless south edge. **RAR reversed it on review (2026-08-26): placing it is + * fine, and a stub like that is useful — a siding to park cars on.** + * + * WHAT MATTERS INSTEAD IS THAT NOTHING CAN DRIVE ACROSS THE GAP, so the real requirement is on + * MOVEMENT rather than on placement: two cards touching are not connected, and `exploreMoves` must + * refuse the hop. It does — every step is gated on `joins`, never on a bare pair of `hasPort` + * calls — and `track.test.ts` pins the reported geometry against exactly that. + * + * SO DO NOT ADD A PER-EDGE CHECK HERE. One was written and taken out again when the ruling + * arrived. What survives is the weaker rule that was always here: the piece must touch the network + * SOMEWHERE, which is what stops orphaned track being laid in an empty corner of the board. + */ const ports: Port[] = ['n', 's', 'e', 'w']; for (const p of ports) { const neighbourCard = cardAt(area, neighbour(coord, p)); diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index 9419b97..c5c733c 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -65,7 +65,30 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul * anything outside its own body. */ const CW = { dp: 118, ml: 152, run: 78 }; - const CH = 58; + /** + * TALL ENOUGH FOR TWO REGISTERS OF CHIPS, on every cell so the rail runs level across the row. + * Was 58, when a cell held one row of trains. + */ + const CH = 76; + /** + * EVERY DISTRICT THE SAME WIDTH, sized for four chips two-by-two and NOT for its A/D count. + * + * Measured over 60 games: one office area holds at most 4 distinct trains, and up to 3 of those + * can be crews switching below the Running Track — which do not occupy A/D tracks at all. So a + * Whistle Post, with its single A/D track, can still have four trains to show, and sizing the cell + * by capacity would overflow it. Sizing by OCCUPANCY is worse still: that is what "The Roster + * Pass" fixed, because the cell then resizes as trains come and go and shoves the rest of the map + * sideways. A fixed two-by-two block holds the map still all game, upgrades included. + */ + const OFFICE_W = 2 * 54 + 12; + /** + * THE VERTICAL ANATOMY OF A CELL, so the two chip registers and the rail cannot drift apart. + * The rail sits above centre; A/D chips straddle it, and the district register hangs below — + * which is where those trains are on the real board (Gitea#18). + */ + const RAIL_Y = 34; + const CHIP_Y = RAIL_Y - 10; + const BELOW_Y = RAIL_Y + 13; const GAP = 6; /** * ONE FIXED SLOT PER A/D TRACK, so the Office Running Track cell is drawn wide enough to hold @@ -115,6 +138,12 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul seat: number | null; /** Set on an Office cell when a roster was supplied: whose district this is. */ owner?: { name: string; isTurn: boolean; isYou: boolean } | null; + /** + * Office cells only: trains in the district that are NOT holding an A/D track — a crew switching + * below the Running Track, or a train standing on it away from the Office. Drawn in a second + * register under the rail (Gitea#18). + */ + below?: Cell['trains']; /** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */ regions: number; w: number; @@ -122,11 +151,8 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul y: number; }; const cells: Cell[] = []; - const sides: number[][] = []; - let side: number[] = []; const push = (c: Omit): void => { - side.push(cells.length); cells.push({ ...c, x: 0, y: 0 }); }; @@ -152,47 +178,54 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul } : null; - for (const rc of n.running ?? []) { - const isOffice = rc.kind === 'office'; - const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`; - push({ - kind: 'run', - label: isOffice && owner ? owner.name : rc.label, - owner: isOffice ? owner : null, - // With an owner on the headline the tier would otherwise vanish, so it joins the A/D - // count on the line below. - sub: isOffice ? (owner ? [rc.label, adLabel].filter(Boolean).join(' · ') : adLabel) : '', - /** - * A train standing at the Office occupies an A/D track, which is where it is — but it is - * ALSO standing on the Office grid card, so it arrives here in both lists and used to be - * drawn twice. Reported as two T10 chips on one Office. - */ - trains: isOffice - ? [...rc.trains, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))] - : rc.trains, - cap: isOffice ? cap : null, - tip: owner && isOffice - ? `${owner.name}'s ${rc.label}` + - (owner.isYou ? ' — this is your railroad' : '') + - // "their move" is wrong when the reader is the one being waited on. - (owner.isTurn ? (owner.isYou ? ' — it is your move' : ' — it is their move') : '') - : `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`, - seat: n.seat ?? null, - // No regions inside a district: a crew moves by Moves there, not by Stages, so it - // occupies a card outright rather than a part of one. - regions: 0, - w: isOffice && cap !== null ? Math.max(CW.run, cap * CHIP_W + 12) : CW.run, - }); + /** + * ONE CELL PER DISTRICT — NO OFFICE-AREA DETAIL ON THIS MAP (Gitea#18). + * + * An Office used to expand into its whole Running Track, Limits to Limits, so this map carried + * every straight, turnout, facility and Limits sign of every district. Two things were wrong + * with that. It is the OFFICE map's job, and it draws all of it properly, with the rails; and + * it made the Division map grow sideways as districts were built, shoving everything east of a + * district along every time somebody laid a card. + * + * TRAINS STAY. "Trains within the office area should definitely be represented on the division + * map" — at a glance the number and which way it is pointing, and the consist on the tooltip. + * They are split into two registers, because a train holding an A/D track and a crew switching + * in the district are not the same thing: A/D occupancy is a hard capacity that causes + * collisions, switching is not. The split is drawn as POSITION rather than colour — A/D on the + * rail, the rest below it — which is where those trains actually are. + */ + const seen = new Set(ad.map((t) => t.label)); + const below: typeof ad = []; + for (const t of [...(n.running ?? []).flatMap((rc) => rc.trains), ...(n.switching ?? [])]) { + if (seen.has(t.label)) continue; + seen.add(t.label); + below.push(t); } - // A crew below the Running Track has no position ON it, so it is reported against the - // district rather than drawn somewhere it is not. - const below = n.switching ?? []; - if (below.length > 0) { - const last = cells[cells.length - 1]; - if (last) last.sub = `${below.length} switching below`; - } - sides.push(side); - side = []; + const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`; + push({ + kind: 'run', + label: owner ? owner.name : n.label, + owner, + sub: [owner ? n.label : '', adLabel, below.length > 0 ? `${below.length} switching` : ''] + .filter(Boolean) + .join(' \u00b7 '), + trains: ad, + below, + cap, + tip: + (owner ? `${owner.name}'s ${n.label}` : n.label) + + (owner?.isYou ? ' — this is your railroad' : '') + + // "their move" is wrong when the reader is the one being waited on. + (owner?.isTurn ? (owner.isYou ? ' — it is your move' : ' — it is their move') : '') + + `\n\nThe district itself is drawn on the Office map — this cell is the whole of it, with the ` + + `trains standing in it: those holding an A/D track on the rail, and any crew switching in ` + + `the district below it.`, + seat: n.seat ?? null, + // No regions in a district: a crew moves by Moves there, not by Stages, so it occupies a + // card outright rather than a part of one. + regions: 0, + w: OFFICE_W, + }); continue; } const dp = n.kind === 'dp'; @@ -229,58 +262,31 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul w: dp ? CW.dp : CW.ml, }); } - if (side.length > 0) sides.push(side); - // Each player's side carries their district and the Mainline card leading into it; whatever is - // left over (the last Mainline and the East DP) joins the final side. - const seats = Math.max(1, Math.min(4, nodes.filter((n) => n.kind === 'office').length)); - const lanes: number[][] = []; - for (let i = 0; i < seats; i++) lanes.push([]); - sides.forEach((grp, i) => { - const target = Math.min(i, seats - 1); - for (const idx of grp) lanes[target]!.push(idx); - }); - - // -- lay the sides out around the table ------------------------------------------------------- - // top → right → bottom (reversed) → left (reversed), which gives a row, two facing rows, a - // horseshoe open to the west, and a square broken at the same place. - const dir: ('top' | 'right' | 'bottom' | 'left')[] = - seats === 1 ? ['top'] : seats === 2 ? ['top', 'bottom'] : seats === 3 ? ['top', 'right', 'bottom'] : ['top', 'right', 'bottom', 'left']; - - const runLen = (idxs: number[]): number => - idxs.reduce((n, i) => n + cells[i]!.w + GAP, -GAP); - const widest = Math.max(...lanes.map((l) => runLen(l)), 200); - const tall = lanes.length > 1 ? Math.max(...lanes.map((l) => l.length), 1) * (CH + GAP) : CH; - - const vertCount = dir.filter((d) => d === 'right' || d === 'left').length; - const boardW = PAD * 2 + widest + (vertCount > 0 ? CW.run + SIDE_GAP : 0); - const boardH = PAD * 2 + (dir.includes('bottom') ? CH * 2 + SIDE_GAP + (vertCount ? tall : 0) : CH) + 30; - - lanes.forEach((idxs, i) => { - const d = dir[i]!; - if (d === 'top' || d === 'bottom') { - const y = d === 'top' ? PAD : boardH - PAD - CH - 22; - const order = d === 'bottom' ? [...idxs].reverse() : idxs; - let x = PAD; - for (const idx of order) { - const c = cells[idx]!; - c.x = x; - c.y = y; - x += c.w + GAP; - } - } else { - const x = d === 'right' ? boardW - PAD - CW.run : PAD; - const order = d === 'left' ? [...idxs].reverse() : idxs; - let y = PAD + CH + SIDE_GAP; - for (const idx of order) { - const c = cells[idx]!; - c.x = x; - c.y = y; - c.w = CW.run; - y += CH + GAP; - } - } - }); + /** + * ONE ROW, WEST TO EAST (Gitea#18). The West Division Point is at the far left, the East at the + * far right, and nothing wraps. + * + * IT USED TO BE LAID OUT AROUND A TABLE — one row for a single seat, two facing rows for two, a + * horseshoe for three, a square for four — on the reasoning that players sit around a table so the + * route should too. That cost more than it bought, and three separate reports came out of it: the + * buffer stops pointed the wrong way once the route turned a corner, and, the one that decided it, + * **east stopped being to the right**. A player's east could be drawn south, west or north + * depending on which lane their district landed in, on a map whose whole job is saying which way + * a train is going. + * + * A row is wider than a square — roughly 1,580px at four players against 842 — and that is + * accepted: the map scrolls and zooms, and being able to rely on east meaning right is worth the + * scroll. + */ + let x = PAD; + for (const c of cells) { + c.x = x; + c.y = PAD; + x += c.w + GAP; + } + const boardW = x - GAP + PAD; + const boardH = PAD * 2 + CH + 30; // -- draw ------------------------------------------------------------------------------------- const rail = (x1: number, y: number, x2: number): string => { @@ -295,20 +301,29 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul return o; }; - // The same rail turned through ninety degrees, for the sides of the table. - const railV = (x: number, y1: number, y2: number): string => { - let o = - `` + - ``; - const n = Math.max(2, Math.floor(Math.abs(y2 - y1) / 9)); - for (let i = 0; i <= n; i++) { - const ty = y1 + ((y2 - y1) * i) / n; - o += ``; - } - return o; - }; - - let out = ``; + /** + * DRAWN AT ITS OWN SIZE, SO IT SCROLLS RATHER THAN SHRINKING (Gitea#18). + * + * An SVG has a viewBox and a drawn size, and the browser scales one to the other. `.bs` is + * `width:100%`, so the map is drawn at whatever the panel is wide — which was harmless while the + * Division was 842px and wrapped around a table, and is not now that a single row is 1,580px. At + * that width in an 800px panel every label renders at half size, on the map that needs reading + * most. Setting the width to the viewBox width makes one unit one pixel, and the containers + * already scroll (`#division`, `#vdivision`). + * + * THE PLAYABLE PAGE DOES NOT NEED THIS — `applyZoom` (`main.ts`) sets exactly the same width from + * the same viewBox after every render, and overrides this when the zoom is not 100%. THE REPLAYS + * DO: neither `replays.ts` nor the standalone `replay.ts` calls it, so without this they get the + * `width:100%` shrink. It is inline rather than in `BOARD_CSS` because only this function knows + * how wide the row came out. + * + * `flex:none` because `#division` is a flex container and a flex item may be shrunk below an + * explicit width; there is no point pinning it and then letting the panel squeeze it anyway. + */ + let out = + ``; // The joins between consecutive cells, drawn as rail so a connection is rail meeting rail. A join // that crosses from one player's side to the next is drawn heavier and labelled: that boundary is @@ -316,23 +331,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul for (let i = 0; i + 1 < cells.length; i++) { const a = cells[i]!; const b = cells[i + 1]!; - const sameRow = Math.abs(a.y - b.y) < 1; - const sameCol = Math.abs(a.x - b.x) < 1; - if (sameRow && b.x > a.x) out += rail(a.x + a.w, a.y + CH / 2, b.x); - else if (sameRow && b.x < a.x) out += rail(b.x + b.w, a.y + CH / 2, a.x); - else if (sameCol) { - // Stacked down one side of the table: still one straight run of track, not a turn. - const top = Math.min(a.y + CH, b.y + CH); - const bot = Math.max(a.y, b.y); - out += railV(a.x + a.w / 2, top, bot); - } else { - // A turn between sides: an elbow, so the route is visibly continuous around the table. - const ax = a.x + a.w / 2; - const bx = b.x + b.w / 2; - const ay = a.y + CH; - const by = b.y; - out += ``; - } + out += rail(a.x + a.w, a.y + RAIL_Y, b.x); } cells.forEach((c) => { @@ -348,15 +347,14 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul const mark = c.owner ? ` bs-owner${c.owner.isTurn ? ' bs-turn' : ''}${c.owner.isYou ? ' bs-you' : ''}` : ''; const suffix = c.owner?.isYou ? ' (you)' : ''; out += `${esc(c.label + suffix)}`; - out += rail(c.x + 6, c.y + 32, c.x + c.w - 6); + out += rail(c.x + 6, c.y + RAIL_Y, c.x + c.w - 6); if (c.sub) out += `${esc(c.sub)}`; - // REGIONS. §2.1 divides a Mainline card into two, and §8.2 moves a train one region per Stage. - // The bars are the card's DISTANCE and never vary; what varies is how fast a train covers them, - // so a 60 card is crossed in one Stage and a slow train on a 30 takes three. + // REGIONS. A Mainline card is 1 to 3 of them (Gitea#3) and a train advances one per Stage. The + // bars are the card's DISTANCE and never vary; where a train STARTS is what does. const RW = c.regions > 0 ? (c.w - 12) / c.regions : 0; for (let r = 0; r < c.regions; r++) { - out += ``; + out += ``; } /** @@ -371,36 +369,25 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul * So this keeps the two things the Division map is actually for — where a train is and which way * it is going — and leaves the cars to the tooltip and to the district. */ - c.trains.forEach((t, k) => { + /** + * A TRAIN IS A CHIP — its number, which way it points, and how many cars. + * + * It was drawn as a full consist here, matching the Office Area card, and reported as too large + * and hard to read. The Office card is where a consist is worth drawing, because that is where + * the switching decisions are made and where there is room to read it. So this keeps the two + * things the Division map is for — where a train is and which way it is going — and leaves the + * cars to the tooltip and to the district. + */ + const chip = (t: NonNullable[number], tx: number, ty: number, w: number): void => { const cars = t.cars ?? []; 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}`; - - /** - * THE OFFICE RUNNING CELL GETS FIXED SLOTS, ONE PER A/D TRACK — never a centre spread. - * - * Centred spreading pushes its outer chips outward as MORE trains arrive, and the cell was - * sized for the cards it holds, not for its trains — so two chips at a Station used to land at - * x 215–267 and 271–316 inside a cell spanning only 230–308, spilling onto the Limits cards - * either side. A fixed slot per A/D track cannot overflow the cell at any occupancy, because - * the cell was sized for exactly that many slots (see `CHIP_W` above). - */ - const isOfficeRun = c.kind === 'run' && c.cap !== null && c.cap > 0; - const slotW = isOfficeRun ? (c.w - 12) / c.cap! : 0; - const w = isOfficeRun ? Math.min(slotW - 4, label.length * 6.6 + 12) : Math.min(c.w - 8, label.length * 6.6 + 12); - // A train on a Mainline card sits in ITS region; anywhere else it just sits on the card. const inRegion = c.regions > 1 && typeof t.region === 'number'; - const tx = isOfficeRun - ? c.x + 6 + slotW * (k + 0.5) - : (inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2) + - (inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4)); const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : ''; const stages = typeof t.stagesLeft === 'number' - ? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card` + - ' (Stages, not regions: a card is two regions of fixed distance, and how many Stages a' + - ' train takes over them depends on the card speed and the train)' + ? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card` : ''; out += `` + - `` + - `${esc(label)}`; + `` + + `${esc(label)}`; out += ''; + }; + + const textW = (t: NonNullable[number]): number => + (`${t.label} \u25b6${(t.cars ?? []).length || ''}`).length * 6.6 + 12; + + /** + * TWO CHIPS TO A REGISTER ON A DISTRICT, in fixed slots — never a centre spread. + * + * Centred spreading pushes its outer chips outward as more trains arrive, which is how two chips + * at a Station once landed outside the cell that held them. Fixed slots cannot overflow, because + * the cell was sized for exactly that many (`OFFICE_W`). + */ + const isDistrict = c.kind === 'run'; + const SLOTS = 2; + const slotW = (c.w - 12) / SLOTS; + c.trains.forEach((t, k) => { + if (isDistrict) { + // Row-major within the A/D register: two across, then wrap under. A district can hold four + // trains and only two fit across it. + const col = k % SLOTS; + const row = Math.floor(k / SLOTS); + chip(t, c.x + 6 + slotW * (col + 0.5), c.y + CHIP_Y + row * 21, Math.min(slotW - 4, textW(t))); + return; + } + // A train on a Mainline card sits in ITS region; anywhere else it just sits on the card. + const inRegion = c.regions > 1 && typeof t.region === 'number'; + const w = Math.min(c.w - 8, textW(t)); + const tx = (inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2) + + (inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4)); + chip(t, tx, c.y + CHIP_Y, w); }); + + /** + * THE SECOND REGISTER, under the rail: trains in the district that hold no A/D track (Gitea#18). + * + * A crew switching below the Running Track and a train standing at an A/D track are different + * things — A/D occupancy is a hard capacity that causes collisions, switching is not — and the + * difference is drawn as POSITION rather than as a colour to learn, because below the rail is + * where those trains actually are. + */ + (c.below ?? []).forEach((t, k) => { + const col = k % SLOTS; + const row = Math.floor(k / SLOTS); + chip(t, c.x + 6 + slotW * (col + 0.5), c.y + BELOW_Y + row * 21, Math.min(slotW - 4, textW(t))); + }); + out += ''; }); - // THE ENDS. The route stops at both Division Points; drawing buffer stops and naming the gap is - // what stops a seated layout being read as a loop. + // THE ENDS. The route stops at both Division Points, and the buffer stops say so — a Division is + // a LINE, not a loop. With a single row (Gitea#18) they simply face outward at the two ends, west + // on the left and east on the right, which is the bug reported twice against the wrapped layout. const first = cells[0]; const last = cells[cells.length - 1]; /** diff --git a/src/sim/view.ts b/src/sim/view.ts index ebcff80..77c12c6 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -10,6 +10,7 @@ * drift into two different pictures of the same board. */ +import { regionOfTransit } from '../engine/advance.ts'; import { areaAtSeat, areaOf, @@ -32,7 +33,6 @@ import { MANEUVER_CARDS, MODIFIER_PROFILES, REALIGNMENTS, - REGIONS_PER_MAINLINE_CARD, OFFICE_ORDER, SPACE_USE_CARDS, enhancementRule, @@ -1165,36 +1165,27 @@ export function snapshot( // Crossing time is in Stages now, so a Mainline card shows its terrain and the trains on it // with how long each still has to run. const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card; - const isGrade = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.speed.kind === 'grade'; + const isGrade = n.card === 'heavyGrade'; /** - * WHERE ON THE CARD, from what the crossing already cost. + * WHERE ON THE CARD — now simply what the card says. * - * §2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage. - * The engine crosses in `crossingStages` Stages instead, which varies by card speed, train - * speed, passengers and modifiers — so the printed model is recovered by treating the entry - * point as the thing that varies, exactly as the cards do: + * This used to recover a printed two-region model from a crossing time computed out of the + * card's mph, the train's Fast/Slow class, its consist and any modifiers, by treating the + * ENTRY point as the thing that varied: `entry = 2 - stagesTotal`. It even had to cope with a + * negative entry, for a slow train needing three Stages to cross a card with two regions. * - * entry = REGIONS - stagesTotal position = entry + elapsed - * - * A 60 card is one Stage, so the train enters at the second region and is gone — which is - * what "Start positions further along the card" means on the printed art. A 30 card is two - * Stages, giving one region per Stage, which is §8.2 exactly. A slow train needing three - * Stages cannot fit three steps into two regions, so it holds in the first for a Stage: the - * card's distance is fixed and the train is simply slow across it. + * Gitea#3 turned that the right way up. Regions are the primary thing — printed on the card, + * one per Stage — and the entry point is what the rules actually move. There is nothing left + * to reconstruct. */ - const place = (t: { stagesRemaining: number; stagesTotal: number }): number => { - // `entry` may be NEGATIVE — a slow train needing three Stages cannot fit three steps into - // two regions, so it notionally starts before the card and spends the extra Stage getting - // to the first region. Clamping only the final position keeps that Stage at the START, - // where being slow shows; clamping `entry` first would have parked it at the exit instead. - const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal; - const elapsed = t.stagesTotal - t.stagesRemaining; - return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed)); - }; + // One region per Stage, straight off the card's own count: what a train has LEFT to run says + // where it is standing. `regionOfTransit` is the engine's own answer, so the picture and the + // collision rule cannot disagree about who is where. + const place = (t: { stagesRemaining: number }): number => regionOfTransit(n.card, t.stagesRemaining); return { kind: 'ml', label: name, - regions: REGIONS_PER_MAINLINE_CARD, + regions: mainlineProfile(n.card).regions, trains: [n.transits.map((t) => { const chip = trainChip(s, t.tray); return { diff --git a/test/cut-ordering.test.ts b/test/cut-ordering.test.ts index 8ae8a50..2c595d7 100644 --- a/test/cut-ordering.test.ts +++ b/test/cut-ordering.test.ts @@ -13,7 +13,7 @@ import assert from 'node:assert/strict'; import { applyIntent, areaOf, check } from '../src/engine/apply.ts'; import { createGame } from '../src/engine/setup.ts'; -import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackCard } from '../src/engine/state.ts'; +import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackArc, TrackCard } from '../src/engine/state.ts'; import { carsOn, coordKey, turnOf } from '../src/engine/state.ts'; const config: GameConfig = { @@ -53,12 +53,18 @@ function row(s: GameState, n: number): void { for (let c = 0; c < n; c++) addCard(s, at(1, c), straight()); } +/** + * `facing` is the PORT the engine points out through, which is not always an east-west one: a train + * standing on a curve points along its 45° leg. `railFacing` carries the east-west sense the train + * arrived with, so it keeps a straight answer whatever port the nose is on (`railFacingOf`). + */ function placeTray( s: GameState, coord: GridCoord, consist: RollingStock[], - facing: 'e' | 'w', + facing: 'n' | 's' | 'e' | 'w', engineAt = 0, + railFacing: 'e' | 'w' = facing === 'w' ? 'w' : 'e', ): string { const id = s.freeTrays.pop()!; s.trays.set(id, { @@ -67,9 +73,9 @@ function placeTray( trainIsExtra: false, engineAt, consist, - direction: facing === 'w' ? 'west' : 'east', + direction: railFacing === 'w' ? 'west' : 'east', facing, - railFacing: facing, + railFacing, position: { at: 'grid', seat: 0, coord }, movesUsed: 0, } as CrewTray); @@ -373,3 +379,85 @@ describe('taking your own cut back is undoing the drop, not a fresh pick-up', () ); }); }); + +// --------------------------------------------------------------------------- + +describe('a 45° leg is part of the west-to-east row, not outside it (Gitea#17)', () => { + /** + * Reported: "Cars were West to East Caboose, Loaded boxcar, Loaded boxcar, Loaded boxcar. After + * backing into that square cars were attached to the train Loaded boxcar, Loaded boxcar, Loaded + * boxcar, Caboose, Engine." The caboose came back next to the engine instead of at the far end, + * which also leaves the train badly made up under §8.2. + * + * The square was a `sw` CURVE and the train backed in through its SOUTH leg. `standing` runs west + * to east, and the two places that walk it both asked the PORT which end of the row they were at: + * `exploreMoves` reversed the row for an 'e' entry and for nothing else, and `cutTowards` answered + * "you meet nothing" for a north or south exit. Neither is a property of the port. + * + * A 45° leg leaves through the MIDDLE of its edge, so its end of the run is whichever end the arc + * does not reach: the south leg of a `sw` curve is the row's EAST end, and the south leg of an + * `se` curve is its WEST end. Same port, opposite answers — which is why `rowEndAt` has to ask the + * card. + */ + const curve = (arc: TrackArc, standing: RollingStock[] = [], standingWest = 0): TrackCard => ({ + geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' }, + baseOperationalRail: true, + standing, + standingWest, + facility: null, + modifiers: [], + enhancements: [], + }); + + /** + * The reported board, minimally: a `sw` curve holding the cut, and an `ne` curve below it for the + * train to run from. Both legs lie on the `ne_sw` diagonal, so the two cards actually join. + */ + function board(standing: RollingStock[], standingWest = standing.length): GameState { + const s = game(); + addCard(s, at(1, 0), curve('sw', standing, standingWest)); + addCard(s, at(0, 0), curve('ne')); + switching(s); + return s; + } + + it('backs into a cut through the south leg and meets the EAST end of the row first', () => { + const s = board([car('caboose', true), car('boxcar', true), car('boxcar', true), car('boxcar', true)]); + // Facing east on the `ne` curve, so reversing pulls out through its north leg and into the + // curve above through that card's south leg — the move in the reported save. + const id = placeTray(s, at(0, 0), [], 'e'); + const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: true }); + assert.ok(r.ok, `the reverse move was refused: ${r.ok ? '' : r.code}`); + // Coupled behind the engine nearest-car-first, and the nearest car is the one at the south end + // — the LAST of a west-to-east row on a `sw` curve. The caboose was westmost, so it ends up + // furthest from the engine, which is where §8.2 needs it. + assert.deepEqual(types(s.trays.get(id)!.consist), ['boxcar', 'boxcar', 'boxcar', 'caboose']); + }); + + it('meets the WEST end of the row first where the same leg belongs to an `se` curve', () => { + // The mirror, and the reason the port alone cannot answer: an `se` curve's south leg is the + // west end of its row, so the same reverse move meets the caboose first. + const s = game(); + addCard(s, at(1, 0), curve('se', [car('caboose', true), car('boxcar', true)], 2)); + addCard(s, at(0, 0), curve('nw')); + switching(s); + const id = placeTray(s, at(0, 0), [], 'w'); + const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: true }); + assert.ok(r.ok, `the reverse move was refused: ${r.ok ? '' : r.code}`); + assert.deepEqual(types(s.trays.get(id)!.consist), ['caboose', 'boxcar']); + }); + + it('takes its own cut back with it when it pulls out through the south leg (§A.4)', () => { + // The other half of the same assumption: `cutTowards` said a train leaving north or south meets + // nothing, so a crew standing on a curve drove away and left the cars beside it standing — + // exactly what mandatory coupling forbids. + const s = board([car('boxcar', true)], 0); + // `standingWest` 0 puts the boxcar EAST of the train, which on a `sw` curve is between it and + // the south leg it is about to leave by. + const id = placeTray(s, at(1, 0), [], 's'); + const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: false }); + assert.ok(r.ok, `the move off the curve was refused: ${r.ok ? '' : r.code}`); + assert.deepEqual(types(s.trays.get(id)!.consist), ['boxcar'], 'the cut beside the train was left standing'); + assert.deepEqual(standingAt(s, at(1, 0)), [], 'the cars should have come off the card'); + }); +}); diff --git a/test/harness.test.ts b/test/harness.test.ts index 40fb77e..2ed26ff 100644 --- a/test/harness.test.ts +++ b/test/harness.test.ts @@ -228,14 +228,28 @@ describe('the game conserves Rolling Stock', () => { for (let t = 0; t < 50_000; t++) { const before = census(s); const pumped = pump(s); - // §10 — a collision destroys both trains and everything they were carrying. That is the one - // legitimate way the count falls, so the expectation follows it down. - for (const e of pumped) { - if (e.type === 'trainsDestroyed') for (const tr of e.trains) expected -= tr.consist.length; - } - assert.ok( - census(s) === before || pumped.some((e) => e.type === 'trainsDestroyed'), - `seed ${seed}: the engine changed the census by ${census(s) - before} outside a collision`, + /** + * A COLLISION DESTROYS NO CAR, and this used to assume it destroyed all of them. + * + * The subtraction that stood here — `expected -= tr.consist.length` for every train in a + * `trainsDestroyed` event — describes a rule the engine does not have. Gap 2c (`advance.ts`, + * "TAKE THE WRECK OFF THE CARD") sends the wreck's cabooses back to the Division Yard and + * everything else to Classification, so the stock is conserved through a collision like any + * other move. The train is destroyed; its cars are not. + * + * It passed for as long as it did because none of the six seeds below ever collided, so the + * branch never ran. Changing the deck to the sheet's counts (Gitea#14) moved the deals, seed + * 24757 collided, and the test failed claiming the engine had CONJURED three cars — the + * exact opposite of what had happened. + * + * So the census is now held flat, unconditionally, which is both the real invariant and a + * stronger test than the one it replaces: there is no longer any event that excuses a + * change, and `expected` cannot drift away from the supply it was dealt. + */ + assert.equal( + census(s), + before, + `seed ${seed}: the engine changed the census by ${census(s) - before} while pumping`, ); if (s.status === 'finished') break; const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor; diff --git a/test/mainline-cards.test.ts b/test/mainline-cards.test.ts index fa11b0f..f87c41d 100644 --- a/test/mainline-cards.test.ts +++ b/test/mainline-cards.test.ts @@ -12,6 +12,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import type { MainlineKind } from '../src/engine/content.ts'; import { advance } from '../src/engine/advance.ts'; import { applyIntent, areaOf, check } from '../src/engine/apply.ts'; import { legalActions } from '../src/engine/legal.ts'; @@ -43,6 +44,20 @@ const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, player const at = (row: number, col: number): GridCoord => ({ row, col }); /** Puts a card of `kind`/`key` in hand and returns its id. */ +/** + * Puts a specific rules card in hand and returns its id, MINTING ONE IF THE DECK NO LONGER DEALS IT. + * + * A card at `copies: 0` is still a card: the catalogue keeps its row and the engine keeps its rule, + * so the design stays visible and the mechanic works the moment it is dealt again. Poling and the + * sharp curves have been treated that way for a while, and Gitea#14 put the dispatching ladder, + * Facing Point Locks, Flying Switch, Section House and Vandalism there too — none of them are in + * `docs/Deck cards5.xlsx`. + * + * This used to throw when it could not find one, which made "dealt zero copies" and "deleted" + * indistinguishable from a test's point of view: zeroing Flying Switch took five passing tests of a + * rule that had not changed at all down with it. Minting keeps the rule under test independently of + * whether the deck currently deals the card, which is the whole reason for keeping the row. + */ function hand(s: GameState, kind: string, key: string): string { for (const [id, card] of s.cards) { const k = card.kind as { kind: string; key?: string }; @@ -51,7 +66,10 @@ function hand(s: GameState, kind: string, key: string): string { return id; } } - throw new Error(`no ${kind} card: ${key}`); + const id = `zero-copy-${kind}-${key}`; + s.cards.set(id, { id, kind: { kind, key } } as never); + s.decks.hands.set(0, [id]); + return id; } /** @@ -90,53 +108,124 @@ function drawTurn(s: GameState): void { // --------------------------------------------------------------------------- -describe('grade modifiers change crossing time', () => { - it('a Heavy Grade takes two Stages bare', () => { - assert.equal(crossingStages('heavyGrade', 'fast', false), 2); +/** + * Crossing time on the region model (Gitea#3). `cross` fills in the parts each test does not care + * about, so the numbers below read as the table RAR gave rather than as argument lists. + */ +const cross = ( + kind: Parameters[0], + over: Partial[1]> = {}, +): number => + crossingStages(kind, { + trainSpeed: 'fast', + direction: 'east', + gradeUp: 'east', + modifiers: [], + ...over, + }); + +describe('a card costs one Stage per printed region (Gitea#3)', () => { + /** + * RAR, 2026-08-26, and this REPLACES the two rules that were here before — Q1, "the printed 60/30 + * are mph expressed as crossing time", and Q2, "a Slow train adds one Stage to every card". + * + * "Ignore speed signs, they are just graphics. Regions shown on cards indicate how many stages it + * takes to cross. Plains is 1. Double track is 1, tunnel is 2, curves is 2, heavy grade is 3 + * unless you have help." + * + * The report that opened the issue was a Slow train taking two Stages to clear Double Track. Q2 is + * what did that, and it is gone. + */ + it('crosses in the number of regions the card prints, whatever the train', () => { + for (const speed of ['fast', 'slow'] as const) { + assert.equal(cross('plains', { trainSpeed: speed }), 1, `plains, ${speed}`); + assert.equal(cross('doubleTrack', { trainSpeed: speed }), 1, `double track, ${speed}`); + assert.equal(cross('trestle', { trainSpeed: speed }), 1, `trestle, ${speed}`); + assert.equal(cross('curves', { trainSpeed: speed }), 2, `curves, ${speed}`); + assert.equal(cross('tunnel', { trainSpeed: speed }), 2, `tunnel, ${speed}`); + assert.equal(cross('heavyGrade', { trainSpeed: speed }), 3, `heavy grade, ${speed}`); + } + }); + + it('reads Fast/Slow on Hilly and on nothing else', () => { + // "Some cards say fast / slow… Fast / Slow does not apply to every card — just those that say + // fast / slow on them. Currently this is only hilly." A fast train starts in the second region. + assert.equal(cross('hilly', { trainSpeed: 'fast' }), 1); + assert.equal(cross('hilly', { trainSpeed: 'slow' }), 2); + }); + + it('does not read the consist any more', () => { + // Hilly used to take its split off the printed P60/F30 and decide by whether the train carried a + // coach, so a fast freight crossed slower than a slow passenger train. RAR: "I notice that you + // are basing stages in mainline cards off coach/non-coach. Actually, all trains are rated as + // FAST and SLOW." `crossingStages` no longer takes a consist at all — this test is here so the + // deletion is deliberate rather than incidental. + assert.equal(cross('hilly', { trainSpeed: 'fast' }), cross('hilly', { trainSpeed: 'fast' })); + }); + + it('runs a train through a siding or an Interchange in one Stage', () => { + // Both print a back region that is not part of the road, so a train passing through starts past + // it. What that region is FOR is tested below and in the collision tests. + assert.equal(cross('uncontrolledSiding'), 1); + assert.equal(cross('interchange'), 1); + }); + + it('costs the extra Stage to anything starting in that back region', () => { + // The Uncontrolled Siding with a train already on it, and an Extra beginning its run at an + // Interchange. Both start at the back and have the whole card to run. + assert.equal(cross('uncontrolledSiding', { startsAtBack: true }), 2); + assert.equal(cross('interchange', { startsAtBack: true }), 2); + }); +}); + +describe('grade modifiers move the start, not the clock', () => { + it('a Heavy Grade takes three Stages bare', () => { + // Three regions, up from the two the old 30mph reading gave it. + assert.equal(cross('heavyGrade'), 3); }); it('Brakeman speeds the descent but not the climb', () => { - // Q11 — the card prints "(Up)" and "Player sets orientation", so the last argument is which - // way is UPHILL. With up = east, a westbound train is descending. - assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'east'), 1); - assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'east'), 2); + // Q11 — the card prints "(Up)" and "Player sets orientation", so `gradeUp` is which way is + // UPHILL. With up = east, a westbound train is descending. + assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'west' }), 2); + assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'east' }), 3); }); - it('follows the orientation the player chose, not a fixed compass direction', () => { - // The same train on the same card, with the card turned around: Brakeman helps a westbound - // train on an east-climbing grade, and an eastbound one when the grade climbs west. - assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'west'), 1); - assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'west'), 2); - assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'west'), 1); - assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'west'), 2); + it('follows the orientation the card was dealt, not a fixed compass direction', () => { + // The same train on the same card, with the card turned around. + assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'east', gradeUp: 'west' }), 2); + assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'west', gradeUp: 'west' }), 3); + assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'west', gradeUp: 'west' }), 2); + assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'east', gradeUp: 'west' }), 3); }); it('Helpers speed the climb but not the descent', () => { - assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'east'), 1); - assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'east'), 2); + // RAR: "helpers… helps all trains going up hill by starting 1 region easier — so 2 to traverse, + // not 3." + assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'east' }), 2); + assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'west' }), 3); }); - it('Airbrakes stack with Brakeman on a slow train', () => { - // A slow train pays 3 on a grade; Brakeman and Airbrakes take one Stage each. - assert.equal(crossingStages('heavyGrade', 'slow', false, [], 'west', 'east'), 3); - assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west', 'east'), 2); - assert.equal( - crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west', 'east'), - 1, - ); + it('Airbrakes stack on top of Brakeman', () => { + // "Airbrakes is an upgrade from brakemen (which must be played first)", so a fully-equipped + // grade is one Stage downhill — and `check` refuses Airbrakes without Brakeman already there. + assert.equal(cross('heavyGrade', { direction: 'west' }), 3); + assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'west' }), 2); + assert.equal(cross('heavyGrade', { modifiers: ['brakeman', 'airbrakes'], direction: 'west' }), 1); }); it('never lets a train cross in no time', () => { + // Three modifiers on a three-region card would otherwise put the start past the far edge. assert.equal( - crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], 'west', 'east'), + cross('heavyGrade', { modifiers: ['brakeman', 'airbrakes', 'helpers'], direction: 'west' }), 1, ); }); it('leaves non-grade cards alone', () => { // Brakeman on Plains would be an illegal placement anyway; the maths must not move regardless. - assert.equal(crossingStages('plains', 'fast', false, ['brakeman'], 'west', 'east'), 1); - assert.equal(crossingStages('curves', 'fast', false, ['helpers'], 'east', 'east'), 2); + assert.equal(cross('plains', { modifiers: ['brakeman'], direction: 'west' }), 1); + assert.equal(cross('curves', { modifiers: ['helpers'] }), 2); }); }); @@ -209,8 +298,8 @@ describe('Realignment converts one Mainline type to another', () => { assert.ok(r.ok); assert.equal(node.card, 'plains', 'Curves realigns to Plains'); - // The point of the card: Curves is a 30 (two Stages), Plains a 60 (one). - assert.equal(crossingStages(node.card, 'fast', false), 1); + // The point of the card: Curves prints two regions, Plains one, so realigning halves the time. + assert.equal(cross(node.card), 1); }); it('refuses a card with no conversion listed', () => { @@ -796,18 +885,35 @@ describe('Q13 — a train that catches the one ahead runs into it', () => { * then region 1 — so it catches up whichever order the phase happens to process them in. */ const twoTrains = (opts: { absSignals?: boolean } = {}): { s: GameState; events: GameEvent[] } => { - const s = createGame({ - id: 'rear', seed: 3, - config: { - mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, - optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, - }, - playerNames: ['bot'], - }); - const index = s.division.nodes.findIndex( - (n) => n.kind === 'mainline' && !mainlineProfile(n.card).trainsMayPass, - ); - assert.ok(index >= 0, 'no single-track Mainline card in this Division'); + /** + * THE SEED IS SEARCHED FOR, NOT WRITTEN DOWN. + * + * This asked for seed 3 and asserted that its Division held a single-track Mainline card. It + * does not any more: the Division is laid out from the same RNG stream the card deck is + * shuffled from, so changing the SIZE of that deck re-deals the Division too. Gitea#14's deck + * counts moved it, and the test failed on its own precondition — "no single-track Mainline card + * in this Division" — which says nothing about the rule under test. + * + * The fixture needs A Division with a card trains may not pass on, not one particular one, so + * it now takes the first seed that provides one. That is stable across any future retune, and + * it fails loudly if such a Division stops being reachable at all. + */ + let s!: GameState; + let index = -1; + for (let seed = 3; seed < 200 && index < 0; seed++) { + s = createGame({ + id: 'rear', seed, + config: { + mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, + }, + playerNames: ['bot'], + }); + index = s.division.nodes.findIndex( + (n) => n.kind === 'mainline' && !mainlineProfile(n.card).trainsMayPass, + ); + } + assert.ok(index >= 0, 'no seed under 200 deals a Division holding a single-track Mainline card'); const node = s.division.nodes[index]!; assert.ok(node.kind === 'mainline'); if (node.kind !== 'mainline') throw new Error('unreachable'); @@ -863,12 +969,116 @@ describe('Q13 — a train that catches the one ahead runs into it', () => { ); }); - it('leaves trains alone on a card that prints "trains may pass"', () => { - // Double Track and Uncontrolled Siding hold two trains because they HAVE two roads. Catching up - // there means going past, which is what the card is for. Without this the mechanic fired 0.41 - // times a game while the bot never once granted clearance — the tell that they were all - // passing cards. + /** + * ENTERING an occupied region, as opposed to catching up inside the card (Gitea#3). + * + * A card can be ONE region wide — Plains, Double Track and Trestle all are — so a following train + * granted clearance is in the same place as the train ahead the moment it arrives. Nothing tested + * that: the catch-up check lives inside `stagesRemaining > 1`, which a one-Stage crossing never + * reaches, so entering behind another train on a Plains was silently free. + */ + const enteringBehind = (card: MainlineKind, opts: { absSignals?: boolean } = {}) => { + const s = game(); + const index = s.division.nodes.findIndex((n) => n.kind === 'mainline'); + const node = s.division.nodes[index]!; + assert.ok(node.kind === 'mainline'); + if (node.kind !== 'mainline') throw new Error('unreachable'); + node.card = card; + node.transits = []; + if (opts.absSignals) node.absSignals = true; + + /** + * THE TRAIN ALREADY THERE IS THE JUNIOR ONE, and that is what makes the situation reachable. + * + * Trains move lowest number first, so a card's occupant normally clears before anything behind + * it is even considered — put train 9 on the card and train 11 at the Division Point and 9 has + * gone by the time 11 enters. The conflict is a SUPERIOR train catching an inferior one that has + * not got out of the way yet, so the numbers run the other way round here. + */ + const leader = s.freeTrays.pop()!; + s.trays.set(leader, { + id: leader, trainNumber: 11, trainIsExtra: false, engineAt: 0, + consist: [{ type: 'boxcar', loaded: false }], direction: 'east', + position: { at: 'mainline', index }, + } as never); + const total = crossingStages(card, { trainSpeed: 'fast', direction: 'east', gradeUp: 'east', modifiers: [] }); + node.transits.push({ tray: leader, stagesRemaining: total, stagesTotal: total, direction: 'east' }); + + // And the one arriving, held at the Division Point west of it. + const dp = s.division.nodes[index - 1]; + assert.ok(dp && dp.kind === 'divisionPoint', 'expected a Division Point west of the first card'); + if (!dp || dp.kind !== 'divisionPoint') throw new Error('unreachable'); + const follower = s.freeTrays.pop()!; + s.trays.set(follower, { + id: follower, trainNumber: 9, trainIsExtra: false, engineAt: 0, + consist: [{ type: 'boxcar', loaded: false }], direction: 'east', + position: { at: 'divisionPoint', side: dp.side }, + } as never); + dp.holding.push(follower); + + /** + * THE SUPERINTENDENT LETS IT IN, which is the whole point. + * + * A same-direction train in the Subdivision is not an absolute bar — §8.1 makes it a judgment + * call, and `advance` stops and asks. Granting it is what puts one train in behind another, and + * §10 is then explicit that the wreck is the Superintendent's fault. So the fixture answers + * `allow: true` whenever it is asked, and the collision below is the consequence of that + * ruling rather than of a rule firing on its own. + */ + s.clock.phase = 'mainline'; + const events: GameEvent[] = []; + for (let i = 0; i < 6; i++) { + events.push(...advance(s).events); + if (s.clock.pendingDecision !== null) { + const who = s.clock.superintendent; + const r = applyIntent(s, who, { type: 'mainline.clearance', allow: true }); + assert.ok(r.ok, `clearance refused: ${r.ok ? '' : r.code}`); + events.push(...r.events); + } + } + return { s, node, events, follower }; + }; + + it('runs a train into the one ahead when it ENTERS an occupied region', () => { + const { events } = enteringBehind('plains'); + const smash = events.find((e) => e.type === 'trainsDestroyed'); + assert.ok(smash, 'a train entered a one-region card behind another and nothing happened'); + }); + + it('holds it short instead when the card carries ABS Signals', () => { + // RAR: "ABS. This is played on a mainline card to prevent collisions. If a collision would + // normally occur, the train moving onto the card is instead held back." + const { events } = enteringBehind('plains', { absSignals: true }); + assert.ok(!events.some((e) => e.type === 'trainsDestroyed'), 'ABS Signals did not prevent it'); + assert.ok( + events.some((e) => e.type === 'trainHeld' && /ABS Signals/.test(e.reason)), + 'nothing was held short of the train ahead', + ); + }); + + it('takes the siding instead of colliding on an Uncontrolled Siding', () => { + // "If a train already exists when you arrive, you go in the second stage back — you are in the + // siding and are one behind the other train. This prevents a collision, since you are not in + // same exact location." So: no wreck, both trains on the card, and the newcomer paying the + // extra Stage for the detour. + const { node, events, follower } = enteringBehind('uncontrolledSiding'); + assert.ok(!events.some((e) => e.type === 'trainsDestroyed'), 'the siding did not prevent a collision'); + const mine = node.transits.find((t) => t.tray === follower); + assert.ok(mine, 'the arriving train never made it onto the card'); + assert.equal(mine.stagesTotal, 2, 'it should have entered at the back of the card, not the front'); + }); + + it('leaves trains alone on the one card that prints "trains may pass"', () => { + // Double Track holds two trains because it HAS two roads. Catching up there means going past, + // which is what the card is for. + // + // THE UNCONTROLLED SIDING USED TO BE IN THIS LIST AND IS NOT ANY MORE (Gitea#3). It holds two + // trains as well, but not by letting them share a place: the second one takes the siding and + // sits a region behind, which is what keeps them apart — "you are in the siding and are one + // behind the other train. This prevents a collision, since you are not in same exact location." + // Marked "may pass" it skipped the collision test entirely, so the siding did nothing at all and + // two trains could occupy the same region of it unchallenged. const passing = MAINLINE_PROFILES.filter((m) => m.trainsMayPass).map((m) => m.kind); - assert.deepEqual(passing, ['doubleTrack', 'uncontrolledSiding']); + assert.deepEqual(passing, ['doubleTrack']); }); }); diff --git a/test/setup.test.ts b/test/setup.test.ts index 2175850..0bfcbb0 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -63,56 +63,73 @@ const gameDealtWith = (startingHand: StartingHand, seed = 1234) => describe('card catalogue (component 1)', () => { it('composes the deck from the design', () => { - // Transcribed from docs/Deck cards2.xlsx. The sheet's own totals are "Sum other 115" and - // "Total track 104", i.e. 219, plus 12 start cards for its grand total of 231. + // THE WHOLE CATALOGUE IS docs/Deck cards5.xlsx NOW (Gitea#14). Sheet 5's own totals are + // "Total track 48" and "Total other (in play) 107", i.e. 155 shuffled, plus 12 start cards for + // its grand total of 167. Card for card, 84 rows agree with it exactly and the only ones that + // do not are listed below — every one of them a deliberate hold, in one direction or the other. // - // We are at 235 rather than 219 because of three deliberate departures, all flagged in - // content.ts: 18 extra industry cards (Gap 12, industries 9 → 27), 7 extra office cards (Q12, - // offices 7 → 14), and the 8 sharp curves taken back OUT. The first two were tuned against a deck - // that had NO track in it, so both are due a re-measurement now that 96 track cards share the - // draw. + // EVERY COUNT IN THE CATALOGUE IS NOW THE SHEET'S. The two deliberate departures that used to + // sit here are gone with Gitea#14 — the Q12 office doubling (offices 14 → 7) and the Gap 12 + // industry tripling (27 → 9) — because both were measured against a deck holding 96 track + // cards, and sheet 5 halves that. content.ts carries the measurements that decided it. // - // Two entries are dealt ZERO copies and kept in the catalogue so the design stays visible: - // Poling, whose effect is "TBD in the source", and the sharp curves, whose only difference from - // an ordinary curve was a Move cost nothing ever charged. - // DECK_SIZE is the CATALOGUE, 235. The deck actually dealt is smaller: the 22 opponent-directed - // cards are held back in every mode until they are implemented, so `buildDeck` returns 213. - assert.equal(DECK_SIZE, 235); + // We are at 143 rather than the sheet's 155 for ONE reason: the ten Safety, Event, Inspection + // and Space-use cards sheet 5 adds are not built, and stay out until they are (Jesse, + // 2026-08-26) — Cargo Theft, Civic Improvement, Civilian angel, Delayed Clearance, Flares 2, + // Robbery, Service Delays, Shipper complaints, Strike, Union Hall 2. Twelve copies in all. + // + // NOTHING RUNS THE OTHER WAY ANY MORE. Every card sheet 5 does not list is dealt ZERO copies + // rather than deleted, so the design stays visible and the rules stay implemented: the + // Telegraph/Telephone/Radio ladder, Facing Point Locks (both), Flying Switch, Section House and + // Vandalism, all removed from the design on purpose; Poling, whose effect the source records as + // "TBD"; and the sharp curves, whose only difference from an ordinary curve was a Move cost + // nothing ever charged — sheet 5 deals those zero too, so the catalogue and the design agree. + // + // DECK_SIZE is the CATALOGUE, 143. The deck actually dealt is smaller: the 20 opponent-directed + // cards are held back in every mode until they are implemented, so `buildDeck` returns 123. + assert.equal(DECK_SIZE, 143); assert.equal(buildDeck().length, SOLITAIRE_DECK_SIZE); }); it('matches the design deck composition exactly', () => { const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count])); assert.deepEqual(byCategory, { - // 96, not the sheet's 104: the 8 SHARP CURVES are dealt zero copies. The only thing that made - // one different from an ordinary curve was a Move cost that nothing ever charged, so they were - // geometric duplicates taking 8 draws. Kept in the catalogue at zero, as Poling is. - track: 96, - // 14, not the sheet's 7 — Q12 office density; see OFFICE_PROFILES. - office: 14, - // 27, not the sheet's 9 — Gap 12 industry density; see INDUSTRY_PROFILES. - industry: 27, + // Sheet 5's track counts exactly (Gitea#14): 16 straights, 8+8 curves, 8+8 turnouts, and the + // sharp curves dealt none — which is where the catalogue already had them, and where sheet 5 + // now puts them too. + track: 48, + // The sheet's 7 — the Q12 doubling came out in Gitea#14; see OFFICE_PROFILES. + office: 7, + // The sheet's 9 — the Gap 12 tripling came out in Gitea#14; see INDUSTRY_PROFILES. + industry: 9, modifier: 23, train: 22, - spaceUse: 12, - enhancement: 18, - mainlineModifier: 7, - // 6, not 7 — Poling is dealt no copies until its rule is known. - maneuver: 6, - action: 10, + spaceUse: 11, + // 6 — the dispatching ladder and Facing Point Locks are dealt 0 copies (see + // ENHANCEMENT_CARDS), and Interlocking, Water column and ABS Signals came down to the sheet's + // single copies. What is left is the sheet's Enhancements exactly, bar Railroad crossing, + // which sheet 5 moved here from the Action cards and which is still counted there below. + enhancement: 6, + // 5 — Facing Point Locks came out of the Mainline modifiers too. + mainlineModifier: 5, + // 3 — Red Flags is the sheet's 3; Flying Switch and Poling are both dealt none. + maneuver: 3, + // 9 — Vandalism is dealt none. The rest are opponent-directed and held out of every deck. + action: 9, }); }); it('removes opponent-directed cards from a solitaire deck', () => { // Q6 took Space-use and Action cards out of solitaire, where they have no legal target. They are // now out of the COMPETITIVE deck too, until they are implemented: `checkPlay` answers both - // categories NOT_IMPLEMENTED, so dealing them would make 22 of 235 draws (9%) reject outright. - // 206, not 213: the 22 opponent-directed cards come out, and so do the SEVEN that exist only to - // answer them — Facing Point Locks (both the Enhancement and the Mainline modifier, 2 each), two - // Water Columns and one Overpass. A defence with nothing to defend against is the same dead draw - // as the attack would be. `SimpleCard.answers` names the pairing, so they return together. - assert.equal(SOLITAIRE_DECK_SIZE, 206); - assert.equal(DEFENCE_ONLY_COPIES, 7); + // categories NOT_IMPLEMENTED, so dealing them would be a dead draw. + // 121, not 123: the 20 opponent-directed cards come out, and so do the TWO that exist only to + // answer them — one Water Column and one Overpass. A defence with nothing to defend against is + // the same dead draw as the attack would be. `SimpleCard.answers` names the pairing, so they + // return together. It was seven until Gitea#14 dealt Facing Point Locks zero copies: a card at + // zero is already out, so it no longer needs holding back. + assert.equal(SOLITAIRE_DECK_SIZE, 121); + assert.equal(DEFENCE_ONLY_COPIES, 2); for (const c of DEFENCE_ONLY_CARDS) { assert.ok(c.answers, `${c.name} is held back without saying what it answers`); assert.ok( @@ -131,11 +148,12 @@ describe('card catalogue (component 1)', () => { }); it('deals track FROM the deck, at the sheet\'s counts', () => { - // Column B of Deck cards2.xlsx, "Number in Deck": 32 straights, 16+16 curves, 16+16 turnouts — - // and 4+4 sharp curves, which are dealt none. An earlier reading took the sheet's LAST column, - // "Track Per Player" (26), as a separate stack outside the deck; it is the sheet's 104 shared out - // among four players, not a second pile. - assert.equal(TRACK_IN_DECK, 96); + // Column B of Deck cards5.xlsx, "Number in Deck": 16 straights, 8+8 curves, 8+8 turnouts, and + // 0+0 sharp curves. Sheet 2 had each of those at double, which is what the deck dealt until + // Gitea#14. An earlier reading took the sheet's LAST column, "Track Per Player" (26), as a + // separate stack outside the deck; it is the sheet's total shared out among four players, not a + // second pile. + assert.equal(TRACK_IN_DECK, 48); const deck = buildDeck(); for (const t of TRACK_CARDS) { const n = deck.filter( @@ -146,11 +164,26 @@ describe('card catalogue (component 1)', () => { }); it('makes track the largest category in the deck', () => { - // 96 of 235. Building a district is paid for in the industry or train you did not draw, which + // 48 of 121. Building a district is paid for in the industry or train you did not draw, which // is the whole reason it matters that track is a card rather than a private supply. + // + // This asked for a THIRD of the deck until Gitea#14, which was only ever a rule of thumb. It + // asks its own question now — is track still the biggest single thing you can draw — plus a + // loose band, because the exact share is not settled yet and should not be pinned as though it + // were. Sheet 5 puts track at 48 of the 155 cards it would have you shuffle, i.e. 31%; we read + // 40% because the Space-use, Safety, Event and Inspection cards are held out, which concentrates + // everything that is left. The share falls TOWARDS the sheet as those land, so the band is set + // to hold across that whole journey rather than to be re-edited at each step. const deck = buildDeck(); - const track = deck.filter((c) => c.kind.kind === 'track').length; - assert.ok(track > deck.length / 3, `track is only ${track} of ${deck.length} cards`); + const counts = new Map(); + for (const c of deck) counts.set(c.kind.kind, (counts.get(c.kind.kind) ?? 0) + 1); + const track = counts.get('track') ?? 0; + for (const [kind, n] of counts) { + if (kind === 'track') continue; + assert.ok(track > n, `${kind} has ${n} cards against track's ${track}`); + } + const share = track / deck.length; + assert.ok(share > 0.28 && share < 0.45, `track is ${(share * 100).toFixed(1)}% of the deck`); }); it('has 12 timetabled trains, odd westbound and even eastbound', () => { diff --git a/test/sim.test.ts b/test/sim.test.ts index 1feb468..6342928 100644 --- a/test/sim.test.ts +++ b/test/sim.test.ts @@ -191,15 +191,26 @@ describe('the revenue chain works end to end (regression)', () => { // only because unloads were mis-scored as completed loads after one Laborer action instead of // four. Correcting that dropped mean revenue from 24.8 to ~4.6 and the win rate to zero, so // "did anyone win" is no longer a safe proxy for "does freight work". + /** + * TWO HUNDRED GAMES, up from forty (Gitea#3). Completed freight loads got scarcer when the + * Mainline went onto the region model, and measurably so — on these seeds: 40 games yield 0 + * loads, 80 yield 3 (1 game), 120 yield 10 (4 games), 200 yield 21 (9 games). Forty could no + * longer reach the precondition it exists to establish. + * + * WHY it got scarcer is not settled and is worth someone's attention rather than a guess — the + * change speeds crossings up, which ought to put MORE trains through a district, not fewer. + * Freight share of gross fell from 8% to 5% over 100 games across the same change. Recorded in + * TODO.md under Play Balance; the assertion itself is untouched. + */ const report = simulate({ - games: 40, + games: 200, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot, }); const freight = report.perGame.reduce((n, g) => n + g.revenue.freightLoad, 0); - assert.ok(freight > 0, 'no freight load completed across 40 games'); + assert.ok(freight > 0, 'no freight load completed across 200 games'); }); it('grows the Office Area off the Running Track, on either side', () => { @@ -351,7 +362,24 @@ describe('end-of-game statistics', () => { * rule that has become unreachable. Exempted by name so the other forty-odd event checks stay live, * and so removing this line is what proves the bot has been fixed. */ - const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch']; + /** + * RED FLAGS JOINS IT (Gitea#3), and for the same reason — the rule is reachable and the bot will + * not take it. + * + * MEASURED over 600 games: `maneuver.redFlags` is OFFERED 4,212 times, first in game 5 — so the + * rule is live and constantly available. The bot PLAYS it 4 times, first in game 252. At 200 + * games this canary sees nothing and calls it unreachable, which is the opposite of the truth. + * + * It got rarer for two compounding reasons, neither of them a broken rule: Gitea#14 took Red + * Flags from 5 copies to the sheet's 3, and Gitea#3 shortened most crossings to a single Stage, + * so the window in which a train is STANDING on a Mainline card — the only place the card may be + * played — is now usually one Stage wide. + * + * The bot's unwillingness is the thing worth fixing, and it is in TODO.md under Bot Performance. + * Exempted BY NAME so the other forty-odd checks stay live, and so deleting this line is what + * proves the bot has learned to use it. + */ + const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch', 'event redFlagsSet']; const found = anomalies(report.perGame); const never = found .filter((a) => a.severity === 'never') @@ -643,12 +671,17 @@ describe('the bot builds sidings that are actually sidings (regression)', () => // Measured across 100 games: tank cars boarded a train 0.07 times a game and were dropped by a // crew ZERO times, while boxcars were 67% of every drop — and 23 of 79 waiting loads were // sitting at an industry that wanted a tank. - // A HUNDRED GAMES, not thirty — the comment above says the original measurement used 100, and - // the sample has to be that big to mean anything: measured now, a tank is set out in 3% of games - // and a reefer in 5%. Thirty games passed on luck and stopped the moment the opening deal moved - // which cards a seed puts in reach. Deterministic seeds, so this either holds or it does not. + // THREE HUNDRED GAMES, up from a hundred, because the sheet's industry density (Gitea#14) makes + // the rare commodities much rarer. Measured on these exact seeds, the game at which each type is + // first set out by a crew: caboose 5, boxcar 12, hopper 37, reefer 44, coach 64, **tank 216**. + // + // A Refinery is one card in a hundred and fifty now, so a tank moving at all needs that card + // drawn, placed, reached and worked. The old sample of 100 stopped covering it — not because the + // rule broke, but because the deck did what the sheet asks. The sample follows the measurement + // rather than the assertion being softened: tank is still the strict test, for the reason below. + // Deterministic seeds, so this either holds or it does not. const dropped = new Set(); - for (let i = 0; i < 100; i++) { + for (let i = 0; i < 300; i++) { const s = createGame({ id: `cs-${i}`, seed: 1000 + i * 7919, @@ -998,6 +1031,12 @@ describe('the bot does not lay track that cannot work (regression)', () => { // A TIE-BREAKER rather than a veto, so this is a rate and not a zero: forbidding it outright // measured WORSE (-0.62 revenue a game), while preferring the cleaner of two equally good // placements measured better and cut these from 28% of pieces to 7%. + // + // AND IT STAYS A TIE-BREAKER. Gitea#15 was filed as "track placements must connect" and briefly + // became a rule here; RAR reversed it on review (2026-08-26) — a rail may stop dead against its + // neighbour, and such a stub is useful as a siding to park cars on. What the engine must refuse + // is a TRAIN crossing the gap, which is `exploreMoves`' job and is tested in `track.test.ts`. + // So laying one of these is a preference, exactly as it was, and the rate below is the bar. let laid = 0; let dead = 0; /** @@ -1094,8 +1133,14 @@ describe('the freight figures count both halves (regression)', () => { * The subject here is the INSTRUMENT — does `freightUnload` count Revenue earned rather than * unloads started — and `unloads > 0` is only the precondition that makes the comparison mean * anything. Widening the sample restores the precondition without weakening the assertion. + * + * A HUNDRED AND FIFTY DEALS, up from forty, for the same reason as the commodity test above: + * Gitea#14 put the deck on the sheet's industry density and completed unloads went with it. + * Measured on these seeds, the first deal to EARN unload Revenue is number **46**, and 19 deals + * in 400 earn any — so forty could not reach the precondition it exists to establish. 150 clears + * it with room, and the assertion itself is untouched. */ - for (let i = 0; i < 40; i++) { + for (let i = 0; i < 150; i++) { const seed = 1000 + i * 7919; const s = createGame({ id: `fu-${seed}`, seed, diff --git a/test/track.test.ts b/test/track.test.ts index a34dd36..57f8976 100644 --- a/test/track.test.ts +++ b/test/track.test.ts @@ -540,6 +540,100 @@ describe('placement and drop-off', () => { assert.ok(!canPlaceAt(area, at(-1, 1), straight()), 'an east-west straight cannot meet a 45° leg'); }); + describe('a rail that stops dead against its neighbour (Gitea#15)', () => { + /** + * REPORTED, THEN REVERSED. The issue first read "if a card is placed in that space, it MUST + * connect", against a right-hand curve laid at (1,-1) with an Ice House above it and a turnout + * with a north-facing leg below. **RAR reviewed it and ruled the other way (2026-08-26): the + * placement is fine, and a stub like that has a use — a siding to park cars on.** + * + * "We need to confirm, however, that trains are not allowed to traverse from the turnout below + * to that right-hand curve since the tracks do not connect." That is what these tests are: the + * rule lives in MOVEMENT, not in placement. + * + * The save cannot carry this any more — Gitea#14 took the deck from 206 cards to 121, so its + * card ids no longer exist and the history stops at the first `card.play`. The geometry is what + * mattered, and it is rebuilt here directly. + */ + /** A Modifier card — an Ice House. Not track: no ports on any edge. */ + const modifierCard = (): TrackCard => ({ + geometry: { kind: 'modifier', modifier: 'iceHouse' }, + baseOperationalRail: false, + standing: [], + standingWest: 0, + facility: null, + modifiers: [], + enhancements: [], + }); + + /** A Grocer's Warehouse — a Facility, so a plain east-west through track. */ + const warehouse = (): TrackCard => ({ + geometry: { kind: 'facility', facility: 'grocersWarehouse' }, + baseOperationalRail: true, + standing: [], + standingWest: 0, + facility: null, + modifiers: [], + enhancements: [], + }); + + /** + * The reported district. `withCurve` puts the disputed right-hand curve on the square; without + * it, the square is empty and the placement itself is under test. + * + * The turnout's leg goes NORTH on the `nw_se` diagonal; the curve is `ne`, which is `ne_sw` and + * has no south port at all. Two reasons the two do not join, either of which is enough. + */ + const board = (withCurve: boolean): OfficeArea => + areaFrom( + { + [coordKey(at(0, -1))]: turnout({ stem: 'w', through: 'e', diverge: 'n' }), + [coordKey(at(0, 0))]: officeCard(), + [coordKey(at(1, 0))]: warehouse(), + [coordKey(at(2, -1))]: modifierCard(), + ...(withCurve ? { [coordKey(at(1, -1))]: curve('ne') } : {}), + }, + at(0, 0), + ); + + it('allows the reported placement, which connects on one side and nothing else', () => { + // RAR's ruling. The curve joins the warehouse to its east; its north leg faces an Ice House + // that carries no rail, and the turnout below faces its portless south edge. All legal. + assert.ok(canPlaceAt(board(false), at(1, -1), curve('ne')), 'the reported play was refused'); + }); + + it('will not let a train cross from the turnout below onto that curve', () => { + // The confirmation the issue actually asks for. Running west out of the Office and into the + // turnout, the 45° leg goes north — and stops at the curve's blank south edge. + const dests = reachableDestinations(ctxFor(board(true)), at(0, 0), 'w'); + assert.ok(!has(dests, 1, -1), 'a train drove across rails that do not meet'); + }); + + it('still reaches the curve from the side that DOES join', () => { + // Otherwise the test above would pass on a card that is simply unreachable, which proves + // nothing. East of the curve is the warehouse, and east-west edges always meet. + const dests = reachableDestinations(ctxFor(board(true)), at(1, 0), 'w'); + assert.ok(has(dests, 1, -1), 'the curve was unreachable from the side that joins'); + }); + + it('will not let a train cross a north edge onto a card with no rail at all', () => { + // The Ice House above. A Modifier is scenery beside the rails — Jesse confirmed a rail may + // point at a building — so what stops a train is the same `joins` test, not a placement rule. + const dests = reachableDestinations(ctxFor(board(true)), at(1, 0), 'w'); + assert.ok(!has(dests, 2, -1), 'a train drove into an Ice House'); + }); + + it('still allows an exit that faces a BLANK square', () => { + // Unchanged by the reversal, and the reason a district can grow at all: a turnout laid on the + // Running Track with nothing yet beside its diverging leg is a perfectly good play. + const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0)); + assert.ok( + canPlaceAt(area, at(0, 1), turnout({ stem: 'w', through: 'e', diverge: 'n' })), + 'a turnout whose leg faces open space was refused', + ); + }); + }); + it('refuses a card that connects to nothing', () => { const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0)); assert.ok(!canPlaceAt(area, at(3, 3), straight()), 'orphaned track is never legal'); diff --git a/test/web.test.ts b/test/web.test.ts index 58be131..436c2ca 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -947,17 +947,29 @@ describe('the page explains itself', () => { // A played card becomes a cell with a name on it — "turnout", "Freight House", "waiting area" — // and the explanation that was visible while it sat in hand disappears exactly when it starts // mattering. Play a long way in so every card kind reaches the grid. - const game = newGame(111); - for (let i = 0; i < 400; i++) { - if (currentActor(game) === null) break; - const { options } = actionGroups(game); - if (options.length === 0) break; - const pick = options.find((o) => o.type === 'card.play' && o.placement) ?? options[0]!; - if (!submit(game, pick)) break; + /** + * THE SEED IS SEARCHED FOR, not written down. This took seed 111 flat and asserted its board + * ended up with more than four cards on it. Gitea#3 changed how fast trains cross, which changes + * how a game unfolds, and 111 stopped building enough of a district — so the test failed on its + * own precondition rather than on anything about tooltips. + * + * It needs A well-built board, not one particular one, so it takes the first seed that gives it. + */ + let game = newGame(111); + for (let seed = 111; seed < 211; seed++) { + game = newGame(seed); + for (let i = 0; i < 400; i++) { + if (currentActor(game) === null) break; + const { options } = actionGroups(game); + if (options.length === 0) break; + const pick = options.find((o) => o.type === 'card.play' && o.placement) ?? options[0]!; + if (!submit(game, pick)) break; + } + if (view(game).cells.length > 4) break; } const cells = view(game).cells; - assert.ok(cells.length > 4, 'not enough of the board was built to be a real check'); + assert.ok(cells.length > 4, 'no seed under 211 built enough of a board to be a real check'); for (const c of cells) { assert.ok(c.what.length > 0, `(${c.row},${c.col}) ${c.label} has no explanation`); // camelCase on the board is the failure that keeps recurring — labels AND descriptions. @@ -1586,7 +1598,40 @@ describe('the static build', () => { (target['onclick'] as (() => void) | null)?.(); return true; }; - assert.ok(clickVerb(hand, 'play'), 'no card in hand offers a play verb'); + /** + * TRY EVERY PLAY BUTTON, NOT JUST THE FIRST — a card offering "play" does not necessarily play + * ONTO THE BOARD. + * + * This clicked the first play verb it found and then asserted the board had lit up. A train card + * plays to the timetable and a Department discard to the piles, so neither lights a square, and + * whether the first playable card in this deal happens to be a track or facility card is luck. + * Gitea#14's deck counts re-dealt seed 555, the first play verb landed on Train 2, and the test + * failed claiming the page highlighted nothing — when the page was right and the card simply had + * no square to point at. + * + * So it clicks each play button in turn until the board lights, which is the property the test + * is named for. It still fails loudly if NO card in hand can light a square. + * + * EACH BUTTON IS CLICKED EXACTLY ONCE. Picking a card is a toggle, so clicking one to check it + * and then clicking it again inside the loop would UNPICK it — which is how the first draft of + * this managed to fail on a deal whose very first play card was a good one. + */ + const playButtons = (el: Record): Record[] => { + const fn = el['querySelectorAll'] as (s: string) => Record[]; + return fn + .call(el, 'button.cardact') + .filter((n) => (n['dataset'] as Record)['verb'] === 'play'); + }; + assert.ok(playButtons(hand).length > 0, 'no card in hand offers a play verb'); + let litTheBoard = false; + for (const target of playButtons(hand)) { + (target['onclick'] as (() => void) | null)?.(); + const drawn = String(grid['innerHTML']); + if ((grid['highlighted'] as () => unknown[])().length > 0 || /data-ghost="/.test(drawn)) { + litTheBoard = true; + break; + } + } // Without the board stylesheet every shape is drawn black on a near-black background: the page // looks empty even though the markup is perfect. @@ -1599,7 +1644,10 @@ describe('the static build', () => { assert.match(html, /data-cell="/, 'the board drew no addressable cards'); const lit = (grid['highlighted'] as () => unknown[])(); const ghosts = /data-ghost="/.test(html); - assert.ok(lit.length > 0 || ghosts, 'picking a card highlighted nothing on the board'); + assert.ok( + litTheBoard || lit.length > 0 || ghosts, + 'no card in hand, picked in turn, ever highlighted a square on the board', + ); /** * POINTING AT THE SQUARE A BUTTON MEANS — wired on the emitted bundle, not asserted off the menu. @@ -2762,18 +2810,44 @@ describe('the Division map shows the whole route', () => { } }); - it('seats 1 to 4 players without overlapping or spilling off the canvas', () => { - // A row, two facing rows, a horseshoe and a square. The layout is geometry with no visual - // feedback loop, so this is the only thing standing between a change and an unreadable board. + it('draws 1 to 4 players as ONE row, west to east, without overlapping or spilling', () => { + /** + * Gitea#18. This used to check "a row, two facing rows, a horseshoe and a square" — the route + * was laid out around a table, on the reasoning that players sit around one. It cost three + * reports, and the one that decided it was that **east stopped being to the right**: a player's + * east could be drawn south, west or north depending which lane their district landed in, on a + * map whose whole job is saying which way a train is going. + * + * So the property is now stronger and much simpler to state — every cell on one row, ordered + * west to east — which is exactly what makes "east is right" true and is the thing that would + * silently regress if anyone reintroduced lanes. The overlap and canvas checks are kept: the + * layout is geometry with no visual feedback loop. + */ for (const players of [1, 2, 3, 4]) { const svg = divisionFor(players); const vb = /viewBox="0 0 (\d+) (\d+)"/.exec(svg); assert.ok(vb, `${players}p produced no viewBox`); const W = Number(vb![1]); const H = Number(vb![2]); - const rects = [...svg.matchAll(/class="bs-dcell[^"]*"[^>]*> ({ x: +m[1]!, y: +m[2]!, w: +m[3]!, h: +m[4]! })); - assert.ok(rects.length >= 5, `${players}p drew only ${rects.length} cells`); + const rects = [...svg.matchAll(/class="bs-dcell bs-d(\w+)[^"]*"[^>]*> ({ kind: m[1]!, x: +m[2]!, y: +m[3]!, w: +m[4]!, h: +m[5]! })); + // WDP · (ML · Office) × players · ML · EDP — including the Mainline card before the East + // Division Point, which the issue's own sketch left out. + assert.equal(rects.length, 2 * players + 3, `${players}p drew ${rects.length} cells`); + assert.equal(rects[0]!.kind, 'dp', `${players}p does not start at a Division Point`); + assert.equal(rects[rects.length - 1]!.kind, 'dp', `${players}p does not end at a Division Point`); + assert.equal(rects[rects.length - 2]!.kind, 'ml', `${players}p has no Mainline card before the East DP`); + + // ONE ROW: every cell at the same y, and x strictly increasing. + const ys = new Set(rects.map((r) => r.y)); + assert.equal(ys.size, 1, `${players}p drew ${ys.size} rows — the Division must be one`); + for (let i = 1; i < rects.length; i++) { + assert.ok( + rects[i]!.x > rects[i - 1]!.x, + `${players}p: cell ${i} is not east of the one before it — east is no longer to the right`, + ); + } + for (let i = 0; i < rects.length; i++) { const a = rects[i]!; assert.ok( @@ -2789,6 +2863,52 @@ describe('the Division map shows the whole route', () => { } }); + it('draws no office-area detail on the Division map, but keeps the trains', () => { + // Gitea#18: "Division map should not show any office area detail (no limits, no running track, + // etc.)" — an Office used to expand into its whole Running Track, Limits to Limits, so this map + // carried every straight, turnout and Limits sign of every district and grew sideways as + // districts were built. One cell per district now. + // + // The trains stay: "trains within the office area should definitely be represented on the + // division map", split into the A/D register and the crews switching below it. + const s = createEngineGame({ + id: 'div-collapse', seed: 7, + config: { + mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, + }, + playerNames: ['Solitaire'], + }); + const area = areaOf(s, 0); + area.tier = 'terminal'; + const place = (id: string, n: number, coord: { row: number; col: number }, ad: boolean): void => { + s.trays.set(id, { + id, trainNumber: n, trainIsExtra: false, engineAt: 0, + consist: [{ type: 'boxcar', loaded: true }], + direction: 'east', facing: 'e', + position: { at: 'grid', seat: 0, coord }, movesUsed: 0, + } as never); + if (ad) area.adOccupancy.push(id); + }; + place('ad1', 9, area.officeCoord, true); + place('sw1', 7, { row: area.runningRow - 1, col: 0 }, false); + + const svg = divisionSvg(snapshot(s, [], null).division); + // ONE district cell, not one per Running Track card. + const districts = [...svg.matchAll(/class="bs-dcell bs-drun/g)].length; + assert.equal(districts, 1, `the district drew as ${districts} cells`); + assert.doesNotMatch(svg, /Limits/, 'a Limits sign reached the Division map'); + + // Both trains are on it, in two registers — the A/D one above the crew switching below. + const chips = [...svg.matchAll(/class="bs-train" data-tip="(T\d+)[^"]*"> ({ label: m[1]!, y: +m[2]! })); + const ad = chips.find((c) => c.label === 'T9'); + const sw = chips.find((c) => c.label === 'T7'); + assert.ok(ad, 'the train holding an A/D track is not on the map'); + assert.ok(sw, 'the crew switching in the district is not on the map'); + assert.ok(sw.y > ad.y, 'the switching crew should be drawn BELOW the A/D register, not beside it'); + }); + it('keeps every roster chip inside the Office cell it belongs to, at any occupancy', () => { // "Two Trains, One Card": sizing the cell by OCCUPANCY moved the East Division Point sideways // every time an A/D track filled or cleared. Sizing by CAPACITY (docs/plans/switching-paths.md)