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
+35 -2
View File
@@ -30,6 +30,7 @@ import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts';
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
import { playerAtSeat } from '../engine/state.ts';
import { cuesFor, narrate } from '../sim/narrate.ts';
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
// which would pull node:fs into a browser bundle.
@@ -53,7 +54,7 @@ import {
import type { Hand, HouseRuleOverrides, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
import { areaOf, trainNeedingCars } from '../engine/apply.ts';
import { areaOf, destinationsFor, selectDestination, trainNeedingCars } from '../engine/apply.ts';
import type { Frame } from '../sim/view.ts';
export const SOLO_CONFIG: GameConfig = {
@@ -99,7 +100,19 @@ export type ActionGroup = {
* Resolved HERE for the same reason `tip` is: a remote client holds no `GameState` and cannot look
* up where a tray is standing. See `docs/architecture/multiplayer.md` §5.
*/
actions: { index: number; label: string; tip?: string; coord?: { row: number; col: number } }[];
actions: {
index: number;
label: string;
tip?: string;
coord?: { row: number; col: number };
/**
* Every square a `switch.move` runs OVER on its way to `coord`, when there is more than one
* legal route there (docs/plans/switching-paths.md) — so hovering a route lights the whole
* road, not just its destination, which is the only way to tell two buttons reading "move to
* (0,0)" apart before clicking one.
*/
route?: { row: number; col: number }[];
}[];
};
/**
@@ -117,6 +130,23 @@ export function coordOf(i: Intent): { row: number; col: number } | null {
return null;
}
/**
* Every intermediate square a `switch.move` runs over, resolved the same way `execute` resolves
* `via` — so the squares that light up on hover are exactly the squares the move will actually
* couple cars from. `undefined` for the overwhelming majority of moves, which run in a straight
* line and need nothing beyond the destination `coordOf` already carries.
*/
function routeFor(state: GameState, i: Intent): { row: number; col: number }[] | undefined {
if (i.type !== 'switch.move') return undefined;
const tray = state.trays.get(i.trayId);
if (!tray || tray.position.at !== 'grid') return undefined;
const actor = playerAtSeat(state, tray.position.seat);
const dests = destinationsFor(state, actor, i.trayId, tray.position.coord, i.reverse);
const dest = selectDestination(dests, i.to, i.via);
if (!dest || dest.path.length === 0) return undefined;
return dest.path.map((step) => step.coord);
}
export type Game = {
state: GameState;
seed: number;
@@ -245,6 +275,7 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
tip?: string;
trayId?: string;
coord?: { row: number; col: number };
route?: { row: number; col: number }[];
};
const byKind = new Map<string, Entry[]>();
options.forEach((intent, index) => {
@@ -261,12 +292,14 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
*/
if (!list.some((a) => a.label === label && a.trayId === trayId)) {
const coord = coordOf(intent);
const route = routeFor(game.state, intent);
list.push({
index,
label,
...(tip ? { tip } : {}),
...(trayId ? { trayId } : {}),
...(coord ? { coord } : {}),
...(route ? { route } : {}),
});
}
byKind.set(intent.type, list);