/** * THE FOUR GAME TYPES, and what "Custom" means. * * Jesse's design, 2026-08-23. A game type is not a mode and not a ruleset — it is a NAMED SET OF * DEFAULTS that the host may then edit. Editing any of them selects `custom`, which keeps the * values and inherits the scoring of the type it was edited away from; clicking a named type again * resets every rule back to it. * * WHY THIS FILE EXISTS RATHER THAN A CONSTANT IN EACH SCREEN. The lobby (`lobby.ts`) and the * solitaire New Game dialog (`main.ts`) ask the same questions, and they had already drifted apart * before this was written: the dialog had "where an Extra may start" and no optional rules, the * lobby had the optional rules and no Extra rule — so a multiplayer game silently played the most * permissive Extra rule and nobody was ever asked. One description of the defaults, imported by * both, is what stops that happening a third time. * * PARAMETERS ARE NOT SETTINGS. Seed, player count and Day count sit ABOVE the type radios on both * screens and never select `custom`: the presets are formulas in players and days, so "Co-op, 3 * players, 8 days" is still Co-op and its Revenue floor re-derives. Everything below the radios is * a rule, and changing one is what makes a game Custom. */ import { DEFAULT_MAX_COLLISIONS_PER_DAY, DEFAULT_MAX_COLLISIONS_TOTAL, collectiveRevenueFloor, houseRules, } from '../engine/content.ts'; import type { ExtraStartRule, RevenueRules, StartingHand } from '../engine/content.ts'; import type { GameConfig, GameMode } from '../engine/state.ts'; export type PresetName = 'solitaire' | 'coop' | 'competitive' | 'cutthroat'; /** What the radio group answers: one of the four named types, or the state of having edited one. */ export type GameType = PresetName | 'custom'; /** * Every rule a game type sets, flattened to one value per control. * * Flat rather than shaped like `GameConfig` because this is what the FORM compares against: each key * is one field on screen, so "which fields differ from the preset" is a key-by-key comparison rather * than a walk through nested objects. `configFromSettings` puts the shape back. */ export type Settings = { startingHand: StartingHand; extraStart: ExtraStartRule; passengerPerCoach: number; freightPerLoad: number; trainPerTransit: number; /** 0 means the condition is off — the engine's convention (`state.ts`). The form draws a checkbox. */ minCombinedRevenue: number; maxCollisionsPerDay: number; maxCollisionsTotal: number; reducedVisibility: boolean; employeeRotation: boolean; emergencyToolbox: boolean; /** §6.2 (Gitea#9) — may a Timetabled train be thrown away? An Extra never may, whatever this says. */ discardTimetabled: boolean; }; export const SETTING_KEYS: readonly (keyof Settings)[] = [ 'startingHand', 'extraStart', 'passengerPerCoach', 'freightPerLoad', 'trainPerTransit', 'minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal', 'reducedVisibility', 'employeeRotation', 'emergencyToolbox', 'discardTimetabled', ]; export type Preset = { name: PresetName; label: string; /** One line under the radio, saying what winning and losing mean in this type. */ blurb: string; /** The engine mode this type is scored under — what a `custom` game inherits. */ scoring: GameMode; /** * Whether the 22 opponent-directed cards would be dealt, once they exist. Not a form control any * more: it is a property of the type (`TODO.md` — the cards are unbuilt, so `buildDeck` holds them * out regardless, and a checkbox that cannot do anything is worse than a sentence saying so). */ pvpCards: boolean; /** * The Revenue floor this type asks for, as a function of the table and the length. Co-op keeps the * engine's own `collectiveRevenueFloor` (3 per player per Day); Competitive asks two thirds of it, * because a table racing each other is not also pulling in one direction; Cutthroat asks nothing. */ revenueFloor: (players: number, days: number) => number; rules: Omit; }; const NO_OPTIONAL_RULES = { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false, /** * ON in every type (Gitea#9). Jesse's ruling is the rule now, and the setting exists so a table * can put Gitea#6's pressure back rather than so a type can choose for them — the reasoning is * about how long a game runs, which is a dial the table already sets for itself. */ discardTimetabled: true, } as const; /** Every type deals six now (Jesse, 2026-08-23) — the hand limit is three, so the first turn is a * discard whichever three you keep. Solitaire moved with the rest so the two screens agree. */ const SIX: StartingHand = 'sixRandom'; export const PRESETS: readonly Preset[] = [ { name: 'solitaire', label: 'Solitaire', blurb: 'One railroad, one player. The whole Division is yours to run.', scoring: 'solitaire', pvpCards: false, revenueFloor: (players, days) => collectiveRevenueFloor(players, days), rules: { startingHand: SIX, // Nobody else's district exists, so "any Control Point" and "your own" are the same rule. extraStart: 'anyOffice', passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 0, maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY, maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL, ...NO_OPTIONAL_RULES, }, }, { name: 'coop', label: 'Co-op', blurb: 'Everyone’s Revenue is one table score. You win together or lose together.', scoring: 'coop', // Never, even once the cards are built: there is no opponent to point them at when the table is // one side. pvpCards: false, revenueFloor: (players, days) => collectiveRevenueFloor(players, days), rules: { startingHand: SIX, extraStart: 'ownOffice', passengerPerCoach: 1, freightPerLoad: 1, // The one economy that pays every player at once, for work nobody had to do — which is the // co-operative one, so it is the type that switches it on. trainPerTransit: 1, maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY, maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL, ...NO_OPTIONAL_RULES, }, }, { name: 'competitive', label: 'Competitive', blurb: 'Highest Revenue wins — unless the table misses its combined minimum, and then everyone loses.', scoring: 'competitive', pvpCards: true, revenueFloor: (players, days) => 2 * players * days, rules: { startingHand: SIX, extraStart: 'ownOffice', passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 0, maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY, maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL, ...NO_OPTIONAL_RULES, }, }, { name: 'cutthroat', label: 'Cutthroat', blurb: 'Highest Revenue wins, and nothing is shared — the only way everyone loses is three collisions in one Day.', scoring: 'competitive', pvpCards: true, // Off. Cutthroat has no collective obligation of any kind. revenueFloor: () => 0, rules: { startingHand: SIX, // The one type that lets you plant an Extra in somebody else's district. extraStart: 'anyOffice', passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 0, maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY, // Off, with the per-Day check left standing: a table can bleed collisions all game, but three // in one Day still ends it. maxCollisionsTotal: 0, ...NO_OPTIONAL_RULES, }, }, ]; export function preset(name: PresetName): Preset { const found = PRESETS.find((p) => p.name === name); if (!found) throw new Error(`no such preset: ${name}`); return found; } /** Every rule a named type sets, at the table size and length the form currently shows. */ export function presetSettings(name: PresetName, players: number, days: number): Settings { const p = preset(name); return { ...p.rules, minCombinedRevenue: p.revenueFloor(players, days) }; } /** The settings a config is actually carrying — the other half of every comparison below. */ export function settingsOf(config: GameConfig): Settings { const rules = houseRules(config); return { startingHand: rules.startingHand, extraStart: rules.extraStart, passengerPerCoach: rules.revenue.passengerPerCoach, freightPerLoad: rules.revenue.freightPerLoad, trainPerTransit: rules.revenue.trainPerTransit, minCombinedRevenue: config.minCombinedRevenue, maxCollisionsPerDay: config.maxCollisionsPerDay, maxCollisionsTotal: config.maxCollisionsTotal, reducedVisibility: config.optionalRules.reducedVisibility, employeeRotation: config.optionalRules.employeeRotation, emergencyToolbox: config.optionalRules.emergencyToolbox, discardTimetabled: rules.discardTimetabled, }; } /** Which controls differ from a named type — what the form paints amber and counts in its summary. */ export function differencesFrom( name: PresetName, settings: Settings, players: number, days: number, ): (keyof Settings)[] { const want = presetSettings(name, players, days); return SETTING_KEYS.filter((k) => settings[k] !== want[k]); } /** * WHICH TYPE IS THIS, derived rather than stored. * * Nothing writes a type name into `GameConfig` — a saved game is its numbers, and a name in the save * would be one more thing that can disagree with them. So the screens ask this instead, which means * a hand-tuned game that happens to match Competitive exactly reads as Competitive, and that is the * honest answer: it IS one. * * `mode` is part of the comparison, so a Co-op game whose dials happen to equal Competitive's is * still not Competitive. */ export function presetOf(config: GameConfig, players: number, days: number): GameType { const settings = settingsOf(config); const match = PRESETS.find( (p) => p.scoring === config.mode && differencesFrom(p.name, settings, players, days).length === 0, ); return match?.name ?? 'custom'; } /** * A `Frame`'s copy of the config, as a `GameConfig` the functions above can read. * * The page has a Frame, never a `GameState` — a remote client holds no game — and the Frame carries * every field these comparisons need. Structurally typed rather than importing `Frame` so this * module stays free of `sim/view.ts`. */ export function configFromFrame(f: { mode: GameMode; days: number; minCombinedRevenue: number; maxCollisionsPerDay: number; maxCollisionsTotal: number; optionalRules: GameConfig['optionalRules']; houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules; discardTimetabled: boolean }; }): GameConfig { return { mode: f.mode, days: f.days, minCombinedRevenue: f.minCombinedRevenue, maxCollisionsPerDay: f.maxCollisionsPerDay, maxCollisionsTotal: f.maxCollisionsTotal, // Never shown from a Frame — the cards are unbuilt, and the type carries the answer. pvpCardsAllowed: false, optionalRules: f.optionalRules, houseRules: { startingHand: f.houseRules.startingHand, extraStart: f.houseRules.extraStart, // Carried like the rest: this path describes SOMEONE ELSE'S game to a joiner, so a setting // dropped here shows them a rule the table is not playing (§6.2, Gitea#9). discardTimetabled: f.houseRules.discardTimetabled, revenue: f.houseRules.revenue, }, }; } /** * The named type a config is NEAREST to, for a screen that has to describe someone else's game. * * The create form always knows which type was clicked, so it compares against that. A join preview * and the seating screen do not — they are handed a finished config and nothing else — and "Custom" * on its own tells a player nothing about what they are joining. Comparing against the same-scoring * type with the fewest differences answers the question they actually have: how far from a normal * game is this, and which normal game? */ export function closestPreset( config: GameConfig, players: number, days: number, ): { name: PresetName; differing: (keyof Settings)[] } { const settings = settingsOf(config); const candidates = PRESETS.filter((p) => p.scoring === config.mode); const scored = (candidates.length > 0 ? candidates : PRESETS).map((p) => ({ name: p.name, differing: differencesFrom(p.name, settings, players, days), })); return scored.reduce((best, c) => (c.differing.length < best.differing.length ? c : best)); } /** * The label a screen shows for the current state of the form — including what a Custom game is being * scored as, which is the one thing a player cannot see from the dials. */ export function gameTypeLabel(type: GameType, scoring: GameMode): string { if (type !== 'custom') return preset(type).label; const named = PRESETS.find((p) => p.scoring === scoring && p.name !== 'cutthroat'); return `Custom — scored as ${named?.label ?? scoring}`; } /** Assembles the config a form's answers describe. `players` reaches the caller separately: it sizes * the lobby's seat array rather than being part of the rules. */ export function configFromSettings( settings: Settings, scoring: GameMode, days: number, pvpCards: boolean, ): GameConfig { return { mode: scoring, days, minCombinedRevenue: settings.minCombinedRevenue, maxCollisionsPerDay: settings.maxCollisionsPerDay, maxCollisionsTotal: settings.maxCollisionsTotal, // Inert until the cards are built (`setup.ts`'s `buildDeck` ANDs it with `cardsImplemented`), // and no longer a control on any screen — it is a property of the game type. pvpCardsAllowed: pvpCards, optionalRules: { reducedVisibility: settings.reducedVisibility, employeeRotation: settings.employeeRotation, emergencyToolbox: settings.emergencyToolbox, }, houseRules: { startingHand: settings.startingHand, extraStart: settings.extraStart, discardTimetabled: settings.discardTimetabled, revenue: { passengerPerCoach: settings.passengerPerCoach, freightPerLoad: settings.freightPerLoad, trainPerTransit: settings.trainPerTransit, }, }, }; } /** The whole config a named type produces, with nothing edited. */ export function configFromPreset(name: PresetName, players: number, days: number): GameConfig { const p = preset(name); return configFromSettings(presetSettings(name, players, days), p.scoring, days, p.pvpCards); }