/** * 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. * `home-deck.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". `home-deck.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/home-deck.md` and `docs/mainline-deck.md` carry what * the cards say — GENERATED from the constants below by `scripts/build-card-reference.ts`, with * `test/card-reference.test.ts` failing if the two disagree. This comment used to name * `card-reference.md`, which describes the v0.4.5 deck and carries a banner saying not to use its * numbers; the code sent readers to a table it had itself superseded. */ ...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 only. The card prints "Sort cars in new order" — **and that is not what this flag * does**, which is why it is worth spelling out where the field is declared. * * The printed sorting has never been implemented: nothing reads this to permit a sort, and a * consist is re-ordered at a Small Yard in a district (`switch.sortConsist`). What this actually * marks is the one Mainline card with a Yard Limit, and therefore the one an Extra may be made up * and started on (`apply.ts` § resolveExtraStart, `legal.ts`). * * Named for the printed text, and kept that way deliberately — renaming it would lose the link to * the card face — but the name has already misled once: `mainlineDescription` grew a sentence * telling players cars could be sorted here, which reached the board and the generated card * reference before it was caught on 2026-09-20. */ 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/mainline-deck.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): 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 { /** * NOT A WORD ABOUT SPEED HERE — Jesse, playtest 2026-09-16. * * This read "the printed speed is scenery", which sent a player hunting the card for a number * that is not drawn on it. The first rewrite said "fast or slow alike", which is true but raises * the question on thirteen cards in order to answer it. **Exactly one card reads FAST/SLOW**: * Hilly, the only profile with `speedStarts` (see the note above it). So the explanation belongs * on that card, where the branch above already gives it, and everywhere else says nothing — * silence is the honest answer when the rating genuinely does not apply. */ parts.push(`${stages(run({}))} for every train.`); } 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.'); } /** * WHAT THE INTERCHANGE ACTUALLY DOES, which is not what it prints. * * This said "Cars may be sorted into any new order here." — the printed capability, shown to * players on the board (`view.ts` renders this as a Mainline card's `what`) and printed in the * generated card reference. It is not implemented and never has been: nothing reads `sortsCars` * to permit a sort. Its one live use is identifying the card an Extra may be made up on, because * the Interchange is the Mainline card with a yard (`apply.ts` § resolveExtraStart). * * Found while bringing the reference documents up to date, 2026-09-20. A card that advertises an * action the game will not offer is worse than one that says nothing — a player goes looking for * a button that does not exist and concludes the game is broken. */ if (p.sortsCars) { parts.push( 'This is the one Mainline card with a yard, so an Extra Train may be made up and started here. ' + 'Its printed car-sorting is NOT implemented — a consist is re-ordered at a Small Yard in a district.', ); } 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; } /** * THE NAMES HERE ARE WHAT A PLAYER READS, since v0.8.0.17 — `cardName` takes them in preference to * `prettyKey`'s guess, which cannot recover an acronym (it rendered `absSignals` as "Abs Signals" * beside a tooltip saying ABS). * * So their CASE matters, and three were transcribed in sentence case while the rules, the panels * and every other line of this repository treat them as proper terms: "Yard Office" outnumbered * "Yard office" 36 to 2, "Small Yard" 36 to 2, "Water Column" 10 to 2. Corrected here rather than * special-cased in `cardName`, because this table is the source and a lookup that second-guesses * its own source is the drift it exists to prevent. */ 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> = { 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; /** Which Office every player opens on — see `StartingOffice`. */ startingOffice: StartingOffice; /** * WHETHER THE SECOND SECTION CARD IS IN THE DECK (Q9, one copy). * * Not a table's choice — there is no dial for it — but a fact about how the game was DEALT, kept * here for the same reason `startingOffice` is: the deal has to be replayable. The card went into * the deck in v0.8.2, after that release's save check had been run, and a deck one card larger * shuffles into a different order from the same seed — so every save on the test server refused * at move 3 under 0.8.2 while its release notes said three would resume. `withSavedDeal` sets * this false for a save that predates the card; everything dealt since carries it as true. */ secondSectionCard: boolean; /** * §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; }; /** * Which Office every player starts on. * * `depot` is the default: a Depot is a Passenger Facility with two A/D tracks, so passengers work * from the first Stage and a second train can stand at an Office. `whistlePost` is the harder game * — one A/D track, no passenger work at all until somebody draws and plays an upgrade. */ export type StartingOffice = 'depot' | 'whistlePost'; /** What a caller may name — any subset, down to none — resolved by `houseRules()`. */ export type HouseRuleOverrides = { startingHand?: StartingHand; revenue?: Partial; extraStart?: ExtraStartRule; discardTimetabled?: boolean; startingOffice?: StartingOffice; secondSectionCard?: 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, /** * EVERYBODY STARTS ON A DEPOT (Jesse, 2026-09-23). A Whistle Post has one A/D track and is not a * Passenger Facility, so the opening of every game was spent unable to work a passenger and * unable to hold a second train — and a single A/D track is what makes an arriving train a * collision. Starting on a Depot makes the game markedly easier; `whistlePost` is offered as the * harder setting for a table that wants it. */ startingOffice: 'depot', secondSectionCard: 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, // Every game before 2026-09-23 opened on a Whistle Post, from a deck with no Second Section card. startingOffice: 'whistlePost', secondSectionCard: false, }; /** 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, startingOffice: given.startingOffice ?? d.startingOffice, secondSectionCard: given.secondSectionCard ?? d.secondSectionCard, }; } /** * THE DEAL A SAVE THAT PREDATES THE 0.8.2 SETTINGS WAS DEALT UNDER. * * Two of the house rules change how a game is DEALT rather than how it plays, and getting either * wrong does not stop a replay part-way where it can be seen — it deals a different railroad from * intent one, silently. `startingOffice` is a different Office and a deck with four more cards in * it; `secondSectionCard` is a deck one card larger, which the same seed shuffles into a different * order. Every game saved before 2026-09-23 opened on a Whistle Post from a deck without the * Second Section card, and its `houseRules` cannot say so. * * So a saved config that names house rules but not `startingOffice` — the settings form has named * it on every config written since the setting existed — gets both of what it was played under. * A config with no house rules at all is a fresh game, not an old save, and is left alone; so is * one that names the opening, in either direction. * * APPLIED ON THE REPLAY PATHS ONLY (`configFor` in `web/game.ts`, `tryResumeSession` in * `server/session.ts`), never in `houseRules()` above: putting the legacy default in the resolver * made a fresh game deal the old railroad and read as "Custom" in the lobby. */ export function withSavedDeal(config: T): T { const given = config.houseRules; if (!given || given.startingOffice !== undefined) return config; return { ...config, houseRules: { ...given, startingOffice: 'whistlePost', secondSectionCard: given.secondSectionCard ?? false }, }; } /** 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) }, // Q9 — dealt since 2026-09-23. It was declared here and left out of `buildDeck`, which is what // made `newTrain.secondSection` a free action rather than one that costs a card. { category: 'secondSection', count: SECOND_SECTION.copies }, ]; } /** * 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), ];