v0.7.0 — four game types, a lobby you can read and leave, and a multiplayer game that makes a sound

The multiplayer set-up, the lobby, the start of a game, and four signals a remote client had never
been sent. Reasoning, the preset table and what was verified how: CHANGELOG.md.

- Co-op, Competitive, Cutthroat, Solitaire and Custom, on both screens, from one shared block —
  they had drifted, and each was missing a question the other asked.
- A player reads the whole rule set before taking a seat, may leave a lobby or a running game, and
  keeps a seat across a reload. The host may clear a chair. The browser remembers every game it is
  in, not just the last one.
- The start of a game is drawn: a handoff beat, an announcement, the code and type in the header.
- Sound, the timetable flash, announcements and the just-drawn badge now reach a remote client;
  justDrawn goes to the seat that drew it and nobody else.
- Played on StartOS, which found the rest: an Extra belongs to the player who played it, the board
  never named the Superintendent, bot seats were reported as absent players, and rule section
  numbers are out of every string a player reads.

Also carries the previous session's Heavy Grade documentation work — asked again, answer unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016JczK5i33ZNSf2PtzZqdhS
This commit is contained in:
Jesse.Markowitz
2026-08-23 05:46:42 -04:00
co-authored by Claude Opus 5
parent 42adfda390
commit 06db36e5b5
41 changed files with 4387 additions and 588 deletions
+351
View File
@@ -0,0 +1,351 @@
/**
* 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;
};
export const SETTING_KEYS: readonly (keyof Settings)[] = [
'startingHand',
'extraStart',
'passengerPerCoach',
'freightPerLoad',
'trainPerTransit',
'minCombinedRevenue',
'maxCollisionsPerDay',
'maxCollisionsTotal',
'reducedVisibility',
'employeeRotation',
'emergencyToolbox',
];
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,
} 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,
};
}
/** 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 };
}): 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,
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,
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);
}