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
This commit is contained in:
Jesse
2026-08-19 13:39:53 -04:00
co-authored by Claude Opus 5
parent 461c4d9bac
commit 7932bb6ccf
+206
View File
@@ -0,0 +1,206 @@
# 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*:
```ts
// 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.
```ts
// 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.**