Track becomes a deck card, Departments become decks, and trains must be made up to leave, industry restrictions enforced, update display and add a new game button

This commit is contained in:
Jesse
2026-08-05 08:28:22 -04:00
parent 2d348e1ecf
commit 9c76b1a5e2
28 changed files with 1927 additions and 394 deletions
+54 -20
View File
@@ -42,14 +42,15 @@ export type TrackGeometry = 'straight' | 'curved' | 'sharpCurved' | 'turnout';
export type Hand = 'left' | 'right' | 'none';
// ---------------------------------------------------------------------------
// Track — a PER-PLAYER supply, not deck cards
// Track — deck cards, drawn and played like any other
// ---------------------------------------------------------------------------
export type TrackProfile = {
geometry: TrackGeometry;
hand: Hand;
name: string;
perPlayer: number;
/** 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)". */
@@ -57,30 +58,42 @@ export type TrackProfile = {
};
/**
* 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.
* TRACK IS IN THE HOME OFFICE DECK, and is drawn and played like any other card.
*
* Handedness is PRINTED, not chosen on placement. That supersedes Gap 11.
* 104 cards, straight from column B of `docs/Deck cards2.xlsx`. 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.
*/
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 },
export const TRACK_CARDS: readonly TrackProfile[] = [
{ geometry: 'straight', hand: 'none', name: 'Straight track', copiesInDeck: 32, isOperationalRail: true, moveCost: 1 },
{ geometry: 'curved', hand: 'left', name: 'Curved track (left)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
{ geometry: 'curved', hand: 'right', name: 'Curved track (right)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
{ geometry: 'sharpCurved', hand: 'left', name: 'Sharp Curved Track (left)', copiesInDeck: 4, isOperationalRail: true, moveCost: 2 },
{ geometry: 'sharpCurved', hand: 'right', name: 'Sharp Curved Track (right)', copiesInDeck: 4, isOperationalRail: true, moveCost: 2 },
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
{ geometry: 'turnout', hand: 'right', name: 'Turnout (right)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
];
export const TRACK_PER_PLAYER = TRACK_SUPPLY.reduce((n, t) => n + t.perPlayer, 0); // 26
/** 104 — the sheet's "Total track". */
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;
/** Legacy alias kept so existing call sites resolve; prefer TRACK_SUPPLY. */
export const TRACK_PROFILES = TRACK_SUPPLY;
/** 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
@@ -174,6 +187,26 @@ export type IndustryProfile = {
* 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 },
@@ -672,7 +705,7 @@ export function lengthProfile(length: GameLength): LengthProfile {
}
// ---------------------------------------------------------------------------
// Deck composition — 140 cards
// Deck composition
// ---------------------------------------------------------------------------
/**
@@ -688,6 +721,7 @@ export function isOpponentOnly(category: string): boolean {
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) },
@@ -700,9 +734,9 @@ export function deckComposition(): { category: string; count: number }[] {
];
}
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 140
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0);
/** The solitaire deck drops the 22 opponent-directed cards, leaving 118. */
/** The solitaire deck drops the 22 opponent-directed cards. */
export const SOLITAIRE_DECK_SIZE = deckComposition()
.filter((c) => !isOpponentOnly(c.category))
.reduce((n, c) => n + c.count, 0);