Files
station-master/src/engine/intents.ts
T
Jesse.MarkowitzandClaude Opus 5 06db36e5b5 v0.7.0 — four game types, a lobby you can read and leave, and a multiplayer game that makes a sound
The multiplayer set-up, the lobby, the start of a game, and four signals a remote client had never
been sent. Reasoning, the preset table and what was verified how: CHANGELOG.md.

- Co-op, Competitive, Cutthroat, Solitaire and Custom, on both screens, from one shared block —
  they had drifted, and each was missing a question the other asked.
- A player reads the whole rule set before taking a seat, may leave a lobby or a running game, and
  keeps a seat across a reload. The host may clear a chair. The browser remembers every game it is
  in, not just the last one.
- The start of a game is drawn: a handoff beat, an announcement, the code and type in the header.
- Sound, the timetable flash, announcements and the just-drawn badge now reach a remote client;
  justDrawn goes to the seat that drew it and nobody else.
- Played on StartOS, which found the rest: an Extra belongs to the player who played it, the board
  never named the Superintendent, bot seats were reported as absent players, and rule section
  numbers are out of every string a player reads.

Also carries the previous session's Heavy Grade documentation work — asked again, answer unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016JczK5i33ZNSf2PtzZqdhS
2026-08-23 05:46:42 -04:00

292 lines
14 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';
/**
* Where an Extra is placed when it is started (§7).
*
* `mainline` names a node index in `division.nodes` and is only ever an Interchange; `office` names
* a SEAT, which is what an Office Area belongs to, and only ever a Control Point.
*/
export type ExtraStart =
| { kind: 'divisionPoint'; side: Direction }
| { kind: 'mainline'; node: number }
| { kind: 'office'; seat: SeatIndex };
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 at EITHER Division Point for
* immediate departure", plus Jesse's ruling on where else and which way.
*
* The number does not decide an Extra's direction — the START does. Either Division Point may be
* chosen and the train runs away from it (west end runs east, east end runs west, since the other
* reading is a train that leaves the Division having crossed nothing). At an Interchange or a
* Control Point, which are in the middle of the railroad, both ways are real runs and `direction`
* says which; it is required there and ignored at a Division Point.
*
* WHICH STARTS ARE OFFERED is the `extraStart` house rule (content.ts) — Division Points and the
* Interchange always, Offices by setting.
*
* `atSeat` IS LEGACY AND WRITE-ONCE. Saves written before this choice existed carry only that
* field: `null` meant "the Division Point this train's number sends it to" and a seat meant that
* Office, both running in the number's direction. `start` absent is exactly what those saves said,
* so they replay unchanged; everything written from now on carries `start` and `atSeat` is
* omitted. `resolveExtraStart` (apply.ts) is the single place that reads either.
*/
| {
type: 'newTrain.startExtra';
trainNumber: number;
atSeat?: SeatIndex | null;
start?: ExtraStart;
direction?: Direction;
}
/** 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)
/**
* `trayId` names the train the Porter works — reported from playtesting v0.4.9d as "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". It was: neither intent carried a train, so the reducer took the first
* one on the A/D tracks and the roster chip the player had clicked changed nothing but the drawing.
*
* OPTIONAL, like `switch.move`'s `via` and for the same reason: intents are the canonical record
* `undo` and every save replay against, and absent means what it has always meant — the first
* eligible train at the Office.
*/
| { type: 'porter.board'; at: GridCoord; trayId?: TrayId }
| { type: 'porter.detrain'; at: GridCoord; trayId?: TrayId }
/** §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'
/**
* §9 (Jesse's ruling, v0.4.9e) — freight or passengers loaded anywhere in an Office Area may not
* be unloaded anywhere in that same Office Area. The load has to be carried out of the district by
* a train first; a Freight House may not break the load it just made, and passengers may not
* detrain at the platform they boarded from.
*
* Distinct from the other refusals because the car IS loaded, the Laborer IS free and the boxes
* ARE clear: the only thing wrong with it is where it came from, and a player looking at a loaded
* boxcar standing on their own industry track deserves to be told that rather than "wrong car".
*/
| 'LOADED_IN_THIS_DISTRICT'
| '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'
/** §7 gives an Extra to the player who played the card; another seat may not place it for them. */
| 'NOT_YOUR_EXTRA'
| 'NO_FREE_TRAY'
| 'NO_FREE_AD_TRACK'
// -- §7, where an Extra may be started (`resolveExtraStart`)
| 'NO_SUCH_DIVISION_POINT'
/** Only the Interchange has a yard an Extra can be made up in. */
| 'NOT_AN_INTERCHANGE'
/** In the middle of the railroad both ways are real runs, so the intent has to say which. */
| 'NO_DIRECTION_CHOSEN'
/** The `extraStart` house rule is `divisionPointsOnly`. */
| 'OFFICE_STARTS_NOT_ALLOWED'
/** The `extraStart` house rule is `ownOffice` and this is somebody else's district. */
| 'NOT_YOUR_OFFICE'
/**
* §6.2, Jesse's ruling (Gitea#6) — a train card is never discarded. Hold it as long as you like;
* the only way it leaves your hand is onto the timetable.
*/
| 'TRAINS_ARE_NEVER_DISCARDED';
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 };