/** * 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, mainlineProfile } from './content.ts'; import { check, areaOf, destinationsFor, withRouteCache } 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(); 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[] { // One position, examined many times over: its routes are walked once (`withRouteCache`). return withRouteCache(s, () => candidates(s, player).filter((i) => check(s, player, i) === null)); } /** * §6.1 — the switching half of the Local Operations candidates, in the order `legalActions` offers * them. Split out so the switching planner (`sim/switch-planner.ts`) can ask for just these without * `check` running over every draw and Freight Agent candidate at each of the thousands of positions it * tries — that was about a quarter of all planning time. Still no rules here: `check` decides. */ function switchCandidates(s: GameState, player: PlayerIndex): Intent[] { const out: Intent[] = []; 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(); 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. * * NOTHING THAT RE-ORDERS NOTHING. Bringing the LAST car to the end is the identity, and a * two-car train's reversal repeats its only real option — so the menu carried a move that spent * one of six Moves to leave the train exactly as it was, beside a duplicate of the move next to * it. Both were invisible while the labels were index lists (playtest, 2026-09-17); both are * plainly wrong once the label reads as a train. Filtered by the ORDER rather than by the case * that produced it, so a new generator cannot reintroduce either. */ const n = tray.consist.length; const identity = [...Array(n).keys()]; if (n > 1) { const orders: number[][] = []; for (let k = 0; k < n; k++) { const order = identity.filter((x) => x !== k); order.push(k); orders.push(order); } orders.push([...identity].reverse()); // Nothing that re-orders nothing: the current train is the one thing on offer that costs a // Move and changes the board not at all. Keyed by (order, engine position) together, since // since 2026-09-17 the same car order at a different engine position is a different train. const seen = new Set([`${identity.join(',')}|${tray.engineAt}`]); const offer = (order: number[], engineAt: number): void => { const key = `${order.join(',')}|${engineAt}`; if (seen.has(key)) return; seen.add(key); out.push({ type: 'switch.sortConsist', trayId, order, engineAt }); }; // The car orders, each leaving the engine on the nose — the Small Yard's ordinary use. for (const order of orders) offer(order, 0); /** * A MADE-UP ORDER IS ALWAYS AMONG THESE, which is worth saying because it looks as though it * might not be (Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the * back"). * * A yard sort serves two errands — pulling one car out to an end so it can be spotted, and * putting the train back together to leave — and the orders above are written for the first. * They cover the second as a by-product: "bring car k to the tail" is enumerated for EVERY car, * so bringing the CABOOSE to the tail is always one of them, and with the engine on the nose * that is a train §8.2 will let out of the Office. * * An explicit "make it up to leave" option was written here and deleted: it produced exactly * the k-is-the-caboose order and was dropped by the dedupe every time. The one case where no * made-up order appears is a train that is ALREADY made up, where such an option would be the * identity — and the labels say which is which, so a player can see that every offer would * break a train that is currently fit to run. */ } /** * WHERE THE ENGINE GOES, as its own short list rather than multiplied through the one above * (Jesse's call, 2026-09-17: "a separate engine control"). * * Offering every car order at every engine position is the honest enumeration and it is * unreadable: a four-car consist would go from four options to twenty, which is the labelling * problem that prompted all of this. So the engine positions are offered against the consist AS * IT STANDS — pick an order, or pick where the engine sits, each one Move. A player who wants * both spends two, which is the same price the yard charges for any second sort. * * OFFERED FOR A ONE-CAR TRAIN TOO, unlike the car orders: a single car ahead of the engine or * behind it is exactly the difference between shoving it into a facing industry and pulling it. */ if (n >= 1) { const seenEngine = new Set([`${identity.join(',')}|${tray.engineAt}`]); for (let k = 0; k <= n; k++) { const key = `${identity.join(',')}|${k}`; if (seenEngine.has(key)) continue; seenEngine.add(key); out.push({ type: 'switch.sortConsist', trayId, order: identity, engineAt: k }); } } } // 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' }); return out; } /** The switching intents `player` may legally submit right now — exactly `legalActions`' switching subset. */ export function legalSwitchingActions(s: GameState, player: PlayerIndex): Intent[] { return withRouteCache(s, () => switchCandidates(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[] = []; /** * §3.3, EXTENDED PLAY (Gitea#11) — the only thing on offer when the timetable has run out and the * table is being asked whether to play on. * * Returned EARLY rather than added to the list, because nothing else is legal in this state and * the phase switch below would otherwise generate a boardful of candidates for `check` to reject * one at a time. It also puts the vote in front of the bot driver through the ordinary path, which * is what lets a bot seat answer without the engine having to know which seats are bots. */ if (s.status === 'awaitingExtension') { if (s.extensionVotes[player] === null) { out.push({ type: 'game.extend', player, agree: true }); out.push({ type: 'game.extend', player, agree: false }); } return out; } // The two interruptions of the Mainline Phase. Each goes to one named player — `check` is the // authority on which — so both are generated here and filtered there. if (s.clock.pendingDecision?.kind === 'clearance') { out.push({ type: 'mainline.clearance', allow: true }); out.push({ type: 'mainline.clearance', allow: false }); } if (s.clock.pendingDecision?.kind === 'yardOffice') { out.push({ type: 'mainline.yardOffice', take: true }); out.push({ type: 'mainline.yardOffice', take: false }); } if (s.clock.pendingDecision?.kind === 'redFlag') { out.push({ type: 'mainline.redFlag', flag: true }); out.push({ type: 'mainline.redFlag', flag: false }); } switch (s.clock.phase) { case 'localOps': out.push(...localOpsCandidates(s, player)); break; case 'newTrain': out.push(...newTrainCandidates(s, player)); 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; // §Q (Gitea#19) — a flag goes on one side of your own district, so the only choice is which. for (const side of ['east', 'west'] as const) out.push({ type: 'maneuver.redFlags', cardId, side }); } 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) out.push(...switchCandidates(s, player)); // -- 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, player: PlayerIndex): 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 MAY START (§7, Jesse's ruling) — every candidate offered, with `check` * doing the filtering, so "is this a Control Point" and "does the house rule allow it" have one * implementation each rather than two. * * BOTH Division Points, not the one the number dictates: an Extra's direction comes from where it * is placed. In the middle of the railroad — an Interchange, a Control Point — both ways are real * runs, so those are offered twice, once per direction. */ // Only the player who played it is offered anywhere to put it (§7) — `check` refuses anyone else // with NOT_YOUR_EXTRA, and offering options that are certain to be refused is how a menu lies. for (const { trainNumber } of s.pendingExtras.filter((x) => x.player === player)) { for (const side of ['west', 'east'] as const) { out.push({ type: 'newTrain.startExtra', trainNumber, start: { kind: 'divisionPoint', side } }); } for (const [node, n] of s.division.nodes.entries()) { if (n.kind !== 'mainline' || !mainlineProfile(n.card).sortsCars) continue; for (const direction of ['west', 'east'] as const) { out.push({ type: 'newTrain.startExtra', trainNumber, start: { kind: 'mainline', node }, direction }); } } for (const seat of s.officeAreas.keys()) { for (const direction of ['west', 'east'] as const) { out.push({ type: 'newTrain.startExtra', trainNumber, start: { kind: 'office', seat }, direction }); } } } return out; } function loadUnloadCandidates(s: GameState, player: PlayerIndex): Intent[] { const out: Intent[] = []; const area = areaOf(s, player); /** * ONE OPTION PER TRAIN STANDING AT THE OFFICE, not one per square. * * Reported from playtesting v0.4.9d: "operating two trains in a station, the select button does * not work — regardless of which you pick, it is always one train, not the other". There was only * ever ONE `board passengers` button, because the intent carried no train; the roster chip chose * what the board drew and nothing else. Now each eligible train is its own candidate, and `check` * filters the ones whose card, consist or passengers rule them out. */ const traysHere = area.adOccupancy.filter((id) => s.trays.has(id)); for (const coord of facilityCoords(s, player)) { for (const trayId of traysHere) { out.push({ type: 'porter.board', at: coord, trayId }); out.push({ type: 'porter.detrain', at: coord, trayId }); } 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(); 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(); 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; }