diff --git a/CHANGELOG.md b/CHANGELOG.md index d0fd744..3a7759b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,100 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.4.9 — 2026-08-19 + +A playtest review of seed 58228926 (day 6), plus one long-standing display complaint and the first +real audio beyond a placeholder. Six changes: one display fix, two rules corrections, one engine bug +found while explaining a report rather than reported directly, three new sound cues, and the splash +page's box art. + +### Office coordinates read X,Y now, not Y,X + +Reported as confusing for anyone reading them the ordinary way: "at game start the office is 0,0; +the western limit should be -1,0, the eastern +1,0; a restaurant due north should be 0,1." The +engine's internal `GridCoord{row,col}` already has exactly that geometry — row increases north, col +increases east (`track.ts:59`) — so this was a **display-only fix**, not a data-model change: every +place a coordinate is shown to a player now prints `(col,row)` instead of `(row,col)`. The on-card +label in `board-svg.ts` (the one everyone sees), the switching-crew tooltip and button label in +`main.ts`, the rejected-option and switching-group text in `game.ts` and `view.ts`, and the +blocked-move text in `narrate.ts`. Internal `Map` keys built from `${row},${col}` are untouched — +nothing reads them but lookup code. + +### "No switching" now means no adding or dropping cars — not "never touch it" + +Reported from play: a train sitting at the Office with NO SWITCHING printed on its card (the two +expresses, Light Engine, Campaign, Circus, Military) could not be moved onto Secondary Track to +clear the mainline for other traffic, even though nothing on the card says that. `switchingRefusal()` +(`apply.ts`) blocked `switch.move` outright for these six cards; it now only refuses a move that +would COUPLE a fresh car — the same way `rules.dropOnly` was already handled for X13 — so the crew +can still be shunted clear, it just cannot pick up, set out or sort. `switch.dropCars` and +`switch.sortConsist` stay blocked. The switching-moves list (`view.ts`) no longer excludes these +trains, and the card description text was rewritten from "it runs the Division and does not shunt" +to "may not add or drop cars, but may still be moved clear of the mainline." + +### Q3, corrected: Expedite is about WHERE a train may be left, not WHEN it leaves + +**The reported symptom:** Train 1 arrived at an Office and was forced out at the end of the same +Stage — the read since v0.4.2 — leaving no Local Operations turn to switch it. **The rule question +underneath it:** why should "Expedite" mean a train is rushed out early at all, rather than simply +never being left parked off the station? + +Corrected to the latter. An expedited train now arrives and stands exactly like any other train, +released by the ordinary §8.1 "stood a full Stage at the Office" rule — it can be switched normally +in between. Deleted entirely: `arriveAtOffice`'s `departsThisStage` flag and the whole +expedited-departures pass that used to run in `shiftChange`, plus the now-dead `pass: 'mainline' | +'expedite'` parameter on `moveTrain`. + +In its place: a new fault. At the top of every Mainline Phase, any expedited train sitting on the +grid but not on the Office square itself — left on Secondary Track to clear a switching move, say — +costs 1 Revenue (`EXPEDITE_FAULT_PENALTY`, `expediteFault`), charged again every Phase it is still +caught there. A train the ordinary rules are legitimately holding AT the station — even one §8.1 is +asking the Superintendent to rule on — is not a fault; only being left elsewhere in the district is. + +**This resolves "3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE"** (`TODO.md`) as a side effect: the +Express can now actually reach a Local Operations turn to use its printed "may drop or pick up one +freight car at every location," which the old same-stage departure made structurally impossible. + +**Measured (30 fresh bot games, standard length):** the fault fires, but the bot has no strategy for +it yet — one game left Train 4 (3/4 Express) parked off the station from Day 3 Stage 10 to the end of +the game, taking the -1 penalty 26 times. Not an engine bug: the mechanism is working as designed and +correctly reveals that the bot doesn't know to bring an expedited train home before ending a +switching turn. Logged in `TODO.md` under Bot Performance for a future pass. + +### `evaluateClearance` now checks every occupant, not just the first one it finds + +Found while explaining a second playtest report from the same game: the Superintendent was asked to +rule on a same-direction train instead of being automatically held against an opposite-direction one +also occupying the card. Root cause: the occupant loop (`advance.ts`) returned on whichever occupant +it examined first, in `node.transits` insertion order — correct while a Subdivision held at most one +train, but a card can now legitimately hold two at once, which is exactly what the +Telegraph/Telephone/Radio dispatch exception creates (a facing train dispatched past another sits on +the same card as it). Fixed to two passes: every occupant is checked for an opposite-direction +absolute bar first (subject to the dispatch exception) before any same-direction judgment call is +offered. Pinned with a regression test that fails against the old single-pass code +(`enhancements.test.ts`). + +### Three more sounds, synthesised like the rest + +`arrive`, `depart` and `crash` join `stage`/`couple`/`drop`/`schedule`/`completed`/`day`/`train` in +`sound.ts` — a train pulling into an Office (decelerating chuffs, a soft bell tap), one highballing +out of one (accelerating chuffs, a falling horn note), and a collision (dissonant tones bent sharply +down over a noise boom and a sub-bass thump), wired to `trainArrived`, `trainHighballed` (Office +departures only, so it doesn't double up with a fresh make-up's "All aboard" or a full division run's +`completed`), and `trainsDestroyed`. Three real WAV clips were sourced for this and considered, but +carried no license information anywhere — no `LICENSE` file, no embedded metadata, no note of where +they came from — so they were not committed; the synthesised versions are good enough to keep as the +real thing rather than a placeholder. + +### The splash page has its box art + +`docs/StationMasterSplashScreen.png` (1122×1402, 3.0 MB) is now a 560×700 JPEG at 145 KB +(`public/images/`, copied verbatim into `dist/images/` by a small addition to `build-web.ts`) and +sits beside the title, tagline, blurb and both buttons in a side-by-side hero on `index.html`, +stacking to image-above-text under the existing mobile breakpoint. + +--- + ## 0.4.8 — 2026-08-19 Four reports from the same game, all about squares: which ones a card may go on, which one a button diff --git a/TODO.md b/TODO.md index ffcda71..b3e5321 100644 --- a/TODO.md +++ b/TODO.md @@ -3,19 +3,117 @@ Things worth coming back to. Anything noted here should either get done or get an explicit decision not to — the point is that nothing quietly evaporates. -Ordered within each section by how much it is currently costing us. +Grouped by what kind of work it is — Next, Replay/Save Games, Bot Performance, Play Balance, +Multiplayer, Rules Questions, Other — and ordered within each by how much it is currently costing us. +Reorganized 2026-08-20 from a flat list; nothing below changed, only where it lives. Two duplicate +entries (Heavy Grade orientation, the Local's coach) were merged into one each, and the industry-table +item that had been sitting in a "these are all done" section without actually being done was moved out +to Rules Questions. --- ## Next +Nothing scheduled at the moment — v0.4.9's plan (coordinate labels, the no-switching fix, the +expedite rewrite, the `evaluateClearance` bug, the splash artwork) is built; see Done below. + +--- + +## Replay / Save Games + +The replay viewer, the save format, and how a game gets shared. + +- [ ] **INVESTIGATE: how would a player publish a replay so other people can watch it?** Today + "Save replay" downloads a JSON file to the player's own machine, and the only way it reaches + the site is by sending it to Jesse to drop into `public/replays/` and redeploy. The question is + what a self-service version would look like. + + **The constraint.** The site is fully static — `dist/` is uploaded to File Browser and Start9 + Pages serves the folder — and the replay list is a build-time `manifest.json` because static + hosting cannot list a directory. So publishing needs something that accepts a write. + + **The one measurement that matters:** a full 5-Day game is **451–1017 bytes** compressed + (brotli), about **600–1150 characters** base64. A save is the seed plus the intents and the + engine recomputes the board, so a whole game fits in a URL. + + Four shapes, roughly costed: + 1. **Share by link, no server (~1–2 hours).** Put the compressed save in the URL fragment + (`replays.html#s=…`); "Share replay" copies a link and anyone opening it watches the game. + The viewer already parses saves and already has a file-open path, so this is compression, a + hash reader and a copy button. The fragment never reaches the host. It is a link rather than + a gallery: nobody discovers a game they were not sent. + 2. **A write endpoint (a day or two, and it is a service).** Accepts a POST, validates the save + by replaying it through the engine — `save-replay.ts` already does exactly that check — + writes the file and regenerates the manifest. The work is the surround: auth or rate + limiting, abuse handling for a public write, CORS, and a deploy story. It also ends "static + hosting is all this needs", which has been load-bearing. + 3. **Browser writes to File Browser directly — rejected.** It needs FB credentials in a static + page, so anyone viewing source gets write access to the whole File Browser, and the manifest + would need a read-modify-write from the browser that loses a save when two people publish at + once. + 4. **Curated, manual (zero code).** What happens today, and it composes with (1): players send + links, Jesse publishes the good ones. + + **The question behind the question is whether a gallery of strangers' games is wanted on a + personal StartOS box at all.** If it is, (1) is the piece (2) would need anyway, so it is the + right thing to build first either way. + +- [ ] **Review the standalone replay against the site's replay viewer.** `node src/sim/replay.ts + --seed 1234 --out replay.html` writes a self-contained HTML file; the site instead reads JSON + saves from `public/replays/`. Nothing links to the standalone one and its output is gitignored, + so it is a developer tool that happens to look like a product feature. It carries two panels + the site viewer does not — the bot's decision trace ("what it chose, why, and what it passed + over") and the timetable — which is debugging material rather than something a player wants. + Decide: fold the decision trace into the JSON viewer and delete the standalone, or keep it and + accept that it is a tool. No action for now. + +- [ ] **The yards are shown on the play page but not in either replay viewer.** The Frame carries + them, so it is a rendering job, not a modelling one. + +- [ ] **Undo is unlimited step-back, and that is a decision to revisit.** The save is the seed plus + the intents, so `undo()` replays without the last one and can walk all the way to the deal. The + RNG advances with the replay, so the same play re-rolls the same 1D12 — you cannot undo your + way to a better die. But you CAN see a train's departure Stage and then spend the turn + differently, which is an ordinary solitaire take-back and also a real information leak. Options + if it starts to feel like cheating: make the Stage boundary a commit point, or cap the depth at + the current Stage. Deliberately left open until it has been played with. Multiplayer gets + nothing until there is a proposal/agreement flow — undo there is a table decision, not a + button. + +- [ ] **The 5 MB replay size limit is arbitrary.** Invented, not a browser constraint. It has earned + its place — it caught a 5.2 MB payload that turned out to be the whole grid re-serialised every + frame — but the number itself deserves a reason. + +- [ ] **Save/restore is not version-aware.** A save from an older ruleset stops replaying rather than + failing loudly, which is the safe direction but says little about what changed. **This has now + bitten once**: both published replays were dead — one got 42 intents into 360, the other 4 of + 338 — and nothing said so; they simply ended early and looked like short games. A save should + carry a ruleset stamp and the page should say "this replay was recorded under an older + ruleset and stops at Stage N" rather than presenting a truncated game as a whole one. + +--- + +## Bot Performance + +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. + +- [ ] **THE BOT DOES NOT KNOW TO BRING AN EXPEDITED TRAIN BACK TO THE STATION — new in v0.4.9.** + The `expediteFault` mechanic (§7, Q3) charges 1 Revenue every Mainline Phase an expedited train + is left off the Office square, and the bot has no heuristic that accounts for it: measured over + 30 fresh games, one left Train 4 (3/4 Express) parked on Secondary Track from Day 3 Stage 10 to + the end of the game, drawing the fault **26 times**. Not an engine bug — the mechanism fires + exactly as designed — but a clear next bot heuristic: prefer ending a switching turn with any + expedited crew back on the Office square, at least once it has finished the work it went out for. - [ ] **THE BOT CANNOT SPOT A CAR AT A STUB INDUSTRY, and the cut-ordering rules made that visible.** Coupling is mandatory on your own square now (v0.4.7), so a crew that sets a car out *between itself and the only way out* picks it straight back up. At a stub industry that is every set-out the bot makes: its trains run engine-first with all four cars behind, so the tail cut always lands on the exit side. The correct play is §A.5's **facing point** move — couple the car onto the nose, shove it into the stub, set out off the nose, back away — which is the same - cross-turn planning already recorded as out of reach of any bot two items below. + cross-turn planning already recorded as out of reach of any bot in "THE RUN-AROUND IS OUT OF + REACH OF ANY BOT" below. Measured over 200 paired seeds: **-0.55 revenue** (t = -3.63) and freight revenue 1.11 → 0.56. Filtering self-recoupling moves out of the bot's options took recoupling from **625 of 1,029 @@ -23,84 +121,6 @@ Ordered within each section by how much it is currently costing us. the game models the difficulty correctly and the bot cannot yet play it — but **every revenue figure in this file measured before v0.4.7 is now low by roughly half a point** and the rebalance pass should not read the drop as a deck problem. -- [ ] **WHERE THE LOCAL'S COACH STANDS WHILE ITS ENGINE WORKS (§A.4) — now hit in play, still open.** - Trains 7/8 print "coach must remain on station track if switching", read as "the coach is never - set out". A cut comes off an OUTER end, so a coach on one outer end with the engine on the - other locks the train completely: it cannot set its freight car out, and cannot uncouple to run - around either, because that leaves the coach standing. Measured over 60 games — **1,181 - positions where a set-out should have been possible, every one refused; no other train blocked - once.** Two of the six possible arrangements lock, and `ENGINE boxcar coach` — the one that - locks — is both prototypical and what make-up naturally produces. - - **Worked around, not solved.** The make-up panel now tells the player to add the coach first - (v0.4.6), which produces `ENGINE coach boxcar` and works. The rules question is untouched: if - the coach may be set out **at the Office**, which is what the card's wording plainly says and - what a real mixed train does, then the prototypical make-up works and the advice becomes - unnecessary. That needs one exception to §A.4's blanket refusal to leave Rolling Stock at the - Office, for the coach and only on the Local. -- [ ] **3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE — Jesse's call.** The card says *"may drop or pick - up one freight car at every location"* and also prints **Expedite**. Expedite means the train - departs the Stage it arrives (Q3): it arrives in the Mainline phase, stands through Cargo, and - highballs in Supervisor Shift — so it is never on the board during a Local Operations phase, - which is the only phase in which freight is coupled or set out. Measured over 40 bot games: - **31 Office visits, 31 of them with no Local Operations turn.** X14 Fruit Growers Express is in - the same position, though its "may pick up one extra loaded reefer" is only a note today. - - The four options put to Jesse, unchanged: drop Expedite from 3/4 only (the other Expedite - trains all print "no switching" and lose nothing); leave Q3 alone and strike the freight line - from the card; drop Expedite everywhere (it partly exists to relieve Crew Tray scarcity, so - this needs re-measuring); or move the Express's freight budget into the Cargo phase, where an - expedited train does still get a turn. **Nothing is broken** — this is a contradiction between - two lines on one card, and the timing rule itself is behaving exactly as recorded. -- [ ] **A DISTRICT CAN NOW ONLY WIDEN AS FAR AS ITS MAIN REACHES (v0.4.8) — worth watching in the - rebalance rather than acting on now.** Track stays inside the Limits at every row, so extending - the Running Track is the only way to buy room for sidings, and a straight laid on the sign is - worth more than it was. The bot barely notices — it built outside its own Limits 5 times in 100 - games — but the bot also builds close to its Office; a human building deliberately hits this on - the first wide district, which is how it was reported. If territory turns out to be the real - constraint on freight, this is one of the two places to look (the other is the track supply, - below). -- [ ] **REBALANCE, once the rules are right — deliberately deferred.** Card counts, industry counts - and the track mix all need a pass together, and none of them should move until the rules stop - moving. Standing distortions to account for when it happens: offices are doubled (Q12) and - industries tripled (Gap 12), both tuned when the deck held 139 cards and **no track**; it now - holds 235 of which 96 are track, so every draw is diluted by 41% — precisely the pressure - those multipliers exist to relieve. The 8 sharp curves have already been taken out on that - argument; offices and industries are the two left. Until then, read no balance conclusion from the revenue - numbers; they are a functionality signal only. -- [ ] **RE-MEASURE THE BOT AT THE NEW DEFAULTS.** Both provisional rules below are now **settings on - the New Game dialog** rather than fixed choices, and the defaults are not what the numbers in - this file were measured under: the opening hand defaults to **three random cards** (the - prototype rule) rather than 3+3, and **train revenue per transit defaults to 0** rather than 1. - That second one is the big move — it was worth ~5.4 of a 7.0 mean, so the bot's revenue should - fall to roughly the working freight-and-passenger economy alone, which is the number this game - has actually been trying to read all along. Every mean, floor and threshold quoted below and in - the tests predates it. The three revenue rates run 0–5, so the useful next step is a sweep - rather than a single re-run. -- [ ] **REVIEW THE TWO NEW RULES ONCE THEY HAVE BEEN PLAYED — both went in provisional, and both are - now selectable rather than fixed.** Jesse's call, both implemented and measured, both flagged - in `rules-v0.2.md`. What follows is what was measured when each was the only option. - - **The opening deal (3 track + 3 other, from two separately shuffled piles).** It did what it - was aimed at, modestly: run-arounds **4/60 → 7/60** and districts **17.9 → 20.3 cards**, with - revenue unmoved on its own (−0.1, inside noise). Still nowhere near the 91/100 of the - private-supply era, so the supply question is softened rather than answered. Two things to - watch at the table: whether opening with six against a limit of three is a real decision or - just bookkeeping, and whether three is the right number of each. - - **~~One Revenue for every train that clears your section.~~ Now: one Revenue to EVERY player - when a train completes its run.** Jesse's revision in v0.4.2. The first version paid the Office - a train departed, which on a five-Office railroad paid five separate times for one train and - paid most to whoever it passed first. It pays once now, when the train runs off the end of the - Division, and it pays the whole table — getting a train the length of the railroad is the - shared achievement, and every Office it crossed had to clear it. - Solitaire is nearly unmoved (7.0 → 7.3 mean over 200 games) because one player's departures and - completions run at almost the same rate; **in a multi-player game the shape is completely - different** and needs measuring once multiplayer exists — N players × 1 per completed run - against the old N payments per train. **The victory-target question stays live**: 20 over 5 Days - is still reachable largely on traffic, which is either the intent or an argument for raising it - — and at the new default of 0 per transit it is not reachable on traffic at all, which is the - first thing a playtest should check. - [ ] **BOT DRIFT ACROSS THIS RELEASE — four measurements, all for the rebalance pass.** Recorded together so the pattern is visible rather than four relaxed thresholds nobody adds up: - **Switching work down ~16%, 1.76 → 1.48 productive acts a game** (400 games), because an @@ -120,18 +140,11 @@ Ordered within each section by how much it is currently costing us. rather than the every-game waste the detector was written for. Each floor was moved to match what is measured, with the reasoning written into the test. None is a crisis on its own; together they say the bot spends its openings worse than it did. -- [ ] **Review the standalone replay against the site's replay viewer.** `node src/sim/replay.ts - --seed 1234 --out replay.html` writes a self-contained HTML file; the site instead reads JSON - saves from `public/replays/`. Nothing links to the standalone one and its output is gitignored, - so it is a developer tool that happens to look like a product feature. It carries two panels - the site viewer does not — the bot's decision trace ("what it chose, why, and what it passed - over") and the timetable — which is debugging material rather than something a player wants. - Decide: fold the decision trace into the JSON viewer and delete the standalone, or keep it and - accept that it is a tool. No action for now. - [ ] **The bot was partly living off an illegal placement.** Barring curves from the Running Track (they have no east-west road and dead-end the main) cost it districts 28.0 → 19.7 cards and revenue ~2.0 → 0.8. It has no plan for where a curve should go once the easy square is gone. - Same root cause as the two items below; fix them together, after the rebalance. + Same root cause as "the bot cannot get a crew next to an industry" and "THE RUN-AROUND IS OUT + OF REACH OF ANY BOT" below; fix them together, after the rebalance. - [ ] **THE BOT'S PRIORITIES ARE NOT THE PROBLEM — measured.** Ten heuristic variations, each paired over 400+ seeds. Every reordering of what the bot prefers came out inside the noise; the only thing that moved revenue was refusing to schedule a train the Office cannot hold @@ -185,6 +198,372 @@ Ordered within each section by how much it is currently costing us. present it that way — the supply has to change, not the player.** Options: give track its own hand or yard the way the prototype did, raise the hand limit for track specifically, or print a siding as a single card. Nothing else reaches it. +- [ ] **Re-run the three "worth ~0" action-mix experiments against the new floor.** Capping the draw, + pairing the two halves of a load, and restricting Enhancements were each measured "within noise + of zero" over 400 games — but at 400 games the standard error is ±0.33, so a real +0.5 would + have looked like nothing. They are nearly free to re-run now and at least one may have been + discarded wrongly. + +--- + +## Play Balance + +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. + +- [ ] **A DISTRICT CAN NOW ONLY WIDEN AS FAR AS ITS MAIN REACHES (v0.4.8) — worth watching in the + rebalance rather than acting on now.** Track stays inside the Limits at every row, so extending + the Running Track is the only way to buy room for sidings, and a straight laid on the sign is + worth more than it was. The bot barely notices — it built outside its own Limits 5 times in 100 + games — but the bot also builds close to its Office; a human building deliberately hits this on + the first wide district, which is how it was reported. If territory turns out to be the real + constraint on freight, this is one of the two places to look (the other is the track supply, + in Bot Performance). +- [ ] **REBALANCE, once the rules are right — deliberately deferred.** Card counts, industry counts + and the track mix all need a pass together, and none of them should move until the rules stop + moving. Standing distortions to account for when it happens: offices are doubled (Q12) and + industries tripled (Gap 12), both tuned when the deck held 139 cards and **no track**; it now + holds 235 of which 96 are track, so every draw is diluted by 41% — precisely the pressure + those multipliers exist to relieve. The 8 sharp curves have already been taken out on that + argument; offices and industries are the two left. Until then, read no balance conclusion from the revenue + numbers; they are a functionality signal only. +- [ ] **RE-MEASURE THE BOT AT THE NEW DEFAULTS.** Both provisional rules below are now **settings on + the New Game dialog** rather than fixed choices, and the defaults are not what the numbers in + this file were measured under: the opening hand defaults to **three random cards** (the + prototype rule) rather than 3+3, and **train revenue per transit defaults to 0** rather than 1. + That second one is the big move — it was worth ~5.4 of a 7.0 mean, so the bot's revenue should + fall to roughly the working freight-and-passenger economy alone, which is the number this game + has actually been trying to read all along. Every mean, floor and threshold quoted below and in + the tests predates it. The three revenue rates run 0–5, so the useful next step is a sweep + rather than a single re-run. +- [ ] **REVIEW THE TWO NEW RULES ONCE THEY HAVE BEEN PLAYED — both went in provisional, and both are + now selectable rather than fixed.** Jesse's call, both implemented and measured, both flagged + in `rules-v0.2.md`. What follows is what was measured when each was the only option. + + **The opening deal (3 track + 3 other, from two separately shuffled piles).** It did what it + was aimed at, modestly: run-arounds **4/60 → 7/60** and districts **17.9 → 20.3 cards**, with + revenue unmoved on its own (−0.1, inside noise). Still nowhere near the 91/100 of the + private-supply era, so the supply question is softened rather than answered. Two things to + watch at the table: whether opening with six against a limit of three is a real decision or + just bookkeeping, and whether three is the right number of each. + + **~~One Revenue for every train that clears your section.~~ Now: one Revenue to EVERY player + when a train completes its run.** Jesse's revision in v0.4.2. The first version paid the Office + a train departed, which on a five-Office railroad paid five separate times for one train and + paid most to whoever it passed first. It pays once now, when the train runs off the end of the + Division, and it pays the whole table — getting a train the length of the railroad is the + shared achievement, and every Office it crossed had to clear it. + Solitaire is nearly unmoved (7.0 → 7.3 mean over 200 games) because one player's departures and + completions run at almost the same rate; **in a multi-player game the shape is completely + different** and needs measuring once multiplayer exists — N players × 1 per completed run + against the old N payments per train. **The victory-target question stays live**: 20 over 5 Days + is still reachable largely on traffic, which is either the intent or an argument for raising it + — and at the new default of 0 per transit it is not reachable on traffic at all, which is the + first thing a playtest should check. +- [ ] **The marginal Local Operations action is worth ~0, and that is the real ceiling.** Three + separate attempts to spend the 60 actions better — capping the draw, pairing the two halves of + a load, restricting Enhancements — each measured within noise of zero over 400 paired seeds. + 76% of the time an outbound industry has neither a stocked green box nor a spotted car, and + only 5% of Stages have a single workable facility anywhere, yet redirecting actions at that + does nothing. Something upstream limits how much work exists to do at all; find out what + before spending more effort on the option mix. +- [ ] **Freight was stuck at ~2.7 loads a game and three fixes have not moved it.** Sidings, + facility placement, car selection and the discarded-load leak all raised revenue (3.2 → 6.5) + without raising `loadStarted` past 2.7. The chain is not leaking and the cars are arriving + correctly (57% of drops land on a facility that wants them, 0% on one that does not). The + binding constraint is now upstream of routing: 60 Local Operations actions a game, and a load + needs a stocked green box AND a spotted car AND a free Laborer to line up in the same Stage. + Measure how many Stages have all three before changing any heuristic — the answer may be that + the economy, not the bot, is what caps freight. +- [ ] **The rolling stock supply is a guess.** `ROLLING_STOCK_SUPPLY` (coach 8+8, boxcar 10+10, + hopper 8+8, reefer 5+5, tank 6+6, caboose 6) is marked provisional in `content.ts` and was + scaled alongside the Gap 12 industry increase. Now that the Classification Yard returns stock + only when the Division Yard empties, these numbers set the real supply pressure. Adjust from + playtesting rather than theory, and watch whether industry density feels light or heavy at the + same time. +- [ ] **Office card density** (Depot 4→8, Station 2→4, Terminal 1→2). Chosen to remove a 25% chance + of an unwinnable opening deal. Blunt: it lifts the whole ladder and dilutes every other + category. The better answer may be fewer Terminals, a cheaper first upgrade, or more A/D + capacity at the Whistle Post itself. +- [ ] **Industry density** (9 → 27, Gap 12). Restored roughly the prototype ratio. The "freight is + only 13–18% of gross" figure that motivated this was partly a measurement bug (see the + `stats.ts` item in Done) and partly the car-selection bug; freight now runs at 37%. Worth + re-deciding whether 27 is still the right number now that the industries are actually served. +- [ ] **Train density.** Left alone by decision, but noted: 22 train cards in 140 are drawn less often + than 22 in 115 were, and trains scheduled fell 2.9 → 2.1 as a side effect of the other density + changes. +- [ ] **The victory target (20 over 5 Days) is out of reach by a factor of about four, and the + Office ladder is why.** Measured over 800 games with the tuned bot, which no longer throws + revenue away on collisions (0.0 a game, down from 0.4): + + | trains scheduled | games | revenue | | Office reached | games | trains | revenue | + |---|---|---|---|---|---|---|---| + | 0 | 110 | 0.67 | | Whistle Post | 297 | 0.81 | 0.62 | + | 1 | 379 | 1.69 | | Depot | 272 | 1.49 | 2.92 | + | 2 | 234 | 3.72 | | Station | 176 | 1.89 | 4.36 | + | 3 | 68 | 5.68 | | Terminal | 55 | 1.93 | 5.04 | + | 4 | 9 | 5.78 | | | | | | + + Revenue is almost exactly linear in trains scheduled — about **1.9 a train** — and trains are + capped by A/D capacity, which is the Office tier, which is a card you have to draw. So the + whole economy hangs off one valve: **37% of games never leave the Whistle Post and earn 0.62; + 53% of all games earn nothing at all.** + + Extrapolating the line, 20 Revenue needs roughly **11 trains and therefore 11 A/D tracks**. A + Terminal has four. The target is not merely missed, it is structurally unreachable under this + deck at this Office ladder — no amount of bot skill closes it, and the best game seen in 800 + was 26 against a median of 0. + + The three ways out are all yours to choose between, and they are different games: + 1. **Lower the target** to what a 5-Day game can produce (6–8 looks like the honest number). + 2. **Open the valve** — more Office cards, or a cheaper first upgrade, or more A/D capacity at + the Whistle Post, so the ladder is climbed rather than drawn. + 3. **Raise revenue per arrival.** It is 0.46 today; each arrival can in principle pay 2 for + passengers alone. That is the freight/passenger conversion problem, not the traffic problem. + + Nothing here is a bot weakness any more, which is what this measurement was waiting on. + +--- + +## Multiplayer + +Deferred while planning the server; decisions and reasoning are in `docs/architecture/multiplayer.md`. + +- [ ] **Let the game join a call and talk to the table.** Long-term. If the game could join a Zoom, + Teams or Jitsi call and post into its chat, it could carry the whole table's shared state + without anyone alt-tabbing: the history of actions as they happen, and a prompt when someone + is holding the game up — "Now waiting on player Alice to complete the Cargo phase." + - Further out, audio into the same call: a crash when a collision happens, a bell as the Stage + clock turns over. + - Further out still, a nudge on a timer — if a player has not moved within some interval, the + game says so, by beep or by spoken line: "Still waiting on Alice to complete the Cargo + phase." That turns the turn chart's "waiting on" chip into something a distracted table + actually notices. +- [ ] **Multiplayer train make-up is a round, not one player's job.** When a new train is built, + players take turns adding cars to the consist; in solitaire one player does all of it. The + engine currently has no per-player turn within the New Train phase, so this is unbuilt rather + than wrong. +- [ ] **MULTIPLAYER — three things deliberately deferred while planning the server.** Decisions and + reasoning are in `docs/architecture/multiplayer.md` §11; these are the ones left open. + - **Bots should take minimally damaging, defensive actions when a player steps away**, so a + game is not permanently halted. Deliberately NOT automatic today: a turn timer forfeiting is + different from a bot competing, and the clearance ruling is the one decision that changes + another player's score. Bots fill empty seats at lobby time only (D8). + - **Let a player resign and hand their railroad to a bot** to finish. Same care needed as + above, but it is consented rather than imposed. + - **A forcing turn timer — explicitly NOT in the design.** `lobby-and-sessions.md` §5 used to + specify one: on expiry the server took "the safest legal action", including denying a + clearance. Cut in the review, because it is the same objection as a bot playing for an absent + player — the clearance decision changes somebody else's score, so anything that answers it + automatically changes the game. Explore later if halted games turn out to be a real problem + at a real table; the reasoning worth keeping is that **deny** is the safe default, since a + held train costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor. + - **~~The opening D12 for the Eastern Division Point (§4.4) decides nothing.~~ Done in + v0.4.1** — it orders the whole chain now, west to east by ascending roll. The lobby still owes + it a display: `state.openingRolls` is kept so clients can show the rolls forming the chain + rather than only the result (`lobby-and-sessions.md` §4). + - **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create + and join. Enough for a private box, probably not enough if `stationmaster.` is + pointed at the open internet for long. Note that one-game-at-a-time per person is expected + usage and deliberately NOT enforced — enforcing it needs cross-game state whose only job is + deciding when to release someone, and getting that wrong locks a player out. +- [ ] **WHY DOES A 4-PLAYER COMPETITIVE GAME END AFTER ~16 STAGES OF A POSSIBLE 60?** Measured while + sizing multiplayer: 8 games, all reaching Day 5, but only ~16 distinct (day, stage) pairs each + and ~355 intents. Most likely the collision or revenue floor (§3.4) firing early, which would + make a competitive game about an hour rather than four. Worth knowing whether that is the + design working or a balance bug — it decides what a lobby should tell players about length. +- [ ] **THE 22 OPPONENT-DIRECTED CARDS — 10 Action, 12 Space-use — ARE OUT OF EVERY DECK UNTIL THEY + ARE BUILT.** Jesse's call. They were already cut from solitaire (Q6, no legal target with one + player); they are now cut from the competitive deck too, because `checkPlay` answers both + categories `NOT_IMPLEMENTED` and dealing them would make ~9% of draws reject outright. Flip + `opponentCardsInDeck` in `setup.ts` when they land. They are played AT another player — + Watertower, Derail, Railroad Crossing and so on — so they are genuinely multiplayer work, and + **three Enhancements are waiting on them**: Facing Point Locks, Water Column and Overpass are + wired and read, and fire only against these cards. Until then those three are dormant by + design rather than broken. +- [ ] **Multiplayer proper — Phases 0 and 1 done (v0.4.0), Phases 2–6 to go.** The full plan is + `docs/architecture/multiplayer.md` §12. The engine now has seat/player separation and + per-player turn state, the page renders from `Frame` + `Menu` alone and talks to a `Session` + rather than to the engine — so a `RemoteSession` can be dropped in without the page changing. + Still no server, no turn submission and no per-player push: that is Phase 2, and it is + deliberately held until the two provisional rules have been playtested, because a rule change + after the wire format is live is much more expensive than one before it. + +--- + +## Rules Questions + +Blocked on a decision, not on work. + +- [ ] **WHERE THE LOCAL'S COACH STANDS WHILE ITS ENGINE WORKS (§A.4) — now hit in play, still open.** + Trains 7/8 print "coach must remain on station track if switching", read as "the coach is never + set out". A cut comes off an OUTER end, so a coach on one outer end with the engine on the + other locks the train completely: it cannot set its freight car out, and cannot uncouple to run + around either, because that leaves the coach standing. Measured over 60 games — **1,181 + positions where a set-out should have been possible, every one refused; no other train blocked + once.** Two of the six possible arrangements lock, and `ENGINE boxcar coach` — the one that + locks — is both prototypical and what make-up naturally produces. + + **Worked around, not solved.** The make-up panel now tells the player to add the coach first + (v0.4.6), which produces `ENGINE coach boxcar` and works. The rules question is untouched: if + the coach may be set out **at the Office**, which is what the card's wording plainly says and + what a real mixed train does, then the prototypical make-up works and the advice becomes + unnecessary. That needs one exception to §A.4's blanket refusal to leave Rolling Stock at the + Office, for the coach and only on the Local. **Decision needed:** should the Office square — or + a station track beside it — accept a parked coach? +- [ ] **3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE — Jesse's call.** The card says *"may drop or pick + up one freight car at every location"* and also prints **Expedite**. Expedite means the train + departs the Stage it arrives (Q3): it arrives in the Mainline phase, stands through Cargo, and + highballs in Supervisor Shift — so it is never on the board during a Local Operations phase, + which is the only phase in which freight is coupled or set out. Measured over 40 bot games: + **31 Office visits, 31 of them with no Local Operations turn.** X14 Fruit Growers Express is in + the same position, though its "may pick up one extra loaded reefer" is only a note today. + + The four options put to Jesse, unchanged: drop Expedite from 3/4 only (the other Expedite + trains all print "no switching" and lose nothing); leave Q3 alone and strike the freight line + from the card; drop Expedite everywhere (it partly exists to relieve Crew Tray scarcity, so + this needs re-measuring); or move the Express's freight budget into the Cargo phase, where an + expedited train does still get a turn. **Nothing is broken** — this is a contradiction between + two lines on one card, and the timing rule itself is behaving exactly as recorded. + + **Should be resolved as a side effect of the Expedite fix in Next**, once built: correcting + Q3 so an expedited train gets an ordinary Local Operations turn removes the contradiction + without picking any of the four options above. Leaving this open until that lands and is + confirmed in play. +- [ ] **THE INDUSTRY TABLE STILL DISAGREES WITH THE CARD REFERENCE — two items left, Jesse's call.** + v0.4.7 corrected the DIRECTIONS: the Grocer's Warehouse and the Oil Refinery are `flow: 'both'`, + as `card-reference.md` always said, which is what let an Ice House finally give a Grocer's its + outbound slot. Two discrepancies remain and both are deliberate for now. + **(a) Base capacities.** The reference prints Grocer's 2/2 with 2 Laborers and the Refinery 2/2 + with 3; the engine gives every industry 1 per direction it allows, Mine Tipple included. Raising + one alone would be a balance change rather than a correction. + **(b) The Freight House card.** The reference is explicit — "'Freight House' is not a card. It is + the collective term for a freight facility that loads *and* unloads" — and the engine deals 6 + copies of one. Removing them is a deck-composition change worth measuring, not a quiet delete. +- [ ] **Poling.** The only card in the deck with no defined behaviour — the sheet records its effect + as "TBD in the source". A test asserts it stays TBD so nobody invents one. +- [ ] **Heavy Grade orientation is rolled, not chosen.** The card prints "Player sets orientation", + but it is dealt during setup and setup has no decision point at all — `createGame` is a pure + function of the seed, which is also what makes a save portable. Rolled from the seed for now. + Revisit when setup gains an interactive phase; the orientation matters, because it decides + which direction climbs and therefore what Brakeman and Helpers are worth. + +--- + +## Other + +Doesn't fit the above. + +- [ ] **Engines are not a SUPPLY yet, only a position.** `engineAt` now records where the engine + sits in the tray and the consist shows it, but an engine is still conjured with the tray + rather than drawn from the Division Yard and returned to it. The rules put engines in the + Division Yard alongside the cars, with a predefined number of them, so running out of engines + should be a second way trains get held — today only the Crew Tray count does that. Needs a + number to start from, then playtesting. +- [ ] **Real audio, as committed assets.** Everything the game plays is synthesised from oscillators + (`src/web/sound.ts`), which was the honest choice for a site that fetches nothing — but it is a + placeholder, not the finished sound. Sound therefore defaults to OFF. + - **"All aboard" most of all.** It currently goes through the browser's `speechSynthesis`, so + it is whatever system voice the player happens to have — a robot, not a conductor. A real + clip is the single biggest improvement available here. + - `arrive` (a train pulling into an Office), `depart` (a train highballing out of one) and + `crash` (§10 — a collision) are now synthesised too, v0.4.9 — three chuffing/screeching cues + built from the same oscillator-and-filtered-noise toolkit as `stage`, wired to `trainArrived`, + `trainHighballed` (Office departures only), and `trainsDestroyed`. Good enough to keep as the + real thing rather than a placeholder — no WAV clips needed for these three. + - **Find and add the rest as assets**: steam whistle, grade-crossing bell, couplers clashing. + Needs licences that permit redistribution (CC0 or similar), files small enough to commit, and + a check that the "fetches nothing external" test still passes — assets must be served from the + site's own folder, never hot-linked. + - Keep the synthesised versions as the fallback for anything not sourced, so a missing file is + a quieter game rather than a broken one. +- [ ] **Regions as the primary model (the other half of §8.2).** The Division map now DRAWS regions, + deriving position from what the crossing already cost. The engine still models a crossing as a + countdown of Stages, so two things printed on the cards remain unimplemented: + - `entryPoints` is declared on every Mainline profile and read nowhere. The Heavy Grade card + has five named Start positions, and playing Brakeman is supposed to move your entry point + along the card. The engine gets the same ANSWER by taking a Stage off the crossing, which is + why the derived drawing looks right — but the mechanism is not the printed one, so a card + whose starts do not correspond to its speed would be drawn wrong. + - `implications.md` §6 calls this "the single largest mechanical gap" and asks for typed cards + with speeds and named entries, with crossing time DERIVED from the region walk. + Doing it properly changes movement, so it invalidates every balance figure — revenue 8.7, the + freight numbers, all of it — and needs a full paired re-measure over 400 seeds. Needs the + source Start-position art for the ten card types before it can begin. +- [ ] **Player settings, saved.** The district's auto-focus is the first of these: it is DISPLAY + state, so in a multiplayer game two players may reasonably want it set differently and it must + never become part of game state. It currently resets on reload. Worth a settings object in + localStorage — auto-focus mode to start with, and whatever else earns a preference — kept + strictly separate from the save, which is the seed plus the intents and has to stay portable. +- [ ] **The test suite fails at random under `npm test`, and it is the runner rather than the code.** + `node --test test/**/*.test.ts` runs the files in parallel and three suites write and read the + same `dist/` — the static build, the published-replay check and "the three places a game is + drawn stay in step". Back-to-back full runs measured **9 failures then 0**; run one file at a + time and every suite passes. That is worse than a slow suite: it trains us to shrug at a red + run, which is exactly how a real regression gets waved through. Give the build test its own + output directory, or mark the trio to run serially. +- [ ] **Curves are drawn as two straight segments meeting**, not true arcs. Fine at this size, angular + close up. +- [ ] **Wide boards scroll.** A 40-card district and a 13-section Division both need horizontal + scrolling. Legible, not compact. + +--- + +## Done, kept for the reasoning + +- [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, + and it was ranked THIRD and predicted not to matter because the Division Yard never runs dry. + The aggregate was the wrong measure; having the right commodity at the right moment is what + counts. +- [x] **Make Enhancements reachable at all.** The bot never laid a straight on the Running Track + (0.00 in 100 games) because two-arc run-arounds do not need one — so 13 of the 18 Enhancement + cards had nowhere to go, including Interlocking, the only cure for the only penalty in the + game (`no free A/D track`, 27% of gross). One scored straight fixed it: enhancements placed + 0.64 → 3.01, collision cost 2.70 → 1.91, worst game −47 → −24. Revenue +0.70 ± 0.74 paired + over 400 seeds — real but not significant alone; the variance reduction is the clearer win. +- [x] **Stop the bot discarding its own freight.** `canStockProductively` did not check the Division + Yard while the engine's `stockOutbound` does, so Freight Agent was chosen when nothing could be + stocked and the follow-through fell through to an unjam that threw a waiting load out of the + green box — 3.10 a game against 2.71 started. Now 0.00. Revenue 6.0 → 6.5, wins 5 → 8 in 100. + Also confirmed **routing was never the problem**: 0% of drops land on a facility that does not + want the car. +- [x] **Why switching work did not become Revenue.** Answered: it was the freight the crew shuffled, + not the shuffling. The chain never leaked — 95% of started loads finished — it was barely + entered, because a load needs a matching empty car spotted and half the industries never asked + for one. Three fixes later (sidings, facility placement, car selection) revenue is 3.2 → 6.0 + and freight 26% → 37% of gross. +- [x] **Fix car selection.** Three of six industries were invisible to `wantedCars` — a hand-written + industry→car map naming two industries that do not exist and omitting three that do — so tank + cars were dropped **0 times in 100 games**. Derived from `INDUSTRY_PROFILES` now, and the + second commodity of the two-commodity industries is reachable. Revenue 5.0 → 6.0, freight + share 25% → 37%, wins 1 → 5 in 100. +- [x] **Put the industries on the run-around.** Facility placement was unscored — the first legal + square — so 0.00 facilities a game sat on a loop; now 1.08. The instructive part was the + second bug: scoring facilities onto the siding row dropped run-arounds 91→36, because the + anchor test asked a card's KIND rather than its PORTS and an industry in the line read as a + dead end. Revenue 4.1 → 5.0. Freight did **not** follow, which is the item above. +- [x] **Make the bot build sidings that are sidings.** 0 run-arounds in 100 games → 91. Three bugs, + all scoring on local shape without checking it reached anything; the decisive one was that + `bestTrackLay` never declined a piece, so it spent the track supply on whatever was legal. +- [x] **Teach the bot what a siding is for.** Nose coupling (§A.3) implemented, so approach direction + decides which car is droppable; the bot runs around rather than setting out, when the drop can + follow. Switching activity transformed, revenue unchanged. +- [x] **Curve geometry.** Curves were topologically identical duplicates of turnouts, and nothing + reached north, so a district could only be a vertical column. Now two-port rotatable arcs. +- [x] **Q10 — when track may be laid.** During the "draw a card" option, one piece a turn. Track was a + card when §6.2 was written; a 26-piece supply has no hand to bound it. +- [x] **Q11 — which way a Heavy Grade climbs.** Answered from the card: it prints "(Up)" and "Player + sets orientation", so it is a property of the placed card, not a compass constant. +- [x] **§6.2's reshuffle.** Implemented, and the `deckReshuffled` event it had already declared and + narrated — but never emitted or reduced — is now real. Not yet reached in play: solitaire + Campaign ends with 168.8 of 243 in the deck and four-player Campaign with 86.6, and no run of + any length has emptied it. It is a safety net rather than a live mechanic today, which is worth + knowing before tuning draw rates. +- [x] **Q12 — Whistle Post lock-in.** Players always start at a Whistle Post; office density doubled + instead. - [x] **~~CLEARING AN INBOUND BOX MINTS A CAR — measured at 1.29 a game against a supply of 80.~~ Re-audited in v0.4.3: rolling stock is EXACTLY CONSERVED, 100 games out of 100, range 0..0.** The old audit's premise was right — the two directions were not symmetrical — but the asymmetry @@ -212,44 +591,19 @@ Ordered within each section by how much it is currently costing us. pins the unreduced set so that closing the gap later is a deliberate act, and asserts the property that does hold. **If you ever do make the phase driver reduce, that test fails and tells you which docs now understate the engine.** -- [ ] **Engines are not a SUPPLY yet, only a position.** `engineAt` now records where the engine - sits in the tray and the consist shows it, but an engine is still conjured with the tray - rather than drawn from the Division Yard and returned to it. The rules put engines in the - Division Yard alongside the cars, with a predefined number of them, so running out of engines - should be a second way trains get held — today only the Crew Tray count does that. Needs a - number to start from, then playtesting. -- [ ] **The yards are shown on the play page but not in either replay viewer.** The Frame carries - them, so it is a rendering job, not a modelling one. -- [ ] **The rolling stock supply is a guess.** `ROLLING_STOCK_SUPPLY` (coach 8+8, boxcar 10+10, - hopper 8+8, reefer 5+5, tank 6+6, caboose 6) is marked provisional in `content.ts` and was - scaled alongside the Gap 12 industry increase. Now that the Classification Yard returns stock - only when the Division Yard empties, these numbers set the real supply pressure. Adjust from - playtesting rather than theory, and watch whether industry density feels light or heavy at the - same time. -- [ ] **Heavy Grade orientation is rolled, not chosen.** The card prints "Player sets orientation", - but it is dealt during setup and setup has no decision point at all — `createGame` is a pure - function of the seed, which is also what makes a save portable. Rolled from the seed for now. - Revisit when setup gains an interactive phase; the orientation matters, because it decides - which direction climbs and therefore what Brakeman and Helpers are worth. - -- [x] ~~**Measure with error bars from now on.**~~ Built: `node src/sim/compare.ts 1600 =` +- [x] **Measure with error bars from now on.** Built: `node src/sim/compare.ts 1600 =` runs the current bot and one variant over the same deals and reports the paired difference. Pairing drops σ from ~9 on the level to **5.3 on the difference**, so 1600 seeds gives ±0.13 in about 1m45s — the noise floor is now ~±0.15 rather than ±1.0. Threshold to keep a heuristic is **t ≥ 3**, and the report prints the better/worse/identical split beside the mean, because a mean carried by a skewed tail is a different claim from broad improvement. -- [ ] **Re-run the three "worth ~0" action-mix experiments against the new floor.** Capping the draw, - pairing the two halves of a load, and restricting Enhancements were each measured "within noise - of zero" over 400 games — but at 400 games the standard error is ±0.33, so a real +0.5 would - have looked like nothing. They are nearly free to re-run now and at least one may have been - discarded wrongly. -- [x] ~~**Confirm the Classification Yard rule against the source.**~~ Confirmed, and the guess was +- [x] **Confirm the Classification Yard rule against the source.** Confirmed, and the guess was wrong. The rule is: used Rolling Stock to the Classification Yard, used engines and cabooses straight back to the Division Yard, and the Classification Yard empties ONLY when the Division Yard is bare — then all at once. The Day-boundary version I had invented was far more generous and worth **+2.42 revenue a game the game does not actually grant**. Corrected; revenue 9.67 → 7.25. -- [x] ~~**Enhancements are placed but mostly do nothing.**~~ Measured: forbidding every Enhancement +- [x] **Enhancements are placed but mostly do nothing.** Measured: forbidding every Enhancement except Interlocking is worth **-0.01 ± 0.41 (t = -0.04)** over 400 paired seeds. They neither pay nor cost. Left alone. Unlocking the Running Track straight put nine kinds on the board (telegraph 0.73, waterColumn 0.57 …), but only Interlocking has a @@ -257,32 +611,9 @@ Ordered within each section by how much it is currently costing us. dispatching facing trains, which may be worth nothing in solitaire; Water Column removes a Watertower; Facing Point Locks prevents Derail, which is multiplayer-only. Worth measuring what each is actually worth before the bot spends actions on them. -- [ ] **The marginal Local Operations action is worth ~0, and that is the real ceiling.** Three - separate attempts to spend the 60 actions better — capping the draw, pairing the two halves of - a load, restricting Enhancements — each measured within noise of zero over 400 paired seeds. - 76% of the time an outbound industry has neither a stocked green box nor a spotted car, and - only 5% of Stages have a single workable facility anywhere, yet redirecting actions at that - does nothing. Something upstream limits how much work exists to do at all; find out what - before spending more effort on the option mix. -- [ ] **Freight was stuck at ~2.7 loads a game and three fixes have not moved it.** Sidings, - facility placement, car selection and the discarded-load leak all raised revenue (3.2 → 6.5) - without raising `loadStarted` past 2.7. The chain is not leaking and the cars are arriving - correctly (57% of drops land on a facility that wants them, 0% on one that does not). The - binding constraint is now upstream of routing: 60 Local Operations actions a game, and a load - needs a stocked green box AND a spotted car AND a free Laborer to line up in the same Stage. - Measure how many Stages have all three before changing any heuristic — the answer may be that - the economy, not the bot, is what caps freight. -- [x] ~~**`stats.ts` undercounts freight.**~~ Fixed: both halves counted, freight share 39% → 49%. - Worth revisiting the **industry density** decision below, which was taken on the old number. - ---- - -## From playtesting, 2026-08-12 - -Jesse played and reported nine things. **All of them are now done** — the entries are kept because -each carries the decision behind it, and two of the nine turned out not to be bugs. The measurements -and what went wrong on the way are in `CHANGELOG.md`. - +- [x] **`stats.ts` undercounts freight.** Fixed: both halves counted, freight share 39% → 49%. + Worth revisiting the **industry density** decision in Play Balance, which was taken on the old + number. - [x] **LEFT AND RIGHT ARE ON THE WRONG DIAGONAL — for turnouts and for curves, the same way.** The engine's `left` turnout is `{stem:'w', through:'e', diverge:'s'}`: a train entering at the points from the west heads east and the diverging route leaves to its **right**. The engine's @@ -367,19 +698,9 @@ and what went wrong on the way are in `CHANGELOG.md`. are wired but dormant in solitaire; **Overpass alone has no code path at all**. The shipped tooltip briefly told players four working cards did nothing, which is worse than the bare label it replaced — `enhancements.test.ts` had passing tests for all four the whole time. -- [ ] **THE INDUSTRY TABLE STILL DISAGREES WITH THE CARD REFERENCE — two items left, Jesse's call.** - v0.4.7 corrected the DIRECTIONS: the Grocer's Warehouse and the Oil Refinery are `flow: 'both'`, - as `card-reference.md` always said, which is what let an Ice House finally give a Grocer's its - outbound slot. Two discrepancies remain and both are deliberate for now. - **(a) Base capacities.** The reference prints Grocer's 2/2 with 2 Laborers and the Refinery 2/2 - with 3; the engine gives every industry 1 per direction it allows, Mine Tipple included. Raising - one alone would be a balance change rather than a correction. - **(b) The Freight House card.** The reference is explicit — "'Freight House' is not a card. It is - the collective term for a freight facility that loads *and* unloads" — and the engine deals 6 - copies of one. Removing them is a deck-composition change worth measuring, not a quiet delete. -- [x] **~~A modifier's grant can land on a direction its host cannot use, and nothing says so.~~ - THE "NO BUG" VERDICT BELOW WAS WRONG, and v0.4.7 corrected it.** The reasoning was that a - Grocer's Warehouse is inbound-only. It is not — the card reference says "Both" — so the grant +- [x] **A modifier's grant can land on a direction its host cannot use, and nothing says so — + corrected in v0.4.7.** The earlier "no bug" verdict below was wrong. The reasoning had been that + a Grocer's Warehouse is inbound-only. It is not — the card reference says "Both" — so the grant was being dropped on a direction the facility should have had. Reported again in play as "grocer's warehouse didn't get extra outbound slot for truck dock". The suppression machinery itself was right and is kept: it still fires for a passenger Modifier beside a Whistle Post, @@ -399,314 +720,51 @@ and what went wrong on the way are in `CHANGELOG.md`. on the board needed), and the panel's "prints N, Modifiers add M" line showing a suppressed grant as suppressed instead of quietly omitting it. That delta display already cites the Ice House as the bug that motivated it. - ---- - -## Open questions for Jesse - -Blocked on a decision, not on work. - -- [x] ~~**Q13 — rear-end collisions on a Mainline card.**~~ Answered: collide on catching up. +- [x] **Q13 — rear-end collisions on a Mainline card.** Answered: collide on catching up. Implemented, and not on cards that print "trains may pass". Invisible to a bot that always denies clearance; a bot that always allows drops from 7.34 revenue to **-5.13**. -- [x] **~~Nine of the twelve special-train rules are declared and read by nothing.~~** Done — all +- [x] **Nine of the twelve special-train rules are declared and read by nothing.** Done — all nine enforced, and one of them deleted instead. `copiesNextScheduled` was never carried by any train card: a Second Section is a Maneuver with its own working intent, so the flag was an unreachable second description of an existing mechanic. Cost 0.8 revenue and half the wins (8.0 → 7.2, 14/200 → 6/200), which is what enforcing restrictions does. -- [ ] **THE LOCAL'S COACH HAS NOWHERE TO STAND, AND THAT IS A §A.4 QUESTION.** Trains 7/8 print - "coach must remain on station track if switching", which should mean the coach is set out at - the station while the engine works. It cannot be: §A.4 refuses the Office square to every drop - ("Rolling Stock may not be left there"), so "only at the Office" and "nowhere" are the same - rule. It is enforced as "the coach is never set out", which keeps the Local from abandoning it - at an industry but loses the drop-and-collect pattern a real local works. **Decision needed: - should the Office square — or a station track beside it — accept a parked coach?** That is a - change to §A.4, not to the train card, and it would also give the A/D tracks something to do. -- [ ] **Poling.** The only card in the deck with no defined behaviour — the sheet records its effect - as "TBD in the source". A test asserts it stays TBD so nobody invents one. -- [ ] **Heavy Grade orientation at setup.** The card says "Player sets orientation", but `createGame` - is synchronous and has no decision point, so it is currently rolled from the seed. Should become - a real choice when setup gains an interactive phase. - ---- - -## Balance, provisional - -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. - -- [ ] **Office card density** (Depot 4→8, Station 2→4, Terminal 1→2). Chosen to remove a 25% chance - of an unwinnable opening deal. Blunt: it lifts the whole ladder and dilutes every other - category. The better answer may be fewer Terminals, a cheaper first upgrade, or more A/D - capacity at the Whistle Post itself. -- [ ] **Industry density** (9 → 27, Gap 12). Restored roughly the prototype ratio. The "freight is - only 13–18% of gross" figure that motivated this was partly a measurement bug (see the - `stats.ts` item) and partly the car-selection bug; freight now runs at 37%. Worth re-deciding - whether 27 is still the right number now that the industries are actually served. -- [ ] **Train density.** Left alone by decision, but noted: 22 train cards in 140 are drawn less often - than 22 in 115 were, and trains scheduled fell 2.9 → 2.1 as a side effect of the other density - changes. -- [ ] **The victory target (20 over 5 Days) is out of reach by a factor of about four, and the - Office ladder is why.** Measured over 800 games with the tuned bot, which no longer throws - revenue away on collisions (0.0 a game, down from 0.4): - - | trains scheduled | games | revenue | | Office reached | games | trains | revenue | - |---|---|---|---|---|---|---|---| - | 0 | 110 | 0.67 | | Whistle Post | 297 | 0.81 | 0.62 | - | 1 | 379 | 1.69 | | Depot | 272 | 1.49 | 2.92 | - | 2 | 234 | 3.72 | | Station | 176 | 1.89 | 4.36 | - | 3 | 68 | 5.68 | | Terminal | 55 | 1.93 | 5.04 | - | 4 | 9 | 5.78 | | | | | | - - Revenue is almost exactly linear in trains scheduled — about **1.9 a train** — and trains are - capped by A/D capacity, which is the Office tier, which is a card you have to draw. So the - whole economy hangs off one valve: **37% of games never leave the Whistle Post and earn 0.62; - 53% of all games earn nothing at all.** - - Extrapolating the line, 20 Revenue needs roughly **11 trains and therefore 11 A/D tracks**. A - Terminal has four. The target is not merely missed, it is structurally unreachable under this - deck at this Office ladder — no amount of bot skill closes it, and the best game seen in 800 - was 26 against a median of 0. - - The three ways out are all yours to choose between, and they are different games: - 1. **Lower the target** to what a 5-Day game can produce (6–8 looks like the honest number). - 2. **Open the valve** — more Office cards, or a cheaper first upgrade, or more A/D capacity at - the Whistle Post, so the ladder is climbed rather than drawn. - 3. **Raise revenue per arrival.** It is 0.46 today; each arrival can in principle pay 2 for - passengers alone. That is the freight/passenger conversion problem, not the traffic problem. - - Nothing here is a bot weakness any more, which is what this measurement was waiting on. - ---- - -## Not yet built - -- [ ] **INVESTIGATE: how would a player publish a replay so other people can watch it?** Today - "Save replay" downloads a JSON file to the player's own machine, and the only way it reaches - the site is by sending it to Jesse to drop into `public/replays/` and redeploy. The question is - what a self-service version would look like. - - **The constraint.** The site is fully static — `dist/` is uploaded to File Browser and Start9 - Pages serves the folder — and the replay list is a build-time `manifest.json` because static - hosting cannot list a directory. So publishing needs something that accepts a write. - - **The one measurement that matters:** a full 5-Day game is **451–1017 bytes** compressed - (brotli), about **600–1150 characters** base64. A save is the seed plus the intents and the - engine recomputes the board, so a whole game fits in a URL. - - Four shapes, roughly costed: - 1. **Share by link, no server (~1–2 hours).** Put the compressed save in the URL fragment - (`replays.html#s=…`); "Share replay" copies a link and anyone opening it watches the game. - The viewer already parses saves and already has a file-open path, so this is compression, a - hash reader and a copy button. The fragment never reaches the host. It is a link rather than - a gallery: nobody discovers a game they were not sent. - 2. **A write endpoint (a day or two, and it is a service).** Accepts a POST, validates the save - by replaying it through the engine — `save-replay.ts` already does exactly that check — - writes the file and regenerates the manifest. The work is the surround: auth or rate - limiting, abuse handling for a public write, CORS, and a deploy story. It also ends "static - hosting is all this needs", which has been load-bearing. - 3. **Browser writes to File Browser directly — rejected.** It needs FB credentials in a static - page, so anyone viewing source gets write access to the whole File Browser, and the manifest - would need a read-modify-write from the browser that loses a save when two people publish at - once. - 4. **Curated, manual (zero code).** What happens today, and it composes with (1): players send - links, Jesse publishes the good ones. - - **The question behind the question is whether a gallery of strangers' games is wanted on a - personal StartOS box at all.** If it is, (1) is the piece (2) would need anyway, so it is the - right thing to build first either way. - -- [ ] **Real audio, as committed assets.** Everything the game plays is synthesised from oscillators - (`src/web/sound.ts`), which was the honest choice for a site that fetches nothing — but it is a - placeholder, not the finished sound. Sound therefore defaults to OFF. - - **"All aboard" most of all.** It currently goes through the browser's `speechSynthesis`, so - it is whatever system voice the player happens to have — a robot, not a conductor. A real - clip is the single biggest improvement available here. - - **Find and add the rest as assets**: steam whistle, grade-crossing bell, couplers clashing, - a train pulling away. Needs licences that permit redistribution (CC0 or similar), files small - enough to commit, and a check that the "fetches nothing external" test still passes — assets - must be served from the site's own folder, never hot-linked. - - Keep the synthesised versions as the fallback for anything not sourced, so a missing file is - a quieter game rather than a broken one. - -- [ ] **Regions as the primary model (the other half of §8.2).** The Division map now DRAWS regions, - deriving position from what the crossing already cost. The engine still models a crossing as a - countdown of Stages, so two things printed on the cards remain unimplemented: - - `entryPoints` is declared on every Mainline profile and read nowhere. The Heavy Grade card - has five named Start positions, and playing Brakeman is supposed to move your entry point - along the card. The engine gets the same ANSWER by taking a Stage off the crossing, which is - why the derived drawing looks right — but the mechanism is not the printed one, so a card - whose starts do not correspond to its speed would be drawn wrong. - - `implications.md` §6 calls this "the single largest mechanical gap" and asks for typed cards - with speeds and named entries, with crossing time DERIVED from the region walk. - Doing it properly changes movement, so it invalidates every balance figure — revenue 8.7, the - freight numbers, all of it — and needs a full paired re-measure over 400 seeds. Needs the - source Start-position art for the ten card types before it can begin. - -- [ ] **Player settings, saved.** The district's auto-focus is the first of these: it is DISPLAY - state, so in a multiplayer game two players may reasonably want it set differently and it must - never become part of game state. It currently resets on reload. Worth a settings object in - localStorage — auto-focus mode to start with, and whatever else earns a preference — kept - strictly separate from the save, which is the seed plus the intents and has to stay portable. - -- [ ] **Let the game join a call and talk to the table.** Long-term. If the game could join a Zoom, - Teams or Jitsi call and post into its chat, it could carry the whole table's shared state - without anyone alt-tabbing: the history of actions as they happen, and a prompt when someone - is holding the game up — "Now waiting on player Alice to complete the Cargo phase." - - Further out, audio into the same call: a crash when a collision happens, a bell as the Stage - clock turns over. - - Further out still, a nudge on a timer — if a player has not moved within some interval, the - game says so, by beep or by spoken line: "Still waiting on Alice to complete the Cargo - phase." That turns the turn chart's "waiting on" chip into something a distracted table - actually notices. -- [ ] **Multiplayer train make-up is a round, not one player's job.** When a new train is built, - players take turns adding cars to the consist; in solitaire one player does all of it. The - engine currently has no per-player turn within the New Train phase, so this is unbuilt rather - than wrong. - -- [ ] **MULTIPLAYER — three things deliberately deferred while planning the server.** Decisions and - reasoning are in `docs/architecture/multiplayer.md` §11; these are the ones left open. - - **Bots should take minimally damaging, defensive actions when a player steps away**, so a - game is not permanently halted. Deliberately NOT automatic today: a turn timer forfeiting is - different from a bot competing, and the clearance ruling is the one decision that changes - another player's score. Bots fill empty seats at lobby time only (D8). - - **Let a player resign and hand their railroad to a bot** to finish. Same care needed as - above, but it is consented rather than imposed. - - **A forcing turn timer — explicitly NOT in the design.** `lobby-and-sessions.md` §5 used to - specify one: on expiry the server took "the safest legal action", including denying a - clearance. Cut in the review, because it is the same objection as a bot playing for an absent - player — the clearance decision changes somebody else's score, so anything that answers it - automatically changes the game. Explore later if halted games turn out to be a real problem - at a real table; the reasoning worth keeping is that **deny** is the safe default, since a - held train costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor. - - **~~The opening D12 for the Eastern Division Point (§4.4) decides nothing.~~ Done in - v0.4.1** — it orders the whole chain now, west to east by ascending roll. The lobby still owes - it a display: `state.openingRolls` is kept so clients can show the rolls forming the chain - rather than only the result (`lobby-and-sessions.md` §4). - - **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create - and join. Enough for a private box, probably not enough if `stationmaster.` is - pointed at the open internet for long. Note that one-game-at-a-time per person is expected - usage and deliberately NOT enforced — enforcing it needs cross-game state whose only job is - deciding when to release someone, and getting that wrong locks a player out. -- [ ] **WHY DOES A 4-PLAYER COMPETITIVE GAME END AFTER ~16 STAGES OF A POSSIBLE 60?** Measured while - sizing multiplayer: 8 games, all reaching Day 5, but only ~16 distinct (day, stage) pairs each - and ~355 intents. Most likely the collision or revenue floor (§3.4) firing early, which would - make a competitive game about an hour rather than four. Worth knowing whether that is the - design working or a balance bug — it decides what a lobby should tell players about length. -- [ ] **THE 22 OPPONENT-DIRECTED CARDS — 10 Action, 12 Space-use — ARE OUT OF EVERY DECK UNTIL THEY - ARE BUILT.** Jesse's call. They were already cut from solitaire (Q6, no legal target with one - player); they are now cut from the competitive deck too, because `checkPlay` answers both - categories `NOT_IMPLEMENTED` and dealing them would make ~9% of draws reject outright. Flip - `opponentCardsInDeck` in `setup.ts` when they land. They are played AT another player — - Watertower, Derail, Railroad Crossing and so on — so they are genuinely multiplayer work, and - **three Enhancements are waiting on them**: Facing Point Locks, Water Column and Overpass are - wired and read, and fire only against these cards. Until then those three are dormant by - design rather than broken. -- [ ] **Multiplayer proper — Phases 0 and 1 done (v0.4.0), Phases 2–6 to go.** The full plan is - `docs/architecture/multiplayer.md` §12. The engine now has seat/player separation and - per-player turn state, the page renders from `Frame` + `Menu` alone and talks to a `Session` - rather than to the engine — so a `RemoteSession` can be dropped in without the page changing. - Still no server, no turn submission and no per-player push: that is Phase 2, and it is - deliberately held until the two provisional rules have been playtested, because a rule change - after the wire format is live is much more expensive than one before it. - ---- - -## Smaller things - - [x] **Carry `links` forward in replay frames.** Done, and the premise was wrong in an instructive way: measured, `links` was 5% of the `cells` payload. What actually cost was the `what` prose (32%), the facility object stored a second time inside its own cell (24%) and the rest of the static identity (26%). All three are interned now — 3415 KB → 1877 KB, and a round-trip test runs the page's own unpacking function. -- [ ] **Undo is unlimited step-back, and that is a decision to revisit.** The save is the seed plus - the intents, so `undo()` replays without the last one and can walk all the way to the deal. The - RNG advances with the replay, so the same play re-rolls the same 1D12 — you cannot undo your - way to a better die. But you CAN see a train's departure Stage and then spend the turn - differently, which is an ordinary solitaire take-back and also a real information leak. Options - if it starts to feel like cheating: make the Stage boundary a commit point, or cap the depth at - the current Stage. Deliberately left open until it has been played with. Multiplayer gets - nothing until there is a proposal/agreement flow — undo there is a table decision, not a - button. -- [ ] **The 5 MB replay size limit is arbitrary.** Invented, not a browser constraint. It has earned - its place — it caught a 5.2 MB payload that turned out to be the whole grid re-serialised every - frame — but the number itself deserves a reason. -- [ ] **The test suite fails at random under `npm test`, and it is the runner rather than the code.** - `node --test test/**/*.test.ts` runs the files in parallel and three suites write and read the - same `dist/` — the static build, the published-replay check and "the three places a game is - drawn stay in step". Back-to-back full runs measured **9 failures then 0**; run one file at a - time and every suite passes. That is worse than a slow suite: it trains us to shrug at a red - run, which is exactly how a real regression gets waved through. Give the build test its own - output directory, or mark the trio to run serially. -- [ ] **Curves are drawn as two straight segments meeting**, not true arcs. Fine at this size, angular - close up. -- [ ] **Wide boards scroll.** A 40-card district and a 13-section Division both need horizontal - scrolling. Legible, not compact. -- [x] ~~**Every published replay was dead.**~~ All three replayed **2 intents of roughly 400** and - presented as short games, exactly as the item below predicted. Re-recorded from bot games with - `node src/sim/save-replay.ts`, which verifies each save round-trips before writing it, and - `harness.test.ts` now fails if a published replay stops short. The version-stamp item below is - still worth doing — this catches the breakage, it does not explain it to a player. -- [ ] **Save/restore is not version-aware.** A save from an older ruleset stops replaying rather than - failing loudly, which is the safe direction but says little about what changed. **This has now - bitten once**: both published replays were dead — one got 42 intents into 360, the other 4 of - 338 — and nothing said so; they simply ended early and looked like short games. A save should - carry a ruleset stamp and the page should say "this replay was recorded under an older - ruleset and stops at Stage N" rather than presenting a truncated game as a whole one. - ---- - -## Done, kept for the reasoning - -- [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, - and it was ranked THIRD and predicted not to matter because the Division Yard never runs dry. - The aggregate was the wrong measure; having the right commodity at the right moment is what - counts. -- [x] **Make Enhancements reachable at all.** The bot never laid a straight on the Running Track - (0.00 in 100 games) because two-arc run-arounds do not need one — so 13 of the 18 Enhancement - cards had nowhere to go, including Interlocking, the only cure for the only penalty in the - game (`no free A/D track`, 27% of gross). One scored straight fixed it: enhancements placed - 0.64 → 3.01, collision cost 2.70 → 1.91, worst game −47 → −24. Revenue +0.70 ± 0.74 paired - over 400 seeds — real but not significant alone; the variance reduction is the clearer win. -- [x] **Stop the bot discarding its own freight.** `canStockProductively` did not check the Division - Yard while the engine's `stockOutbound` does, so Freight Agent was chosen when nothing could be - stocked and the follow-through fell through to an unjam that threw a waiting load out of the - green box — 3.10 a game against 2.71 started. Now 0.00. Revenue 6.0 → 6.5, wins 5 → 8 in 100. - Also confirmed **routing was never the problem**: 0% of drops land on a facility that does not - want the car. -- [x] **Why switching work did not become Revenue.** Answered: it was the freight the crew shuffled, - not the shuffling. The chain never leaked — 95% of started loads finished — it was barely - entered, because a load needs a matching empty car spotted and half the industries never asked - for one. Three fixes later (sidings, facility placement, car selection) revenue is 3.2 → 6.0 - and freight 26% → 37% of gross. -- [x] **Fix car selection.** Three of six industries were invisible to `wantedCars` — a hand-written - industry→car map naming two industries that do not exist and omitting three that do — so tank - cars were dropped **0 times in 100 games**. Derived from `INDUSTRY_PROFILES` now, and the - second commodity of the two-commodity industries is reachable. Revenue 5.0 → 6.0, freight - share 25% → 37%, wins 1 → 5 in 100. -- [x] **Put the industries on the run-around.** Facility placement was unscored — the first legal - square — so 0.00 facilities a game sat on a loop; now 1.08. The instructive part was the - second bug: scoring facilities onto the siding row dropped run-arounds 91→36, because the - anchor test asked a card's KIND rather than its PORTS and an industry in the line read as a - dead end. Revenue 4.1 → 5.0. Freight did **not** follow, which is the item above. -- [x] **Make the bot build sidings that are sidings.** 0 run-arounds in 100 games → 91. Three bugs, - all scoring on local shape without checking it reached anything; the decisive one was that - `bestTrackLay` never declined a piece, so it spent the track supply on whatever was legal. -- [x] **Teach the bot what a siding is for.** Nose coupling (§A.3) implemented, so approach direction - decides which car is droppable; the bot runs around rather than setting out, when the drop can - follow. Switching activity transformed, revenue unchanged. -- [x] **Curve geometry.** Curves were topologically identical duplicates of turnouts, and nothing - reached north, so a district could only be a vertical column. Now two-port rotatable arcs. -- [x] **Q10 — when track may be laid.** During the "draw a card" option, one piece a turn. Track was a - card when §6.2 was written; a 26-piece supply has no hand to bound it. -- [x] **Q11 — which way a Heavy Grade climbs.** Answered from the card: it prints "(Up)" and "Player - sets orientation", so it is a property of the placed card, not a compass constant. -- [x] **§6.2's reshuffle.** Implemented, and the `deckReshuffled` event it had already declared and - narrated — but never emitted or reduced — is now real. Not yet reached in play: solitaire - Campaign ends with 168.8 of 243 in the deck and four-player Campaign with 86.6, and no run of - any length has emptied it. It is a safety net rather than a live mechanic today, which is worth - knowing before tuning draw rates. -- [x] **Q12 — Whistle Post lock-in.** Players always start at a Whistle Post; office density doubled - instead. +- [x] **Every published replay was dead.** All three replayed **2 intents of roughly 400** and + presented as short games, exactly as the item below it (now in Replay / Save Games) predicted. + Re-recorded from bot games with `node src/sim/save-replay.ts`, which verifies each save + round-trips before writing it, and `harness.test.ts` now fails if a published replay stops + short. The version-stamp item in Replay / Save Games is still worth doing — this catches the + breakage, it does not explain it to a player. +- [x] **Coordinate labels read Y,X on the board — v0.4.9.** Now X,Y everywhere a coordinate is shown + to a player: the on-card label (`board-svg.ts`), the switching-crew tooltip and button + (`main.ts`), the rejected-option and switching-group text (`game.ts`, `view.ts`), and the + blocked-move text (`narrate.ts`). Display-only — `GridCoord{row,col}` already had the right + geometry (row increases north, col increases east), and internal `Map` keys are untouched. +- [x] **"No switching" blocked moving a train clear of the mainline — v0.4.9.** The six no-switching + cards (both expresses, Light Engine, Campaign, Circus, Military) mean may not add or drop cars, + not may never be touched. `switch.move` now refuses only a move that would couple a fresh car — + the same way `dropOnly` was already handled — so these trains can still be shunted onto + Secondary Track to clear the mainline. `switch.dropCars` and `switch.sortConsist` stay blocked. +- [x] **Q3 corrected: Expedite governs WHERE a train may stand, not WHEN it leaves — v0.4.9.** The + forced same-Stage departure (`departsThisStage`, the `shiftChange` expedite pass) is gone; an + expedited train now arrives and is released like any other train, switchable in between. New + fault instead: left off the Office square when a Mainline Phase begins, it costs 1 Revenue + (`expediteFault`, `EXPEDITE_FAULT_PENALTY`), every Phase it is still caught there. Resolved + "3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE" as a side effect — it can now reach the Local + Operations turn its printed freight rule needs. Revealed a bot gap instead: logged above under + Bot Performance. +- [x] **`evaluateClearance` checked only the first occupant it found — v0.4.9.** Found while + explaining a playtest report: the Superintendent was asked to rule on a same-direction train + instead of being held against an opposite-direction one also on the card, because the loop + returned on whichever occupant it examined first rather than checking all of them — invisible + until the Telegraph/Telephone/Radio exception made it possible for a card to hold two trains at + once. Now checks every occupant for an absolute bar before offering any judgment call. Pinned + with a test that fails against the old single-pass code. +- [x] **The splash page now shows the box art — v0.4.9.** `docs/StationMasterSplashScreen.png` + (3.0 MB) resized to a 145 KB JPEG (`public/images/`, copied into the build by `build-web.ts`) + and placed beside the title, tagline, blurb and both buttons in a side-by-side hero, stacking + to image-above-text on mobile. diff --git a/docs/StationMasterSplashScreen.png b/docs/StationMasterSplashScreen.png new file mode 100644 index 0000000..8adb60b Binary files /dev/null and b/docs/StationMasterSplashScreen.png differ diff --git a/package.json b/package.json index 7dbb2de..ce8cbd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.4.8", + "version": "0.4.9", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/public/images/station-master-splash.jpg b/public/images/station-master-splash.jpg new file mode 100644 index 0000000..01fd2ed Binary files /dev/null and b/public/images/station-master-splash.jpg differ diff --git a/scripts/build-web.ts b/scripts/build-web.ts index 73bee41..4e133ba 100644 --- a/scripts/build-web.ts +++ b/scripts/build-web.ts @@ -10,7 +10,7 @@ */ import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -151,6 +151,17 @@ if (existsSync(replaySrc)) { } writeFileSync(join(replayOut, 'manifest.json'), JSON.stringify(manifest, null, 1)); +/** + * Static images — the splash artwork and whatever joins it. Copied verbatim, unlike the replays + * above: an image needs no validation, only a place in `dist/` to be served from. + */ +const imageSrc = join(root, 'public/images'); +if (existsSync(imageSrc)) { + const imageOut = join(dist, 'images'); + mkdirSync(imageOut, { recursive: true }); + for (const f of readdirSync(imageSrc)) copyFileSync(join(imageSrc, f), join(imageOut, f)); +} + // A tiny note for whoever unzips this later and wonders what it needs. writeFileSync( join(dist, 'README.txt'), diff --git a/src/engine/advance.ts b/src/engine/advance.ts index 42ac6a4..2419d6b 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -18,6 +18,7 @@ import { COLLISION_PENALTY, + EXPEDITE_FAULT_PENALTY, MAINLINE_PROFILES, enhancementRule, crossingStages, @@ -307,6 +308,38 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult { return d !== 0 ? d : Number(a.trainIsExtra) - Number(b.trainIsExtra); }); + /** + * Q3 — A STATION MASTER FAULT: an expedited train not at the station when a Mainline Phase begins + * was not kept ready to highball, wherever in the district it has been left — switched onto + * Secondary Track to clear a move, say. Checked against positions as they stand BEFORE this phase + * moves anything, and charged every Phase it is still caught away: the fault is in leaving it + * there, not a one-time slip. A train the ordinary §8.1 rules are holding at the Office itself is + * unaffected — this only bites when the train is not even in the queue to leave. + */ + for (const [, tray] of order) { + if (!isExpedited(tray) || tray.position.at !== 'grid') continue; + const area = areaAtSeat(s, tray.position.seat); + const { coord } = tray.position; + if (coord.row === area.officeCoord.row && coord.col === area.officeCoord.col) continue; + const owner = playerAtSeat(s, tray.position.seat); + const p = s.players[owner]; + if (!p) continue; + p.revenue -= EXPEDITE_FAULT_PENALTY; + events.push({ + type: 'expediteFault', + player: owner, + trainNumber: tray.trainNumber ?? 0, + where: `(${coord.col},${coord.row})`, + }); + events.push({ + type: 'revenueChanged', + player: owner, + delta: -EXPEDITE_FAULT_PENALTY, + total: p.revenue, + reason: 'expedited train left off the station', + }); + } + for (const [id, tray] of order) { if (s.movedThisPhase.has(id)) continue; const where = tray.position; @@ -329,13 +362,13 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult { (tray.position.at !== 'grid' || where.at !== 'grid' || (tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col)); - if (!tray.departsThisStage && stillThere) { + if (stillThere) { tray.stopPointClaimed = true; // The point goes to whoever is SITTING in the district it stopped in. const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0; const label = tray.position.at === 'grid' - ? `(${tray.position.coord.row},${tray.position.coord.col})` + ? `(${tray.position.coord.col},${tray.position.coord.row})` : tray.position.at === 'mainline' ? `Mainline card ${tray.position.index}` : `the ${tray.position.side} Division Point`; @@ -475,14 +508,18 @@ function spendDispatchBonus( return 0; } -/** Q3 — an expedited train does not spend a Stage standing at the Office. */ /** - * Does this train leave in the same Stage it arrives (§7)? + * Q3 — is this train subject to the "kept ready" fault (§7)? + * + * Expedite does not change WHEN a train leaves — it is released by the ordinary §8.1 rules like any + * other train, and may be switched normally while it stands. What it means is that it must not be + * held anywhere but the station: a train the player parks on Secondary Track to clear a switching + * move faults if it is not back on the Office square by the next Mainline Phase (`mainlinePhase`). * * Two ways to earn it. `expedite` is printed and permanent. `stopThenExpedite` is the X17 Campaign * Train — "one turn at station (speeches) then expedite": its FIRST arrival is an ordinary stop while * the speeches are made, and every arrival after that is expedited. `speechMade` is set on that first - * stop, so the train is slow once and fast thereafter. + * stop, so the train is exempt once and subject to the fault thereafter. */ function isExpedited(tray: CrewTray): boolean { const rules = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules; @@ -491,18 +528,7 @@ function isExpedited(tray: CrewTray): boolean { return rules.stopThenExpedite === true && tray.speechMade === true; } -function moveTrain( - s: GameState, - id: TrayId, - tray: CrewTray, - events: GameEvent[], - /** - * Which pass is asking. The Mainline Phase moves every train once; Supervisor Shift then asks the - * Expedited arrivals to go again. The train cannot tell the difference, but the player must — see - * `trainHighballed.why`. - */ - pass: 'mainline' | 'expedite' = 'mainline', -): MoveOutcome { +function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]): MoveOutcome { const dir = step(tray.direction); if (tray.position.at === 'divisionPoint') { @@ -588,10 +614,7 @@ function moveTrain( trainNumber: tray.trainNumber ?? 0, from: 'the Office', to: 'the Mainline', - why: - pass === 'expedite' - ? 'its card prints EXPEDITE, so it leaves at the end of the Stage it arrived in — you got the Cargo phase with it, but no Local Operations turn to switch it' - : 'it stood a full Stage at the Office, so §8.1 released it this Mainline Phase', + why: 'it stood a full Stage at the Office, so §8.1 released it this Mainline Phase', }); return 'moved'; } @@ -751,28 +774,44 @@ function evaluateClearance( for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i }); } + /** + * TWO PASSES, NOT ONE — an opposite-direction occupant is an absolute bar and has to be checked + * against EVERY occupant before any same-direction judgment call is offered. + * + * A single pass returned on whichever occupant it examined first, in `node.transits` insertion + * order — which is fine while a Subdivision holds at most one train, but the dispatch exception + * below means it now legitimately can hold two: a Telegraph-cleared facing train sits on the same + * card as whatever it was cleared past. Found from a playtest report where the Superintendent was + * asked to rule on a same-direction train instead of being auto-held against an uncleared + * opposite-direction one also on the card — the loop had reached the same-direction occupant + * first simply because it entered the transit list first, and returned before ever looking at + * the other. + */ + for (const { tray: other } of occupants) { + if (other === id) continue; + const otherTray = s.trays.get(other); + if (!otherTray || otherTray.direction === tray.direction) continue; + // §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands. + // + // The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the + // Superintendent can arrange a meet with a facing train: "once a day, when dispatching + // facing trains, add +4/+8/+12 to the other train's number". Without a device there is no + // way to pass the order, so the train simply holds. + if (spendDispatchBonus(s, tray, otherTray, events) > 0) continue; + return 'blocked'; + } + for (const { tray: other, onCard } of occupants) { if (other === id) continue; const otherTray = s.trays.get(other); - if (!otherTray) continue; + if (!otherTray || otherTray.direction !== tray.direction) continue; const onNode = s.division.nodes[onCard]; - if (otherTray.direction !== tray.direction) { - // §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands. - // - // The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the - // Superintendent can arrange a meet with a facing train: "once a day, when dispatching - // facing trains, add +4/+8/+12 to the other train's number". Without a device there is no - // way to pass the order, so the train simply holds. - if (spendDispatchBonus(s, tray, otherTray, events) > 0) return 'clear'; - return 'blocked'; - } - // Red Flags — "a stopped train is prevented from being hit; the approaching train is prevented // from moving". Flagging is per-train rather than per-card, so it protects one specific train // where ABS Signals protects everything on the card. // - // Both of these now read the card the OTHER train is standing on rather than the card being + // Both of these read the card the OTHER train is standing on rather than the card being // entered. They were the same card while this only looked one card ahead; across a Subdivision // they are not, and the protection belongs where the train it protects actually is. if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked'; @@ -871,27 +910,9 @@ function arriveAtOffice( }); /** - * Q3 — AN EXPEDITED TRAIN DEPARTS THE STAGE IT ARRIVED, BUT NOT BEFORE THE WORK IS DONE. - * - * It used to leave inside this very Mainline Phase — a second `moveTrain` on the spot. That made - * "departs the Stage it arrives" true and the coaches worthless: Load/Unload runs AFTER Mainline, - * so an expedited train was gone before a single Porter could reach it. Measured over 60 games, - * Train 2 arrived 32 times and stood at an Office during Load/Unload exactly none of them, and - * every coach-carrying express is expedited (1/2 Crack Limited, 5/6 The Sparrow, 19 Military). - * The Crack Limited's own card says "stop at Terminals only", which it could never do. - * - * So it stands here through Load/Unload and leaves at the end of the Stage instead. It still does - * not lay over to the next Stage — the keyword is honoured — and it is still subject to every - * §8.1 condition when it goes, so it can be held. - */ - if (isExpedited(tray)) { - tray.departsThisStage = true; - return 'moved'; - } - /** - * X17 Campaign Train — the speeches happen at the first Office it reaches. Recorded AFTER the - * expedite test above, so this arrival is the ordinary stop the card pays for and every later one - * runs expedited. + * X17 Campaign Train — the speeches happen at the first Office it reaches, and every arrival after + * that runs expedited (`isExpedited` reads `speechMade`). Setting it here is idempotent on every + * later arrival, so it needs no guard against re-firing. */ if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopThenExpedite) { tray.speechMade = true; @@ -1018,40 +1039,6 @@ function retireTrain( // --------------------------------------------------------------------------- function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult { - /** - * EXPEDITED DEPARTURES — the last thing that happens in a Stage (Q3, and `arriveAtOffice`). - * - * Run here rather than as a phase of its own because there is no decision in it: the trains that - * arrived expedited this Stage try to leave, in train-number order like any other movement, and - * §8.1 may still hold them. Being after Load/Unload is the whole point — it is what gives the - * Porters their one Stage with the train. - */ - const expedited = [...s.trays.entries()] - .filter(([, t]) => t.departsThisStage) - .sort(([, a], [, b]) => (a.trainNumber ?? 0) - (b.trainNumber ?? 0)); - for (const [id, tray] of expedited) { - tray.departsThisStage = false; - /** - * An expedited train that would need a §8.1 ruling STAYS, and runs normally from then on. - * - * Said in the log, because it is the difference between "this train leaves at the end of the - * Stage" and "this train is still here next turn", and a player watching one Expedited train - * vanish and the next one linger has no way to tell why. Measured on one seed: with ordinary - * traffic running, Train 6 was held this way and collected four Local Operations turns. - */ - if (moveTrain(s, id, tray, events, 'expedite') === 'needsClearance') { - events.push({ - type: 'trainHeld', - trainNumber: tray.trainNumber ?? 0, - reason: - 'it EXPEDITES and was due out at the end of this Stage, but the Subdivision ahead needs a ' + - 'clearance ruling and Supervisor Shift has no Superintendent to give one. It stays, and ' + - 'runs like an ordinary train from now on — so you DO get a Local Operations turn with it', - }); - continue; - } - } - // §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12. if (s.clock.stage % STAGES_PER_SHIFT === 0) { s.clock.superintendent = playerLeftOf(s, s.clock.superintendent); diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 943a7d8..7d80935 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -486,11 +486,14 @@ function freightBudgetLeft( } /** - * Whether a train may do switching work at all (§7). + * Whether a train may set out or sort cars (§7). * * Six cards print "no switching" — the two expresses, the Light Engine, the Campaign, Circus and - * Military trains. They run the Division; they do not shunt. This covers moving, setting out and - * sorting alike, because all three are switching. + * Military trains — which means they may not ADD or DROP cars, not that they may never be touched: a + * train held at the Office may still need to clear onto Secondary Track ahead of other traffic. So + * this covers `switch.dropCars` and `switch.sortConsist` only; `switch.move` handles `noSwitching` + * itself, the same way it handles `dropOnly`, because the restriction has to bite on the pick-up a + * move would make, not on the move itself. */ function switchingRefusal(tray: CrewTray): RejectionCode | null { return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null; @@ -626,8 +629,6 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING'; const tray = s.trays.get(i.trayId); if (!tray) return 'NO_SUCH_TRAY'; - const noSwitch = switchingRefusal(tray); - if (noSwitch) return noSwitch; const from = trayCoord(s, i.trayId); if (!from) return 'ILLEGAL_MOVE'; const dests = destinationsFor(s, player, i.trayId, from, i.reverse); @@ -652,6 +653,14 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo */ const fresh = dest.couples.slice(ownCutFor(s, player, i.trayId, i.reverse).length); if (fresh.length > 0) { + /** + * "NO SWITCHING" MEANS NO ADDING OR DROPPING CARS, not "never move". A no-switching train + * held at the Office may still need to clear onto Secondary Track ahead of other traffic — + * §7 governs coupling, setting out and sorting, and a move that picks nothing up is none of + * those. Handled here, alongside `dropOnly`, rather than as a blanket refusal on the intent: + * the restriction has to bite on the pick-up itself, the same reasoning as the comment above. + */ + if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED'; if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED'; if (rules.pickUpEmptiesOnly && fresh.some((c) => c.loaded)) return 'EMPTIES_ONLY'; const freight = fresh.filter(isFreight).length; diff --git a/src/engine/content.ts b/src/engine/content.ts index 562aa55..4e0663d 100644 --- a/src/engine/content.ts +++ b/src/engine/content.ts @@ -1004,6 +1004,12 @@ export const MOVES_PER_LOCAL_OPS_NIGHT = 5; export const LABORER_ACTIONS_PER_LOAD = 4; export const COLLISION_PENALTY = 5; export const COLLISION_FLOOR_PER_DAY = 3; +/** + * Q3 — an expedited train left parked off the Office when a Mainline Phase begins is a Station + * Master fault: the train was not kept ready to highball the moment the Subdivision allowed it. + * Charged every Phase it is caught there, not just once, since the fault is being left that way. + */ +export const EXPEDITE_FAULT_PENALTY = 1; export function collectiveRevenueFloor(players: number, days: number): number { return 3 * players * days; diff --git a/src/engine/events.ts b/src/engine/events.ts index 11c794e..11db0d1 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -124,6 +124,13 @@ export type GameEvent = | { type: 'trainHeld'; trainNumber: number; reason: string } /** A train whose card pays for standing still (X18 Circus) collected on it. */ | { type: 'trainStoodStill'; trainNumber: number; where: string } + /** + * Q3 — A STATION MASTER FAULT. An expedited train is not to be held anywhere but the station: if + * it is still off the Office square — parked on Secondary Track, say, to clear a switching move — + * when a Mainline Phase begins, that is a failure to keep it ready to highball, and it costs + * Revenue. `where` names the square it was found on. Always followed by a `revenueChanged`. + */ + | { type: 'expediteFault'; player: PlayerIndex; trainNumber: number; where: string } /** * `why` is the RULE that let it go now, not a restatement of the move. * @@ -134,9 +141,9 @@ export type GameEvent = */ | { type: 'trainHighballed'; trainNumber: number; from: string; to: string; why: string } /** - * `expedited` because it changes what the player must do RIGHT NOW. Q3 — an expedited train - * departs the same Stage it arrives, so there is no Local Operations turn in which to work it; - * every other train stands until the next Mainline Phase and can be switched in between. + * `expedited` flags a train that must not be left standing off the station (Q3) — it may be + * switched normally like any other arrival, but it has to be back on the Office square before the + * next Mainline Phase begins, or `expediteFault` fires. */ | { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean } | { type: 'trainDiverted'; trainNumber: number; to: string; reason: string } diff --git a/src/engine/state.ts b/src/engine/state.ts index 1a69faf..5af0c9d 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -384,17 +384,12 @@ export type CrewTray = { * X17 Campaign Train — "one turn at station (speeches) then expedite". * * It makes its speech at the first Office it reaches: that arrival is an ordinary stop, and from - * then on the train runs expedited, departing every Office in the Stage it arrives. Recorded on the - * tray for the same reason as `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs - * once, so there is no later visit to hang it on. + * then on the train is expedited — it may be switched normally, but it faults (Q3) if it is left + * off the Office square when a Mainline Phase begins. Recorded on the tray for the same reason as + * `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs once, so there is no later + * visit to hang it on. */ speechMade?: boolean; - /** - * Arrived expedited this Stage, so it departs at the END of the Stage rather than laying over to - * the next one (Q3). Set on arrival; cleared when `shiftChange` attempts the departure — after - * Load/Unload, which is what gives the Porters their one Stage with the train. - */ - departsThisStage?: boolean; }; // --------------------------------------------------------------------------- diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index b0a09b7..bbedb12 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -552,7 +552,8 @@ export function officeSvg( out += `${esc(cell.label)}`; - out += `${cell.row},${cell.col}`; + // X,Y — east/west then north/south, not the internal row/col storage order. + out += `${cell.col},${cell.row}`; /** * THE ROSTER, one chip per A/D track — not one per train. @@ -872,7 +873,7 @@ export function officeSvg( out += `` + `` + `place here` + - `${g.row},${g.col}` + + `${g.col},${g.row}` + ``; } diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index 1b22d38..9b83260 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -280,25 +280,25 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { }; case 'trainArrived': /** - * AN EXPEDITED TRAIN IS NOT COMING BACK ROUND — AND IS NOT GONE YET EITHER. + * AN EXPEDITED ARRIVAL IS AN ORDINARY ARRIVAL, WITH ONE STANDING OBLIGATION. * - * This said "it will highball again next Mainline Phase" to every arrival, which is backwards - * for an Expedited train. Corrected once to "it leaves again this same Mainline Phase", which - * was ALSO wrong and in a way that cost the player work: the expedited departure was moved to - * Supervisor Shift precisely so the Porters and Laborers get their Stage with the train, so - * Cargo IS available and only Local Operations is not. Telling a player there is "no turn in - * which to work it" talked them out of the one turn they had. - * - * So both branches now name the phases: what you can still do with this train, and when it - * goes. That is the whole of the reported confusion — "some trains seem to be moving before I - * can switch or do other operations on them". + * This used to say the train stands through Cargo and is forced out at the end of the Stage — + * true once, and wrong: Q3 means the train must not be PARKED anywhere but the station, not + * that it is rushed out early. It is switched, worked and released exactly like any other + * arrival; the only difference is what happens if it is left on Secondary Track when the next + * Mainline Phase begins (`expediteFault`). */ return { tone: 'plain', text: e.expedited - ? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so it stands through the Cargo phase and departs at the END OF THIS STAGE. Porters and Laborers can work it now; it is normally gone before your next Local Operations turn, so it cannot be switched — unless §8.1 holds it for a ruling, in which case it stays and runs like any other train.` + ? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so keep it on the Office square: parked anywhere else in the district when the next Mainline Phase begins costs a Revenue point. It works and switches normally in the meantime.` : `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands here for the rest of this Stage. You can work it in Cargo now, switch it in the NEXT Stage's Local Operations, and it departs in that Stage's Mainline Phase.`, }; + case 'expediteFault': + return { + tone: 'bad', + text: `Train ${e.trainNumber} was left at ${e.where}, off the station, when the Mainline Phase began — an expedited train must be kept ready to highball. Station Master fault.`, + }; case 'trainDiverted': return { tone: 'good', @@ -582,7 +582,7 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] // the board says it on the card instead. if (b.kind === 'noStopping') continue; out.push({ - where: `${tray.trainNumber === null ? 'crew' : `Train ${tray.trainNumber}`} → (${b.coord.row},${b.coord.col})`, + where: `${tray.trainNumber === null ? 'crew' : `Train ${tray.trainNumber}`} → (${b.coord.col},${b.coord.row})`, why: b.why, // Not "stuck": these are the shape of the district and of the other trains in it, which is // the puzzle rather than a fault. Amber, not red. @@ -602,12 +602,10 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] * said so. * * Reported again as "passenger trains arrive at my Office and move on before I can load or unload - * them", which turned out to be three separate silences. This block only looked at trains carrying + * them", which turned out to be two separate silences. This block only looked at trains carrying * a LOADED coach, so a train arriving to PICK UP said nothing at all. A Whistle Post's Office card * carries a passenger facility with zero Porters, so the engine answered `RESOURCE_SPENT` — "all - * Porters used" — to a player who had used none. And an expedited train leaves at the end of the - * Stage it arrives, so its one Load/Unload phase is now-or-never and nothing said which trains - * those are. + * Porters used" — to a player who had used none. * * §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which no panel * mentioned at all. @@ -620,8 +618,6 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] .filter((t): t is NonNullable => !!t && t.consist.some((c) => c.type === 'coach')); if (coachTrains.length > 0) { - const label = (t: (typeof coachTrains)[number]): string => - t.trainNumber === null ? 'the local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`; const say = (why: string, severity: Impediment['severity'] = 'waiting'): void => { out.push({ where: 'Office — passengers', why, severity }); }; @@ -658,17 +654,6 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] say('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)'); } } - - // NOW OR NEVER. An expedited train leaves at the end of this Stage, so this is the only - // Load/Unload phase it will ever stand for — the exact thing the playtest report was about. - for (const t of coachTrains) { - if (!t.departsThisStage) continue; - say( - `${label(t)} is EXPEDITED — it leaves at the END of this Stage, so this is the only ` + - 'Load/Unload phase it will stand for. Work it now or not at all.', - 'risk', - ); - } } } @@ -748,6 +733,15 @@ export function cuesFor(events: readonly GameEvent[]): string[] { // A train running the whole length of the Division pays every player, and it is the one event // nobody made happen on the turn it lands. It should be heard, not found in the log. if (e.type === 'trainCompleted') out.push('completed'); + // A train pulling into an Office. `trainDiverted` (the Yard Office siding) is deliberately not + // included — that one goes straight to a work track without the A/D stop `arrive` depicts. + if (e.type === 'trainArrived') out.push('arrive'); + // Only an OFFICE departure, not every highball: a fresh make-up leaving a Division Point already + // sounds `train` ("All aboard"), and a train clearing the whole Division already sounds + // `completed` — this is the one case neither of those covers. + if (e.type === 'trainHighballed' && e.from === 'the Office') out.push('depart'); + // §10 — the one event nobody wants to hear and everybody needs to. + if (e.type === 'trainsDestroyed') out.push('crash'); if (e.type === 'stageBegan') { // A Stage BEGINNING is the previous one ending — except the first, which is the game opening // and has nothing behind it. A Day boundary rings the bell only: sounding both would collide, diff --git a/src/sim/view.ts b/src/sim/view.ts index 5dce3b7..b62ccb8 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -678,7 +678,8 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string { /** One readable line for a single intent. */ export function describeIntent(s: GameState, i: Intent): string { - const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`; + // X,Y — east/west then north/south, not the internal row/col storage order. + const at = (c: { row: number; col: number }): string => `(${c.col},${c.row})`; // An intent belongs to whoever is acting, so it is described against THEIR district. // The acting PLAYER, not a seat — `describeIntent` describes an intent against the district of // whoever is making it. Named `seat` once, and then used as an `officeAreas` key, which is the @@ -1229,7 +1230,7 @@ export function snapshot( ? `${t.position.side} Division Point` : t.position.at === 'mainline' ? `Mainline card ${t.position.index}` - : `Office Area (${t.position.coord.row},${t.position.coord.col})`, + : `Office Area (${t.position.coord.col},${t.position.coord.row})`, })), }; } @@ -1424,7 +1425,9 @@ export function trainRules(t: { * DOES rather than that it exists, because the restriction is the whole character of the card: a * Military train that cannot be worked by Porters plays nothing like a Local. */ - if (p.rules.noSwitching) parts.push('NO SWITCHING — it runs the Division and does not shunt'); + if (p.rules.noSwitching) { + parts.push('NO SWITCHING — may not add or drop cars, but may still be moved clear of the mainline'); + } if (p.rules.terminalsOnly) parts.push('TERMINALS ONLY — Porters may work it at a Terminal and nowhere else'); if (p.rules.coachStaysOnStationTrack) { // Said "may only be set out at the Office", which reads as a place you can do it. You cannot: @@ -1446,15 +1449,12 @@ export function trainRules(t: { } if (p.rules.expedite) { - // The single most-asked question in play — "why did that train leave before I could switch it?" - // — so it names the phases rather than the Stage. It stands through Cargo and goes at the end of - // the Stage, which is after the Porters and Laborers but before the next Local Operations. + // It is released and switched exactly like any other train — the restriction is on where it may + // be LEFT, not on when it leaves. parts.push( - 'EXPEDITED — it does not lay over. It stands through the Cargo phase and departs at the END OF ' + - 'THE STAGE IT ARRIVES IN, so Porters and Laborers can work it but it is normally gone before ' + - 'your next Local Operations turn and cannot be switched. UNLESS §8.1 wants a clearance ruling ' + - 'at that moment: there is no Superintendent in Supervisor Shift to give one, so the train ' + - 'stays and runs like any other from then on.', + 'EXPEDITED — must be kept ready to highball. It works and switches normally, but if it is not ' + + 'back on the Office square when the next Mainline Phase begins, that is a Station Master ' + + 'fault and costs 1 Revenue.', ); } if (p.rules.stopEarnsPoint) parts.push('EARNS A POINT for one Stage spent standing still, once'); @@ -1736,14 +1736,9 @@ function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] { const out: Frame['moves'] = []; for (const [id, tray] of s.trays) { if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue; - /** - * A crew that may not switch is not a crew you can be switching. - * - * `movesFor` is pure track geometry — it answers where a train COULD roll — and six cards print - * "no switching", which `check` enforces and it does not. Offering the Circus Train in a "which - * train are you switching?" row is offering a train whose every move the rules refuse. - */ - if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.noSwitching) continue; + // A no-switching train may still be moved clear of the mainline (§7) — only coupling, setting + // out and sorting are refused, and `check` rejects those the same way it rejects a pick-up X13 + // (dropOnly) is not allowed to make, so this row is not filtered any differently for either. const { to, blocked } = movesFor(s, player, id); out.push({ trayId: id, label: trainName(s, id), from: tray.position.coord, to, blocked }); } diff --git a/src/web/game.ts b/src/web/game.ts index ed73cc3..58261a2 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -344,7 +344,7 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro const tray = game.state.trays.get(trayId); const where = tray?.position.at === 'grid' - ? `, standing at (${tray.position.coord.row}, ${tray.position.coord.col})` + ? `, standing at (${tray.position.coord.col}, ${tray.position.coord.row})` : ''; groups.push({ kind: prefix, @@ -670,7 +670,8 @@ function subjectOf( node?: number; links: string[]; } | null { - const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`; + // X,Y — east/west then north/south, not the internal row/col storage order. + const at = (c: { row: number; col: number }): string => `(${c.col}, ${c.row})`; /** * ABS Signals is placed on a MAINLINE card, which is not in the Office Area at all — so it names * a Division NODE and carries no coordinate. It used to travel as `{ row: -1, col: node }`, and diff --git a/src/web/index.html b/src/web/index.html index 9b28b68..9d68783 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -11,6 +11,11 @@ body{margin:0;background:var(--bg);color:var(--fg); font:15px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px} main{width:100%;max-width:760px} +.hero{display:flex;gap:24px;align-items:flex-start} +.hero-image{width:230px;flex:none;border-radius:9px;overflow:hidden;border:1px solid var(--line)} +.hero-image img{display:block;width:100%;height:auto} +.hero-content{flex:1;min-width:0} +@media(max-width:640px){.hero{flex-direction:column}.hero-image{width:100%;max-width:280px}} h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em} .tag{color:var(--dim);margin:0;font-size:14px} .blurb{color:#c6ccd6;margin:16px 0 26px;max-width:66ch} @@ -32,39 +37,48 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
-

Station Master

-

TimeTable and Train Order (TT&TO) railroading, 1840–1950.

+
+
+ Station Master — TimeTable & Train Order Railroading, 1840–1950. Box art: two trains meet at a junction beside a train order office. +
+
+

Station Master

+

TimeTable and Train Order (TT&TO) railroading, 1840–1950.

- + -

- As Station Master in your line-side office, you manage the town's railroad connection. You are - responsible for all track between your Limits, a district of industries hanging beneath it, and - trains that arrive whether or not you are ready for them. Spot the right cars, work the loads, - and get every train away again — a train with nowhere to stand is a collision, and a - collision costs more than the freight was worth. -

+

+ As Station Master in your line-side office, you manage the town's railroad connection. You are + responsible for all track between your Limits, a district of industries hanging beneath it, and + trains that arrive whether or not you are ready for them. Spot the right cars, work the loads, + and get every train away again — a train with nowhere to stand is a collision, and a + collision costs more than the freight was worth. +

-
diff --git a/src/web/main.ts b/src/web/main.ts index 6507ecc..451dc21 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -819,8 +819,8 @@ function renderActions( .map( (m) => ``, + `data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.col}, ${m.from.row}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` + + `${esc(m.label)} (${m.from.col}, ${m.from.row})`, ) .join('') + `
`; diff --git a/src/web/sound.ts b/src/web/sound.ts index 627d503..c4222bf 100644 --- a/src/web/sound.ts +++ b/src/web/sound.ts @@ -64,6 +64,38 @@ export function playCue(cue: string): void { osc.stop(at + dur + 0.02); }; + /** + * A burst of filtered noise — the breath of steam behind a whistle, one chuff of a moving + * locomotive, or the boom of an impact. Shared by `stage` (one long burst) and the chuffs in + * `arrive`/`depart` (several short ones), and the wide-open burst under `crash`. + */ + const noiseBurst = ( + at: number, + dur: number, + gain: number, + freq: number, + q: number, + type: BiquadFilterType = 'bandpass', + ): void => { + const frames = Math.max(1, Math.floor(c.sampleRate * dur)); + const buf = c.createBuffer(1, frames, c.sampleRate); + const data = buf.getChannelData(0); + for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1; + const src = c.createBufferSource(); + src.buffer = buf; + const filt = c.createBiquadFilter(); + filt.type = type; + filt.frequency.value = freq; + filt.Q.value = q; + const amp = c.createGain(); + amp.gain.setValueAtTime(0, at); + amp.gain.linearRampToValueAtTime(gain, at + Math.min(0.05, dur / 4)); + amp.gain.exponentialRampToValueAtTime(0.0001, at + dur); + src.connect(filt).connect(amp).connect(c.destination); + src.start(at); + src.stop(at + dur + 0.02); + }; + const t = c.currentTime + 0.02; if (cue === 'stage') { @@ -73,23 +105,55 @@ export function playCue(cue: string): void { for (const fg of [[392, 0.16], [466, 0.13], [587, 0.1], [784, 0.05]]) { tone(t, fg[0]!, d, fg[1]!, 'sawtooth', fg[0]! * 0.985); } - const frames = Math.floor(c.sampleRate * d); - const buf = c.createBuffer(1, Math.max(1, frames), c.sampleRate); - const data = buf.getChannelData(0); - for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1; - const src = c.createBufferSource(); - src.buffer = buf; - const band = c.createBiquadFilter(); - band.type = 'bandpass'; - band.frequency.value = 1400; - band.Q.value = 0.8; - const amp = c.createGain(); - amp.gain.setValueAtTime(0, t); - amp.gain.linearRampToValueAtTime(0.05, t + 0.05); - amp.gain.exponentialRampToValueAtTime(0.0001, t + d); - src.connect(band).connect(amp).connect(c.destination); - src.start(t); - src.stop(t + d); + noiseBurst(t, d, 0.05, 1400, 0.8); + return; + } + + if (cue === 'arrive') { + /** + * A LOCOMOTIVE SLOWING TO A STOP: chuffs (one per wheel revolution) spaced FARTHER apart as + * road speed drops, then a soft two-partial bell tap as it settles — a lighter cousin of the + * `day` bell, not a repeat of it. + */ + let at = t; + const gaps = [0.13, 0.17, 0.22, 0.28]; + for (let i = 0; i < gaps.length; i++) { + noiseBurst(at, 0.07, 0.11 - i * 0.015, 190, 1.1, 'bandpass'); + at += gaps[i]!; + } + for (const fg of [[1046, 0.07], [1568, 0.04]]) { + tone(at + 0.12, fg[0]!, 0.3, fg[1]!, 'sine'); + } + return; + } + + if (cue === 'depart') { + /** + * THE INVERSE OF `arrive`: chuffs starting slow and pulling closer together as the train + * gathers speed, then one falling, fading horn note — a single call, not `completed`'s pair, + * since this train is only leaving the Office, not clearing the whole Division. + */ + let at = t; + const gaps = [0.22, 0.15, 0.1, 0.07, 0.05]; + for (let i = 0; i < gaps.length; i++) { + noiseBurst(at, 0.06, 0.06 + i * 0.012, 210, 1.2, 'bandpass'); + at += gaps[i]!; + } + tone(at, 349, 0.45, 0.1, 'sawtooth', 300); + return; + } + + if (cue === 'crash') { + /** + * IMPACT, NOT MUSIC: two tones bent sharply downward at a DISSONANT (non-harmonic) interval, + * so the ear reads a collision rather than a chord — `couple`'s clank is consonant on purpose, + * and this is its opposite. A wide-open noise boom and a percussive sub-bass thump give it + * weight under the screech. + */ + tone(t, 311, 0.35, 0.16, 'square', 70); + tone(t, 233, 0.4, 0.13, 'sawtooth', 52); + noiseBurst(t, 0.4, 0.16, 900, 0.4, 'lowpass'); + tone(t, 45, 0.3, 0.18, 'sine'); return; } diff --git a/test/advance.test.ts b/test/advance.test.ts index e94a2b6..a8ab728 100644 --- a/test/advance.test.ts +++ b/test/advance.test.ts @@ -9,7 +9,7 @@ import assert from 'node:assert/strict'; import { advance, pump } from '../src/engine/advance.ts'; import { applyIntent, areaOf, check } from '../src/engine/apply.ts'; -import { HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts'; +import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts'; import { legalActions } from '../src/engine/legal.ts'; import { createGame } from '../src/engine/setup.ts'; import { developerBot } from '../src/sim/bot.ts'; @@ -690,9 +690,16 @@ describe('the history says WHY a train moved, and says it truthfully', () => { * them — it may be the rules are wrong, or it may be my perception." * * It was perception, but the log was actively feeding it. Every departure read alike, and the - * arrival line for an Expedited train said there was "no turn in which to work it" — which is - * false and cost the player the Cargo turn they did have. These pin the two claims the log now - * makes, so a phase-order change cannot leave the narration lying about it. + * arrival line for an Expedited train said there was "no turn in which to work it" — which was + * false under the OLD (v0.4.8 and earlier) same-stage-departure reading of Q3, and cost the player + * the Cargo turn they did have. + * + * v0.4.9 CORRECTED Q3 ITSELF, not just the narration: Expedite does not change when a train + * leaves — it is released by the ordinary §8.1 rules exactly like any other train, and may be + * switched normally while it stands. What it means is that the train must not be PARKED anywhere + * but the station; leaving it on Secondary Track when a Mainline Phase begins is a Station Master + * fault (`expediteFault`, `EXPEDITE_FAULT_PENALTY`). These pin that behaviour, so a phase-order + * change cannot silently bring back the old forced departure. */ /** * Drop one named train at the Western Division Point and follow it, counting the DISTINCT phases @@ -704,11 +711,10 @@ describe('the history says WHY a train moved, and says it truthfully', () => { const s = game(7, { length: 'standard' }); for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains'; /** - * A DIVISION WITH NOTHING ELSE ON IT. Expedite is not absolute: §8.1 can still hold the train, - * and `shiftChange` says so — "an expedited train that would need a ruling simply stays, and - * runs normally next Stage". With the bot's own traffic running, Train 6 was held four Stages - * and collected four Local Operations turns, which is correct behaviour and the opposite of what - * this test is trying to pin. Clearing the timetable isolates the rule from the traffic. + * A DIVISION WITH NOTHING ELSE ON IT. §8.1 can still hold any train, Expedited or not, on a + * same-direction meet — with the bot's own traffic running, Train 6 was held four Stages and + * collected four Local Operations turns. Correct behaviour, but it is traffic noise for what + * this test is trying to pin, so clearing the timetable isolates the rule from it. */ s.timetable = s.timetable.map(() => null); const id = 'watched'; @@ -779,40 +785,55 @@ describe('the history says WHY a train moved, and says it truthfully', () => { assert.equal(r.leftIn, 'mainline', 'an ordinary train should leave in a Mainline Phase'); }); - it('gives an Expedited train the Cargo phase but never Local Operations', () => { - // Train 6 The Sparrow — Expedite. The claim the arrival line makes is precisely this pair: - // Porters and Laborers can reach it, a switching turn never comes, and it goes at the end of - // the Stage rather than in a Mainline Phase. + it('gives an Expedited train a Local Operations turn too, exactly like an ordinary one', () => { + // Train 6 The Sparrow — Expedite. v0.4.9 corrected Q3: Expedite no longer forces a same-stage + // departure, so an Expedited train is released by the ordinary §8.1 rule and stands for a + // Local Operations turn the same as Train 12 above. const r = phasesWith(6); - assert.equal(r.localOps, 0, 'an Expedited train got a Local Operations turn after all'); + assert.ok(r.localOps >= 1, `an Expedited train got ${r.localOps} Local Operations turns`); assert.ok(r.loadUnload >= 1, 'an Expedited train never stood through a Cargo phase'); - assert.equal(r.leftIn, 'shiftChange', 'an Expedited train should leave in Supervisor Shift'); + assert.equal(r.leftIn, 'mainline', 'an Expedited train should leave in a Mainline Phase, like any other'); }); - it('does NOT expedite when §8.1 wants a ruling — it stays and runs normally', () => { - /** - * EXPEDITE IS CONDITIONAL, which is most of why it feels arbitrary at the table. - * - * `shiftChange` says so — "an expedited train that would need a ruling simply stays, and runs - * normally next Stage" — and it happens often: driven by the developer bot this was reached by - * ordinary traffic, and Train 6 collected FOUR Local Operations turns instead of none. Built by - * hand here rather than fished out of a bot game, because whether the bot happens to produce a - * meet depends on the deck, and the deck moves. - * - * So the log and the card must not promise that an Expedited train can never be switched. - */ + it('faults an Expedited train left off the station when a Mainline Phase begins', () => { + // What Expedite actually restricts now: not WHEN the train leaves, but WHERE it may be left + // standing in the meantime. Parked on Secondary Track — cleared there to make way for other + // switching, say — it must not still be there when the next Mainline Phase begins. const s = game(7, { length: 'standard' }); - for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains'; + const area = areaOf(s, 0); + const id = 'expedited'; + const secondary = { row: area.officeCoord.row, col: area.officeCoord.col + 1 }; + s.trays.set(id, { + id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [], + direction: 'east', facing: 'e', + position: { at: 'grid', seat: 0, coord: secondary }, movesUsed: 0, + }); - // Train 6 (Expedite) standing at the Office, having just arrived: this is the state - // `arriveAtOffice` leaves behind when it sets `departsThisStage`. + s.clock.phase = 'mainline'; + const before = s.players[0]!.revenue; + const r = advance(s); + + assert.ok( + r.events.some((e) => e.type === 'expediteFault' && e.trainNumber === 6), + 'no fault was raised for an Expedited train left off the station', + ); + assert.equal( + s.players[0]!.revenue, + before - EXPEDITE_FAULT_PENALTY, + 'the fault did not cost Revenue', + ); + }); + + it('does not fault an Expedited train legitimately held at the station itself', () => { + // A train sitting on the Office square is not "left" anywhere — including one §8.1 is holding + // for a same-direction meet, which is an ordinary hold, not a Station Master failure. + const s = game(7, { length: 'standard' }); const area = areaOf(s, 0); const id = 'expedited'; s.trays.set(id, { id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [], direction: 'east', facing: 'e', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0, - departsThisStage: true, }); area.adOccupancy.push(id); @@ -829,26 +850,22 @@ describe('the history says WHY a train moved, and says it truthfully', () => { node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' }); } - s.clock.phase = 'shiftChange'; + s.clock.phase = 'mainline'; + const before = s.players[0]!.revenue; const r = advance(s); assert.ok( r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id), - 'the Expedited departure was not put to the Superintendent', - ); - assert.equal( - s.trays.get(id)?.position.at, - 'grid', - 'the Expedited train left despite needing a ruling nobody could give', + 'the departure was not put to the Superintendent', ); assert.ok( - r.events.some((e) => e.type === 'trainHeld' && /EXPEDITES/.test(e.reason)), - 'the log does not say why the Expedited train stayed', + !r.events.some((e) => e.type === 'expediteFault'), + 'a train properly parked at the station was faulted anyway', ); assert.equal( - s.trays.get(id)?.departsThisStage, - false, - 'it should run as an ordinary train from now on, not retry the expedited departure', + s.players[0]!.revenue, + before, + 'Revenue changed for a train legitimately held at the station', ); }); }); diff --git a/test/enhancements.test.ts b/test/enhancements.test.ts index cd04154..888b22e 100644 --- a/test/enhancements.test.ts +++ b/test/enhancements.test.ts @@ -468,9 +468,10 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St * Nothing states the rule, which makes it exactly the kind of property a later change to the loop * would break in silence. These pin it. * - * It is also what gives the printed **Expedite** rule something to do — an Expedited train departs - * at the END of the Stage it arrived, precisely because the general rule forbids it departing at - * all. It stands through Load/Unload first, which is what makes its coaches workable. + * v0.4.9 CORRECTED Q3 so **Expedite is not an exception to this any more**: an Expedited train + * stops for the Stage exactly like any other and may be switched in the Local Operations turn that + * follows. What Expedite restricts is where the train may be left standing afterwards, not when it + * departs — see `advance.test.ts`'s `expediteFault` coverage for that half. */ const arriving = (s: GameState, trainNumber: number, isExtra = false): string => { const id = 'arriving'; @@ -503,12 +504,14 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St ); }); - it('unless its card prints Expedite — but it still stands through Load/Unload first', () => { + it('an Expedite train follows the same rule now — no exception any more', () => { /** - * Q3, as revised in v0.4.2. An Expedite train leaves the Stage it arrives, but at the END of that - * Stage rather than inside the Mainline Phase. It used to be gone before Load/Unload ran, which - * made the coaches on every coach-carrying express (1/2, 5/6, 19) permanently unworkable — - * measured, Train 2 arrived 32 times in 60 games and stood for Load/Unload in none of them. + * Q3, as CORRECTED in v0.4.9. Before this, an Expedite train left the Stage it arrived — at the + * end of it rather than inside the Mainline Phase (v0.4.2's fix for coaches otherwise never + * reaching Load/Unload) — which made it the one exception to "stops for the Stage". That forced + * departure is gone: Expedite no longer changes WHEN a train leaves, only where it may be left + * standing afterwards (`expediteFault`, pinned in `advance.test.ts`). So it stops for the Stage + * exactly like Train 9 above. */ const s = game(); assert.equal(trainProfile(3, false)?.rules.expedite, true, 'train 3 should be an Expedite train'); @@ -518,19 +521,9 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St assert.equal( s.trays.get(id)!.position.at, 'grid', - 'an Expedite train left inside the Mainline Phase, before Load/Unload could reach it', + 'an Expedite train departed again in the same Stage it arrived from a mainline card', ); - assert.equal(s.trays.get(id)!.departsThisStage, true, 'it is not flagged to leave at the end of the Stage'); assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'it is not standing on an A/D track'); - - // Run the end of the Stage directly. Load/Unload waits on the player, and the thing under test - // is the departure pass that follows it — not that phase's own input handling. - s.clock.phase = 'shiftChange'; - advance(s); - assert.notEqual( - s.trays.get(id)?.position.at, 'grid', - 'an Expedite train should be gone by the end of the Stage it arrived', - ); }); }); @@ -666,6 +659,50 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => { assert.deepEqual(s.trays.get('mine')!.position, { at: 'divisionPoint', side: 'west' }); }); + it('holds against an uncleared facing train even if a same-direction one is checked first', () => { + // Reproduces a playtest report: the Superintendent was asked to rule on a same-direction train + // instead of being automatically held against an opposite-direction one also occupying the + // card. Root cause — `evaluateClearance` returned on whichever occupant it examined first, in + // `node.transits` insertion order, rather than checking every occupant. Only visible once a + // card can hold two trains at once, which the Telegraph/Telephone/Radio exception is exactly + // what creates: a facing train dispatched past another sits on the same card as it. + const s = game(); + const ml = s.division.nodes[1]; + assert.equal(ml?.kind, 'mainline'); + if (ml?.kind === 'mainline') { + ml.card = 'plains'; + // SAME direction, entered the transit list FIRST — what the old code asked about. + s.trays.set('sameDir', { + id: 'sameDir', trainNumber: 3, trainIsExtra: false, engineAt: 0, + consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0, + }); + ml.transits.push({ tray: 'sameDir', stagesRemaining: 2, stagesTotal: 2, direction: 'east' }); + // OPPOSITE direction, entered SECOND, no dispatch device on the board — an absolute bar. + s.trays.set('facing', { + id: 'facing', trainNumber: 5, trainIsExtra: false, engineAt: 0, + consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0, + }); + ml.transits.push({ tray: 'facing', stagesRemaining: 2, stagesTotal: 2, direction: 'west' }); + } + s.trays.set('mine', { + id: 'mine', trainNumber: 9, trainIsExtra: false, engineAt: 0, + consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0, + }); + s.clock.phase = 'mainline'; + s.movedThisPhase = new Set(); + + const r = advance(s); + + assert.equal( + s.trays.get('mine')!.position.at, 'divisionPoint', + 'the train entered despite an uncleared opposite-direction occupant on the card', + ); + assert.ok( + !r.events.some((e) => e.type === 'clearanceRequested'), + 'the Superintendent was asked to rule on the same-direction occupant instead of an automatic hold', + ); + }); + it('lets a junior train win the meet with a Telegraph', () => { // Train 9 against Train 3: +4 makes the other count as 7 — still senior. Radio's +12 wins. const s = game(); diff --git a/test/events.test.ts b/test/events.test.ts index 3063104..9982231 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -46,6 +46,7 @@ const KNOWN_UNREDUCED = [ 'carPassed', 'clearanceRequested', 'dispatchBonusUsed', + 'expediteFault', 'phaseBegan', 'stageBegan', 'trainArrived', diff --git a/test/replay.test.ts b/test/replay.test.ts index d59109a..9213aec 100644 --- a/test/replay.test.ts +++ b/test/replay.test.ts @@ -446,6 +446,13 @@ describe('the replay behaves like the game it is replaying', () => { assert.ok(kinds.has('couple'), 'coupling a cut never made a sound'); assert.ok(kinds.has('drop'), 'setting cars out never made a sound'); assert.ok(kinds.has('schedule'), 'the 1D12 that sets a train\'s departure Stage landed silently'); + assert.ok(kinds.has('arrive'), 'a train pulling into an Office never made a sound'); + assert.ok(kinds.has('depart'), 'a train highballing out of an Office never made a sound'); + // Collisions are rare — measured 2 in 40 games — so this is the one cue this pool is not + // guaranteed to contain on every stride; it happens to (seeds 96028 and 159380) at the current + // stride and seed count. If this starts failing after either changes, widen the pool rather than + // deleting the assertion — §10 is the one event a player most needs to hear. + assert.ok(kinds.has('crash'), 'a collision never made a sound'); // One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same // accounting the live game is held to. The switching cues are counted separately: they follow diff --git a/test/sim.test.ts b/test/sim.test.ts index a12a7a3..df68eea 100644 --- a/test/sim.test.ts +++ b/test/sim.test.ts @@ -345,8 +345,8 @@ describe('end-of-game statistics', () => { * BOT no longer gets a crew next to an industry: it builds shallow districts and places 2.2 * facilities a game, down from 3.8 when industries could sit on the main. * - * That is the bot's district-building weakness, which is the top item in TODO.md, not a rule - * that has become unreachable. Exempted by name so the other forty-odd event checks stay live, + * That is the bot's district-building weakness, tracked in TODO.md under Bot Performance, not a + * 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']; diff --git a/test/train-rules.test.ts b/test/train-rules.test.ts index 416c94d..2d547b9 100644 --- a/test/train-rules.test.ts +++ b/test/train-rules.test.ts @@ -76,13 +76,23 @@ const coach = (loaded = false): RollingStock => ({ type: 'coach', loaded }); // --------------------------------------------------------------------------- describe('§7 — no switching', () => { - it('refuses every switching act to a train whose card forbids it', () => { + it('still allows a plain move — "no switching" means no adding or dropping cars', () => { // Six cards print it: both expresses, the Light Engine, Campaign, Circus and Military trains. - // Moving, setting out and sorting are all switching, so all three are refused. + // A train held at the Office may still need to clear onto Secondary Track ahead of other + // traffic, so a move that couples nothing is legal. const s = game(); switching(s, 1, false, [boxcar()]); + assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null); + }); - assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), 'NO_SWITCHING'); + it('refuses a move that would couple a car, and refuses dropping and sorting outright', () => { + const s = game(); + switching(s, 1, false, [boxcar()], [boxcar()]); + + assert.equal( + check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), + 'PICKUP_NOT_ALLOWED', + ); assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'NO_SWITCHING'); assert.equal(check(s, 0, { type: 'switch.sortConsist', trayId: 't', order: [0] }), 'NO_SWITCHING'); }); diff --git a/test/web.test.ts b/test/web.test.ts index 60603d2..ff0ab17 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -841,7 +841,7 @@ describe('board highlighting', () => { } assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`); assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`); - assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree'); + assert.ok(sp.label.includes(`(${sp.coord.col}, ${sp.coord.row})`), 'label and coord disagree'); } } }); @@ -3113,18 +3113,19 @@ describe('two crews switching are told apart', () => { assert.notDeepEqual(f.moves[0]!.from, f.moves[1]!.from); }); - it('leaves out a train the rules will not let switch at all', () => { - // `movesFor` is pure track geometry; six cards print "no switching" and `check` enforces it. - // Offering the Circus Train in a "which train are you switching?" row offers a train whose - // every move is refused. + it('still offers a no-switching train as a crew to switch — it may be moved, just not worked', () => { + // v0.4.9 — six cards print "no switching", which means may not couple, set out or sort, not + // "may never be touched": a train held at the Office may still need to clear onto Secondary + // Track ahead of other traffic. `check` refuses the coupling itself, not the move, so this row + // is not filtered any differently for a no-switching train than for any other. const game = twoCrews(); const s = game.state; const circus = [...s.trays.values()][0]!; circus.trainNumber = 18; circus.trainIsExtra = true; // X18 Circus Train — noSwitching const f = view(game); - assert.equal(f.moves.length, 1, 'a no-switching train was offered as a crew to switch'); - assert.equal(f.moves[0]!.label, 'Train 12'); + assert.equal(f.moves.length, 2, 'a no-switching train was left out of the crews offered to switch'); + assert.deepEqual(f.moves.map((m) => m.label).sort(), ['Train 12', 'Train X18']); }); });