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:
+52
-2
@@ -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
|
||||
|
||||
Two reports from the same game, both about squares: which ones a card may go on, and which one a
|
||||
button in the action list means.
|
||||
Three reports from the same game, all about squares: which ones a card may go on, which one a button
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- The three published replays were re-recorded. A save is a seed and its intents, so tightening a
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 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
|
||||
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
|
||||
|
||||
@@ -28,6 +28,7 @@ left open, with the consequence noted.
|
||||
| 11 | Track card orientation | **SUPERSEDED** — handedness is printed; both hands supplied |
|
||||
| 12 | Balance targets | **INVALIDATED** — measured against a placeholder ruleset |
|
||||
| 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
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
included **[Gap 4c]**.
|
||||
- You automatically couple to cars you meet during your Moves. The same is **not** true of trains
|
||||
|
||||
+21
-3
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(' ') : []);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
import { coordKey, isOperationalRail, turnOf } from '../src/engine/state.ts';
|
||||
import { createGame } from '../src/engine/setup.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 {
|
||||
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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user