Fifteen items from two playtest sessions. Three that read as drawing faults were engine bugs: cars could be added to a train that was not being made up (50 offers in 8 games), the make-up panel merged two trains and could couple a car to the wrong one, and an Office upgrade silently deleted what a Modifier had added. A fourth was a sentinel inside a coordinate's own value range — a Mainline placement travelling as row -1, which is an ordinary district row. Trains are now drawn the way they stand: west on the left, nose toward the way the engine faces, on both the district card and the Division chip. Undo steps back through the game by replaying the save without its last intent. The switching walk keeps its rejections, so the board can say why a square is not offered. Laborers and Porters are on the card, and the rule that a district only grows outwards is finally written down. Versions start here: third digit for fixes, second for a feature set, 1.0 for a release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GgtkX8JnvKa8y2tuJ8aQf4
149 lines
6.4 KiB
TypeScript
149 lines
6.4 KiB
TypeScript
/**
|
||
* 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, 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)
|
||
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean }
|
||
/**
|
||
* 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 }
|
||
// -- New Train Phase (§7)
|
||
| { type: 'newTrain.placeCar'; trayId: TrayId; carType: CarType; loaded: boolean }
|
||
| { type: 'newTrain.passCar'; trayId: TrayId }
|
||
/** 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'
|
||
| 'NOT_IMPLEMENTED'
|
||
| 'WRONG_INTENT'
|
||
| 'NOT_A_GRADE'
|
||
| 'TRAIN_ON_CARD'
|
||
| 'CONSIST_ORDER'
|
||
| 'NO_TRAIN_AT_OFFICE';
|
||
|
||
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 };
|