"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." REPLACES the old rule outright, per Jesse's call. Red Flags used to be played on a stopped train out on the Mainline and protected it from a rear-ender: offered 4,212 times and played 4 across 600 games, a mechanic nobody used, and ABS Signals already does that job better. The flag is now planted on one side of your own district and holds the next train arriving from that side. SPENT ON THE TRAIN IT STOPS. One card, one train, so there is no lifting action to build, nothing to forget, and a flag cannot quietly strangle the Division. The held train loses one Mainline Phase and comes in on the next — it buys a Stage to clear the lead, which is what "wish to complete switching" asks for. PLAYABLE OUT OF PHASE, which is the other half of the issue: when an arrival would certainly collide and the district's owner holds the card, the phase breaks in and asks. Offered ONLY to somebody holding one — a prompt with a single button is not a choice, and it would leak that a collision is coming. The danger is read from §8.3's own two triggers rather than restated, so the prompt cannot offer a flag against a collision that will not happen. Built on the decision union Gitea#5 introduced: this adds a `redFlag` case and nothing else structural. THE BOT STILL NEVER PLAYS IT, AND I MEASURED RATHER THAN ASSUMED. It now takes the out-of-phase prompt unconditionally — the engine has already established the danger, so there is nothing left to judge — and over 200 solitaire games `redFlagsSet` fires ZERO times. The prompt needs an arrival that would collide (0.14 per game, about one game in seven) to coincide with holding the card from a three-card hand out of 121. So the anomaly exemption in sim.test.ts stays, but its comment no longer claims the bot is unwilling: it is measuring deck luck. What is left to fix is the half of the card a human would use, planting a flag on purpose to buy switching time, and TODO.md now says that instead of the old finding. A BUG WORTH RECORDING, because the next interruption will meet it too: the flag was originally taken down in a `reduce` case, which never fires for an event advance.ts emits — the phase driver mutates state and then describes it. The flag stayed up and held every train that came. test/events.test.ts's unreduced-event registry is what makes that class of mistake visible, and `redFlagSpent` is on it deliberately now, with the reasoning. 858 tests pass. Closes #19 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
346 lines
17 KiB
TypeScript
346 lines
17 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, 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 }
|
||
/**
|
||
* §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'
|
||
| '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 };
|