diff --git a/CHANGELOG.md b/CHANGELOG.md index 7414ac5..362edae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,9 @@ page as `v0.1.0 · · `, 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 diff --git a/docs/plans/switching-paths.md b/docs/plans/switching-paths.md index f2ae10f..b6da640 100644 --- a/docs/plans/switching-paths.md +++ b/docs/plans/switching-paths.md @@ -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 diff --git a/docs/rules/open-questions.md b/docs/rules/open-questions.md index 3e10877..de6028f 100644 --- a/docs/rules/open-questions.md +++ b/docs/rules/open-questions.md @@ -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. diff --git a/docs/rules/rules-v0.2.md b/docs/rules/rules-v0.2.md index 7d4c66f..75d6a24 100644 --- a/docs/rules/rules-v0.2.md +++ b/docs/rules/rules-v0.2.md @@ -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 diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 8fb5dee..ab2d969 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -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) { diff --git a/src/engine/events.ts b/src/engine/events.ts index 44f0d6c..11c794e 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -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; diff --git a/src/engine/intents.ts b/src/engine/intents.ts index d298ded..6210a5a 100644 --- a/src/engine/intents.ts +++ b/src/engine/intents.ts @@ -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 diff --git a/src/engine/legal.ts b/src/engine/legal.ts index cd6879f..2bba97f 100644 --- a/src/engine/legal.ts +++ b/src/engine/legal.ts @@ -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(); + 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(); + 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++) { diff --git a/src/engine/track.ts b/src/engine/track.ts index ef489d8..07f4b85 100644 --- a/src/engine/track.ts +++ b/src/engine/track.ts @@ -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(); + const resultKeys = new Set(); - 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; + }; // 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 }); } } diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index 022366f..1b22d38 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -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': { /** diff --git a/src/sim/view.ts b/src/sim/view.ts index 92e14da..ad96197 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -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': { /** diff --git a/src/web/game.ts b/src/web/game.ts index 3a498d6..ed73cc3 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -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(); 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); diff --git a/src/web/main.ts b/src/web/main.ts index 9007615..4975a61 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -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 ( - `` + `` ); }; @@ -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(' ') : []); } } diff --git a/test/track.test.ts b/test/track.test.ts index 8972805..23804d6 100644 --- a/test/track.test.ts +++ b/test/track.test.ts @@ -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 => + 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 = { [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`, + ); + }); +});