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