Six reports from the Day 2-3 playtest of v0.8.0.13. GAMES IN PROGRESS DO NOT SURVIVE THIS ONE. Modifiers are now bounded by the Limits, which makes a once-legal move illegal, so a save holding one is refused at that move: whistle-6945.day3.stage10 stops at intent 528 of 539. Jesse's call, knowing it strands the game on the box. The file is untouched and v0.8.0.13 still finishes it. The Sparrow running empty and Tom unable to unload his passengers are the same shortage from opposite ends, and both are the rules working as printed. §9.2 boarding discards the emptied coach into the CLASSIFICATION yard, detraining draws a fresh empty out of the DIVISION yard, and §2.2 sends Classification back only when the Division Yard runs bare — so coaches move one way. Measured over the save: sixteen in the Division Yard at setup, zero from Day 2 Stage 8 to the end, fifteen piled in Classification, the Division Yard steady at 46-47 freight cars with no prospect of going bare. Jesse's ruling is Gitea#2's: the shortage stays and the game says so. A train made up short now reports what its card wanted and why none is coming (`makeUpShort` — `trainNeedingCars` answered null for "done" and for "cannot be done" alike, so the phase moved on in silence); the yard panel warns while the condition lasts; the Depot's blocked panel was right all along. The modifier outside the Limits was working as designed and the design was Jesse's own call, now reversed. What decided it is what the board shows — a card beyond your own sign, in territory §8.1 and §10 reason about. The case that motivated the exemption was checked on the reported move rather than argued away: the Power Plant sat at (-1,3) against a sign at column 3 and two spots inside were free, legal and adjacent. Switching filled the history with coordinates — a line per move, plus one per mandatory coupling. It is still LOGGED in full; what the panel draws is the line saying somebody switched, the first move, work at an INDUSTRY (named, not a coordinate), the Small Yard sort, and a closing summary. The suppressed lines are still WRITTEN, marked `trace`: dropping them outright was the first attempt and the step-queue suite caught it, because dwellForStep pays nothing for a step that said nothing, so the board stopped replaying switching at all. The last move rides in the closing line rather than being kept in place — nothing knows a move was the last until the turn is over, by which time the line has been streamed to every client and cannot be revised. Two things fell out of reading those lines: every move ended with a tutorial sentence the opener already gives, and the move count said "of 6" with the six hardcoded, which is wrong on a night Stage. Make-up lines name their train — they all read "the train being made up", so looking back for train 10 found nothing under that name — and "a empty tank" is now "an empty tank". The Small Yard's options read as the train they would build instead of `[1,2,3,0]`; the one Jesse wanted was the first of five and unreadable. Two of those five were junk: bringing the last car to the end is the identity and would have spent a Move, and a two-car reversal duplicated its only real option. Both are filtered by the resulting order, not by the case that made them. A Small Yard may now put cars AHEAD of the engine, which was Jesse's own open question. Two sources disagreed and the design notes won: the v0.4.5 card text says the sort puts the engine at the nose, implications.md says "any order, including cars ahead of the engine". `engineAt` is optional on the intent, so older saves replay to the same train. The menu did not multiply — the engine is a separate short list against the consist as it stands, eight options for a four-car train rather than twenty. §8.2 needed no new code: badlyMadeUp is deliberately direction-free, so a PUSHING train is fit to run and only a broken-backed one is held. The button warns by asking that predicate rather than copying it, and immediately earned itself — every one of train 10's eight options is refused, the one asked for at the table included, because that train carries a caboose and each sort moves it off the rear. That is the right answer rather than a gap: the train is already made up, so every offer would break it, and the labels say which is which. A made-up order is always on the menu for a train that needs one, because "bring car k to the tail" is enumerated for every car and the caboose is one of them. Labels read WEST TO EAST, with the engine drawn as the board's own ◀ / ▶ arrow. "Front to back" is not a direction a table can read — which end is the front depends on which way the train points — and board-svg has reversed east-facing consists since v0.8.0, so the button now describes the same train as the picture. The Freight Agent, Porter and Laborer groups now say what the role is for, where the role is chosen. Tom reached for the Freight Agent to detrain passengers, which is a Porter's action in the Cargo phase; both halves were working and neither was visible. TODO closes #107 (the nose sort) and gains #108 (the coach ratchet, with the measurement, to revisit on a second game's data). 999 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
1302 lines
60 KiB
TypeScript
1302 lines
60 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,
|
|
HouseRuleOverrides,
|
|
ModifierKind,
|
|
OfficeTier,
|
|
TrackGeometry,
|
|
} from './content.ts';
|
|
import { HAND_LIMIT, 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;
|
|
/**
|
|
* WHICH OFFICE AREA MADE THIS LOAD — the physical game's chip turned upside down in the tray.
|
|
*
|
|
* Reported from playtesting v0.4.9d as two bugs with one cause: a boxcar loaded at a Freight
|
|
* House could be unloaded at that same Freight House on the next Laborer action, and passengers
|
|
* who had just boarded could be detrained again before the train turned a wheel. Both paid full
|
|
* Revenue at each end for a load that never went anywhere.
|
|
*
|
|
* Jesse's rule (v0.4.9e): freight or passengers loaded anywhere in an Office Area may not be
|
|
* unloaded ANYWHERE in that same Office Area — not at another facility, not in a later Stage.
|
|
* They have to be carried by a train to a different Office Area. So the stamp is the SEAT, which
|
|
* is what an Office Area belongs to (Employee Rotation moves players between chairs; the district
|
|
* stays with the chair), and it never expires.
|
|
*
|
|
* A SEAT, NOT A PLAYER, and undefined rather than -1 for "no origin": the Division Yard opens with
|
|
* loaded cars and loaded coaches that were made up off-Division (`ROLLING_STOCK_SUPPLY`), and
|
|
* those are exactly the inbound traffic a solitaire district lives on. A sentinel inside
|
|
* `SeatIndex`'s own value range is not a sentinel — see `card.play`'s `node` in intents.ts.
|
|
*
|
|
* Stripped by `pooled` whenever a car goes back to a yard: the stamp belongs to the LOAD, and a
|
|
* car returning to the common supply is carrying nothing.
|
|
*/
|
|
origin?: SeatIndex;
|
|
};
|
|
|
|
/**
|
|
* A car returning to the common pool — the Division or Classification Yard — with its load's origin
|
|
* stamp taken off.
|
|
*
|
|
* Every yard push goes through this. A loaded car CAN reach a yard still loaded (a train retires at
|
|
* a Division Point with freight aboard, `advance.ts`), and without this it would carry a stamp from
|
|
* a district it left several Days ago into whatever train is made up from it next.
|
|
*/
|
|
export function pooled(car: RollingStock): RollingStock {
|
|
if (car.origin === undefined) return car;
|
|
const { origin: _origin, ...rest } = car;
|
|
return rest;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
* `standingWest` below records where a train standing here sits among them.
|
|
*/
|
|
standing: RollingStock[];
|
|
/**
|
|
* HOW MANY OF `standing` LIE WEST OF THE TRAIN(S) STANDING HERE.
|
|
*
|
|
* `standing` runs west to east and a train sits somewhere IN that row: cars `[0, standingWest)`
|
|
* are west of it, cars `[standingWest, end)` are east. 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: which cars
|
|
* are AHEAD of an engine and which BEHIND (combine with `CrewTray.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.
|
|
*
|
|
* WITH NO TRAIN STANDING HERE, THIS NUMBER MEANS NOTHING: 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 — nothing reads
|
|
* `standingWest` in that state, so a stale value left over from a train that has since moved on is
|
|
* inert, not wrong. That reasoning is unchanged from when this lived on `CrewTray`.
|
|
*
|
|
* WHAT MOVED IT TO THE CARD: the Office square is the one place more than one train may stand at
|
|
* once, and only one route lets two trays disagree about the same row of cars — a train already
|
|
* at the Office sets out a cut in place, without moving, while a second train on another A/D track
|
|
* still holds whatever split it arrived with. A cut lies west or east of the WHOLE block of A/D
|
|
* tracks, never of one particular track and never between two trains, so there is exactly one
|
|
* number for the card to carry and every train standing there reads the same one. Everywhere else
|
|
* on the board at most one train ever stands on a card, so the two homes for this field are
|
|
* identical in every other case.
|
|
*/
|
|
standingWest: number;
|
|
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;
|
|
/**
|
|
* STILL BEING ASSEMBLED, somewhere that is not a Division Point.
|
|
*
|
|
* `isBeingMadeUp` used to read the position alone — "standing at a Division Point" — which was
|
|
* true of every train being built when the only place to build one WAS a Division Point. An Extra
|
|
* may now be started at a Control Point or in an Interchange's yard (§7), and those trains could
|
|
* not be given a consist at all: they ran empty, and so did every Control Point Extra since that
|
|
* option was added. Jesse's report says it plainly — an Extra started at the Interchange "would be
|
|
* loaded with cars".
|
|
*
|
|
* Set when such an Extra is placed and cleared the moment it starts running (`enterMainline`), so
|
|
* it names a train that is being made up rather than one that merely happens to be standing
|
|
* somewhere. That distinction is load-bearing: a train that ARRIVED at an Office must never be
|
|
* fillable from the Division Yard, which is the "cars appearing on a train nobody was making up"
|
|
* bug `isBeingMadeUp` was tightened to kill, and an arriving train never carries this.
|
|
*/
|
|
beingMadeUp?: boolean;
|
|
/**
|
|
* WHOSE TRAIN THIS IS TO BUILD, for an Extra only.
|
|
*
|
|
* §7's Extra is loaded by the player who played the card, not by the Superintendent-first round
|
|
* that fills a Timetabled train — so the tray has to remember them: `pendingExtras` is emptied the
|
|
* moment the Extra starts, which is before a single car goes on. Undefined on every Timetabled
|
|
* train, where the round decides instead.
|
|
*/
|
|
builtBy?: PlayerIndex;
|
|
/**
|
|
* 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.
|
|
*
|
|
* This only records POSITION, not a supply — and that is not a gap. `rules-v0.2.md`:339 (Gap 4b)
|
|
* ties Crew Trays and engine pieces together as one combined resource, `player count + 3`
|
|
* (`crewTrayCount` in `content.ts`), so engine scarcity IS tray scarcity: `freeTrays` running out
|
|
* already blocks a new train exactly when the engine supply would. There is no state for "a tray
|
|
* with no engine" because the rules never separate the two.
|
|
*/
|
|
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';
|
|
position: NodeRef;
|
|
movesUsed: number;
|
|
/**
|
|
* X18 Circus / X17 Campaign — Office Areas this train has already been paid for setting up in
|
|
* (Gitea#13).
|
|
*
|
|
* "Once per stop in an office area. In a multiplayer game, each player could score if the circus
|
|
* stops in their area" (Jesse, 2026-08-29). So the claim is per SEAT, not per train: a Circus
|
|
* touring three districts is paid three times, and one that parks in the same district for six
|
|
* Stages is paid once.
|
|
*
|
|
* Recorded on the tray, which also gives the other half of Jesse's ruling for free — "if the
|
|
* circus train gets recycled and played a second time as a second extra, then it could again
|
|
* score points later too". A train is made up onto a FRESH tray object every time, so a re-played
|
|
* Extra starts with an empty list and no reset code is needed.
|
|
*/
|
|
stopPointSeats?: SeatIndex[];
|
|
/**
|
|
* 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 is expedited — it may be switched normally, but it faults (Q3) if it is left
|
|
* off the Office square when a Mainline Phase begins. Recorded on the tray for the same reason as
|
|
* `stopPointSeats` — it is the TRAIN that stops, and a re-played Extra gets a fresh tray.
|
|
*/
|
|
speechMade?: 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[];
|
|
/**
|
|
* TRAINS STANDING IN THE INTERCHANGE'S YARD — not out on the running line.
|
|
*
|
|
* Only an Interchange ever has these. An Extra started there (§7, Jesse's ruling) is made up
|
|
* in the yard beside the Mainline, which is why placing it can never be a collision however
|
|
* busy the card is: it is not on the road yet. It highballs onto this same card at a later
|
|
* Mainline Phase, through the ordinary §8.1 clearance check — held automatically against a
|
|
* facing train, put to the Superintendent against a following one — and becomes a `Transit`
|
|
* at that moment, exactly like a train leaving a Division Point.
|
|
*
|
|
* A tray listed here has `position.at === 'mainline'` with this node's index and NO entry in
|
|
* `transits`. That pair is what distinguishes standing from crossing.
|
|
*/
|
|
holding?: TrayId[];
|
|
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;
|
|
}
|
|
| {
|
|
kind: 'office';
|
|
seat: SeatIndex;
|
|
/**
|
|
* §Q, RED FLAGS (Gitea#19) — the side of this district a flag is planted on.
|
|
*
|
|
* "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)." So the value names the SIDE,
|
|
* and a train arriving from that side is held: a westbound train comes from the east.
|
|
*
|
|
* SPENT ON THE TRAIN IT STOPS (Jesse's ruling, 2026-08-29). One card, one train — the flag
|
|
* comes down as it is used, so there is no lifting action to build, nothing to forget, and a
|
|
* flag cannot quietly strangle the Division.
|
|
*/
|
|
redFlag?: Direction;
|
|
};
|
|
|
|
/** 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. */
|
|
/**
|
|
* AN INTERRUPTION TO THE AUTOMATIC MAINLINE PHASE — a question the driver cannot answer itself.
|
|
*
|
|
* There was one of these and it was hardcoded to one question asked of one player: the §8.1
|
|
* clearance ruling, always to the Superintendent. Gitea#5 and Gitea#19 each need to stop the same
|
|
* phase and ask a DIFFERENT player something different, so the shape is a union and `decisionActor`
|
|
* below decides who answers.
|
|
*
|
|
* Every member names the `train` the question is about, because the answer has to be matched back
|
|
* to it — see `DecisionAnswer`.
|
|
*/
|
|
export type PendingDecision =
|
|
/** §8.1 — a following train in the same Subdivision. The Superintendent rules. */
|
|
| { kind: 'clearance'; train: TrayId; occupiedBy: TrayId }
|
|
/**
|
|
* §11 (Gitea#5) — an inbound freight may take the Yard Office instead of the Train Order Office.
|
|
* Asked of whoever sits in `seat`, on the Mainline Phase the train arrives.
|
|
*/
|
|
| { kind: 'yardOffice'; train: TrayId; seat: SeatIndex }
|
|
/**
|
|
* §Q (Gitea#19) — a train is about to enter this district into a collision, and its owner holds a
|
|
* Red Flags card. "You can play the card normally or out of phase, but only if you need it."
|
|
*/
|
|
| { kind: 'redFlag'; train: TrayId; seat: SeatIndex; from: Direction };
|
|
|
|
/**
|
|
* The answer, waiting to be consumed by the train that asked.
|
|
*
|
|
* Without this the driver would re-evaluate the same train, ask the same question, and never
|
|
* advance. Keyed by `kind` as well as `train` so an answer can never be mistaken for the reply to a
|
|
* different question about the same train.
|
|
*/
|
|
export type DecisionAnswer =
|
|
| { kind: 'clearance'; train: TrayId; allow: boolean }
|
|
| { kind: 'yardOffice'; train: TrayId; take: boolean }
|
|
| { kind: 'redFlag'; train: TrayId; flag: boolean };
|
|
|
|
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 a player something (§8.1, §11). */
|
|
pendingDecision: PendingDecision | null;
|
|
/** The answer to `pendingDecision`, waiting to be consumed by the train that asked. */
|
|
decisionAnswer: DecisionAnswer | 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';
|
|
|
|
/**
|
|
* VICTORY CONDITIONS — designed 2026-08-20, unified across all three modes.
|
|
*
|
|
* Replaces the old `length`-preset lookup (`LENGTH_PROFILES.target`) and the dead
|
|
* `VictoryCondition: 'firstToTarget'` (grepped: never selected anywhere in the codebase). Winner is
|
|
* whoever has the most Revenue when `days` run out — solitaire's own player counts as "everyone" —
|
|
* unless `minCombinedRevenue` was missed, in which case everyone loses. Coop keeps summing every
|
|
* player's Revenue into one table score, now against a configurable floor instead of
|
|
* `profile.target * players.length`.
|
|
*
|
|
* `0` means "off" for every numeric field below. `minCombinedRevenue`'s natural default is
|
|
* `collectiveRevenueFloor(players, days)` (`content.ts`), computed by whoever authors the config —
|
|
* the engine only ever reads a concrete number here, never resolves one lazily, because player count
|
|
* is not always known at config-authoring time (a lobby, a CLI flag, a dialog).
|
|
*
|
|
* `maxCollisionsPerDay`/`maxCollisionsTotal` are deliberately FLAT, not scaled by player count: more
|
|
* players means more independent chances to collide, not a bigger shared budget, so a multiplayer
|
|
* table is genuinely riskier than solitaire at the same default (Jesse's call).
|
|
*/
|
|
export type GameConfig = {
|
|
mode: GameMode;
|
|
/** How many Days the game runs. */
|
|
days: number;
|
|
/** Everyone loses if the table's total Revenue is below this when `days` run out. 0 = off. */
|
|
minCombinedRevenue: number;
|
|
/** Everyone loses immediately, mid-game, once collisions in one Day reach this. 0 = off. */
|
|
maxCollisionsPerDay: number;
|
|
/** Same, summed across the whole game, never reset. 0 = off. */
|
|
maxCollisionsTotal: number;
|
|
/**
|
|
* Whether the 22 opponent-directed cards are in the deck (`setup.ts`'s `buildDeck`). Forced off in
|
|
* `solitaire` and `coop` — neither has a valid target for them — on by default in `competitive`.
|
|
* Has no effect until those cards are built (Phase 5); see `TODO.md`.
|
|
*/
|
|
pvpCardsAllowed: boolean;
|
|
optionalRules: {
|
|
reducedVisibility: 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 = 'daysElapsed' | 'collisionFloor' | 'revenueFloor';
|
|
|
|
export type Outcome = {
|
|
result: 'win' | 'loss';
|
|
winner: PlayerIndex | null;
|
|
reason: OutcomeReason;
|
|
};
|
|
|
|
/**
|
|
* §3.3, EXTENDED PLAY (Gitea#11) — which endings may be played past.
|
|
*
|
|
* Both days-based endings offer another Day: running out of timetable, and closing short of the
|
|
* combined Revenue floor, are the same event seen twice — the last Day ended and this is what the
|
|
* books say. A `collisionFloor` ending is NOT extendable, and neither is a collision breach that
|
|
* happens during an extended Day: §3.4 stopped the game because the railroad was declared unsafe,
|
|
* and carrying on regardless would contradict the rule that stopped it (Jesse's call, 2026-08-28).
|
|
*/
|
|
export function isExtendable(reason: OutcomeReason): boolean {
|
|
return reason === 'daysElapsed' || reason === 'revenueFloor';
|
|
}
|
|
|
|
/**
|
|
* Running counts of everything interesting that has happened, tallied from the event stream
|
|
* (Gitea#16).
|
|
*
|
|
* WHY IT LIVES ON `GameState` rather than being computed by whoever happens to want it. Three
|
|
* reasons, in ascending order of how much they cost to work around:
|
|
*
|
|
* 1. `snapshot()` already takes a `GameState`, so every number here reaches a MULTIPLAYER client
|
|
* through the `Frame` it is already being sent — no new server route, no new `Push` field, no
|
|
* new `Session` method, and no second implementation that can disagree with the first.
|
|
* 2. It is REPLAY-EXACT. A save is `{ seed, config, history }` replayed through the engine
|
|
* (`web/game.ts`'s `fromSave`), so a tally folded from the events that replay emits is rebuilt
|
|
* identically every time — which is what makes Undo and a server restart correct here for free.
|
|
* 3. The official result freezes a COPY of this at the moment the timetable ran out (`official`
|
|
* below), and a frozen copy has to be taken from something that already exists.
|
|
*
|
|
* Aggregate counts only. Nothing here is seat-secret — no card ids, no hands — which is why
|
|
* `test/redaction.test.ts` stays green with the whole thing on the Frame.
|
|
*
|
|
* NOT SCORING. Nothing in here feeds a rule; it is read by the results screen and by the badge work
|
|
* that Gitea#16 leaves to a second pass. Adding a counter is always safe.
|
|
*/
|
|
export type Tally = {
|
|
/** §8.3 — a train that ran the length of the Division and left it. */
|
|
trainsCompleted: number;
|
|
/**
|
|
* Of those, how many did some switching between being made up and leaving.
|
|
*
|
|
* The join Gitea#16 asks for by name ("a player who completes an entire game where every train
|
|
* that passed through did some switching on"). Counted as the train completes, against whether
|
|
* that tray has coupled or dropped anything since it was made up — which is why `switchedSince`
|
|
* below exists rather than this being derivable afterwards.
|
|
*/
|
|
trainsCompletedWithWork: number;
|
|
/** §10 — trains lost to a collision, and the cars that went with them. */
|
|
trainsDestroyed: number;
|
|
carsDestroyed: number;
|
|
/** §6 — switching volume, both directions. */
|
|
carsCoupled: number;
|
|
carsDropped: number;
|
|
/** §9.1 — the MEN | AT | WORK pipeline: begun, and carried all the way through. */
|
|
loadsStarted: number;
|
|
loadsCompleted: number;
|
|
unloadsBegun: number;
|
|
unloadsCompleted: number;
|
|
/** §9.2 — passenger work. */
|
|
passengersBoarded: number;
|
|
passengersDetrained: number;
|
|
/** Colour, and the vocabulary the badge pass will draw on. */
|
|
flyingSwitches: number;
|
|
officeUpgrades: number;
|
|
dispatchBonusesUsed: number;
|
|
facilitiesUnjammed: number;
|
|
expediteFaults: number;
|
|
trainsHeld: number;
|
|
trainsDiverted: number;
|
|
secondSections: number;
|
|
extrasStarted: number;
|
|
cardsDrawn: number;
|
|
cardsPlayed: number;
|
|
cardsDiscarded: number;
|
|
clearancesRequested: number;
|
|
/** §8.1 — rulings that let the other train through. A refusal is a ruling too, but not this one. */
|
|
clearancesAllowed: number;
|
|
/**
|
|
* X18 CIRCUS SET-UPS — a train that spent a Stage standing still and was paid for it (§X18).
|
|
*
|
|
* NOT "the longest an engine sat on a siding", which is what Gitea#16 asks for and what the
|
|
* comment on that issue assumed this was. `trainStoodStill` is emitted ONCE IN A GAME PER SUCH
|
|
* TRAIN — only for a train whose profile has `stopEarnsPoint`, and `advance.ts` sets
|
|
* `stopPointClaimed` so it can never fire twice. There is no per-Stage "this train did not move"
|
|
* signal in the engine at all, so a longest-stand streak cannot be folded from the event stream:
|
|
* it needs an engine-side signal that does not exist yet. Recorded in `TODO.md` for the badge
|
|
* pass rather than shipped as a statistic that would read "1 Stage" for ever.
|
|
*/
|
|
circusStops: { trainNumber: number; where: string }[];
|
|
/**
|
|
* Train numbers that have coupled or dropped something since they were made up, for
|
|
* `trainsCompletedWithWork`. Cleared when the train is made up and when it leaves the Division.
|
|
*/
|
|
switchedSince: number[];
|
|
/** Indexed by PLAYER. Only events that name a player reach these. */
|
|
byPlayer: PlayerTally[];
|
|
};
|
|
|
|
export type PlayerTally = {
|
|
loads: number;
|
|
unloads: number;
|
|
passengersBoarded: number;
|
|
passengersDetrained: number;
|
|
cardsPlayed: number;
|
|
/** §10 — collisions this player was faulted for, not collisions they were caught in. */
|
|
collisions: number;
|
|
/** Revenue gained and Revenue lost, kept apart: the net is already on `players[i].revenue`. */
|
|
revenueGained: number;
|
|
revenueLost: number;
|
|
};
|
|
|
|
/**
|
|
* THE OFFICIAL RESULT, frozen at the moment the timetable ran out (Gitea#11).
|
|
*
|
|
* "The winner is based upon the original game length. In a five-day game, even if it's extended to
|
|
* eight or nine days, the winner and the official answer is the winner at the end of five days"
|
|
* (Jesse, 2026-08-28). So this is written ONCE, at the first ending, and never overwritten —
|
|
* including by a §3.4 collision breach during an extended Day, which ends play without touching it.
|
|
*
|
|
* `state.outcome` keeps moving: it is always the CURRENT evaluation, which is what the live game
|
|
* wants. Once `official` exists, everything after it is informational.
|
|
*/
|
|
export type FinalReport = {
|
|
/** The Day the game was scheduled to end on — always `config.days`. */
|
|
day: number;
|
|
outcome: Outcome;
|
|
/** Every player's Revenue at that moment, in player order. */
|
|
revenues: number[];
|
|
collisionsTotal: number;
|
|
/** The Tally as it stood when the timetable ran out. */
|
|
tally: Tally;
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
/**
|
|
* What `movesRemaining` started at this Stage — six, or five under Reduced Visibility at night.
|
|
*
|
|
* CARRIED RATHER THAN ASSUMED. Every reader of `movesRemaining` that wanted to say "3 of 6" had
|
|
* hardcoded the 6, which is simply wrong on a night Stage, and the only other way to recover it is
|
|
* to re-derive `movesForStage` outside the phase driver that owns it. It also makes "is this the
|
|
* FIRST move of the turn?" a comparison rather than a guess, which is what the history panel needs
|
|
* to keep the opening move of a switching turn and drop the ones in the middle.
|
|
*/
|
|
movesAllowed: number;
|
|
/**
|
|
* The last square this player's crew moved to this Stage, and which crew it was.
|
|
*
|
|
* Switching ends with a summary line, and "where did the train end up" is the half of it a player
|
|
* actually wants. It cannot be recovered from the log: the line naming the last move is written
|
|
* before anyone knows it was the last, and the log streams to clients as it is written, so a line
|
|
* already sent cannot be revised afterwards.
|
|
*/
|
|
lastMove?: { trayId: TrayId; to: GridCoord };
|
|
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;
|
|
}
|
|
|
|
/** A Tally with everything at zero — the state every game starts in (Gitea#16). */
|
|
export function emptyTally(players: number): Tally {
|
|
return {
|
|
trainsCompleted: 0,
|
|
trainsCompletedWithWork: 0,
|
|
trainsDestroyed: 0,
|
|
carsDestroyed: 0,
|
|
carsCoupled: 0,
|
|
carsDropped: 0,
|
|
loadsStarted: 0,
|
|
loadsCompleted: 0,
|
|
unloadsBegun: 0,
|
|
unloadsCompleted: 0,
|
|
passengersBoarded: 0,
|
|
passengersDetrained: 0,
|
|
flyingSwitches: 0,
|
|
officeUpgrades: 0,
|
|
dispatchBonusesUsed: 0,
|
|
facilitiesUnjammed: 0,
|
|
expediteFaults: 0,
|
|
trainsHeld: 0,
|
|
trainsDiverted: 0,
|
|
secondSections: 0,
|
|
extrasStarted: 0,
|
|
cardsDrawn: 0,
|
|
cardsPlayed: 0,
|
|
cardsDiscarded: 0,
|
|
clearancesRequested: 0,
|
|
clearancesAllowed: 0,
|
|
circusStops: [],
|
|
switchedSince: [],
|
|
byPlayer: Array.from({ length: players }, () => ({
|
|
loads: 0,
|
|
unloads: 0,
|
|
passengersBoarded: 0,
|
|
passengersDetrained: 0,
|
|
cardsPlayed: 0,
|
|
collisions: 0,
|
|
revenueGained: 0,
|
|
revenueLost: 0,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A deep copy, for freezing the official result (`FinalReport`).
|
|
*
|
|
* Written out rather than reached for via `structuredClone` because a Tally is a flat bag of numbers
|
|
* with two containers in it, and spelling the copy out means a field added later that needs deep
|
|
* copying is a compile error here rather than a shared reference discovered in a results screen.
|
|
*/
|
|
export function cloneTally(t: Tally): Tally {
|
|
return {
|
|
...t,
|
|
circusStops: t.circusStops.map((c) => ({ ...c })),
|
|
switchedSince: [...t.switchedSince],
|
|
byPlayer: t.byPlayer.map((p) => ({ ...p })),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 by the `exit` END OF THE ROW — the cars
|
|
* between it and that end. Returned in the order the train MEETS them, nearest first, which is what
|
|
* `carsCoupled` wants.
|
|
*
|
|
* `exit` IS AN END OF THE ROW, NOT A PORT. It used to be a raw `Port`, and answered "you meet
|
|
* nothing" for north and south on the reasoning that a leg leaving through an edge is not running
|
|
* along the west-to-east row. It is: a `sw` curve's south leg IS the east end of that row, so a
|
|
* crew standing on the curve pulled out through the leg and drove away leaving the cars beside it
|
|
* standing, against §A.4's mandatory coupling (Gitea#17). Callers resolve the leg with `rowEndAt`
|
|
* (`track.ts`), which lives there because only the card's arc can say which end a leg is — and the
|
|
* narrowed type is what makes every caller do it.
|
|
*/
|
|
export function cutTowards(
|
|
tray: { standingWest?: number | undefined },
|
|
cars: readonly RollingStock[],
|
|
exit: 'e' | 'w',
|
|
): RollingStock[] {
|
|
const { west, east } = standingSides(tray, cars);
|
|
return exit === 'e' ? east : [...west].reverse();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* §6.2 — IS THIS PLAYER HOLDING MORE THAN THEY MAY? Three cards, or four while a Red Flag is held.
|
|
*
|
|
* ONE answer, because there were three of them: `check('draw.end')` refused on it, `snapshot()`
|
|
* recomputed it inline for the Frame, and `web/game.ts` kept a third for the page. All three agreed
|
|
* — which is the state a disagreement starts from, and #96 is what that costs when the two halves
|
|
* are a screen and the server that refuses what the screen offered.
|
|
*/
|
|
export function overHandLimit(s: GameState, player: PlayerIndex): boolean {
|
|
const hand = s.decks.hands.get(player) ?? [];
|
|
return hand.length > (s.decks.redFlags.get(player) ? HAND_LIMIT + 1 : HAND_LIMIT);
|
|
}
|
|
|
|
export function freshTurn(moves: number): TurnState {
|
|
return {
|
|
option: null,
|
|
movesRemaining: moves,
|
|
movesAllowed: 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).
|
|
*
|
|
* CARRIES WHO PLAYED IT (2026-08-23, Jesse's call). §7 gives an Extra to the player who played the
|
|
* card — "may place the Crew Tray at either Division Point ... and may load the consist as he
|
|
* chooses" — which is a different rule from the Timetabled make-up round, where the table goes
|
|
* round starting at the Superintendent. This was a bare `number[]`, so the engine could not tell
|
|
* whose Extra it was and asked whoever the acting order happened to be on: correct in solitaire,
|
|
* where there is only one player, and wrong at every table.
|
|
*/
|
|
pendingExtras: { trainNumber: number; player: PlayerIndex }[];
|
|
/**
|
|
* 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; checked against `config.maxCollisionsPerDay`. */
|
|
collisionsToday: number;
|
|
/**
|
|
* What `collisionsToday` held for the Day that just ENDED — captured at the rollover, immediately
|
|
* before the reset.
|
|
*
|
|
* The Day-end dialog exists to report the Day that finished, and it is drawn from the frame AFTER
|
|
* the rollover, because that is the frame whose `day` went up. So it read `collisionsToday` as 0 no
|
|
* matter what had happened: Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it —
|
|
* "it shows a total of two collisions, but zero today ... that does seem to be a contradiction".
|
|
*
|
|
* NOT DERIVABLE ON THE CLIENT. A Day turns over inside the phases that run themselves, so in
|
|
* multiplayer the push that reports the new Day is the same push that reports the reset — a client
|
|
* may never see the ended Day's final count to remember it.
|
|
*/
|
|
collisionsPrevDay: number;
|
|
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
|
|
collisionsTotal: number;
|
|
/**
|
|
* `awaitingExtension` is Gitea#11: the timetable has run out, the result is recorded, and the
|
|
* table is being asked whether to play one more Day. It is a PAUSE, not an ending — `advance`
|
|
* reports `needsInput` there, the server resumes it like any live game, and the only intent the
|
|
* rules will accept is `game.extend`.
|
|
*/
|
|
status: 'setup' | 'active' | 'awaitingExtension' | 'finished';
|
|
/** The CURRENT evaluation, re-decided at the end of every Day including extended ones. */
|
|
outcome: Outcome | null;
|
|
/**
|
|
* §3.3 (Gitea#11) — Days granted beyond `config.days`, one vote at a time.
|
|
*
|
|
* `config.days` is deliberately never touched: it is what the official result was decided at, so
|
|
* leaving it alone is what makes "the winner is decided at the original game length" a fact about
|
|
* the code rather than a comment on it.
|
|
*/
|
|
extraDays: number;
|
|
/**
|
|
* Per PLAYER, while `awaitingExtension`. `null` means they have not voted yet.
|
|
*
|
|
* Unanimous, and one refusal is decisive: nobody is made to wait on a player who has already said
|
|
* no (Jesse's call, 2026-08-28). Solitaire is the same code with one voter.
|
|
*/
|
|
extensionVotes: (boolean | null)[];
|
|
/** Frozen at the FIRST ending and never overwritten. See `FinalReport`. */
|
|
official: FinalReport | null;
|
|
/** Gitea#16. Folded from the event stream; see `Tally`. */
|
|
tally: Tally;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
|
|
/** This seat's node on the Division — where its Limits, and any Red Flag on them, live. */
|
|
export function officeNodeFor(
|
|
state: GameState,
|
|
seat: SeatIndex,
|
|
): Extract<DivisionNode, { kind: 'office' }> | null {
|
|
for (const n of state.division.nodes) if (n.kind === 'office' && n.seat === seat) return n;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* WHO MUST ANSWER the interruption, or null when nothing is pending.
|
|
*
|
|
* The one place that knows which player each kind of question goes to. §8.1's clearance is the
|
|
* Superintendent's ruling wherever it happens; the Yard Office is offered to whoever sits in the
|
|
* district the train is arriving at, because it is their card and their yard.
|
|
*/
|
|
export function decisionActor(state: GameState): PlayerIndex | null {
|
|
const d = state.clock.pendingDecision;
|
|
if (!d) return null;
|
|
return d.kind === 'clearance' ? state.clock.superintendent : playerAtSeat(state, d.seat);
|
|
}
|
|
|
|
/**
|
|
* WHOSE MOVE IT IS RIGHT NOW — a pending interruption's owner if there is one, else the phase's
|
|
* own actor.
|
|
*
|
|
* Written out six times across the engine, the sim, the web client and the tests as
|
|
* `pendingDecision !== null ? superintendent : currentActor`, which stopped being right the moment
|
|
* a second kind of question existed. One copy now, so a new decision kind cannot be half-adopted.
|
|
*/
|
|
export function actingPlayer(state: GameState): PlayerIndex | null {
|
|
return decisionActor(state) ?? state.clock.currentActor;
|
|
}
|
|
|
|
/** 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);
|
|
}
|