Eight play reports and one design that had been written up and not built. The
through-line is switching: what a card can hold, which end of a train a cut comes
off, which way a train meets cars standing on the line, and what the board and the
log say about all of it.
TRACK ORDER FOR STANDING CARS, AND THE CUT ON YOUR OWN CARD
Two reports turned out to be one root cause. `TrackCard.standing` claimed "in track
order (§A.3)" and had no defined orientation at all, while `CrewTray.consist` does
(nose first, relative to facing) — so every transfer between them was a conversion
nothing performed. §A.3 says what it should be: cars occupy the track "in the same
order they originally held, left-to-right". Left-to-right is west-to-east, and that
is now the defined orientation of `standing` and of an industry track through
`carsOn`. It is the board's orientation, not the train's, so it does not change when
a different train touches the card.
- Setting out is batch-invariant. Four cars at once, four singles and two pairs
parked three different orders, one of them physically impossible. Successive
cuts off the same end stack up towards the engine, so the insertion point is the
train's own place in the row.
- Approaching a cut from either end now mirrors. `couples` is built nearest-first
along the direction of travel and reverses onto the nose, so the farthest car met
ends up nose-most — which is what makes a run-around worth its Move.
- A train no longer drives through its own cut. The walk began at the neighbour of
the start square and never read the start card, so a crew could set cars out and
pull straight away from them. Coupling is mandatory (§A.4) and your own square is
no exception; the cut counts against the four-car limit. Setting out off the end
you are not leaving by still works.
`CrewTray.standingWest` records where a train stands among the cars on its card — a
train may set out off both ends on one square, so which side a cut is on is not
recoverable from the array alone.
On the board, the cut is drawn split at the train — west cars left, east cars right,
engine in the gap — and each car's tooltip says whether it stands ahead of or behind
the engine. The history says which end a cut came off, and a move's button separates
"takes your own boxcar back off this card" from cars found standing on the line.
Decided: taking your own cut back on the square you are standing on is UNDOING the
drop. It is exempt from trains 3/4's per-location freight budget, X13's "drop but not
pick up" and X22's "empties only", and it refunds the budget the drop spent.
Otherwise a legal-looking drop becomes silently one-way.
Measured, 200 paired seeds, developer bot: -0.55 revenue (t = -3.63), freight revenue
1.11 -> 0.56. That cost is the bot's, not the rule's — its trains run engine-first,
so at a stub industry it sets a car out between itself and the only way out, and the
correct play is §A.5's facing-point move, which is the cross-turn planning TODO.md
already records as out of reach of any bot. Filtering self-recoupling moves out of its
options took recoupling from 625 of 1,029 set-outs to 101 of 677, and all 101 that
remain are that case. Read the number as a bot measurement, not a balance one.
THE SUPERINTENDENT'S RULING NAMES THE TRAINS IT IS ABOUT
Reported: the Superintendent could not tell which train he was clearing. The heading
asks the question now — "may Train 6 follow Train 4 onto the same Mainline card?" —
and the trains moved to the FRONT of each button, because the button splits its label
at the first em-dash and showed only the head.
AN INDUSTRY TRACK HOLDS FOUR CARS, LIKE EVERY OTHER CARD
Reported at undo 188: "we wanted to drop two cars, but were only allowed to drop one."
An industry track was built as long as its box count, so a one-box industry had room
for one car. Box count is how much WORK an industry can hold, not how much RAIL it
has. Ordinary track was the other exception, unbounded; both are gone and every card
holds four.
THE FREIGHT AGENT MAY STAGE A LOAD BEFORE THE CAR IS THERE
§6.3 asks nothing of the industry track — the empty car belongs to §9.3's Load the
car, which is the Laborer's action. The gate now lives only there, so cargo can wait
on the dock while the car to ship it in is still being switched in. Nothing can jam:
a load in a green box is waiting, not stuck.
THE TRUCK DOCK UNLOADS, AND BRINGS NOBODY
+1 inbound, no Laborer. It printed +1 outbound and +1 Laborer, which made it a
longer-host-list copy of Forklifts. Beside Packing Sheds it now does nothing at all,
and the hand tooltip says so before it is played.
Also in this release, from the days before: Mainline card tooltips computed from the
crossing rule; an Extra starts from the Division Point its number sends it to; a
modifier's suppressed grant comes back when a Whistle Post is upgraded; the Oil
Refinery and the Grocer's Warehouse ship as well as receive, per the card reference;
and the dormant defences name the attack they answer. `.claude/` is now gitignored —
it holds Claude Code's worktrees, i.e. a second checkout of this repository.
570 tests, typecheck clean. The three published replays were re-recorded twice —
legality changed, so bot play changed. Full detail in CHANGELOG.md.
829 lines
36 KiB
TypeScript
829 lines
36 KiB
TypeScript
/**
|
|
* Component 2 — State model and types.
|
|
*
|
|
* The entity model from docs/architecture/game-state.md. Pure data; no behaviour beyond a few
|
|
* derivations that must never be cached (see DERIVED note below).
|
|
* See architecture/components.md §2 A.2.
|
|
*/
|
|
|
|
import type {
|
|
CarType,
|
|
Direction,
|
|
Hand,
|
|
MainlineKind,
|
|
FreightKind,
|
|
GameLength,
|
|
HouseRuleOverrides,
|
|
ModifierKind,
|
|
OfficeTier,
|
|
TrackGeometry,
|
|
} from './content.ts';
|
|
import { MAX_CONSIST, officeProfile } from './content.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Identifiers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type PlayerIndex = number;
|
|
|
|
/**
|
|
* A SEAT at the table — a fixed position in the west-to-east chain of Offices (§4.3).
|
|
*
|
|
* NOT the same thing as a `PlayerIndex`, even though the two are equal in every game today. An
|
|
* Office is a place: it sits between two Mainline cards and never moves. A player OCCUPIES a seat,
|
|
* and Employee Rotation (Appendix B) moves every player one seat left at the end of each Day while
|
|
* their Revenue and the Fedora travel with them.
|
|
*
|
|
* So: offices, districts and grid positions are keyed by SEAT; hands, Revenue, the Superintendent
|
|
* and whose turn it is are keyed by PLAYER. `s.seating` maps one to the other, and both are plain
|
|
* numbers, so the distinction is carried by naming and by the accessors rather than by the type
|
|
* system — `areaOf(s, player)` and `areaAtSeat(s, seat)` are the two doors, and code should use them
|
|
* rather than reaching into `officeAreas` directly.
|
|
*/
|
|
export type SeatIndex = number;
|
|
export type CardId = string;
|
|
export type TrayId = string;
|
|
|
|
/** A cell in a player's Office Area grid. Sparse — cards are placed during play. */
|
|
export type GridCoord = { row: number; col: number };
|
|
|
|
export function coordKey(c: GridCoord): string {
|
|
return `${c.row},${c.col}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rolling stock
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** §2.2 — a coloured car is loaded, a white car is empty. */
|
|
export type RollingStock = { type: CarType; loaded: boolean };
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Track and Office Area
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A turnout's handedness: the stem is §A.1's "A", the two legs are "B" and "C". The rule that
|
|
* matters is that `through` and `diverge` are NOT joined to each other.
|
|
*
|
|
* The through track is ALWAYS east-west (`{stem, through}` is `{e,w}`); the diverging leg is the
|
|
* 45° one and always reaches north or south. Which of the two the card can be turned to is decided
|
|
* by its printed handedness — see `Slope`.
|
|
*/
|
|
export type TurnoutOrientation = { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' };
|
|
|
|
/**
|
|
* A curve joins two ADJACENT edges: it runs along the card's centre line from the east or west edge
|
|
* to a frog, then leaves at 45° through the MIDDLE of the north or south edge. Four such arcs
|
|
* exist, but a printed card reaches only two of them (`Slope`).
|
|
*/
|
|
export type TrackArc = 'ne' | 'nw' | 'se' | 'sw';
|
|
|
|
/**
|
|
* WHICH DIAGONAL A 45° LEG LIES ON.
|
|
*
|
|
* The printed cards (docs/tracks.png) put the through rail dead centre and send every diverging leg
|
|
* out at 45° through the middle of the north or south edge. A card can be turned 180° but not
|
|
* flipped over, so its leg never changes diagonal: the slope is printed, not chosen.
|
|
*
|
|
* Each name is the pair of arcs that MATE across a horizontal card edge — an `sw` card sitting
|
|
* above an `ne` card is one unbroken rail, whereas `sw` above `nw` is a V and joins nothing. That
|
|
* is the whole matching rule, and it is why the name is spelt this way.
|
|
*
|
|
* On screen `ne_sw` descends to the right and `nw_se` descends to the left.
|
|
*/
|
|
export type Slope = 'ne_sw' | 'nw_se';
|
|
|
|
export type CardGeometry =
|
|
| {
|
|
kind: 'track';
|
|
geometry: TrackGeometry;
|
|
turnout?: TurnoutOrientation;
|
|
/** Which two edges a CURVE joins — chosen on placement, since the card can be turned. */
|
|
arc?: TrackArc;
|
|
/** Curves and turnouts are printed left- or right-handed, which fixes their `Slope`. */
|
|
hand?: 'left' | 'right';
|
|
}
|
|
| { kind: 'office' }
|
|
| { kind: 'limits' }
|
|
| { kind: 'facility'; facility: FreightKind }
|
|
/** Not track — a Modifier sits beside a Facility and raises its capacity (§9). */
|
|
| { kind: 'modifier'; modifier: ModifierKind }
|
|
/** Not track — a Space-use card played at a district to consume a cell (Q6). */
|
|
| { kind: 'spaceUse'; key: string };
|
|
|
|
export type TrackCard = {
|
|
geometry: CardGeometry;
|
|
/**
|
|
* DERIVED for facility cards — a Facility track locked by loads on MEN|AT|WORK stops being
|
|
* Operational Rail (§9.3). Use isOperationalRail() rather than reading a stored flag.
|
|
*/
|
|
baseOperationalRail: boolean;
|
|
/**
|
|
* Uncoupled cars left standing here, **ordered WEST to EAST** (§A.3).
|
|
*
|
|
* §A.3 states the requirement outright: "cars are loaded into and unloaded from the Crew Tray, and
|
|
* occupy the track, in the same order they originally held, **left-to-right**". Left-to-right is
|
|
* west-to-east, and this array had no defined orientation at all — the doc comment said "in track
|
|
* order" and named no direction, so nothing performed the conversion.
|
|
*
|
|
* That is one bug wearing three faces, all reported from play. Setting out four cars at once
|
|
* parked them in a different order than setting out one car four times, because `carsDropped`
|
|
* always pushed onto the end. A train that ran onto a parked cut got the SAME consist whichever
|
|
* way it approached, when the two must mirror. And the board drew the tray strip flipped for an
|
|
* east-facing train and the card's cars unflipped, so the same cut read one way in the tray and
|
|
* the other on the card.
|
|
*
|
|
* WEST-TO-EAST IS THE BOARD'S ORIENTATION, not the train's, which is exactly why it is the right
|
|
* one: it is a property of the card, so it does not change when a different train touches it. The
|
|
* two conversions are stated once, in `apply.ts`'s `carsDropped` and `carsCoupled` reducers, and
|
|
* `CrewTray.standingWest` records where a train standing here sits among them.
|
|
*/
|
|
standing: RollingStock[];
|
|
facility: Facility | null;
|
|
modifiers: ModifierKind[];
|
|
/** Enhancement cards laid on this card (§7 of implications.md). */
|
|
enhancements: string[];
|
|
};
|
|
|
|
export type OfficeArea = {
|
|
/** Where this Office sits in the chain, not who is sitting at it. See `SeatIndex`. */
|
|
seat: SeatIndex;
|
|
tier: OfficeTier;
|
|
grid: Map<string, TrackCard>;
|
|
officeCoord: GridCoord;
|
|
/** The grid row that is the Running Track (§2.1). */
|
|
runningRow: number;
|
|
limitsWest: GridCoord;
|
|
limitsEast: GridCoord;
|
|
/** Trays holding at the Office. Length must never exceed the tier's A/D track count. */
|
|
adOccupancy: TrayId[];
|
|
/**
|
|
* Trains held at the Limits by an Interlocking rather than admitted to the Office. They are
|
|
* inside the player's Limits but not occupying an A/D track.
|
|
*/
|
|
heldAtLimits: TrayId[];
|
|
/** Dispatch bonuses spent this Day, by enhancement key — each is once a Day. */
|
|
dispatchUsedToday: string[];
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Facilities
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A load in transit along the MEN | AT | WORK track (§9.3).
|
|
*
|
|
* Direction matters and is easy to miss. **Outbound** loading runs Green -> MEN -> AT -> WORK ->
|
|
* onto a spotted empty car. **Inbound** unloading runs the other way: car -> WORK -> AT -> MEN ->
|
|
* red Inbound box. Same three boxes, opposite traversal.
|
|
*/
|
|
export type Load = { type: CarType; dir: 'out' | 'in' };
|
|
|
|
export type Facility = {
|
|
kind: 'freight' | 'passenger';
|
|
subtype: FreightKind | 'office';
|
|
allows: { outbound: boolean; inbound: boolean };
|
|
outboundBox: RollingStock[];
|
|
inboundBox: RollingStock[];
|
|
capacity: { outbound: number; inbound: number };
|
|
/**
|
|
* FREIGHT ONLY — `null` on a Passenger Facility, which is what makes that unrepresentable rather
|
|
* than merely unused.
|
|
*
|
|
* One load per box; three boxes, so it is a pipeline (§9.1). §9.2's passenger work is porters
|
|
* boarding and detraining with no pipeline at all, and the sign is printed "For Freight
|
|
* Facilities" (rules-v0.2.md:452). Every Office got a three-slot array anyway, and because it was
|
|
* an array the renderers happily drew three boxes on a Depot that has no Laborer to work them —
|
|
* reported from playtesting. Typing it away means a fourth renderer cannot reintroduce the bug.
|
|
*/
|
|
menAtWork: [Load | null, Load | null, Load | null] | null;
|
|
/**
|
|
* Where cars are spotted for loading and unloading.
|
|
*
|
|
* NO LENGTH. This used to carry `length: baseOut + baseIn`, which quietly made an industry's
|
|
* SIDING as long as its box count — so a Mine Tipple (one green box) had room for exactly one
|
|
* car, and a crew standing on it with two hoppers to set out could only put down one. Reported
|
|
* from play at undo 188. The box count is how much WORK an industry can hold, not how much RAIL
|
|
* it has; conflating the two invented a printed siding that no industry card actually has.
|
|
*
|
|
* An industry track is ordinary Operating Rail and holds what any card holds — see `spaceOn`.
|
|
* Modifiers raise `capacity` (another red or green box) and never the room for cars.
|
|
*/
|
|
industryTrack: { cars: RollingStock[] };
|
|
laborers: number;
|
|
porters: number;
|
|
/** Resets at the start of each Stage (§9.1). */
|
|
usedThisStage: { laborers: number; porters: number };
|
|
};
|
|
|
|
/**
|
|
* §9.3 — while ANY load sits on MEN|AT|WORK the industry's track is locked down and loses its
|
|
* Operational Rail status. This is why isOperationalRail is a function, not a stored field.
|
|
*/
|
|
/**
|
|
* The cars physically standing on a card, **ordered WEST to EAST**.
|
|
*
|
|
* On a Facility card the industry track IS where cars stand — spotting a car there and leaving a
|
|
* car there are the same act (§9.3). Modelling them as two separate places was a bug: cars dropped
|
|
* at a facility went into `standing`, while loading looked for them on `industryTrack`, so no
|
|
* freight load could ever complete and freight revenue was structurally zero.
|
|
*
|
|
* THE ORIENTATION IS THE BOARD'S, NOT THE TRAIN'S, and it is `standing`'s alone to keep — see the
|
|
* comment on that field. `industryTrack.cars` inherits it through this function, so every reader of
|
|
* either place gets the same convention and the two can never drift apart.
|
|
*/
|
|
export function carsOn(card: TrackCard): RollingStock[] {
|
|
// Gated on the facility being FREIGHT, not on a track length that no longer exists. A Passenger
|
|
// Facility has no industry track at all — passengers board off the platform — so cars left on a
|
|
// Depot stand on the card like they would anywhere else.
|
|
return card.facility && card.facility.kind === 'freight' ? card.facility.industryTrack.cars : card.standing;
|
|
}
|
|
|
|
/**
|
|
* How many more cars this card can hold.
|
|
*
|
|
* ONE RULE FOR EVERY OPERATING TRACK CARD, industry or not: a card holds up to `MAX_CONSIST` cars,
|
|
* because a consist may not exceed four and four is therefore all that can ever be shoved onto one.
|
|
* An industry used to be the exception — its room came from its box count, so a one-box industry
|
|
* refused a second car — and that exception was the bug. Ordinary track used to be the other
|
|
* exception, unbounded, which let a pile build up that no train could then legally couple.
|
|
*/
|
|
export function spaceOn(card: TrackCard): number {
|
|
return MAX_CONSIST - carsOn(card).length;
|
|
}
|
|
|
|
export function isOperationalRail(card: TrackCard): boolean {
|
|
if (!card.baseOperationalRail) return false;
|
|
if (isLockedByWork(card)) return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* §9.3 — "While ANY loads are in the MEN | AT | WORK track, the industry's track is locked down for
|
|
* safety reasons. It loses its status as Operational Rail. No cars can be picked up or dropped off,
|
|
* and **no trains may occupy or move on it**."
|
|
*
|
|
* That last clause is stronger than losing Operational Rail status. A turnout is not Operational
|
|
* Rail either, and a train runs straight through one — so "cannot stop here" and "cannot pass
|
|
* through here" are different properties, and only a locked industry has both. Kept separate from
|
|
* `isOperationalRail` for exactly that reason.
|
|
*/
|
|
export function isLockedByWork(card: TrackCard): boolean {
|
|
// A Passenger Facility has no pipeline, so it is never locked by freight work.
|
|
return !!card.facility?.menAtWork?.some((slot) => slot !== null);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trains
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type NodeRef =
|
|
| { at: 'divisionPoint'; side: Direction }
|
|
| { at: 'mainline'; index: number }
|
|
/** A tray standing in someone's district. `seat` is WHICH district, not whose turn it is. */
|
|
| { at: 'grid'; seat: SeatIndex; coord: GridCoord };
|
|
|
|
export type CrewTray = {
|
|
id: TrayId;
|
|
/** null while a local crew is switching without a train card. */
|
|
trainNumber: number | null;
|
|
trainIsExtra: boolean;
|
|
/**
|
|
* WHERE THE ENGINE SITS IN THE TRAY, as an index into `consist`.
|
|
*
|
|
* A Crew Tray is an engine plus its Rolling Stock, and the engine may be PULLING (index 0, ahead
|
|
* of everything), PUSHING (index `consist.length`, behind everything) or somewhere in the middle
|
|
* doing both at once. That last case is why this is an index and not the boolean it replaced —
|
|
* `engineFront` was written in three places and read in none, so the engine had a position the
|
|
* game recorded and never used.
|
|
*
|
|
* The engine is NOT one of the `consist` entries: §8.2 counts the consist as Rolling Stock, and
|
|
* the four-car limit (§A.4) is a limit on cars, not on the locomotive hauling them.
|
|
*/
|
|
engineAt: number;
|
|
/**
|
|
* ORDERED NOSE FIRST: index 0 is the end nearest the front of the train, the last index is the
|
|
* tail. Max 4 including any caboose (§A.4); the engine is not one of them.
|
|
*
|
|
* Both diagrams in Appendix A read this way. A train drawn `[<ENG][yellow][brown][blue][red]`
|
|
* drops "red as he starts, then brown and blue", ending with only yellow — cars come off the TAIL,
|
|
* which is the end of the array. And a train that picks cars up on its nose gets them "in the
|
|
* order that they were in, pushing them into the Facility" — ahead of the engine, which is why
|
|
* `engineAt` moves when it happens.
|
|
*/
|
|
consist: RollingStock[];
|
|
direction: Direction;
|
|
/**
|
|
* Which way the engine points, as an actual port on the card beneath it.
|
|
*
|
|
* NOT derivable from `direction`, which only has east and west: a crew standing on a north-south
|
|
* spur points north or south, and deriving 'e'/'w' gave it an exit port the card does not have —
|
|
* so it had no legal moves at all and was stranded permanently. Left optional so a tray placed
|
|
* without one falls back to `direction`.
|
|
*/
|
|
facing?: 'n' | 's' | 'e' | 'w';
|
|
/**
|
|
* WHICH WAY THE ENGINE POINTS IN RAILROAD TERMS — east or west, and never anything else.
|
|
*
|
|
* `facing` is a PORT, because movement needs one: a crew on a north-south spur must be able to
|
|
* leave by 'n' or 's'. But a Division runs east and west, and a player reads a train the way a
|
|
* railroader does — "the engine is on the west end" — so a ▲ on a crew that had turned onto a
|
|
* spur read as a train that had somehow stood itself on end. Worse, nothing resets `facing` when
|
|
* a train leaves the district, so a crew that shunted onto a north-south spur and then departed
|
|
* carried its 'n' out onto the Division map and drew ▲ there too.
|
|
*
|
|
* So this is the DISPLAY facing, and it is a separate field because it cannot be derived: on
|
|
* north-south track the east-west sense is not in the current port, it is in the last one. It
|
|
* holds its value across north-south track and updates whenever `facing` becomes 'e' or 'w' —
|
|
* which is exactly the railroad's own convention, where compass north on a branch is still
|
|
* timetable east. A train that runs forward through 180° of curves genuinely does come out
|
|
* pointing the other way, and this follows it; backing up does not change it, because a train
|
|
* that backs up has not turned around.
|
|
*
|
|
* NOT `direction`: that is the timetable direction of the RUN and does not move when a run-around
|
|
* puts the engine on the other end, which is the one thing the arrow exists to show.
|
|
*/
|
|
railFacing?: 'e' | 'w';
|
|
/**
|
|
* HOW MANY OF THIS CARD'S STANDING CARS LIE WEST OF THIS TRAIN.
|
|
*
|
|
* `carsOn(card)` runs west to east and a train standing on the card sits somewhere IN that row:
|
|
* cars `[0, standingWest)` are west of the engine, cars `[standingWest, end)` are east of it. A
|
|
* train can set out off both ends on the same square — §A.3 lets a cut come off either outer end —
|
|
* so "which side is this cut on" is not recoverable from the array alone, and it is the question
|
|
* every remaining rule asks.
|
|
*
|
|
* It answers all three: which cars are AHEAD of the engine and which BEHIND (combine with
|
|
* `railFacing`), which cut a departing train must couple back up (the one at the end it leaves
|
|
* by), and which side of the chip the renderer draws the cut on.
|
|
*
|
|
* Kept on the TRAY rather than the card because it describes a relationship, not the card: with no
|
|
* train standing there, a card's cars are simply a cut with no near or far side, and the next
|
|
* train to arrive couples all of them regardless. Reset to 0 on every move, which is sound because
|
|
* coupling is mandatory — a train arrives on a card it has just emptied.
|
|
*/
|
|
standingWest?: number;
|
|
position: NodeRef;
|
|
movesUsed: number;
|
|
/**
|
|
* X18 Circus Train — "one turn stopped on any track (circus set-up) earns 1 point", claimed once.
|
|
*
|
|
* Recorded on the tray rather than the player because it is the TRAIN that sets up, and an Extra
|
|
* runs once and is gone; there is no second visit to claim it on.
|
|
*/
|
|
stopPointClaimed?: boolean;
|
|
/**
|
|
* X17 Campaign Train — "one turn at station (speeches) then expedite".
|
|
*
|
|
* It makes its speech at the first Office it reaches: that arrival is an ordinary stop, and from
|
|
* then on the train runs expedited, departing every Office in the Stage it arrives. Recorded on the
|
|
* tray for the same reason as `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs
|
|
* once, so there is no later visit to hang it on.
|
|
*/
|
|
speechMade?: boolean;
|
|
/**
|
|
* Arrived expedited this Stage, so it departs at the END of the Stage rather than laying over to
|
|
* the next one (Q3). Set on arrival; cleared when `shiftChange` attempts the departure — after
|
|
* Load/Unload, which is what gives the Porters their one Stage with the train.
|
|
*/
|
|
departsThisStage?: boolean;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The Division
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A train part-way across a Mainline card. Crossing time is measured in STAGES (Q1/Q2), so the
|
|
* regions the placeholder used are gone — the cells printed on the cards are decoration.
|
|
*/
|
|
/**
|
|
* A train crossing a Mainline card.
|
|
*
|
|
* `stagesTotal` is what the crossing cost when the train ENTERED, and it exists so the Division map
|
|
* can say where on the card a train is. §2.1 divides a Mainline card into two regions and §8.2 has
|
|
* a train move one region per Stage; the engine models crossing as a countdown of Stages instead,
|
|
* so position has to be derived — and it cannot be recomputed later, because a modifier played onto
|
|
* the card mid-crossing would change the answer and make the train appear to jump backwards.
|
|
*
|
|
* Read by nothing that decides anything: no legality check, no movement, no bot. It records what
|
|
* already happened so the picture can be drawn.
|
|
*/
|
|
export type Transit = {
|
|
tray: TrayId;
|
|
stagesRemaining: number;
|
|
stagesTotal: number;
|
|
direction: Direction;
|
|
};
|
|
|
|
export type DivisionNode =
|
|
| { kind: 'divisionPoint'; side: Direction; holding: TrayId[] }
|
|
| {
|
|
kind: 'mainline';
|
|
card: MainlineKind;
|
|
transits: Transit[];
|
|
absSignals?: boolean;
|
|
/** Brakeman / Airbrakes / Helpers / Realignment laid on this card. */
|
|
modifiers?: string[];
|
|
/**
|
|
* Which way a train is travelling when it climbs. The Heavy Grade card prints "(Up)" and
|
|
* "Player sets orientation", so the direction is chosen when the card is placed.
|
|
*/
|
|
gradeUp?: Direction;
|
|
/** Red Flags protecting a stopped train here, by tray. */
|
|
redFlagged?: TrayId[];
|
|
}
|
|
| { kind: 'office'; seat: SeatIndex };
|
|
|
|
/** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */
|
|
export type Division = { nodes: DivisionNode[] };
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Decks and yards
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type CardKind =
|
|
| { kind: 'timetabledTrain'; number: number }
|
|
| { kind: 'extraTrain'; number: number }
|
|
| { kind: 'office'; tier: OfficeTier }
|
|
| { kind: 'freightFacility'; facility: FreightKind }
|
|
| { kind: 'modifier'; modifier: ModifierKind }
|
|
/** Handedness is printed on the card: it is the diagonal the 45° leg lies on. */
|
|
| { kind: 'track'; geometry: TrackGeometry; hand: Hand }
|
|
/**
|
|
* The four categories recovered from the design. They are in the deck so the composition is
|
|
* right; their BEHAVIOUR is not implemented yet (implications.md §10 is still open), so
|
|
* `check` rejects playing them and they can only be discarded.
|
|
*/
|
|
| { kind: 'spaceUse'; key: string }
|
|
| { kind: 'enhancement'; key: string }
|
|
| { kind: 'mainlineModifier'; key: string }
|
|
| { kind: 'maneuver'; key: string }
|
|
| { kind: 'action'; key: string };
|
|
|
|
export type Card = { id: CardId; kind: CardKind };
|
|
|
|
export type Decks = {
|
|
/** Face down. Order is SECRET — never projected to any client. */
|
|
homeOffice: CardId[];
|
|
/**
|
|
* THE THREE DEPARTMENT DECKS — shared by every player, and stacks rather than single slots.
|
|
*
|
|
* §6.2: a discard goes "face up ON TOP of one of the three Department slots", and a draw takes
|
|
* "the TOP face-up card". Both phrases only mean anything if a Department is a pile, and modelling
|
|
* them as one-card slots silently DESTROYED whatever was underneath — discarding onto an occupied
|
|
* slot overwrote it, leaking cards out of a closed deck.
|
|
*
|
|
* Last element is the top of the pile: the card that is face up, the only one that may be drawn,
|
|
* and the one a discarding player is choosing to offer or to bury.
|
|
*/
|
|
departments: CardId[][];
|
|
/** Face up, so players can audit discards (§2.6). */
|
|
salvageYard: CardId[];
|
|
/** Private to the owner. Max 3, or 4 with a Red Flag. */
|
|
hands: Map<PlayerIndex, CardId[]>;
|
|
redFlags: Map<PlayerIndex, boolean>;
|
|
};
|
|
|
|
export type Yards = {
|
|
divisionYard: RollingStock[];
|
|
classificationYard: RollingStock[];
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Clock and phases
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type Phase = 'localOps' | 'newTrain' | 'mainline' | 'loadUnload' | 'shiftChange';
|
|
|
|
/** §8.1 fourth condition — the Superintendent rules on a following train. */
|
|
export type SuperintendentClearance = {
|
|
train: TrayId;
|
|
occupiedBy: TrayId;
|
|
};
|
|
|
|
export type Clock = {
|
|
day: number;
|
|
/** 1..12 — the Pocket Watch. */
|
|
stage: number;
|
|
phase: Phase;
|
|
/** Exactly one player may act at a time. Null during automatic Mainline movement. */
|
|
currentActor: PlayerIndex | null;
|
|
/** Interrupts the Mainline Phase to ask the Superintendent (§8.1). */
|
|
pendingDecision: SuperintendentClearance | null;
|
|
/**
|
|
* The Superintendent's answer, waiting to be consumed by the train that asked. Without this the
|
|
* driver would re-evaluate the same train and ask the same question forever.
|
|
*/
|
|
clearanceRuling: { train: TrayId; allow: boolean } | null;
|
|
superintendent: PlayerIndex;
|
|
/**
|
|
* How far round the table the current phase has got. Acting order starts at the Superintendent
|
|
* and proceeds left (Gap 1), so `currentActor = (superintendent + actorOffset) % players`.
|
|
* When it reaches the player count, the phase is complete.
|
|
*/
|
|
actorOffset: number;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Players and game
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type Player = {
|
|
index: PlayerIndex;
|
|
name: string;
|
|
/** May go negative — a collision costs 5 (§10). */
|
|
revenue: number;
|
|
};
|
|
|
|
export type GameMode = 'solitaire' | 'competitive' | 'coop';
|
|
export type VictoryCondition = 'firstToTarget' | 'highestAfterDays';
|
|
|
|
export type GameConfig = {
|
|
mode: GameMode;
|
|
victory: VictoryCondition;
|
|
length: GameLength;
|
|
optionalRules: {
|
|
reducedVisibility: boolean;
|
|
sisterTrains: boolean;
|
|
employeeRotation: boolean;
|
|
emergencyToolbox: boolean;
|
|
};
|
|
/**
|
|
* The opening deal and the three revenue rates, chosen when the game is dealt (`content.ts`).
|
|
*
|
|
* Optional and PARTIAL on purpose. Every caller that does not care about them — and most of the
|
|
* engine tests do not — gets `DEFAULT_HOUSE_RULES` through `houseRules()`, which is the one place
|
|
* a default is written down. A caller that cares names only the dials it is setting.
|
|
*/
|
|
houseRules?: HouseRuleOverrides;
|
|
};
|
|
|
|
export type OutcomeReason =
|
|
| 'targetReached'
|
|
| 'daysElapsed'
|
|
| 'collisionFloor'
|
|
| 'revenueFloor';
|
|
|
|
export type Outcome = {
|
|
result: 'win' | 'loss';
|
|
winner: PlayerIndex | null;
|
|
reason: OutcomeReason;
|
|
};
|
|
|
|
/**
|
|
* Per-Stage transient bookkeeping for the acting player. Reset when the actor changes.
|
|
*
|
|
* §6 — the three Local Operations options are mutually exclusive: choosing one forecloses the
|
|
* others for that Stage. That exclusivity lives here.
|
|
*/
|
|
export type TurnState = {
|
|
option: 'switch' | 'draw' | 'freightAgent' | null;
|
|
movesRemaining: number;
|
|
drawnThisTurn: boolean;
|
|
freightAgentUsed: boolean;
|
|
/**
|
|
* Trains 3/4 Express — "may drop or pick up ONE freight car at every location".
|
|
*
|
|
* Keyed `trayId@row,col`, counting freight cars that train has exchanged on that square this turn.
|
|
* Per LOCATION rather than per turn, so the Express can work its way along a district a car at a
|
|
* time — which is what makes it an Express rather than a train that may move one car a Stage.
|
|
* Cleared with the rest of the turn.
|
|
*/
|
|
freightWorked: Record<string, number>;
|
|
/** Set when the actor finishes; the phase driver then moves to the next player. */
|
|
done: boolean;
|
|
};
|
|
|
|
/**
|
|
* One turn per player, created together at phase entry.
|
|
*
|
|
* This was a single `TurnState` on the game, replaced one player at a time as the cursor walked the
|
|
* table. That is indistinguishable from this while only the player at the cursor may act — which is
|
|
* exactly the case today, and every existing test still describes the same game.
|
|
*
|
|
* It is per-player now because making it so later would mean the same change PLUS reworking a client
|
|
* built around "wait your turn". What it enables is Local Operations work that no other player can
|
|
* observe — switching inside your own district — happening off-cursor, which is a change to
|
|
* `isActor` and nothing else. See `docs/architecture/multiplayer.md` D19.
|
|
*/
|
|
export function freshTurns(players: number, moves: number): Map<PlayerIndex, TurnState> {
|
|
const turns = new Map<PlayerIndex, TurnState>();
|
|
for (let p = 0; p < players; p++) turns.set(p, freshTurn(moves));
|
|
return turns;
|
|
}
|
|
|
|
/**
|
|
* WHICH WAY TO DRAW THE ENGINE — east or west, for every train, everywhere.
|
|
*
|
|
* The single place the display facing is decided, so the Division map, the Office cards and the
|
|
* tooltips can never disagree about which end of a train the engine is on. Three sources, in the
|
|
* order they can be trusted: the carried east-west sense; the current port, when it happens to be
|
|
* an east-west one (a tray placed straight onto the board has no history yet); and failing both,
|
|
* the direction of the run.
|
|
*/
|
|
export function railFacingOf(tray: Pick<CrewTray, 'railFacing' | 'facing' | 'direction'>): 'e' | 'w' {
|
|
if (tray.railFacing) return tray.railFacing;
|
|
if (tray.facing === 'e' || tray.facing === 'w') return tray.facing;
|
|
return tray.direction === 'west' ? 'w' : 'e';
|
|
}
|
|
|
|
/**
|
|
* THE ONE CONVERSION BETWEEN A TRAY AND A TRACK, stated once and used by both directions.
|
|
*
|
|
* `consist` is ordered NOSE FIRST and the nose points `facing`; `standing` is ordered WEST TO EAST.
|
|
* So for an east-facing train the tray reads east-to-west and has to be reversed to go onto the
|
|
* card; for a west-facing train it already reads west-to-east and goes on as it stands.
|
|
*
|
|
* It is its own inverse, which is why lifting cars off a card uses the same function.
|
|
*/
|
|
export function trackOrder(cut: readonly RollingStock[], facing: 'e' | 'w'): RollingStock[] {
|
|
return facing === 'e' ? [...cut].reverse() : [...cut];
|
|
}
|
|
|
|
/**
|
|
* The cars standing west and east of a train, split at `standingWest`.
|
|
*
|
|
* Clamped rather than trusted: a tray that has never dropped anything carries no `standingWest` at
|
|
* all, and a saved game from before this field existed carries none either — both read as 0, which
|
|
* puts every car on the card east of the engine. That is the same answer the engine gave before the
|
|
* split existed, so an old save degrades to the old behaviour instead of throwing.
|
|
*/
|
|
export function standingSides(
|
|
tray: { standingWest?: number | undefined },
|
|
cars: readonly RollingStock[],
|
|
): { west: RollingStock[]; east: RollingStock[] } {
|
|
const k = Math.max(0, Math.min(cars.length, tray.standingWest ?? 0));
|
|
return { west: cars.slice(0, k), east: cars.slice(k) };
|
|
}
|
|
|
|
/**
|
|
* The cut a train would run into if it left this card through `exit` — the cars between it and that
|
|
* end of the card.
|
|
*
|
|
* Only 'e' and 'w' can hold a cut: the array is a west-to-east row, so a train leaving north or
|
|
* south off a curve or a spur is not running along it and meets nothing. Returned in the order the
|
|
* train MEETS them, nearest first, which is what `carsCoupled` wants.
|
|
*/
|
|
export function cutTowards(
|
|
tray: { standingWest?: number | undefined },
|
|
cars: readonly RollingStock[],
|
|
exit: 'n' | 's' | 'e' | 'w',
|
|
): RollingStock[] {
|
|
const { west, east } = standingSides(tray, cars);
|
|
if (exit === 'e') return east;
|
|
if (exit === 'w') return [...west].reverse();
|
|
return [];
|
|
}
|
|
|
|
export function turnOf(s: GameState, player: PlayerIndex): TurnState {
|
|
const t = s.turns.get(player);
|
|
if (!t) throw new Error(`no turn state for player ${player}`);
|
|
return t;
|
|
}
|
|
|
|
export function freshTurn(moves: number): TurnState {
|
|
return {
|
|
option: null,
|
|
movesRemaining: moves,
|
|
drawnThisTurn: false,
|
|
freightAgentUsed: false,
|
|
freightWorked: {},
|
|
done: false,
|
|
};
|
|
}
|
|
|
|
export type GameState = {
|
|
id: string;
|
|
config: GameConfig;
|
|
/** All RNG derives from this. Games are exactly replayable. */
|
|
seed: number;
|
|
rngState: number;
|
|
players: Player[];
|
|
/**
|
|
* Who is sitting where: `seating[seat] = player`, seat 0 at the WESTERN end of the chain.
|
|
*
|
|
* Decided at setup by §4.4's D12 (`openingRolls.division`) — a real permutation, not the identity,
|
|
* except in solitaire where one player means one seat. Employee Rotation would rotate this array
|
|
* and nothing else.
|
|
*/
|
|
seating: PlayerIndex[];
|
|
/**
|
|
* The two opening D12s, per player, kept so a client can show the rolls rather than only their
|
|
* outcome — it is the game's first moment of drama (`lobby-and-sessions.md` §4). Indexed by
|
|
* PLAYER, since that is who rolls.
|
|
*/
|
|
openingRolls: { division: number[]; superintendent: number[] };
|
|
division: Division;
|
|
officeAreas: Map<SeatIndex, OfficeArea>;
|
|
trays: Map<TrayId, CrewTray>;
|
|
/** Trays not yet in play; §7 scarcity is an explicit mechanic. */
|
|
freeTrays: TrayId[];
|
|
cards: Map<CardId, Card>;
|
|
decks: Decks;
|
|
yards: Yards;
|
|
/** Index 0 = Stage 1. A train number, or null for an empty slot. */
|
|
timetable: (number | null)[];
|
|
/**
|
|
* §7 — Extra Trains played from hand, waiting for a free Crew Tray. An Extra is not scheduled:
|
|
* it runs once, immediately, then its card goes to the Salvage Yard (§2.3).
|
|
*/
|
|
pendingExtras: number[];
|
|
/**
|
|
* Q9 — train numbers ordered to run a second section. The next New Train Phase makes up an
|
|
* identical train behind the first, if a Crew Tray is free.
|
|
*/
|
|
pendingSecondSections: number[];
|
|
clock: Clock;
|
|
/** One per player, keyed by PLAYER (a turn belongs to a person, not to a chair). */
|
|
turns: Map<PlayerIndex, TurnState>;
|
|
/** Transient: trains already moved in the current Mainline Phase. Cleared when it ends. */
|
|
movedThisPhase: Set<TrayId>;
|
|
/** §3.4 — resets at the start of each Day. */
|
|
collisionsToday: number;
|
|
status: 'setup' | 'active' | 'finished';
|
|
outcome: Outcome | null;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Derivations — never stored, never cached
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A Subdivision is the Mainline track between the Limits of opposing Control Points (§2.1).
|
|
* Whistle Posts are NOT Control Points and sit inside a Subdivision like ordinary mainline.
|
|
*
|
|
* At game start every Office is a Whistle Post, so the entire railroad is ONE Subdivision (§8) —
|
|
* which is why early traffic is so constrained. Each Office upgrade splits one in two.
|
|
*
|
|
* Recomputed on demand. Caching this means every Office upgrade must remember to invalidate,
|
|
* and forgetting is a silent bug in highball legality.
|
|
*/
|
|
export function subdivisions(state: GameState): number[][] {
|
|
const out: number[][] = [];
|
|
let current: number[] = [];
|
|
|
|
state.division.nodes.forEach((node, i) => {
|
|
const isBoundary =
|
|
node.kind === 'divisionPoint' ||
|
|
(node.kind === 'office' && isControlPoint(state, node.seat));
|
|
|
|
if (isBoundary) {
|
|
if (current.length > 0) out.push(current);
|
|
current = [];
|
|
} else {
|
|
current.push(i);
|
|
}
|
|
});
|
|
|
|
if (current.length > 0) out.push(current);
|
|
return out;
|
|
}
|
|
|
|
/** Who is sitting at this seat. */
|
|
export function playerAtSeat(state: GameState, seat: SeatIndex): PlayerIndex {
|
|
const p = state.seating[seat];
|
|
if (p === undefined) throw new Error(`no player at seat ${seat}`);
|
|
return p;
|
|
}
|
|
|
|
/** Where this player is sitting, and therefore which Office Area is theirs. */
|
|
/**
|
|
* The player `n` seats to the LEFT of this one, wrapping round the table.
|
|
*
|
|
* Acting order, the deal and the Fedora are all "starting here and proceeding left" (Gap 1, §4.7,
|
|
* §5), which is a statement about the physical chain of Offices — so it is seat arithmetic, not
|
|
* player arithmetic. All three used to do `(player + n) % players`, which was the same thing only
|
|
* while seating was the identity mapping. It stopped being that when §4.4's D12 started deciding
|
|
* who sits where.
|
|
*
|
|
* "Left" is increasing seat index, i.e. eastward along the chain, matching what the shift-change
|
|
* tests have always asserted.
|
|
*/
|
|
export function playerLeftOf(state: GameState, player: PlayerIndex, n = 1): PlayerIndex {
|
|
return playerAtSeat(state, (seatOf(state, player) + n) % state.seating.length);
|
|
}
|
|
|
|
export function seatOf(state: GameState, player: PlayerIndex): SeatIndex {
|
|
const seat = state.seating.indexOf(player);
|
|
if (seat < 0) throw new Error(`player ${player} is not seated`);
|
|
return seat;
|
|
}
|
|
|
|
export function isControlPoint(state: GameState, seat: SeatIndex): boolean {
|
|
const area = state.officeAreas.get(seat);
|
|
if (!area) throw new Error(`no Office Area at seat ${seat}`);
|
|
return officeProfile(area.tier).isControlPoint;
|
|
}
|
|
|
|
export function adTrackCount(state: GameState, seat: SeatIndex): number {
|
|
const area = state.officeAreas.get(seat);
|
|
if (!area) throw new Error(`no Office Area at seat ${seat}`);
|
|
return officeProfile(area.tier).adTracks;
|
|
}
|
|
|
|
export function totalRevenue(state: GameState): number {
|
|
return state.players.reduce((n, p) => n + p.revenue, 0);
|
|
}
|