Files
station-master/src/engine/content.ts
T
Jesse.MarkowitzandClaude Opus 5 5e34c73b16 Extras that must run loaded, and a circus paid per district (Gitea#13)
"I've redefined some of the extra trains that they have to run full boxcars —
military trains, circus trains, etc. If not loaded, then empty, and if none
available, run without."

MAKE-UP. X17 Campaign, X18 Circus and X19 Military carry `mustRunLoaded`. It is a
preference order rather than a flat requirement, so the rule is asked of the
DIVISION YARD: an empty is refused only while the yard can still supply a loaded
car this train would accept, and once it cannot, the empty is legal and the train
may still depart short. Per category, since that is the slot the car competes for
— a loaded coach is no reason to refuse an empty boxcar.

SCORING, per Jesse's ruling (2026-08-29): "once per stop in an office area. In a
multiplayer game, each player could score if the circus stops in their area." So
`stopPointClaimed` (a boolean, once per game) becomes `stopPointSeats` (the seats
already paid). A Circus touring three districts is paid three times; one parked in
the same district all game is paid once. The other half of the ruling — "if the
circus train gets recycled and played a second time as a second extra, then it
could again score points later too" — needs no code: a train is made up onto a
fresh tray every time, so a re-played Extra starts with an empty list.

The point now requires the train to be FULLY LOADED, meaning every non-caboose car
loaded. A coach counts as loaded when occupied, which is what makes this the right
test for the Campaign Train: X17 carries one coach and no freight, so "fully
loaded" is exactly "the candidate is aboard". X17 also GAINS the per-stop point —
it had `stopThenExpedite` and no scoring rule at all, and Jesse's "credit for a
circus or campaign train (one point per stop)" says it should score.

TWO BUGS FIXED ALONG THE WAY:

  - `ConsistSpec.emptiesOnly` was declared on X13 Appleseed, RENDERED to the player
    as "(empties only)" by both web/game.ts and sim/view.ts, and enforced by
    nothing — `acceptsCar` never read it, so the Appleseed could be made up loaded
    while its own card said otherwise. It is the same rule as this issue pointing
    the other way, and it would have been perverse to add one and leave the other.
  - Setting up out on the Mainline paid a point to PLAYER 0 whoever was playing:
    `playerAtSeat` needs a seat, off the grid there is none, and the fallback was
    `0`. Scoping the rule to Office Areas is what the ruling says and removes the
    misattribution rather than patching it.

Both loading rules exempt the caboose: every caboose in ROLLING_STOCK_SUPPLY is
minted loaded, so an unexempted rule would bar the one car a consist lists by name.

`trainNeedingCars` now asks the full question per car rather than the shape
question. It shares its predicate with `check` precisely to avoid the stall its own
comment describes, and the shape question stopped being the same question: an
empties-only train facing a yard of loaded cars would have been told a car was
available and then refused every one.

The two published replays that stopped replaying under the new rules were retired
and re-recorded with save-replay.ts, which verifies each candidate before writing
it. That is what test/harness.test.ts is for and what its comment prescribes.

846 tests pass.

Closes #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
2026-08-29 06:44:13 -04:00

1386 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Component 1 — Card catalogue / static content.
*
* TRANSCRIBED FROM THE RECOVERED DESIGN FILES (2026-07-30):
* docs/Deck cards2.xlsx — the complete card list and counts
* docs/Trains3.pdf — the train cards
* docs/Mainline Cards.pdf — the Mainline card types, and the two Division Points
* docs/tracks.png — card art
*
* This replaced an invented 52-card placeholder. See docs/rules/implications.md for what changed
* and which decisions it supersedes.
*
* This is DATA, not logic. Keep tuning confined here so it never requires touching the engine.
*
* BEHAVIOUR NOT YET IMPLEMENTED, pending answers from the designer (implications.md §10): the
* meaning of Mainline speeds and Fast/Slow, the "Expedite" keyword, lockout semantics, and how
* space-use cards are played. The DATA for all of these is transcribed below and marked.
*/
// ---------------------------------------------------------------------------
// Primitives
// ---------------------------------------------------------------------------
export type CarType = 'coach' | 'boxcar' | 'reefer' | 'hopper' | 'tank' | 'caboose';
export type Direction = 'east' | 'west';
/** §9 — an industry loads only, unloads only, or does both. */
export type FlowDirection = 'outbound' | 'inbound' | 'both';
export type OfficeTier = 'whistlePost' | 'depot' | 'station' | 'terminal';
/** The six industries in the design. Note: no "oilRefinery" — it is `refinery`. */
export type FreightKind =
| 'freightHouse'
| 'mineTipple'
| 'refinery'
| 'powerPlant'
| 'packingSheds'
| 'grocersWarehouse';
export type TrackGeometry = 'straight' | 'curved' | 'sharpCurved' | 'turnout';
export type Hand = 'left' | 'right' | 'none';
// ---------------------------------------------------------------------------
// Track — deck cards, drawn and played like any other
// ---------------------------------------------------------------------------
export type TrackProfile = {
geometry: TrackGeometry;
hand: Hand;
name: string;
/** Column B of the sheet, "Number in Deck". */
copiesInDeck: number;
/** Column J of the sheet, "Train can stop here". Turnouts are blank. */
isOperationalRail: boolean;
/** Sharp curves: "Any move across this track is doubled (two moves)". */
moveCost: number;
};
/**
* TRACK IS IN THE HOME OFFICE DECK, and is drawn and played like any other card.
*
* Every row's dealt count is in `copiesInDeck` below; the sharp curves are dealt zero, see below.
* How many that comes to is `TRACK_IN_DECK`, computed rather than written down — it moves with
* balance work, so a number in this comment would be wrong before long.
*
* An earlier reading made track a separate per-player supply, sitting outside the deck and laid one
* a turn. That came from misreading the sheet's LAST column, headed "Track Per Player" —
* 8/4/4/1/1/4/4 = 26, which is a note about each player's likely share across four players, not a
* second stack of cards. The sheet's own totals settle it: "Sum other 115", "Total track 104", and a
* grand total of 231 once the 12 start cards are counted. 115 + 104 + 12 = 231. Those are figures
* from `docs/Deck cards2.xlsx`, a fixed document, and stay here as the audit trail for the
* transcription — they are not claims about what the game deals today.
*
* THE COUNTS BELOW NOW COME FROM `docs/Deck cards5.xlsx` (Gitea#14), which HALVES every track row
* against sheet 2: straight 32 → 16, each curve 16 → 8, each turnout 16 → 8. Track is the only
* section of that sheet whose numbers moved — every station, industry, modifier and train row is
* character-for-character what sheet 2 said — so this is the whole of the deck change it asks for.
*
* Sheet 5 also deals the sharp curves ZERO, which is where they already were: Jesse took them out
* for the reason below, and RAR arrived at the same number independently. Nothing to do, but worth
* recording that the two agree rather than leaving it looking like a coincidence.
*
* IT LANDS ON RAR'S OWN TARGETS, which is the check that matters — the top right of sheet 5 states
* the draw rates he is designing to. Against his denominators (start cards counted for track, only
* the non-track deck counted for trains): he wants track 48/167 = **28.7%** and trains 22/107 =
* **20.6%**; this deck gives 48/170 = **28.2%** and 22/110 = **20.0%**.
*
* THAT MATCH IS PARTLY A CANCELLATION, and whoever retunes next should know it. The engine holds
* ~25 more office and industry cards than the sheet (doubled and tripled, below) and is missing the
* ~33 Safety, Event, Inspection and Space-use cards sheet 5 lists, which Gitea#14 defers. The two
* errors are opposite and nearly equal today. Build the deferred categories and they stop
* cancelling, so the ratios have to be re-measured then rather than assumed to have held.
*
* It matters well beyond bookkeeping. Track competes for the draw with industry, trains and
* enhancements, so building a district is paid for in cards you did not draw instead — and the
* hand-of-three is the real constraint on how fast a railroad grows.
*
* Handedness is PRINTED, not chosen on placement: it is the diagonal the 45° leg lies on.
*
* DO NOT REORDER THESE ROWS to put left before right. `setup.ts` builds the deck by walking this
* array, so a row's POSITION decides which physical card a given seed deals — and the replays in
* `public/replays/` are saved as a seed plus a list of intents, replayed through this deck. When
* playtesting found the hands inverted (see `track.ts`, Orientation), the fix flipped each pair's
* `hand` label in place and left the order alone, so slot 32 still holds an `nw_se` curve and every
* published replay still plays. Reordering to look tidy would silently re-deal every saved game.
*/
export const TRACK_CARDS: readonly TrackProfile[] = [
{ geometry: 'straight', hand: 'none', name: 'Straight track', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
{ geometry: 'curved', hand: 'right', name: 'Curved track (right)', copiesInDeck: 8, isOperationalRail: true, moveCost: 1 },
{ geometry: 'curved', hand: 'left', name: 'Curved track (left)', copiesInDeck: 8, isOperationalRail: true, moveCost: 1 },
/**
* SHARP CURVES ARE DEALT ZERO COPIES — Jesse's call, and the same treatment as Poling.
*
* The only thing that made them different from an ordinary curve was `moveCost: 2`, and nothing
* ever charged it: every switching move costs exactly 1, hard-coded. So they were geometric
* duplicates of the curves, taking draws from a deck the rebalance already thinks is too diluted.
* They come out rather than having the Move cost built, because a per-card movement cost is a
* change to the Move model and the rebalance can wait.
*
* The rows stay in the catalogue at zero, exactly as Poling does, so the design is still visible
* and the geometry still works if they are ever dealt again.
*/
{ geometry: 'sharpCurved', hand: 'right', name: 'Sharp Curved Track (right)', copiesInDeck: 0, isOperationalRail: true, moveCost: 2 },
{ geometry: 'sharpCurved', hand: 'left', name: 'Sharp Curved Track (left)', copiesInDeck: 0, isOperationalRail: true, moveCost: 2 },
{ geometry: 'turnout', hand: 'right', name: 'Turnout (right)', copiesInDeck: 8, isOperationalRail: false, moveCost: 1 },
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 8, isOperationalRail: false, moveCost: 1 },
];
/** Summed from `copiesInDeck` above, never written down — it moves whenever the deck is retuned. */
export const TRACK_IN_DECK = TRACK_CARDS.reduce((n, t) => n + t.copiesInDeck, 0);
/** Start cards, placed at setup and never shuffled. */
export const WHISTLE_POST_SUPPLY = 4;
export const LIMITS_SUPPLY = 8;
/** Look up a track card's profile by the two things that identify it. */
export function trackProfile(geometry: TrackGeometry, hand: Hand): TrackProfile | undefined {
return TRACK_CARDS.find((t) => t.geometry === geometry && t.hand === hand);
}
// ---------------------------------------------------------------------------
// Offices — the upgrade ladder: Whistle Post, Depot, Station, Terminal
// ---------------------------------------------------------------------------
export type OfficeProfile = {
tier: OfficeTier;
name: string;
isControlPoint: boolean;
isPassengerFacility: boolean;
adTracks: number;
porters: number;
/** Green slots. The design gives slots EQUAL to porters, not one more. */
passengerOut: number;
/** Red slots. */
passengerIn: number;
copiesInDeck: number;
};
/**
* A/D tracks and Porter counts are exactly as designed. The slot counts are corrected downward:
* an earlier guess gave one more slot than porters at every tier. Capacity is instead grown by the
* passenger modifier cards (Waiting Area, Restaurant, Hotel).
*/
/**
* Office cards, at `docs/Deck cards5.xlsx`'s counts exactly: Depot 4, Station 2, Terminal 1.
*
* THE Q12 DOUBLING IS GONE (Gitea#14). Every tier used to be dealt at twice the sheet, to remove a
* 25% chance of an unwinnable opening deal: players always start at a Whistle Post, which has ONE
* A/D track, so a second arrival is an automatic collision (§8.3, Gap 2a), and measured at the
* sheet's density 25 of 100 games never drew a Depot and never escaped — averaging **−6.0** revenue
* against **−0.4** for games that upgraded at least once, with 25 of 26 collisions at a Whistle
* Post.
*
* That measurement was taken against a deck with 96 track cards in it, and the failure it describes
* does not survive the halving of track. RE-MEASURED at the sheet's counts, 100 games: **43 of 100**
* never upgrade off a Whistle Post, up from 25 — but they average **−0.2** revenue against **+1.4**
* for games that do upgrade, where the gap used to be −6.0 against −0.4. Collisions fell from 26 per
* 100 games to **6**, and only 3 of those are in games that never upgraded, against 25 of 26 before.
*
* So staying at a Whistle Post is now common and survivable rather than rare and fatal, which is the
* opposite of the shape Q12 was answering: with fewer trains reaching an Office, a single A/D track
* is seldom contested. The doubling was the blunt instrument its own note called it, and at the
* sheet's deck size it costs more than it buys — see `TRACK_CARDS` for the whole comparison and
* `INDUSTRY_PROFILES` for the other half of the same decision.
*
* Upgrades are strictly sequential (Gap 3b, no skipping), so Station and Terminal are rarer than
* their raw counts imply — Terminal needs all three cards in order.
*
* IF THE OPENING BITES AGAIN, the fix is not to re-double this. The note it replaces already listed
* the better options: fewer Terminals, a cheaper first upgrade, or more A/D capacity at a Whistle
* Post. Any of those answers the collision without diluting every other category to do it.
*/
export const OFFICE_PROFILES: readonly OfficeProfile[] = [
{ tier: 'whistlePost', name: 'Whistle Post', isControlPoint: false, isPassengerFacility: false, adTracks: 1, porters: 0, passengerOut: 0, passengerIn: 0, copiesInDeck: 0 },
{ tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 4 },
{ tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 2 },
{ tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 1 },
];
export const OFFICE_ORDER: readonly OfficeTier[] = ['whistlePost', 'depot', 'station', 'terminal'];
export function officeProfile(tier: OfficeTier): OfficeProfile {
const found = OFFICE_PROFILES.find((p) => p.tier === tier);
if (!found) throw new Error(`unknown office tier: ${tier}`);
return found;
}
export function nextOfficeTier(tier: OfficeTier): OfficeTier | null {
const i = OFFICE_ORDER.indexOf(tier);
return i >= 0 && i + 1 < OFFICE_ORDER.length ? OFFICE_ORDER[i + 1]! : null;
}
// ---------------------------------------------------------------------------
// Industries — one car out, one loader. Capacity grows via modifiers.
// ---------------------------------------------------------------------------
export type IndustryProfile = {
kind: FreightKind;
name: string;
carTypes: readonly CarType[];
flow: FlowDirection;
/** Base green-box capacity. Every industry starts at ONE. */
baseOut: number;
/** Base red-box capacity. */
baseIn: number;
/** "Loaders" in the design; the rules PDF calls them Laborers. One to start. */
baseLoaders: number;
/** Column E — industries that may not coexist with this one. Semantics pending (§10 Q4). */
lockouts: readonly FreightKind[];
copies: number;
};
/**
* Industry density, at `docs/Deck cards5.xlsx`'s counts exactly (Gitea#14).
*
* THE GAP-12 TRIPLING IS GONE. The recovered sheet listed 9 industries in a 115-card deck and the
* prototype ran 10 in 52; at that density a game saw 1.6 Freight Facilities, freight was 10% of
* gross revenue, and `carsCoupled` fired 4 times per 100 games, so the freight loop effectively
* never ran. Tripling every kind restored roughly the prototype's ratio.
*
* ALL OF THAT WAS MEASURED AGAINST A DECK WITH 96 TRACK CARDS. Sheet 5 halves the track, and the
* tripling then works backwards: the deck keeps dealing industries while the district stays too
* small to reach them. Measured over 300 bot games on identical seeds — 96 track with the multiplier
* / 48 track with it / 48 track without — reefer cars set out by a crew went **49 / 0 / 39** and
* mean revenue **−0.20 / +0.22 / +0.27**. The middle column is the tripling meeting the halved
* deck: it wipes out the reefer chain completely. The sheet's own density is the best of the three
* on both counts.
*
* The sheet's proportions were always preserved by the multiplier, since every kind scaled by the
* same factor — so removing it changes the density and nothing else. The outbound/inbound balance
* and the lockout structure below are the sheet's, as they always were.
*/
/**
* LOCKOUTS, from the sheet's "Lockouts" column verbatim:
*
* Freight house -> Freight house, Grocer's warehouse
* Mine Tipple -> Power Plant
* Refinery -> Power plant
* Power Plant -> Mine Tipple, Refinery
* Packing Sheds -> Grocer's Warehouse
* Grocer's Warehouse -> Packing Sheds, Freight House
*
* Every pair is a PRODUCER and the CONSUMER of the same commodity: Mine Tipple makes coal and the
* Power Plant burns it, the Refinery makes oil and the Power Plant burns that too, Packing Sheds
* fill reefers and the Grocer's Warehouse empties them. You may build one end of a chain or the
* other, never both — which is what forces the traffic to run BETWEEN districts rather than in
* circles inside one.
*
* Freight house listing ITSELF is the sheet stating the general rule in the one row where it would
* otherwise look like an omission: no two of the same industry in one Office Area. That rule is
* enforced for every kind in `isLockedOut`, not repeated in each row here.
*/
export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 2 },
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 2 },
/**
* OUTBOUND ONLY. A Refinery ships oil out and receives nothing; reported from playtesting and
* confirmed by Jesse (v0.4.9e): "only ships out tanks, does not receive anything".
*
* It was briefly `flow: 'both'`, on the reading that "'Freight House' is not a card — it is the
* collective term for a freight facility that loads *and* unloads, the Grocer's Warehouse and the
* Oil Refinery", which made §9.3's "Passenger Facilities and Freight Houses permit cars to move
* each direction" name exactly those two. That premise is dead: `glossary.md` and
* `rules-v0.2.md` corrected the Freight House to a card of its own, dealt like any other industry,
* so §9.3 names the Freight House and nothing else, and card-reference.md's "Both" column loses
* the only argument it had.
*
* The card set says the same thing on its own. All three Refinery modifiers — Pipelines, Oil
* Depot, Viscosity Breakers — grant `+1 outbound`; a two-way Refinery would be the one industry in
* the game with no way to raise the direction it is supposed to use half its capacity on.
* `StationMaster-Home-Deck-v0.4.5.md` prints it "Outbound, 1 out / 0 in".
*/
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 1 },
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 2 },
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 1 },
/**
* INBOUND ONLY — the mirror of the Refinery above, and the same correction. Reported from
* playtesting and confirmed by Jesse (v0.4.9e): "Grocer's Warehouse should be receive only, does
* not ship anything out". `StationMaster-Home-Deck-v0.4.5.md` prints it "Inbound, 0 out / 1 in".
*
* THE ICE HOUSE IS THEREFORE A DEAD CARD BESIDE A GROCER'S, and that is the design, not an
* oversight: `usableGrant` drops a Modifier's grant on a direction its host cannot use, and the
* Home Deck sheet says so outright — "a bonus beside a facility that cannot use its direction is
* not usable", naming the Truck Dock's inbound bonus beside the outbound-only Packing Sheds as the
* other example. The Truck Dock (+1 inbound) and Local Small Groceries (+1 Laborer) are the two
* that do work here.
*/
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 1 },
];
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
export const FREIGHT_PROFILES = INDUSTRY_PROFILES;
/**
* §9.3 — "Passenger Facilities and Freight Houses permit cars to move each direction".
*
* ONE CARD ANSWERS TO THIS NOW: the Freight House itself. It was briefly three, while the Refinery
* and the Grocer's Warehouse were also `both` on a reading of the term the glossary has since
* corrected — a Freight House is a card, not a collective noun. Kept as a predicate on `flow`
* rather than a comparison against the kind, because it is the DIRECTION §9.3 is talking about.
*/
export function isFreightHouse(p: IndustryProfile): boolean {
return p.flow === 'both';
}
export function industryProfile(kind: FreightKind): IndustryProfile {
const f = INDUSTRY_PROFILES.find((p) => p.kind === kind);
if (!f) throw new Error(`unknown industry: ${kind}`);
return f;
}
// ---------------------------------------------------------------------------
// Modifiers — passenger and industry, each tied to specific hosts
// ---------------------------------------------------------------------------
export type ModifierKind =
| 'waitingArea' | 'restaurant' | 'hotel'
| 'truckDock' | 'railroadExpressAgency' | 'forklifts'
| 'prepPlant' | 'coalPiles' | 'conveyorBelts'
| 'pipelines' | 'oilDepot' | 'viscosityBreakers'
| 'transmissionLines' | 'rotaryDumps' | 'steamTurbines'
| 'iceHouse' | 'localSmallGroceries';
export type ModifierProfile = {
kind: ModifierKind;
name: string;
/** Which hosts it may sit beside. 'office' means any Passenger Facility. */
hosts: readonly (FreightKind | 'office')[];
addOut: number;
addIn: number;
addLoaders: number;
addPorters: number;
copies: number;
};
export const MODIFIER_PROFILES: readonly ModifierProfile[] = [
// Passenger — "Add 1 Psgn out, 1 porter"
{ kind: 'waitingArea', name: 'Waiting area', hosts: ['office'], addOut: 1, addIn: 0, addLoaders: 0, addPorters: 1, copies: 3 },
{ kind: 'restaurant', name: 'Restaurant', hosts: ['office'], addOut: 1, addIn: 0, addLoaders: 0, addPorters: 1, copies: 2 },
{ kind: 'hotel', name: 'Hotel', hosts: ['office'], addOut: 1, addIn: 0, addLoaders: 0, addPorters: 1, copies: 1 },
// Freight House / Packing Sheds / Grocer's
/**
* INBOUND, AND NO LABORER — the one Modifier that helps a facility RECEIVE.
*
* It printed "+1 outbound, +1 Laborer" like every other freight Modifier, which made it a
* duplicate of Forklifts with a longer host list. A dock is where a truck backs up to take
* delivery, so it earns its own line in the deck by adding the red box instead of the green one —
* and pays for it by bringing no man to work it.
*
* Two consequences, both intended. `usableGrant` drops an inbound grant on a host that only ships,
* so beside **Packing Sheds** this card now does nothing at all — the hand tooltip says so before
* it is played. And it is the first Modifier that grants no worker, so a facility's laborer count
* no longer rises with every card set beside it.
*/
{ kind: 'truckDock', name: 'Truck dock', hosts: ['freightHouse', 'packingSheds', 'grocersWarehouse'], addOut: 0, addIn: 1, addLoaders: 0, addPorters: 0, copies: 2 },
{ kind: 'railroadExpressAgency', name: 'Railroad Express Agency', hosts: ['freightHouse'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
{ kind: 'forklifts', name: 'Forklifts', hosts: ['freightHouse', 'packingSheds'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 2 },
// Mine Tipple
{ kind: 'prepPlant', name: 'Prep Plant', hosts: ['mineTipple'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
{ kind: 'coalPiles', name: 'Coal Piles', hosts: ['mineTipple'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
{ kind: 'conveyorBelts', name: 'Conveyor Belts', hosts: ['mineTipple'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
// Refinery
{ kind: 'pipelines', name: 'Pipelines', hosts: ['refinery'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
{ kind: 'oilDepot', name: 'Oil Depot', hosts: ['refinery'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
{ kind: 'viscosityBreakers', name: 'Viscosity breakers', hosts: ['refinery'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
// Power Plant — inbound, so these add loaders rather than outbound capacity
{ kind: 'transmissionLines', name: 'Transmission lines', hosts: ['powerPlant'], addOut: 0, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
{ kind: 'rotaryDumps', name: 'Rotary Dumps', hosts: ['powerPlant'], addOut: 0, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
{ kind: 'steamTurbines', name: 'Steam Turbines', hosts: ['powerPlant'], addOut: 0, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
// Produce
{ kind: 'iceHouse', name: 'Ice House', hosts: ['packingSheds', 'grocersWarehouse'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 2 },
{ kind: 'localSmallGroceries', name: 'Local small groceries', hosts: ['grocersWarehouse'], addOut: 0, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
];
export function modifierProfile(kind: ModifierKind): ModifierProfile {
const m = MODIFIER_PROFILES.find((p) => p.kind === kind);
if (!m) throw new Error(`unknown modifier: ${kind}`);
return m;
}
// ---------------------------------------------------------------------------
// Trains — named, with a speed class and individual printed rules
// ---------------------------------------------------------------------------
export type TrainSpeed = 'fast' | 'slow';
/**
* Consists are specified by CATEGORY, not by car type: "Freight (2)" means any two freight cars.
* `freightTypes` narrows it where a card does (X14 is reefers only).
*/
export type ConsistSpec = {
freight: number;
coach: number;
caboose: number;
freightTypes?: readonly CarType[];
/** X13 Appleseed: empties only. */
emptiesOnly?: boolean;
};
/** Operating rules printed on the card. Behaviour pending §10 Q1 for `expedite`. */
export type TrainRules = {
noSwitching?: boolean;
terminalsOnly?: boolean;
expedite?: boolean;
/** Train 7/8: "Coach must remain on station track if switching." */
coachStaysOnStationTrack?: boolean;
/** Train 3/4: may drop or pick up one freight car at every location. */
oneFreightPerLocation?: boolean;
noPassengerWork?: boolean;
/** X13: may drop empties but not pick anything up. X22: may only pick up empties. */
dropOnly?: boolean;
pickUpEmptiesOnly?: boolean;
/** X17 Campaign, X18 Circus: a scheduled stop that does something. */
stopEarnsPoint?: boolean;
stopThenExpedite?: boolean;
/**
* MUST RUN LOADED (Gitea#13) — X17 Campaign, X18 Circus, X19 Military.
*
* "I've redefined some of the extra trains that they have to run full boxcars (not just any crazy
* stuff) — military trains, circus trains, etc. If not loaded, then empty, and if none available,
* run without." So it is a PREFERENCE ORDER enforced at make-up, not a flat requirement: a loaded
* car of an acceptable type must be taken while one is in the Division Yard; only once none is
* left may an empty be taken; and a train may still depart short (§8.2 already allows fewer cars
* than the card lists).
*
* Distinct from `ConsistSpec.emptiesOnly`, which is the opposite rule for X13 Appleseed.
*/
mustRunLoaded?: boolean;
/**
* `copiesNextScheduled` was here and is DELETED. No train card ever carried it: a Second Section
* is a Maneuver card played on a train that is due out, and it has its own intent
* (`newTrain.secondSection`, `SECOND_SECTION` below) which has been implemented all along. The
* flag was a second, unreachable way to describe a mechanic that already worked — it appeared in
* the "nine rules read by nothing" count while being the one entry that needed removing rather
* than building.
*/
note?: string;
};
export type TrainProfile = {
number: number;
isExtra: boolean;
name: string;
speed: TrainSpeed;
direction: Direction | 'playerChoice';
consist: ConsistSpec;
rules: TrainRules;
};
/** Odd numbers run westbound, even eastbound (§2.3). Pairs share a class. */
function pair(odd: number, name: string, speed: TrainSpeed, consist: ConsistSpec, rules: TrainRules): TrainProfile[] {
return [
{ number: odd, isExtra: false, name, speed, direction: 'west', consist, rules },
{ number: odd + 1, isExtra: false, name, speed, direction: 'east', consist, rules },
];
}
export const TIMETABLED_TRAINS: readonly TrainProfile[] = [
/**
* COACH COUNTS ON 1/2 AND 5/6 WERE SWAPPED BY JESSE (Gitea#7, v0.4.9e playtest): the Crack Limited
* drops from three coaches to two, and The Sparrow rises from two to three. A change to the card
* faces themselves, not a transcription fix — `Trains3.pdf` and the tables that transcribe it
* still print the old numbers, so `docs/rules/card-reference.md` is the place that now carries
* what the cards say.
*/
...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 2, caboose: 0 },
{ terminalsOnly: true, noSwitching: true, expedite: true, note: 'Stop at Terminals only.' }),
...pair(3, 'Express', 'fast', { freight: 2, coach: 0, caboose: 0 },
{ oneFreightPerLocation: true, expedite: true, note: 'May drop or pick up one freight car at every location.' }),
...pair(5, 'The Sparrow', 'fast', { freight: 0, coach: 3, caboose: 0 },
{ noSwitching: true, expedite: true }),
...pair(7, 'Local', 'slow', { freight: 1, coach: 1, caboose: 0 },
{ coachStaysOnStationTrack: true, note: 'Maximum one freight, one coach.' }),
...pair(9, 'Heavy Freight', 'slow', { freight: 3, coach: 0, caboose: 1 }, {}),
...pair(11, 'Drag Freight', 'slow', { freight: 2, coach: 0, caboose: 1 }, {}),
];
/**
* Extras are X13–X22 — ALL junior to every timetabled train, so Gap 5's tie between an Extra and a
* Timetabled train of the same number can no longer occur.
*/
export const EXTRA_TRAINS: readonly TrainProfile[] = [
{ number: 13, isExtra: true, name: 'Appleseed Extra', speed: 'slow', direction: 'playerChoice', consist: { freight: 3, coach: 0, caboose: 1, emptiesOnly: true }, rules: { dropOnly: true, note: 'May drop MTs but not pick up anything.' } },
{ number: 14, isExtra: true, name: 'Fruit Growers Express', speed: 'fast', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1, freightTypes: ['reefer'] }, rules: { expedite: true, note: 'Reefers only. May pick up one extra loaded reefer.' } },
{ number: 15, isExtra: true, name: 'Yard Xfer', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1 }, rules: {} },
{ number: 16, isExtra: true, name: 'Light Engine Move', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 0 }, rules: { noSwitching: true, note: 'No cars at all.' } },
{ number: 17, isExtra: true, name: 'Campaign Train', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 1, caboose: 0 }, rules: { noSwitching: true, stopThenExpedite: true, stopEarnsPoint: true, mustRunLoaded: true, note: 'One turn at station (speeches) then expedite. Earns a point per Office Area if the candidate is aboard.' } },
{ number: 18, isExtra: true, name: 'Circus Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 1 }, rules: { noSwitching: true, stopEarnsPoint: true, mustRunLoaded: true, note: 'One turn stopped in an Office Area (circus set-up) earns 1 point, once per Area, if fully loaded.' } },
{ number: 19, isExtra: true, name: 'Military Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 1, coach: 2, caboose: 0 }, rules: { noSwitching: true, noPassengerWork: true, expedite: true, mustRunLoaded: true, note: 'Troops and materiel: runs loaded where the yard can supply it.' } },
{ number: 20, isExtra: true, name: "Director's private car", speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 0 }, rules: { noPassengerWork: true } },
{ number: 21, isExtra: true, name: 'Freight Extra', speed: 'slow', direction: 'playerChoice', consist: { freight: 3, coach: 0, caboose: 1 }, rules: {} },
{ number: 22, isExtra: true, name: 'Pee-Dee', speed: 'slow', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 1 }, rules: { pickUpEmptiesOnly: true, note: 'Per-diem train. May only pick up MTs.' } },
];
export const ALL_TRAINS: readonly TrainProfile[] = [...TIMETABLED_TRAINS, ...EXTRA_TRAINS];
/**
* §2.3 — ODD RUNS WEST, EVEN RUNS EAST. The number is the direction for a TIMETABLED train.
*
* NOT for an Extra any more. Extras print `direction: 'playerChoice'` and now mean it (Jesse's
* ruling, superseding "the number decides, like everything else on the timetable"): the player who
* played the card picks where it starts, and the start decides the direction — a Crew Tray placed
* at the Western Division Point runs east and one at the Eastern runs west, because the alternative
* is a train that leaves the Division on its first move having crossed nothing. Where the start is
* NOT an end of the Division — an Interchange, or a Control Point — both ways are real runs and the
* player says which. `resolveExtraStart` in apply.ts is where that happens.
*
* Still read for an Extra in one place: replaying a save written before the choice existed, where
* `newTrain.startExtra` carries only the legacy `atSeat` field.
*/
export function runDirection(trainNumber: number): Direction {
return trainNumber % 2 === 0 ? 'east' : 'west';
}
/** The Division Point a train of this number starts from — the opposite end to the way it runs. */
export function startingDivisionPoint(trainNumber: number): Direction {
return runDirection(trainNumber) === 'east' ? 'west' : 'east';
}
export function trainProfile(number: number, isExtra: boolean): TrainProfile | null {
return ALL_TRAINS.find((t) => t.number === number && t.isExtra === isExtra) ?? null;
}
/** Total cars a consist calls for. The Crew Tray limit still applies (§A.4). */
export function consistSize(c: ConsistSpec): number {
return c.freight + c.coach + c.caboose;
}
// ---------------------------------------------------------------------------
// Mainline cards — ten types, with speeds and named entry points
// ---------------------------------------------------------------------------
export type MainlineKind =
| 'plains' | 'curves' | 'hilly' | 'heavyGrade' | 'doubleTrack'
| 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'interchange';
/**
* THE PRINTED SPEEDS ARE GRAPHICS. RAR, 2026-08-26 (Gitea#3): "please ignore the speed signs I put
* on the cards — those are nothing but scene-setting graphics that mimic the speed you are
* travelling. It's just ambiance, nothing more."
*
* They used to decide everything: a `MainlineSpeed` of 60 meant one Stage and a 30 meant two, plus
* one more for a Slow train. Both rules are gone. **What crosses a card is REGIONS** — the boxes
* printed on it — one per Stage, and where a train STARTS decides how many it has left to run.
*/
export type MainlineProfile = {
kind: MainlineKind;
name: string;
/** Regions printed on the card. A train advances one per Stage, so a full run costs `regions`. */
regions: number;
/**
* The region an ordinary train enters at. Zero on nearly everything — but the Uncontrolled Siding
* and the Interchange print a back region that is a siding or a holding spur rather than part of
* the road, so a train running straight through starts past it and crosses in one Stage.
*/
defaultStart: number;
/**
* Cards that print a FAST and a SLOW start, and the region each begins at. "Some cards say fast /
* slow. This is an indication that if on the train card, the train is listed as fast or slow,
* that's starting position / how many stages it takes to traverse the card. Fast / Slow does not
* apply to every card — just those that say fast / slow on them. Currently this is only hilly."
*
* So the train's rating is read HERE and nowhere else. It used to add a Stage to every card.
*/
speedStarts?: { fast: number; slow: number };
/** Double Track: "Trains may pass". */
trainsMayPass: boolean;
/** Interchange: "Sort cars in new order". */
sortsCars: boolean;
/** Named entry points printed on the card; some are unlocked by modifier cards. */
entryPoints: readonly string[];
};
export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
{ kind: 'plains', name: 'Plains', regions: 1, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
{ kind: 'curves', name: 'Curves', regions: 2, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
/**
* THE ONLY CARD THAT READS A TRAIN'S FAST/SLOW RATING. A fast train starts in the second region
* and is across in one Stage; a slow one starts at the beginning and takes two.
*
* It used to read the CONSIST instead — any coach aboard made the train "passenger" for this card
* — off the printed P60/F30. RAR corrected that directly: "I notice that you are basing stages in
* mainline cards off coach/non-coach. Actually, all trains are rated as FAST and SLOW."
*/
{ kind: 'hilly', name: 'Hilly', regions: 2, defaultStart: 0, speedStarts: { fast: 1, slow: 0 }, trainsMayPass: false, sortsCars: false, entryPoints: ['fast', 'slow'] },
/**
* THREE REGIONS, AND THE MODIFIERS MOVE THE START RATHER THAN CUTTING THE TIME — which comes to
* the same number of Stages and is how the card is actually printed and played. "If you play the
* home deck card 'helpers' against the mainline card heavy grade, it remains there the rest of the
* game and helps all trains going up hill by starting 1 region easier — so 2 to traverse, not 3.
* Other cards help the other direction, similar idea. Airbrakes is an upgrade from brakemen (which
* must be played first)."
*
* So: Helpers moves an UPHILL train up one region; Brakeman moves a DOWNHILL train up one, and
* Airbrakes another on top of it. A fully-equipped grade is one Stage downhill and two up.
*/
{ kind: 'heavyGrade', name: 'Heavy Grade', regions: 3, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start', 'brakemen', 'airbrakes', 'helpers'] },
{ kind: 'doubleTrack', name: 'Double Track', regions: 1, defaultStart: 0, trainsMayPass: true, sortsCars: false, entryPoints: ['start'] },
/**
* TWO REGIONS, AND THE BACK ONE IS THE SIDING. A train with the card to itself starts past it and
* crosses in one Stage. "Uncontrolled siding: if a train already exists when you arrive, you go in
* the second stage back (you are in the siding and are one behind the other train). This prevents
* a collision — since you are not in same exact location."
*
* `trainsMayPass` is FALSE here, and used to be true. Two trains fit, but not by passing: the
* second one takes the siding and sits a region behind, which is what keeps them apart. Leaving it
* true skipped the collision test altogether and made the siding do nothing at all.
*/
{ kind: 'uncontrolledSiding', name: 'Uncontrolled Siding', regions: 2, defaultStart: 1, trainsMayPass: false, sortsCars: false, entryPoints: ['through', 'siding'] },
{ kind: 'tunnel', name: 'Tunnel', regions: 2, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
{ kind: 'trestle', name: 'Trestle', regions: 1, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
/**
* RENAMED FROM "Yard" after play. The card is unchanged — same "sort cars in new order", same art
* — but "Yard" collided with the Division Yard, the Classification Yard, the Salvage Yard, the
* Yard Office and the Small Yard, none of which are this. Its own key is renamed with it, so the
* two never drift apart. Those OTHER yards are deliberately left alone: they are different things
* that merely shared a word.
*
* TWO REGIONS, the back one a holding spur, exactly as the Uncontrolled Siding: "interchange has
* new extras show up in second region (like uncontrolled siding)", and earlier, "Plains is 1 stage
* for ALL trains. So are interlockings, with a second stage for incoming extras to hold at." A
* train running through crosses in one Stage; an Extra beginning its run here starts at the back.
*/
{ kind: 'interchange', name: 'Interchange', regions: 2, defaultStart: 1, trainsMayPass: false, sortsCars: true, entryPoints: ['through', 'extraStart'] },
];
/**
* THE MAINLINE DECK AS PRINTED, dealt WITHOUT replacement.
*
* `MAINLINE_PROFILES` above is a list of card TYPES, one entry each. It is not the deck, and setup
* used it as one: `buildDivision` drew uniformly from those types with replacement, which made two
* Interchanges (or two Trestles, or two Tunnels) an ordinary outcome and gave Plains the same weight
* as everything else although the deck prints two of it. `Mainline Cards.pdf` is the
* inventory, transcribed in `docs/StationMaster-Mainline-Deck-v0.4.5.md`, which had already flagged
* the mismatch as needing correction.
*
* It matters more than card flavour now that an Extra may start at an Interchange (§7): "if an
* Interchange is on the board" has to mean a card there is at most one of, not a type the deal can
* hand out twice.
*
* The two Division Point cards in that inventory are not here — they are the fixed ends of the
* Division, laid by `buildDivision` itself rather than drawn. Plains appears twice because the deck
* prints it twice; every other type once. The list below is the deck, so it is not counted here.
*/
export const MAINLINE_DECK: readonly MainlineKind[] = [
'plains', 'plains', 'curves', 'hilly', 'heavyGrade',
'doubleTrack', 'uncontrolledSiding', 'tunnel', 'trestle', 'interchange',
];
/**
* WHERE A TRAIN ENTERS A MAINLINE CARD, and therefore how long it takes to cross (Gitea#3).
*
* "Regions shown on cards indicate how many stages it takes to cross. Plains is 1. Double track is
* 1, tunnel is 2, curves is 2, heavy grade is 3 unless you have help." A train advances one region
* per Stage, so the whole of crossing time is `regions - startRegion`.
*
* FOUR THINGS MOVE THE START, and nothing else does:
*
* 1. the card's own `defaultStart` — the Uncontrolled Siding and the Interchange print a back
* region that is not part of the road, so a train running through begins past it;
* 2. `speedStarts`, on a card that prints a Fast and a Slow start. Only Hilly does;
* 3. the permanent Heavy Grade modifiers, which move a train one region up the hill each;
* 4. `takesSiding` / `startsAtBack`, the two occupancy cases below.
*
* WHAT NO LONGER MOVES IT: the printed mph, which is now scenery, and a train's Fast/Slow rating on
* any card but Hilly. That rating used to add a Stage to EVERY card, which is what made a Slow train
* cross Double Track in two Stages and produced the report this issue opened with.
*/
export type MainlineEntry = {
trainSpeed: TrainSpeed;
direction: Direction;
gradeUp: Direction;
modifiers: readonly string[];
/**
* The Uncontrolled Siding with a train already on it: this one takes the siding and sits a region
* behind, which is what keeps them out of the same place. Also the Interchange, where an Extra
* beginning its run starts in the holding region rather than on the road.
*/
startsAtBack?: boolean;
};
export function startRegion(kind: MainlineKind, entry: MainlineEntry): number {
const profile = mainlineProfile(kind);
if (entry.startsAtBack) return 0;
const base = profile.speedStarts
? profile.speedStarts[entry.trainSpeed]
: profile.defaultStart;
const climbing = entry.direction === entry.gradeUp;
let help = 0;
if (kind === 'heavyGrade') {
if (climbing) {
if (entry.modifiers.includes('helpers')) help++;
} else {
// Airbrakes is an upgrade on Brakeman and cannot be played without it, so this counts both.
if (entry.modifiers.includes('brakeman')) help++;
if (entry.modifiers.includes('airbrakes')) help++;
}
}
// Never past the last region: a card always costs at least one Stage to cross.
return Math.min(profile.regions - 1, base + help);
}
/** How many Stages a train needs to cross a Mainline card — the regions it has left to run. */
export function crossingStages(kind: MainlineKind, entry: MainlineEntry): number {
return mainlineProfile(kind).regions - startRegion(kind, entry);
}
/**
* WHAT THIS MAINLINE CARD DOES TO A TRAIN, in a sentence.
*
* Reported from play: "mainline cards need a tooltip stating what they do. Hilly and Uncontrolled
* Siding — I have no idea the impact they have on game play." Both are invisible without one: Hilly
* charges freight double what it charges passengers, and Uncontrolled Siding is one of only two
* cards where a following train is not stuck behind a slower one.
*
* The crossing times are COMPUTED by `crossingStages` rather than written out, so a tooltip cannot
* drift from the rule it describes — including the Hilly split, which is the whole point of the
* card and is decided by whether the train carries a coach.
*/
export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'east'): string {
const p = mainlineProfile(kind);
const stages = (n: number): string => `${n} Stage${n === 1 ? '' : 's'}`;
const run = (entry: Partial<MainlineEntry>): number =>
crossingStages(kind, { trainSpeed: 'fast', direction: 'east', gradeUp, modifiers: [], ...entry });
const parts: string[] = [];
parts.push(`${p.regions} region${p.regions === 1 ? '' : 's'} — one Stage each.`);
if (p.speedStarts) {
parts.push(
`This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses ` +
`in ${stages(run({ trainSpeed: 'fast' }))}, a slow one in ${stages(run({ trainSpeed: 'slow' }))}. ` +
`No other card cares which it is.`,
);
} else if (kind === 'heavyGrade') {
parts.push(
`A grade, climbing ${gradeUp === 'east' ? 'eastward' : 'westward'}. ` +
`${stages(run({ direction: gradeUp }))} to climb it and ${stages(run({ direction: gradeUp === 'east' ? 'west' : 'east' }))} to run down, ` +
`before help. Helpers start an UPHILL train a region further on; Brakeman does the same ` +
`DOWNHILL and Airbrakes another again, and Airbrakes cannot be played without Brakeman. ` +
`Never less than one Stage.`,
);
} else if (p.defaultStart > 0) {
parts.push(
`A train with the card to itself starts past the back region and is across in ` +
`${stages(run({}))}.`,
);
} else {
parts.push(`${stages(run({}))} for every train — the printed speed is scenery.`);
}
if (kind === 'uncontrolledSiding') {
parts.push(
'UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region ' +
'behind it. You are not in the same place, so you do not run into it; it costs you the ' +
'extra Stage instead.',
);
}
if (kind === 'interchange') {
parts.push('An Extra beginning its run here starts in the back region and takes the extra Stage.');
}
if (p.trainsMayPass) {
parts.push(
'TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held ' +
'behind a slower one.',
);
} else {
parts.push('One train at a time — anything following has to wait for it to clear.');
}
if (p.sortsCars) parts.push('Cars may be sorted into any new order here.');
return parts.join(' · ');
}
export function mainlineProfile(kind: MainlineKind): MainlineProfile {
const p = MAINLINE_PROFILES.find((m) => m.kind === kind);
if (!p) throw new Error(`unknown mainline card: ${kind}`);
return p;
}
/** Which Mainline modifiers may sit on `kind`, and what each additionally requires. */
export const MAINLINE_MODIFIER_RULES: readonly {
key: string;
/** Only placeable on a card whose speed is a grade. */
gradeOnly: boolean;
/** Another modifier that must already be on the same card. */
requiresOnCard?: string;
}[] = [
{ key: 'brakeman', gradeOnly: true },
{ key: 'airbrakes', gradeOnly: true, requiresOnCard: 'brakeman' },
{ key: 'helpers', gradeOnly: true },
{ key: 'realignment', gradeOnly: false },
];
export function mainlineModifierRule(key: string) {
return MAINLINE_MODIFIER_RULES.find((r) => r.key === key) ?? null;
}
/** `Realignment` converts one Mainline card into another. */
export const REALIGNMENTS: readonly { from: MainlineKind; to: MainlineKind }[] = [
{ from: 'plains', to: 'doubleTrack' },
{ from: 'curves', to: 'plains' },
{ from: 'uncontrolledSiding', to: 'doubleTrack' },
{ from: 'trestle', to: 'uncontrolledSiding' },
];
// ---------------------------------------------------------------------------
// The four new card categories
// ---------------------------------------------------------------------------
export type SimpleCard = {
key: string;
name: string;
copies: number;
placement: string;
effect: string;
/**
* The opponent-directed card this exists SOLELY to answer.
*
* A defence with nothing to defend against is a dead draw, exactly as the attack itself would be.
* The Space-use and Action cards are held out of every deck until they are implemented (Q6), and
* these go with them — named here rather than in a list somewhere else so the pairing is visible
* on the card, and so they come back together when their attacker does.
*/
answers?: string;
};
/** "Burns tablespace" — occupies a grid cell and does nothing useful. Semantics pending §10 Q6. */
export const SPACE_USE_CARDS: readonly SimpleCard[] = [
{ key: 'beanHouse', name: 'Bean house', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
{ key: 'flopHouse', name: 'Flop house', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' },
{ key: 'watertower', name: 'Watertower', copies: 1, placement: 'adjacent to any straight, turnout on Running Track', effect: 'Burns tablespace.' },
{ key: 'hoboJungle', name: 'Hobo Jungle', copies: 1, placement: 'adjacent to any straight, turnout, Limit on Running Track', effect: 'Burns tablespace. Vandalism can loot a boxcar passing it.' },
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26), the same treatment as the ladder.
{ key: 'sectionHouse', name: 'Section House', copies: 0, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' },
{ key: 'cityBlocks', name: 'City blocks', copies: 4, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
{ key: 'engineShops', name: 'Engine Shops', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' },
{ key: 'tenderloin', name: 'Tenderloin District', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
{ key: 'engineerCemetery', name: 'Engineer cemetery', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
];
export type EnhancementKey =
| 'interlocking' | 'facingPointLocks' | 'yardOffice' | 'smallYard'
| 'waterColumn' | 'overpass' | 'telegraph' | 'telephone' | 'radio' | 'absSignals';
/** Where an Enhancement may be laid. */
export type EnhancementPlacement =
| 'runningTrackStraight'
| 'secondaryTrackStraight'
| 'mainlineCard'
| 'onCard';
export type EnhancementRule = {
key: EnhancementKey;
placement: EnhancementPlacement;
/** Must sit on a card already carrying this enhancement (Telephone on Telegraph, etc.). */
requiresOnSameCard?: EnhancementKey;
/** Must exist somewhere in the district (Facing Point Locks needs Interlocking). */
requiresInDistrict?: EnhancementKey;
/** Bonus added to an opposing train's number when resolving a meet, once a Day. */
dispatchBonus?: number;
/**
* WHETHER THE PRINTED EFFECT ACTUALLY DOES ANYTHING, so the card can say so.
*
* - `live` — resolves in a solitaire game.
* - `dormantSolo` — implemented and read at the point of attack, but the attack is an
* opponent-directed card that a solitaire deck does not contain (Q6).
* - `unbuilt` — nothing reads it at all. The effect is recorded here and not yet written.
*
* Most are live. Each row below cites the file that reads it — the row is the answer, and no
* tally is kept here, because a tally is one more thing to forget when a card is implemented.
*
* The first attempt at this table got FIVE of the ten wrong: it was filled in by grepping for four
* helper function names and reading "no match" as "no implementation", when Interlocking, Yard
* Office, Small Yard and ABS Signals are all read directly by key — and all four are covered by
* tests in `enhancements.test.ts` that were passing the whole time. The result was a tooltip
* telling players that four working cards did nothing, which is worse than the bare label it
* replaced.
*
* KEEP THIS HONEST, AND CHECK THE CITATION. Implementing one of these means changing its value in
* the same commit; otherwise the card goes on apologising for something it now does. It is data
* rather than something derived because "is this key read anywhere" is not a question the type
* system can answer — but a claim here without a reference beside it is a claim nobody checked.
*
* CITE THE FUNCTION, NEVER THE LINE NUMBER. Every one of these rows once carried a `file.ts:NNN`
* and every one of them had rotted — `apply.ts:405` for Small Yard was pointing four hundred lines
* short by the time anyone looked. A line number is a citation that decays silently on the next
* edit anywhere above it, which is the opposite of what this note is for.
*/
effect: 'live' | 'dormantSolo' | 'unbuilt';
};
export const ENHANCEMENT_RULES: readonly EnhancementRule[] = [
// Holds an arrival at the Limits instead of colliding when the Office is full — `arriveAtOffice`.
{ key: 'interlocking', placement: 'runningTrackStraight', effect: 'live' },
// Wired in `isProtectedFromDerail`, but it answers Derail, an Action card the solo deck omits (Q6).
{ key: 'facingPointLocks', placement: 'onCard', requiresInDistrict: 'interlocking', effect: 'dormantSolo' },
// Diverts a coachless arrival away from the Train Order Office — `arriveAtOffice`.
{ key: 'yardOffice', placement: 'secondaryTrackStraight', effect: 'live' },
// Lets a consist be re-ordered for one Move — `check`'s `switch.sortConsist` case.
{ key: 'smallYard', placement: 'secondaryTrackStraight', effect: 'live' },
// Wired in `watertowersRemovable`, but it removes a Watertower, a Space-use card the solo deck omits.
{ key: 'waterColumn', placement: 'runningTrackStraight', effect: 'dormantSolo' },
// The only one with NO code path at all: nothing anywhere reads `overpass`.
{ key: 'overpass', placement: 'onCard', effect: 'unbuilt' },
{ key: 'telegraph', placement: 'runningTrackStraight', dispatchBonus: 4, effect: 'live' },
{ key: 'telephone', placement: 'onCard', requiresOnSameCard: 'telegraph', dispatchBonus: 8, effect: 'live' },
{ key: 'radio', placement: 'onCard', requiresOnSameCard: 'telephone', dispatchBonus: 12, effect: 'live' },
// Stored on the Mainline node rather than in `enhancements[]` — written by `reduce`'s
// `enhancementPlaced`, read in `moveTrain` (no rear-ending) and `evaluateClearance` (the follower
// holds instead of being ruled on).
{ key: 'absSignals', placement: 'mainlineCard', effect: 'live' },
];
/**
* What an Enhancement's card does, and whether it does it yet — one line, ready for a tooltip.
*
* Reported from playtesting: an Interlocking on the board is a bare label with no hover text at all.
* Saying only the printed effect would be worse than silence for the four that are `unbuilt` — a
* player who builds one to hold a train at the Limit watches it not happen with no way to tell a
* misread card from a bug. Same discipline as `checkPlay`'s NOT_IMPLEMENTED: never let a card look
* like it is doing something it is not.
*/
export function enhancementText(key: string): string | null {
const card = ENHANCEMENT_CARDS.find((c) => c.key === key);
if (!card) return null;
const rule = enhancementRule(key);
const note =
rule?.effect === 'unbuilt'
? ' — NOT YET IMPLEMENTED: this card has no effect in play.'
: rule?.effect === 'dormantSolo'
? ' — it answers an opponent-directed card, which a solitaire deck does not contain, so it never fires in this game.'
: '';
return `${card.name}: ${card.effect}${note}`;
}
export function enhancementRule(key: string): EnhancementRule | null {
return ENHANCEMENT_RULES.find((r) => r.key === key) ?? null;
}
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
{ key: 'interlocking', name: 'Interlocking', copies: 1, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26). It answers Derail, which is itself an
// Event held out until built, so at zero it defends against nothing that can be dealt anyway.
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 0, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.', answers: 'Derail' },
{ key: 'yardOffice', name: 'Yard office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
{ key: 'smallYard', name: 'Small yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
{ key: 'waterColumn', name: 'Water column', copies: 1, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
{ key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.', answers: 'Railroad crossing' },
/**
* THE DISPATCHING LADDER IS OUT OF THE DECK, at 0 copies rather than deleted — the treatment
* Poling and the sharp curves already get, and for the same reason.
*
* `docs/Deck cards5.xlsx` does not list Telegraph, Telephone or Radio at any count, and **Jesse
* confirmed (2026-08-26) that the removal is deliberate, not a row that failed to carry across**
* from sheet 2. So no copy is dealt, which is what the sheet asks for.
*
* The rows and `ENHANCEMENT_RULES`' `dispatchBonus` chain stay exactly where they are. The rule
* is implemented and tested — `advance.ts` reads the ladder when the Superintendent dispatches
* facing trains, best device first — and deleting working machinery to express a count of zero
* would throw away the only record of how it worked. At zero copies the code is unreachable: no
* card is ever dealt, so nothing ever places one, so the bonus never applies.
*/
{ key: 'telegraph', name: 'Telegraph', copies: 0, placement: 'any Running Track Straight', effect: 'Once a day, when dispatching facing trains, add +4 to the other train’s number.' },
{ key: 'telephone', name: 'Telephone', copies: 0, placement: 'on Telegraph', effect: 'Once a day, add +8 to the other train’s number.' },
{ key: 'radio', name: 'Radio', copies: 0, placement: 'on Telephone', effect: 'Once a day, add +12 to the other train’s number.' },
{ key: 'absSignals', name: 'ABS Signals', copies: 1, placement: 'any Mainline card', effect: 'Trains on this card will not rear-end each other; they stop short of a collision.' },
];
export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [
{ key: 'brakeman', name: 'Brakeman', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage downhill.' },
{ key: 'airbrakes', name: 'Airbrakes', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage downhill. Brakeman must be in effect.' },
{ key: 'helpers', name: 'Helpers', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage uphill.' },
{ key: 'realignment', name: 'Realignment', copies: 2, placement: 'a Mainline card', effect: 'Convert one Mainline type to another. Not while a train is on it.' },
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26); see the Enhancement of the same name.
{ key: 'facingPointLocksMainline', name: 'Facing Point Locks', copies: 0, placement: 'adjacent to Interlocking', effect: 'Prevents Derail being played on you.', answers: 'Derail' },
];
/**
* Q9 — the Second Section card. Played on a train that is DUE OUT; a second identical train runs
* immediately behind it, needing its own Crew Tray. It deliberately creates the following-train
* situation §8.1 makes the Superintendent rule on. Supersedes the "Sister trains" optional rule.
*/
export const SECOND_SECTION = { key: 'secondSection', name: 'Second Section', copies: 1 };
export const MANEUVER_CARDS: readonly SimpleCard[] = [
{ key: 'redFlags', name: 'Red Flags', copies: 3, placement: 'any time', effect: 'A stopped train is prevented from being hit; the approaching train is prevented from moving.' },
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26). The reducer stays; nothing can reach it.
{ key: 'flyingSwitch', name: 'Flying Switch', copies: 0, placement: 'any time', effect: 'Break a cut of cars away from behind the engine and roll them into an industry.' },
// POLING IS OUT OF THE DECK, at 0 copies rather than deleted.
//
// It is the one card whose effect the source records as "TBD", so there is nothing to implement
// and inventing something would be worse than leaving it out. Kept in the table with its text so
// the gap stays visible and the card can be dealt again the moment its rule is known.
{ key: 'poling', name: 'Poling', copies: 0, placement: 'any time', effect: 'TBD in the source.' },
];
/** Played AT other players. We have no player-interaction mechanic yet. */
export const ACTION_CARDS: readonly SimpleCard[] = [
{ key: 'derail', name: 'Derail', copies: 2, placement: 'a moving train in the Local Phase', effect: 'That train must stop for the remainder of the turn.' },
{ key: 'brokenCoupler', name: 'Broken coupler', copies: 1, placement: 'a moving train in the Mainline Phase', effect: 'That train must stop and not move.' },
{ key: 'railroadCrossing', name: 'Railroad crossing', copies: 1, placement: 'any Secondary Track Straight', effect: 'May not be used as a stop point for switching. May not become an Industry.' },
{ key: 'perDiemInventory', name: 'Per Diem inventory', copies: 1, placement: 'another player', effect: 'Lose one point per 2 empty cars on Secondary Tracks.' },
{ key: 'demurrageCharge', name: 'Demurrage charge', copies: 1, placement: 'another player', effect: 'Lose one point per 2 loaded freight cars on Secondary Tracks.' },
{ key: 'customerComplaints', name: 'Customer complaints', copies: 1, placement: 'another player', effect: 'Lose one point per 2 coaches in loading boxes.' },
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26).
{ key: 'vandalism', name: 'Vandalism', copies: 0, placement: 'another player', effect: 'A train passing a Hobo Jungle has a boxcar looted (converted to empty).' },
{ key: 'hotbox', name: 'Hotbox', copies: 1, placement: 'another player', effect: 'A train just arrived must set one car (chooser’s pick) onto Secondary Track until it departs.' },
{ key: 'outlawed', name: 'Outlawed', copies: 1, placement: 'another player', effect: 'A train just arrived may not depart for one turn — the crew’s hours have expired.' },
];
// ---------------------------------------------------------------------------
// Rolling stock, scale, and victory
// ---------------------------------------------------------------------------
export type StockSupply = { type: CarType; loaded: number; empty: number };
/**
* NOT in the recovered files — still the provisional figure.
*
* Scaled up alongside the Gap 12 industry increase. The rule it was set by, rather than the numbers
* it produced: worst-case demand for a car type is every copy of every industry that uses it, in
* play at full capacity, and the supply must cover that — a Division Yard that runs dry starves the
* freight loop the increase exists to feed. Lockouts and district size mean the worst case cannot
* actually occur, so this carries deliberate headroom. RE-DERIVE IT FROM `INDUSTRY_PROFILES`
* whenever industry copies move; a figure written here would not survive the next retune.
*
* Coaches are the type this rule does NOT cover, because no industry asks for one — their demand
* comes from passenger work, and Gitea#2 is the open report that the supply is short (see TODO.md).
*/
export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [
{ type: 'coach', loaded: 8, empty: 8 },
{ type: 'boxcar', loaded: 10, empty: 10 },
{ type: 'hopper', loaded: 8, empty: 8 },
{ type: 'reefer', loaded: 5, empty: 5 },
{ type: 'tank', loaded: 6, empty: 6 },
{ type: 'caboose', loaded: 6, empty: 0 },
];
export const TOTAL_ROLLING_STOCK = ROLLING_STOCK_SUPPLY.reduce((n, s) => n + s.loaded + s.empty, 0);
export function mainlineCardCount(players: number): number {
return players + 1;
}
/**
* Sourced, not provisional: `rules-v0.2.md`:339, "There are only a limited number of Crew Trays
* and engine pieces [Gap 4b: player count + 3]." The rules tie Crew Trays and engine pieces
* together as ONE combined resource, not two independently-tracked supplies — an engine is never
* conjured separately from the tray it rides in, and the game has no state for "an engine with no
* tray" or "a tray with no engine". `state.ts`'s `freeTrays` pool already IS the engine supply.
*/
export function crewTrayCount(players: number): number {
return players + 3;
}
export const STAGES_PER_DAY = 12;
export const STAGES_PER_SHIFT = 3;
export const HAND_LIMIT = 3;
/**
* The split opening deal — track cards and others, from two separately shuffled piles (`setup.ts`).
* One of the three `StartingHand` options below, not the only one any more.
*
* Deliberately over the hand limit: the first turn is spent choosing which district you can afford
* to build. The two constants below are the deal.
*/
export const OPENING_TRACK = 3;
export const OPENING_OTHER = 3;
// ---------------------------------------------------------------------------
// House rules — the settings the New Game dialog offers
// ---------------------------------------------------------------------------
/**
* WHAT EACH PLAYER OPENS HOLDING.
*
* Three answers, all of which have been the rule at some point, and none of which is obviously
* right — so the game stops guessing and asks whoever deals.
*
* - `threeRandom` — the prototype rule. Three cards off one deck, at the hand limit, no guarantees.
* - `sixRandom` — six off one deck, so the first turn is a discard and the opening is a choice,
* without the track being handed to you.
* - `threeTrackThreeOther` — three of each from separately shuffled piles. Introduced because a
* run-around needs five specific pieces and the bot held a turnout and a matching-hand curve
* together on 0.2% of turns; measured five ways, that was a SUPPLY problem, not a bot weakness.
*/
export type StartingHand = 'threeRandom' | 'sixRandom' | 'threeTrackThreeOther';
/** How many cards come off which pile, per `StartingHand`. `any` is dealt from the single deck. */
export const OPENING_DEALS: Readonly<Record<StartingHand, { any: number; track: number; other: number }>> = {
threeRandom: { any: 3, track: 0, other: 0 },
sixRandom: { any: 6, track: 0, other: 0 },
threeTrackThreeOther: { any: 0, track: OPENING_TRACK, other: OPENING_OTHER },
};
/**
* WHAT THE THREE WORKING ECONOMIES PAY.
*
* Balance is the open problem in this game, and the way to settle it is to play it at several
* settings rather than to keep re-deriving it. So the three rates are dials, set when the game is
* dealt and fixed for its duration.
*
* DO NOT WRITE A CURRENT BALANCE FIGURE HERE. This said "the developer bot averages 7.0 Revenue
* against a target of 20" long after that stopped being true: 7.0 was measured while
* `trainPerTransit` still defaulted to 1, and the paragraph below explains that this very setting
* was then defaulted to 0 for being worth ~5.4 of it. Re-measured 2026-08-22 over 400 games at the
* current defaults, the developer bot means about ZERO. Run `node src/sim/harness.ts 400 standard`
* for today's number rather than trusting one written here.
*
* `passengerPerCoach` and `freightPerLoad` each pay on BOTH halves of their cycle: a coach pays when
* it is boarded and again when it is detrained, a load pays when it is made up outbound and again
* when it is broken inbound. That is what the rates have always done; these scale it.
*
* `trainPerTransit` pays every player, once, when a train runs off the end of the Division — it is
* the shared achievement, and every Office it crossed had to clear it. It defaults to 0 because at 1
* it was worth ~5.4 of a 7.0 mean: the railroad was earning most of its money from traffic no one
* had to work, which drowned out the freight and passenger economies this game is actually about.
*/
export type RevenueRules = {
passengerPerCoach: number;
freightPerLoad: number;
trainPerTransit: number;
};
/**
* WHERE AN EXTRA MAY BE STARTED — a setting, because the table disagrees about it.
*
* §7 gives an Extra "either Division Point", and Jesse's ruling adds the Interchange to that base
* set: both are places on the shared Division that belong to nobody, so neither favours a seat.
* Starting one inside a player's own district is the part that does, which is what this dials.
*
* - `divisionPointsOnly` — the Division Points and the Interchange. No Office start at all.
* - `ownOffice` — those, plus a Control Point in the district of the player who played the card.
* - `anyOffice` — those, plus a Control Point in ANY player's district.
*
* A Whistle Post never qualifies however this is set: an Office has to be a Control Point to start
* an Extra, which is part of what upgrading buys (§11).
*/
export type ExtraStartRule = 'divisionPointsOnly' | 'ownOffice' | 'anyOffice';
export type HouseRules = {
startingHand: StartingHand;
revenue: RevenueRules;
extraStart: ExtraStartRule;
/**
* §6.2 — MAY A TIMETABLED TRAIN BE THROWN AWAY? (Gitea#9, superseding Gitea#6.)
*
* Jesse: "Timetabled trains are at the choice of the player — they can either play or discard. If
* someone else wants to pick it up, they are more than able to. The reason: I don't want, if you
* decide to play a game longer than five days, to decide that maybe there are too many trains, the
* stations are jammed, and the railroad doesn't need any more. You can toss it. Someone else might
* disagree and pick it up."
*
* A setting rather than a flat rule because Jesse asked for it as one — the reasoning above is
* about LONG games, and a five-Day game may well want the pressure Gitea#6 created. Discarding
* puts the card face-up on a Department pile, so "someone else might pick it up" needs no
* machinery of its own: that is where every discard already goes.
*
* AN EXTRA IS NEVER DISCARDED WHATEVER THIS SAYS. Gitea#9 is about the timetable filling up, and
* an Extra never joins it — it runs once and ends in the Salvage Yard, so it cannot jam anything.
*/
discardTimetabled: boolean;
};
/** What a caller may name — any subset, down to none — resolved by `houseRules()`. */
export type HouseRuleOverrides = {
startingHand?: StartingHand;
revenue?: Partial<RevenueRules>;
extraStart?: ExtraStartRule;
discardTimetabled?: boolean;
};
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
export const REVENUE_MIN = 0;
export const REVENUE_MAX = 5;
export const DEFAULT_HOUSE_RULES: HouseRules = {
startingHand: 'threeRandom',
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 0 },
// `anyOffice` is what the engine did before the setting existed, so a game dealt without naming
// it plays the way it always has. Jesse's call, so the 0.4.9 playtest line does not change under
// its testers in the middle of a bugfix release.
extraStart: 'anyOffice',
// Gitea#9's ruling is the default, so a game dealt without naming it plays the rule Jesse most
// recently gave rather than the one it replaced. This keeps main and the 0.4.9 playtest line —
// which has no setting and simply allows it — playing the same game.
discardTimetabled: true,
};
/**
* THE RULES A SAVE THAT PREDATES THIS SETTING WAS PLAYED UNDER.
*
* A save is a seed and a list of intents, so it only replays under the ruleset that produced it —
* `TODO.md` records two published replays going dead unnoticed when the rules moved, one of them 42
* intents into 360. Every save written from now on carries its rules; the ones already written do
* not, and this is what they meant. Do not "tidy" it into the defaults above: that silently kills
* the three replays in `public/replays/`.
*/
export const LEGACY_HOUSE_RULES: HouseRules = {
startingHand: 'threeTrackThreeOther',
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 1 },
// An Extra could always be started at a Control Point in these games, in any district.
extraStart: 'anyOffice',
// These games predate Gitea#6 as well as Gitea#9: a train card could simply be discarded. `true`
// is what they were played under, and a replay that discards a Timetabled train needs it.
discardTimetabled: true,
};
/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRules {
const given = config.houseRules ?? {};
const rev = given.revenue ?? {};
const clamp = (n: number | undefined, fallback: number): number =>
typeof n === 'number' && Number.isFinite(n)
? Math.max(REVENUE_MIN, Math.min(REVENUE_MAX, Math.round(n)))
: fallback;
const d = DEFAULT_HOUSE_RULES;
return {
startingHand: given.startingHand ?? d.startingHand,
revenue: {
passengerPerCoach: clamp(rev.passengerPerCoach, d.revenue.passengerPerCoach),
freightPerLoad: clamp(rev.freightPerLoad, d.revenue.freightPerLoad),
trainPerTransit: clamp(rev.trainPerTransit, d.revenue.trainPerTransit),
},
extraStart: given.extraStart ?? d.extraStart,
discardTimetabled: given.discardTimetabled ?? d.discardTimetabled,
};
}
/** What the dialog calls each option, in the order it offers them. */
export const STARTING_HAND_LABELS: readonly { value: StartingHand; label: string }[] = [
{ value: 'threeRandom', label: 'Three random cards' },
{ value: 'sixRandom', label: 'Six random cards' },
{ value: 'threeTrackThreeOther', label: 'Three random track and three random non-track cards' },
];
/** What the dialog calls each `ExtraStartRule`, in the order it offers them. */
export const EXTRA_START_LABELS: readonly { value: ExtraStartRule; label: string }[] = [
{ value: 'divisionPointsOnly', label: 'Division Points and the Interchange only' },
{ value: 'ownOffice', label: 'Also the playing player\u2019s own Control Point' },
{ value: 'anyOffice', label: 'Also any player\u2019s Control Point' },
];
export const MAX_CONSIST = 4;
export const MOVES_PER_LOCAL_OPS = 6;
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
export const LABORER_ACTIONS_PER_LOAD = 4;
export const COLLISION_PENALTY = 5;
/** Suggested defaults for `GameConfig`'s collision floors — see `state.ts`'s `GameConfig` doc. */
export const DEFAULT_MAX_COLLISIONS_PER_DAY = 3;
export const DEFAULT_MAX_COLLISIONS_TOTAL = 5;
/** Suggested default for `GameConfig.days`, all modes. */
export const DEFAULT_DAYS = 5;
/**
* Q3 — an expedited train left parked off the Office when a Mainline Phase begins is a Station
* Master fault: the train was not kept ready to highball the moment the Subdivision allowed it.
* Charged every Phase it is caught there, not just once, since the fault is being left that way.
*/
export const EXPEDITE_FAULT_PENALTY = 1;
/**
* The suggested default for `GameConfig.minCombinedRevenue` — a caller computes this before
* submitting a config; the engine itself never calls it (`state.ts`'s `GameConfig` doc explains why).
*/
export function collectiveRevenueFloor(players: number, days: number): number {
return 3 * players * days;
}
/**
* `GameLength` IS NOT PART OF `GameConfig` ANY MORE (2026-08-20) — `days` is a free variable there
* now, replacing the old `target`-bearing preset. This survives only as a convenience for sim/CLI
* tooling (`harness.ts`, `compare.ts`, `replay.ts`) that still wants a `short`/`standard`/`campaign`
* argument instead of a raw day count; `lengthProfile()` just resolves that argument to `days`.
*/
export type GameLength = 'short' | 'standard' | 'campaign';
export type LengthProfile = { length: GameLength; days: number };
export const LENGTH_PROFILES: readonly LengthProfile[] = [
{ length: 'short', days: 3 },
{ length: 'standard', days: 5 },
{ length: 'campaign', days: 10 },
];
export function lengthProfile(length: GameLength): LengthProfile {
const found = LENGTH_PROFILES.find((p) => p.length === length);
if (!found) throw new Error(`unknown game length: ${length}`);
return found;
}
// ---------------------------------------------------------------------------
// Deck composition
// ---------------------------------------------------------------------------
/**
* Cards that can only be played AT another player (Q6). In a solitaire game they have no legal
* target, so they are removed from the deck rather than sitting in hand as dead draws — which, when
* this was written, was very nearly a fifth of the deck.
*/
export const OPPONENT_ONLY_CATEGORIES: readonly string[] = ['spaceUse', 'action'];
export function isOpponentOnly(category: string): boolean {
return OPPONENT_ONLY_CATEGORIES.includes(category);
}
/**
* How many cards `DEFENCE_ONLY_CARDS` accounts for. Summed from the rows rather than written down:
* which cards qualify is `SimpleCard.answers`, and the total moves whenever one is added.
*/
export const DEFENCE_ONLY_COPIES =
ENHANCEMENT_CARDS.filter((c) => c.answers).reduce((n, c) => n + c.copies, 0) +
MAINLINE_MODIFIER_CARDS.filter((c) => c.answers).reduce((n, c) => n + c.copies, 0);
export function deckComposition(): { category: string; count: number }[] {
const sum = (xs: readonly { copies: number }[]): number => xs.reduce((n, x) => n + x.copies, 0);
return [
{ category: 'track', count: TRACK_IN_DECK },
{ category: 'office', count: OFFICE_PROFILES.reduce((n, o) => n + o.copiesInDeck, 0) },
{ category: 'industry', count: sum(INDUSTRY_PROFILES) },
{ category: 'modifier', count: sum(MODIFIER_PROFILES) },
{ category: 'train', count: ALL_TRAINS.length },
{ category: 'spaceUse', count: sum(SPACE_USE_CARDS) },
{ category: 'enhancement', count: sum(ENHANCEMENT_CARDS) },
{ category: 'mainlineModifier', count: sum(MAINLINE_MODIFIER_CARDS) },
{ category: 'maneuver', count: sum(MANEUVER_CARDS) },
{ category: 'action', count: sum(ACTION_CARDS) },
];
}
/**
* The whole CATALOGUE, including cards not currently dealt. Not the size of any deck in play — see
* `SOLITAIRE_DECK_SIZE` below, which is what `buildDeck` actually deals. (This pointed at a
* `DEALT_DECK_SIZE` that has never existed.)
*/
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0);
/**
* The deck actually dealt, in every mode: the catalogue less the opponent-directed cards, less the
* defensive cards that exist only to answer them.
*
* Named for solitaire because Q6 dropped them there first, and kept under that name because the
* number is the same either way. They are out of the competitive deck too until they are
* implemented — `checkPlay` answers both categories NOT_IMPLEMENTED, so dealing them would make a
* meaningful share of draws reject outright. See `buildDeck`.
*/
export const SOLITAIRE_DECK_SIZE =
deckComposition()
.filter((c) => !isOpponentOnly(c.category))
.reduce((n, c) => n + c.count, 0) - DEFENCE_ONLY_COPIES;
/**
* Every card held back BECAUSE its attacker is held back — see `SimpleCard.answers`.
*
* Reported from play: "just like the opponent directed cards are removed from the solitaire game,
* remove any of the defensive cards whose only purpose is to answer them. No need to have them in
* the deck when they can never be used."
*/
export const DEFENCE_ONLY_CARDS: readonly SimpleCard[] = [
...ENHANCEMENT_CARDS.filter((c) => c.answers),
...MAINLINE_MODIFIER_CARDS.filter((c) => c.answers),
];