switching paths: two routes to the same square

Builds docs/plans/switching-paths.md. A passing loop can offer two legal
routes between the same two squares, coupling different cars — the engine
only ever found one, an artifact of search order (Reported by Jesse, undo
379). exploreMoves now enumerates every simple route (per-path visited set,
capped at 4000 frontier nodes) and dedupes on outcome — destination, entry
side, and origin-tagged cars — rather than on reaching the square at all.

switch.move gains an optional `via: GridCoord` naming one intermediate
square on the chosen route; absent, it resolves exactly as before, so
every existing save and bot decision replays identically (575/575, then
579/579 with the new tests). Threaded through the label, the action-list
dedupe, the hover highlight (data-route), and the history (trayMoved.via).

Ruling recorded as Gap 14 in docs/rules/open-questions.md: the player may
choose the path; §A.4's "may not go around" a car does not reach a
different track the player declined to enter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAt2YCXgd5qCjBcF2x34aK
This commit is contained in:
Jesse
2026-08-19 16:52:10 -04:00
co-authored by Claude Sonnet 5
parent 7932bb6ccf
commit fbaa3d4147
14 changed files with 440 additions and 36 deletions
+136
View File
@@ -18,6 +18,8 @@ import type {
import { coordKey, isOperationalRail, turnOf } from '../src/engine/state.ts';
import { createGame } from '../src/engine/setup.ts';
import { applyIntent, areaOf } from '../src/engine/apply.ts';
import { legalActions } from '../src/engine/legal.ts';
import type { Intent } from '../src/engine/intents.ts';
import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
import {
allReachable,
@@ -1110,3 +1112,137 @@ describe('why a square is NOT offered (the same walk, keeping its rejections)',
}
});
});
// ---------------------------------------------------------------------------
// docs/plans/switching-paths.md — two routes to the same square
// ---------------------------------------------------------------------------
describe('two routes to one square', () => {
const limitsCard = (): TrackCard => ({
geometry: { kind: 'limits' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
});
/**
* A passing loop: a turnout diverges off the Running Track, curves down to an industry, and
* curves back up to rejoin the main two columns west. Both routes from (0,4) to (0,0) are legal
* under §2.4 (neither ever leaves a card by the port it entered); they differ in what they
* couple. This is the fixture in the plan's §1, verified identical on v0.4.7 and v0.4.8.
*/
function loopArea(midCard: (col: number) => TrackCard = () => straight()): OfficeArea {
return areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
[coordKey(at(0, 2))]: midCard(2),
[coordKey(at(0, 3))]: turnout({ stem: 'e', through: 'w', diverge: 's' }),
[coordKey(at(0, 4))]: limitsCard(),
[coordKey(at(-1, 1))]: curve('ne'),
[coordKey(at(-1, 2))]: midCard(-1),
[coordKey(at(-1, 3))]: curve('nw'),
},
at(9, 9), // office coord, deliberately away from the fixture
);
}
const tanker: RollingStock = { type: 'tank', loaded: false };
it('offers both routes, and only one couples the tanker on the spur', () => {
const area = loopArea((col) => (col === -1 ? straight([tanker]) : straight()));
const dests = reachableDestinations(ctxFor(area), at(0, 4), 'w');
const atZero = dests.filter((d) => d.coord.row === 0 && d.coord.col === 0);
assert.equal(atZero.length, 2, `expected two distinct routes to (0,0), got ${atZero.length}`);
const withTanker = atZero.filter((d) => d.couples.some((c) => c.type === 'tank'));
const without = atZero.filter((d) => d.couples.length === 0);
assert.equal(withTanker.length, 1, 'exactly one route should couple the tanker');
assert.equal(without.length, 1, 'exactly one route should couple nothing');
});
it('offers two routes coupling the SAME car type off DIFFERENT cards, and neither is dropped', () => {
// The dedupe key must include the origin card, not just the car type (plan §3) — two boxcars,
// one on the bypass and one on the main, must not collapse into one destination.
const area = loopArea(() => straight([{ type: 'boxcar', loaded: false }]));
const dests = reachableDestinations(ctxFor(area), at(0, 4), 'w');
const atZero = dests.filter((d) => d.coord.row === 0 && d.coord.col === 0);
assert.equal(atZero.length, 2, `expected two distinct routes to (0,0), got ${atZero.length}`);
for (const d of atZero) {
assert.deepEqual(d.couples.map((c) => c.type), ['boxcar'], 'each route should couple its own boxcar');
}
});
it('a `via`-qualified switch.move resolves to the route it names', () => {
function freshGame(): GameState {
const s = gameWith(loopArea((col) => (col === -1 ? straight([tanker]) : straight())));
s.trays.set('crew', {
id: 'crew', trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'west', facing: 'w', position: { at: 'grid', seat: 0, coord: at(0, 4) }, movesUsed: 0,
});
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
turnOf(s, 0).movesRemaining = 6;
return s;
}
const probe = freshGame();
const options = legalActions(probe, 0).filter(
(i): i is Extract<Intent, { type: 'switch.move' }> =>
i.type === 'switch.move' && i.to.row === 0 && i.to.col === 0,
);
assert.equal(options.length, 2, 'expected two distinct switch.move intents to (0,0)');
assert.ok(options.every((i) => i.via !== undefined), 'an ambiguous route must carry a distinguishing via');
const vias = new Set(options.map((i) => `${i.via!.row},${i.via!.col}`));
assert.equal(vias.size, 2, 'the two routes must carry DIFFERENT via');
const outcomes = options.map((intent) => {
const s = freshGame();
const r = applyIntent(s, 0, intent);
assert.ok(r.ok, 'a via-qualified move to a square the engine offered must be legal');
return s.trays.get('crew')!.consist.map((c) => c.type);
});
assert.ok(outcomes.some((c) => c.includes('tank')), 'one via should have coupled the tanker');
assert.ok(outcomes.some((c) => c.length === 0), 'the other via should have coupled nothing');
// `via` absent still resolves — the first route enumerated, exactly as before this feature.
const s = freshGame();
const r = applyIntent(s, 0, { type: 'switch.move', trayId: 'crew', to: at(0, 0), reverse: false });
assert.ok(r.ok, 'a plain move with no via must still resolve to SOME legal route');
});
it('a dense district terminates and stays bounded, preferring the routes BFS finds first', () => {
// Chained passing loops: each one independently offers "through" or "detour", so N of them
// offer 2^N routes to the far end with no global memo to stop it — exactly the exponential case
// the cap exists for (plan §3). 15 segments is 32,768 routes; the cap is 4,000 frontier nodes.
const segments = 15;
const start = 3 * segments;
const grid: Record<string, TrackCard> = { [coordKey(at(0, start + 1))]: limitsCard() };
for (let k = 0; k < segments; k++) {
const divergeCol = start - 3 * k;
const midCol = divergeCol - 1;
const mergeCol = divergeCol - 2;
grid[coordKey(at(0, divergeCol))] = turnout({ stem: 'e', through: 'w', diverge: 's' });
grid[coordKey(at(0, midCol))] = straight();
grid[coordKey(at(0, mergeCol))] = turnout({ stem: 'w', through: 'e', diverge: 's' });
grid[coordKey(at(-1, divergeCol))] = curve('nw');
grid[coordKey(at(-1, midCol))] = straight();
grid[coordKey(at(-1, mergeCol))] = curve('ne');
}
grid[coordKey(at(0, 0))] = straight();
const area = areaFrom(grid, at(9, 9));
const startedAt = Date.now();
const dests = reachableDestinations(ctxFor(area), at(0, start + 1), 'w');
const elapsed = Date.now() - startedAt;
assert.ok(elapsed < 5000, `exploreMoves took ${elapsed}ms — the cap should keep this fast`);
assert.ok(dests.length > 0, 'the cap must not eliminate every route');
assert.ok(
dests.length < 2 ** segments,
`${dests.length} destinations found — the cap should have cut off full 2^${segments} enumeration`,
);
});
});