Initial commit
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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. */
|
||||
export type FlowDirection = 'outbound' | 'inbound' | 'both';
|
||||
|
||||
export type OfficeTier = 'whistlePost' | 'depot' | 'station' | 'terminal';
|
||||
|
||||
export type FreightKind =
|
||||
| 'mineTipple'
|
||||
| 'produceShed'
|
||||
| 'grocersWarehouse'
|
||||
| 'oilRefinery'
|
||||
| 'powerPlant';
|
||||
|
||||
export type ModifierKind =
|
||||
| 'teamTrack'
|
||||
| 'loadingDock'
|
||||
| 'storageShed'
|
||||
| 'extraPlatform'
|
||||
| 'sectionGang';
|
||||
|
||||
export type TrackGeometry = 'turnout' | 'straight' | 'runAround';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Freight facilities — card-reference.md §2
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FreightProfile = {
|
||||
kind: FreightKind;
|
||||
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;
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* "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';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offices — card-reference.md §4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OfficeProfile = {
|
||||
tier: OfficeTier;
|
||||
name: string;
|
||||
isControlPoint: boolean;
|
||||
isPassengerFacility: boolean;
|
||||
adTracks: number;
|
||||
porters: number;
|
||||
greenSlots: number;
|
||||
redSlots: number;
|
||||
/** Copies in the deck. Whistle Posts are a fixed supply outside it (§12.2). */
|
||||
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.
|
||||
*/
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
/** Upgrade order is strict — no skipping (Gap 3b). */
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ModifierProfile = {
|
||||
kind: ModifierKind;
|
||||
name: string;
|
||||
effect: string;
|
||||
appliesTo: 'freight' | 'passenger' | 'either';
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trains — card-reference.md §3
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConsistSpec = {
|
||||
/** Revenue cars, excluding the caboose. */
|
||||
count: number;
|
||||
allowedTypes: readonly CarType[];
|
||||
requiresCaboose: boolean;
|
||||
};
|
||||
|
||||
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). */
|
||||
direction: Direction | 'playerChoice';
|
||||
consist: ConsistSpec;
|
||||
};
|
||||
|
||||
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[] {
|
||||
return [
|
||||
{ number: odd, isExtra: false, className, direction: 'west', consist },
|
||||
{ number: odd + 1, isExtra: false, className, direction: 'east', consist },
|
||||
];
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
];
|
||||
|
||||
/** 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 },
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track — card-reference.md §6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TrackProfile = {
|
||||
geometry: TrackGeometry;
|
||||
name: string;
|
||||
/** Turnouts carry no wheel icon: a train may pass through but not stop (§A.1). */
|
||||
isOperationalRail: boolean;
|
||||
copies: number;
|
||||
};
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rolling stock supply — card-reference.md §8
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type StockSupply = { type: CarType; loaded: number; empty: number };
|
||||
|
||||
export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [
|
||||
{ type: 'coach', loaded: 8, empty: 8 },
|
||||
{ type: 'boxcar', loaded: 6, empty: 6 },
|
||||
{ type: 'hopper', loaded: 6, empty: 6 },
|
||||
{ type: 'reefer', loaded: 4, empty: 4 },
|
||||
{ type: 'tank', loaded: 4, empty: 4 },
|
||||
{ type: 'caboose', loaded: 6, 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). */
|
||||
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 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;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Victory — rules §3.2, revised by Gap 10e
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GameLength = 'short' | 'standard' | 'campaign';
|
||||
|
||||
export type LengthProfile = { length: GameLength; target: number; days: number };
|
||||
|
||||
export const LENGTH_PROFILES: readonly LengthProfile[] = [
|
||||
{ length: 'short', target: 10, days: 3 },
|
||||
{ length: 'standard', target: 20, days: 5 },
|
||||
{ length: 'campaign', target: 45, 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;
|
||||
}
|
||||
|
||||
/** §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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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.
|
||||
*/
|
||||
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);
|
||||
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 },
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user