Six reports from the Day 2-3 playtest of v0.8.0.13. GAMES IN PROGRESS DO NOT SURVIVE THIS ONE. Modifiers are now bounded by the Limits, which makes a once-legal move illegal, so a save holding one is refused at that move: whistle-6945.day3.stage10 stops at intent 528 of 539. Jesse's call, knowing it strands the game on the box. The file is untouched and v0.8.0.13 still finishes it. The Sparrow running empty and Tom unable to unload his passengers are the same shortage from opposite ends, and both are the rules working as printed. §9.2 boarding discards the emptied coach into the CLASSIFICATION yard, detraining draws a fresh empty out of the DIVISION yard, and §2.2 sends Classification back only when the Division Yard runs bare — so coaches move one way. Measured over the save: sixteen in the Division Yard at setup, zero from Day 2 Stage 8 to the end, fifteen piled in Classification, the Division Yard steady at 46-47 freight cars with no prospect of going bare. Jesse's ruling is Gitea#2's: the shortage stays and the game says so. A train made up short now reports what its card wanted and why none is coming (`makeUpShort` — `trainNeedingCars` answered null for "done" and for "cannot be done" alike, so the phase moved on in silence); the yard panel warns while the condition lasts; the Depot's blocked panel was right all along. The modifier outside the Limits was working as designed and the design was Jesse's own call, now reversed. What decided it is what the board shows — a card beyond your own sign, in territory §8.1 and §10 reason about. The case that motivated the exemption was checked on the reported move rather than argued away: the Power Plant sat at (-1,3) against a sign at column 3 and two spots inside were free, legal and adjacent. Switching filled the history with coordinates — a line per move, plus one per mandatory coupling. It is still LOGGED in full; what the panel draws is the line saying somebody switched, the first move, work at an INDUSTRY (named, not a coordinate), the Small Yard sort, and a closing summary. The suppressed lines are still WRITTEN, marked `trace`: dropping them outright was the first attempt and the step-queue suite caught it, because dwellForStep pays nothing for a step that said nothing, so the board stopped replaying switching at all. The last move rides in the closing line rather than being kept in place — nothing knows a move was the last until the turn is over, by which time the line has been streamed to every client and cannot be revised. Two things fell out of reading those lines: every move ended with a tutorial sentence the opener already gives, and the move count said "of 6" with the six hardcoded, which is wrong on a night Stage. Make-up lines name their train — they all read "the train being made up", so looking back for train 10 found nothing under that name — and "a empty tank" is now "an empty tank". The Small Yard's options read as the train they would build instead of `[1,2,3,0]`; the one Jesse wanted was the first of five and unreadable. Two of those five were junk: bringing the last car to the end is the identity and would have spent a Move, and a two-car reversal duplicated its only real option. Both are filtered by the resulting order, not by the case that made them. A Small Yard may now put cars AHEAD of the engine, which was Jesse's own open question. Two sources disagreed and the design notes won: the v0.4.5 card text says the sort puts the engine at the nose, implications.md says "any order, including cars ahead of the engine". `engineAt` is optional on the intent, so older saves replay to the same train. The menu did not multiply — the engine is a separate short list against the consist as it stands, eight options for a four-car train rather than twenty. §8.2 needed no new code: badlyMadeUp is deliberately direction-free, so a PUSHING train is fit to run and only a broken-backed one is held. The button warns by asking that predicate rather than copying it, and immediately earned itself — every one of train 10's eight options is refused, the one asked for at the table included, because that train carries a caboose and each sort moves it off the rear. That is the right answer rather than a gap: the train is already made up, so every offer would break it, and the labels say which is which. A made-up order is always on the menu for a train that needs one, because "bring car k to the tail" is enumerated for every car and the caboose is one of them. Labels read WEST TO EAST, with the engine drawn as the board's own ◀ / ▶ arrow. "Front to back" is not a direction a table can read — which end is the front depends on which way the train points — and board-svg has reversed east-facing consists since v0.8.0, so the button now describes the same train as the picture. The Freight Agent, Porter and Laborer groups now say what the role is for, where the role is chosen. Tom reached for the Freight Agent to detrain passengers, which is a Porter's action in the Cargo phase; both halves were working and neither was visible. TODO closes #107 (the nose sort) and gains #108 (the coach ratchet, with the measurement, to revisit on a second game's data). 999 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
524 lines
24 KiB
TypeScript
524 lines
24 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, 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<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[] {
|
||
// 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<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.
|
||
*
|
||
* 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<string>([`${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<string>([`${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<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;
|
||
}
|