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
+19 -9
View File
@@ -656,8 +656,16 @@ function renderDistrict(f: Frame): void {
* `null` for the actions that act on no square at all — ending a turn, drawing a card, a train card
* that goes to the timetable — so those buttons carry nothing and highlight nothing.
*/
function cellRef(coord: { row: number; col: number } | null | undefined): string {
return coord ? ` data-square="${coord.row},${coord.col}"` : '';
function cellRef(
coord: { row: number; col: number } | null | undefined,
route?: { row: number; col: number }[],
): string {
const square = coord ? ` data-square="${coord.row},${coord.col}"` : '';
// Only a `switch.move` with more than one legal route carries this (docs/plans/switching-paths.md)
// — every intermediate square the chosen route runs over, so hovering lights the whole road, not
// just where it ends.
const path = route && route.length > 0 ? ` data-route="${route.map((c) => `${c.row},${c.col}`).join(' ')}"` : '';
return square + path;
}
/**
@@ -676,13 +684,13 @@ function cellRef(coord: { row: number; col: number } | null | undefined): string
* `data-ghost` target, which is precisely the case where the player is choosing between coordinates.
* Matching only one of the two would leave the most mistake-prone moment unhelped.
*/
function wirePointing(node: HTMLElement, key: string): void {
function wirePointing(node: HTMLElement, key: string, routeKeys: readonly string[] = []): void {
const grid = $('grid');
const keys = [key, ...routeKeys];
const marks = (): Element[] =>
[
grid.querySelector(`g[data-cell="${key}"]`),
grid.querySelector(`g[data-ghost="${key}"]`),
].filter((g): g is Element => g !== null);
keys
.flatMap((k) => [grid.querySelector(`g[data-cell="${k}"]`), grid.querySelector(`g[data-ghost="${k}"]`)])
.filter((g): g is Element => g !== null);
const on = (): void => {
for (const g of marks()) g.classList.add('bs-point');
};
@@ -744,6 +752,7 @@ function renderActions(
label: string;
tip?: string;
coord?: { row: number; col: number };
route?: { row: number; col: number }[];
}): string => {
const { label, index } = a;
const cut = label.indexOf(' — ');
@@ -751,7 +760,7 @@ function renderActions(
// The menu resolved the card's description server-side, so the page never needs the state.
const rest = cut > 0 ? label.slice(cut + 3) : (a.tip ?? '');
return (
`<button class="act" data-i="${index}"${cellRef(a.coord)}${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
`<button class="act" data-i="${index}"${cellRef(a.coord, a.route)}${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
);
};
@@ -933,7 +942,8 @@ function renderActions(
}
: () => apply(Number(node.dataset['i']));
const square = node.dataset['square'];
if (square) wirePointing(node, square);
const route = node.dataset['route'];
if (square) wirePointing(node, square, route ? route.split(' ') : []);
}
}