v0.4.4 — New Game dialog for the opening hand and the three revenue rates, Yard renamed Interchange, and two movement bugs behind a mirrored consist

This commit is contained in:
Jesse
2026-08-14 22:39:50 -04:00
parent d1314066bd
commit 6655e20ea8
29 changed files with 5450 additions and 3902 deletions
+116 -5
View File
@@ -417,7 +417,7 @@ export function consistSize(c: ConsistSpec): number {
export type MainlineKind =
| 'plains' | 'curves' | 'hilly' | 'heavyGrade' | 'doubleTrack'
| 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'yard';
| 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'interchange';
/**
* Speed as printed. `60` and `30` appear on the cards; Hilly prints P60/F30, and Heavy Grade
@@ -437,7 +437,7 @@ export type MainlineProfile = {
speed: MainlineSpeed;
/** Double Track and Uncontrolled Siding: "Trains may pass". */
trainsMayPass: boolean;
/** Yard: "Sort cars in new order". */
/** Interchange: "Sort cars in new order". */
sortsCars: boolean;
/** Named entry points printed on the card; some are unlocked by modifier cards. */
entryPoints: readonly string[];
@@ -452,7 +452,14 @@ export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
{ kind: 'uncontrolledSiding', name: 'Uncontrolled Siding', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['noPass', 'passingTrains'] },
{ kind: 'tunnel', name: 'Tunnel', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
{ kind: 'trestle', name: 'Trestle', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
{ kind: 'yard', name: 'Yard', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] },
/**
* 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'] },
];
/**
@@ -756,13 +763,117 @@ export const STAGES_PER_SHIFT = 3;
export const HAND_LIMIT = 3;
/**
* The opening deal: 3 track cards and 3 others, from two separately shuffled piles (`setup.ts`).
* 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. PROVISIONAL, and flagged in `TODO.md` for review after play.
* 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;