The third release from the audit; nothing a player sees changes. CHANGELOG has the detail. The 0.4.9 playtest line is no longer maintained (Jesse, 2026-09-29): the deploy rule that existed for it is gone and #85 is moot. The table test (#39 #35 #42a #40) is closed — every line of the checklist was met at a table. #46 is done and cannot regrow: the 36 unused declarations are removed and `noUnusedLocals`/`noUnusedParameters` are on; two of them were dead bot functions from rejected candidates the round said it had deleted. The documents no longer teach `trainCapSlack` (a knob that throws), point at `as-built.md` (deleted in 0.8.2), model `officeType` (the engine says `tier`) or describe `collisionOccurred` (never emitted); the README's account of bot flags now matches the bot's. Five playtest saves committed in `docs/` against the repository's own rule are in the ignored `playtests/`. What the audit found and did not fix is written down as TODO #112-#117, each with its reason. #112 is `docs/plans/structure.md`, the proposal for `http.ts`, `main.ts` and `check`. #117 — `/api/save` hands a seat the seed mid-game — waits on a conversation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
363 lines
18 KiB
TypeScript
363 lines
18 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, OfficeTier } 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.
|
||
*/
|
||
/**
|
||
* §Enhancements, Small Yard — one Move to re-make a train standing on the yard.
|
||
*
|
||
* `order` is a permutation of the current consist, nose first. `engineAt` is where the LOCOMOTIVE
|
||
* ends up in it: 0 puts it back on the front, which is what the v0.4.5 card text describes and
|
||
* what this action did unconditionally until 2026-09-17. `implications.md` records the design
|
||
* source saying a train here "may sort itself into any order, including cars ahead of the engine",
|
||
* and Jesse ruled that way — so it is a number now, and a train left nose-loaded is one §8.2 will
|
||
* not let out of the Office until it is sorted again.
|
||
*
|
||
* Optional, defaulting to 0, so every save written before this replays exactly as it did.
|
||
*/
|
||
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[]; engineAt?: 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 }
|
||
/**
|
||
* §Q, RED FLAGS (Gitea#19) — plant a flag on one side of your own district.
|
||
*
|
||
* "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits from
|
||
* that direction (i.e. Flag East holds westbound trains). You can do this if you see a problem or
|
||
* wish to complete switching."
|
||
*
|
||
* `side` names the side of the district the flag goes on, so a train arriving from that side is
|
||
* held. It REPLACES the old rule, which was played on a stopped train out on the Mainline and
|
||
* protected it from a rear-ender: measured at 4,212 offers and 4 plays across 600 games, a
|
||
* mechanic nobody used. ABS Signals already protects a train standing on a Mainline card.
|
||
*/
|
||
| { type: 'maneuver.redFlags'; cardId: CardId; side: Direction }
|
||
/**
|
||
* The same card, played OUT OF PHASE at the moment of danger (Gitea#19) — "COLLISION RISK! FLAG
|
||
* AGAINST T2?". Answers a pending `redFlag` decision; `flag: false` declines and lets the
|
||
* collision happen. The side is not asked for: the train is already coming from one.
|
||
*/
|
||
| { type: 'mainline.redFlag'; flag: boolean; cardId?: CardId }
|
||
/**
|
||
* 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' }
|
||
/**
|
||
* §3.3, EXTENDED PLAY (Gitea#11) — one vote on whether to play one more Day.
|
||
*
|
||
* ARRIVES OUT OF TURN, like `mainline.clearance`, and unlike it goes to EVERY seat rather than to
|
||
* the Superintendent: it is a table decision, not a ruling. Unanimous, and one `agree: false`
|
||
* ends the game immediately — nobody waits on a player who has already refused.
|
||
*
|
||
* It is an intent, rather than a button the client handles by itself, because a save is
|
||
* `{ seed, config, history }` replayed through the engine: a decision that is not in the history
|
||
* did not happen, and an extended game would evaporate on the next reload, Undo, or server
|
||
* restart. This is the record of the table agreeing.
|
||
*
|
||
* CARRIES ITS VOTER, uniquely among intents, and it has to. A saved history is a flat `Intent[]`
|
||
* with no seat recorded against each move: the replay DERIVES who acted from the turn order
|
||
* (`fromMultiplayerSave`). That works for every other intent, including `mainline.clearance`,
|
||
* because there is exactly one seat it could have been. Here there is not — every seat may vote,
|
||
* in any order — so a vote whose voter is not written down cannot be replayed at all, and a
|
||
* resumed server would refuse the save with `NO_ACTOR`. The server checks this against the seat
|
||
* it authenticated (`NOT_YOUR_TURN`), so it is a record, never a claim.
|
||
*/
|
||
| { type: 'game.extend'; player: PlayerIndex; agree: boolean }
|
||
/**
|
||
* §11 (Gitea#5) — take the Yard Office, or the standard Office.
|
||
*
|
||
* Interrupts the Mainline Phase like `mainline.clearance`, and like it goes to one named player:
|
||
* whoever sits in the district the train is arriving at. Offered only when a route exists, so
|
||
* `take: true` always has somewhere to go — though it may still meet cars on the lead and crash,
|
||
* which is the point of the rule.
|
||
*/
|
||
| { type: 'mainline.yardOffice'; take: boolean };
|
||
|
||
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'
|
||
/**
|
||
* A passenger Modifier — Waiting Area, Restaurant, Hotel — played at an Office that is still a
|
||
* Whistle Post. A Whistle Post is not a Passenger Facility (§9), so it has nothing to add to.
|
||
*/
|
||
| 'OFFICE_NOT_PASSENGER'
|
||
| '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'
|
||
/** §3.3 (Gitea#11) — `game.extend` when the game is not waiting on an extension vote. */
|
||
| 'NOT_AWAITING_EXTENSION'
|
||
/** §3.3 (Gitea#11) — this seat has already voted on this extension. */
|
||
| 'ALREADY_VOTED'
|
||
/** §11 (Gitea#5) — answering a Yard Office offer that is not open. */
|
||
| 'NO_YARD_OFFICE_OFFER'
|
||
/** §Q (Gitea#19) — answering a Red Flag prompt that is not open. */
|
||
| 'NO_RED_FLAG_PROMPT'
|
||
/** §Q (Gitea#19) — this district already has a flag on that side. */
|
||
| 'ALREADY_FLAGGED';
|
||
|
||
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 };
|