Files
station-master/src/engine/intents.ts
T
JesseandClaude Sonnet 5 fbaa3d4147 switching paths: two routes to the same square
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
2026-08-19 16:52:10 -04:00

224 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The intent vocabulary — every legal player choice, from docs/architecture/protocol.md §1.
*
* An intent is a PROPOSAL. It may be rejected. Contrast with an event (events.ts), which is a fact.
*/
import type { CarType, Direction, Hand, OfficeTier, TrackGeometry } from './content.ts';
import type { CardId, GridCoord, PlayerIndex, SeatIndex, TrayId } from './state.ts';
// ---------------------------------------------------------------------------
// Local Operations Phase (§6) — a three-way exclusive choice
// ---------------------------------------------------------------------------
export type LocalOpsOption = 'switch' | 'draw' | 'freightAgent';
export type Intent =
| { type: 'localOps.choose'; option: LocalOpsOption }
// -- switch (§6.1, Appendix A)
/**
* `via` names one INTERMEDIATE card on the chosen route, for the rare case where two routes to
* `to` couple different cars (see docs/plans/switching-paths.md) — never the start, never `to`
* itself. Absent, it resolves exactly as it always has: the first route `legal.ts` enumerated to
* `to`. An index into the destination list would do the same job worse — intents are the
* canonical record `undo` replays against, and enumeration order is not a stable thing to save.
*/
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean; via?: GridCoord }
/**
* Set out a cut. `fromNose` takes it off the FRONT of the train rather than the back — Appendix A's
* worked example does exactly this: "Back up and drop off everything on the nose of your train
* (red and blue) on Card B." Without it, cars taken onto the nose could never be set out again and
* an engine buried in its own train had no way back to an end.
*/
| { type: 'switch.dropCars'; trayId: TrayId; count: number; fromNose?: boolean }
/**
* Small Yard enhancement — "a train that spends one move in the yard may sort itself in any
* order, including cars in front of the engine". This is the designed answer to §A.3's
* come-off-in-seated-order constraint, which is what makes facing-point work possible.
*/
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[] }
| { type: 'switch.end' }
// -- draw (§6.2)
| { type: 'draw.fromHomeOffice' }
| { type: 'draw.fromDepartment'; slot: number }
/**
* `variant` indexes `variantsFor(geometry, hand)` — 0° or 180°, the only two ways a printed card
* can be laid. Track is played through here like every other card; there is no separate lay
* intent, because there is no separate supply to lay from.
*
* `placement` is a square in the player's Office Area; `node` indexes `division.nodes` and is for
* the one card that goes out on the Mainline instead (ABS Signals). They are alternatives, and
* they are SEPARATE FIELDS on purpose. A Mainline placement used to travel as the fake coordinate
* `{ row: -1, col: node }`, and row −1 is an ordinary district row — the first one below the
* Running Track, where most districts start. So a real placement on row −1 was labelled "out on
* the Mainline, node −2", and an ABS Signals placement highlighted whichever district card
* happened to sit at that column. A sentinel inside a coordinate's own value range is not a
* sentinel.
*/
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number; node?: number }
| { type: 'card.discard'; cardId: CardId; toSlot: number }
| { type: 'draw.end' }
// -- freight agent (§6.3)
| { type: 'freightAgent.stockOutbound'; at: GridCoord; carType: CarType }
| { type: 'freightAgent.clearInbound'; at: GridCoord; index: number }
| { type: 'freightAgent.unjam'; at: GridCoord; from: 'outbound' | 'inbound' | 'menAtWork'; index: number }
/**
* Finish the turn having chosen the Freight Agent and done nothing.
*
* §6.3 lists three things the Freight Agent MAY do; it does not say one must be done. Without this
* the option was a trap — having chosen it, the only legal moves were to stock, to clear, or to
* UNJAM, and unjamming a healthy facility throws away a load that cost a whole action to stock.
*/
| { type: 'freightAgent.end' }
// -- New Train Phase (§7)
| { type: 'newTrain.placeCar'; trayId: TrayId; carType: CarType; loaded: boolean }
| { type: 'newTrain.passCar'; trayId: TrayId }
/**
* §7 — "the player who played the card may place the Crew Tray in either division point for
* immediate departure", extended by Jesse: an Extra starts at the Division Point its NUMBER sends
* it to (odd runs west, even east, exactly as a timetabled train), or at any Control Point — any
* Office above a Whistle Post — at the player's choice. `atSeat` null means the Division Point.
*/
| { type: 'newTrain.startExtra'; trainNumber: number; atSeat: SeatIndex | null }
/** Q9 — run a second, identical section behind a train that is due out this Stage. */
| { type: 'newTrain.secondSection'; trainNumber: number }
// -- Mainline Phase (§8.1) — the Superintendent's clearance ruling
| { type: 'mainline.clearance'; allow: boolean }
/**
* Lay a Mainline modifier (Brakeman / Airbrakes / Helpers / Realignment) on a Mainline card.
* `node` indexes `division.nodes`.
*/
| { type: 'mainline.modify'; cardId: CardId; node: number }
/**
* Red Flags — protect a stopped train. The flagged train cannot be hit; an approaching train is
* held instead of colliding.
*/
| { type: 'maneuver.redFlags'; cardId: CardId; trayId: TrayId }
/**
* Flying Switch — cut cars off behind the engine and roll them into an adjacent industry, without
* the engine entering it.
*/
| { type: 'maneuver.flyingSwitch'; cardId: CardId; trayId: TrayId; count: number; to: GridCoord }
| { type: 'redFlag.play' }
// -- Load/Unload Phase (§9)
| { type: 'porter.board'; at: GridCoord }
| { type: 'porter.detrain'; at: GridCoord }
/** §9.3 — the first Laborer step: Green Loading Slot -> MEN. */
| { type: 'laborer.startLoad'; at: GridCoord }
| { type: 'laborer.advanceLoad'; at: GridCoord; box: number }
| { type: 'laborer.beginUnload'; at: GridCoord; carIndex: number }
| { type: 'loadUnload.end' };
export type IntentType = Intent['type'];
// ---------------------------------------------------------------------------
// Rejections — protocol.md §2
// ---------------------------------------------------------------------------
export type RejectionCode =
| 'NOT_YOUR_TURN'
| 'WRONG_PHASE'
| 'OPTION_ALREADY_CHOSEN'
| 'OPTION_NOT_CHOSEN'
| 'NO_MOVES_REMAINING'
| 'ILLEGAL_MOVE'
| 'NO_SUCH_TRAY'
| 'NO_SUCH_CARD'
| 'NO_SUCH_FACILITY'
| 'CANNOT_DROP_HERE'
| 'CONSIST_EMPTY'
| 'CONSIST_FULL'
| 'HAND_LIMIT'
| 'CARD_NOT_IN_HAND'
| 'NO_PLACEMENT'
| 'NOT_CONNECTED'
| 'DECK_EMPTY'
| 'SLOT_EMPTY'
| 'RESOURCE_SPENT'
| 'BOX_FULL'
| 'BOX_EMPTY'
| 'NO_SUITABLE_CAR'
| 'SUITABLE_CAR_EXISTS'
/** §7 — cars go onto the train being assembled at a Division Point, not onto one already running. */
| 'NOT_BEING_MADE_UP'
| 'WRONG_CAR_TYPE'
| 'NOT_SUPERINTENDENT'
| 'NO_PENDING_DECISION'
| 'NOT_UPGRADEABLE'
| 'FACILITY_LOCKED'
/** An Industry goes on a straight stub, never on the Running Track (sheet: "Placed" column). */
| 'ON_RUNNING_TRACK'
/** A card with no east-west road would dead-end the Running Track it was laid in. */
| 'BREAKS_RUNNING_TRACK'
/**
* §2.1 — track, and the Facilities that carry it, stay inside your own Limits at every row. A
* Modifier is not track and is exempt (§9's nine spots), which is why this is not shared with it.
*/
| 'OUTSIDE_LIMITS'
/**
* A turnout may be laid on top of a straight, or of a curve on the same arc as its diverging leg
* — but only those, and only while the square is idle. See `checkTurnoutUpgrade`.
*/
| 'NOT_UPGRADEABLE_TRACK'
/** You cannot swap the track out from under a standing car. */
| 'UPGRADE_OCCUPIED'
/** An Interlocking or Telegraph is built on that card; the upgrade would have to lift it. */
| 'UPGRADE_ENHANCED'
/**
* §7 — the operating rules printed on a train's own card. Each names the restriction it broke, so
* the UI can say which card is refusing rather than "illegal move".
*/
| 'NO_SWITCHING'
| 'NOT_A_TERMINAL'
| 'COACH_MUST_STAY'
| 'FREIGHT_WORKED_HERE'
| 'NO_PASSENGER_WORK'
| 'PICKUP_NOT_ALLOWED'
| 'EMPTIES_ONLY'
| 'NOT_IMPLEMENTED'
| 'WRONG_INTENT'
| 'NOT_A_GRADE'
| 'TRAIN_ON_CARD'
| 'CONSIST_ORDER'
| 'NO_TRAIN_AT_OFFICE'
/**
* WHY PASSENGER WORK WAS REFUSED, told apart.
*
* All three used to come back as `NO_TRAIN_AT_OFFICE` — said while a train was standing at the
* Office, which reads as a broken game rather than a rule. Measured over 60 solitaire games, the
* fallback fired 51 times with a coach train in front of the player: 27 with nobody waiting to
* travel, 24 with passengers waiting and every coach on the train already full.
*/
/**
* §9.3 — a load may only start down the sign when an empty car of THAT type is standing on the
* industry's track and is not already promised to another load. Walking cargo onto WORK with
* nothing to put it in is "just dropping it onto the tracks".
*
* STOCKING THE GREEN BOX IS NOT GATED ON IT. §6.3 lets the Freight Agent stage a load whenever
* the Division Yard has the car and the box has room; the empty car only has to be there before
* the Laborers can move it out of the box.
*/
| 'NO_EMPTY_CAR_SPOTTED'
| 'NO_PORTERS_HERE'
| 'NO_PASSENGERS_WAITING'
| 'NO_EMPTY_COACH'
| 'NO_LOADED_COACH'
| 'INBOUND_BOX_FULL'
| 'NO_EMPTY_COACH_IN_YARD'
| 'NOT_A_CONTROL_POINT'
| 'NO_EXTRA_PENDING'
| 'NO_FREE_TRAY'
| 'NO_FREE_AD_TRACK';
export type Rejection = { code: RejectionCode; message: string };
export function reject(code: RejectionCode, message: string): Rejection {
return { code, message };
}
// ---------------------------------------------------------------------------
// Re-exports used by handlers
// ---------------------------------------------------------------------------
export type { CardId, Direction, GridCoord, OfficeTier, PlayerIndex, TrayId };