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) {
+6 -1
View File
@@ -30,7 +30,12 @@ export type GameEvent =
| { type: 'actorChanged'; player: PlayerIndex | null }
// -- local operations
| { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption }
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w' }
/**
* `via` mirrors the intent's `via` (docs/plans/switching-paths.md) — present only when there was
* more than one legal route to `to`, so the history can say which one ran rather than leaving a
* choice the player made invisible in their own log.
*/
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
| {
type: 'carsCoupled';
trayId: TrayId;
+8 -1
View File
@@ -16,7 +16,14 @@ export type LocalOpsOption = 'switch' | 'draw' | 'freightAgent';
export type Intent =
| { type: 'localOps.choose'; option: LocalOpsOption }
// -- switch (§6.1, Appendix A)
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean }
/**
* `via` names one INTERMEDIATE card on the chosen route, for the rare case where two routes to
* `to` couple different cars (see docs/plans/switching-paths.md) — never the start, never `to`
* itself. Absent, it resolves exactly as it always has: the first route `legal.ts` enumerated to
* `to`. An index into the destination list would do the same job worse — intents are the
* canonical record `undo` replays against, and enumeration order is not a stable thing to save.
*/
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean; via?: GridCoord }
/**
* Set out a cut. `fromNose` takes it off the FRONT of the train rather than the back — Appendix A's
* worked example does exactly this: "Back up and drop off everything on the nose of your train
+44 -3
View File
@@ -17,9 +17,38 @@ import { enhancementRule } from './content.ts';
import { check, areaOf, destinationsFor } from './apply.ts';
import type { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
import { seatOf } from './state.ts';
import { coordKey, seatOf } from './state.ts';
import type { MoveDestination } from './track.ts';
import { variantsFor } from './track.ts';
/**
* A square GridCoord this route passes through that no OTHER route to the same destination does —
* the discriminator `switch.move.via` needs so `selectDestination` (apply.ts) never has to guess
* which of two routes an intent meant. `siblings` is every route the same `destinationsFor` call
* returned at this same square; `route` must be one of them.
*
* Absent when `route` is the only route there (the overwhelmingly common case — `via` need not be
* carried at all), or when the destination is one hop away, which is never ambiguous: the square
* immediately before a given entry port is fixed by geometry, so a zero-length path cannot have a
* sibling.
*/
function distinguishingVia(route: MoveDestination, siblings: readonly MoveDestination[]): GridCoord | undefined {
if (route.path.length === 0) return undefined;
const otherSquares = new Set<string>();
for (const sibling of siblings) {
if (sibling === route) continue;
for (const step of sibling.path) otherSquares.add(coordKey(step.coord));
}
for (const step of route.path) {
if (!otherSquares.has(coordKey(step.coord))) return step.coord;
}
// Every square on this route is shared with some sibling — geometrically possible only if the
// routes fork and rejoin more than once, which `track.ts`'s doc comment says not to rely on not
// happening. Naming the last square before the destination at least matches today's behaviour:
// wrong in the same way a route with no `via` at all would be.
return route.path[route.path.length - 1]!.coord;
}
const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose'];
/** Every intent `player` may legally submit right now. */
@@ -86,8 +115,20 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
const from = tray.position.coord;
for (const reverse of [false, true]) {
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
out.push({ type: 'switch.move', trayId, to: d.coord, reverse });
const dests = destinationsFor(s, player, trayId, from, reverse);
// Grouped by destination square so `distinguishingVia` only ever compares routes that are
// actually racing for the same button — two routes to DIFFERENT squares need no `via` to
// tell apart, `to` already does that.
const byCoord = new Map<string, MoveDestination[]>();
for (const d of dests) {
const k = coordKey(d.coord);
(byCoord.get(k) ?? byCoord.set(k, []).get(k)!).push(d);
}
for (const group of byCoord.values()) {
for (const d of group) {
const via = group.length > 1 ? distinguishingVia(d, group) : undefined;
out.push({ type: 'switch.move', trayId, to: d.coord, reverse, ...(via ? { via } : {}) });
}
}
}
for (let n = 1; n <= tray.consist.length; n++) {
+69 -9
View File
@@ -383,6 +383,28 @@ export type MoveBlock = { coord: GridCoord; kind: MoveBlockKind; why: string };
* beyond a reachable one whose rails do not meet. Squares the walk never came near are not listed:
* "there is no track between here and there" is not news.
*/
/**
* How many frontier nodes a single `exploreMoves` walk may enqueue before it stops finding new
* routes. A per-path visited set (below) guarantees every individual route terminates, but the
* NUMBER of simple paths through a dense district is worst-case exponential — a real board never
* gets close, but nothing stops a pathological one from being built. BFS order means the queue
* fills shortest-route-first, so hitting the cap loses only the longest, least-likely-to-matter
* routes; it never loses a shorter one in favour of a longer one.
*/
const MAX_ENUMERATED_FRONTIER = 4000;
/**
* The identity of a route's OUTCOME, for dedupe: which square it ends at, which side it entered
* by, and exactly which cars it couples, each tagged with the card it came off. Two routes that
* couple the same car TYPES from DIFFERENT cards are not the same outcome — sweeping the wrong
* card clean is exactly the bug this key exists to avoid — so the origin card rides along with
* every car, not just its type.
*/
function routeOutcomeKey(coord: GridCoord, entry: Port, couples: RollingStock[], origins: string[]): string {
const cars = couples.map((c, idx) => `${origins[idx]}:${c.type}:${c.loaded ? 1 : 0}`).join(',');
return `${coordKey(coord)}|${entry}|${cars}`;
}
export function exploreMoves(
ctx: MoveContext,
start: GridCoord,
@@ -403,9 +425,20 @@ export function exploreMoves(
if (!startCard) return { destinations: results, blocked };
if (!hasPort(startCard, initialExit)) return { destinations: results, blocked };
const seen = new Set<string>();
const resultKeys = new Set<string>();
type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[] };
type Frontier = {
coord: GridCoord;
entry: Port;
path: MoveStep[];
couples: RollingStock[];
/** Coord key each entry in `couples` came off, aligned by index — see `routeOutcomeKey`. */
origins: string[];
/** Cards visited on THIS route, start included. A per-path set, not a global one — see the
* module doc comment on `MAX_ENUMERATED_FRONTIER` for why a global one would forbid the very
* routes this walk exists to find. */
visited: Set<string>;
};
// The very first hop is checked here because `start`'s card is not itself enqueued; every later
// hop is checked at the push site below, where both sides of the edge are in hand.
@@ -427,9 +460,18 @@ export function exploreMoves(
* Ordered nearest-first like every other card's, so it simply seeds the accumulator.
*/
const ownCut = cutTowards({ standingWest: ctx.standingWest }, carsOn(startCard), initialExit);
const startKey = coordKey(start);
const queue: Frontier[] = [
{ coord: first, entry: opposite(initialExit), path: [], couples: ownCut },
{
coord: first,
entry: opposite(initialExit),
path: [],
couples: ownCut,
origins: ownCut.map(() => startKey),
visited: new Set([startKey]),
},
];
let enumerated = 1;
while (queue.length > 0) {
const node = queue.shift()!;
@@ -476,6 +518,8 @@ export function exploreMoves(
*/
const met = node.entry === 'e' ? [...carsOn(card)].reverse() : carsOn(card);
const couples = [...node.couples, ...met];
const nodeKey = coordKey(node.coord);
const origins = [...node.origins, ...met.map(() => nodeKey)];
if (ctx.consistSize + couples.length > MAX_CONSIST) {
const own =
ownCut.length > 0
@@ -489,18 +533,24 @@ export function exploreMoves(
continue;
}
const key = `${coordKey(node.coord)}|${node.entry}`;
if (seen.has(key)) continue;
seen.add(key);
if (isOperationalRail(card) && !sameCoord(node.coord, start)) {
results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
// Dedupe on OUTCOME, not on reaching the square: two routes that end here having coupled
// different cars (or the same cars off different cards) are a real choice, and both are
// offered. Two routes that end here having coupled identically are not a choice — one is
// noise doubling the button list and the bot's branching factor for nothing.
const rkey = routeOutcomeKey(node.coord, node.entry, couples, origins);
if (!resultKeys.has(rkey)) {
resultKeys.add(rkey);
results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
}
} else if (!sameCoord(node.coord, start)) {
// Reached, crossable, but not somewhere a train may STOP — a turnout carries no wheel icon
// (§A.1). Trains run through one all day; they just cannot finish a Move on it.
block(node.coord, 'noStopping', 'a train may run through here but not stop — this is not Operational Rail (§A.1), so it cannot be the end of a Move');
}
if (enumerated >= MAX_ENUMERATED_FRONTIER) continue;
for (const exit of exitsFrom(card, node.entry)) {
// Slope is a property of the EDGE, not of either card, so it can only be tested with both in
// hand. Enqueueing on `hasPort` alone routed trains across a 45° leg that bent back on itself.
@@ -510,8 +560,18 @@ export function exploreMoves(
if (next) block(to, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal');
continue;
}
// A card twice on ONE route is a loop, not a longer route (§2.4 travels without changing
// direction; it never says without repeating ground, but a train cannot occupy the same
// track twice at once either). Per-path, not global — a DIFFERENT route may legitimately
// pass through a card this one already used.
const toKey = coordKey(to);
if (node.visited.has(toKey)) continue;
if (enumerated >= MAX_ENUMERATED_FRONTIER) break;
enumerated++;
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples });
const visited = new Set(node.visited);
visited.add(toKey);
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins, visited });
}
}
+4 -1
View File
@@ -126,10 +126,13 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
: 'Chose FREIGHT AGENT work — one car moved to or from a facility',
};
case 'trayMoved':
// `via` rides on the event only when there was another legal route to the same square
// (docs/plans/switching-paths.md) — so naming it here says which one the crew actually took,
// rather than leaving a real choice invisible in the crew's own history.
return {
tone: 'plain',
where: e.to,
text: `CREW moved ${at(e.from)} → ${at(e.to)} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
text: `CREW moved ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
};
case 'carsCoupled': {
/**
+14 -3
View File
@@ -19,6 +19,7 @@ import {
movesFor,
ownCutFor,
portersLeft,
selectDestination,
} from '../engine/apply.ts';
import {
ACTION_CARDS,
@@ -736,9 +737,19 @@ export function describeIntent(s: GameState, i: Intent): string {
const tray = s.trays.get(i.trayId);
const here = tray?.position.at === 'grid' ? tray.position.coord : null;
let picks = '';
let routeNote = '';
if (here) {
const dest = destinationsFor(s, tray!.position.at === 'grid' ? playerAtSeat(s, tray!.position.seat) : 0, i.trayId, here, i.reverse)
.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
const dests = destinationsFor(s, tray!.position.at === 'grid' ? playerAtSeat(s, tray!.position.seat) : 0, i.trayId, here, i.reverse);
const dest = selectDestination(dests, i.to, i.via);
// Two routes to the same square (docs/plans/switching-paths.md) would otherwise print the
// identical button twice — "move to (0,0)" and "move to (0,0)" — and the action list drops
// duplicate labels, silently discarding the second choice. `via` is the one thing that
// differs in the DATA, so it is the one thing safe to print without guessing at scenery
// this function has no other reason to know the name of.
const atSameSquare = dests.filter((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
if (dest && i.via && atSameSquare.length > 1) {
routeNote = ` via ${at(i.via)}`;
}
if (dest && dest.couples.length > 0) {
/**
* NAME THE CARS THE CREW SET OUT HERE SEPARATELY. They are at the front of `couples` — the
@@ -756,7 +767,7 @@ export function describeIntent(s: GameState, i: Intent): string {
: ` — couples ${carsLabel(dest.couples)} on the way${end}`;
}
}
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}${picks}`;
return `move to ${at(i.to)}${routeNote}${i.reverse ? ' (reverse)' : ''}${picks}`;
}
case 'switch.dropCars': {
/**
+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);
+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(' ') : []);
}
}