/** * Browser entry point — wires the DOM to a `Session`. * * Presentation only. Every question of what is legal, what it means, or what the board looks like * is answered by the engine or by the shared view helpers. */ import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts'; import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts'; import type { Frame } from '../sim/view.ts'; import type { Menu, Save } from './game.ts'; import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts'; import { TOOLTIP_CSS, installTooltips } from './tooltip.ts'; import { playCue } from './sound.ts'; import { DEFAULT_MAX_COLLISIONS_PER_DAY, DEFAULT_MAX_COLLISIONS_TOTAL, MOVES_PER_LOCAL_OPS, STARTING_HAND_LABELS, collectiveRevenueFloor, houseRules, } from '../engine/content.ts'; import type { HouseRuleOverrides, HouseRules, RevenueRules, StartingHand } from '../engine/content.ts'; import type { NewGameOptions } from './game.ts'; import type { LocalSession, Session } from './session.ts'; import { createLocalSession, createRemoteSession } from './session.ts'; import type { PlayerIndex } from '../engine/state.ts'; const SAVE_KEY = 'station-master.save.v1'; const SETTINGS_KEY = 'station-master.settings.v1'; /** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */ const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const; /** * Small persisted preferences, kept in a `localStorage` key of their own — separate from * `SAVE_KEY`. A save is the seed plus the intents and has to stay portable; none of this belongs * in it, and in a multiplayer game two players may reasonably want these set differently. Grows as * more of the page's display state earns a preference; `districtMode`/`soundOn`/`zoom` are the * first three. */ type Settings = { districtMode: 'auto' | 'open' | 'closed'; soundOn: boolean; zoom: number; }; const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1 }; function loadSettings(): Settings { try { const raw = localStorage.getItem(SETTINGS_KEY); const parsed = raw ? (JSON.parse(raw) as Partial) : {}; // A missing key, a corrupt value, or a level dropped from `ZOOM_LEVELS` since it was saved all // fall back to the default for that one field, rather than rejecting the whole object. return { districtMode: parsed.districtMode === 'open' || parsed.districtMode === 'closed' ? parsed.districtMode : 'auto', soundOn: typeof parsed.soundOn === 'boolean' ? parsed.soundOn : DEFAULT_SETTINGS.soundOn, zoom: typeof parsed.zoom === 'number' && (ZOOM_LEVELS as readonly number[]).includes(parsed.zoom) ? parsed.zoom : DEFAULT_SETTINGS.zoom, }; } catch { // A full or disabled localStorage must not take the game down with it — same guard as the save. return { ...DEFAULT_SETTINGS }; } } let settings = loadSettings(); function saveSettings(patch: Partial): void { settings = { ...settings, ...patch }; try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch { /* nothing to do */ } } /** * The game, behind the Session boundary — `LocalSession` (solitaire, `?seat=` absent from the URL) * or `RemoteSession` (`?seat=` present, Phase 2). Typed as the common `Session` surface; every * LocalSession-only touch (undo, local saves, dealing a new game) goes through `isLocal` below rather * than assuming, since `session` may now be either. */ let session: Session; /** * The three `Capabilities` (`undo`/`saveLocal`/`newGame`) travel together — all `true` for a * `LocalSession`, all `false` for a `RemoteSession` (`session.ts`) — so any one of them is a safe * discriminant. `newGame` is used here since it reads clearly at every call site: "only if this * session can deal locally." */ function isLocal(s: Session): s is LocalSession { return s.capabilities.newGame; } /** Which card or track piece is picked, waiting for a location. */ let selected: string | null = null; /** * WHAT the picked card is being used for. * * The hand is the action surface, and a card has two verbs: play it onto the board, or discard it * onto a Department. Both then ask "where?", and the answer is highlighted in place — squares on the * board for a play, the three Department piles for a discard. Without this the two flows would need * two selections, which is the thing being removed. */ let mode: 'play' | 'discard' | null = null; /** A square picked on the board, waiting for a rotation. */ let pendingAt: string | null = null; /** * AUTO-FOCUS on the district. * * It is only worth the vertical space during the phases that change it — Local Operations, where * cards and track are placed and the crew switches, and Cargo, where loads move. During New Train, * Mainline and Supervisor Shift nothing in the district moves and the Division map is what matters. * * 'auto' follows the phase; 'open' and 'closed' are the player overriding it and stay put until * they change it again. Display only — in a multiplayer game two players may reasonably want it * set differently, so this must never become part of game state. Persisted in `settings`, not the * save, for exactly that reason. */ let districtMode: 'auto' | 'open' | 'closed' = settings.districtMode; /** * Sound, OFF by default until a player asks for it once — then remembered via `settings`. * * Everything it plays is synthesised rather than recorded, so it is a placeholder for real audio * rather than the finished thing. One click in the title bar turns it on, and that click is also * the gesture browsers require before any audio may start. */ let soundOn = settings.soundOn; /** Preset board zoom (see `ZOOM_LEVELS`), persisted in `settings`. */ let zoom = settings.zoom; /** * The phase the page last drew, so a change of phase can be announced. * * Local Operations ends the moment the last Move is spent, and New Train and Mainline then run * themselves — so a player looking at the board finds themselves in Cargo with no idea their turn * ended or what happened in between. Reported exactly that way. */ let lastPhase: string | null = null; /** * WHICH CREW THE BOARD IS DRAWING, when the district holds more than one. * * Reported from play: "make it clear which train you are switching — it is possible to have more * than one train available." A train standing on an A/D track while a local shunts is ordinary, and * the board used to highlight whichever crew came first out of the map while the action list offered * every crew's moves under one heading. * * Display state, deliberately not in the game: which train a player is looking at is not a fact * about the railroad, and in a multiplayer game two players may reasonably be looking at different * ones. Cleared whenever the named crew stops being one of the choices. */ let selectedCrew: string | null = null; const FOCUS_PHASES = new Set(['localOps', 'loadUnload']); /** The crew whose squares the board is drawing: the chosen one, or the only one there is. */ function pickedCrew(f: Frame): Frame['moves'][number] | null { if (f.moves.length === 0) return null; return f.moves.find((m) => m.trayId === selectedCrew) ?? f.moves[0] ?? null; } const $ = (id: string): HTMLElement => { const el = document.getElementById(id); if (!el) throw new Error(`missing element: ${id}`); return el as HTMLElement; }; const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c); /** * Size a freshly-rendered board SVG off its own `viewBox`, at the current `zoom`. * * `#grid` and `#division` already scroll horizontally when their content is wider than the column * (`overflow-x:auto` in `play.html`) — that mechanism is untouched. This only changes how big the * SVG itself renders, in real pixels rather than a CSS `transform` (which would leave the container's * scrollable area the wrong size), so zooming in genuinely grows the scrollable area and zooming out * genuinely shrinks it. */ function applyZoom(container: HTMLElement): void { const svg = container.querySelector('svg'); const box = svg?.viewBox.baseVal; if (!svg || !box || box.width === 0) return; svg.style.width = `${box.width * zoom}px`; svg.style.height = `${box.height * zoom}px`; } /** * A DRAWING OF THE PIECE A PLACEMENT WOULD LAY. * * "Allows traffic from the east to travel west or turn to the south" is exact and still leaves a * player working out which way the leg will point once the card is down. Rendered by `officeSvg` — * the board's own renderer, on a one-card board — so the preview and the board cannot disagree about * what the piece looks like. The coordinate stamp and the RUNNING TRACK caption are stripped: they * belong to a position, and this is a card on its own. */ function piecePreview(links: string[], label: string): string { const cell = { row: 0, col: 0, kind: 'trk', label, running: false, enhancements: [], enhancementsWhat: [], trains: [], adTracks: null, cars: [], standingWest: 0, facility: null, links, what: '', }; return officeSvg([cell] as never, 99).replace( /]*>[^<]*<\/text>/g, '', ); } // --------------------------------------------------------------------------- /** * THE TURN CHART — the five phases of a Stage, in order, with the current one lit. * * Built by `turnChartHtml`, shared with both replay viewers, so all three screens report where you * are in the Day the same way and with the same violet highlight. It used to live here alone. */ function renderTurnChart(f: Frame): void { const actorName = f.actor === null ? null : (f.players[f.actor]?.name ?? null); $('turnchart').innerHTML = turnChartHtml(f, actorName); } /** * The settings this game was dealt under, beside the seed, because the seed alone does not name it. * * Abbreviated to fit a header that must not wrap — `3 cards · 1/1/0` — with the whole of it in the * tooltip. Written down at all because a playtest note is worthless without it: "scored 4" means one * thing at 1 Revenue per transit and another at 5. */ function renderHouseRules(rules: HouseRules): void { const { passengerPerCoach: pax, freightPerLoad: frt, trainPerTransit: trn } = rules.revenue; const short = { threeRandom: '3 cards', sixRandom: '6 cards', threeTrackThreeOther: '3+3 cards' }; const el = $('houserules'); el.textContent = `· ${short[rules.startingHand]} · ${pax}/${frt}/${trn}`; const handWords = STARTING_HAND_LABELS.find((o) => o.value === rules.startingHand)?.label ?? ''; el.title = `Opening hand: ${handWords.toLowerCase()}.\n` + `Passenger revenue per coach: ${pax} (paid on boarding and again on detraining).\n` + `Freight revenue per load: ${frt} (paid on loading and again on unloading).\n` + `Train revenue per transit: ${trn} (paid to every player when a train leaves the Division).`; } /** * THE HOUSE RULES AND VICTORY DIALS TRAVEL IN THE URL, BESIDE THE SEED. * * A seed on its own no longer names a game: `?seed=430` dealt three random cards is a different * railroad from `?seed=430` dealt three track and three other, and at 0 Revenue per transit it is a * different economy again — and now a game with `minrev=15` is a different game from one with * `minrev=0`. The link has to carry all of it or "same link, same deal" stops being true — and the * New Game dialog navigates by URL, so this is also how its answers reach `start()`. * * Absent parameters mean the DEFAULTS, not the legacy rules: a bare `?seed=430` is a new game at * today's settings. It is a save with no rules in it that is old (`game.ts`, `configFor`). */ const RULE_PARAMS = { passenger: 'passengerPerCoach', freight: 'freightPerLoad', transit: 'trainPerTransit' } as const; /** The four victory-condition dials added 2026-08-20 (`GameConfig`), same URL-round-trip convention. */ const VICTORY_PARAMS = { days: 'days', minrev: 'minCombinedRevenue', colday: 'maxCollisionsPerDay', coltotal: 'maxCollisionsTotal', } as const; function gameOptionsFromUrl(params: URLSearchParams): NewGameOptions { const rules: HouseRuleOverrides = {}; const hand = params.get('hand'); if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand; const revenue: Partial = {}; for (const [param, key] of Object.entries(RULE_PARAMS)) { const raw = params.get(param); // `houseRules()` clamps and rounds, so anything hand-edited into the URL lands in range rather // than dealing a game at 900 Revenue a coach. if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) revenue[key] = Number(raw); } if (Object.keys(revenue).length > 0) rules.revenue = revenue; const options: NewGameOptions = { houseRules: rules }; for (const [param, key] of Object.entries(VICTORY_PARAMS)) { const raw = params.get(param); // Negative or fractional values from a hand-edited URL are clamped the same way `configWith`'s // defaults are — a stray `minrev=-5` should mean "off-ish", not a config the engine never sees. if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) { options[key] = Math.max(0, Math.round(Number(raw))); } } return options; } function rulesToUrl(rules: HouseRules, options: NewGameOptions, seed: string): string { const params = new URLSearchParams(); if (seed !== '') params.set('seed', seed); params.set('hand', rules.startingHand); for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key])); for (const [param, key] of Object.entries(VICTORY_PARAMS)) { const value = options[key]; if (value !== undefined) params.set(param, String(value)); } return `?${params}`; } /** * `?seat=` PRESENT means multiplayer (D4 — one bundle, runtime switch). The seed, house rules and * URL-carried save/restore logic below are all solitaire concepts: a remote game's rules come from * whatever the server was configured with, not from this browser's URL or `localStorage`. */ function start(): void { const params = new URLSearchParams(location.search); const seatParam = params.get('seat'); if (seatParam !== null) { const seat = Number(seatParam) as PlayerIndex; session = createRemoteSession(seat, params.get('secret') ?? ''); applyCapabilities(); // A LocalSession has data the instant it is constructed; a RemoteSession does not — its first // real Frame only exists once the SSE connection's first push arrives, so the first render waits // for `subscribe`'s callback rather than firing immediately (`session.ts`'s own doc comment on // `createRemoteSession` explains why `view()` would otherwise throw). session.subscribe(render); return; } const requested = params.get('seed'); // A seed in the URL makes a game shareable and reproducible: same link, same deal. const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9); const local = createLocalSession(seed, gameOptionsFromUrl(params)); session = local; // A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see // `configFor`. That is why the restore happens after the session is built rather than feeding it. const saved = load(); if (saved && requested === null) local.restore(saved); applyCapabilities(); // Every render goes through the session, so the page redraws whenever the game says it changed — // which is what a remote session will use to push. Locally it fires on each accepted intent. session.subscribe(render); render(); } /** * Hide the controls this session does not offer. * * Undo, a local save and dealing a new game are all things only a local session can do — a server * cannot un-see what the other players have already seen, the server is the store, and dealing is * the lobby's job. Hidden rather than disabled: a greyed-out Undo in a multiplayer game invites the * question "why not?" every turn, and the honest answer is that the control does not belong there. */ function applyCapabilities(): void { const c = session.capabilities; const hide = (id: string, on: boolean): void => { const el = document.getElementById(id); if (el) el.hidden = !on; }; hide('undo', c.undo); hide('savefile', c.saveLocal); hide('newgame', c.newGame); } function render(): void { const f = session.view(); const menu = session.menu(); // Which squares the selected card or track piece may go on. Highlighting them is what turns the // coordinate list into a board: you pick the thing, then click where it goes. // Squares light up only while a card is picked FOR PLAY. `selected` names the card; the placeable // entry that carries its squares is keyed the same way the menu keys it. const forPlay = mode === 'play' && selected !== null ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined; const chosen = forPlay ? menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === forPlay.placeKey) : undefined; // A spot with no coordinate is not on this board — ABS Signals goes out on the Mainline — so it // is chosen from the action list and lights nothing in the district. const spotsAt = new Map(); for (const sp of chosen?.spots ?? []) { if (sp.coord === null) continue; const key = `${sp.coord.row},${sp.coord.col}`; spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]); } renderTurnChart(f); $('revenue').textContent = String(f.revenue); /** * THE OBJECTIVE, WITHOUT THE COMMENTARY. * * It used to read "3 of 20 · 2 Days left · behind the pace (expected 8)". The score, the target and * the Days left are what a player steers by; the pace verdict and the engine's guess at what the * score ought to be were an opinion taking up the one line that must not wrap — and the expected * figure came from a target that is itself an open question. */ const obj = $('objective'); obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`; obj.className = 'pace'; // The seed is never sent to a remote client at all (it would leak every future shuffle and roll, // `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return. $('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${session.seat()}`; renderHouseRules(f.houseRules); // -- division $('division').innerHTML = divisionSvg(f.division); applyZoom($('division')); // -- board. Both renderers are shared with the replay so the two can never draw different // pictures of the same position. const grid = $('grid'); // The empty squares a legal placement would extend the district onto. Drawn by `officeSvg` in the // same pass so they share its origin and its canvas: a target outside the canvas is a legal move // the player cannot click. const ghostCoords = [...spotsAt.keys()] .filter((k) => !f.cells.some((c) => `${c.row},${c.col}` === k)) .map((k) => { const [gr, gc] = k.split(',').map(Number); return { row: gr!, col: gc! }; }); /** * WHAT PLAYING ON AN OCCUPIED SQUARE WOULD DO. * * Three different acts light up the same blue: building on empty ground, EXTENDING the Running * Track at a Limits sign (the sign moves outward with the card — the only way a district grows * along the main), and ATTACHING an Enhancement to a card already down. The first says "place * here" on a ghost card; the other two said nothing at all, which is why the Depot lighting up * read as a bug rather than as an offer. */ const legalCaps = [...spotsAt.keys()].flatMap((k) => { const cell = f.cells.find((c) => `${c.row},${c.col}` === k); if (!cell) return []; const label = cell.kind === 'limits' ? 'EXTEND THE RUNNING TRACK HERE' : cell.kind === 'office' ? 'ATTACH TO YOUR OFFICE' : 'ATTACH TO THIS CARD'; return [{ row: cell.row, col: cell.col, label }]; }); grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew); applyZoom(grid); // Wire the roster chips: clicking one sets `selectedCrew`, the same value the "Which train are // you switching?" picker writes, so the board and the action panel drive one value either way. for (const g of Array.from(grid.querySelectorAll('g[data-crew]'))) { const trayId = (g as HTMLElement).dataset['crew']; if (trayId) (g as unknown as HTMLElement).onclick = () => { selectedCrew = trayId; render(); }; } // Highlighting rides on top of the drawing: outline the legal squares and make them clickable. for (const [key, list] of spotsAt) { const [gr, gc] = key.split(',').map(Number); const g = grid.querySelector(`g[data-cell="${gr},${gc}"]`); if (g) { g.classList.add('bs-legal'); (g as unknown as HTMLElement).onclick = () => pick(key, list); } } // Wire the targets. They are already in the SVG, so nothing is re-serialised here. for (const [key, list] of spotsAt) { if (f.cells.some((c) => `${c.row},${c.col}` === key)) continue; const g = grid.querySelector(`g[data-ghost="${key}"]`); if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list); } /** * THE SWITCHING MOVE, ON THE BOARD. * * Every switching decision is about geography — which card the crew can reach, what it will couple * on the way, whether it can get back — and none of it was drawn: the moves were text buttons * reading "move to (0, -2)". The classes and the replay viewer have highlighted a position for * months; the play page simply never used them. * * WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own * tooltip, so "why can I not get into that industry?" is answered by hovering the industry. */ /** * ONE CREW'S SQUARES AT A TIME. Drawing every crew's reachable squares at once is worse than * drawing one crew's: the highlights merge into a single blob and stop meaning "here is where * THIS train can go", which is the whole reason they are on the board. */ const crew = pickedCrew(f); if (crew && !forPlay) { // Marked on the crew strip, not the whole card: the Office is the one square a second train may // share, and outlining the card would claim it belongs to both. const strip = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"] .bs-crew`); if (strip) strip.classList.add('bs-from'); for (const c of crew.to) { const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`); if (g) g.classList.add('bs-focus'); } for (const b of crew.blocked) { const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`); if (!g) continue; // Two different things wearing two different marks. A turnout you cannot STOP on is not in // your way — you run through it — so it must not be drawn like an industry that is locked. const passable = b.kind === 'noStopping'; g.classList.add(passable ? 'bs-nostop' : 'bs-blocked'); const own = g.getAttribute('data-tip') ?? ''; const head = passable ? 'NO STOPPING HERE' : 'THE CREW CANNOT MOVE HERE'; g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`); } } function pick(key: string, list: { label: string; index: number }[]): void { if (list.length === 1) { const intent = menu.options[list[0]!.index]; if (intent) void session.submit(intent); selected = null; pendingAt = null; } else { pendingAt = key; } render(); } // -- facilities $('facs').innerHTML = facilitiesHtml(f); // Name AND effect. A hand of names alone tells a player nothing about what they can do. // Name and status stay on the page; what the card DOES is reference detail, so it hovers. /** * THE HAND, AS THE ACTION SURFACE. * * Each card carries its own verbs. This used to be a read-only row, with the same cards appearing * again in the action list — once as subjects to play and once as one button per Department to * discard, which for a four-card hand was twelve buttons repeating three choices four times. */ $('hand').innerHTML = menu.hand.length ? menu.hand .map((h) => { const canPlace = h.placeKey !== null && h.spots > 0; const canPlay = canPlace || h.playNow !== null; const canDiscard = h.discard.some((d) => d !== null); const picked = selected === `hand:${h.cardId}`; const verb = (kind: string, on: boolean, text: string, tip: string): string => on ? `` : ''; // A track card shows what it would look like on the board, in every orientation it has — // which is how a player sees a turnout has two at all, and what a curve actually is. const fig = h.shapes.length ? ` data-tip-html="${esc(h.shapes.map((l) => piecePreview(l, h.name)).join(''))}"` : ''; // The card just drawn, badged so it can be told from the two beside it. It stands until // another draw replaces it, rather than flashing once — the question a player asks looking // at the row is "which of these is new", not "did something happen". const fresh = h.cardId === session.justDrawn(); return ( `
` + `${esc(h.name)}` + `
` + verb( 'play', canPlay, canPlace ? `play ${h.spots}` : 'play', canPlace ? `Play it onto the board — ${h.spots} square${h.spots === 1 ? '' : 's'} will light up.` : 'Play it. This card needs no square on the board.', ) + verb('discard', canDiscard, 'discard', 'Discard it face up on top of a Department — pick which one.') + `
` ); }) .join('') : 'empty'; /** * THE THREE DEPARTMENT PILES, and how deep each one is. * * They are decks, not single face-up cards: a discard goes on TOP of the one its owner chooses, so * a pile is a card being offered with a history of cards buried under it. Only the top may be * taken, which makes the depth real information — a deep pile is where cards have been put beyond * reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw * action rather than a click on the card itself. */ $('depts').innerHTML = pilesHtml(f); renderYards(f); // -- the hand's verbs. Picking one selects the card and says what it is for; the "where" is then // highlighted in place, on the board or on the Department piles. for (const b of Array.from($('hand').querySelectorAll('button.cardact'))) { const node = b as HTMLElement; node.onclick = () => { const cardId = node.dataset['card']!; const verb = node.dataset['verb'] as 'play' | 'discard'; const entry = menu.hand.find((h) => h.cardId === cardId); if (!entry) return; // A card that needs no square goes straight down; there is nothing to ask. if (verb === 'play' && entry.placeKey === null && entry.playNow !== null) { const intent = menu.options[entry.playNow]; if (intent) void session.submit(intent); selected = null; mode = null; pendingAt = null; render(); return; } const key = `hand:${cardId}`; const same = selected === key && mode === verb; selected = same ? null : key; mode = same ? null : verb; pendingAt = null; render(); }; } // -- discarding: the three Department piles become the targets. They already show their top card // and their depth, which is exactly what you choose between. const discarding = mode === 'discard' && selected !== null ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined; if (discarding) { // Dim the piles that are not targets, so the three that are read as a choice being offered // rather than as four cards that happen to be there. $('depts').classList.add('aiming'); for (const el of Array.from($('depts').querySelectorAll('[data-dept]'))) { const node = el as HTMLElement; const slot = Number(node.dataset['dept']); const index = discarding.discard[slot]; if (index === null || index === undefined) continue; node.classList.add('target'); node.onclick = () => { const intent = menu.options[index]; if (intent) void session.submit(intent); selected = null; mode = null; render(); }; } } else { $('depts').classList.remove('aiming'); } // -- making up a train: the Division Yard chip that shows the car IS the button. if (menu.makeUp) { for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) { const node = el as HTMLElement; const car = menu.makeUp!.cars.find( (c) => c.carType === node.dataset['car'] && c.loaded === (node.dataset['loaded'] === '1'), ); if (!car) continue; node.classList.add('addable'); node.onclick = () => { const intent = menu.options[car.index]; if (intent) void session.submit(intent); render(); }; } } /** * THE TIMETABLE, with the slot the die just filled flashed. * * `scheduled` is cleared as it is consumed, so the flash marks the moment rather than the state — * it is gone by the next render, which is what makes it read as "that just happened". */ // Draining: the flash marks the moment the die was read, not a state, so it is gone by the next // render. Taken once here and handed to both the timetable and the action panel. const justSet = session.takeScheduled(); $('timetable').innerHTML = timetableHtml(f, justSet); $('blocked').innerHTML = blockedHtml(f); // -- log const log = $('log'); log.innerHTML = session.lines() .slice(-60) .map((l) => `
${esc(l.text)}
`) .join(''); log.scrollTop = log.scrollHeight; /** * SAY WHEN THE PHASE TURNS OVER. * * The five phases run themselves once Local Operations ends, so the page can change out from under * a player between one click and the next. The turn chart already shows WHERE you are; this says * that it moved, which is the part you miss when you were looking at the board. */ if (lastPhase !== null && lastPhase !== f.phaseKey) { const el = $('phasenote'); el.textContent = `${f.phase} — Day ${f.day}, Stage ${f.stage}`; el.className = 'shown'; window.setTimeout(() => { if (el.textContent?.startsWith(f.phase)) el.className = ''; }, 2600); } lastPhase = f.phaseKey; /** * A train completing its run pays every player and nobody took a turn to cause it, so it is said * out loud rather than left in the log. Drained, so it shows once and does not re-fire on a redraw. */ const announcement = session.takeAnnouncement(); if (announcement) { const el = $('announce'); el.textContent = announcement; el.className = 'shown'; window.setTimeout(() => { if (el.textContent === announcement) el.className = ''; }, 4200); } renderDistrict(f); renderActions(menu, f, justSet); renderUndo(); // Drain whatever the last batch of events earned. Cleared either way, so turning sound on does // not then play a backlog of everything that happened while it was off. const cues = session.takeCues(); if (soundOn) for (const c of cues) playCue(c); save(); } /** * UNDO, as far back as you like. * * The game is a seed and a list of intents, so stepping back is replaying without the last one — * see `undo()` in game.ts. The button says how many moves are behind you, because "can I still get * back?" is the question it exists to answer; it goes quiet when there is nothing to take back. * * The whole page is re-rendered from the rebuilt game, including the history panel, so nothing on * screen is left describing a move that no longer happened. Any card picked up mid-choice is * dropped: the position it was going to be played into may not exist any more. */ function renderUndo(): void { const btn = document.getElementById('undo') as HTMLButtonElement | null; if (!btn || !isLocal(session)) return; // Captured as a `const` rather than read as `session` again inside the closure below: `session` is // a mutable module-level `let`, so TypeScript cannot carry the `isLocal` narrowing across a closure // boundary — a local const it can never see reassigned keeps the narrowed `LocalSession` type. const local = session; const n = local.steps(); btn.disabled = n === 0; btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`; btn.onclick = () => { // The session drops the rebuilt game's cues and draws — replaying the history re-records them, // and none of it is news to a player who just stepped back. if (!local.undo()) return; selected = null; mode = null; pendingAt = null; // A phase change is announced by comparing against the last frame drawn. Stepping BACK into a // different phase is not that event, so the banner is suppressed rather than fired backwards. lastPhase = null; }; } /** * The two yards, and how close the Division Yard is to running out. * * §2 — used Rolling Stock is set out in the Classification Yard, and it only returns when the * Division Yard is BARE. So the interesting number is not how much has been used but how little is * left, and the moment the Division Yard empties a whole pile comes back at once. */ function renderYards(f: Frame): void { $('divyard').innerHTML = yardHtml(f.yards.division); $('clsyard').innerHTML = yardHtml(f.yards.classification); $('divtot').textContent = `${f.yards.divisionTotal} cars`; $('clstot').textContent = `${f.yards.classificationTotal} cars`; // The one thing worth calling out: the yard about to turn over. const bare = f.yards.divisionTotal === 0; $('divyard').classList.toggle('bare', bare); $('yardnote').textContent = bare ? `The Division Yard is bare — the ${f.yards.classificationTotal} cars in Classification return to it now.` : 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.'; } function renderDistrict(f: Frame): void { const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open'; const sec = $('district'); if (open) sec.classList.remove('folded'); else sec.classList.add('folded'); const cars = f.cells.reduce((n, c) => n + c.cars.length, 0); // Trains, not cards-with-a-crew: the Office is the one card that may hold more than one, and a // card-count silently read "1 crew on the board" with two trains standing at a busy Station. const crew = f.cells.reduce((n, c) => n + c.trains.length, 0); $('districtsummary').textContent = `${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` + (crew > 0 ? ` · ${crew} crew on the board` : ''); // Say what pressing it DOES, not what the panel is currently doing. "auto · folded" reads as a // status line and was missed entirely; "always show" is an instruction. const btn = $('districttoggle'); btn.textContent = districtMode === 'auto' ? (open ? 'auto-hide: on — click to keep open' : 'auto-hide: on — click to show') : districtMode === 'open' ? 'always showing — click for auto-hide' : 'always hidden — click for auto-hide'; btn.onclick = () => { // auto -> pin it to the opposite of what auto is doing -> back to auto. districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto'; saveSettings({ districtMode }); render(); }; } /** * The square an action button acts on, as an attribute the board can be matched against. * * `null` for the actions that act on no square at all — ending a turn, drawing a card, a train card * that goes to the timetable — so those buttons carry nothing and highlight nothing. */ function cellRef( coord: { row: number; col: number } | null | undefined, route?: { row: number; col: number }[], ): string { const square = coord ? ` data-square="${coord.row},${coord.col}"` : ''; // Only a `switch.move` with more than one legal route carries this (docs/plans/switching-paths.md) // — every intermediate square the chosen route runs over, so hovering lights the whole road, not // just where it ends. const path = route && route.length > 0 ? ` data-route="${route.map((c) => `${c.row},${c.col}`).join(' ')}"` : ''; return square + path; } /** * POINT AT THE SQUARE THE BUTTON MEANS. * * The action list is a column of sentences that differ by a coordinate — "(1,3)" against "(-1,3)" — * and the board is right beside it saying nothing about which one is which. Hovering a button now * lights its square up, so the check happens with the eye rather than by reading two numbers off a * button and finding them on a map. Reported by Jesse: recoverable in solitaire, where Undo is a * click; a disaster in multiplayer, where it is not. * * ON FOCUS AS WELL AS HOVER, so tabbing through the list works the same way as pointing at it — the * keyboard route is not a lesser one. * * BOTH KINDS OF SQUARE. A played card is a `data-cell`; a square being placed ONTO is an empty * `data-ghost` target, which is precisely the case where the player is choosing between coordinates. * Matching only one of the two would leave the most mistake-prone moment unhelped. */ function wirePointing(node: HTMLElement, key: string, routeKeys: readonly string[] = []): void { const grid = $('grid'); const keys = [key, ...routeKeys]; const marks = (): Element[] => keys .flatMap((k) => [grid.querySelector(`g[data-cell="${k}"]`), grid.querySelector(`g[data-ghost="${k}"]`)]) .filter((g): g is Element => g !== null); const on = (): void => { for (const g of marks()) g.classList.add('bs-point'); }; const off = (): void => { for (const g of marks()) g.classList.remove('bs-point'); }; node.onmouseenter = on; node.onmouseleave = off; node.onfocus = on; node.onblur = off; } function renderActions( menu: Menu, f: Frame, justSet: number | null, ): void { const el = $('actions'); if (f.status !== 'active') { const o = f.outcome; el.innerHTML = `
` + `${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}
` + `final Revenue ${f.revenue} against a target of ${f.objective.target}
` + ``; $('again').onclick = () => { clearSave(); location.search = ''; }; return; } if (menu.direct.length === 0 && menu.placeable.length === 0) { el.innerHTML = '
nothing to decide — the engine is running the Division
'; return; } const apply = (index: number): void => { const intent = menu.options[index]; if (intent) void session.submit(intent); selected = null; mode = null; pendingAt = null; render(); }; /** * A long label is two things: the action, and why it is offered. Put the first on the button and * the second on the tooltip, or the list crowds out the board. * * AND IF THE LABEL CARRIES NO EXPLANATION, ask the card. Tooltips used to depend entirely on a * label happening to contain an em-dash, so an action naming a card could have none at all while * the same card in hand explained itself perfectly — reported on "Realignment on Mainline card 3", * which had neither a name for the card it meant nor a word about what it would do. */ const actionButton = (a: { index: number; label: string; tip?: string; coord?: { row: number; col: number }; route?: { row: number; col: number }[]; }): string => { const { label, index } = a; const cut = label.indexOf(' — '); const head = cut > 0 ? label.slice(0, cut) : label; // The menu resolved the card's description server-side, so the page never needs the state. const rest = cut > 0 ? label.slice(cut + 3) : (a.tip ?? ''); return ( `` ); }; /** * MOVES LEFT, WHERE THE MOVES ARE. * * §6.1 gives six Moves a turn and every switching decision is really "can I still get back?" — so * the count belongs beside the buttons. It was reported only in the history panel, which is the * one place a player is not looking while switching. */ /** * WHAT THE DIE DID, said where the player is looking. * * Playing a train card rolls 1D12 for its departure Stage, and the card simply left the hand — the * answer arrived only in the history panel, one line among many. The timetable now shows it and * flashes the slot; this says it in words at the same moment. */ const scheduledNote = justSet !== null && f.timetable[justSet] != null ? `
` + `Train ${f.timetable[justSet]} is scheduled to depart at Stage ${justSet + 1} — see the Timetable
` : ''; const movesNote = f.movesLeft !== null ? `
` + `${f.movesLeft} of ${MOVES_PER_LOCAL_OPS} Moves left
` : ''; // A heading over the buttons, so the panel says what it is before it says what is in it. The // list below is already phase-specific: it comes from `legalActions`, so in the Cargo phase with // no worker able to act, the only thing offered is "End my Cargo phase". let html = `

Actions

${scheduledNote}${movesNote}`; /** * WHICH TRAIN AM I SWITCHING? Only asked when there is more than one crew to be switching, so a * solitaire opening — one crew, no ambiguity — is unchanged. The chosen one is the crew whose * reachable squares the board draws, so this row and the highlights are the same statement. */ if (f.moves.length > 1) { const chosen = pickedCrew(f); html += `

Which train are you switching?

` + f.moves .map( (m) => ``, ) .join('') + `
`; } /** * WHAT IS LEFT IN THE ACTION LIST. * * Everything about a card in hand now lives on the card, and everything about making up a train * lives on the yard chip. What remains is the rest of the turn: the Local Operations choice, * drawing, switching moves, the Freight Agent, and finishing. * * EXCLUDE BY TITLE, NOT BY PREFIX. This used to be `!/^Making up /.test(g.title)`, on the * assumption only the yard-chip panel's own group is ever titled that way. It is not: "choose * where Extra X22 starts" is titled by `trainCardTitle`, which also begins "Making up Extra * X22…", so the regex swallowed it too — and with no tray yet being filled, `menu.makeUp` is * null, so nothing rendered it anywhere else. REPORTED as the game hanging with the Freight * House mid-cycle: an Extra came due, the action panel had a legal decision and zero buttons, * and nothing short of restarting looked like it would ever move again. Matching the exact * title of the ONE group `menu.makeUp` actually covers leaves every other "Making up …" group, * however it is titled, on screen where a player can act on it. */ html += menu.direct .filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title) .map( (g) => `

${esc(g.title)}

` + g.actions .map((a) => { // §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on // the INTENT, not the label: matching button text would break the moment the wording // changed, and would have caught `switch.end` too. return actionButton(a); }) .join('') + `
`, ) .join(''); /** * MAKING UP A TRAIN. * * The cars are added from the Division Yard chips, but the panel still has to exist: it names the * train and what its card calls for, and it carries the "no more cars" button, which is the ONLY * way to finish when the yard holds nothing the train may take. * * Moving the cars onto the yard chips without this left a train being made up with no control on * screen at all whenever no chip was addable — a hard softlock, reported at Stage 10 of seed * 775569289 with Train 10 waiting at the West Division Point. */ if (menu.makeUp) { const addable = menu.makeUp.cars.length; html += `

${esc(menu.makeUp.title)}

` + `
` + (addable > 0 ? `Click a car in the Division Yard below to add it — ${addable} kind${addable === 1 ? '' : 's'} it may take are highlighted there.` : menu.makeUp.pass !== null ? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.' : 'Nothing in the Division Yard may join this train, and §7 does not allow passing while the yard holds cars.') + `
` + /** * WHICH ORDER TO ADD THEM IN, when the order is what decides whether the train can work. * * Only ever present for a train the order can lock — 7/8 Local — so this is not a standing * caption a player learns to skip. Reported from play as "I can't drop a car at all": the * Local made up ENGINE, freight, coach cannot set anything out for the rest of the game, and * nothing on screen said so until the crew was out on the district with no button to press. */ (menu.makeUp.advice ? `
${esc(menu.makeUp.advice.text)}
` : '') + (menu.makeUp.pass !== null ? `` : '') + `
`; } /** * The card is picked in the hand and the square on the board, so all that is left here is the * ROTATION — the one question neither of those can ask, and only for the card actually picked. */ const picked = mode === 'play' && selected !== null ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined; for (const group of menu.placeable) { const relevant = group.items.filter((it) => picked && it.subjectKey === picked.placeKey); if (relevant.length === 0) continue; html += `

${esc(group.title)}

`; for (const item of relevant) { { // Once a square is picked, show only that square's rotations — the rest is noise. const shown = pendingAt ? item.spots.filter((sp) => sp.coord !== null && `${sp.coord.row},${sp.coord.col}` === pendingAt) : item.spots; html += `
` + (pendingAt ? `choose a rotation for (${esc(pendingAt.replace(',', ', '))}):` : 'click a highlighted square, or:') + `
` + shown .map((sp) => { // The picture rides on the button that would lay it, so hovering a rotation shows // that rotation — which is the question "which of these two do I want?" answered. const fig = sp.links.length ? ` data-tip-html="${esc(piecePreview(sp.links, item.subject))}"` : ''; return ( `` ); }) .join('') + `
`; } } html += `
`; } if ( f.phaseKey === 'localOps' && f.option === 'draw' && !menu.options.some((i) => i.type === 'draw.end') ) { // The hand being counted is the ACTOR's — they are the one who cannot end the turn. const hand = f.handCount; html += `
`; } el.innerHTML = html; for (const b of Array.from(el.querySelectorAll('button.act'))) { const node = b as HTMLElement; // The crew buttons choose what the board draws; they submit nothing, so they must not fall // through to `apply` with an undefined index. const crewId = node.dataset['crew']; node.onclick = crewId ? () => { selectedCrew = crewId; render(); } : () => apply(Number(node.dataset['i'])); const square = node.dataset['square']; const route = node.dataset['route']; if (square) wirePointing(node, square, route ? route.split(' ') : []); } } // --------------------------------------------------------------------------- // Saving. localStorage only — nothing leaves the browser. // --------------------------------------------------------------------------- /** * Write the game out as a file. * * The save IS the replay: a seed and the moves made, which the engine can replay exactly. A few * hundred bytes, so a finished game can be emailed or dropped on the site's replay directory — * where a rendered page would have been megabytes. */ function downloadSave(): void { // The button this fires from is hidden by `applyCapabilities()` for any session that cannot save // (`#savefile`), but nothing stops this function being called directly, so the guard is repeated // here rather than only trusted to the DOM. if (!isLocal(session)) return; const data = JSON.stringify(session.save(), null, 1); const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `station-master-seed${session.seed()}-day${session.view().day}.json`; a.click(); URL.revokeObjectURL(url); } function save(): void { if (!isLocal(session)) return; try { localStorage.setItem(SAVE_KEY, JSON.stringify(session.save())); } catch { // A full or disabled localStorage must not take the game down with it. } } function load(): Save | null { try { const raw = localStorage.getItem(SAVE_KEY); return raw ? (JSON.parse(raw) as Save) : null; } catch { return null; } } function clearSave(): void { try { localStorage.removeItem(SAVE_KEY); } catch { /* nothing to do */ } } // BOTH stylesheets. The board is SVG built by the shared renderers, and every shape it draws is // styled by class — without BOARD_CSS each rect falls back to a black fill on a near-black // background, so the cards are drawn correctly and are simply invisible. const pageStyle = document.createElement('style'); pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS + TURNCHART_CSS + PANEL_CSS; document.head.appendChild(pageStyle); installTooltips(); const saveBtn = document.getElementById('savefile'); if (saveBtn) saveBtn.onclick = downloadSave; /** * Forget the saved game and deal a fresh one. * * The only way out used to be finishing the game — `start()` restores from localStorage on every * load, so a game you no longer wanted followed you across reloads, and the "new game" button * appeared solely on the game-over screen. Confirmed because it throws the whole game away — Undo * steps back one action at a time, but nothing brings back a game that has been dealt over, and the * replay download is right beside it. * * Navigating rather than re-rendering, so a stale `?seed=` in the URL goes too — leaving it would * deal the same game again and look like the button had done nothing. */ const newBtn = document.getElementById('newgame'); const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null; if (newBtn && dlg) { const field = (id: string): T => document.getElementById(id) as T; type Mode = 'solitaire' | 'competitive' | 'coop'; /** * PICKING A TYPE JUST SETS THE FIELDS BELOW TO THAT TYPE'S DEFAULTS (Jesse's design, 2026-08-20) — * every number stays editable afterward, so "Competitive" isn't a fixed ruleset, it's a starting * point. `players = 4` for Competitive/Co-op is a nominal stand-in: there's no lobby yet to ask who * is actually seated (Phase 4), so this is a suggestion a real seat count will replace. * * Only Solitaire can be dealt today — Deal disables itself for the other two, with a note, rather * than pretending a click would do something (`RemoteSession` is Phase 2). */ function applyModePreset(mode: Mode): void { const days = 5; const players = mode === 'solitaire' ? 1 : 4; field('ng-days').value = String(days); field('ng-minrev').value = String(collectiveRevenueFloor(players, days)); field('ng-colday').value = String(DEFAULT_MAX_COLLISIONS_PER_DAY); field('ng-coltotal').value = String(DEFAULT_MAX_COLLISIONS_TOTAL); // No valid target for these cards in Solitaire or Co-op — forced off, not merely defaulted off. const pvp = field('ng-pvp'); pvp.checked = mode === 'competitive'; pvp.disabled = mode !== 'competitive'; field('ng-deal').disabled = mode !== 'solitaire'; field('ng-multiplayer-note').style.visibility = mode === 'solitaire' ? 'hidden' : 'visible'; } for (const input of dlg.querySelectorAll('input[name="ng-mode"]')) { input.onchange = () => applyModePreset(input.value as Mode); } /** * ASK FOR ALL OF IT, rather than documenting URL parameters in the title bar. * * It asked for the seed alone, through `prompt()`. The opening hand and the three revenue rates * were constants in the source, so trying a variation meant an edit and a rebuild — and balance is * the open question this game has (`TODO.md`). A dialog is what lets a playtest be a playtest. * * The dialog OPENS ON THE RULES IN PLAY rather than on the defaults: dealing a second game to * compare against the first is the common case, and re-entering settings each time is how a * comparison silently stops comparing. Mode always reopens on Solitaire — it's the only one a * previous session could actually have been, since Deal is disabled for the other two. */ newBtn.onclick = () => { // The button itself is hidden for a session that cannot deal (`applyCapabilities`), but the // dialog's whole answer-reading/URL-navigating flow below assumes a LocalSession throughout, so // the guard is repeated — and `local` is captured as a `const` so the narrowing survives the // closures below it (see `renderUndo`'s identical note on why `session` itself cannot be). if (!isLocal(session)) return; const local = session; const f = local.view(); const day = f.day; const started = f.status === 'active' && (day > 1 || f.stage > 1); if (started && !confirm(`Forget this game (seed ${local.seed()}, Day ${day}) and deal a new one?`)) return; const current = f.houseRules; field('ng-seed').value = ''; for (const input of dlg.querySelectorAll('input[name="ng-mode"]')) { input.checked = input.value === 'solitaire'; } applyModePreset('solitaire'); for (const input of dlg.querySelectorAll('input[name="ng-hand"]')) { input.checked = input.value === current.startingHand; } field('ng-passenger').value = String(current.revenue.passengerPerCoach); field('ng-freight').value = String(current.revenue.freightPerLoad); field('ng-transit').value = String(current.revenue.trainPerTransit); // Overwrite the preset with the actual rules in play — solitaire is the only real session today. field('ng-days').value = String(f.days); field('ng-minrev').value = String(f.minCombinedRevenue); field('ng-colday').value = String(f.maxCollisionsPerDay); field('ng-coltotal').value = String(f.maxCollisionsTotal); dlg.showModal(); }; /** * One handler for every way the dialog can close — the Deal button, the Cancel button, and Esc, * which `` answers with an empty `returnValue` and no submit event at all. * * The answers go into the URL and the page navigates, which is the same path `?seed=` already * took: `start()` reads them back, so there is exactly one place that turns a URL into a game. * Deal is disabled whenever the mode radio isn't Solitaire, so this never actually runs for the * other two — nothing here needs to branch on mode. */ dlg.addEventListener('close', () => { if (dlg.returnValue !== 'deal') return; const asked = field('ng-seed').value.trim(); // A seed the browser cannot parse is not a reason to refuse to deal — blank and unparseable // both mean "surprise me", which is what leaving the box alone plainly asks for. const seed = asked === '' || !Number.isFinite(Number(asked)) ? '' : String(Math.trunc(Number(asked))); const picked = dlg.querySelector('input[name="ng-hand"]:checked')?.value; const rules = houseRules({ houseRules: { ...(STARTING_HAND_LABELS.some((o) => o.value === picked) ? { startingHand: picked as StartingHand } : {}), revenue: { passengerPerCoach: Number(field('ng-passenger').value), freightPerLoad: Number(field('ng-freight').value), trainPerTransit: Number(field('ng-transit').value), }, }, }); const victory: NewGameOptions = { days: Math.max(1, Math.round(Number(field('ng-days').value)) || 5), minCombinedRevenue: Math.max(0, Math.round(Number(field('ng-minrev').value)) || 0), maxCollisionsPerDay: Math.max(0, Math.round(Number(field('ng-colday').value)) || 0), maxCollisionsTotal: Math.max(0, Math.round(Number(field('ng-coltotal').value)) || 0), }; clearSave(); const next = rulesToUrl(rules, victory, seed); // Assigning the search string the page ALREADY has does nothing at all, which reads as a button // that did not work — and it is the common case: deal a random seed, decide it was a bad deal, // deal another at the same settings. Reload instead, and `start()` rolls a fresh seed. if (next === location.search) location.reload(); else location.search = next; }); } const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null; const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null; const zoomLabel = document.getElementById('zoomlabel'); if (zoomOutBtn && zoomInBtn && zoomLabel) { const paintZoom = (): void => { zoomLabel.textContent = `${Math.round(zoom * 100)}%`; zoomOutBtn.disabled = zoom <= ZOOM_LEVELS[0]!; zoomInBtn.disabled = zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1]!; }; const setZoom = (level: number): void => { zoom = level; saveSettings({ zoom }); paintZoom(); // Re-apply to whichever boards are already on the page — no full re-render needed, this is // display-only sizing, same as `render()`'s own calls after each innerHTML assignment. applyZoom($('division')); applyZoom($('grid')); }; zoomOutBtn.onclick = () => { const i = ZOOM_LEVELS.indexOf(zoom as (typeof ZOOM_LEVELS)[number]); if (i > 0) setZoom(ZOOM_LEVELS[i - 1]!); }; zoomInBtn.onclick = () => { const i = ZOOM_LEVELS.indexOf(zoom as (typeof ZOOM_LEVELS)[number]); if (i >= 0 && i < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[i + 1]!); }; paintZoom(); } const soundBtn = document.getElementById('sound'); if (soundBtn) { const paint = (): void => { soundBtn.textContent = soundOn ? '\u{1F50A} sound' : '\u{1F507} muted'; }; soundBtn.onclick = () => { soundOn = !soundOn; saveSettings({ soundOn }); paint(); // Confirm the change audibly — the one press where a sound is unambiguously wanted, and it // doubles as the user gesture the browser needs before any audio may start. if (soundOn) playCue('stage'); }; paint(); } start();