Expand game engine, replay, tests, and documentation
This commit is contained in:
+416
-308
@@ -1,132 +1,89 @@
|
||||
/**
|
||||
* Component 1 — Card catalogue / static content.
|
||||
*
|
||||
* The printed values of every card. Source of truth: docs/rules/card-reference.md.
|
||||
* See architecture/components.md §2 A.1.
|
||||
* TRANSCRIBED FROM THE RECOVERED DESIGN FILES (2026-07-30):
|
||||
* docs/Deck cards2.xlsx — the complete card list and counts
|
||||
* docs/Trains3.pdf — all 22 train cards
|
||||
* docs/Mainline Cards.pdf — the ten Mainline card types
|
||||
* docs/tracks.png — card art
|
||||
*
|
||||
* This is DATA, not logic. Every number here is provisional and will be retuned repeatedly
|
||||
* against playtesting — keep tuning confined to this file so it never requires touching the
|
||||
* engine.
|
||||
* 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** §2.2. A coloured car is loaded; a white car is empty. */
|
||||
export type CarType = 'coach' | 'boxcar' | 'reefer' | 'hopper' | 'tank' | 'caboose';
|
||||
|
||||
export type Direction = 'east' | 'west';
|
||||
|
||||
/** §9 — some freight facilities load only, some unload only, some both. */
|
||||
/** §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'
|
||||
| 'produceShed'
|
||||
| 'grocersWarehouse'
|
||||
| 'oilRefinery'
|
||||
| 'powerPlant';
|
||||
| 'refinery'
|
||||
| 'powerPlant'
|
||||
| 'packingSheds'
|
||||
| 'grocersWarehouse';
|
||||
|
||||
export type ModifierKind =
|
||||
| 'teamTrack'
|
||||
| 'loadingDock'
|
||||
| 'storageShed'
|
||||
| 'extraPlatform'
|
||||
| 'sectionGang';
|
||||
|
||||
export type TrackGeometry = 'turnout' | 'straight' | 'runAround';
|
||||
export type TrackGeometry = 'straight' | 'curved' | 'sharpCurved' | 'turnout';
|
||||
export type Hand = 'left' | 'right' | 'none';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Freight facilities — card-reference.md §2
|
||||
// Track — a PER-PLAYER supply, not deck cards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FreightProfile = {
|
||||
kind: FreightKind;
|
||||
export type TrackProfile = {
|
||||
geometry: TrackGeometry;
|
||||
hand: Hand;
|
||||
name: string;
|
||||
carType: CarType;
|
||||
flow: FlowDirection;
|
||||
laborers: number;
|
||||
/** Green box capacity; 0 when the facility does not load. */
|
||||
outboundCapacity: number;
|
||||
/** Red box capacity; 0 when the facility does not unload. */
|
||||
inboundCapacity: number;
|
||||
/** Cars that fit on the industry track. Never above 4 (§9.3). */
|
||||
industryTrackLength: number;
|
||||
/** Copies in the deck. Identical pairs — Gap 10b. */
|
||||
copies: number;
|
||||
perPlayer: 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;
|
||||
};
|
||||
|
||||
export const FREIGHT_PROFILES: readonly FreightProfile[] = [
|
||||
{
|
||||
kind: 'mineTipple',
|
||||
name: 'Mine Tipple',
|
||||
carType: 'hopper',
|
||||
flow: 'outbound',
|
||||
laborers: 3,
|
||||
outboundCapacity: 3,
|
||||
inboundCapacity: 0,
|
||||
industryTrackLength: 4,
|
||||
copies: 2,
|
||||
},
|
||||
{
|
||||
kind: 'produceShed',
|
||||
name: 'Produce Shed',
|
||||
carType: 'reefer',
|
||||
flow: 'outbound',
|
||||
laborers: 2,
|
||||
outboundCapacity: 2,
|
||||
inboundCapacity: 0,
|
||||
industryTrackLength: 3,
|
||||
copies: 2,
|
||||
},
|
||||
{
|
||||
kind: 'grocersWarehouse',
|
||||
name: "Grocer's Warehouse",
|
||||
carType: 'boxcar',
|
||||
flow: 'both',
|
||||
laborers: 2,
|
||||
outboundCapacity: 2,
|
||||
inboundCapacity: 2,
|
||||
industryTrackLength: 3,
|
||||
copies: 2,
|
||||
},
|
||||
{
|
||||
kind: 'oilRefinery',
|
||||
name: 'Oil Refinery',
|
||||
carType: 'tank',
|
||||
flow: 'both',
|
||||
laborers: 3,
|
||||
outboundCapacity: 2,
|
||||
inboundCapacity: 2,
|
||||
industryTrackLength: 4,
|
||||
copies: 2,
|
||||
},
|
||||
{
|
||||
kind: 'powerPlant',
|
||||
name: 'Power Plant',
|
||||
carType: 'hopper',
|
||||
flow: 'inbound',
|
||||
laborers: 3,
|
||||
outboundCapacity: 0,
|
||||
inboundCapacity: 3,
|
||||
industryTrackLength: 4,
|
||||
copies: 2,
|
||||
},
|
||||
/**
|
||||
* 26 pieces per player, 104 for four players. Track is NOT in the Home Office deck — this is the
|
||||
* single biggest structural change from the placeholder, and it means the draw is entirely about
|
||||
* industry, trains and enhancements.
|
||||
*
|
||||
* Handedness is PRINTED, not chosen on placement. That supersedes Gap 11.
|
||||
*/
|
||||
export const TRACK_SUPPLY: readonly TrackProfile[] = [
|
||||
{ geometry: 'straight', hand: 'none', name: 'Straight track', perPlayer: 8, isOperationalRail: true, moveCost: 1 },
|
||||
{ geometry: 'curved', hand: 'left', name: 'Curved track (left)', perPlayer: 4, isOperationalRail: true, moveCost: 1 },
|
||||
{ geometry: 'curved', hand: 'right', name: 'Curved track (right)', perPlayer: 4, isOperationalRail: true, moveCost: 1 },
|
||||
{ geometry: 'sharpCurved', hand: 'left', name: 'Sharp Curved Track (left)', perPlayer: 1, isOperationalRail: true, moveCost: 2 },
|
||||
{ geometry: 'sharpCurved', hand: 'right', name: 'Sharp Curved Track (right)', perPlayer: 1, isOperationalRail: true, moveCost: 2 },
|
||||
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', perPlayer: 4, isOperationalRail: false, moveCost: 1 },
|
||||
{ geometry: 'turnout', hand: 'right', name: 'Turnout (right)', perPlayer: 4, isOperationalRail: false, moveCost: 1 },
|
||||
];
|
||||
|
||||
/**
|
||||
* "Freight House" (§9.3, Appendix A) is not a card — it is the collective term for a freight
|
||||
* facility permitting both directions. Gap 10d.
|
||||
*/
|
||||
export function isFreightHouse(profile: FreightProfile): boolean {
|
||||
return profile.flow === 'both';
|
||||
}
|
||||
export const TRACK_PER_PLAYER = TRACK_SUPPLY.reduce((n, t) => n + t.perPlayer, 0); // 26
|
||||
|
||||
/** Start cards, placed at setup and never shuffled: 4 Whistle Posts, 8 Limits. */
|
||||
export const WHISTLE_POST_SUPPLY = 4;
|
||||
export const LIMITS_SUPPLY = 8;
|
||||
|
||||
/** Legacy alias kept so existing call sites resolve; prefer TRACK_SUPPLY. */
|
||||
export const TRACK_PROFILES = TRACK_SUPPLY;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offices — card-reference.md §4
|
||||
// Offices — Depot 4, Station 2, Terminal 1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OfficeProfile = {
|
||||
@@ -136,65 +93,25 @@ export type OfficeProfile = {
|
||||
isPassengerFacility: boolean;
|
||||
adTracks: number;
|
||||
porters: number;
|
||||
greenSlots: number;
|
||||
redSlots: number;
|
||||
/** Copies in the deck. Whistle Posts are a fixed supply outside it (§12.2). */
|
||||
/** Green slots. The design gives slots EQUAL to porters, not one more. */
|
||||
passengerOut: number;
|
||||
/** Red slots. */
|
||||
passengerIn: number;
|
||||
copiesInDeck: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gap 8: all four tiers carry IDENTICAL track geometry — a through track plus a plain junction
|
||||
* stub above and below. They differ only in the three flags and the counts below, which is what
|
||||
* makes an upgrade a drop-in replacement that never disturbs a connection.
|
||||
* 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).
|
||||
*/
|
||||
export const OFFICE_PROFILES: readonly OfficeProfile[] = [
|
||||
{
|
||||
tier: 'whistlePost',
|
||||
name: 'Whistle Post',
|
||||
isControlPoint: false,
|
||||
isPassengerFacility: false,
|
||||
adTracks: 1,
|
||||
porters: 0,
|
||||
greenSlots: 0,
|
||||
redSlots: 0,
|
||||
copiesInDeck: 0,
|
||||
},
|
||||
{
|
||||
tier: 'depot',
|
||||
name: 'Depot',
|
||||
isControlPoint: true,
|
||||
isPassengerFacility: true,
|
||||
adTracks: 2,
|
||||
porters: 1,
|
||||
greenSlots: 2,
|
||||
redSlots: 2,
|
||||
copiesInDeck: 4,
|
||||
},
|
||||
{
|
||||
tier: 'station',
|
||||
name: 'Station',
|
||||
isControlPoint: true,
|
||||
isPassengerFacility: true,
|
||||
adTracks: 3,
|
||||
porters: 2,
|
||||
greenSlots: 3,
|
||||
redSlots: 3,
|
||||
copiesInDeck: 3,
|
||||
},
|
||||
{
|
||||
tier: 'terminal',
|
||||
name: 'Terminal',
|
||||
isControlPoint: true,
|
||||
isPassengerFacility: true,
|
||||
adTracks: 4,
|
||||
porters: 3,
|
||||
greenSlots: 4,
|
||||
redSlots: 4,
|
||||
copiesInDeck: 2,
|
||||
},
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
/** Upgrade order is strict — no skipping (Gap 3b). */
|
||||
export const OFFICE_ORDER: readonly OfficeTier[] = ['whistlePost', 'depot', 'station', 'terminal'];
|
||||
|
||||
export function officeProfile(tier: OfficeTier): OfficeProfile {
|
||||
@@ -203,163 +120,371 @@ export function officeProfile(tier: OfficeTier): OfficeProfile {
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The next tier up, or null at Terminal. Strict sequence, no skipping. */
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modifiers — card-reference.md §5
|
||||
// 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;
|
||||
};
|
||||
|
||||
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 },
|
||||
{ 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 },
|
||||
{ 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 — the collective term for an industry that both loads and unloads. */
|
||||
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 — 17 industry + 6 passenger, 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;
|
||||
effect: string;
|
||||
appliesTo: 'freight' | 'passenger' | 'either';
|
||||
/** 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[] = [
|
||||
{
|
||||
kind: 'teamTrack',
|
||||
name: 'Team Track',
|
||||
effect: '+1 car on the industry track',
|
||||
appliesTo: 'freight',
|
||||
},
|
||||
{
|
||||
kind: 'loadingDock',
|
||||
name: 'Loading Dock',
|
||||
effect: '+1 green Outbound capacity',
|
||||
appliesTo: 'freight',
|
||||
},
|
||||
{
|
||||
kind: 'storageShed',
|
||||
name: 'Storage Shed',
|
||||
effect: '+1 red Inbound capacity',
|
||||
appliesTo: 'freight',
|
||||
},
|
||||
{
|
||||
kind: 'extraPlatform',
|
||||
name: 'Extra Platform',
|
||||
effect: '+1 green and +1 red slot',
|
||||
appliesTo: 'passenger',
|
||||
},
|
||||
{
|
||||
kind: 'sectionGang',
|
||||
name: 'Section Gang',
|
||||
effect: '+1 Laborer or +1 Porter',
|
||||
appliesTo: 'either',
|
||||
},
|
||||
// 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
|
||||
{ kind: 'truckDock', name: 'Truck dock', hosts: ['freightHouse', 'packingSheds', 'grocersWarehouse'], addOut: 1, addIn: 0, addLoaders: 1, 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 — card-reference.md §3
|
||||
// Trains — 22 cards, named, with speed class and individual 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 = {
|
||||
/** Revenue cars, excluding the caboose. */
|
||||
count: number;
|
||||
allowedTypes: readonly CarType[];
|
||||
requiresCaboose: boolean;
|
||||
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;
|
||||
/** The Second Section card copies the next scheduled train. */
|
||||
copiesNextScheduled?: boolean;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type TrainProfile = {
|
||||
/** 1..12. For an Extra this is the number following the "X" (§2.3). */
|
||||
number: number;
|
||||
isExtra: boolean;
|
||||
className: string;
|
||||
/** Extras are head-on: the player choosing determines direction (§2.3). */
|
||||
name: string;
|
||||
speed: TrainSpeed;
|
||||
direction: Direction | 'playerChoice';
|
||||
consist: ConsistSpec;
|
||||
rules: TrainRules;
|
||||
};
|
||||
|
||||
const ALL_CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank'];
|
||||
|
||||
/**
|
||||
* Odd numbers run westbound, even eastbound (§2.3). Pairs are sister trains under the optional
|
||||
* rule. Seniority runs passenger-first: low numbers are the varnish, high the drags and locals.
|
||||
*
|
||||
* NOTE the four-slot Crew Tray limit INCLUDES the caboose (§A.4), which is why no caboose train
|
||||
* carries more than three revenue cars.
|
||||
*/
|
||||
function timetabledPair(
|
||||
odd: number,
|
||||
className: string,
|
||||
consist: ConsistSpec,
|
||||
): readonly TrainProfile[] {
|
||||
/** 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, className, direction: 'west', consist },
|
||||
{ number: odd + 1, isExtra: false, className, direction: 'east', consist },
|
||||
{ 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[] = [
|
||||
...timetabledPair(1, 'Limited', {
|
||||
count: 4,
|
||||
allowedTypes: ['coach'],
|
||||
requiresCaboose: false,
|
||||
}),
|
||||
...timetabledPair(3, 'Mail-Express', {
|
||||
count: 3,
|
||||
allowedTypes: ['coach'],
|
||||
requiresCaboose: false,
|
||||
}),
|
||||
...timetabledPair(5, 'Manifest Freight', {
|
||||
count: 3,
|
||||
allowedTypes: ['boxcar', 'reefer'],
|
||||
requiresCaboose: true,
|
||||
}),
|
||||
...timetabledPair(7, 'Coal Drag', {
|
||||
count: 3,
|
||||
allowedTypes: ['hopper'],
|
||||
requiresCaboose: true,
|
||||
}),
|
||||
...timetabledPair(9, 'Oil Train', {
|
||||
count: 3,
|
||||
allowedTypes: ['tank'],
|
||||
requiresCaboose: true,
|
||||
}),
|
||||
...timetabledPair(11, 'Way Freight', {
|
||||
count: 3,
|
||||
allowedTypes: ALL_CAR_TYPES,
|
||||
requiresCaboose: true,
|
||||
}),
|
||||
...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 3, 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: 2, 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 }, {}),
|
||||
];
|
||||
|
||||
/** Gap 4a: Extras carry high numbers, i.e. low seniority. They yield to every scheduled train. */
|
||||
export const EXTRA_TRAINS: readonly TrainProfile[] = [9, 10, 11, 12].map((n) => ({
|
||||
number: n,
|
||||
isExtra: true,
|
||||
className: 'Extra',
|
||||
direction: 'playerChoice' as const,
|
||||
consist: { count: 3, allowedTypes: ALL_CAR_TYPES, requiresCaboose: true },
|
||||
}));
|
||||
/**
|
||||
* 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, note: 'One turn at station (speeches) then expedite.' } },
|
||||
{ number: 18, isExtra: true, name: 'Circus Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 1 }, rules: { noSwitching: true, stopEarnsPoint: true, note: 'One turn stopped on any track (circus set-up) earns 1 point.' } },
|
||||
{ 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 } },
|
||||
{ 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];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track — card-reference.md §6
|
||||
// Mainline cards — ten types, with speeds and named entry points
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TrackProfile = {
|
||||
geometry: TrackGeometry;
|
||||
export type MainlineKind =
|
||||
| 'plains' | 'curves' | 'hilly' | 'heavyGrade' | 'doubleTrack'
|
||||
| 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'yard';
|
||||
|
||||
/**
|
||||
* Speed as printed. `60` and `30` appear on the cards; Hilly prints P60/F30, and Heavy Grade
|
||||
* prints "G" with the player setting orientation.
|
||||
*
|
||||
* WHAT THESE NUMBERS MEAN IS NOT YET SETTLED — see implications.md §10 Q2. Transcribed as data so
|
||||
* the answer can be applied without re-reading the cards.
|
||||
*/
|
||||
export type MainlineSpeed =
|
||||
| { kind: 'uniform'; value: number }
|
||||
| { kind: 'byTrainType'; passenger: number; freight: number }
|
||||
| { kind: 'grade' };
|
||||
|
||||
export type MainlineProfile = {
|
||||
kind: MainlineKind;
|
||||
name: string;
|
||||
/** Turnouts carry no wheel icon: a train may pass through but not stop (§A.1). */
|
||||
isOperationalRail: boolean;
|
||||
copies: number;
|
||||
speed: MainlineSpeed;
|
||||
/** Double Track and Uncontrolled Siding: "Trains may pass". */
|
||||
trainsMayPass: boolean;
|
||||
/** Yard: "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 TRACK_PROFILES: readonly TrackProfile[] = [
|
||||
{ geometry: 'turnout', name: 'Turnout', isOperationalRail: false, copies: 6 },
|
||||
{ geometry: 'straight', name: 'Straight', isOperationalRail: true, copies: 3 },
|
||||
{ geometry: 'runAround', name: 'Run-around', isOperationalRail: true, copies: 3 },
|
||||
export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
|
||||
{ kind: 'plains', name: 'Plains', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'curves', name: 'Curves', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'hilly', name: 'Hilly', speed: { kind: 'byTrainType', passenger: 60, freight: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['passenger', 'freight'] },
|
||||
{ kind: 'heavyGrade', name: 'Heavy Grade', speed: { kind: 'grade' }, trainsMayPass: false, sortsCars: false, entryPoints: ['start', 'brakemen', 'airbrakes', 'helpers'] },
|
||||
{ kind: 'doubleTrack', name: 'Double Track', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'uncontrolledSiding', name: 'Uncontrolled Siding', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['noPass', 'passingTrains'] },
|
||||
{ kind: 'tunnel', name: 'Tunnel', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'trestle', name: 'Trestle', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'yard', name: 'Yard', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* How many Stages a train needs to cross a Mainline card.
|
||||
*
|
||||
* Q1 — the printed 60/30 are miles per hour expressed as crossing time: a 60 card takes one Stage,
|
||||
* a 30 card takes two. The cells drawn on the cards are decoration.
|
||||
* Q2 — a Slow train adds one Stage to every card.
|
||||
*
|
||||
* Hilly prints P60/F30, so it reads the consist rather than the speed class: a train carrying any
|
||||
* coach is "passenger" for this purpose.
|
||||
*/
|
||||
export function crossingStages(
|
||||
kind: MainlineKind,
|
||||
trainSpeed: TrainSpeed,
|
||||
carriesPassengers: boolean,
|
||||
): number {
|
||||
const profile = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
||||
if (!profile) throw new Error(`unknown mainline card: ${kind}`);
|
||||
|
||||
let mph: number;
|
||||
switch (profile.speed.kind) {
|
||||
case 'uniform':
|
||||
mph = profile.speed.value;
|
||||
break;
|
||||
case 'byTrainType':
|
||||
mph = carriesPassengers ? profile.speed.passenger : profile.speed.freight;
|
||||
break;
|
||||
case 'grade':
|
||||
// Heavy Grade has no printed number; the modifier cards (Brakeman/Airbrakes/Helpers) are what
|
||||
// improve it. Treated as a 30 until those are implemented.
|
||||
mph = 30;
|
||||
break;
|
||||
}
|
||||
|
||||
const base = mph >= 60 ? 1 : 2;
|
||||
return base + (trainSpeed === 'slow' ? 1 : 0);
|
||||
}
|
||||
|
||||
/** `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' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rolling stock supply — card-reference.md §8
|
||||
// The four new card categories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SimpleCard = { key: string; name: string; copies: number; placement: string; effect: 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.' },
|
||||
{ key: 'sectionHouse', name: 'Section House', copies: 1, 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 const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'interlocking', name: 'Interlocking', copies: 2, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
|
||||
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.' },
|
||||
{ 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: 2, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.' },
|
||||
{ key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.' },
|
||||
{ key: 'telegraph', name: 'Telegraph', copies: 3, 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: 2, placement: 'on Telegraph', effect: 'Once a day, add +8 to the other train’s number.' },
|
||||
{ key: 'radio', name: 'Radio', copies: 2, placement: 'on Telephone', effect: 'Once a day, add +12 to the other train’s number.' },
|
||||
{ key: 'absSignals', name: 'ABS Signals', copies: 2, 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.' },
|
||||
{ key: 'facingPointLocksMainline', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Prevents Derail being played on you.' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 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: 5, placement: 'any time', effect: 'A stopped train is prevented from being hit; the approaching train is prevented from moving.' },
|
||||
{ key: 'flyingSwitch', name: 'Flying Switch', copies: 1, placement: 'any time', effect: 'Break a cut of cars away from behind the engine and roll them into an industry.' },
|
||||
{ key: 'poling', name: 'Poling', copies: 1, 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.' },
|
||||
{ key: 'vandalism', name: 'Vandalism', copies: 1, 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. */
|
||||
export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [
|
||||
{ type: 'coach', loaded: 8, empty: 8 },
|
||||
{ type: 'boxcar', loaded: 6, empty: 6 },
|
||||
@@ -369,54 +494,36 @@ export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [
|
||||
{ type: 'caboose', loaded: 6, empty: 0 },
|
||||
];
|
||||
|
||||
export const TOTAL_ROLLING_STOCK = ROLLING_STOCK_SUPPLY.reduce(
|
||||
(n, s) => n + s.loaded + s.empty,
|
||||
0,
|
||||
);
|
||||
export const TOTAL_ROLLING_STOCK = ROLLING_STOCK_SUPPLY.reduce((n, s) => n + s.loaded + s.empty, 0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixed supplies and scale — card-reference.md §8, rules §12.2
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** §4.3 — one Mainline card between each adjacent pair, and one beyond each end. */
|
||||
export function mainlineCardCount(players: number): number {
|
||||
return players + 1;
|
||||
}
|
||||
|
||||
/** Gap 4b — scarcity is an explicit mechanic (§7). */
|
||||
/** Provisional; not in the recovered files. */
|
||||
export function crewTrayCount(players: number): number {
|
||||
return players + 3;
|
||||
}
|
||||
|
||||
/** Each Mainline card is divided into two regions (Gap 4b). */
|
||||
export const REGIONS_PER_MAINLINE_CARD = 2;
|
||||
|
||||
export const REGIONS_PER_MAINLINE_CARD = 2; // provisional, pending §10 Q2
|
||||
export const STAGES_PER_DAY = 12;
|
||||
|
||||
/** §5 — the Fedora passes every three Stages; shift changes at Stages 3, 6, 9, 12. */
|
||||
export const STAGES_PER_SHIFT = 3;
|
||||
|
||||
/** §6.2 — hand limit, before the optional Red Flag. */
|
||||
export const HAND_LIMIT = 3;
|
||||
|
||||
/** §A.4 — a Crew Tray may hold at most four Rolling Stock, cabooses included. */
|
||||
export const MAX_CONSIST = 4;
|
||||
|
||||
/** §6.1 — Moves per Local Operations Phase; five during night Stages under Reduced Visibility. */
|
||||
export const MOVES_PER_LOCAL_OPS = 6;
|
||||
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
|
||||
|
||||
/** §9.3 — Green -> MEN -> AT -> WORK -> car. */
|
||||
export const LABORER_ACTIONS_PER_LOAD = 4;
|
||||
export const COLLISION_PENALTY = 5;
|
||||
export const COLLISION_FLOOR_PER_DAY = 3;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Victory — rules §3.2, revised by Gap 10e
|
||||
// ---------------------------------------------------------------------------
|
||||
export function collectiveRevenueFloor(players: number, days: number): number {
|
||||
return 3 * players * days;
|
||||
}
|
||||
|
||||
export type GameLength = 'short' | 'standard' | 'campaign';
|
||||
|
||||
export type LengthProfile = { length: GameLength; target: number; days: number };
|
||||
|
||||
/** Provisional and now unvalidated — the balance run they came from used a placeholder ruleset. */
|
||||
export const LENGTH_PROFILES: readonly LengthProfile[] = [
|
||||
{ length: 'short', target: 10, days: 3 },
|
||||
{ length: 'standard', target: 20, days: 5 },
|
||||
@@ -429,37 +536,38 @@ export function lengthProfile(length: GameLength): LengthProfile {
|
||||
return found;
|
||||
}
|
||||
|
||||
/** §10 — a collision costs the party at fault 5 Revenue. */
|
||||
export const COLLISION_PENALTY = 5;
|
||||
|
||||
/** §3.4 — Competitive only: three collisions in one Day and everyone loses. */
|
||||
export const COLLISION_FLOOR_PER_DAY = 3;
|
||||
|
||||
/** §3.5 — Competitive timed games: all players' Revenue combined must reach this. */
|
||||
export function collectiveRevenueFloor(players: number, days: number): number {
|
||||
return 3 * players * days;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deck composition — card-reference.md §1
|
||||
// Deck composition — 115 cards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DECK_SIZE = 52;
|
||||
|
||||
/**
|
||||
* The Home Office deck is a SINGLE deck containing every card type (§2.6, Gap 4a). The three
|
||||
* Department slots are face-up market slots fed from it, not decks with their own contents.
|
||||
* 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 19% dead draws.
|
||||
*/
|
||||
export const OPPONENT_ONLY_CATEGORIES: readonly string[] = ['spaceUse', 'action'];
|
||||
|
||||
export function isOpponentOnly(category: string): boolean {
|
||||
return OPPONENT_ONLY_CATEGORIES.includes(category);
|
||||
}
|
||||
|
||||
export function deckComposition(): { category: string; count: number }[] {
|
||||
const office = OFFICE_PROFILES.reduce((n, p) => n + p.copiesInDeck, 0);
|
||||
const freight = FREIGHT_PROFILES.reduce((n, p) => n + p.copies, 0);
|
||||
const track = TRACK_PROFILES.reduce((n, p) => n + p.copies, 0);
|
||||
const sum = (xs: readonly { copies: number }[]): number => xs.reduce((n, x) => n + x.copies, 0);
|
||||
return [
|
||||
{ category: 'timetabledTrain', count: TIMETABLED_TRAINS.length },
|
||||
{ category: 'extraTrain', count: EXTRA_TRAINS.length },
|
||||
{ category: 'office', count: office },
|
||||
{ category: 'freightFacility', count: freight },
|
||||
{ category: 'modifier', count: MODIFIER_PROFILES.length },
|
||||
{ category: 'track', count: track },
|
||||
{ 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) },
|
||||
];
|
||||
}
|
||||
|
||||
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 115
|
||||
|
||||
/** The solitaire deck drops the 22 opponent-directed cards, leaving 93. */
|
||||
export const SOLITAIRE_DECK_SIZE = deckComposition()
|
||||
.filter((c) => !isOpponentOnly(c.category))
|
||||
.reduce((n, c) => n + c.count, 0);
|
||||
|
||||
Reference in New Issue
Block a user