diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ad9f6..919bc71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,271 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie ## Unreleased +### Sharp curves are out of the deck + +Eight cards, dealt zero copies. The only thing that made a sharp curve different from an ordinary one +was `moveCost: 2`, and nothing ever charged it — every switching move costs exactly 1, hard-coded — so +they were geometric duplicates taking eight draws from a deck the rebalance already considers too +diluted. `track.test.ts` even had a case called *"a sharp curve differs from a curve only in the Moves +it costs"* which asserted the two were identical, describing a difference that did not exist. + +Jesse's call: take them out rather than build the Move cost, since a per-card movement cost is a +change to the Move model and the rebalance can wait. The rows stay in the catalogue at zero, exactly +as Poling does, so the design is still visible and the geometry still works if they are ever dealt +again. **Deck 243 → 235, track 104 → 96, solitaire 221 → 213.** + +Four tests broke on the new deck composition and none of them was wrong about the game — all four +were resting on a single seed or a threshold that had drifted, so all four are now measured properly: + +- **The oscillation detector ran on one seed.** Measured across sixteen: thirteen games show no + aimless shuttling at all and three reach a run of five. So it is a minority behaviour, not the + every-game waste the test was written for — it now asserts a rate (most games clean) plus a ceiling. +- **Interlocking reaches the board in 7/60 games, down from 15/60.** The bot has been pulled toward + other work by departure Revenue and spends its opening on the track it was dealt. Floor lowered to + match, and recorded as drift rather than quietly restated. +- **The action list reaches 9 buttons, up from 8** — all of them Switching, because the opening deal + grew districts from 17.9 to 20.3 cards and a crew has more squares it can legally reach. The list + getting longer for a good reason, not the cross-products that test was written to kill. +- **A test hoped seed 111 would deal an Office upgrade.** It now puts one in hand. + +Bot after all of it: **7.0 mean, median 5.0, wins 5/200**, districts 18.8 cards. + +### The special-train rules are enforced + +All nine. They had been declared on `TrainRules` and read by nothing, so a Crack Limited could shunt +an industry and a Military train could be worked by Porters — the restrictions are what make a +special train special, and none of them applied. Each is checked where the act happens, and a LOCAL +CREW (`trainNumber: null`) is exempt from every one of them: it has no card, so it has no rules. + +- **noSwitching** (both expresses, Light Engine, Campaign, Circus, Military) — moving, setting out + and sorting are all refused. All three are switching. +- **dropOnly** (X13 Appleseed) and **pickUpEmptiesOnly** (X22 Pee-Dee) — enforced on the MOVE, not on + a coupling action, because coupling is mandatory (§A.4): there is no running over cars and leaving + them, so the restriction has to bite on the move that would pick them up or it cannot bite at all. +- **oneFreightPerLocation** (trains 3/4 Express) — a budget per SQUARE rather than per turn, which is + what "one freight car at every location" says: the Express works a car here, moves on, works + another there. Dropping and picking up share the one budget, because the card says "drop OR pick + up". Kept in `turn.freightWorked`, keyed by tray and square, cleared with the turn. +- **noPassengerWork** (Military, Director's car) and **terminalsOnly** (Crack Limited) — Porters + refuse the train. `canBoard`/`canDetrain` now filter the trains at the Office rather than taking + any of them, so the rule actually bites; a new `passengerRefusal` works out whether a card is the + reason and says which, because "no train at the Office" was both wrong and unhelpful when there was + a train standing right there. +- **stopThenExpedite** (X17 Campaign) — the speeches happen at the first Office it reaches: that + arrival is an ordinary stop, and every arrival after it is expedited. A `speechMade` flag on the + tray, alongside `stopPointClaimed`, for the same reason — it is the TRAIN that stops, and an Extra + runs once. + +**`coachStaysOnStationTrack` could not be built as intended, and the reason is worth recording.** The +chosen reading was "the coach may only be set out at the Office", but §A.4 refuses the Office square +to *every* drop — "the Office track is Operational Rail, but Rolling Stock may not be left there" — +so "only at the Office" and "nowhere at all" are the same rule. It is enforced as the effect that +survives: the Local may shunt its freight car around the district and may not abandon its coach doing +it. If the station track is meant to become a real place to leave a coach, that is a change to §A.4 +rather than to this card; noted in `TODO.md`. + +**`copiesNextScheduled` was deleted rather than implemented.** No train card ever carried it: a +Second Section is a Maneuver card played on a train due out, with its own intent +(`newTrain.secondSection`) that has worked all along. It was an unreachable second description of a +mechanic that already existed, and it had been sitting in the "nine rules read by nothing" count as +the one entry that needed removing. + +**Cost, measured over 200 games: revenue 8.0 → 7.2 and wins 14/200 → 6/200.** Restrictions make the +game harder, which is the point — but it is worth knowing that enforcing §7 took back about a sixth +of what the departure-Revenue rule gave. The train tooltip no longer says "NOT YET ENFORCED BY THE +ENGINE"; it says what each rule does, because the restriction is the character of the card. + +## 0.3.0 — 2026-08-12 + +### Two rule changes, both provisional, both measured + +**The opening deal is now 3 track + 3 other, from two separately shuffled piles.** Track is shuffled +apart from the rest, each player is dealt three of each, and the leftover track is shuffled back in +for the rest of the game — the split is an opening-deal device only. A player opens holding six +against a hand limit of three, so the first turn is spent choosing which district they can afford; +§6.2 already permits "any or all" cards to be played in a turn and puts no cap on discards, so the +reduction can always be made and the engine needed no special first-turn case. + +This is the answer to the run-around problem, which was measured five ways and is a SUPPLY problem: +a turnout and a matching-hand curve were in hand together on 0.2% of turns, about once every eight +games. **It worked, modestly** — run-arounds **4/60 → 7/60**, districts **17.9 → 20.3 cards** — with +revenue unmoved on its own (−0.1, inside noise). Nowhere near the 91/100 of the private-supply era, +so the supply question is softened rather than answered. + +**One Revenue for every train that clears your section.** Paid when a train is highballed out of your +Office, once per train, and not again when it later runs off the end of the Division — that far +Division Point belongs to whoever is seated at it. A local switching crew has no train number and +earns nothing. Worth exactly **+5.40 a game**: departures run at 5.40 and each pays 1. The bot goes +**2.7 → 8.0 mean, median 0 → 6.5, wins 2/200 → 14/200**. Unlike freight and passengers it pays for +traffic the player does not have to work, which is the point — keeping the line clear is the +Superintendent's job and nothing paid for doing it well. It also makes the victory target live: 20 +over 5 Days is now reachable largely on traffic. + +**A regression that came with the deal, recorded rather than hidden.** Forced to shed on turn one, +the bot lays pieces it should discard: track butting a card that cannot accept it went from 7% to +15%. A player would simply discard the ones with nowhere good to go. The regression floor in +`sim.test.ts` was raised from 0.15 to 0.25 with the reasoning written in, and it is in `TODO.md` as +bot work for after the rebalance. It also means part of that district-size gain is padding. + +**Found while fixing the tests this broke.** Three of them were passing on luck rather than checking +anything, and the deal change exposed all three at once: the commodity test asserted a tank car is +set out somewhere in 30 games when it happens in **3% of games**; the replay-sound test asserted six +cue kinds from a single seed when the bot couples in only **20%** of games and sets cars out in 43%; +and two web tests depended on a seed happening to deal a particular card. All four are now decisive — +pooled seeds, a sample sized to the base rate, or the card put in hand deliberately. Separately, +`moveTrain`'s branch for departing an Office straight onto a Division Point turns out to be +**unreachable**: `buildDivision` always lays `DP · Mainline · Office · Mainline · … · DP`, so an +Office is never adjacent to one. It is kept correct rather than deleted, and `setup.test.ts` now +asserts the flanking invariant that makes it dead. + +All five published replays were re-recorded: the deal changes what every seed deals, so the previous +recordings died at 2 intents of ~350. + +### Nine things playtesting found + +Eight fixes and one new rule, all from one session at the table. Two of the nine turned out not to be +bugs at all, and saying why is most of the work in those. + +**Left and right were on the wrong diagonal — for turnouts and for curves, the same way.** The engine +called a card `left` when a train entering at its points saw the diverging route leave to its +**right**: `{stem:'w', through:'e', diverge:'s'}` heads east, and south is the driver's right. Curves +inherited the same inversion, because a curve takes its hand from the turnout whose leg it continues. +Every track card a player has ever held was labelled as its mirror. **The artwork was never wrong** — +`board-svg.ts` draws rails from `connectionsFor` geometry alone — so this is words only, and the fix +flips the `hand` field on the `TRACK_CARDS` rows together with `CURVE_VARIANTS` and +`TURNOUT_VARIANTS`. The row ORDER is deliberately left alone: `setup.ts` builds the deck by walking +that array, so a row's position decides which physical card a seed deals, and reordering to look tidy +would silently re-deal every published replay. Verified by fingerprinting seed 1234's whole track +deck before and after — 101 cards, byte-identical, and the three replays that survived the rest of +this work prove it in practice. + +**A turnout can now be laid on top of a card already down.** Reported as: a district can only hang off +a turnout, so a player who laid a straight along the main and then wanted to branch there had no move +at all — the piece had to have been a turnout when it went down. A turnout may now upgrade a straight +at any rotation, or a curve whose arc matches its own diverging leg. Both are strict port supersets of +what they replace, so an upgrade can never sever a join a neighbour relies on and needs no connection +test; the new leg may reach nothing, which is the point. Blocked on a standing car (`UPGRADE_OCCUPIED`) +and on a built Interlocking or Telegraph (`UPGRADE_ENHANCED`) — both about the card being in use rather +than its shape. The rule is stated in ARCS rather than hands so the flip above cannot reach it. The bot +found and played it 40 times across 40 games without being taught anything. + +**A Depot showed three MEN | AT | WORK boxes it has no Laborer to work.** An Office is a Passenger +Facility and the sign is printed "For Freight Facilities" (§9.1), but every facility got a three-slot +array of nulls and the renderers loop it — the green and red rows either side *were* guarded and the +MAW row was not, in all three renderers. Fixed in the MODEL rather than the renderers: `menAtWork` is +now `null` on a passenger facility, which is what the "Freight only" comment on that field has claimed +all along. The type flushed out every reader, freight work is now refused on the shape of the facility +rather than on it happening to hold 0 Laborers, and a fourth renderer cannot reintroduce the bug. Two +more artifacts of the same "an Office is a Facility" modelling went with it: a Depot read +**SHIPS + RECEIVES**, an industry's flow word, and was drawn a siding square although its industry +track has length 0. + +**The side panel painted inbound loads green.** Its shared box helper used one class for every filled +box, so the red row rendered green while the board SVG on the same screen drew it red — two views of +one card disagreeing about the colour code at the same moment. Green is outbound everywhere now, red +inbound, grey for the siding, which is a place rather than a direction. + +**The card just drawn now sits first in the hand, badged NEW.** The engine pushes onto the end, which +with a wrapping row put the card you just turned over wherever the eye is least likely to be, among +two others that look exactly like it. Reversed in the DISPLAY — `actionMenu` for the play page and +`snapshot` for both replay viewers — and deliberately not in the engine: the bot iterates its hand to +generate options, so moving the stored order would reshuffle its tie-breaks and invalidate every +revenue figure in `TODO.md`. Confirmed: 2.8 before, 2.7 after, over 200 games. + +**Two Ice Houses could be built in one district.** Industries have been barred from doubling up since +Q4, but a Modifier is a different card kind and had no such check at all. One of a kind per Office +Area now. Enhancements are deliberately left alone — an Interlocking is a plant at one junction, so a +second on another straight is a different installation. This is the change that killed a published +replay: `seed-4894942` had recorded a game that played two Waiting Areas, so it was a recording of +illegal play and has been re-recorded. + +**NOT A BUG — "the Ice House added the laborer but not the outbound slot".** The card prints +1 +*outbound*; a Grocer's Warehouse is `flow: 'inbound'`, so `allows.outbound` is false and the capacity +was being raised on a direction that can never be drawn or stocked. The laborer landed because +Laborers have no direction. An industry's printed flow stays absolute — no modifier turns a receiver +into a shipper — so the grant is now dropped rather than credited, and the reason is said out loud in +both directions: the card in hand names which of its printed hosts cannot use which half, and the +facility panel reports a suppressed bonus instead of quietly showing a number that did not move. The +same trap catches Truck Dock and Forklifts on a Grocer's, and Waiting Area, Restaurant and Hotel on a +Whistle Post. Two things fell out of this: `TrackCard.modifiers` was initialised everywhere and +appended nowhere, so the panel's "Modifier cards standing beside this industry" row had always been +empty; and a Waiting Area was lengthening the Office's `industryTrack`, which drew a Depot a siding to +spot cars on — the phantom siding above, arriving by another door. That one was found by rendering +12,000 frames of real games, not by a fixture, and it is why the smoke pass exists. + +**An Interlocking on the board was a bare label.** It now carries hover text from the card catalogue, +and the one card nothing reads says so. **Seven of the ten resolve in play**: the dispatch chain +(Telegraph, Telephone, Radio) plus Interlocking, which holds an arrival at the Limits when the Office +is full; Yard Office, which diverts a coachless train; Small Yard, which re-orders a consist; and ABS +Signals, stored on the Mainline node rather than in `enhancements[]`. Facing Point Locks and the Water +Column are wired and read but answer opponent-directed cards a solitaire deck omits. **Overpass alone +has no code path anywhere**, and is the only one that carries the warning. + +**Got this badly wrong first time, and it is worth recording how.** The table was filled in by +grepping for four helper function names — `dispatchBonus`, `hasDistrictEnhancement`, +`isProtectedFromDerail`, `watertowersRemovable` — and reading "no match" as "no implementation". But +Interlocking, Yard Office and Small Yard are read *by key* in `advance.ts` and `apply.ts`, and ABS +Signals through `node.absSignals`, so five of ten statuses were wrong and the tooltip told players +that four working cards did nothing — worse than the bare label it replaced. `enhancements.test.ts` +had covering tests for all four, passing, the whole time. The test written to guard the table made it +worse rather than catching it: it asserted `effect === 'live'` if and only if the rule carried a +`dispatchBonus`, which restates the assumption that produced the error instead of checking it. It now +asserts the three sets by name, and every row cites the file that reads it, because a status without a +citation beside it is a claim nobody checked. + +### Three rules read back from the sheet + +Jesse's follow-up after the above. Two were already right, which is worth recording so nobody +re-investigates them; one was a real gap that had been sitting in plain sight. + +**Clearance asked about the next CARD, not the next Subdivision.** §8.1 is explicit — "if there is a +train in the next **Subdivision** moving towards the considered train, the considered train will not +depart" — and `evaluateClearance` only ever inspected `node.transits`, the single card being entered. +A train ran headlong into a Subdivision an opposing train was two cards deep in and was stopped only +on the Stage they actually met. `subdivisions()` had been sitting in `state.ts` for this the whole +time, called by nothing outside a test. Red Flags and ABS Signals now read the card the OTHER train +stands on rather than the card being entered; those were the same card while this only looked one +ahead, and the protection belongs where the train it protects actually is. Worth knowing how much +this bites: every Office starts as a Whistle Post, so the whole railroad is ONE Subdivision until +someone upgrades, and each upgrade to a Control Point splits one in two and buys capacity back. The +bot is unmoved (**2.7 both sides, 200 games**) because solitaire schedules only ~1.3 trains a game — +this is a rule that earns its keep with 2–5 players. + +**Straight-placed enhancements replace the straight, so they no longer stack.** An Interlocking's +printed placement is "any Running Track Straight": it goes down IN PLACE OF the straight, and what +stands there afterwards is an Interlocking, not a straight carrying one. Nothing checked it — an +enhancement only pushes a string onto `enhancements[]` and leaves the geometry alone — so a single +straight could hold Interlocking, Telegraph and a Water Column at once. **The Telegraph → Telephone → +Radio chain is untouched and is not an exception**: Telephone prints "on Telegraph" and Radio "on +Telephone", so those target a named card rather than a straight, which is exactly why they still +stack. The printed placements settle it; `requiresOnSameCard` already enforced it. + +**ALREADY CORRECT, twice.** Control Points and Subdivisions were modelled properly — `isControlPoint` +is false only for a Whistle Post, and `subdivisions()` splits between them — so only the clearance +*use* of them was missing. And §8.2's "moved immediately to the Office, where it stops for orders" was +already the behaviour: `arriveAtOffice` returns `moved`, and the caller adds the train to +`movedThisPhase`. The one exception is deliberate and recovered — Q3, an expedited train departs the +Stage it arrives, and gets a second `moveTrain` still subject to §8.1. + +**A missing §8.1 condition, restored to the ruleset.** Jesse read the highball conditions back off the +source sheet and the first one — *"the train did not just initially arrive from a mainline card at the +Office this Stage"* — was absent from `rules-v0.2.md` entirely. The BEHAVIOUR was right, but only as a +side effect: the Mainline Phase visits each train once per Stage in numeric order, so a train that +spends its visit arriving has no visit left to depart with. Nothing anywhere stated the rule, which +makes it precisely the sort of property a later change to that loop breaks in silence. Now written +into §8.1 and pinned by tests, together with the observation that **this is the condition the printed +`Expedite` rule exists to override** — without it, `Expedite` was a special case with no general rule +to be special against, which is why Q1/Q3 found it so hard to place. All six conditions now have +coverage, including the Gap 2b carve-out: a train standing clear on Secondary Track in a Whistle Post +district must not hold up the Subdivision, which the new subdivision-wide scan had to be careful not +to break. + +**Verification.** 438 tests (up from 416) and a clean `tsc`. Beyond the unit tests: the track deck +fingerprint before and after the flip; all published replays replayed end to end; the bot baseline +re-measured against a worktree at HEAD; and 40 real games rendered frame by frame through the same +functions the page calls — 12,169 frames, which is where the Waiting Area siding surfaced. Not done: +nobody has clicked through this in a browser. + ## 0.2.0 — 2026-08-09 ### The bot plays twice as well, and a rule it was never following diff --git a/README.md b/README.md index 6262e85..c0e6838 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ train into an occupied Subdivision. Get that wrong and two trains meet at speed. ## Status -**v0.1.0 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure, +**v0.3.0 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure, imports nothing outside itself, and never touches `Math.random`, so a static host is all it needs. - **Rules** — fully specified. Ten gaps in the original prototype rules found and resolved. @@ -22,8 +22,10 @@ imports nothing outside itself, and never touches `Math.random`, so a static hos no server, no turn submission and no per-player view), the 22 opponent-directed cards, and real audio. -Balance is *not* where it should be: the developer bot averages 1.4 Revenue against a target of 20. -`TODO.md` says why, and says which of it is the bot and which is the deck. +Balance is *not* where it should be: the developer bot averages 7.0 Revenue against a target of 20 — +of which ~5.4 is the "one Revenue per train that clears your section" rule, so the working freight +and passenger economy is still only ~2. `TODO.md` says why, and says which of it is the bot and which +is the deck. Versions follow the convention at the top of [`CHANGELOG.md`](CHANGELOG.md): third digit for fixes, second for a set of features, 1.0 for the first release that deserves the name. @@ -94,13 +96,35 @@ is the thing this machinery exists to prevent. - **State is `fold(events)`.** The event log is the source of truth, which is what gives reconnection, restart recovery and post-game replay from a single decision. - **Never call `Math.random()`.** One ambient random call silently breaks replay. -- **Track is a deck card.** 104 of the 243 cards in the Home Office deck are track — the largest - category — so a district is built from what you draw, and building it costs you the industry or - train you drew instead. There is no separate supply and no one-piece-a-turn cap. +- **Track is a deck card, but the opening district is dealt.** 96 of the 235 cards are track — the + largest category — so a district is built from what you draw, and building it costs you the + industry or train you drew instead. The one exception is setup: track is shuffled separately and + each player is dealt **3 track + 3 other**, with the leftover track shuffled back in afterwards. + You therefore open holding six against a limit of three, and the first turn is spent choosing. + Provisional — see `TODO.md`. +- **Clearing the line scores.** Every train highballed out of your Office earns one Revenue, paid + once. It is worth +5.4 a game, more than the entire freight and passenger economy put together, + and it pays for traffic you do not have to work. Also provisional. +- **A turnout can be laid on top of a card already down.** It upgrades a straight at any rotation, or + a curve whose arc matches its own diverging leg — both strict port supersets of what they replace, + so an upgrade can never sever an existing join. Without it a district could only hang off track that + happened to be a turnout when it went down. Blocked by a standing car or a built Enhancement; the + replaced card leaves play, as board cards always do. `checkTurnoutUpgrade` in `src/engine/apply.ts`. +- **A Subdivision is the unit of clearance, not a card.** §8.1 asks whether the next *Subdivision* + holds an opposing train (an absolute bar) or a following one (the Superintendent's call). Every + Office starts as a Whistle Post, which is not a Control Point, so the whole railroad begins as ONE + Subdivision — that is why early traffic is so constrained, and why upgrading an Office to a Control + Point splits one in two and buys capacity. `subdivisions()` in `src/engine/state.ts`. +- **A Passenger Facility handles no freight, structurally.** `menAtWork` is `null` on a Depot, Station + or Terminal rather than an unused array, so freight work is refused on the shape of the facility and + no renderer can draw a pipeline that cannot exist. The converse holds: industries carry no porters. - **The track is 45° geometry, not a graph on a grid.** Measured off `docs/tracks.png`: the through rail runs east–west across the *exact vertical middle* of every card, there is no north–south track anywhere, and everything that leaves through the north or south edge does so at **45°, through the middle of that edge**. So two ports meeting is not enough to make a rail — two 45° legs can meet at the same point and still form a V. Adjacency is `joins()` in `src/engine/track.ts`, never a bare pair of `hasPort()` calls. A printed card turns 180° but never flips, so its handedness fixes which - diagonal its leg lies on for good, and a run-around needs one card of each hand. + diagonal its leg lies on for good, and a run-around needs one card of each hand. **Left is `nw_se`** + — named from the points, the side a train entering a turnout sees its diverging route leave toward, + with curves following the turnout whose leg they continue. Both tables had it inverted until + playtesting caught it; the geometry was never wrong, only the words. diff --git a/TODO.md b/TODO.md index 6e45438..1e9590e 100644 --- a/TODO.md +++ b/TODO.md @@ -13,9 +13,37 @@ Ordered within each section by how much it is currently costing us. 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 243 of which 104 are track, so every draw is diluted by 43% — precisely the pressure - those multipliers exist to relieve. Until then, read no balance conclusion from the revenue + 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. +- [ ] **REVIEW THE TWO NEW RULES ONCE THEY HAVE BEEN PLAYED — both went in provisional.** Jesse's + call, both implemented and measured, both flagged in `rules-v0.2.md`. + + **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.** Worth exactly **+5.40 a game** — + departures run at 5.40 and each pays 1 — taking the bot from 2.7 to 8.0 mean, median 0 → 6.5, + wins 2/200 → 14/200. It more than doubles the score on its own and pays for traffic the player + does not have to work. **This makes the victory-target question live**: 20 over 5 Days is now + reachable largely on traffic, which is either the intent or an argument for raising the target. +- [ ] **BOT DRIFT ACROSS THIS RELEASE — three measurements, all for the rebalance pass.** Recorded + together so the pattern is visible rather than three relaxed thresholds nobody adds up: + - **Track laid badly, 7% → 15%** of pieces butting a card that cannot accept them. Forced to + shed on turn one, the bot would rather lay a piece than discard it; a player would discard + the ones with nowhere good to go. It also means the district-size gain from the new deal is + partly padding rather than useful railroad. + - **Interlocking placed, 15/60 → 7/60 games.** Departure Revenue pulls the bot toward other + work and it spends its opening on the track it was dealt. + - **Aimless shuttling in 3 games of 16** — thirteen are clean, so this is a minority behaviour + 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, @@ -171,46 +199,134 @@ Ordered within each section by how much it is currently costing us. --- +## 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] **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 + `left` curve is arc `sw`, which turns an eastbound train **right** as well. One consistent sign + error in the hand↔diagonal mapping, and it mislabels every track card a player ever holds. + **The artwork is right and does not change** — `board-svg.ts:462` draws rails from + `connectionsFor()` geometry alone, so only words are wrong. **Decision: flip the `hand` value on + the `TRACK_CARDS` rows AND the two variant tables in the same commit**, so the code keeps + speaking left/right like the physical supply and now means it. Keep the **row order** in + `TRACK_CARDS` untouched: `setup.ts:95` builds the deck by iterating that array, so flipping only + the labels leaves pre-shuffle slot 32 holding a `ne_sw` curve before and after, and + `variantsFor(…)[0]` still `'sw'` — same seed, same board, and every published replay still + plays. Also: `track.ts:126` arc fallback, `track.ts:191-212` doc block, `view.ts:1041` and + `view.ts:1208` diagonal phrases, `bot.ts:837-852` `arcInHand`, seven test files, and the prose + plus ~14 `data-tip="Turnout · left"` strings in `docs/design/track-geometry.html` — which has no + generator and must be hand-edited. Verify by fingerprinting a fixed seed's board before and + after: identical geometry, different words. +- [x] **A turnout should be playable as an UPGRADE, on top of a card already down.** On a straight, + or on a curve whose arc matches the turnout's diverging leg. Nothing like this exists — the only + "upgrade" in the game is the Office tier change, which is explicitly not a card swap + (`apply.ts:1290`), and `canPlaceAt` hard-stops at `if (existing && !isMovableSign) return false` + (`track.ts:518`). **Decision: cars AND enhancements both block it** — `standing.length > 0` → + `UPGRADE_OCCUPIED`, `enhancements.length > 0` → `UPGRADE_ENHANCED`, so an Interlocked straight + stays a straight. Express the curve rule on **arcs, not hands**, so it survives the flip above. + No extra connection requirement: all four turnout orientations are port supersets of a straight + and of any same-arc curve, so an upgrade can never sever an existing join, and the new leg is + allowed to dangle — that is what it is for. The lifted card leaves play, which is already how + board cards behave (`apply.ts:1249` salvages only cards that were *not* placed). Reuse + `card.play` with a placement on an occupied square; `legal.ts:252` must offer those squares for + turnouts, and the `attachments` set at `legal.ts:137` is already exactly that list. + **The other half of this report needs no work:** a turnout carries the through route + (`carriesThroughTrack`), so it is already legal at a Limit, along the Running Track and on + Secondary Track. Confirmed, not re-investigated. +- [x] **A Depot shows three MEN AT WORK boxes it can never work.** Offices are Passenger Facilities — + no freight — but `setup.ts:176` gives every one a three-slot `menAtWork` array, and the three + renderers loop it with no guard while the green and red rows beside them *are* guarded + (`board-svg.ts:548`, `panels.ts:218`, `replay.ts:522`). **Decision: all three tiers** — Depot, + Station and Terminal are all Passenger Facilities and all get `laborers: 0`, so the boxes are + inert on every one. **Fix it in the model, not the renderers**, so it cannot reappear in a + fourth place: make `menAtWork` nullable and null for passenger facilities, which is what the + "Freight only" comment at `state.ts:153` has claimed all along. Freight handling must be neither + allowed nor displayed there. Two more artifacts of the same "an Office is a Facility" modelling + go with it: `board-svg.ts:562` calls a Depot **"SHIPS + RECEIVES"**, an industry's flow word, + and `board-svg.ts:599`'s `Math.max(1, trackCap)` draws it a siding slot although its industry + track has length 0. `FacilityView` needs a `kind` field; it has no freight/passenger flag today. +- [x] **Two Ice Houses can be built in one district.** Industries already ban duplicates per Office + Area — `isLockedOut` (`apply.ts:1774`) covers the Mine Tipple half of the report — but **Ice + House is a Modifier, not an industry**, and `checkPlay`'s modifier branch (`apply.ts:662`) has + no duplicate check at all. **Decision: extend the ban to modifier kinds, leave enhancements + alone.** An Interlocking is a plant at one junction, so a second on another straight is a + different installation, and the Telegraph → Telephone → Radio chain is already gated per card. + Expect modifiers with `copies > 1` to go partly dead in solitaire, where there is one Office + Area — correct, since the spare copies exist for other players' districts. +- [x] **A drawn card lands at the far end of the hand with nothing to mark it.** `apply.ts:1219` + pushes, the hand renders in state order (`game.ts:355`), and with `flex-wrap` the newest card is + exactly where the eye is least likely to be. **Decision: reverse in the display layer, not the + engine** — `view.ts:898-899` (reversing `hand` and `handWhat` identically, or they desync) + covers the replay viewer and the standalone replay together, and `game.ts:355` covers the play + page. Keeping `hand.push` means the bot's option-iteration order does not move, so every revenue + figure in this file stays comparable; an engine `unshift` would invalidate the lot. The marker + is play-page only — a `Frame` carries card names, not ids, so a replay cannot say which card + arrived that step. Follow the `game.scheduled` precedent for a `justDrawn` field, but make the + badge **persist** rather than flash: it says *which card is new*, not *something just happened*. + A static `::before` badge as `.handcard.target` does it (`panels.ts:262`), no keyframe — the + innerHTML rebuild would restart an animation on every render. Clear it in `renderUndo()` and + after `fromSave()`, or a fresh page load badges last session's draw. +- [x] **The inbound boxes render green in the side panel.** Green is outbound and red is inbound + everywhere the colour carries direction — `board-svg.ts:528-545`, `replay.ts:452`, rules §9.1 — + except `panels.ts`, whose shared `boxes()` helper (`panels.ts:162`) emits class `f` for every + filled box regardless of direction, so the red row at `panels.ts:222` comes out green. The board + SVG on the same page draws it correctly, which makes the panel actively contradict the board. + Give `boxes()` its class from the caller as `replay.ts` already does, add `.box.r` in + `board-svg.ts:820`'s palette, and take the siding off green in both panels and `replay.ts:525`. +- [x] **An Interlocking on the board is a bare label, and it does nothing.** The enhancement text is + drawn with no tooltip (`board-svg.ts:651`) while the copy already exists as data in + `ENHANCEMENT_CARDS` (`content.ts:589`). Done: every card prints its effect, and the one nothing + reads says so. **CORRECTED — the first pass had five of the ten statuses wrong.** It claimed + only Telegraph/Telephone/Radio were live, because the survey grepped for four helper function + names and read "no match" as "no implementation". In fact **seven are live**: those three plus + Interlocking (`advance.ts:770`), Yard Office (`advance.ts:750`), Small Yard (`apply.ts:405`) and + ABS Signals (`advance.ts:599`, stored on the Mainline node). Facing Point Locks and Water Column + 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. +- [x] **A modifier's grant can land on a direction its host cannot use, and nothing says so.** + Reported as "Ice House added the laborer but not the outbound slot" — **checked, and there is no + bug**: Ice House prints +1 *outbound*, a Grocer's Warehouse is `flow: 'inbound'` so + `allows.outbound` is false, and the capacity was raised on a direction that can never render or + be stocked. The laborer arrived because laborers have no direction gate. **Decision: an + industry's printed flow is absolute** — drop the grant on hosts that cannot use it rather than + opening the direction up. So `applyModifier` (`apply.ts:1788`) gates each capacity grant on + `allows` and grows `industryTrack.length` only by what was actually applied. **Audit all 17 + profiles for the same trap:** `iceHouse`, `truckDock` and `forklifts` all print `addOut: 1` and + list `grocersWarehouse`; `waitingArea`, `restaurant` and `hotel` print `addOut: 1` for + `hosts: ['office']`, which includes a Whistle Post. Then say it in both directions — the hand + tooltip naming which printed hosts cannot use which half (computable from the profiles, no host + 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. -- [ ] **PROPOSED RULE — deal each player six track cards at the start.** Jesse's proposal, and it - aims squarely at the measured problem above: a run-around needs five specific pieces and the - bot holds a turnout and a matching curve together on 0.2% of turns. Six pieces in the opening - hand is roughly the private supply the prototype had, in a form that does not reintroduce an - unbounded one — and it would make the opening district a decision rather than a wait. Questions - before it goes in: are the six drawn from the track cards in the deck (thinning it for - everyone) or from outside it; do they sit in the hand, against the three-card limit, or in a - separate track hand that does not compete; and does the bot's opening change enough to need - re-measuring (it will — this is the one change that could move the run-around numbers). -- [ ] **PROPOSED RULE — one Revenue point for every train that exits your section.** Jesse's - proposal. Worth noting what it would do to the economy as measured: arrivals run at 5.4 a game - and completed runs at about the same, so this is roughly **+5 Revenue a game** against a - current mean of 2.6 — it would more than triple the score and, unlike freight or passengers, it - pays for traffic the player does not have to work. That may be exactly the intent (it rewards - keeping the line clear, which is the Superintendent's job) but it changes what the game is - about, and it interacts with the victory-target question below: 20 over 5 Days becomes - reachable almost on traffic alone. Cheap to measure once decided. - - [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**. -- [ ] **NINE OF THE TWELVE SPECIAL-TRAIN RULES ARE DECLARED AND READ BY NOTHING.** Found by grepping - each flag on `TrainRules` for a reader outside `content.ts`. Only `expedite`, `emptiesOnly` and - `freightTypes` are enforced; `stopEarnsPoint` now is too, after a playtest reported the Circus - Train standing still for a Stage and earning nothing. Still unbuilt: - - `noSwitching` · `terminalsOnly` · `coachStaysOnStationTrack` · `oneFreightPerLocation` · - `noPassengerWork` · `dropOnly` · `pickUpEmptiesOnly` · `stopThenExpedite` · `copiesNextScheduled` - - These are what make a special train special — a Circus Train that may not switch, a per-diem - train that may only pick up empties, a Second Section that copies the train ahead. Until they - are enforced, `trainRules()` marks them **NOT YET ENFORCED BY THE ENGINE** in the train's - tooltip rather than listing them as if they applied, because telling a player a rule is in - force when it is not is worse than saying nothing. Each is small on its own; the question is - whether they are worth building before the rebalance, since several of them restrict switching - and would move the freight numbers. +- [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` @@ -270,6 +386,41 @@ target is settled and freight carries its intended share. ## 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. diff --git a/docs/design/track-geometry.html b/docs/design/track-geometry.html index f10c0f3..2defdf3 100644 --- a/docs/design/track-geometry.html +++ b/docs/design/track-geometry.html @@ -186,31 +186,31 @@ td:first-child{color:var(--dim);text-transform:uppercase;letter-spacing:.07em;fo
-
Turnout · left
+
Turnout · right

w–e · w–sne_sw

Frog on the west end, leg out through the middle of the south edge. Turn it 180° and the leg goes north instead.

-
Turnout · right
+
Turnout · left

e–w · e–snw_se

The mirror card. Same shape, opposite diagonal — which is the whole reason a siding needs one of each hand.

-
Curve · left
+
Curve · right

s–wne_sw

The leg without the through track: along the centre line to the frog, then out at 45°.

-
Curve · right
+
Curve · left

s–enw_se

-

Its right-hand twin. Both cards also turn 180°, reaching ne and nw respectively.

+

Its left-hand twin. Both cards also turn 180°, reaching ne and nw respectively.

@@ -228,14 +228,14 @@ td:first-child{color:var(--dim);text-transform:uppercase;letter-spacing:.07em;fo

Two cards stacked. Both pairs have the ports; in both, the two legs meet the shared edge at the same point. Only one of them is a railway.

-
turnout · leftcurve · left
+
turnout · rightcurve · right

Joins

-

Both legs lie on the ne_sw diagonal, so the rail crosses the card edge without bending. This is the left-hand turnout with its own left-hand curve.

+

Both legs lie on the ne_sw diagonal, so the rail crosses the card edge without bending. This is the right-hand turnout with its own right-hand curve.

-
turnout · leftcurve · right
+
turnout · rightcurve · left

Does not join

-

The right-hand curve is on nw_se. The rails touch and turn straight back — a V, not a junction. The engine refuses it, and the picture has to show why.

+

The left-hand curve is on nw_se. The rails touch and turn straight back — a V, not a junction. The engine refuses it, and the picture has to show why.

@@ -243,7 +243,7 @@ td:first-child{color:var(--dim);text-transform:uppercase;letter-spacing:.07em;fo

04A district

The Running Track between the Limits, with a leg going up at the west end and a siding hanging below at the east. Because no card joins north to south, dropping a row always costs two cards — the leg out, then a curve turning the run back east–west. Districts come out wide and shallow.

-
LimitsWhistle Postturnout · left 180°straightturnout · leftstraightturnout · rightLimitscurve · leftcurve · leftRefinerycurve · rightRUNNING TRACK
+
LimitsWhistle Postturnout · right 180°straightturnout · rightstraightturnout · leftLimitscurve · rightcurve · rightRefinerycurve · leftRUNNING TRACK