/** * THE RULES BLOCK, driven identically on both screens. * * The lobby (`lobby.ts`) and the solitaire New Game dialog (`main.ts`) ask the same twelve questions. * They used to ask them in two hand-written copies and had already drifted — the dialog had "where * an Extra may start" and no optional rules, the lobby the reverse — so this module owns reading, * writing, comparing and annotating the block, and each screen supplies only the id prefix its * markup uses (`lb-` or `ng-`). * * THE MARKUP ITSELF STAYS STATIC IN `play.html`, deliberately. Generating it here would be less * duplication, but `test/web.test.ts` drives the emitted bundle against a hand-built DOM stub with * no HTML parser in it (there is no jsdom in this project, and adding one to test a form is a poor * trade), and the ids in that stub come from scanning the real `play.html`. A test in the same suite * asserts both screens carry every key below, which is the drift guard the shared markup would have * been. */ import { REVENUE_MAX, REVENUE_MIN } from '../engine/content.ts'; import type { ExtraStartRule, StartingHand } from '../engine/content.ts'; import { closestPreset, differencesFrom, presetOf, presetSettings, settingsOf } from './presets.ts'; import type { PresetName, Settings } from './presets.ts'; import type { GameConfig } from '../engine/state.ts'; /** * How each rule is asked on screen. * * `gated` is the pair introduced 2026-08-23: a checkbox that says whether the condition applies at * all, plus the number it applies at. The engine's convention is that `0` switches these off, which * is exact but unreadable — a Cutthroat game showed two zeroes and left the player to know the * convention. Unchecked still WRITES 0, so nothing under this changed. */ type Field = | { key: keyof Settings; kind: 'radio'; id: string } | { key: keyof Settings; kind: 'number'; id: string } | { key: keyof Settings; kind: 'checkbox'; id: string } | { key: keyof Settings; kind: 'gated'; id: string }; export const FIELDS: readonly Field[] = [ { key: 'startingHand', kind: 'radio', id: 'hand' }, { key: 'extraStart', kind: 'radio', id: 'extra' }, { key: 'passengerPerCoach', kind: 'number', id: 'passenger' }, { key: 'freightPerLoad', kind: 'number', id: 'freight' }, { key: 'trainPerTransit', kind: 'number', id: 'transit' }, { key: 'minCombinedRevenue', kind: 'gated', id: 'minrev' }, { key: 'maxCollisionsPerDay', kind: 'gated', id: 'colday' }, { key: 'maxCollisionsTotal', kind: 'gated', id: 'coltotal' }, { key: 'reducedVisibility', kind: 'checkbox', id: 'visibility' }, { key: 'employeeRotation', kind: 'checkbox', id: 'rotation' }, { key: 'emergencyToolbox', kind: 'checkbox', id: 'toolbox' }, { key: 'discardTimetabled', kind: 'checkbox', id: 'tossloco' }, { key: 'startWhistlePosts', kind: 'checkbox', id: 'whistlestart' }, ]; /** * What `play.html` must contain for a screen to be able to ask all twelve questions — the exact * attribute text, so `test/web.test.ts` can assert it against the built page. * * THIS IS THE DRIFT GUARD. The two blocks are generated from one template today; this is what says * so tomorrow, when someone edits one of them by hand. */ export function fieldSelectors(prefix: string): string[] { const out: string[] = []; for (const f of FIELDS) { // A radio group is addressed by NAME — there is no one element carrying the field's id. out.push(f.kind === 'radio' ? `name="${prefix}${f.id}"` : `id="${prefix}${f.id}"`); if (f.kind === 'gated') out.push(`id="${prefix}${f.id}-on"`); out.push(`id="${prefix}${f.id}-row"`, `id="${prefix}${f.id}-hint"`); } return out; } /** The label each field goes under in the read-only view below. */ const FIELD_LABELS: Record = { startingHand: 'Starting hand', extraStart: 'An Extra may start at', discardTimetabled: 'A Timetabled train may be discarded', startWhistlePosts: 'Players start with Whistle Posts, not Depots', passengerPerCoach: 'Passenger per coach', freightPerLoad: 'Freight per load', trainPerTransit: 'Train per transit', minCombinedRevenue: 'Combined Revenue floor', maxCollisionsPerDay: 'Collisions in one Day', maxCollisionsTotal: 'Collisions in the game', reducedVisibility: 'Reduced Visibility', employeeRotation: 'Employee Rotation', emergencyToolbox: 'Emergency Toolbox', }; const esc = (s: string): string => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); /** * THE WHOLE RULE SET, READ-ONLY — what a player weighing a join reads before taking a seat, and what * the seating screen keeps showing afterwards. * * Generated rather than a fourth copy of the form: nobody may change these, so form controls would * be a lie, and a `
` says "this is what you are joining" in a third of the space. Fields that * differ from the nearest named type are amber here for the same reason they are amber in the form — * a non-standard game should never be a surprise. */ export function rulesListHtml(config: GameConfig, players: number, days: number): string { const settings = settingsOf(config); const type = presetOf(config, players, days); const near = closestPreset(config, players, days); const differing = type === 'custom' ? near.differing : []; const rows = (keys: (keyof Settings)[]): string => keys .map((k) => { const changed = differing.includes(k) ? ' class="changed"' : ''; return `
${esc(FIELD_LABELS[k])}
${esc(describe(k, settings[k]))}`; }) .join(''); const head = `
Players
${players}
Days
${days}
` + `
Opponent-directed cards
not implemented yet
`; return ( head + `

Opening

${rows(['startingHand', 'extraStart', 'startWhistlePosts'])}
` + `

Train cards

${rows(['discardTimetabled'])}
` + `

Revenue

${rows(['passengerPerCoach', 'freightPerLoad', 'trainPerTransit'])}
` + `

Victory conditions

${rows(['minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal'])}
` + `

Optional rules

${rows(['reducedVisibility', 'employeeRotation', 'emergencyToolbox'])}
` ); } /** What a value looks like beside its label — "Co-op default: 75", "Co-op default: six random cards". */ function describe(key: keyof Settings, value: Settings[keyof Settings]): string { if (typeof value === 'boolean') return value ? 'on' : 'off'; if (key === 'startingHand') { const words: Record = { threeRandom: 'three random', sixRandom: 'six random', threeTrackThreeOther: 'three track and three other', }; return words[value as StartingHand]; } if (key === 'extraStart') { const words: Record = { divisionPointsOnly: 'Division Points only', ownOffice: 'your own Control Point', anyOffice: 'any Control Point', }; return words[value as ExtraStartRule]; } // A victory condition at 0 is not "0" on screen, it is a condition that does not apply. if (value === 0 && (key === 'minCombinedRevenue' || key === 'maxCollisionsPerDay' || key === 'maxCollisionsTotal')) { return 'off'; } return String(value); } export type SettingsForm = { read(): Settings; /** `suggestions` fills the placeholder of a switched-off condition, so ticking it back on has a * number to offer rather than an empty box. */ write(values: Settings, suggestions: Settings): void; /** * Paint the block against a named type: every field that differs goes amber and every field gets * its " default: x" hint. Returns what differs, for the summary line the screen draws. */ mark(name: PresetName, players: number, days: number): (keyof Settings)[]; /** Custom with no named baseline to compare against — clears the paint rather than lying. */ clearMarks(): void; /** Called whenever the player changes any rule; the screen answers by selecting Custom. */ onEdit(fn: (key: keyof Settings) => void): void; /** Read-only for the join preview and the seating screen: shown in full, changeable by nobody. */ setEditable(on: boolean): void; /** Employee Rotation is meaningless at one player — disabled with a note rather than hidden, so * the two screens still read the same. */ setEmployeeRotationAvailable(on: boolean): void; }; const $ = (id: string): T | null => document.getElementById(id) as T | null; export function settingsForm(prefix: string): SettingsForm { const el = (id: string): T | null => $(`${prefix}${id}`); const radios = (name: string): HTMLInputElement[] => Array.from(document.querySelectorAll(`input[name="${prefix}${name}"]`)); const checkedRadio = (name: string): string | null => radios(name).find((r) => r.checked)?.value ?? null; let editHandler: ((key: keyof Settings) => void) | null = null; let editable = true; function num(id: string, fallback: number): number { const raw = el(id)?.value ?? ''; return raw.trim() === '' || !Number.isFinite(Number(raw)) ? fallback : Math.round(Number(raw)); } function readGated(id: string): number { // Unchecked IS zero — the engine's "off". The number in the box is kept so re-ticking restores it. if (el(`${id}-on`)?.checked !== true) return 0; return Math.max(0, num(id, 0)); } function read(): Settings { return { startingHand: (checkedRadio('hand') ?? 'sixRandom') as StartingHand, extraStart: (checkedRadio('extra') ?? 'anyOffice') as ExtraStartRule, passengerPerCoach: clampRevenue(num('passenger', 1)), freightPerLoad: clampRevenue(num('freight', 1)), trainPerTransit: clampRevenue(num('transit', 0)), minCombinedRevenue: readGated('minrev'), maxCollisionsPerDay: readGated('colday'), maxCollisionsTotal: readGated('coltotal'), reducedVisibility: el('visibility')?.checked === true, employeeRotation: el('rotation')?.checked === true, emergencyToolbox: el('toolbox')?.checked === true, discardTimetabled: el('tossloco')?.checked === true, startWhistlePosts: el('whistlestart')?.checked === true, }; } function write(values: Settings, suggestions: Settings): void { for (const r of radios('hand')) r.checked = r.value === values.startingHand; for (const r of radios('extra')) r.checked = r.value === values.extraStart; setNumber('passenger', values.passengerPerCoach); setNumber('freight', values.freightPerLoad); setNumber('transit', values.trainPerTransit); writeGated('minrev', values.minCombinedRevenue, suggestions.minCombinedRevenue); writeGated('colday', values.maxCollisionsPerDay, suggestions.maxCollisionsPerDay); writeGated('coltotal', values.maxCollisionsTotal, suggestions.maxCollisionsTotal); setChecked('visibility', values.reducedVisibility); setChecked('rotation', values.employeeRotation); setChecked('toolbox', values.emergencyToolbox); setChecked('tossloco', values.discardTimetabled); setChecked('whistlestart', values.startWhistlePosts); } function setNumber(id: string, value: number): void { const input = el(id); if (input) input.value = String(value); } function setChecked(id: string, on: boolean): void { const input = el(id); if (input) input.checked = on; } function writeGated(id: string, value: number, suggestion: number): void { const box = el(`${id}-on`); const input = el(id); if (box) box.checked = value > 0; if (input) { // A switched-off condition shows an empty box with the suggestion as its placeholder, rather // than a `0` the player has to decode. input.value = value > 0 ? String(value) : ''; input.placeholder = String(suggestion); input.disabled = !editable || value === 0; } } function mark(name: PresetName, players: number, days: number): (keyof Settings)[] { const want = presetSettings(name, players, days); const differing = differencesFrom(name, read(), players, days); const label = presetLabelOf(name); for (const f of FIELDS) { const row = el(`${f.id}-row`); const hint = el(`${f.id}-hint`); const off = differing.includes(f.key); row?.classList.toggle('changed', off); if (hint) hint.textContent = `${label} default: ${describe(f.key, want[f.key])}`; } return differing; } function clearMarks(): void { for (const f of FIELDS) { el(`${f.id}-row`)?.classList.remove('changed'); const hint = el(`${f.id}-hint`); if (hint) hint.textContent = ''; } } function setEditable(on: boolean): void { editable = on; for (const f of FIELDS) { if (f.kind === 'radio') { for (const r of radios(f.id)) r.disabled = !on; continue; } const input = el(f.id); if (input) { // A gated number stays disabled when its own condition is off, whatever the block's state. const gatedOff = f.kind === 'gated' && el(`${f.id}-on`)?.checked !== true; input.disabled = !on || gatedOff; } if (f.kind === 'gated') { const box = el(`${f.id}-on`); if (box) box.disabled = !on; } } } function setEmployeeRotationAvailable(on: boolean): void { const input = el('rotation'); if (input) { input.disabled = !on || !editable; if (!on) input.checked = false; } el('rotation-row')?.classList.toggle('unavailable', !on); } /** One change handler for every control — the screens all answer it the same way (select Custom). */ for (const f of FIELDS) { const fire = (): void => editHandler?.(f.key); if (f.kind === 'radio') { for (const r of radios(f.id)) r.onchange = fire; continue; } const input = el(f.id); if (input) { input.oninput = fire; input.onchange = fire; } if (f.kind === 'gated') { const box = el(`${f.id}-on`); if (box) { box.onchange = () => { const number = el(f.id); if (number) { number.disabled = !box.checked || !editable; // Ticking a condition back on with an empty box takes the suggestion showing in it, // so "on" never means "on, at zero". if (box.checked && number.value.trim() === '') number.value = number.placeholder; } fire(); }; } } } return { read, write, mark, clearMarks, onEdit: (fn) => void (editHandler = fn), setEditable, setEmployeeRotationAvailable, }; } function clampRevenue(n: number): number { return Math.max(REVENUE_MIN, Math.min(REVENUE_MAX, n)); } /** Kept local rather than imported from `presets.ts`'s `preset()`, which would drag the whole table * in for one word — and this is the only place a hint needs it. */ function presetLabelOf(name: PresetName): string { const labels: Record = { solitaire: 'Solitaire', coop: 'Co-op', competitive: 'Competitive', cutthroat: 'Cutthroat', }; return labels[name]; }