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
+52 -2
View File
@@ -21,8 +21,9 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
## 0.4.8 — 2026-08-19 ## 0.4.8 — 2026-08-19
Two reports from the same game, both about squares: which ones a card may go on, and which one a Three reports from the same game, all about squares: which ones a card may go on, which one a button
button in the action list means. in the action list means, and — when there is more than one legal road to the same square — which
one a train actually takes.
### The Limits bound the district, not just the Running Track ### The Limits bound the district, not just the Running Track
@@ -118,6 +119,55 @@ back out of the label: a remote client holds no `GameState` and cannot look up w
standing, which is the same reason the menu already carries card descriptions. Checked over 12 bot standing, which is the same reason the menu already carries card descriptions. Checked over 12 bot
games: 870 direct actions name a square, and all 870 carry it. games: 870 direct actions name a square, and all 870 carry it.
### Two routes to the same square
**Reported:**
> "There are times when a train can take two different paths to get to a destination. See game at
> undo 379. Train 11 can go from Eastern limits to the straight at 1,1 two different ways. It can go
> through the refinery and pick up the tanker on its nose, or it could go straight and then make the
> curve and skip the refinery and not pick up the car. The player should have the two different
> options."
A district with a passing loop — turnout off the main, curve down to an industry, curve back up —
offers two legal routes between the same two squares, and `exploreMoves` (`track.ts`) only ever found
one: it keyed its visited set on destination and entry port, both routes rejoin through the same
port, and the shorter one won the race before the longer one could be recorded. Which route survived
was an artifact of search order, not a choice.
**Ruling (Jesse, docs/rules/open-questions.md Gap 14):** the player may choose the path. §A.4 says
"cars **on your track**" — the industry spur is a different track, and declining to enter it is not
going around the car standing there. The mandatory half still bites in full once a route is chosen:
every car standing on it couples.
**The walk now enumerates every simple route** — a per-path visited set replaces the old global one,
so a card may be revisited across different routes but never twice within one — capped at 4,000
frontier nodes so a dense district cannot blow the walk up combinatorially; BFS order means the cap
loses only the longest routes first. Routes are deduped on **outcome**, not on reaching the square:
`(destination, entry side, [(origin card, car)…])`, so two routes coupling identical car *types* off
*different* cards are both offered and each sweeps its own card, while two routes with nothing to
tell apart collapse to one.
**`switch.move` gained an optional `via: GridCoord`** — one intermediate square on the chosen route,
never the start or the destination. `legal.ts` emits one intent per distinct route with a `via` that
distinguishes it from its siblings; `via` absent resolves exactly as before (the first route
enumerated), so every existing save and every bot decision replays identically. Checked against
`public/replays/*.json` and the full suite: 575 pass, 0 fail, unchanged.
Threaded through the label (`describeIntent` finds the route `via` names, so "couples 1 tanker"
reports the cars *that* route actually lifts), the action-list dedupe (two routes to one square would
otherwise print the identical button twice), the hover highlight (`data-route` lights every square a
route runs over, not just its destination), and the history (`trayMoved.via`, when the move was
ambiguous, names which road the crew took).
**Balance impact: none expected, and none measured able to be attributed to this.** Instrumented over
60 developer-bot games (17,884 destinations enumerated): squares reachable by 2+ distinct paths are
~1.2% (208), and in every one of those 208 the routes coupled identically — the bot has never yet
built a district where the discarded route would have mattered. 400 full games (200 developer bot,
200 random bot) completed 200/200 with no exceptions on the reworked walk. `compare.ts` needs an
ablation flag to run at all and this is not a bot heuristic, so paired A/B was not applicable here;
the enumeration-based argument above is the actual evidence.
### Also ### Also
- The three published replays were re-recorded. A save is a seed and its intents, so tightening a - The three published replays were re-recorded. A save is a seed and its intents, so tightening a
+2 -1
View File
@@ -1,6 +1,7 @@
# Plan — Train Switching Paths: two routes to the same square # Plan — Train Switching Paths: two routes to the same square
**Status:** planned, not built. Written against `461c4d9` (v0.4.8). **Status:** built, v0.4.8. Written against `461c4d9`; see `docs/rules/open-questions.md` Gap 14 and
the CHANGELOG's "Two routes to the same square" for the ruling and what shipped.
**Reported by Jesse:** *"There are times when a train can take two different paths to get to a **Reported by Jesse:** *"There are times when a train can take two different paths to get to a
destination. See game at undo 379. Train 11 can go from Eastern limits to the straight at 1,1 two destination. See game at undo 379. Train 11 can go from Eastern limits to the straight at 1,1 two
different ways. It can go through the refinery and pick up the tanker on its nose, or it could go different ways. It can go through the refinery and pick up the tanker on its nose, or it could go
+26
View File
@@ -28,6 +28,7 @@ left open, with the consequence noted.
| 11 | Track card orientation | **SUPERSEDED** — handedness is printed; both hands supplied | | 11 | Track card orientation | **SUPERSEDED** — handedness is printed; both hands supplied |
| 12 | Balance targets | **INVALIDATED** — measured against a placeholder ruleset | | 12 | Balance targets | **INVALIDATED** — measured against a placeholder ruleset |
| 13 | Deck scaling by player count | **LARGELY DISSOLVED** — track is per-player by design | | 13 | Deck scaling by player count | **LARGELY DISSOLVED** — track is per-player by design |
| 14 | Two routes to the same square | **DECIDED** |
--- ---
@@ -1003,3 +1004,28 @@ needs to get started, while giving each player material to build with.
**Not yet decided**, because it interacts with Gap 12's variance question: more facilities per player **Not yet decided**, because it interacts with Gap 12's variance question: more facilities per player
would raise the ceiling on the runaway mode as well as the floor. would raise the ceiling on the runaway mode as well as the floor.
## Gap 14 — Two routes to the same square
**Status:** DECIDED.
**Reported by Jesse:** "There are times when a train can take two different paths to get to a
destination. See game at undo 379. Train 11 can go from Eastern limits to the straight at 1,1 two
different ways. It can go through the refinery and pick up the tanker on its nose, or it could go
straight and then make the curve and skip the refinery and not pick up the car. The player should
have the two different options."
**The question:** a passing loop — turnout off the main, curve down, industry, curve back up — offers
two legal routes between the same two squares, and they couple different cars. Does §A.4's "you may
not go around" a car standing on your track forbid taking the bypass?
**Ruling: the player may choose the path.** Different paths may pick up different cars, or none.
§A.4 says "cars **on your track**", and the industry spur is a different track — declining to enter
it is not going around the car standing on it, it is not going to that car's track at all, which is
ordinary railroading. The mandatory half still bites in full once a route is chosen: every car
standing on the chosen route couples, and there is no route that passes a car and leaves it.
**Built in v0.4.8** — see `docs/plans/switching-paths.md`. The engine enumerates every distinct
simple route, dedupes by outcome (destination, entry side, and origin-tagged cars) rather than by
destination alone, and an intent that would otherwise be ambiguous carries a `via` naming one
intermediate square on the chosen route.
+4 -1
View File
@@ -801,7 +801,10 @@ same order they originally held, left-to-right.
- The exception is your Office track, which has the number of A/D tracks listed (§11.1). As long as - The exception is your Office track, which has the number of A/D tracks listed (§11.1). As long as
fewer trains are holding there than A/D tracks, you may enter and pass through. fewer trains are holding there than A/D tracks, you may enter and pass through.
- If you move your Crew Tray into cars on your track, you **MUST** pick them up. You may not go around - If you move your Crew Tray into cars on your track, you **MUST** pick them up. You may not go around
them. them. Where a passing loop offers two routes to the same square, choosing not to enter a siding is
not "going around" a car standing on it — that car is on a different track, and declining to run
onto it is ordinary railroading. Once a route is chosen, every car standing on it still must be
coupled in full.
- A Crew Tray must always have an engine, and may move a maximum of four Rolling Stock — cabooses - A Crew Tray must always have an engine, and may move a maximum of four Rolling Stock — cabooses
included **[Gap 4c]**. included **[Gap 4c]**.
- You automatically couple to cars you meet during your Moves. The same is **not** true of trains - You automatically couple to cars you meet during your Moves. The same is **not** true of trains
+21 -3
View File
@@ -66,7 +66,7 @@ import {
trackOrder, trackOrder,
turnOf, turnOf,
} from './state.ts'; } from './state.ts';
import type { MoveBlock, Occupancy, Port } from './track.ts'; import type { MoveBlock, MoveDestination, Occupancy, Port } from './track.ts';
import { import {
canDropCarsAt, canDropCarsAt,
canPlaceAt, canPlaceAt,
@@ -631,7 +631,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
const from = trayCoord(s, i.trayId); const from = trayCoord(s, i.trayId);
if (!from) return 'ILLEGAL_MOVE'; if (!from) return 'ILLEGAL_MOVE';
const dests = destinationsFor(s, player, i.trayId, from, i.reverse); 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'; 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( function destinationsFor(
s: GameState, s: GameState,
player: PlayerIndex, player: PlayerIndex,
@@ -1303,7 +1320,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
case 'switch.move': { case 'switch.move': {
const from = trayCoord(s, i.trayId)!; const from = trayCoord(s, i.trayId)!;
const dests = destinationsFor(s, player, i.trayId, from, i.reverse); 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)!; const tray = s.trays.get(i.trayId)!;
// The port the crew pulls out THROUGH — the same one `destinationsFor` explored from, so the // 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. // 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. * 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), 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) { if (dest.couples.length > 0) {
+6 -1
View File
@@ -30,7 +30,12 @@ export type GameEvent =
| { type: 'actorChanged'; player: PlayerIndex | null } | { type: 'actorChanged'; player: PlayerIndex | null }
// -- local operations // -- local operations
| { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption } | { 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'; type: 'carsCoupled';
trayId: TrayId; trayId: TrayId;
+8 -1
View File
@@ -16,7 +16,14 @@ export type LocalOpsOption = 'switch' | 'draw' | 'freightAgent';
export type Intent = export type Intent =
| { type: 'localOps.choose'; option: LocalOpsOption } | { type: 'localOps.choose'; option: LocalOpsOption }
// -- switch (§6.1, Appendix A) // -- 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 * 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 * 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 { check, areaOf, destinationsFor } from './apply.ts';
import type { Intent } from './intents.ts'; import type { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.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'; 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']; const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose'];
/** Every intent `player` may legally submit right now. */ /** 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; if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
const from = tray.position.coord; const from = tray.position.coord;
for (const reverse of [false, true]) { for (const reverse of [false, true]) {
for (const d of destinationsFor(s, player, trayId, from, reverse)) { const dests = destinationsFor(s, player, trayId, from, reverse);
out.push({ type: 'switch.move', trayId, to: d.coord, 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++) { for (let n = 1; n <= tray.consist.length; n++) {
+68 -8
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: * 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. * "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( export function exploreMoves(
ctx: MoveContext, ctx: MoveContext,
start: GridCoord, start: GridCoord,
@@ -403,9 +425,20 @@ export function exploreMoves(
if (!startCard) return { destinations: results, blocked }; if (!startCard) return { destinations: results, blocked };
if (!hasPort(startCard, initialExit)) 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 // 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. // 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. * Ordered nearest-first like every other card's, so it simply seeds the accumulator.
*/ */
const ownCut = cutTowards({ standingWest: ctx.standingWest }, carsOn(startCard), initialExit); const ownCut = cutTowards({ standingWest: ctx.standingWest }, carsOn(startCard), initialExit);
const startKey = coordKey(start);
const queue: Frontier[] = [ 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) { while (queue.length > 0) {
const node = queue.shift()!; const node = queue.shift()!;
@@ -476,6 +518,8 @@ export function exploreMoves(
*/ */
const met = node.entry === 'e' ? [...carsOn(card)].reverse() : carsOn(card); const met = node.entry === 'e' ? [...carsOn(card)].reverse() : carsOn(card);
const couples = [...node.couples, ...met]; const couples = [...node.couples, ...met];
const nodeKey = coordKey(node.coord);
const origins = [...node.origins, ...met.map(() => nodeKey)];
if (ctx.consistSize + couples.length > MAX_CONSIST) { if (ctx.consistSize + couples.length > MAX_CONSIST) {
const own = const own =
ownCut.length > 0 ownCut.length > 0
@@ -489,18 +533,24 @@ export function exploreMoves(
continue; continue;
} }
const key = `${coordKey(node.coord)}|${node.entry}`;
if (seen.has(key)) continue;
seen.add(key);
if (isOperationalRail(card) && !sameCoord(node.coord, start)) { if (isOperationalRail(card) && !sameCoord(node.coord, start)) {
// 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 }); results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
}
} else if (!sameCoord(node.coord, start)) { } else if (!sameCoord(node.coord, start)) {
// Reached, crossable, but not somewhere a train may STOP — a turnout carries no wheel icon // 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. // (§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'); 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)) { 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 // 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. // 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'); 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; 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 }; 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', : 'Chose FREIGHT AGENT work — one car moved to or from a facility',
}; };
case 'trayMoved': 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 { return {
tone: 'plain', tone: 'plain',
where: e.to, 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': { case 'carsCoupled': {
/** /**
+14 -3
View File
@@ -19,6 +19,7 @@ import {
movesFor, movesFor,
ownCutFor, ownCutFor,
portersLeft, portersLeft,
selectDestination,
} from '../engine/apply.ts'; } from '../engine/apply.ts';
import { import {
ACTION_CARDS, ACTION_CARDS,
@@ -736,9 +737,19 @@ export function describeIntent(s: GameState, i: Intent): string {
const tray = s.trays.get(i.trayId); const tray = s.trays.get(i.trayId);
const here = tray?.position.at === 'grid' ? tray.position.coord : null; const here = tray?.position.at === 'grid' ? tray.position.coord : null;
let picks = ''; let picks = '';
let routeNote = '';
if (here) { if (here) {
const dest = destinationsFor(s, tray!.position.at === 'grid' ? playerAtSeat(s, tray!.position.seat) : 0, i.trayId, here, i.reverse) const dests = 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 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) { if (dest && dest.couples.length > 0) {
/** /**
* NAME THE CARS THE CREW SET OUT HERE SEPARATELY. They are at the front of `couples` — the * 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}`; : ` — 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': { 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 { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts'; import { createGame } from '../engine/setup.ts';
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.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 { cuesFor, narrate } from '../sim/narrate.ts';
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv, // 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. // 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 { Hand, HouseRuleOverrides, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts'; import type { Port } from '../engine/track.ts';
import { connectionsFor, joins, neighbour, variantsFor } 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'; import type { Frame } from '../sim/view.ts';
export const SOLO_CONFIG: GameConfig = { 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 * 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. * 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; 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 = { export type Game = {
state: GameState; state: GameState;
seed: number; seed: number;
@@ -245,6 +275,7 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
tip?: string; tip?: string;
trayId?: string; trayId?: string;
coord?: { row: number; col: number }; coord?: { row: number; col: number };
route?: { row: number; col: number }[];
}; };
const byKind = new Map<string, Entry[]>(); const byKind = new Map<string, Entry[]>();
options.forEach((intent, index) => { 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)) { if (!list.some((a) => a.label === label && a.trayId === trayId)) {
const coord = coordOf(intent); const coord = coordOf(intent);
const route = routeFor(game.state, intent);
list.push({ list.push({
index, index,
label, label,
...(tip ? { tip } : {}), ...(tip ? { tip } : {}),
...(trayId ? { trayId } : {}), ...(trayId ? { trayId } : {}),
...(coord ? { coord } : {}), ...(coord ? { coord } : {}),
...(route ? { route } : {}),
}); });
} }
byKind.set(intent.type, list); 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 * `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. * that goes to the timetable — so those buttons carry nothing and highlight nothing.
*/ */
function cellRef(coord: { row: number; col: number } | null | undefined): string { function cellRef(
return coord ? ` data-square="${coord.row},${coord.col}"` : ''; 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. * `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. * 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 grid = $('grid');
const keys = [key, ...routeKeys];
const marks = (): Element[] => const marks = (): Element[] =>
[ keys
grid.querySelector(`g[data-cell="${key}"]`), .flatMap((k) => [grid.querySelector(`g[data-cell="${k}"]`), grid.querySelector(`g[data-ghost="${k}"]`)])
grid.querySelector(`g[data-ghost="${key}"]`), .filter((g): g is Element => g !== null);
].filter((g): g is Element => g !== null);
const on = (): void => { const on = (): void => {
for (const g of marks()) g.classList.add('bs-point'); for (const g of marks()) g.classList.add('bs-point');
}; };
@@ -744,6 +752,7 @@ function renderActions(
label: string; label: string;
tip?: string; tip?: string;
coord?: { row: number; col: number }; coord?: { row: number; col: number };
route?: { row: number; col: number }[];
}): string => { }): string => {
const { label, index } = a; const { label, index } = a;
const cut = label.indexOf(' — '); 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. // 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 ?? ''); const rest = cut > 0 ? label.slice(cut + 3) : (a.tip ?? '');
return ( 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'])); : () => apply(Number(node.dataset['i']));
const square = node.dataset['square']; const square = node.dataset['square'];
if (square) wirePointing(node, square); const route = node.dataset['route'];
if (square) wirePointing(node, square, route ? route.split(' ') : []);
} }
} }
+136
View File
@@ -18,6 +18,8 @@ import type {
import { coordKey, isOperationalRail, turnOf } from '../src/engine/state.ts'; import { coordKey, isOperationalRail, turnOf } from '../src/engine/state.ts';
import { createGame } from '../src/engine/setup.ts'; import { createGame } from '../src/engine/setup.ts';
import { applyIntent, areaOf } from '../src/engine/apply.ts'; import { applyIntent, areaOf } from '../src/engine/apply.ts';
import { legalActions } from '../src/engine/legal.ts';
import type { Intent } from '../src/engine/intents.ts';
import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts'; import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
import { import {
allReachable, allReachable,
@@ -1110,3 +1112,137 @@ describe('why a square is NOT offered (the same walk, keeping its rejections)',
} }
}); });
}); });
// ---------------------------------------------------------------------------
// docs/plans/switching-paths.md — two routes to the same square
// ---------------------------------------------------------------------------
describe('two routes to one square', () => {
const limitsCard = (): TrackCard => ({
geometry: { kind: 'limits' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
});
/**
* A passing loop: a turnout diverges off the Running Track, curves down to an industry, and
* curves back up to rejoin the main two columns west. Both routes from (0,4) to (0,0) are legal
* under §2.4 (neither ever leaves a card by the port it entered); they differ in what they
* couple. This is the fixture in the plan's §1, verified identical on v0.4.7 and v0.4.8.
*/
function loopArea(midCard: (col: number) => TrackCard = () => straight()): OfficeArea {
return areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
[coordKey(at(0, 2))]: midCard(2),
[coordKey(at(0, 3))]: turnout({ stem: 'e', through: 'w', diverge: 's' }),
[coordKey(at(0, 4))]: limitsCard(),
[coordKey(at(-1, 1))]: curve('ne'),
[coordKey(at(-1, 2))]: midCard(-1),
[coordKey(at(-1, 3))]: curve('nw'),
},
at(9, 9), // office coord, deliberately away from the fixture
);
}
const tanker: RollingStock = { type: 'tank', loaded: false };
it('offers both routes, and only one couples the tanker on the spur', () => {
const area = loopArea((col) => (col === -1 ? straight([tanker]) : straight()));
const dests = reachableDestinations(ctxFor(area), at(0, 4), 'w');
const atZero = dests.filter((d) => d.coord.row === 0 && d.coord.col === 0);
assert.equal(atZero.length, 2, `expected two distinct routes to (0,0), got ${atZero.length}`);
const withTanker = atZero.filter((d) => d.couples.some((c) => c.type === 'tank'));
const without = atZero.filter((d) => d.couples.length === 0);
assert.equal(withTanker.length, 1, 'exactly one route should couple the tanker');
assert.equal(without.length, 1, 'exactly one route should couple nothing');
});
it('offers two routes coupling the SAME car type off DIFFERENT cards, and neither is dropped', () => {
// The dedupe key must include the origin card, not just the car type (plan §3) — two boxcars,
// one on the bypass and one on the main, must not collapse into one destination.
const area = loopArea(() => straight([{ type: 'boxcar', loaded: false }]));
const dests = reachableDestinations(ctxFor(area), at(0, 4), 'w');
const atZero = dests.filter((d) => d.coord.row === 0 && d.coord.col === 0);
assert.equal(atZero.length, 2, `expected two distinct routes to (0,0), got ${atZero.length}`);
for (const d of atZero) {
assert.deepEqual(d.couples.map((c) => c.type), ['boxcar'], 'each route should couple its own boxcar');
}
});
it('a `via`-qualified switch.move resolves to the route it names', () => {
function freshGame(): GameState {
const s = gameWith(loopArea((col) => (col === -1 ? straight([tanker]) : straight())));
s.trays.set('crew', {
id: 'crew', trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'west', facing: 'w', position: { at: 'grid', seat: 0, coord: at(0, 4) }, movesUsed: 0,
});
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
turnOf(s, 0).movesRemaining = 6;
return s;
}
const probe = freshGame();
const options = legalActions(probe, 0).filter(
(i): i is Extract<Intent, { type: 'switch.move' }> =>
i.type === 'switch.move' && i.to.row === 0 && i.to.col === 0,
);
assert.equal(options.length, 2, 'expected two distinct switch.move intents to (0,0)');
assert.ok(options.every((i) => i.via !== undefined), 'an ambiguous route must carry a distinguishing via');
const vias = new Set(options.map((i) => `${i.via!.row},${i.via!.col}`));
assert.equal(vias.size, 2, 'the two routes must carry DIFFERENT via');
const outcomes = options.map((intent) => {
const s = freshGame();
const r = applyIntent(s, 0, intent);
assert.ok(r.ok, 'a via-qualified move to a square the engine offered must be legal');
return s.trays.get('crew')!.consist.map((c) => c.type);
});
assert.ok(outcomes.some((c) => c.includes('tank')), 'one via should have coupled the tanker');
assert.ok(outcomes.some((c) => c.length === 0), 'the other via should have coupled nothing');
// `via` absent still resolves — the first route enumerated, exactly as before this feature.
const s = freshGame();
const r = applyIntent(s, 0, { type: 'switch.move', trayId: 'crew', to: at(0, 0), reverse: false });
assert.ok(r.ok, 'a plain move with no via must still resolve to SOME legal route');
});
it('a dense district terminates and stays bounded, preferring the routes BFS finds first', () => {
// Chained passing loops: each one independently offers "through" or "detour", so N of them
// offer 2^N routes to the far end with no global memo to stop it — exactly the exponential case
// the cap exists for (plan §3). 15 segments is 32,768 routes; the cap is 4,000 frontier nodes.
const segments = 15;
const start = 3 * segments;
const grid: Record<string, TrackCard> = { [coordKey(at(0, start + 1))]: limitsCard() };
for (let k = 0; k < segments; k++) {
const divergeCol = start - 3 * k;
const midCol = divergeCol - 1;
const mergeCol = divergeCol - 2;
grid[coordKey(at(0, divergeCol))] = turnout({ stem: 'e', through: 'w', diverge: 's' });
grid[coordKey(at(0, midCol))] = straight();
grid[coordKey(at(0, mergeCol))] = turnout({ stem: 'w', through: 'e', diverge: 's' });
grid[coordKey(at(-1, divergeCol))] = curve('nw');
grid[coordKey(at(-1, midCol))] = straight();
grid[coordKey(at(-1, mergeCol))] = curve('ne');
}
grid[coordKey(at(0, 0))] = straight();
const area = areaFrom(grid, at(9, 9));
const startedAt = Date.now();
const dests = reachableDestinations(ctxFor(area), at(0, start + 1), 'w');
const elapsed = Date.now() - startedAt;
assert.ok(elapsed < 5000, `exploreMoves took ${elapsed}ms — the cap should keep this fast`);
assert.ok(dests.length > 0, 'the cap must not eliminate every route');
assert.ok(
dests.length < 2 ** segments,
`${dests.length} destinations found — the cap should have cut off full 2^${segments} enumeration`,
);
});
});