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
389 lines
17 KiB
TypeScript
389 lines
17 KiB
TypeScript
/**
|
||
* Component 6 — Legal-action enumeration.
|
||
*
|
||
* `legalActions(state, actor) -> Intent[]` — what the UI greys out, and what a bot picks from.
|
||
* See architecture/components.md §2 A.6.
|
||
*
|
||
* THE DISCIPLINE. This module enumerates *candidate* intents and then filters them through
|
||
* component 4's `check`. It contains no rules of its own. That is deliberate: two independent
|
||
* implementations of turnout directionality or the four-slot limit would eventually disagree, and
|
||
* the failure mode is a legal move the UI refuses or an illegal one it offers.
|
||
*
|
||
* If you find yourself writing a rule here, it belongs in apply.ts.
|
||
*/
|
||
|
||
import type { CarType, Hand, TrackGeometry } from './content.ts';
|
||
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 { coordKey, seatOf } from './state.ts';
|
||
import type { MoveDestination } from './track.ts';
|
||
import { variantsFor } from './track.ts';
|
||
|
||
/**
|
||
* A square GridCoord this route passes through that no OTHER route to the same destination does —
|
||
* the discriminator `switch.move.via` needs so `selectDestination` (apply.ts) never has to guess
|
||
* which of two routes an intent meant. `siblings` is every route the same `destinationsFor` call
|
||
* returned at this same square; `route` must be one of them.
|
||
*
|
||
* Absent when `route` is the only route there (the overwhelmingly common case — `via` need not be
|
||
* carried at all), or when the destination is one hop away, which is never ambiguous: the square
|
||
* immediately before a given entry port is fixed by geometry, so a zero-length path cannot have a
|
||
* sibling.
|
||
*/
|
||
function distinguishingVia(route: MoveDestination, siblings: readonly MoveDestination[]): GridCoord | undefined {
|
||
if (route.path.length === 0) return undefined;
|
||
const otherSquares = new Set<string>();
|
||
for (const sibling of siblings) {
|
||
if (sibling === route) continue;
|
||
for (const step of sibling.path) otherSquares.add(coordKey(step.coord));
|
||
}
|
||
for (const step of route.path) {
|
||
if (!otherSquares.has(coordKey(step.coord))) return step.coord;
|
||
}
|
||
// Every square on this route is shared with some sibling — geometrically possible only if the
|
||
// routes fork and rejoin more than once, which `track.ts`'s doc comment says not to rely on not
|
||
// happening. Naming the last square before the destination at least matches today's behaviour:
|
||
// wrong in the same way a route with no `via` at all would be.
|
||
return route.path[route.path.length - 1]!.coord;
|
||
}
|
||
|
||
const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose'];
|
||
|
||
/** Every intent `player` may legally submit right now. */
|
||
export function legalActions(s: GameState, player: PlayerIndex): Intent[] {
|
||
return candidates(s, player).filter((i) => check(s, player, i) === null);
|
||
}
|
||
|
||
export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean {
|
||
return check(s, player, i) === null;
|
||
}
|
||
|
||
/**
|
||
* Candidate generation. Over-generates freely — `check` is the authority, so a candidate that
|
||
* turns out to be illegal simply gets filtered. Being generous here is what stops this module
|
||
* from quietly acquiring rules.
|
||
*/
|
||
function candidates(s: GameState, player: PlayerIndex): Intent[] {
|
||
const out: Intent[] = [];
|
||
|
||
// The clearance ruling arrives out of turn order and goes to the Superintendent (§8.1).
|
||
if (s.clock.pendingDecision !== null) {
|
||
out.push({ type: 'mainline.clearance', allow: true });
|
||
out.push({ type: 'mainline.clearance', allow: false });
|
||
}
|
||
|
||
switch (s.clock.phase) {
|
||
case 'localOps':
|
||
out.push(...localOpsCandidates(s, player));
|
||
break;
|
||
case 'newTrain':
|
||
out.push(...newTrainCandidates(s));
|
||
break;
|
||
case 'loadUnload':
|
||
out.push(...loadUnloadCandidates(s, player));
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
// Red Flags — "any time", so they are candidates in every phase, on any train standing out on
|
||
// the Mainline (the player's own or another's: protecting a train is not an attack).
|
||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||
const k = s.cards.get(cardId)?.kind;
|
||
if (k?.kind !== 'maneuver' || k.key !== 'redFlags') continue;
|
||
for (const [trayId] of s.trays) out.push({ type: 'maneuver.redFlags', cardId, trayId });
|
||
}
|
||
|
||
out.push({ type: 'redFlag.play' });
|
||
return out;
|
||
}
|
||
|
||
function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||
const out: Intent[] = [];
|
||
|
||
// §6 — the three-way exclusive choice.
|
||
out.push({ type: 'localOps.choose', option: 'switch' });
|
||
out.push({ type: 'localOps.choose', option: 'draw' });
|
||
out.push({ type: 'localOps.choose', option: 'freightAgent' });
|
||
|
||
const area = areaOf(s, player);
|
||
|
||
// -- switch (§6.1)
|
||
for (const [trayId, tray] of s.trays) {
|
||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||
const from = tray.position.coord;
|
||
for (const reverse of [false, true]) {
|
||
const dests = destinationsFor(s, player, trayId, from, reverse);
|
||
// Grouped by destination square so `distinguishingVia` only ever compares routes that are
|
||
// actually racing for the same button — two routes to DIFFERENT squares need no `via` to
|
||
// tell apart, `to` already does that.
|
||
const byCoord = new Map<string, MoveDestination[]>();
|
||
for (const d of dests) {
|
||
const k = coordKey(d.coord);
|
||
(byCoord.get(k) ?? byCoord.set(k, []).get(k)!).push(d);
|
||
}
|
||
for (const group of byCoord.values()) {
|
||
for (const d of group) {
|
||
const via = group.length > 1 ? distinguishingVia(d, group) : undefined;
|
||
out.push({ type: 'switch.move', trayId, to: d.coord, reverse, ...(via ? { via } : {}) });
|
||
}
|
||
}
|
||
}
|
||
for (let n = 1; n <= tray.consist.length; n++) {
|
||
out.push({ type: 'switch.dropCars', trayId, count: n });
|
||
// Off the nose as well as the tail — the only way to get cars back off the front of a train
|
||
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
|
||
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
|
||
}
|
||
// Small Yard: enumerating every permutation would explode, so offer the useful ones —
|
||
// bringing each car to the droppable end, plus a full reversal. `check` validates any order,
|
||
// so a UI may submit an arbitrary permutation.
|
||
const n = tray.consist.length;
|
||
if (n > 1) {
|
||
for (let k = 0; k < n; k++) {
|
||
const order = [...Array(n).keys()].filter((x) => x !== k);
|
||
order.push(k);
|
||
out.push({ type: 'switch.sortConsist', trayId, order });
|
||
}
|
||
out.push({ type: 'switch.sortConsist', trayId, order: [...Array(n).keys()].reverse() });
|
||
}
|
||
}
|
||
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
|
||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||
const k = s.cards.get(cardId)?.kind;
|
||
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
|
||
for (const [trayId, tray] of s.trays) {
|
||
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)) {
|
||
for (let count = 1; count <= tray.consist.length; count++) {
|
||
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to: d.coord });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out.push({ type: 'switch.end' });
|
||
|
||
// -- draw (§6.2)
|
||
out.push({ type: 'draw.fromHomeOffice' });
|
||
for (let slot = 0; slot < 3; slot++) out.push({ type: 'draw.fromDepartment', slot });
|
||
|
||
const placements = placementCandidates(s, player);
|
||
// Enhancements ATTACH to a card already in the grid, so their candidates are the occupied cells,
|
||
// not the empty ones every other placeable card wants. Offering them `placements` meant an
|
||
// Enhancement was never once legal on a real card — 18 of 93 solitaire cards, permanently dead.
|
||
const attachments = [...area.grid.keys()].map((k) => {
|
||
const [row, col] = k.split(',').map(Number);
|
||
return { row: row!, col: col! };
|
||
});
|
||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||
out.push({ type: 'card.play', cardId });
|
||
const kind = s.cards.get(cardId)?.kind;
|
||
/**
|
||
* Enhancements ATTACH to a card already down, so their candidates are the occupied cells — with
|
||
* one exception. ABS Signals goes on a MAINLINE card, which is not a grid square at all, so it
|
||
* is offered as a Division NODE and never as a coordinate.
|
||
*
|
||
* It used to be offered as `{ row: -1, col: node }`. Row −1 is a real district row — the first
|
||
* one below the Running Track, where most districts start — so the card appeared to be playable
|
||
* all over the Office Area, and ordinary placements on that row were labelled as Mainline ones.
|
||
*/
|
||
const onMainline = kind?.kind === 'enhancement' && enhancementRule(kind.key)?.placement === 'mainlineCard';
|
||
if (onMainline) {
|
||
for (let node = 0; node < s.division.nodes.length; node++) {
|
||
out.push({ type: 'card.play', cardId, node });
|
||
}
|
||
}
|
||
/**
|
||
* A TURNOUT MAY ALSO UPGRADE A CARD ALREADY DOWN, so it is offered the occupied cells as well as
|
||
* the empty ones — `check` decides which of them it can actually be laid over. Without this the
|
||
* upgrade rule exists in the engine and is never once presented, which is precisely how the 18
|
||
* Enhancement cards came to be permanently dead.
|
||
*
|
||
* Deduplicated because the two lists overlap: `placements` already contains the Limits signs.
|
||
*/
|
||
const isTurnout = kind?.kind === 'track' && kind.geometry === 'turnout';
|
||
const targets = onMainline
|
||
? []
|
||
: kind?.kind === 'enhancement'
|
||
? attachments
|
||
: isTurnout
|
||
? dedupe([...placements, ...attachments])
|
||
: placements;
|
||
// Orientation is chosen on placement, and a printed card turns but never flips, so the widest
|
||
// variant set is TWO. Anything that is not track has a single orientation and needs one entry.
|
||
const rotations = kind?.kind === 'track' ? variantsFor(kind.geometry, kind.hand).length : 1;
|
||
for (const placement of targets) {
|
||
for (let variant = 0; variant < rotations; variant++) {
|
||
out.push({ type: 'card.play', cardId, placement, variant });
|
||
}
|
||
}
|
||
for (let slot = 0; slot < 3; slot++) out.push({ type: 'card.discard', cardId, toSlot: slot });
|
||
}
|
||
// Mainline modifiers go on a Mainline card, not a grid cell, so `node` indexes division.nodes.
|
||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||
if (s.cards.get(cardId)?.kind.kind !== 'mainlineModifier') continue;
|
||
for (let node = 0; node < s.division.nodes.length; node++) {
|
||
out.push({ type: 'mainline.modify', cardId, node });
|
||
}
|
||
}
|
||
out.push({ type: 'draw.end' });
|
||
|
||
// -- freight agent (§6.3)
|
||
// Ending without acting is always on offer: the section lists what the Freight Agent MAY do and
|
||
// requires none of it.
|
||
out.push({ type: 'freightAgent.end' });
|
||
for (const coord of facilityCoords(s, player)) {
|
||
for (const carType of CAR_TYPES) {
|
||
out.push({ type: 'freightAgent.stockOutbound', at: coord, carType });
|
||
}
|
||
const f = area.grid.get(`${coord.row},${coord.col}`)?.facility;
|
||
if (f) {
|
||
for (let idx = 0; idx < f.inboundBox.length; idx++) {
|
||
out.push({ type: 'freightAgent.clearInbound', at: coord, index: idx });
|
||
}
|
||
for (const from of ['outbound', 'inbound', 'menAtWork'] as const) {
|
||
for (let idx = 0; idx < 3; idx++) {
|
||
out.push({ type: 'freightAgent.unjam', at: coord, from, index: idx });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
function newTrainCandidates(s: GameState): Intent[] {
|
||
const out: Intent[] = [];
|
||
for (const [trayId] of s.trays) {
|
||
for (const carType of CAR_TYPES) {
|
||
out.push({ type: 'newTrain.placeCar', trayId, carType, loaded: true });
|
||
out.push({ type: 'newTrain.placeCar', trayId, carType, loaded: false });
|
||
}
|
||
out.push({ type: 'newTrain.passCar', trayId });
|
||
}
|
||
const due = s.timetable[s.clock.stage - 1];
|
||
if (due !== null && due !== undefined) {
|
||
out.push({ type: 'newTrain.secondSection', trainNumber: due });
|
||
}
|
||
/**
|
||
* Where a pending Extra starts: its own Division Point, decided by its number, or any Control
|
||
* Point. `check` refuses a Whistle Post and a full Office, so every seat is offered and the rules
|
||
* do the filtering — one implementation of "is this a Control Point", not two.
|
||
*/
|
||
for (const trainNumber of s.pendingExtras) {
|
||
out.push({ type: 'newTrain.startExtra', trainNumber, atSeat: null });
|
||
for (const seat of s.officeAreas.keys()) {
|
||
out.push({ type: 'newTrain.startExtra', trainNumber, atSeat: seat });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function loadUnloadCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||
const out: Intent[] = [];
|
||
const area = areaOf(s, player);
|
||
|
||
for (const coord of facilityCoords(s, player)) {
|
||
out.push({ type: 'porter.board', at: coord });
|
||
out.push({ type: 'porter.detrain', at: coord });
|
||
const f = area.grid.get(`${coord.row},${coord.col}`)?.facility;
|
||
if (f) {
|
||
out.push({ type: 'laborer.startLoad', at: coord });
|
||
// A Passenger Facility has no MEN | AT | WORK boxes to advance a load along.
|
||
for (let box = 0; box < (f.menAtWork?.length ?? 0); box++) {
|
||
out.push({ type: 'laborer.advanceLoad', at: coord, box });
|
||
}
|
||
for (let ci = 0; ci < f.industryTrack.cars.length; ci++) {
|
||
out.push({ type: 'laborer.beginUnload', at: coord, carIndex: ci });
|
||
}
|
||
}
|
||
}
|
||
out.push({ type: 'loadUnload.end' });
|
||
return out;
|
||
}
|
||
|
||
function facilityCoords(s: GameState, player: PlayerIndex): GridCoord[] {
|
||
const out: GridCoord[] = [];
|
||
for (const [key, card] of areaOf(s, player).grid) {
|
||
if (!card.facility) continue;
|
||
const [row, col] = key.split(',').map(Number);
|
||
out.push({ row: row!, col: col! });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function dedupe(coords: GridCoord[]): GridCoord[] {
|
||
const seen = new Set<string>();
|
||
return coords.filter((c) => {
|
||
const k = `${c.row},${c.col}`;
|
||
if (seen.has(k)) return false;
|
||
seen.add(k);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/** Empty cells adjacent to occupied ones — `check` decides which actually connect. */
|
||
function placementCandidates(s: GameState, player: PlayerIndex): GridCoord[] {
|
||
const area = areaOf(s, player);
|
||
const seen = new Set<string>();
|
||
const out: GridCoord[] = [];
|
||
|
||
for (const key of area.grid.keys()) {
|
||
const [row, col] = key.split(',').map(Number);
|
||
const around: GridCoord[] = [
|
||
{ row: row! + 1, col: col! },
|
||
{ row: row! - 1, col: col! },
|
||
{ row: row!, col: col! + 1 },
|
||
{ row: row!, col: col! - 1 },
|
||
];
|
||
for (const c of around) {
|
||
// A district grows on BOTH sides of the Running Track: a turnout turned 180° sends its 45°
|
||
// leg north instead of south. The rows above used to be filtered out here on Q7's authority.
|
||
const k = `${c.row},${c.col}`;
|
||
if (seen.has(k)) continue;
|
||
// The Limits signs are candidates even though they are occupied: laying track there is how
|
||
// the Running Track grows, and the sign moves outward (§2.1). `check` still has the final say.
|
||
const occupied = area.grid.get(k);
|
||
if (occupied && !(occupied.geometry.kind === 'limits' && c.row === area.runningRow)) continue;
|
||
seen.add(k);
|
||
out.push(c);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* THE NINE-SPOT MODIFIER NEIGHBOURHOOD (§9), which the four-square walk above cannot reach.
|
||
*
|
||
* A Modifier goes "adjacent to a Facility, on any of the nine nearby spots" — diagonals included —
|
||
* and `check` has always accepted all eight. It was the CANDIDATES that were orthogonal-only, so a
|
||
* diagonal square with no orthogonal neighbour was legal and never offered: reported by Jesse as
|
||
* being unable to place a Modifier to the south-east of his industry.
|
||
*
|
||
* Only around a Facility, and only in a second pass. Track has to JOIN, and a diagonal shares no
|
||
* edge, so a Modifier is the only card these squares can ever take — generating diagonals around
|
||
* every card instead costs 54% more simulation time for candidates `check` then rejects. Appending
|
||
* rather than interleaving leaves the existing order untouched, which matters because the bot
|
||
* breaks ties by first-best: measured over 200 seeded games, 0 played differently.
|
||
*/
|
||
for (const [key, card] of area.grid) {
|
||
if (!card.facility) continue;
|
||
const [row, col] = key.split(',').map(Number);
|
||
for (const c of [
|
||
{ row: row! + 1, col: col! + 1 },
|
||
{ row: row! + 1, col: col! - 1 },
|
||
{ row: row! - 1, col: col! + 1 },
|
||
{ row: row! - 1, col: col! - 1 },
|
||
]) {
|
||
const k = `${c.row},${c.col}`;
|
||
if (seen.has(k) || area.grid.has(k)) continue;
|
||
seen.add(k);
|
||
out.push(c);
|
||
}
|
||
}
|
||
return out;
|
||
}
|