switching paths: two routes to the same square

Builds docs/plans/switching-paths.md. A passing loop can offer two legal
routes between the same two squares, coupling different cars — the engine
only ever found one, an artifact of search order (Reported by Jesse, undo
379). exploreMoves now enumerates every simple route (per-path visited set,
capped at 4000 frontier nodes) and dedupes on outcome — destination, entry
side, and origin-tagged cars — rather than on reaching the square at all.

switch.move gains an optional `via: GridCoord` naming one intermediate
square on the chosen route; absent, it resolves exactly as before, so
every existing save and bot decision replays identically (575/575, then
579/579 with the new tests). Threaded through the label, the action-list
dedupe, the hover highlight (data-route), and the history (trayMoved.via).

Ruling recorded as Gap 14 in docs/rules/open-questions.md: the player may
choose the path; §A.4's "may not go around" a car does not reach a
different track the player declined to enter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAt2YCXgd5qCjBcF2x34aK
This commit is contained in:
Jesse
2026-08-19 16:52:10 -04:00
co-authored by Claude Sonnet 5
parent 7932bb6ccf
commit fbaa3d4147
14 changed files with 440 additions and 36 deletions
+52 -2
View File
@@ -21,8 +21,9 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
## 0.4.8 — 2026-08-19
Two reports from the same game, both about squares: which ones a card may go on, and which one a
button in the action list means.
Three reports from the same game, all about squares: which ones a card may go on, which one a button
in the action list means, and — when there is more than one legal road to the same square — which
one a train actually takes.
### The Limits bound the district, not just the Running Track
@@ -118,6 +119,55 @@ back out of the label: a remote client holds no `GameState` and cannot look up w
standing, which is the same reason the menu already carries card descriptions. Checked over 12 bot
games: 870 direct actions name a square, and all 870 carry it.
### Two routes to the same square
**Reported:**
> "There are times when a train can take two different paths to get to a destination. See game at
> undo 379. Train 11 can go from Eastern limits to the straight at 1,1 two different ways. It can go
> through the refinery and pick up the tanker on its nose, or it could go straight and then make the
> curve and skip the refinery and not pick up the car. The player should have the two different
> options."
A district with a passing loop — turnout off the main, curve down to an industry, curve back up —
offers two legal routes between the same two squares, and `exploreMoves` (`track.ts`) only ever found
one: it keyed its visited set on destination and entry port, both routes rejoin through the same
port, and the shorter one won the race before the longer one could be recorded. Which route survived
was an artifact of search order, not a choice.
**Ruling (Jesse, docs/rules/open-questions.md Gap 14):** the player may choose the path. §A.4 says
"cars **on your track**" — the industry spur is a different track, and declining to enter it is not
going around the car standing there. The mandatory half still bites in full once a route is chosen:
every car standing on it couples.
**The walk now enumerates every simple route** — a per-path visited set replaces the old global one,
so a card may be revisited across different routes but never twice within one — capped at 4,000
frontier nodes so a dense district cannot blow the walk up combinatorially; BFS order means the cap
loses only the longest routes first. Routes are deduped on **outcome**, not on reaching the square:
`(destination, entry side, [(origin card, car)…])`, so two routes coupling identical car *types* off
*different* cards are both offered and each sweeps its own card, while two routes with nothing to
tell apart collapse to one.
**`switch.move` gained an optional `via: GridCoord`** — one intermediate square on the chosen route,
never the start or the destination. `legal.ts` emits one intent per distinct route with a `via` that
distinguishes it from its siblings; `via` absent resolves exactly as before (the first route
enumerated), so every existing save and every bot decision replays identically. Checked against
`public/replays/*.json` and the full suite: 575 pass, 0 fail, unchanged.
Threaded through the label (`describeIntent` finds the route `via` names, so "couples 1 tanker"
reports the cars *that* route actually lifts), the action-list dedupe (two routes to one square would
otherwise print the identical button twice), the hover highlight (`data-route` lights every square a
route runs over, not just its destination), and the history (`trayMoved.via`, when the move was
ambiguous, names which road the crew took).
**Balance impact: none expected, and none measured able to be attributed to this.** Instrumented over
60 developer-bot games (17,884 destinations enumerated): squares reachable by 2+ distinct paths are
~1.2% (208), and in every one of those 208 the routes coupled identically — the bot has never yet
built a district where the discarded route would have mattered. 400 full games (200 developer bot,
200 random bot) completed 200/200 with no exceptions on the reworked walk. `compare.ts` needs an
ablation flag to run at all and this is not a bot heuristic, so paired A/B was not applicable here;
the enumeration-based argument above is the actual evidence.
### Also
- The three published replays were re-recorded. A save is a seed and its intents, so tightening a