switching paths: two routes to the same square

Builds docs/plans/switching-paths.md. A passing loop can offer two legal
routes between the same two squares, coupling different cars — the engine
only ever found one, an artifact of search order (Reported by Jesse, undo
379). exploreMoves now enumerates every simple route (per-path visited set,
capped at 4000 frontier nodes) and dedupes on outcome — destination, entry
side, and origin-tagged cars — rather than on reaching the square at all.

switch.move gains an optional `via: GridCoord` naming one intermediate
square on the chosen route; absent, it resolves exactly as before, so
every existing save and bot decision replays identically (575/575, then
579/579 with the new tests). Threaded through the label, the action-list
dedupe, the hover highlight (data-route), and the history (trayMoved.via).

Ruling recorded as Gap 14 in docs/rules/open-questions.md: the player may
choose the path; §A.4's "may not go around" a car does not reach a
different track the player declined to enter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAt2YCXgd5qCjBcF2x34aK
This commit is contained in:
Jesse
2026-08-19 16:52:10 -04:00
co-authored by Claude Sonnet 5
parent 7932bb6ccf
commit fbaa3d4147
14 changed files with 440 additions and 36 deletions
+21 -3
View File
@@ -66,7 +66,7 @@ import {
trackOrder,
turnOf,
} from './state.ts';
import type { MoveBlock, Occupancy, Port } from './track.ts';
import type { MoveBlock, MoveDestination, Occupancy, Port } from './track.ts';
import {
canDropCarsAt,
canPlaceAt,
@@ -631,7 +631,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
const from = trayCoord(s, i.trayId);
if (!from) return 'ILLEGAL_MOVE';
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
const dest = selectDestination(dests, i.to, i.via);
if (!dest) return 'ILLEGAL_MOVE';
/**
@@ -1207,6 +1207,23 @@ function checkPlay(
}
}
/**
* Among destinations at `to`, the one `via` names — or the first `legal.ts` enumerated there, if
* `via` is absent or names no route on record. That fallback is what makes an old save (or any
* caller that never learned about routing) replay exactly as before: the first-enumerated route is
* the only one that ever existed until this feature did.
*/
export function selectDestination(
dests: MoveDestination[],
to: GridCoord,
via: GridCoord | undefined,
): MoveDestination | undefined {
const atTo = dests.filter((d) => d.coord.row === to.row && d.coord.col === to.col);
if (via === undefined) return atTo[0];
const chosen = atTo.find((d) => d.path.some((step) => step.coord.row === via.row && step.coord.col === via.col));
return chosen ?? atTo[0];
}
function destinationsFor(
s: GameState,
player: PlayerIndex,
@@ -1303,7 +1320,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
case 'switch.move': {
const from = trayCoord(s, i.trayId)!;
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)!;
const dest = selectDestination(dests, i.to, i.via)!;
const tray = s.trays.get(i.trayId)!;
// The port the crew pulls out THROUGH — the same one `destinationsFor` explored from, so the
// cut it recouples on the way out is the cut the walk counted.
@@ -1343,6 +1360,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
* Move on one (§A.1) — so there is exactly one way out of it.
*/
facing: i.reverse ? dest.entry : farPort(areaOf(s, player).grid.get(coordKey(i.to)), dest.entry),
...(i.via ? { via: i.via } : {}),
},
];
if (dest.couples.length > 0) {