Files
station-master/docs/plans/switching-paths.md
T
JesseandClaude Opus 5 7932bb6ccf plan: switching paths — two routes to the same square
Planning only; no source files touched. The BFS visited set in exploreMoves
keys on (coord, entry), so two legal routes that rejoin through the same port
collapse to whichever is shorter — silently discarding the option that couples
cars on the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQAJ4dND7enLj54eLyiF2C
2026-08-19 13:39:53 -04:00

10 KiB
Raw Blame History

Plan — Train Switching Paths: two routes to the same square

Status: planned, not built. Written against 461c4d9 (v0.4.8). Reported by Jesse: "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 straight and then make the curve and skip the refinery and not pick up the car. The player should have the two different options."


1. The defect

A district with a passing loop — turnout down off the main, curve, industry, curve, turnout back up — offers two routes from one end to the rejoin square. Both are legal under §2.4 (a Move runs any distance without changing direction; neither route ever leaves a card by the port it entered). They differ in what they couple.

The engine offers only one of them, and which one is an artifact of search order.

exploreMoves (src/engine/track.ts:386) keys its visited set on destination and entry port:

// src/engine/track.ts:492
const key = `${coordKey(node.coord)}|${node.entry}`;
if (seen.has(key)) continue;

Both routes arrive at the rejoin square through the same port, so they collide on that key. The walk is breadth-first, so the shorter route is recorded and the longer one is discarded before it can become a destination.

Reproduction

Fixture — Running Track on row 0, industry spur on row −1:

col 0 col 1 col 2 col 3 col 4
row 0 straight turnout {w,e,s} straight turnout {e,w,s} Limits (east)
row −1 — curve ne Refinery + tanker curve nw —

Walking west from (0,4), today's engine returns:

(0,2)  entry=e  couples=[]        path=(0,3)
(-1,3) entry=n  couples=[]        path=(0,3)
(-1,2) entry=e  couples=[tanker]  path=(0,3)->(-1,3)
(0,0)  entry=e  couples=[]        path=(0,3)->(0,2)->(0,1)      ← only one route to (0,0)
(-1,1) entry=e  couples=[tanker]  path=(0,3)->(-1,3)->(-1,2)

The refinery route to (0,0) — which couples the tanker — is absent. A simple-path prototype on the same fixture returns both. Verified identical on v0.4.7 and v0.4.8.

It is not cosmetic

dest.path feeds carsCoupled.from (src/engine/apply.ts, the switch.move execute branch at :1303) — the list of cards swept clean when cars are lifted. The route reaches the reducer, so "which route" must be part of the intent, not just the button text.

How often

Instrumented over 60 developer-bot games (17,884 destinations enumerated):

measure count
destinations enumerated 17,884
squares reachable by 2+ distinct paths 208 (~1.2%)
squares where the routes differed in what they couple 0

So the geometry occurs and today's engine silently discards a route about 1% of the time — but the bot never builds a district where the discarded route matters. Two consequences:

  • Balance impact is expected to be nil. Still confirm with compare.ts (see §6).
  • The bot will never regression-test this. It needs explicit unit tests.

2. The rules question, and Jesse's ruling

§A.4 says: "If you move your Crew Tray into cars on your track, you MUST pick them up. You may not go around them." Whether taking the main instead of the siding counts as "going around" decides whether this feature is legal at all.

Ruling (Jesse): the player may choose the path. Different paths may pick up different cars, or none.

The permissive reading is well-supported by the text: §A.4 says "cars on your track", and the industry spur is a different track. Declining to enter it is not going around the tanker — it is not going to the tanker's track at all, which is ordinary railroading. The mandatory half still bites in full: once a route is chosen, every car standing on it couples, and there is no route that passes a car and leaves it.

Doc changes: add the clarifying sentence to §A.4 in docs/rules/rules-v0.2.md, and record the ruling in docs/rules/open-questions.md.


3. Design decisions

The discriminator is via: one intermediate GridCoord on the chosen route (Jesse's call). Not an index into the destination list — intents are the canonical record and undo replays history minus one intent, so an order-dependent field would silently reinterpret saved games.

// src/engine/intents.ts:19
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean; via?: GridCoord }
  • via names an intermediate card — never the start, never the destination.
  • Resolution: among destinations at to matching reverse, take the first enumerated route whose path contains via. Enumeration stays shortest-first.
  • via absent resolves exactly as today (first route at to), so existing histories replay unchanged. Note v0.4.8 already established that saves are not preserved across rule changes — this is belt-and-braces, not a hard constraint.
  • legal.ts emits a distinguishing via per route, so a generated intent is never ambiguous.

Collapse routes whose outcome is identical. Two routes that lift the same cars from the same cards are not a choice; offering both is noise in the action list and doubles the bot's branching factor for nothing.

The dedupe key must include the origin cards, not just car types. Two routes can couple an identical car multiset lifted from different cards. Keying on type alone would collapse them and sweep the wrong card clean. Key on (coord, entry, [(origin card, car)...]).

Simple-path walk with an enumeration cap. Relaxing seen to a per-path visited set keeps termination (no card twice within one path), but the number of simple paths is worst-case exponential in a dense district. Cap enumeration and prefer shortest routes when the cap bites.

Note: the 45°-diagonal matching rule appears to make closed loops geometrically impossible — a rectangle of four curves cannot close, because the slopes do not match at the corners. Do not rely on this. Use a path-local visited set and a cap regardless.


4. Change list

File Site Change
src/engine/track.ts exploreMoves :386, seen :492 Per-path visited set; dedupe on outcome; cap. Preserve shortest-first ordering.
src/engine/intents.ts :19 Add via?: GridCoord.
src/engine/apply.ts check :623 Select destination by via, not first-by-coord.
src/engine/apply.ts execute :1303 Same selection; carsCoupled.from follows the chosen path.
src/engine/apply.ts destinationsFor :1210, movesFor :1260 Unchanged signatures; movesFor still collapses to one square per coord for the board.
src/engine/legal.ts :90 Emit one intent per distinct route, each with a distinguishing via.
src/sim/view.ts describeIntent :724 Find the destination by via so the label names the cars that route couples.
src/web/game.ts coordOf :113, label dedupe ~:229 Labels must differ per route or the label-dedupe drops one button.
src/web/main.ts cellRef :660 Route-on-hover — see §5.
src/sim/narrate.ts move narration Name the route when it is not the only one.

ownCutFor (apply.ts:1241) needs no change — it depends only on the exit port, not the route.


5. UI — route on hover (Jesse's choice)

v0.4.8 already built the mechanism: coordOf(intent) → cellRef → data-square="r,c" on each action button, and hovering or focusing lights that square. This feature extends it from one square to a route.

  • Carry the path on the menu, resolved server-side, for the same reason coordOf is: a remote client holds no GameState.
  • Emit the route as a second attribute (data-route="r,c r,c …"); hovering or focusing lights every card the route runs over, with the destination keeping its existing stronger mark.
  • On focus as well as hover, matching v0.4.8 — the keyboard route is not a lesser one.

Target rendering:

Switching Train 11, standing at (1, 4)
  [ move to (1,1) ]
  [ move to (1,1) — couples 1 tanker on the way (onto the nose) ]   ← hover

6. Verification

  • Unit test the fixture in §1 — both routes offered, one coupling the tanker and one not.
  • via round-trip — each intent legal.ts emits resolves back to the route it was generated for.
  • Back-compat — replay public/replays/*.json and assert identical outcomes with via absent.
  • Origin-card dedupe — two routes coupling identical car types from different cards must both be offered, and each must sweep its own card.
  • Cap — a dense district must terminate and stay bounded.
  • Balance — node src/sim/compare.ts 1600. Expect no movement (the bot never builds this geometry); keep only at t ≥ 3 per README.md, and read the better/worse/identical split.

Run the suite serially: node --test --test-concurrency=1. The npm test script runs files in parallel and several of them invoke the static build against a shared dist/, which produces ~20 spurious failures. Measured on 461c4d9: parallel 20 fail / serial 575 pass, 0 fail.

v0.4.8's CHANGELOG attributes those 20 failures to the tracked node_modules symlink. That fix was real and worth making, but it is not the whole cause: on this checkout tsc resolves and typecheck exits 0, yet the same 20 fail under parallel execution and pass under --test-concurrency=1. The dist/ race is still there. Worth fixing separately — give each build-invoking test its own output directory.


7. Open question for Jesse

A route's cards may be reachable only as a subset of a longer route's cards. If that ever happens, no single via uniquely selects the shorter route, and it is addressable only by the shortest-first tiebreak. It did not arise in any fixture or in 60 bot games, and it may be geometrically impossible — but if it does arise, the alternatives are to reject the ambiguous intent or to let shortest-first stand. Shortest-first is assumed here.