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
10 KiB
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 }
vianames an intermediate card — never the start, never the destination.- Resolution: among destinations at
tomatchingreverse, take the first enumerated route whose path containsvia. Enumeration stays shortest-first. viaabsent resolves exactly as today (first route atto), 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.tsemits a distinguishingviaper 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
coordOfis: a remote client holds noGameState. - 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.
viaround-trip — each intentlegal.tsemits resolves back to the route it was generated for.- Back-compat — replay
public/replays/*.jsonand assert identical outcomes withviaabsent. - 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 perREADME.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_modulessymlink. That fix was real and worth making, but it is not the whole cause: on this checkouttscresolves and typecheck exits 0, yet the same 20 fail under parallel execution and pass under--test-concurrency=1. Thedist/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.