# dsh_theme_terraria
> Replace the DeepSeek Harness Web UI with an entire Terraria-style pixel world — the Guide codes alongside you.
>
> [中文版](./README.md)
[](https://dsh.market/?q=10086ggqq/dsh_theme_terraria)

---
## Table of Contents
1. [Overview & Core Positioning](#1-overview--core-positioning)
2. [Installation & Deployment Guide (with Pitfalls)](#2-installation--deployment-guide-with-pitfalls)
3. [Visual Design System (UI/UX Details)](#3-visual-design-system-uiux-details)
4. [Sound Interaction System (Audio Feedback)](#4-sound-interaction-system-audio-feedback)
5. [Hidden Easter Eggs (Narrative Interaction)](#5-hidden-easter-eggs-narrative-interaction)
6. [Performance & Accessibility Guardrails](#6-performance--accessibility-guardrails)
7. [Project Maintenance (Uninstall, Structure & License)](#7-project-maintenance-uninstall-structure--license)
---
## 1. Overview & Core Positioning
**dsh_theme_terraria** (npm package name `dsh-theme-terraria`) is a Terraria-themed plugin for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh). It replaces the official React frontend with a single self-contained `index.html` (~98 KB, zero external JS dependencies), wrapping the "AI coding agent console" as a pixel adventure:
- **A standard dsh plugin package**: this repository is itself an npm package — the entry `index.js` exports the `apply(ctx)` required by the plugin spec, `cordis.patch.yml` declares the `dsh.bundle` patch layer, and a single `dsh plugin --profile web add dsh-theme-terraria` installs or removes it without touching any file in the dsh installation.
- **Not a mere skin**: the theme talks directly to the dsh host over JSON-RPC (HTTP upstream) + WebSocket (event downstream). Session creation, message sending, streaming output, tool approvals, model switching, and workspace management are all fully functional. Enter a DeepSeek API key and you can chat with the model for real.
- **Four difficulty tiers = four agent presets**: Journey (standard, full-featured coding agent), Softcore (code, Code Mode SDK), Mediumcore (minimal, bare dual-tool agent), Hardcore (cordis, custom preset template). Switching difficulty switches the preset — the plugin composition changes live with it.
- **Single-file delivery**: the entire UI is inlined in one HTML file served directly by the plugin — no runtime framework, no build step.
### Core Mapping
| Official UI concept | Terraria theme mapping |
|---|---|
| Agent Preset | Difficulty tier: Journey / Softcore / Mediumcore / Hardcore |
| Session | Journey / Adventure |
| User message | Adventurer |
| Tool approval request | Approval tablet |
| Todo list | Quest HUD |
| API key status | Life crystal indicator |
| Plugin composition | Equipment that changes with difficulty |
---
## 2. Installation & Deployment Guide (with Pitfalls)
### Requirements
- An installed dsh (DeepSeek Harness) CLI — `dsh --version` works in your terminal
- Node.js `^22.19 || >=24` and pnpm (`dsh plugin` forwards to pnpm for dependency management)
- A DeepSeek API key (required for chatting; all other UI works without one)
### Installation (standard plugin install)
```sh
# 1. Install the theme as a plugin into the web profile
dsh plugin --profile web add dsh-theme-terraria
# 2. Start the web server
dsh web
# Default URL: http://127.0.0.1:3080/
```
`dsh plugin add` writes `dsh-theme-terraria` into the web profile's dependencies and `dsh.profile.bundles` layer list; the plugin's `cordis.patch.yml` patch layer disables the official `web-runtime` row and inserts an identical `terraria-theme` row, while the plugin entry `index.js` — which exports `apply(ctx)` — points the official `@deepseek-ai/dsh-web-app` frontend resolver at this package's `web/index.html`. Everything else the official Web runtime owns (dist serving, the /api trust fence, `DSH_WEB_URL`, the URL line) keeps working unchanged.
#### Before the package is published to npm
```sh
# Install from the GitHub repository
dsh plugin --profile web add github:10086ggqq/dsh_theme_terraria
# Or install from a local checkout of this repository
dsh plugin --profile web add /path/to/dsh_theme_terraria
```
> When running dsh from source inside the deepseek-harness repository, prefix the commands with `pnpm` (e.g. `pnpm dsh plugin --profile web add ...`, `pnpm dsh web`).
### How it applies inside the Desktop (Electron) app
The Desktop window has two hard constraints: it loads `dsh-app://app/`, and that protocol maps `/`,
`/index.html`, `/assets/*`, `/favicon.svg` and `/manifest.webmanifest` to the frontend **bundled inside
the application** — the dist served by the Host is never requested there. Only other paths are forwarded
to the Host together with its cookie.
So the plugin takes two routes:
1. **Full takeover (default)**: the injection layer preflights `/terraria/theme.html` (which serves
`web/index.html`) and then navigates the window there — the Desktop window runs the **very same UI**
as the web version (title screen, Guide dialog, character creation, quest HUD, 8-bit sounds,
wallpapers, desk pets). `/api/*` keeps flowing through the Desktop shell with the Host cookie, and
the WebSocket connects to the Host origin taken from `__DSH_TRANSPORT__.streamBaseUrl`.
2. **Fallback skin**: when the theme page is unavailable (missing route / failed preflight) or the
takeover is switched off, the official UI gets an injected pixel skin (`skin/terraria.css`
overriding the official `--dsw-*` tokens and generic element styles).
Desktop-only adaptations: native caption colours (the preload reads the page's `--dsw-*` tokens), a
40 px title-bar reservation, and `-webkit-app-region: drag` on the top bar and title screen — with
every interactive element marked `no-drag`, because app-region is a **hit test, not a stacking
order**. Modal overlays (`#modal-root`) are `no-drag` too, and while a modal is open the page sets
`data-terraria-modal` to drop the drag regions underneath it; otherwise the full-window title-screen
drag area swallows every click on the overlay (the "关闭" buttons of *Continue* / *Desk Pet* looked
dead). Esc closes a modal as well.
- **Quit the application completely and relaunch it**: the injection rows are a one-shot snapshot taken
when the Host starts, so reloading the window or reloading the plugin will not pick up new rows.
- Way out: "切回官方界面" on the theme title screen, or Alt+O. On the Host side,
`DSH_TERRARIA_STANDALONE=0` disables the takeover and `DSH_TERRARIA_SKIN=0` disables the fallback skin.
- **The way out works both ways**: "切回官方界面" writes `localStorage['terraria.takeover'] = '0'` and stays
in effect across restarts, so once you are back on the official UI a "🌳 泰拉瑞亚主题" pixel button sits in
the bottom-right corner (or press Alt+T); it clears the flag and takes the window
back. Every decision is recorded in `localStorage['terraria.inject']` — check that first when the UI
looks wrong.
### Configure the API Key and Start Chatting
1. Open http://127.0.0.1:3080/ and click "新的会话" (New Session);
2. The first visit automatically opens Settings — type your `sk-...` key into the "DeepSeek API 密钥" field and click "保存密钥" (Save Key);
3. The key is stored on the host machine via `credentials.set` (`.dsh-home/.credentials.yaml`); the page itself never persists the plaintext key;
4. Once the HUD shows "API 密钥:已配置" (configured), type any instruction into the dialog to start a real conversation.

### Pitfall Guide (All Field-Tested)
| Pitfall | Symptom | Fix |
|---|---|---|
| Desktop window shows a "warm parchment official UI" instead of Terraria | Pixel palette and pixel font are there, but the layout is the stock one (sidebar "插件 / 自动化任务", "探索未至之境" in the middle); restarting does not help | Rendering is fine — the **takeover is switched off**. `localStorage['terraria.takeover'] = '0'` (written by "切回官方界面" / `Alt+O`) persists forever. Click the "🌳 泰拉瑞亚主题" button in the bottom-right corner, or press `Alt+T`; or run `localStorage.removeItem('terraria.takeover'); location.reload()` in DevTools. When it still refuses, read `localStorage['terraria.inject']` — it records which step bailed out |
| Desktop app window does not show the theme | Plugin says "running" but the window stays official | The Desktop window never requests the Host dist; since 0.2.0 the injection layer takes the window over to the theme page. Injection rows are a **one-shot snapshot at Host start**, so quit the app completely and relaunch (reloading the window or the plugin is not enough). If the takeover is unavailable, it falls back to "official UI + pixel skin" |
| Theme page loads but never connects | Status keeps reconnecting | Use "切回官方界面" on the theme title screen, or Alt+O, to get a working UI back, then send the console error |
| Browser caches old UI | Page still shows the old version after a plugin update | Hard refresh with `Ctrl+F5`, or append `?v=2` to the URL |
| `dsh plugin add` fails | `pnpm not found on PATH` or a pnpm error | `dsh plugin` forwards to pnpm: install pnpm and reopen the terminal; for git installs, follow the printed hint and add the key under `allowBuilds` in the profile's `pnpm-workspace.yaml`, then re-run |
| Key save fails | Toast reports "保存失败" | Make sure `dsh web` was started from your own terminal; sandboxed environments may block host-directory writes |
| Image wallpaper won't store | "图片过大,保存失败" | localStorage caps around 5 MB; the theme already compresses non-GIF images to 1920px JPEG. If it still fails, use a smaller image or a video wallpaper |
| Folder picker returns no full path | Only the folder name appears after selecting | Browser security restriction (uniform across the web). The theme smart-fills registered same-name directories via a "使用 E:\full\path" button; otherwise it prompts for manual completion |
| WebSocket drops | Top bar shows "连接已断开,重连中…" | Normal auto-reconnect (2s interval); if the host process exited, restart `dsh web` |
---
## 3. Visual Design System (UI/UX Details)
### Design Language: Pixel Triad + Slate Texture
The visual foundation is three layers:
1. **Background layer**: default forest wallpaper (`forest.png`) with the title-screen tree (`Tree.png`); `image-rendering: pixelated` keeps pixels crisp. User-replaceable (see wallpaper system).
2. **Panel layer**: every card/modal uses the "Terraria slate" style — nested deep-blue double layers (outer `#181e58`, inner `#2e3692`), 3 px dark outlines (`#0e1340`), 8 px corner radius, bold serif (`SimSun/Songti SC`).
3. **Accent layer**: gold (`--gold`) for titles and highlights, torch orange (`--torch`) for section headers, parchment white (`--parchment`) for body text, with a dark drop shadow `text-shadow: 1px 1px 0 var(--black)`.
### Typography
- Primary: **Fusion Pixel 12px** (an open-source CJK pixel font), a single ~600 KB `woff2` bundled in `web/` — crisp CJK rendering;
- Modal titles: bold SimSun, recreating the game's dot-matrix feel;
- Monospace: the terminal panel renders tool output in monospace.
### Title Screen
A faithful recreation of the game's main menu: logo, five pixel menu items (New Session / Continue / Plugin Manager / Settings / Desk Pet), and the big tree silhouette over the forest background.
### Main Chat Interface (Three-Column Layout)
- **Top bar**: back button, session name (the current **workspace name**, e.g. `dsh_theme_terraria`; sessions without a workspace fall back to the mode name), connection lamp ("已连接到向导世界" — connected to the Guide's world), Chat/Terminal tab switch, sound toggle, music toggle, plugin manager, settings entry. The model name lives in the HUD's "当前模型" only — the top bar no longer repeats it as a badge;
- **Chat column**: message stream (Adventurer = user, Guide = assistant) plus the bottom NPC dialog box — Guide portrait (`xiangdao.png`) and heart emblem (`heart.png`) on the left, input box and seven pixel buttons on the right: **Attach, Mode Select, Workspace, Permissions, Model Select, Stop, Send**. **A turn leaves one "process" line plus one result reply**: reasoning text, tool calls and intermediate step prose ("Now the endpoint mapping:"-style transition lines) all fold into one collapsible process block — expanded while the turn runs so progress stays visible, collapsed at turn end into a summary such as "过程 · 已思考 1.1k 字 · 7 次工具 · 3 段中间说明 · 24s". The stream therefore holds only your messages, one process line per turn, and the result bubble. **Steps without prose leave no bubble at all** (they used to leave bricks containing nothing but a token line or "已思考 · N 字");
- **Reasoning lives inside the process block**: expand the summary line to read the raw thinking (tagged "已思考 · N 字") and every tool call; tool activity is mirrored to the Terminal tab as before. While running, a single "••• 正在思考…" line is all that shows;
- **No `tokens:` line under replies**: the old per-reply usage line is gone, so **no token copy appears in the message stream** — token stats live in the HUD's rate panel only;
- **HUD sidebar**: current model (name + description), API key status lamp (the login role is reported here too — status only, with **no sign-out entry**; that button lives in the settings modal's login-role row), quest list (live todo projection), Guide tips with a **token output rate** readout (live tok/s + this turn's tokens + session average + a mini bar chart) — the one and only place token usage is shown (every step's `outputTokens` accumulates here).
### Chat History: Paging, Anchor Navigation, Markdown
- **History paging (your own prompts survive "Continue")**: the `session/follow` snapshot is only a window (measured ~300 records, sometimes just the latest prompt), so `loadHistory()` keeps paging backwards with `session/page` (`throughSeq` inclusive / `beforeSeq` exclusive) — four pages automatically, and a "⬆ 加载更早的历史" button (up to 12 more pages / 6000 records) at the top of the stream and in the navigation panel. Records are re-rendered in chronological order and the scroll position is preserved by height compensation;
- **Anchor navigation**: "≡ 导航" in the top-right corner of the chat opens a panel listing the **results** as anchors (Adventurer = each of your prompts, Guide = replies that actually have text, with a preview); clicking one scrolls to it and flashes a gold border. It has a text filter, jump-to-oldest/newest buttons, and auto-highlights the message you are currently reading while scrolling;
- **Results only**: intermediate steps that carried just a thinking block or a tokens line (no reply text) are **not** listed in the history panel, so the list never fills up with un-jumpable noise;
- **Markdown rendering**: assistant replies render as Markdown (headings, bold/italic, inline code, fenced code blocks with a copy button, quotes, ordered/unordered/task lists, tables, rules, links, images) and keep rendering live while streaming. Everything is HTML-escaped *before* parsing, so model output can never inject markup; user messages stay plain `pre-wrap` text.

### Mode Select Modal: 1:1 Character-Creation Recreation
Clicking "模式选择" opens a **character-creation**-style modal — the most faithful recreation in the whole theme:
- Top bar: walking character gif (`Style_1_male_walking.gif`) + "模式选择" title;
- Left column: four difficulty buttons in their in-game colors — Journey (purple `#8a4b7c`), Softcore (teal `#388e8d`), Mediumcore (brown `#855526`), Hardcore (red `#8a3a3b`);
- Selected state: bright cyan frame (`#6be1d8`) with an inner glow stroke;
- Right panel: live description of the selected difficulty's real preset;
- Bottom "返回 / 选择" (Back / Select) buttons with a `scale(0.96)` press feedback;
- "选择" actually calls `agentPreset.select` to switch the backend preset; the session name updates accordingly.
### Settings Modal & Wallpaper System
The settings modal has four sections:
- **Login role** (row 0, `#set-role` / `#set-role-detail`): which identity is running — "DeepSeek 账号 · 已登录 · " or "API 密钥 · 未登录 DeepSeek 账号,用密钥直连", plus a line noting that account credentials are kept by the host and used automatically (no API key needed). The snapshot comes from the same-origin `GET /terraria/account`. **While signed in with an account this row also carries a "退出登录" (sign out) button** (`#set-signout`, a danger-styled small button): it first asks for confirmation (querying `account/hasRunningAccountTasks` so the copy can warn that running tasks will be interrupted), then calls the host's `account/signOut` with the client identity `{version, locale, timezoneOffsetSeconds}` — the same endpoint the official frontend uses. The host deletes the local account grant and revokes it through Platform in the background; **API keys are untouched**. Afterwards the page polls `/terraria/account` until the role is no longer an account and refreshes the role row and the HUD lamp. The button is hidden in API-key mode (there is no login to end), and it lives **only in the settings modal — the chat page (HUD / top bar) never shows it**;
- **API key**: an orange "DeepSeek API 密钥" heading with the config status on the right; the password input sits on the left (start position and height unchanged, width adapts), with the gold "保存密钥" (Save Key) button and the tool-approval mode switch (auto-allow / manual confirm) to its right on the same row, all bottom-aligned; stored on the host via `credentials.set`; on narrow screens the buttons wrap below instead of covering the input;
- **Wallpaper**: dashed drop zone "拖拽图片/视频到此处,或点击选择" — supports both drag-and-drop and click upload; images are auto-compressed to 1920px JPEG for localStorage, GIFs keep their animation as-is, and **videos (MP4/WebM, ≤512 MB) are stored in IndexedDB as fullscreen live wallpapers** (muted, looping, behind everything); "恢复默认" restores the forest in one click;
- **Background music**: dashed drop zone "拖拽音频到此处,或点击选择" — drag or click to upload any audio file (MP3/WAV/OGG etc., ≤64 MB), stored in IndexedDB and looped as BGM; comes with a play/pause button, a volume slider, and "移除音乐" (remove); the top-bar "音乐:开/关" (music on/off) toggles it anytime;
- **Interface**: reload the UI (the desktop shell has no refresh shortcut) and open the plugin manager.

### Plugin Manager (search / inspect / install / enable / remove)
The top bar's "插件管理" (also on the title screen) opens the plugin dialog, talking to the host's `pluginManager` service:
- **Search**: asks the public npm registry directly (`registry.npmjs.org/-/v1/search`, CORS-enabled) and ranks dsh-related hits first; an empty query searches `keywords:dsh-plugin`. When search is unavailable it tells you to paste a package name instead;
- **Local / Git source install** (for everything search cannot find): a spec input with "选择文件夹…" (the desktop shell's native picker returns the host's absolute path), "选择 .tgz…" (`__DSH_HOST_PATHS__.pathFor`), and drag-and-drop of a folder onto the row. Every host spec form is accepted — an absolute path (`D:\code\my-plugin`, must contain package.json), a `*.tgz` (local or URL), a git repo (`github:you/repo`, `https://github.com/you/repo`, `git+https://…`, `git@host:path`) and a registry name (optionally with `@version`). **Local paths must be absolute**: `file:///D:/…` and relative paths are refused by the host (measured: `not-a-package: the path does not exist` / `invalid-spec: a local path must be absolute`);
- **Inspect**: `pluginManager/inspect` resolves a spec (package name, npm alias, git URL, local path, tarball) and reports whether it is a bundle, plus version and description; refusals show the `problem` and the raw reason (e.g. `not-found` + registry text, or `already-installed`);
- **Install**: `pluginManager/installBundle` (with a client-minted `requestId` and `enabled: true`); the outcome is reported by `application` (`applied / restart-required / failed`) with error code, diagnostic and the tail of the pnpm output. The log area also collects the host's `plugin-manager/install-log` events;
- **Installed**: `pluginManager/listBundles` lists every bundle grouped by origin, and **both groups fold** — **Personal** (declared in the current profile's dependencies, installed by you, disable / remove) is expanded by default, **Official** (shipped with the dsh installation, disable-only) is collapsed by default and opens automatically when the Personal group is empty. Each row can be **enabled / disabled** (`setBundleEnabled`) and, when removable, **removed** (`removeBundle`); **double-click any row to open the plugin detail page**;
- **Plugin detail page**: name @ version, title, origin (Personal / Official), state, removability (built-in, or protected by `management-required`), optional-bundle flag, error code and description; below that, "Contains N plugins" (each module name, its Chinese note, whether it is running) and "Raw info" (the bundle JSON) — both **collapsed by default** and opened on click. Enable / disable / remove also work from the detail page and refresh in place; removing the plugin drops you back to the list;
- **Registry**: shows what `pluginManager/registries` resolved (here `registry.npmjs.org` with `registry.npmmirror.com` as fallback);
- **Current preset inventory**: the old settings-page cordis.yml inventory moved here, tucked into a **collapsed-by-default** section (with a count in its title); expanding it still offers the raw preset file for comparison.
#### Why plugins that live on GitHub do not show up in the search
The search queries the **npm registry index**, not GitHub:
1. A repo that was never **published to npm** has no registry metadata at all, so it cannot be found (the host's `inspect` answers `not-found`);
2. Packages that are published but have no `dsh` keyword in their name/description rank far down, because search is relevance-ordered rather than a precise filter;
3. Packages that live in a **private registry / internal mirror** are invisible to the public one (this machine resolves `registry.npmjs.org` with `registry.npmmirror.com` as fallback);
4. **Scoped packages** (`@you/pkg`) usually need their exact name.
Installing is unaffected: paste the repository into "本地 / Git 来源安装"
(`https://github.com/you/repo`, `github:you/repo` — `inspect` reports `{status:"accepted",kind:"git",host:"github.com"}`),
use an absolute path for a local checkout, or point at a `*.tgz`; pnpm clones, unpacks and installs the dependencies itself.


### Continue-Session Modal: Workspaces as Parents, Sessions as Children
"继续会话" on the title screen lays the sessions out as a **folder tree** (it aligns `session/list` with `workspace/follow`):
- **Parent node = workspace**: the header row shows the workspace name (e.g. `dsh_theme_terraria`) + full path + session count, and clicking it collapses/expands the node (`▾ / ▸`);
- **Child node = "第 x 个对话"**: children are ordered by most recent activity, so #1 is the newest; each row carries the session title, timestamp, a `运行中` (running) tag and the working directory;
- **Archive**: every session row ends with an "归档" button calling `workspace/archiveSession` (a running session has its activity stopped as well). Archived sessions leave the tree and move into a collapsed **"已归档"** parent at the bottom, where each row offers "取消归档" (`workspace/unarchiveSession`) to put it back;
- Ownership comes from the workspace's `sessionIds`, with a `cwd` match as fallback; sessions covered by neither are grouped by their `cwd` tail, and those without a `cwd` land under "未归入工作空间".
### Workspace Modal
- Lists all registered workspaces (title, full path, session count); **click any row to start a new session in that directory** (the session name then shows that workspace name, e.g. "assets");
- "添加本地文件夹" (Add local folder) supports three methods: **Select Folder…** (system dialog), **drag-and-drop** (auto-parses file:// / Windows paths), or **manual input** of the full path;
- Because browsers only hand out the folder name from the picker, the theme smart-matches registered directories of the same name and offers a one-click "使用 E:\full\path" backfill.

### Desk Pet
Open "人物桌宠" from the title screen to swap the character keeping you company and to command the five desk pets roaming your screen:
- **Portrait upload**: drag or click to pick an image (≤16 MB, any size), shown as an 88×88 square portrait (`object-fit: cover` center-crop); stored in IndexedDB and restored after reloads; photos render smoothly while the default pixel art stays `pixelated` crisp;
- **Name editing**: type a new name (≤12 chars, committed on blur or Enter); it updates the dialog nameplate, chat-bubble signatures, the approval slate ("XX想要使用工具"), the question modal title, the welcome line, and the input placeholder;
- **Personality**: edit the textarea directly, or import from an `.md` / `.markdown` file (drag-and-drop or click, ≤512KB); the import shows a gold progress bar, and the content is processed (BOM stripped, line endings normalized, trimmed, capped at 64K chars) before saving; hover the dialog portrait to preview the first 120 characters;
- **Desk pet toggles (five independent switches)**: each pet gets its own pixel toggle — the **Eye of Cthulhu** (a wandering, rotating floating eye), **King Slime** (a bouncing slime with squash-and-stretch), **Draedon** (a frame-animated flying mechanic), **Devourer of Gods** (a multi-segment worm crawling in follow-the-leader fashion), and **Cryogen** (a spinning ice crystal in flight); each pet lives in its own full-screen transparent iframe (`pointer-events` passes through so nothing is ever blocked), sized to the shared 160px standard (Draedon 133×160 to keep its 5:6 ratio); showing/hiding plays a 0.5s fade transition; each pet's state persists independently in localStorage (`terraria.pet.`) across reloads; the host page and each iframe talk over postMessage (`dsh:pet` pause/resume commands + `dsh:pet-ack` confirmations + a `dsh:pet-ready` handshake, matched by contentWindow), and closing a pet freezes only its own animation loop to save CPU;
- **Reset**: one click clears the custom portrait, name, and personality, returning to the Guide and the default pixel art.

---
## 4. Sound Interaction System (Audio Feedback)
The theme ships an **8-bit style WebAudio engine** — pure oscillator synthesis (square wave), no audio files, zero network requests:
| Event | Frequency/Duration | Feel |
|---|---|---|
| Assistant reply finished (`assistant/message`) | 660 Hz / 60 ms | A short "ding", like picking up a coin |
| Tool approval request | 440 Hz / 100 ms | Mid-tone alert drawing attention |
| Ask-user question | 520 Hz / 100 ms | Rising inquiring tone |
| Turn failed / send failed | 180 Hz / 100 ms | Low "thud", the hurt sound |
Design principles:
- **Restrained**: one beep per event, never looping; streaming (tokens arriving one by one) stays completely silent;
- **Switchable**: the top-bar "音效:开 / 音效:关" toggle persists in localStorage across reloads;
- **Fail-safe**: the whole playback path is wrapped in `try/catch` — silent environments (autoplay policy blocks, missing audio devices) are ignored without ever throwing.
### Background Music (Custom BGM)
A custom-BGM system independent of the sound-effect engine:
- **Upload**: drag or click in the settings modal to pick an audio file (MP3/WAV/OGG/FLAC/M4A/AAC/OPUS, ≤64 MB); it is stored in IndexedDB (the same database as video wallpapers, bypassing the 5 MB localStorage cap) and restored automatically after reloads;
- **Playback**: looped through an `