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:
+35
-2
@@ -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
@@ -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(' ') : []);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user