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