diff --git a/docs/plans/switching-paths.md b/docs/plans/switching-paths.md new file mode 100644 index 0000000..f2ae10f --- /dev/null +++ b/docs/plans/switching-paths.md @@ -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.**