Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user