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:
+21
-3
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user