v0.5.0 — multiplayer Phases 2 and 3: a server that runs a game and survives being restarted

Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary).
This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per
docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed
cards, StartOS packaging) are still ahead.

Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing
Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling
submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add
POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/.
src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found
and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server
computing every connected seat's Menu would have handed the acting player's legal moves to a
waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a
rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and
test/redaction.test.ts. Not verified: an actual browser (none available in this environment).

Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then-
rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing
it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment
there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified
live: server killed and restarted mid-game, both seats reconnected exactly where they left off.

Two rules bugs found while building this: the New Train phase never implemented its car-placement
round (every car of every train was placed by the Superintendent alone, in every mode, all along —
now reads the round position off tray.consist.length); and victory conditions are now one shared,
configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and
a dead firstToTarget condition.

Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching
train's crew badge failing to draw once it left the Office square, an unload that always took the
westmost car regardless of which was picked, and a legal decision that could render with zero
buttons.

docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json)
included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated
side-project work, not part of this release. 635 tests, 0 failures.
This commit is contained in:
Jesse
2026-08-20 23:50:38 -04:00
parent f9c4d9fa92
commit c3c5cbfeec
52 changed files with 5282 additions and 420 deletions
+263 -37
View File
@@ -12,21 +12,89 @@ 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 { MOVES_PER_LOCAL_OPS, STARTING_HAND_LABELS, houseRules } from '../engine/content.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 { LocalSession } from './session.ts';
import { createLocalSession } from './session.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;
/**
* The game, behind the Session boundary.
*
* Typed as `LocalSession` because this page is the solitaire client and uses undo, local saves and
* new-game — all of which are local-only. The parts that draw and submit go through the plain
* `Session` surface, which is what a remote client will provide unchanged.
* 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.
*/
let session: LocalSession;
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<Settings>) : {};
// 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<Settings>): 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;
/**
@@ -49,18 +117,20 @@ let pendingAt: string | null = null;
*
* '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.
* 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' = 'auto';
let districtMode: 'auto' | 'open' | 'closed' = settings.districtMode;
/**
* Sound, OFF until asked for.
* 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 — and a playtester who did not ask for noise should not get any.
* One click in the title bar turns it on, and that click is also the gesture browsers require
* before any audio may start.
* 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 = false;
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.
*
@@ -99,6 +169,23 @@ const $ = (id: string): HTMLElement => {
const esc = (s: string): string =>
s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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.
*
@@ -153,19 +240,27 @@ function renderHouseRules(rules: HouseRules): void {
}
/**
* THE HOUSE RULES TRAVEL IN THE URL, BESIDE THE SEED.
* 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. 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()`.
* 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 rulesFromUrl(params: URLSearchParams): HouseRuleOverrides {
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;
@@ -178,29 +273,62 @@ function rulesFromUrl(params: URLSearchParams): HouseRuleOverrides {
if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) revenue[key] = Number(raw);
}
if (Object.keys(revenue).length > 0) rules.revenue = revenue;
return rules;
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, seed: string): string {
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 requested = params.get('seed');
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);
session = createLocalSession(seed, rulesFromUrl(params));
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) session.restore(saved);
if (saved && requested === null) local.restore(saved);
applyCapabilities();
// Every render goes through the session, so the page redraws whenever the game says it changed —
@@ -263,11 +391,14 @@ function render(): void {
const obj = $('objective');
obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
obj.className = 'pace';
$('seed').textContent = String(session.seed());
// 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.
@@ -302,6 +433,7 @@ function render(): void {
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.
@@ -593,14 +725,18 @@ function render(): void {
*/
function renderUndo(): void {
const btn = document.getElementById('undo') as HTMLButtonElement | null;
if (!btn || !session.capabilities.undo) return;
const n = session.steps();
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 (!session.undo()) return;
if (!local.undo()) return;
selected = null;
mode = null;
pendingAt = null;
@@ -657,6 +793,7 @@ function renderDistrict(f: Frame): void {
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();
};
}
@@ -980,6 +1117,10 @@ function renderActions(
* 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);
@@ -991,7 +1132,7 @@ function downloadSave(): void {
}
function save(): void {
if (!session.capabilities.saveLocal) return;
if (!isLocal(session)) return;
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(session.save()));
} catch {
@@ -1043,32 +1184,79 @@ const newBtn = document.getElementById('newgame');
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
if (newBtn && dlg) {
const field = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
type Mode = 'solitaire' | 'competitive' | 'coop';
/**
* ASK FOR ALL THREE, rather than documenting URL parameters in the title bar.
* 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<HTMLInputElement>('ng-days').value = String(days);
field<HTMLInputElement>('ng-minrev').value = String(collectiveRevenueFloor(players, days));
field<HTMLInputElement>('ng-colday').value = String(DEFAULT_MAX_COLLISIONS_PER_DAY);
field<HTMLInputElement>('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<HTMLInputElement>('ng-pvp');
pvp.checked = mode === 'competitive';
pvp.disabled = mode !== 'competitive';
field<HTMLButtonElement>('ng-deal').disabled = mode !== 'solitaire';
field<HTMLElement>('ng-multiplayer-note').style.visibility = mode === 'solitaire' ? 'hidden' : 'visible';
}
for (const input of dlg.querySelectorAll<HTMLInputElement>('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 four settings each time is how a
* comparison silently stops comparing.
* 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 = () => {
const f = session.view();
// 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 ${session.seed()}, Day ${day}) and deal a new one?`)) return;
if (started && !confirm(`Forget this game (seed ${local.seed()}, Day ${day}) and deal a new one?`)) return;
const current = session.view().houseRules;
const current = f.houseRules;
field<HTMLInputElement>('ng-seed').value = '';
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-mode"]')) {
input.checked = input.value === 'solitaire';
}
applyModePreset('solitaire');
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-hand"]')) {
input.checked = input.value === current.startingHand;
}
field<HTMLInputElement>('ng-passenger').value = String(current.revenue.passengerPerCoach);
field<HTMLInputElement>('ng-freight').value = String(current.revenue.freightPerLoad);
field<HTMLInputElement>('ng-transit').value = String(current.revenue.trainPerTransit);
// Overwrite the preset with the actual rules in play — solitaire is the only real session today.
field<HTMLInputElement>('ng-days').value = String(f.days);
field<HTMLInputElement>('ng-minrev').value = String(f.minCombinedRevenue);
field<HTMLInputElement>('ng-colday').value = String(f.maxCollisionsPerDay);
field<HTMLInputElement>('ng-coltotal').value = String(f.maxCollisionsTotal);
dlg.showModal();
};
@@ -1078,6 +1266,8 @@ if (newBtn && dlg) {
*
* 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;
@@ -1097,9 +1287,15 @@ if (newBtn && dlg) {
},
},
});
const victory: NewGameOptions = {
days: Math.max(1, Math.round(Number(field<HTMLInputElement>('ng-days').value)) || 5),
minCombinedRevenue: Math.max(0, Math.round(Number(field<HTMLInputElement>('ng-minrev').value)) || 0),
maxCollisionsPerDay: Math.max(0, Math.round(Number(field<HTMLInputElement>('ng-colday').value)) || 0),
maxCollisionsTotal: Math.max(0, Math.round(Number(field<HTMLInputElement>('ng-coltotal').value)) || 0),
};
clearSave();
const next = rulesToUrl(rules, seed);
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.
@@ -1108,6 +1304,35 @@ if (newBtn && dlg) {
});
}
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 => {
@@ -1115,6 +1340,7 @@ if (soundBtn) {
};
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.