Four issues off the Gitea tracker, all of them things a player saw at the board. Reasoning for every item, and what was verified how: CHANGELOG.md. - Gitea#8: X22 Pee-Dee refused every caboose, including the one it was made up with, so setting it out stranded the train. All six cabooses are minted loaded because §2.2's "coloured is loaded, white is empty" doubles as a piece count in the supply table; one read of the flag took that literally. A caboose carries the crew, not freight, so it is never a load. - Gitea#10: a Day turns over inside the phases that run themselves, so it passes between one click and the next — and both transient signals fade before a player reading the board notices. A modal stops and waits, carrying the standings, the Days left and the combined target. Suppressed on the first frame, on Undo stepping back across a rollover, and on the Day the game ends. - Gitea#9, which SUPERSEDES Gitea#6 from three days ago: a Timetabled train may be tossed face-up to a Department slot, where a rival may pick it up — the second half of the ruling needed no code, since that is where every discard already goes. An Extra still may not. A New Game setting on this line (discardTimetabled, on by default), the plain rule on the 0.4.9 line. - Gitea#2 is not an engine bug: the rules are implemented exactly, and running the coach pool dry is Jesse's ruling to keep — "part of the strategy". What was wrong is that the game said nothing. A blocked platform now gives its reason, from the engine's own predicate, including how many coaches are stranded in Classification and what brings them back. The same four ship as v0.4.9g on the playtest line. Closes #2 Closes #8 Closes #9 Closes #10 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLnYR4XtXQNamYJXGYT8oC
366 lines
14 KiB
TypeScript
366 lines
14 KiB
TypeScript
/**
|
||
* 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<Settings, 'minCombinedRevenue'>;
|
||
};
|
||
|
||
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);
|
||
}
|