Initial commit

This commit is contained in:
Jesse
2026-07-31 07:19:57 -04:00
commit e7bc07df85
39 changed files with 12438 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
/**
* 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 } from './content.ts';
import { check, areaOf, destinationsFor } from './apply.ts';
import type { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
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;
}
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.owner !== 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 });
}
}
for (let n = 1; n <= tray.consist.length; n++) {
out.push({ type: 'switch.dropCars', trayId, count: n });
}
}
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);
for (const cardId of s.decks.hands.get(player) ?? []) {
out.push({ type: 'card.play', cardId });
// Gap 11 — orientation is chosen on placement, so every rotation is a distinct candidate.
// Six is the widest set (turnouts); `check` discards the ones whose ports do not meet.
for (const placement of placements) {
for (let variant = 0; variant < 6; variant++) {
out.push({ type: 'card.play', cardId, placement, variant });
}
}
for (let slot = 0; slot < 3; slot++) out.push({ type: 'card.discard', cardId, toSlot: slot });
}
out.push({ type: 'draw.end' });
// -- freight agent (§6.3)
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 });
}
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 });
for (let box = 0; box < f.menAtWork.length; 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;
}
/** 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) {
const k = `${c.row},${c.col}`;
if (area.grid.has(k) || seen.has(k)) continue;
seen.add(k);
out.push(c);
}
}
return out;
}