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:
@@ -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.**
|
||||
Reference in New Issue
Block a user