v0.6.2 — an Extra starts where you put it, a train card is never discarded
Three more from the v0.4.9e gameplay-testing round, filed as Gitea issues, plus two bugs found underneath them. Gitea#2 is diagnosed but NOT fixed: it needs a ruling, and the reasoning is in TODO.md under Play Balance. GITEA#4 — AN EXTRA STARTS WHERE THE PLAYER PUTS IT. Only one Division Point was ever offered, chosen by number parity. The number no longer decides an Extra's direction — the start does, which supersedes the recorded ruling that "the number decides, like everything else on the timetable". The two cannot both hold: an odd, westbound Extra placed at the WEST end would leave the Division on its first move having crossed nothing, and be paid for the run. Either end now runs the train away from itself; at an Interchange or a Control Point the player picks the direction. The Interchange start is a YARD, off the running line, which is what makes the Superintendent clause work: placing it can never force a collision, a guaranteed one holds it there for another Stage, and a potential one is the Superintendent's to rule on — exactly evaluateClearance's `blocked` and `ask`, so nothing new decides collisions. Where an Extra may start is now a house rule (divisionPointsOnly / ownOffice / anyOffice, defaulting to what the engine already did). The legacy `atSeat` intent field still replays as it always meant. FOUND UNDERNEATH IT: an Extra started away from a Division Point ran empty. isBeingMadeUp tested position alone, so the Control Point start has been shipping since it was added with a train that could never be given a consist. Found by playing it, not by the tests, which had only asserted where the tray landed. FOUND UNDERNEATH IT: collide left the wrecks on the card. Destroyed trains kept their Transit entries, and evaluateClearance counts every transit as an occupant, so one rear-end collision permanently poisoned that Mainline card for every later train. THE MAINLINE CARDS WERE ROLLED, NOT DEALT — drawn from the nine types with replacement, so a Division could hold two Interchanges and Plains carried the weight of a card printed once. "An Extra may start at the Interchange if one is on the board" only reads as a rule if the board holds at most one. Now dealt from the printed deck without replacement, verified over 1600 deals. This re-deals every seed: the published replays were re-recorded, and the saved games in docs/ are retired too — two of those were already dead before this release and nobody had noticed. GITEA#6 — A TRAIN CARD IS NEVER DISCARDED, Timetabled and Extra alike. The forced play needed no mechanism: nothing discardable plus a hand over the limit leaves exactly one legal way to end the turn, and playing a train is unconditionally legal, so the corner cannot trap anyone. The bot needed no rule either. 400/400 games finished, revenue unmoved, trains scheduled 1.2 -> 1.3. The player is told on the card and on the button. GITEA#7 — COACH COUNTS. 1/2 Crack Limited 3 -> 2, 5/6 The Sparrow 2 -> 3. A change to the cards, so Trains3.pdf and the transcription keep the original numbers with a footnote while content.ts and the Home Deck reference carry what the game plays. CONTENT.TS COMMENT PASS — no data changed, only comments. Four were factually wrong, including an office table naming counts doubled long ago and a pointer to a DEALT_DECK_SIZE that has never existed. Every Enhancement row cited its implementation by line number and every citation had rotted; they name functions now. Card counts came out of the comments, since they move with play balance; source-sheet figures and dated measurements stayed. TODO.md gains an item for a card reference generated from content.ts, in six sections, so the documentation cannot disagree with the game. 715 tests pass, tsc clean, site builds.
This commit is contained in:
+173
-74
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* 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/Trains3.pdf — the train cards
|
||||
* docs/Mainline Cards.pdf — the Mainline card types, and the two Division Points
|
||||
* docs/tracks.png — card art
|
||||
*
|
||||
* This replaced an invented 52-card placeholder. See docs/rules/implications.md for what changed
|
||||
@@ -60,13 +60,17 @@ export type TrackProfile = {
|
||||
/**
|
||||
* 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.
|
||||
* Every row's dealt count is in `copiesInDeck` below; the sharp curves are dealt zero, see below.
|
||||
* How many that comes to is `TRACK_IN_DECK`, computed rather than written down — it moves with
|
||||
* balance work, so a number in this comment would be wrong before long.
|
||||
*
|
||||
* An earlier reading made track a separate per-player supply, sitting outside the deck and laid one
|
||||
* a turn. That came from misreading the sheet's LAST column, headed "Track Per Player" —
|
||||
* 8/4/4/1/1/4/4 = 26, which is a note about each player's likely share across four players, not a
|
||||
* second stack of cards. The sheet's own totals settle it: "Sum other 115", "Total track 104", and a
|
||||
* grand total of 231 once the 12 start cards are counted. 115 + 104 + 12 = 231. Those are figures
|
||||
* from `docs/Deck cards2.xlsx`, a fixed document, and stay here as the audit trail for the
|
||||
* transcription — they are not claims about what the game deals today.
|
||||
*
|
||||
* 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
|
||||
@@ -89,10 +93,10 @@ export const TRACK_CARDS: readonly TrackProfile[] = [
|
||||
* 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.
|
||||
* ever charged it: every switching move costs exactly 1, hard-coded. So they were geometric
|
||||
* duplicates of the curves, taking draws from a deck the rebalance already thinks is too diluted.
|
||||
* They come out rather than having the Move cost built, because a per-card movement cost is a
|
||||
* change to the Move model and the rebalance can wait.
|
||||
*
|
||||
* The rows stay in the catalogue at zero, exactly as Poling does, so the design is still visible
|
||||
* and the geometry still works if they are ever dealt again.
|
||||
@@ -103,10 +107,10 @@ export const TRACK_CARDS: readonly TrackProfile[] = [
|
||||
{ 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. */
|
||||
/** Summed from `copiesInDeck` above, never written down — it moves whenever the deck is retuned. */
|
||||
export const TRACK_IN_DECK = TRACK_CARDS.reduce((n, t) => n + t.copiesInDeck, 0);
|
||||
|
||||
/** Start cards, placed at setup and never shuffled: 4 Whistle Posts, 8 Limits. */
|
||||
/** Start cards, placed at setup and never shuffled. */
|
||||
export const WHISTLE_POST_SUPPLY = 4;
|
||||
export const LIMITS_SUPPLY = 8;
|
||||
|
||||
@@ -116,7 +120,7 @@ export function trackProfile(geometry: TrackGeometry, hand: Hand): TrackProfile
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offices — Depot 4, Station 2, Terminal 1
|
||||
// Offices — the upgrade ladder: Whistle Post, Depot, Station, Terminal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OfficeProfile = {
|
||||
@@ -139,13 +143,12 @@ export type OfficeProfile = {
|
||||
* passenger modifier cards (Waiting Area, Restaurant, Hotel).
|
||||
*/
|
||||
/**
|
||||
* Office cards. `copiesInDeck` was **doubled** (Depot 4→8, Station 2→4, Terminal 1→2) — Q12.
|
||||
* Office cards. Every tier's `copiesInDeck` was **doubled** against the recovered design — 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.
|
||||
* upgraded at least once, and 25 of 26 collisions happened at Whistle Post.
|
||||
*
|
||||
* 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
|
||||
@@ -153,9 +156,10 @@ export type OfficeProfile = {
|
||||
*
|
||||
* 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.
|
||||
* dilutes every other category slightly. 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. The counts themselves are in the rows below, which
|
||||
* is the only place they should be read from.
|
||||
*/
|
||||
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 },
|
||||
@@ -199,13 +203,14 @@ export type IndustryProfile = {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* prototype ran 10 in 52. At the sheet's density 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
|
||||
* Each industry's `copies` is TRIPLED against the sheet, which 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.
|
||||
* lockout structure are unchanged, because every kind scales by the same factor. The multiplier is
|
||||
* the decision; the resulting totals are in the rows below and move with every retune.
|
||||
*/
|
||||
/**
|
||||
* LOCKOUTS, from the sheet's "Lockouts" column verbatim:
|
||||
@@ -287,7 +292,7 @@ export function industryProfile(kind: FreightKind): IndustryProfile {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modifiers — 17 industry + 6 passenger, each tied to specific hosts
|
||||
// Modifiers — passenger and industry, each tied to specific hosts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ModifierKind =
|
||||
@@ -356,7 +361,7 @@ export function modifierProfile(kind: ModifierKind): ModifierProfile {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trains — 22 cards, named, with speed class and individual rules
|
||||
// Trains — named, with a speed class and individual printed rules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TrainSpeed = 'fast' | 'slow';
|
||||
@@ -420,11 +425,18 @@ function pair(odd: number, name: string, speed: TrainSpeed, consist: ConsistSpec
|
||||
}
|
||||
|
||||
export const TIMETABLED_TRAINS: readonly TrainProfile[] = [
|
||||
...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 3, caboose: 0 },
|
||||
/**
|
||||
* COACH COUNTS ON 1/2 AND 5/6 WERE SWAPPED BY JESSE (Gitea#7, v0.4.9e playtest): the Crack Limited
|
||||
* drops from three coaches to two, and The Sparrow rises from two to three. A change to the card
|
||||
* faces themselves, not a transcription fix — `Trains3.pdf` and the tables that transcribe it
|
||||
* still print the old numbers, so `docs/rules/card-reference.md` is the place that now carries
|
||||
* what the cards say.
|
||||
*/
|
||||
...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 2, caboose: 0 },
|
||||
{ terminalsOnly: true, noSwitching: true, expedite: true, note: 'Stop at Terminals only.' }),
|
||||
...pair(3, 'Express', 'fast', { freight: 2, coach: 0, caboose: 0 },
|
||||
{ oneFreightPerLocation: true, expedite: true, note: 'May drop or pick up one freight car at every location.' }),
|
||||
...pair(5, 'The Sparrow', 'fast', { freight: 0, coach: 2, caboose: 0 },
|
||||
...pair(5, 'The Sparrow', 'fast', { freight: 0, coach: 3, caboose: 0 },
|
||||
{ noSwitching: true, expedite: true }),
|
||||
...pair(7, 'Local', 'slow', { freight: 1, coach: 1, caboose: 0 },
|
||||
{ coachStaysOnStationTrack: true, note: 'Maximum one freight, one coach.' }),
|
||||
@@ -452,11 +464,18 @@ export const EXTRA_TRAINS: readonly TrainProfile[] = [
|
||||
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.
|
||||
* §2.3 — ODD RUNS WEST, EVEN RUNS EAST. The number is the direction for a TIMETABLED train.
|
||||
*
|
||||
* 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.
|
||||
* NOT for an Extra any more. Extras print `direction: 'playerChoice'` and now mean it (Jesse's
|
||||
* ruling, superseding "the number decides, like everything else on the timetable"): the player who
|
||||
* played the card picks where it starts, and the start decides the direction — a Crew Tray placed
|
||||
* at the Western Division Point runs east and one at the Eastern runs west, because the alternative
|
||||
* is a train that leaves the Division on its first move having crossed nothing. Where the start is
|
||||
* NOT an end of the Division — an Interchange, or a Control Point — both ways are real runs and the
|
||||
* player says which. `resolveExtraStart` in apply.ts is where that happens.
|
||||
*
|
||||
* Still read for an Extra in one place: replaying a save written before the choice existed, where
|
||||
* `newTrain.startExtra` carries only the legacy `atSeat` field.
|
||||
*/
|
||||
export function runDirection(trainNumber: number): Direction {
|
||||
return trainNumber % 2 === 0 ? 'east' : 'west';
|
||||
@@ -527,6 +546,29 @@ export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
|
||||
{ kind: 'interchange', name: 'Interchange', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* THE MAINLINE DECK AS PRINTED, dealt WITHOUT replacement.
|
||||
*
|
||||
* `MAINLINE_PROFILES` above is a list of card TYPES, one entry each. It is not the deck, and setup
|
||||
* used it as one: `buildDivision` drew uniformly from those types with replacement, which made two
|
||||
* Interchanges (or two Trestles, or two Tunnels) an ordinary outcome and gave Plains the same weight
|
||||
* as everything else although the deck prints two of it. `Mainline Cards.pdf` is the
|
||||
* inventory, transcribed in `docs/StationMaster-Mainline-Deck-v0.4.5.md`, which had already flagged
|
||||
* the mismatch as needing correction.
|
||||
*
|
||||
* It matters more than card flavour now that an Extra may start at an Interchange (§7): "if an
|
||||
* Interchange is on the board" has to mean a card there is at most one of, not a type the deal can
|
||||
* hand out twice.
|
||||
*
|
||||
* The two Division Point cards in that inventory are not here — they are the fixed ends of the
|
||||
* Division, laid by `buildDivision` itself rather than drawn. Plains appears twice because the deck
|
||||
* prints it twice; every other type once. The list below is the deck, so it is not counted here.
|
||||
*/
|
||||
export const MAINLINE_DECK: readonly MainlineKind[] = [
|
||||
'plains', 'plains', 'curves', 'hilly', 'heavyGrade',
|
||||
'doubleTrack', 'uncontrolledSiding', 'tunnel', 'trestle', 'interchange',
|
||||
];
|
||||
|
||||
/**
|
||||
* How many Stages a train needs to cross a Mainline card.
|
||||
*
|
||||
@@ -693,9 +735,9 @@ export type SimpleCard = {
|
||||
* 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.
|
||||
* The Space-use and Action cards are held out of every deck until they are implemented (Q6), and
|
||||
* these go with them — named here rather than in a list somewhere else so the pairing is visible
|
||||
* on the card, and so they come back together when their attacker does.
|
||||
*/
|
||||
answers?: string;
|
||||
};
|
||||
@@ -741,39 +783,48 @@ export type EnhancementRule = {
|
||||
* 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.
|
||||
* Most are live. Each row below cites the file that reads it — the row is the answer, and no
|
||||
* tally is kept here, because a tally is one more thing to forget when a card is implemented.
|
||||
*
|
||||
* The first attempt at this table got FIVE of the ten wrong: it was filled in by grepping for four
|
||||
* helper function names and reading "no match" as "no implementation", when Interlocking, Yard
|
||||
* Office, Small Yard and ABS Signals are all read directly by key — and all four are covered by
|
||||
* tests in `enhancements.test.ts` that were passing the whole time. The result was a tooltip
|
||||
* telling players that four working cards did nothing, which is worse than the bare label it
|
||||
* replaced.
|
||||
*
|
||||
* KEEP THIS HONEST, AND CHECK THE CITATION. Implementing one of these means changing its value in
|
||||
* the same commit; otherwise the card goes on apologising for something it now does. It is data
|
||||
* rather than something derived because "is this key read anywhere" is not a question the type
|
||||
* system can answer — but a claim here without a file reference beside it is a claim nobody checked.
|
||||
* system can answer — but a claim here without a reference beside it is a claim nobody checked.
|
||||
*
|
||||
* CITE THE FUNCTION, NEVER THE LINE NUMBER. Every one of these rows once carried a `file.ts:NNN`
|
||||
* and every one of them had rotted — `apply.ts:405` for Small Yard was pointing four hundred lines
|
||||
* short by the time anyone looked. A line number is a citation that decays silently on the next
|
||||
* edit anywhere above it, which is the opposite of what this note is for.
|
||||
*/
|
||||
effect: 'live' | 'dormantSolo' | 'unbuilt';
|
||||
};
|
||||
|
||||
export const ENHANCEMENT_RULES: readonly EnhancementRule[] = [
|
||||
// Holds an arrival at the Limits instead of colliding when the Office is full — advance.ts:770.
|
||||
// Holds an arrival at the Limits instead of colliding when the Office is full — `arriveAtOffice`.
|
||||
{ key: 'interlocking', placement: 'runningTrackStraight', effect: 'live' },
|
||||
// Wired at apply.ts:1738, but it answers Derail, an Action card the solitaire deck omits (Q6).
|
||||
// Wired in `isProtectedFromDerail`, but it answers Derail, an Action card the solo deck omits (Q6).
|
||||
{ key: 'facingPointLocks', placement: 'onCard', requiresInDistrict: 'interlocking', effect: 'dormantSolo' },
|
||||
// Diverts a coachless arrival away from the Train Order Office — advance.ts:750.
|
||||
// Diverts a coachless arrival away from the Train Order Office — `arriveAtOffice`.
|
||||
{ key: 'yardOffice', placement: 'secondaryTrackStraight', effect: 'live' },
|
||||
// Lets a consist be re-ordered for one Move — apply.ts:405.
|
||||
// Lets a consist be re-ordered for one Move — `check`'s `switch.sortConsist` case.
|
||||
{ key: 'smallYard', placement: 'secondaryTrackStraight', effect: 'live' },
|
||||
// Wired at apply.ts:1743, but it removes a Watertower, a Space-use card the solo deck omits.
|
||||
// Wired in `watertowersRemovable`, but it removes a Watertower, a Space-use card the solo deck omits.
|
||||
{ key: 'waterColumn', placement: 'runningTrackStraight', effect: 'dormantSolo' },
|
||||
// The only one with NO code path at all: nothing anywhere reads `overpass`.
|
||||
{ key: 'overpass', placement: 'onCard', effect: 'unbuilt' },
|
||||
{ key: 'telegraph', placement: 'runningTrackStraight', dispatchBonus: 4, effect: 'live' },
|
||||
{ key: 'telephone', placement: 'onCard', requiresOnSameCard: 'telegraph', dispatchBonus: 8, effect: 'live' },
|
||||
{ key: 'radio', placement: 'onCard', requiresOnSameCard: 'telephone', dispatchBonus: 12, effect: 'live' },
|
||||
// Stored on the Mainline node rather than in `enhancements[]` — apply.ts:1461, read at
|
||||
// advance.ts:599 (no rear-ending) and advance.ts:721 (the follower holds instead of being ruled on).
|
||||
// Stored on the Mainline node rather than in `enhancements[]` — written by `reduce`'s
|
||||
// `enhancementPlaced`, read in `moveTrain` (no rear-ending) and `evaluateClearance` (the follower
|
||||
// holds instead of being ruled on).
|
||||
{ key: 'absSignals', placement: 'mainlineCard', effect: 'live' },
|
||||
];
|
||||
|
||||
@@ -864,11 +915,15 @@ 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.
|
||||
* Scaled up alongside the Gap 12 industry increase. The rule it was set by, rather than the numbers
|
||||
* it produced: worst-case demand for a car type is every copy of every industry that uses it, in
|
||||
* play at full capacity, and the supply must cover that — a Division Yard that runs dry starves the
|
||||
* freight loop the increase exists to feed. Lockouts and district size mean the worst case cannot
|
||||
* actually occur, so this carries deliberate headroom. RE-DERIVE IT FROM `INDUSTRY_PROFILES`
|
||||
* whenever industry copies move; a figure written here would not survive the next retune.
|
||||
*
|
||||
* Coaches are the type this rule does NOT cover, because no industry asks for one — their demand
|
||||
* comes from passenger work, and Gitea#2 is the open report that the supply is short (see TODO.md).
|
||||
*/
|
||||
export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [
|
||||
{ type: 'coach', loaded: 8, empty: 8 },
|
||||
@@ -902,11 +957,11 @@ 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.
|
||||
* The split opening deal — track cards and others, from two separately shuffled piles (`setup.ts`).
|
||||
* One of the three `StartingHand` options below, not the only one any more.
|
||||
*
|
||||
* Six against a limit of three on purpose — the first turn is spent choosing which district you can
|
||||
* afford to build.
|
||||
* Deliberately over the hand limit: the first turn is spent choosing which district you can afford
|
||||
* to build. The two constants below are the deal.
|
||||
*/
|
||||
export const OPENING_TRACK = 3;
|
||||
export const OPENING_OTHER = 3;
|
||||
@@ -940,10 +995,16 @@ export const OPENING_DEALS: Readonly<Record<StartingHand, { any: number; track:
|
||||
/**
|
||||
* 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.
|
||||
* Balance is the open problem in this game, and the way to settle it is to play it at several
|
||||
* settings rather than to keep re-deriving it. So the three rates are dials, set when the game is
|
||||
* dealt and fixed for its duration.
|
||||
*
|
||||
* DO NOT WRITE A CURRENT BALANCE FIGURE HERE. This said "the developer bot averages 7.0 Revenue
|
||||
* against a target of 20" long after that stopped being true: 7.0 was measured while
|
||||
* `trainPerTransit` still defaulted to 1, and the paragraph below explains that this very setting
|
||||
* was then defaulted to 0 for being worth ~5.4 of it. Re-measured 2026-08-22 over 400 games at the
|
||||
* current defaults, the developer bot means about ZERO. Run `node src/sim/harness.ts 400 standard`
|
||||
* for today's number rather than trusting one written here.
|
||||
*
|
||||
* `passengerPerCoach` and `freightPerLoad` each pay on BOTH halves of their cycle: a coach pays when
|
||||
* it is boarded and again when it is detrained, a load pays when it is made up outbound and again
|
||||
@@ -960,10 +1021,30 @@ export type RevenueRules = {
|
||||
trainPerTransit: number;
|
||||
};
|
||||
|
||||
export type HouseRules = { startingHand: StartingHand; revenue: RevenueRules };
|
||||
/**
|
||||
* WHERE AN EXTRA MAY BE STARTED — a setting, because the table disagrees about it.
|
||||
*
|
||||
* §7 gives an Extra "either Division Point", and Jesse's ruling adds the Interchange to that base
|
||||
* set: both are places on the shared Division that belong to nobody, so neither favours a seat.
|
||||
* Starting one inside a player's own district is the part that does, which is what this dials.
|
||||
*
|
||||
* - `divisionPointsOnly` — the Division Points and the Interchange. No Office start at all.
|
||||
* - `ownOffice` — those, plus a Control Point in the district of the player who played the card.
|
||||
* - `anyOffice` — those, plus a Control Point in ANY player's district.
|
||||
*
|
||||
* A Whistle Post never qualifies however this is set: an Office has to be a Control Point to start
|
||||
* an Extra, which is part of what upgrading buys (§11).
|
||||
*/
|
||||
export type ExtraStartRule = 'divisionPointsOnly' | 'ownOffice' | 'anyOffice';
|
||||
|
||||
export type HouseRules = { startingHand: StartingHand; revenue: RevenueRules; extraStart: ExtraStartRule };
|
||||
|
||||
/** What a caller may name — any subset, down to none — resolved by `houseRules()`. */
|
||||
export type HouseRuleOverrides = { startingHand?: StartingHand; revenue?: Partial<RevenueRules> };
|
||||
export type HouseRuleOverrides = {
|
||||
startingHand?: StartingHand;
|
||||
revenue?: Partial<RevenueRules>;
|
||||
extraStart?: ExtraStartRule;
|
||||
};
|
||||
|
||||
/** 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;
|
||||
@@ -972,6 +1053,10 @@ export const REVENUE_MAX = 5;
|
||||
export const DEFAULT_HOUSE_RULES: HouseRules = {
|
||||
startingHand: 'threeRandom',
|
||||
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 0 },
|
||||
// `anyOffice` is what the engine did before the setting existed, so a game dealt without naming
|
||||
// it plays the way it always has. Jesse's call, so the 0.4.9 playtest line does not change under
|
||||
// its testers in the middle of a bugfix release.
|
||||
extraStart: 'anyOffice',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -986,6 +1071,8 @@ export const DEFAULT_HOUSE_RULES: HouseRules = {
|
||||
export const LEGACY_HOUSE_RULES: HouseRules = {
|
||||
startingHand: 'threeTrackThreeOther',
|
||||
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 1 },
|
||||
// An Extra could always be started at a Control Point in these games, in any district.
|
||||
extraStart: 'anyOffice',
|
||||
};
|
||||
|
||||
/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
|
||||
@@ -1004,6 +1091,7 @@ export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRu
|
||||
freightPerLoad: clamp(rev.freightPerLoad, d.revenue.freightPerLoad),
|
||||
trainPerTransit: clamp(rev.trainPerTransit, d.revenue.trainPerTransit),
|
||||
},
|
||||
extraStart: given.extraStart ?? d.extraStart,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1013,6 +1101,12 @@ export const STARTING_HAND_LABELS: readonly { value: StartingHand; label: string
|
||||
{ value: 'sixRandom', label: 'Six random cards' },
|
||||
{ value: 'threeTrackThreeOther', label: 'Three random track and three random non-track cards' },
|
||||
];
|
||||
/** What the dialog calls each `ExtraStartRule`, in the order it offers them. */
|
||||
export const EXTRA_START_LABELS: readonly { value: ExtraStartRule; label: string }[] = [
|
||||
{ value: 'divisionPointsOnly', label: 'Division Points and the Interchange only' },
|
||||
{ value: 'ownOffice', label: 'Also the playing player\u2019s own Control Point' },
|
||||
{ value: 'anyOffice', label: 'Also any player\u2019s Control Point' },
|
||||
];
|
||||
export const MAX_CONSIST = 4;
|
||||
export const MOVES_PER_LOCAL_OPS = 6;
|
||||
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
|
||||
@@ -1065,7 +1159,8 @@ export function lengthProfile(length: GameLength): LengthProfile {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* target, so they are removed from the deck rather than sitting in hand as dead draws — which, when
|
||||
* this was written, was very nearly a fifth of the deck.
|
||||
*/
|
||||
export const OPPONENT_ONLY_CATEGORIES: readonly string[] = ['spaceUse', 'action'];
|
||||
|
||||
@@ -1073,8 +1168,10 @@ 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. */
|
||||
/**
|
||||
* How many cards `DEFENCE_ONLY_CARDS` accounts for. Summed from the rows rather than written down:
|
||||
* which cards qualify is `SimpleCard.answers`, and the total moves whenever one is added.
|
||||
*/
|
||||
export const DEFENCE_ONLY_COPIES =
|
||||
ENHANCEMENT_CARDS.filter((c) => c.answers).reduce((n, c) => n + c.copies, 0) +
|
||||
MAINLINE_MODIFIER_CARDS.filter((c) => c.answers).reduce((n, c) => n + c.copies, 0);
|
||||
@@ -1097,17 +1194,19 @@ export function deckComposition(): { category: string; count: number }[] {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `SOLITAIRE_DECK_SIZE` below, which is what `buildDeck` actually deals. (This pointed at a
|
||||
* `DEALT_DECK_SIZE` that has never existed.)
|
||||
*/
|
||||
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0);
|
||||
|
||||
/**
|
||||
* The deck actually dealt, in every mode: the catalogue less the 22 opponent-directed cards.
|
||||
* The deck actually dealt, in every mode: the catalogue less the opponent-directed cards, less the
|
||||
* defensive cards that exist only to answer them.
|
||||
*
|
||||
* Named for solitaire because Q6 dropped them there first, and kept under that name because the
|
||||
* number is the same either way. They are out of the competitive deck too until they are
|
||||
* implemented — `checkPlay` answers both categories NOT_IMPLEMENTED, so dealing them would make ~9%
|
||||
* of draws reject. See `buildDeck`.
|
||||
* implemented — `checkPlay` answers both categories NOT_IMPLEMENTED, so dealing them would make a
|
||||
* meaningful share of draws reject outright. See `buildDeck`.
|
||||
*/
|
||||
export const SOLITAIRE_DECK_SIZE =
|
||||
deckComposition()
|
||||
|
||||
Reference in New Issue
Block a user