Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary). This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed cards, StartOS packaging) are still ahead. Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/. src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server computing every connected seat's Menu would have handed the acting player's legal moves to a waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and test/redaction.test.ts. Not verified: an actual browser (none available in this environment). Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then- rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified live: server killed and restarted mid-game, both seats reconnected exactly where they left off. Two rules bugs found while building this: the New Train phase never implemented its car-placement round (every car of every train was placed by the Superintendent alone, in every mode, all along — now reads the round position off tray.consist.length); and victory conditions are now one shared, configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and a dead firstToTarget condition. Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching train's crew badge failing to draw once it left the Office square, an unload that always took the westmost car regardless of which was picked, and a legal decision that could render with zero buttons. docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json) included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this release. 635 tests, 0 failures.
1119 lines
60 KiB
TypeScript
1119 lines
60 KiB
TypeScript
/**
|
||
* 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 — all 22 train cards
|
||
* docs/Mainline Cards.pdf — the ten Mainline card types
|
||
* 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.
|
||
*
|
||
* 96 dealt of the sheet's 104 (column B of `docs/Deck cards2.xlsx`) — the 8 sharp curves are dealt
|
||
* zero, see below. An earlier reading made track a
|
||
* separate per-player supply of 26 pieces, 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 of 104 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.
|
||
*
|
||
* 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: 32, isOperationalRail: true, moveCost: 1 },
|
||
{ geometry: 'curved', hand: 'right', name: 'Curved track (right)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
|
||
{ geometry: 'curved', hand: 'left', name: 'Curved track (left)', copiesInDeck: 16, 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 the 8 cards in the deck
|
||
* were geometric duplicates of the curves, taking 8 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: 16, isOperationalRail: false, moveCost: 1 },
|
||
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
|
||
];
|
||
|
||
/** 96 — the sheet's "Total track" of 104, less the 8 sharp curves now dealt at zero. */
|
||
export const TRACK_IN_DECK = TRACK_CARDS.reduce((n, t) => n + t.copiesInDeck, 0);
|
||
|
||
/** Start cards, placed at setup and never shuffled: 4 Whistle Posts, 8 Limits. */
|
||
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 — Depot 4, Station 2, Terminal 1
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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. `copiesInDeck` was **doubled** (Depot 4→8, Station 2→4, Terminal 1→2) — Q12.
|
||
*
|
||
* 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). Measured at the original density, 25 of 100 games never drew
|
||
* a Depot and never escaped: they averaged **−6.0** revenue against **−0.4** for games that
|
||
* upgraded at least once, and 25 of 26 collisions happened at Whistle Post. Escaping needed one of
|
||
* 4 Depot cards in 111, roughly a 59% chance across a game's draws.
|
||
*
|
||
* 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. Station and Terminal were
|
||
* doubled with Depot to keep that ladder in proportion rather than making Depot a special case.
|
||
*
|
||
* PROVISIONAL — re-evaluate. This was chosen to remove a 25% chance of an unwinnable opening deal,
|
||
* not from the recovered design, and it is a blunt instrument: it lifts the whole office ladder and
|
||
* dilutes every other category slightly (deck 133 → 140). Revisit once the victory target is
|
||
* settled and freight is carrying its intended share; the right answer may instead be fewer
|
||
* Terminals, a cheaper first upgrade, or more A/D capacity at Whistle Post.
|
||
*/
|
||
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: 8 },
|
||
{ tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 4 },
|
||
{ tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 2 },
|
||
];
|
||
|
||
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 (Gap 12). The recovered sheet lists 9 industries in a 115-card deck; the
|
||
* prototype ran 10 in 52. At 9-in-115 a game saw 1.6 Freight Facilities, freight was 10% of gross
|
||
* revenue, and `carsCoupled` fired 4 times per 100 games — the freight loop, which is the point of
|
||
* the game, effectively never ran.
|
||
*
|
||
* Each industry's `copies` is TRIPLED, giving 27 in 133. That restores roughly the prototype's
|
||
* ratio while preserving the sheet's proportions exactly: the outbound/inbound balance and the
|
||
* lockout structure are unchanged, because every kind scales by the same factor.
|
||
*/
|
||
/**
|
||
* 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: 6 },
|
||
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 },
|
||
/**
|
||
* BOTH DIRECTIONS, per the card reference — this was `outbound` and it contradicted the rules.
|
||
*
|
||
* `card-reference.md`: "Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4", and in prose — "'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." §9.3's "Passenger Facilities and Freight Houses
|
||
* permit cars to move each direction" therefore names exactly these two, and the engine had both
|
||
* of them one-way.
|
||
*
|
||
* The consequence was silent: `usableGrant` drops a Modifier's grant on a direction its host
|
||
* cannot use, so every +1 inbound beside a Refinery went nowhere.
|
||
*
|
||
* The base numbers stay at the engine's own scale (1 per direction it allows) rather than the card
|
||
* reference's 2/2 — every industry here is scaled down the same way, Mine Tipple included, and
|
||
* raising one of them alone would be a balance change rather than a correction. Flagged in TODO.
|
||
*/
|
||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
|
||
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 },
|
||
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 },
|
||
/**
|
||
* BOTH DIRECTIONS — see the Refinery above; "Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3".
|
||
*
|
||
* Reported from play: "grocer's warehouse didn't get extra outbound slot for truck dock." It could
|
||
* not: the Truck Dock printed +1 outbound at the time and this was `flow: 'inbound'`, so the grant
|
||
* was dropped on a direction the facility did not have. The same trap still swallows an Ice House
|
||
* set beside a Grocer's that has been left one-way.
|
||
*
|
||
* `TODO.md` had previously recorded this as "checked, and there is no bug" on the reasoning that a
|
||
* Grocer's is inbound-only. That premise was the bug.
|
||
*/
|
||
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
|
||
];
|
||
|
||
/** 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;
|
||
/** 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 — 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 = {
|
||
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;
|
||
/**
|
||
* `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[] = [
|
||
...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 }, {}),
|
||
];
|
||
|
||
/**
|
||
* 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];
|
||
|
||
/**
|
||
* §2.3 — ODD RUNS WEST, EVEN RUNS EAST. The number is the direction, for an Extra as much as for a
|
||
* timetabled train, and the Division Point it starts at is therefore the one it runs away from.
|
||
*
|
||
* Extras print `direction: 'playerChoice'`, which the engine read as "always eastbound from the West
|
||
* Division Point". Jesse's ruling: the number decides, like everything else on the timetable.
|
||
*/
|
||
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';
|
||
|
||
/**
|
||
* 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;
|
||
speed: MainlineSpeed;
|
||
/** Double Track and Uncontrolled Siding: "Trains may pass". */
|
||
trainsMayPass: boolean;
|
||
/** Interchange: "Sort cars in new order". */
|
||
sortsCars: boolean;
|
||
/** Named entry points printed on the card; some are unlocked by modifier cards. */
|
||
entryPoints: readonly string[];
|
||
};
|
||
|
||
export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
|
||
{ kind: 'plains', name: 'Plains', 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'] },
|
||
/**
|
||
* RENAMED FROM "Yard" after play. The card is unchanged — same 60, same "sort cars in new
|
||
* order", same entry points, 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.
|
||
*/
|
||
{ kind: 'interchange', name: 'Interchange', 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,
|
||
modifiers: readonly string[] = [],
|
||
direction: Direction = 'east',
|
||
gradeUp: Direction = 'east',
|
||
): 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 are what improve it, so it is a 30
|
||
// until one is placed.
|
||
mph = 30;
|
||
break;
|
||
}
|
||
|
||
const base = mph >= 60 ? 1 : 2;
|
||
const stages = base + (trainSpeed === 'slow' ? 1 : 0);
|
||
return Math.max(1, stages - gradeReduction(profile, modifiers, direction, gradeUp));
|
||
}
|
||
|
||
/**
|
||
* 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 parts: string[] = [];
|
||
|
||
if (p.speed.kind === 'byTrainType') {
|
||
// Hilly. The split is by CONSIST, not by the train's speed class: anything with a coach on it
|
||
// takes the passenger figure.
|
||
parts.push(
|
||
`P${p.speed.passenger} / F${p.speed.freight} — a train carrying ANY coach crosses as a ` +
|
||
`${p.speed.passenger} (${stages(crossingStages(kind, 'fast', true))} for a fast train), and a ` +
|
||
`freight-only train as a ${p.speed.freight} (${stages(crossingStages(kind, 'fast', false))}). ` +
|
||
`A slow train adds one Stage either way.`,
|
||
);
|
||
} else if (p.speed.kind === 'grade') {
|
||
parts.push(
|
||
`A grade, climbing ${gradeUp === 'east' ? 'eastward' : 'westward'}. It crosses as a 30 — ` +
|
||
`${stages(crossingStages(kind, 'fast', false, [], gradeUp, gradeUp))} for a fast train, and one ` +
|
||
`more for a slow one. Brakeman and Airbrakes each take a Stage off a train running DOWNHILL; ` +
|
||
`Helpers takes one off a train running UPHILL. Never below one Stage.`,
|
||
);
|
||
} else {
|
||
parts.push(
|
||
`${p.speed.value} — ${stages(crossingStages(kind, 'fast', false))} for a fast train, ` +
|
||
`${stages(crossingStages(kind, 'slow', false))} for a slow one.`,
|
||
);
|
||
}
|
||
|
||
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. Only this and the Double Track allow it.',
|
||
);
|
||
} else {
|
||
parts.push('One train at a time — anything following has to wait for it to clear.');
|
||
}
|
||
|
||
if (p.sortsCars) parts.push('Cars may be sorted into any new order here.');
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
/**
|
||
* Q11, answered: the Heavy Grade card prints "(Up)" and "Player sets orientation", so which way it
|
||
* climbs is a property of the placed card, not a constant. `gradeUp` is the direction a train is
|
||
* travelling when it goes UPHILL; a train heading the other way is descending.
|
||
*
|
||
* Each applicable card takes a Stage off, never below one: a train cannot cross in no time.
|
||
* Airbrakes only counts when Brakeman is already there, which the placement rule enforces.
|
||
*/
|
||
function gradeReduction(
|
||
profile: MainlineProfile,
|
||
modifiers: readonly string[],
|
||
direction: Direction,
|
||
gradeUp: Direction,
|
||
): number {
|
||
if (profile.speed.kind !== 'grade') return 0;
|
||
const downhill = direction !== gradeUp;
|
||
let n = 0;
|
||
if (downhill) {
|
||
if (modifiers.includes('brakeman')) n++;
|
||
if (modifiers.includes('airbrakes')) n++;
|
||
} else if (modifiers.includes('helpers')) {
|
||
n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
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 22 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.' },
|
||
{ 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 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.
|
||
*
|
||
* Seven of the ten are live. Each row below cites the file that reads it, because 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 file reference beside it is a claim nobody checked.
|
||
*/
|
||
effect: 'live' | 'dormantSolo' | 'unbuilt';
|
||
};
|
||
|
||
export const ENHANCEMENT_RULES: readonly EnhancementRule[] = [
|
||
// Holds an arrival at the Limits instead of colliding when the Office is full — advance.ts:770.
|
||
{ key: 'interlocking', placement: 'runningTrackStraight', effect: 'live' },
|
||
// Wired at apply.ts:1738, but it answers Derail, an Action card the solitaire deck omits (Q6).
|
||
{ key: 'facingPointLocks', placement: 'onCard', requiresInDistrict: 'interlocking', effect: 'dormantSolo' },
|
||
// Diverts a coachless arrival away from the Train Order Office — advance.ts:750.
|
||
{ key: 'yardOffice', placement: 'secondaryTrackStraight', effect: 'live' },
|
||
// Lets a consist be re-ordered for one Move — apply.ts:405.
|
||
{ key: 'smallYard', placement: 'secondaryTrackStraight', effect: 'live' },
|
||
// Wired at apply.ts:1743, 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[]` — apply.ts:1461, read at
|
||
// advance.ts:599 (no rear-ending) and advance.ts:721 (the follower holds instead of being ruled on).
|
||
{ key: 'absSignals', placement: 'mainlineCard', effect: 'live' },
|
||
];
|
||
|
||
/**
|
||
* What an Enhancement's card does, and whether it does it yet — one line, ready for a tooltip.
|
||
*
|
||
* Reported from playtesting: an Interlocking on the board is a bare label with no hover text at all.
|
||
* Saying only the printed effect would be worse than silence for the four that are `unbuilt` — a
|
||
* player who builds one to hold a train at the Limit watches it not happen with no way to tell a
|
||
* misread card from a bug. Same discipline as `checkPlay`'s NOT_IMPLEMENTED: never let a card look
|
||
* like it is doing something it is not.
|
||
*/
|
||
export function enhancementText(key: string): string | null {
|
||
const card = ENHANCEMENT_CARDS.find((c) => c.key === key);
|
||
if (!card) return null;
|
||
const rule = enhancementRule(key);
|
||
const note =
|
||
rule?.effect === 'unbuilt'
|
||
? ' — NOT YET IMPLEMENTED: this card has no effect in play.'
|
||
: rule?.effect === 'dormantSolo'
|
||
? ' — it answers an opponent-directed card, which a solitaire deck does not contain, so it never fires in this game.'
|
||
: '';
|
||
return `${card.name}: ${card.effect}${note}`;
|
||
}
|
||
|
||
export function enhancementRule(key: string): EnhancementRule | null {
|
||
return ENHANCEMENT_RULES.find((r) => r.key === key) ?? null;
|
||
}
|
||
|
||
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
|
||
{ key: 'interlocking', name: 'Interlocking', copies: 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.', 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: 2, 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' },
|
||
{ 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.', 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: 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.' },
|
||
// 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.' },
|
||
{ 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.
|
||
*
|
||
* Scaled up alongside the Gap 12 industry increase. Worst-case demand (every copy of every
|
||
* industry in play at full capacity) is boxcar 15, hopper 12, tank 9, reefer 6; the supply must
|
||
* cover that, since 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.
|
||
*/
|
||
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 REGIONS_PER_MAINLINE_CARD = 2; // provisional, pending §10 Q2
|
||
export const STAGES_PER_DAY = 12;
|
||
export const STAGES_PER_SHIFT = 3;
|
||
export const HAND_LIMIT = 3;
|
||
|
||
/**
|
||
* The split opening deal: 3 track cards and 3 others, from two separately shuffled piles
|
||
* (`setup.ts`). One of the three `StartingHand` options below, not the only one any more.
|
||
*
|
||
* Six against a limit of three on purpose — the first turn is spent choosing which district you can
|
||
* afford to build.
|
||
*/
|
||
export const OPENING_TRACK = 3;
|
||
export const OPENING_OTHER = 3;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// House rules — the settings the New Game dialog offers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* WHAT EACH PLAYER OPENS HOLDING.
|
||
*
|
||
* Three answers, all of which have been the rule at some point, and none of which is obviously
|
||
* right — so the game stops guessing and asks whoever deals.
|
||
*
|
||
* - `threeRandom` — the prototype rule. Three cards off one deck, at the hand limit, no guarantees.
|
||
* - `sixRandom` — six off one deck, so the first turn is a discard and the opening is a choice,
|
||
* without the track being handed to you.
|
||
* - `threeTrackThreeOther` — three of each from separately shuffled piles. Introduced because a
|
||
* run-around needs five specific pieces and the bot held a turnout and a matching-hand curve
|
||
* together on 0.2% of turns; measured five ways, that was a SUPPLY problem, not a bot weakness.
|
||
*/
|
||
export type StartingHand = 'threeRandom' | 'sixRandom' | 'threeTrackThreeOther';
|
||
|
||
/** How many cards come off which pile, per `StartingHand`. `any` is dealt from the single deck. */
|
||
export const OPENING_DEALS: Readonly<Record<StartingHand, { any: number; track: number; other: number }>> = {
|
||
threeRandom: { any: 3, track: 0, other: 0 },
|
||
sixRandom: { any: 6, track: 0, other: 0 },
|
||
threeTrackThreeOther: { any: 0, track: OPENING_TRACK, other: OPENING_OTHER },
|
||
};
|
||
|
||
/**
|
||
* WHAT THE THREE WORKING ECONOMIES PAY.
|
||
*
|
||
* Balance is the open problem in this game — the developer bot averages 7.0 Revenue against a target
|
||
* of 20, of which most came from traffic nobody had to work — 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.
|
||
*
|
||
* `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;
|
||
};
|
||
|
||
export type HouseRules = { startingHand: StartingHand; revenue: RevenueRules };
|
||
|
||
/** What a caller may name — any subset, down to none — resolved by `houseRules()`. */
|
||
export type HouseRuleOverrides = { startingHand?: StartingHand; revenue?: Partial<RevenueRules> };
|
||
|
||
/** 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 },
|
||
};
|
||
|
||
/**
|
||
* 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 },
|
||
};
|
||
|
||
/** 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),
|
||
},
|
||
};
|
||
}
|
||
|
||
/** 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' },
|
||
];
|
||
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 19% dead draws.
|
||
*/
|
||
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 — 7: two Facing Point Locks of each kind, two
|
||
* Water Columns and one Overpass. */
|
||
export const DEFENCE_ONLY_COPIES =
|
||
ENHANCEMENT_CARDS.filter((c) => c.answers).reduce((n, c) => n + c.copies, 0) +
|
||
MAINLINE_MODIFIER_CARDS.filter((c) => c.answers).reduce((n, c) => n + c.copies, 0);
|
||
|
||
export function deckComposition(): { category: string; count: number }[] {
|
||
const sum = (xs: readonly { copies: number }[]): number => xs.reduce((n, x) => n + x.copies, 0);
|
||
return [
|
||
{ category: 'track', count: TRACK_IN_DECK },
|
||
{ category: 'office', count: OFFICE_PROFILES.reduce((n, o) => n + o.copiesInDeck, 0) },
|
||
{ category: 'industry', count: sum(INDUSTRY_PROFILES) },
|
||
{ category: 'modifier', count: sum(MODIFIER_PROFILES) },
|
||
{ category: 'train', count: ALL_TRAINS.length },
|
||
{ category: 'spaceUse', count: sum(SPACE_USE_CARDS) },
|
||
{ category: 'enhancement', count: sum(ENHANCEMENT_CARDS) },
|
||
{ category: 'mainlineModifier', count: sum(MAINLINE_MODIFIER_CARDS) },
|
||
{ category: 'maneuver', count: sum(MANEUVER_CARDS) },
|
||
{ category: 'action', count: sum(ACTION_CARDS) },
|
||
];
|
||
}
|
||
|
||
/**
|
||
* The whole CATALOGUE, including cards not currently dealt. Not the size of any deck in play — see
|
||
* `DEALT_DECK_SIZE`, which is what `buildDeck` actually returns.
|
||
*/
|
||
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0);
|
||
|
||
/**
|
||
* The deck actually dealt, in every mode: the catalogue less the 22 opponent-directed cards.
|
||
*
|
||
* 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 ~9%
|
||
* of draws reject. 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),
|
||
];
|