import { GraphicsOverrides, GraphicsOverridesSchema, GraphicsPresets, GraphicsPresetsSchema, } from "../../client/render/gl/GraphicsOverrides"; import { COLUMN_IDS, ColumnId, DEFAULT_STATS_COLUMNS, StatsTableKind, } from "../../client/StatsConstants"; // DesktopShell.ts imports nothing, so this cannot introduce an import cycle // (verified with madge: 58 cycles before and after, none involving it). import { isDesktopShell } from "../../client/DesktopShell"; import { Cosmetics } from "../CosmeticSchemas"; import { PlayerPattern } from "../Schemas"; export function getDefaultKeybinds(isMac: boolean): Record { return { toggleView: "Space", coordinateGrid: "KeyM", buildCity: "Digit1", buildFactory: "Digit2", buildPort: "Digit3", buildDefensePost: "Digit4", buildMissileSilo: "Digit5", buildSamLauncher: "Digit6", buildWarship: "Digit7", buildAtomBomb: "Digit8", buildHydrogenBomb: "Digit9", buildMIRV: "Digit0", attackRatioDown: "KeyT", attackRatioUp: "KeyY", boatAttack: "KeyB", groundAttack: "KeyG", retaliateAttack: "Shift+KeyR", requestAlliance: "KeyK", breakAlliance: "KeyL", swapDirection: "KeyU", zoomOut: "KeyQ", zoomIn: "KeyE", centerCamera: "KeyC", moveUp: "KeyW", moveLeft: "KeyA", moveDown: "KeyS", moveRight: "KeyD", buildMenuModifier: isMac ? "MetaLeft" : "ControlLeft", emojiMenuModifier: "AltLeft", boxSelectWarships: "ShiftLeft", shiftKey: "ShiftLeft", resetGfx: "KeyR", selectAllWarships: "KeyF", pauseGame: "KeyP", gameSpeedUp: "Period", gameSpeedDown: "Comma", altKey: "AltLeft", }; } export const USER_SETTINGS_CHANGED_EVENT = "event:user-settings-changed"; /** * Mixer channels. Category is a pure function of the cue name (categoryOf in * client/sound/Sounds.ts); nothing chooses a channel at the call site. */ export type AudioCategory = | "master" | "music" | "effects" | "alerts" | "ambience" | "interface"; const AUDIO_DEFAULTS: Record = { // Not 1.0, in answer to the "too loud on desktop" reports. perceptualGain // squares the slider position, so the cut is twice what the number reads // as: 0.9 is -0.9 dB on the handle and -1.8 dB by the time it is heard. // // Desktop is where it is felt, because that is the platform this default // actually applies on (see defaultMasterVolume), but it is not a // desktop-only value: it is also where the web carve-out lands a player // who opts in, and the two should agree about how loud "default" is. master: 0.9, music: 0.5, effects: 0.7, alerts: 0.8, ambience: 0.4, interface: 0.5, }; // Read-through, not a migration pass: a category with no key of its own // inherits the value the player had already chosen under the old two-slider // scheme, so splitting effects into four channels doesn't reset three of them. // The legacy keys are left in place and simply stop being written. // Channels the single old "sound effects" slider used to cover. const SPLIT_FROM_SOUND_EFFECTS: readonly AudioCategory[] = [ "effects", "alerts", "ambience", "interface", ]; function clampVolume(value: number): number { if (!Number.isFinite(value)) return 0; return Math.max(0, Math.min(1, value)); } const AUDIO_CHANNELS = [ "master", "music", "effects", "alerts", "ambience", "interface", ] as const; /** Everything "reset to defaults" clears, so the read-through sees a clean slate. */ const AUDIO_RESET_KEYS: readonly string[] = [ ...AUDIO_CHANNELS.map((category) => `settings.audio.${category}`), "settings.audio.muteOnBlur", "settings.audio.alertsWhenUnfocused", // The legacy keys too: leaving them would have the read-through hand the // old two-slider values straight back, which is not "defaults". "settings.backgroundMusicVolume", "settings.soundEffectsVolume", ]; /** * Bumped to force every existing player back to the platform defaults once. * * Version 1 is the new audio delivery itself. The read-through in * audioVolume() below was meant to carry an existing player's two old sliders * across, but it only carries the channels those sliders covered: a player who * had ever dragged ONE of them stored a key, which satisfies the master * carve-out in defaultMasterVolume() -- so master resolves to audible -- while * the four channels the other slider never covered fall through to the new * defaults. That is a web player who opted into music years ago now hearing * the entire new cue layer at full level, having opted into none of it. * * A reset rather than a narrower rule because the stored state cannot say * which it is: "dragged the music slider and left effects alone" and "dragged * the music slider and never had the choice" are the same two keys. Clearing * everything lets the platform default answer instead, which on the web is * silence until the player asks otherwise. * * The cost is that choices already made in the new Audio tab go with it. * Deliberate, and the reason this is version-stamped rather than repeated: * whatever the player picks after the reset is theirs and survives. */ const AUDIO_RESET_VERSION = 1; const AUDIO_RESET_VERSION_KEY = "settings.audio.resetVersion"; /** Every key that means "this player has chosen an audio volume before". */ const AUDIO_VOLUME_KEYS: readonly string[] = [ "settings.backgroundMusicVolume", "settings.soundEffectsVolume", ...AUDIO_CHANNELS.map((category) => `settings.audio.${category}`), ]; const AUDIO_LEGACY_KEY: Partial> = { music: "settings.backgroundMusicVolume", effects: "settings.soundEffectsVolume", alerts: "settings.soundEffectsVolume", ambience: "settings.soundEffectsVolume", interface: "settings.soundEffectsVolume", }; /** * Storage key for the player's selected territory cosmetic. Stores either * `"pattern:[:]"` or `"skin:"` — patterns and skins are * mutually exclusive, so they share one slot. */ export const PATTERN_KEY = "territoryPattern"; export const FLAG_KEY = "flag"; export const CROWN_KEY = "crown"; export const COLOR_KEY = "settings.territoryColor"; export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay"; export const KEYBINDS_KEY = "settings.keybinds"; export const GRAPHICS_KEY = "settings.graphics"; export const GRAPHICS_PRESETS_KEY = "settings.graphicsPresets"; export const EFFECTS_KEY = "settings.effects"; /** Saved cosmetic loadouts — see {@link CosmeticLoadout}. */ export const LOADOUTS_KEY = "settings.cosmeticLoadouts"; /** The loadout slot equip changes are written back into, if any. */ export const ACTIVE_LOADOUT_KEY = "settings.activeLoadout"; // Keep the existing storage key so the rename does not reset saved columns. export const PLAYER_STATS_COLUMNS_KEY = "settings.leaderboardColumns"; export const TEAM_STATS_COLUMNS_KEY = "settings.teamStatsColumns"; const STATS_COLUMNS_KEYS: Record = { player: PLAYER_STATS_COLUMNS_KEY, team: TEAM_STATS_COLUMNS_KEY, }; /** * Cosmetic selections are stored per player: while logged in, the storage key * is suffixed with the player's publicId so selections survive logout and are * restored on the next login (#4955). Logged out, the bare key is used. */ const PER_PLAYER_KEYS: readonly string[] = [ PATTERN_KEY, FLAG_KEY, CROWN_KEY, EFFECTS_KEY, LOADOUTS_KEY, ACTIVE_LOADOUT_KEY, ]; /** * A named snapshot of every equip slot, so a player can switch their whole * cosmetic set in one action. Values are the raw stored forms of the slots: * `pattern` is a PATTERN_KEY value (`"pattern:[:]"` or * `"skin:"`), `flag` a FLAG_KEY value, `crown` a crown name, and * `effects` the EFFECTS_KEY slot map. `null` means the slot is unequipped. */ export interface CosmeticLoadout { name: string; pattern: string | null; flag: string | null; crown: string | null; effects: Record; } /** Loadouts live in localStorage, so the list is bounded. */ export const MAX_LOADOUTS = 10; /** Slots are numbered rather than named: "01", "02", … */ export function loadoutSlotName(slot: number): string { return slot.toString().padStart(2, "0"); } function parseLoadout(value: unknown): CosmeticLoadout | null { if (value === null || typeof value !== "object" || Array.isArray(value)) { return null; } const entry = value as Record; if (typeof entry.name !== "string" || entry.name === "") return null; const slot = (key: string): string | null => typeof entry[key] === "string" ? (entry[key] as string) : null; const effects: Record = {}; if ( entry.effects !== null && typeof entry.effects === "object" && !Array.isArray(entry.effects) ) { for (const [key, name] of Object.entries( entry.effects as Record, )) { if (typeof name === "string") effects[key] = name; } } return { name: entry.name, pattern: slot("pattern"), flag: slot("flag"), crown: slot("crown"), effects, }; } export class UserSettings { private static cache = new Map(); /** publicId of the logged-in player, or null when logged out. */ private static playerId: string | null = null; /** Set while applyLoadout writes, to stop the mirror writing back. */ private static applyingLoadout = false; /** * Sets which player's cosmetic selections are active. Called with the * player's publicId when /users/@me resolves, and with null on logout. * * Selections made while logged out — including values written by builds * that predate per-player keying — are moved into the player's scope, * overwriting the stored ones (the most recent selection wins), so existing * users keep their cosmetics. */ static setPlayerId(playerId: string | null): void { if (UserSettings.playerId === playerId) return; UserSettings.playerId = playerId; const settings = new UserSettings(); if (playerId !== null) { for (const key of PER_PLAYER_KEYS) { const bare = localStorage.getItem(key); if (bare === null) continue; const scopedKey = `${key}:${playerId}`; localStorage.setItem(scopedKey, bare); UserSettings.cache.set(scopedKey, bare); localStorage.removeItem(key); UserSettings.cache.set(key, null); } } // The active selections changed with the identity; let listeners re-read. for (const key of PER_PLAYER_KEYS) { settings.emitChange(key, settings.getCached(key)); } } private storageKey(key: string): string { if (UserSettings.playerId !== null && PER_PLAYER_KEYS.includes(key)) { return `${key}:${UserSettings.playerId}`; } return key; } private emitChange(key: string, value: any): void { try { const maybeDispatch = (globalThis as any)?.dispatchEvent; if (typeof maybeDispatch !== "function") return; (globalThis as any).dispatchEvent( new CustomEvent(`${USER_SETTINGS_CHANGED_EVENT}:${key}`, { detail: value, }), ); } catch { // Ignore - settings should still be applied even if event dispatch fails. } } private getCached(key: string): string | null { const storageKey = this.storageKey(key); if (!UserSettings.cache.has(storageKey)) { UserSettings.cache.set(storageKey, localStorage.getItem(storageKey)); } return UserSettings.cache.get(storageKey) ?? null; } // Change events always use the base key — listeners subscribe with the // exported key constants, not the per-player storage key. private setCached(key: string, value: string, emitChange: boolean = true) { const storageKey = this.storageKey(key); localStorage.setItem(storageKey, value); UserSettings.cache.set(storageKey, value); if (emitChange) { this.emitChange(key, value); } } public removeCached(key: string, emitChange: boolean = true) { const storageKey = this.storageKey(key); localStorage.removeItem(storageKey); UserSettings.cache.set(storageKey, null); if (emitChange) { this.emitChange(key, null); } } private getBool(key: string, defaultValue: boolean): boolean { const value = this.getCached(key); if (!value) return defaultValue; if (value === "true") return true; if (value === "false") return false; return defaultValue; } private setBool(key: string, value: boolean) { this.setCached(key, value ? "true" : "false"); } private getString(key: string, defaultValue: string = ""): string { const value = this.getCached(key); if (value === null) return defaultValue; return value; } private setString(key: string, value: string) { this.setCached(key, value); } private getFloat(key: string, defaultValue: number): number { const value = this.getCached(key); if (!value) return defaultValue; const floatValue = parseFloat(value); if (isNaN(floatValue)) return defaultValue; return floatValue; } private setFloat(key: string, value: number) { this.setCached(key, value.toString()); } emojis() { return this.getBool("settings.emojis", true); } performanceOverlay() { return this.getBool(PERFORMANCE_OVERLAY_KEY, false); } alertFrame() { return this.getBool("settings.alertFrame", true); } anonymousNames() { return this.getBool("settings.anonymousNames", false); } lobbyIdVisibility() { return this.getBool("settings.lobbyIdVisibility", true); } steamBuildSeen() { return this.getBool("settings.steamBuildSeen", false); } markSteamBuildSeen() { this.setBool("settings.steamBuildSeen", true); } steamLobbyLinks(): "ask" | "steam" | "browser" { const value = this.getString("settings.steamLobbyLinks", "ask"); return value === "steam" || value === "browser" ? value : "ask"; } setSteamLobbyLinks(value: "steam" | "browser") { this.setString("settings.steamLobbyLinks", value); } leftClickOpensMenu() { return this.getBool("settings.leftClickOpensMenu", false); } goToPlayer() { return this.getBool("settings.goToPlayer", true); } attackingTroopsOverlay() { return this.getBool("settings.attackingTroopsOverlay", true); } toggleAttackingTroopsOverlay() { this.setBool( "settings.attackingTroopsOverlay", !this.attackingTroopsOverlay(), ); } cursorCostLabel() { const legacy = this.getBool("settings.ghostPricePill", true); return this.getBool("settings.cursorCostLabel", legacy); } toggleLeftClickOpenMenu() { this.setBool("settings.leftClickOpensMenu", !this.leftClickOpensMenu()); } toggleEmojis() { this.setBool("settings.emojis", !this.emojis()); } // Performance overlay specifically needs a direct setter for Shift-D setPerformanceOverlay(value: boolean) { this.setBool(PERFORMANCE_OVERLAY_KEY, value); } togglePerformanceOverlay() { this.setBool(PERFORMANCE_OVERLAY_KEY, !this.performanceOverlay()); } toggleAlertFrame() { this.setBool("settings.alertFrame", !this.alertFrame()); } helpMessages() { return this.getBool("settings.helpMessages", true); } toggleHelpMessages() { this.setBool("settings.helpMessages", !this.helpMessages()); } tutorialDismissed() { return this.getBool("settings.tutorialDismissed", false); } setTutorialDismissed(value: boolean) { this.setBool("settings.tutorialDismissed", value); } toggleRandomName() { this.setBool("settings.anonymousNames", !this.anonymousNames()); } toggleLobbyIdVisibility() { this.setBool("settings.lobbyIdVisibility", !this.lobbyIdVisibility()); } toggleCursorCostLabel() { this.setBool("settings.cursorCostLabel", !this.cursorCostLabel()); } toggleGoToPlayer() { this.setBool("settings.goToPlayer", !this.goToPlayer()); } nukeAllianceSafetyDuration(): number { const raw = this.getCached("settings.nukeAllianceSafetyDuration"); if (raw === null || raw.trim() === "") return 5; const val = Number(raw); if (!Number.isInteger(val) || val < 0 || val > 30) { return 5; } return val; } setNukeAllianceSafetyDuration(duration: number) { const val = Math.max(0, Math.min(30, Math.round(duration))); this.setCached("settings.nukeAllianceSafetyDuration", val.toString()); } // For development only. Used for testing patterns, set in the console manually. getDevOnlyPattern(): PlayerPattern | undefined { const data = localStorage.getItem("dev-pattern") ?? undefined; if (data === undefined) return undefined; return { name: "dev-pattern", patternData: data, colorPalette: { name: "dev-color-palette", primaryColor: localStorage.getItem("dev-primary") ?? "#ffffff", secondaryColor: localStorage.getItem("dev-secondary") ?? "#000000", }, } satisfies PlayerPattern; } getSelectedPatternName(cosmetics: Cosmetics | null): PlayerPattern | null { if (cosmetics === null) return null; let data = this.getCached(PATTERN_KEY); if (data === null) return null; // Skin selections share this key — defer to getSelectedSkinName. if (data.startsWith("skin:")) return null; const patternPrefix = "pattern:"; // Accept both `pattern:[:]` (current) and bare `[:]` // (older builds wrote unprefixed) so existing localStorage values still resolve. if (data.startsWith(patternPrefix)) { data = data.slice(patternPrefix.length); } const [patternName, colorPalette] = data.split(":"); const pattern = cosmetics.patterns[patternName]; if (pattern === undefined) return null; return { name: patternName, patternData: pattern.pattern, colorPalette: cosmetics.colorPalettes?.[colorPalette], } satisfies PlayerPattern; } /** * Accepts a fully-prefixed cosmetic value: `"pattern:[:]"` * or `"skin:"`. Patterns and skins share storage because they're * mutually exclusive — writing one automatically clears the other. */ setSelectedPatternName(value: string | undefined): void { if (value === undefined) { this.removeCached(PATTERN_KEY); } else { this.setCached(PATTERN_KEY, value); } this.syncActiveLoadout(); } /** Returns the bare skin name (no `skin:` prefix), or null if a pattern (or nothing) is selected. */ getSelectedSkinName(): string | null { const data = this.getCached(PATTERN_KEY); if (data === null) return null; const skinPrefix = "skin:"; return data.startsWith(skinPrefix) ? data.slice(skinPrefix.length) : null; } // For development only. Crown image URL for testing, set in the console // manually (localStorage "dev-crown"), like getDevOnlyPattern. getDevOnlyCrown(): string | undefined { return localStorage.getItem("dev-crown") ?? undefined; } /** Returns the selected crown name, or null if none is selected. */ getSelectedCrownName(): string | null { return this.getCached(CROWN_KEY); } setSelectedCrownName(name: string | undefined): void { if (name === undefined) { this.removeCached(CROWN_KEY); } else { this.setCached(CROWN_KEY, name); } this.syncActiveLoadout(); } getFlag(): string | null { let flag = this.getCached(FLAG_KEY); if (!flag) return null; // Migrate bare country codes to country: prefix if (!flag.startsWith("flag:") && !flag.startsWith("country:")) { flag = `country:${flag}`; // Silent migration: don't emit change event for FlagInput this.setCached(FLAG_KEY, flag, false); } return flag; } setFlag(flag: string): void { if (flag === "country:xx") { this.clearFlag(true); } else { this.setCached(FLAG_KEY, flag); this.syncActiveLoadout(); } } clearFlag(emitChange: boolean = false): void { this.removeCached(FLAG_KEY, emitChange); this.syncActiveLoadout(); } /** * Selected effect cosmetics, keyed by selection slot (at most one per slot). * A slot is the effectType for trails and the nukeType for nuke explosions — * see effectTypeForSlot. Persisted as a single JSON blob under EFFECTS_KEY. */ getSelectedEffects(): Record { const raw = this.getString(EFFECTS_KEY, ""); if (!raw) return {}; try { const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; } catch { return {}; } } getSelectedEffectName(slot: string): string | null { return this.getSelectedEffects()[slot] ?? null; } setSelectedEffectName(slot: string, name: string | undefined): void { const map = this.getSelectedEffects(); if (name === undefined) delete map[slot]; else map[slot] = name; this.setSelectedEffects(map); } /** Replaces every effect slot at once, e.g. when applying a loadout. */ setSelectedEffects(effects: Record): void { if (Object.keys(effects).length === 0) this.removeCached(EFFECTS_KEY); else this.setString(EFFECTS_KEY, JSON.stringify(effects)); this.syncActiveLoadout(); } /** * Saved loadouts, oldest first. Corrupt storage and malformed entries are * dropped rather than thrown, matching the getSelectedEffects pattern. */ getLoadouts(): CosmeticLoadout[] { const raw = this.getString(LOADOUTS_KEY, ""); if (!raw) return []; try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed .map(parseLoadout) .filter((loadout): loadout is CosmeticLoadout => loadout !== null) .slice(0, MAX_LOADOUTS); } catch { return []; } } getLoadout(name: string): CosmeticLoadout | null { return this.getLoadouts().find((loadout) => loadout.name === name) ?? null; } /** The currently equipped cosmetics, as a loadout under the given name. */ captureLoadout(name: string): CosmeticLoadout { return { name, pattern: this.getCached(PATTERN_KEY), flag: this.getCached(FLAG_KEY), crown: this.getCached(CROWN_KEY), effects: this.getSelectedEffects(), }; } /** * Stores the currently equipped cosmetics under `name`, replacing a loadout * of the same name in place. Returns null when the name is blank, or when a * new loadout would exceed MAX_LOADOUTS. */ saveLoadout(name: string): CosmeticLoadout | null { const trimmed = name.trim(); if (trimmed === "") return null; const loadouts = this.getLoadouts(); const loadout = this.captureLoadout(trimmed); const existing = loadouts.findIndex((entry) => entry.name === trimmed); if (existing >= 0) { loadouts[existing] = loadout; } else { if (loadouts.length >= MAX_LOADOUTS) return null; loadouts.push(loadout); } this.setLoadouts(loadouts); return loadout; } /** * Adds a loadout in the lowest free slot number, holding whatever is * equipped now, and makes it active. Returns null at MAX_LOADOUTS. */ addLoadout(): CosmeticLoadout | null { const taken = new Set(this.getLoadouts().map((loadout) => loadout.name)); for (let slot = 1; slot <= MAX_LOADOUTS; slot++) { const name = loadoutSlotName(slot); if (taken.has(name)) continue; const loadout = this.saveLoadout(name); if (loadout !== null) this.setActiveLoadout(name); return loadout; } return null; } deleteLoadout(name: string): void { const loadouts = this.getLoadouts(); const remaining = loadouts.filter((loadout) => loadout.name !== name); if (remaining.length === loadouts.length) return; this.setLoadouts(remaining); if (this.getActiveLoadout() === name) this.setActiveLoadout(null); } /** * Equips every slot of the named loadout, clearing slots it left empty, and * makes it the active one. Returns false when no such loadout exists. */ applyLoadout(name: string): boolean { const loadout = this.getLoadout(name); if (loadout === null) return false; // The writes below would otherwise each mirror straight back into the // loadout being read from. UserSettings.applyingLoadout = true; try { this.setSelectedPatternName(loadout.pattern ?? undefined); if (loadout.flag === null) this.clearFlag(true); else this.setFlag(loadout.flag); this.setSelectedCrownName(loadout.crown ?? undefined); this.setSelectedEffects(loadout.effects); } finally { UserSettings.applyingLoadout = false; } this.setActiveLoadout(name); return true; } /** The loadout equip changes are mirrored into, or null when none is. */ getActiveLoadout(): string | null { const name = this.getCached(ACTIVE_LOADOUT_KEY); if (name === null) return null; // A loadout deleted in another tab leaves the pointer dangling. return this.getLoadout(name) === null ? null : name; } setActiveLoadout(name: string | null): void { if (name === null) this.removeCached(ACTIVE_LOADOUT_KEY); else this.setCached(ACTIVE_LOADOUT_KEY, name); } /** * Mirrors the equipped cosmetics into the active loadout, so the slot always * shows what's being worn. A no-op when no slot is active, or while a * loadout is being applied. */ private syncActiveLoadout(): void { if (UserSettings.applyingLoadout) return; const active = this.getActiveLoadout(); if (active === null) return; this.saveLoadout(active); } /** Clears every equip slot. Saved loadouts are left alone. */ unequipAll(): void { this.setSelectedPatternName(undefined); this.clearFlag(true); this.setSelectedCrownName(undefined); this.setSelectedEffects({}); } private setLoadouts(loadouts: readonly CosmeticLoadout[]): void { if (loadouts.length === 0) this.removeCached(LOADOUTS_KEY); else this.setString(LOADOUTS_KEY, JSON.stringify(loadouts)); } // Invalid/corrupt storage, unknown ids, or an empty result fall back to // defaults, matching the getSelectedEffects defensive pattern. Returned // order is registry (display) order regardless of stored order. private getColumnIds(key: string, defaults: readonly ColumnId[]): ColumnId[] { const raw = this.getString(key, ""); if (raw) { try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { const filtered = COLUMN_IDS.filter((id) => parsed.includes(id)); if (filtered.length > 0) return filtered; } } catch { // fall through to defaults } } return [...defaults]; } statsColumns(kind: StatsTableKind): ColumnId[] { return this.getColumnIds( STATS_COLUMNS_KEYS[kind], DEFAULT_STATS_COLUMNS[kind], ); } setStatsColumns(kind: StatsTableKind, ids: ColumnId[]): void { this.setString(STATS_COLUMNS_KEYS[kind], JSON.stringify(ids)); } /** * Channel volume, 0-1. Falls back to the legacy key before the default, so * an existing player keeps the level they chose. A stored 0 is respected: * the only writer is a slider drag, so 0 is always a deliberate choice and * never means "unset". */ /** * What master falls back to with nothing stored for it. * * The desktop shell is a game the player deliberately launched, so it starts * audible. The web build starts silent, matching main today — both of the * old sliders defaulted to 0, and audio that starts by itself on the web is * bad manners besides. * * The carve-out: master has no legacy key of its own, so defaulting it to 0 * would silence a returning player who had deliberately set the old * sliders. If any audio value is stored at all, master falls back to * AUDIO_DEFAULTS.master and that player keeps hearing what they chose. * * Named rather than quoted, here and in setAudioVolume below, so the two * cannot drift apart the next time the default moves. */ private defaultMasterVolume(): number { if (isDesktopShell()) return AUDIO_DEFAULTS.master; const chosenBefore = AUDIO_VOLUME_KEYS.some( (key) => this.getCached(key) !== null, ); return chosenBefore ? AUDIO_DEFAULTS.master : 0; } audioVolume(category: AudioCategory): number { const legacyKey = AUDIO_LEGACY_KEY[category]; // Only master is platform-dependent; every channel default is the same // everywhere, and the mixer is identical on both. const base = category === "master" ? this.defaultMasterVolume() : AUDIO_DEFAULTS[category]; const fallback = legacyKey === undefined ? base : this.getFloat(legacyKey, base); // Clamp on read as well as on write: the legacy keys were never bounded, // so a stored "1.5" would otherwise reach the slider as 150. return clampVolume(this.getFloat(`settings.audio.${category}`, fallback)); } setAudioVolume(category: AudioCategory, volume: number): void { // Writing any channel can flip the web master carve-out from 0 to // AUDIO_DEFAULTS.master (see defaultMasterVolume): the player now has a // stored audio value. Nothing else would announce that, so the mixer // would sit at master 0 — a silent game — while the tab showed the // default. const masterBefore = this.audioVolume("master"); this.setFloat(`settings.audio.${category}`, clampVolume(volume)); if (category === "master") return; // A stored master is authoritative; the carve-out cannot apply. if (this.getCached("settings.audio.master") !== null) return; const masterAfter = this.audioVolume("master"); if (masterAfter !== masterBefore) { this.emitChange("settings.audio.master", String(masterAfter)); } } muteOnBlur(): boolean { // Off by default (Josh, 11 Sept 2026): the game keeps playing when the // window loses focus unless the player asks otherwise. alertsWhenUnfocused // stays on, since it only applies once this is turned on. return this.getBool("settings.audio.muteOnBlur", false); } setMuteOnBlur(value: boolean): void { this.setBool("settings.audio.muteOnBlur", value); } alertsWhenUnfocused(): boolean { return this.getBool("settings.audio.alertsWhenUnfocused", true); } /** * Back to the fresh-install state for this platform: every stored audio key * is dropped, including the legacy pair, so the defaults and the master * carve-out resolve against nothing. * * The change events carry the value each key now *resolves to*, not null. * The mixer's listener parses `detail` as a number and ignores NaN, so a * null payload would leave it playing at the old volumes while the tab * showed the new ones. */ resetAudio(): void { for (const key of AUDIO_RESET_KEYS) { this.removeCached(key, false); } for (const category of AUDIO_CHANNELS) { this.emitChange( `settings.audio.${category}`, String(this.audioVolume(category)), ); } this.emitChange("settings.audio.muteOnBlur", String(this.muteOnBlur())); this.emitChange( "settings.audio.alertsWhenUnfocused", String(this.alertsWhenUnfocused()), ); } /** * Runs resetAudio() once per player, on every platform, the first time a * build carrying a new AUDIO_RESET_VERSION is loaded. See that constant for * why the reset exists. * * Idempotent by the stamp, not by a flag in memory: the stamp is written * whether or not there was anything to clear, so a fresh install spends the * version without a reset it did not need and a returning player is reset * exactly once however many times the page reloads. * * The stamp is written LAST. A throw anywhere in resetAudio -- localStorage * full, or unavailable in a hardened browser -- then leaves the version * unspent and the next load tries again, rather than recording a reset that * did not happen. * * @returns whether this call performed the reset. */ resetAudioOnce(): boolean { const raw = this.getCached(AUDIO_RESET_VERSION_KEY); // Number, not parseInt: parseInt stops at the first character it cannot // use, so "1-corrupt" reads as 1 and skips a reset that has never run. // A stamp is a whole non-negative number or it is not a stamp. const stamped = raw === null ? Number.NaN : Number(raw); // The fallback covers "never stamped" and anything this build cannot // read; either way the reset has not happened here. const applied = Number.isSafeInteger(stamped) && stamped >= 0 ? stamped : 0; if (applied >= AUDIO_RESET_VERSION) return false; this.resetAudio(); // No change event: nothing listens for the stamp, and resetAudio has // already announced every value that actually moved. this.setCached(AUDIO_RESET_VERSION_KEY, String(AUDIO_RESET_VERSION), false); return true; } setAlertsWhenUnfocused(value: boolean): void { this.setBool("settings.audio.alertsWhenUnfocused", value); } /** @deprecated use audioVolume("music"). */ backgroundMusicVolume(): number { return this.audioVolume("music"); } /** @deprecated use setAudioVolume("music", v). */ setBackgroundMusicVolume(volume: number): void { this.setAudioVolume("music", volume); } // What % attack ratio increments per click/scroll attackRatioIncrement(): number { const increment = Math.round( this.getFloat("settings.attackRatioIncrement", 10), ); if (!Number.isFinite(increment) || increment <= 0) return 10; return increment; } setAttackRatioIncrement(value: number): void { this.setFloat("settings.attackRatioIncrement", value); } // What % attack ratio is set to attackRatio(): number { return this.getFloat("settings.attackRatio", 0.2); } setAttackRatio(value: number): void { this.setFloat("settings.attackRatio", value); } // Returns {} if missing, unparseable, or fails schema validation. graphicsOverrides(): GraphicsOverrides { const raw = this.getString(GRAPHICS_KEY, ""); if (!raw) return {}; try { const json: unknown = JSON.parse(raw); const parsed = GraphicsOverridesSchema.safeParse(json); if (parsed.success) { const overrides = parsed.data; // Legacy: colorblind was an accessibility.colorblind boolean before // the palette enum existed; the schema strips the unknown section, so // translate it here. Rewritten to the new shape on the next save. const legacyColorblind = ( json as { accessibility?: { colorblind?: unknown } } ).accessibility?.colorblind; if (overrides.palette === undefined && legacyColorblind === true) { overrides.palette = "colorblind"; } return overrides; } } catch { // fall through } return {}; } setGraphicsOverrides(value: GraphicsOverrides): void { this.setString(GRAPHICS_KEY, JSON.stringify(value)); } // Named user-saved graphics presets. Returns {} if missing, unparseable, or // fails schema validation. graphicsPresets(): GraphicsPresets { const raw = this.getString(GRAPHICS_PRESETS_KEY, ""); if (!raw) return {}; try { const parsed = GraphicsPresetsSchema.safeParse(JSON.parse(raw)); if (parsed.success) return parsed.data; } catch { // fall through } return {}; } setGraphicsPresets(value: GraphicsPresets): void { this.setString(GRAPHICS_PRESETS_KEY, JSON.stringify(value)); } // Whether the presets key has ever been written. Distinguishes a player who // deleted all their presets (stored "{}") from one who has never seen the // preset UI — used to run the legacy-overrides migration exactly once. hasGraphicsPresets(): boolean { return this.getString(GRAPHICS_PRESETS_KEY, "") !== ""; } // In case localStorage was manually edited to be invalid, return an empty object parsedUserKeybinds(): Record { const raw = this.getString(KEYBINDS_KEY, "{}"); try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { return parsed; } } catch (e) { console.warn("Invalid keybinds JSON:", e); } return {}; } // Returns a flat keybind map { action: "keyCode" }, handling nested objects and legacy strings private normalizedUserKeybinds(): Record { const parsed = this.parsedUserKeybinds(); return Object.fromEntries( Object.entries(parsed) // Extract value from nested object or plain string, filter out non-string values .map(([k, v]) => { let val = v; if (v && typeof v === "object" && !Array.isArray(v) && "value" in v) { val = v.value; } if (Array.isArray(val) && typeof val[0] === "string") { val = val[0]; } return [k, val]; }) .filter(([, v]) => typeof v === "string"), ) as Record; } keybinds(isMac: boolean): Record { const merged = { ...getDefaultKeybinds(isMac), ...this.normalizedUserKeybinds(), }; // Actually unbind key: if Unbind is clicked in UserSettingsModal, eg. for Attack Ratio Up, // keybind is "Null". Even if it is in default kindbinds (Y), it should not work anymore. // The key (Y) can now be bound to another action like Boat Attack, and no two actions listen to the same key. for (const k in merged) { if (merged[k] === "Null") { delete merged[k]; } } return merged; } setKeybinds(value: string | Record): void { if (typeof value === "string") { this.setString(KEYBINDS_KEY, value); } else { this.setString(KEYBINDS_KEY, JSON.stringify(value)); } } /** @deprecated use audioVolume("effects"). */ soundEffectsVolume(): number { return this.audioVolume("effects"); } /** * @deprecated use setAudioVolume("effects", v). * * Writes every channel that split out of the old "sound effects" slider, * not just effects. Until the Audio tab ships there is one slider for all * four, and writing only effects would leave clicks, alerts and ambience * stuck at the inherited value with no control that moves them — a player * muting sound effects would still hear them. */ setSoundEffectsVolume(volume: number): void { for (const category of SPLIT_FROM_SOUND_EFFECTS) { this.setAudioVolume(category, volume); } } }