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
|
## 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(' ') : []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user