dsh-plugin-vrmpet3d 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -17,7 +17,9 @@
17
17
  // boundary hub still provides a baseline; with no `webServer` service the pet
18
18
  // still runs from its built-in placeholder avatar and reports why.
19
19
 
20
- import { copyFileSync, createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
20
+ import { copyFileSync, createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
21
+ import { createRequire } from 'node:module';
22
+ import { createHash } from 'node:crypto';
21
23
  import { homedir } from 'node:os';
22
24
  import { basename, dirname, extname, isAbsolute, join, normalize, resolve, sep } from 'node:path';
23
25
  import { fileURLToPath } from 'node:url';
@@ -25,6 +27,7 @@ import { fileURLToPath } from 'node:url';
25
27
  import { SettingsStore, settingsFileFor } from './settings.js';
26
28
  // `VRMPET3D_*` with the pre-rename `PET3D_*` names as a fallback — see the module's own note.
27
29
  import { envVar } from './env.js';
30
+ import { createLevelStore } from './mini-game-level-store.js';
28
31
 
29
32
  // `lib/core.js` is a classic script that publishes itself on the global — that is
30
33
  // how the client half reads it too, so importing it here is what keeps ONE list
@@ -54,6 +57,21 @@ import { COMPRESSED_SUFFIX, decompressBrotli, isCompressedMotionName } from './b
54
57
  /** The shared pure logic, published by the import above. */
55
58
  const core = globalThis.__VRMPET3D_CORE__;
56
59
 
60
+ /** Resolve from the running CLI, not the plugin's dependencies or the Node executable.
61
+ * package.json is exported by the chat package; lib/client.js is intentionally not.
62
+ * Resolve the entry's symlink first so globally installed CLI shims work too.
63
+ */
64
+ export function installRootFromDshEntry(entry = process.argv[1]) {
65
+ if (!entry) return null;
66
+ try {
67
+ const fromDsh = createRequire(realpathSync(entry));
68
+ const chatPackage = fromDsh.resolve('@deepseek-ai/dsh-client-ui-chat/package.json');
69
+ return dirname(dirname(dirname(dirname(chatPackage))));
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
57
75
  export const name = 'vrmpet3d';
58
76
 
59
77
  /**
@@ -208,6 +226,7 @@ export const ROUTES = {
208
226
  * guide's 偏好模型 step — one page plus one shared component, instead of two hand-kept copies of a grid.
209
227
  */
210
228
  picker: '/vrmpet3d/picker',
229
+ game: '/vrmpet3d/game',
211
230
  /**
212
231
  * The pet's live state, reported by the in-app pet and read by the floating window.
213
232
  *
@@ -267,6 +286,7 @@ export const WINDOW_SOURCES = new Set([
267
286
  // grid of thumbnails is exactly how the two would drift (and the operator asked for them to be identical).
268
287
  'model-picker.js',
269
288
  'picker-page.js',
289
+ 'mini-game-core.js', 'mini-game-page.js', 'mini-game.css',
270
290
  // The stylesheet the TWO settings pages share. One file rather than a copy in each page's `<head>`: the two
271
291
  // render the same rows, tables and status lines, and a copy is how the two drift apart.
272
292
  'pages.css',
@@ -1808,7 +1828,43 @@ function sendJson(req, res, payload) {
1808
1828
  * copies of this drifted before — one capped at 64 KB and one not — and the route without the
1809
1829
  * cap is the one that reads an arbitrary upload into memory.
1810
1830
  */
1831
+ function allowWriteRequest(req, res) {
1832
+ const headers = req.headers || {};
1833
+ const site = headers['sec-fetch-site'];
1834
+ if (site === 'cross-site') {
1835
+ sendStatus(res, 403, 'cross-site writes are not allowed');
1836
+ return false;
1837
+ }
1838
+ // This forbidden browser header describes the PUBLIC request, even when a proxy
1839
+ // rewrites Host internally. A foreign web page cannot forge same-origin metadata.
1840
+ if (site === 'same-origin' && headers.origin !== 'null') return true;
1841
+ // Compare authority using the browser's scheme: TLS may terminate at a reverse proxy.
1842
+ // Do not trust arbitrary X-Forwarded-* headers to authorize a different public host.
1843
+ const source = headers.origin || headers.referer;
1844
+ if (source) {
1845
+ try {
1846
+ const origin = new URL(source);
1847
+ const target = new URL(`${origin.protocol}//${headers.host}`);
1848
+ if (!/^https?:$/.test(origin.protocol) || origin.host !== target.host || target.username || target.password) throw new Error('origin');
1849
+ } catch {
1850
+ sendStatus(res, 403, 'a same-origin request is required');
1851
+ return false;
1852
+ }
1853
+ } else if (site && site !== 'same-origin' && site !== 'none') {
1854
+ sendStatus(res, 403, 'a same-origin request is required');
1855
+ return false;
1856
+ }
1857
+ // Native/local clients have no browser Origin. Browser JSON writes below still require
1858
+ // a non-safelisted content type, so text/plain cannot bypass the CORS preflight.
1859
+ return true;
1860
+ }
1861
+
1811
1862
  function readJsonBody(req, res, limit, onBody) {
1863
+ if (!allowWriteRequest(req, res)) return;
1864
+ if (!/^application\/json\s*(?:;|$)/i.test(String(req.headers?.['content-type'] || ''))) {
1865
+ sendStatus(res, 415, 'application/json is required');
1866
+ return;
1867
+ }
1812
1868
  const chunks = [];
1813
1869
  let size = 0;
1814
1870
  let refused = false;
@@ -1852,6 +1908,12 @@ const MODEL_UPLOAD_LIMIT = 256 * 1024 * 1024;
1852
1908
  * and no callback at all once the response has been sent.
1853
1909
  */
1854
1910
  function readRawBody(req, res, limit, onBody) {
1911
+ if (!allowWriteRequest(req, res)) return;
1912
+ const mediaType = String(req.headers?.['content-type'] || '').split(';')[0].trim().toLowerCase();
1913
+ if (!mediaType || ['text/plain', 'application/x-www-form-urlencoded', 'multipart/form-data'].includes(mediaType)) {
1914
+ sendStatus(res, 415, 'a binary content type is required');
1915
+ return;
1916
+ }
1855
1917
  const chunks = [];
1856
1918
  let size = 0;
1857
1919
  let refused = false;
@@ -1888,18 +1950,32 @@ function methodAllowed(req, res) {
1888
1950
  return false;
1889
1951
  }
1890
1952
 
1891
- /**
1892
- * Stream one allowlisted file. Shared by the asset route and the motion route.
1893
- *
1894
- * Shared rather than copied: the size cap, the MIME table and the read-error
1895
- * handling are the parts a second implementation would get subtly wrong, and a
1896
- * motion has to be served exactly like a model.
1897
- *
1898
- * `root` is the directory the entry was scanned from. The id map is the
1899
- * allowlist, but containment is re-checked here, so an entry that was somehow
1900
- * built with a path outside its own root is refused rather than streamed.
1901
- */
1902
- function sendFile(req, res, target, root) {
1953
+ /** Private browser validators for a model or its sibling texture. */
1954
+ function modelCacheHeaders(target, stats, reload) {
1955
+ // Include file identity as well as size/times: model IDs are positional and can
1956
+ // later address a different file. Hash the identity so no server path is exposed.
1957
+ const fingerprint = [realpathSync(target), stats.dev, stats.ino, stats.size, stats.mtimeMs, stats.ctimeMs].join('|');
1958
+ return {
1959
+ etag: `W/"${createHash('sha256').update(fingerprint).digest('hex')}"`,
1960
+ // Store only in the user's browser. Revalidation still passes through proxy auth.
1961
+ 'cache-control': reload ? 'no-store' : 'private, no-cache',
1962
+ 'access-control-allow-origin': '*',
1963
+ 'x-content-type-options': 'nosniff',
1964
+ };
1965
+ }
1966
+
1967
+ function modelNotModified(req, res, headers, reload) {
1968
+ if (reload) return false;
1969
+ const requested = String(req.headers['if-none-match'] || '').split(',').map(value => value.trim());
1970
+ const tag = headers.etag.replace(/^W\//, '');
1971
+ if (!requested.some(value => value === '*' || value.replace(/^W\//, '') === tag)) return false;
1972
+ res.writeHead(304, headers);
1973
+ res.end();
1974
+ return true;
1975
+ }
1976
+
1977
+ /** Stream an allowlisted model/texture or motion; only model callers enable caching. */
1978
+ function sendFile(req, res, target, root, modelCache = null) {
1903
1979
  if (!isInside(root, target)) {
1904
1980
  sendStatus(res, 404, 'file not found');
1905
1981
  return;
@@ -1921,6 +1997,12 @@ function sendFile(req, res, target, root) {
1921
1997
  return;
1922
1998
  }
1923
1999
 
2000
+ let cacheHeaders = {};
2001
+ if (modelCache) {
2002
+ try { cacheHeaders = modelCacheHeaders(target, stats, modelCache.reload); }
2003
+ catch { sendStatus(res, 404, 'file not found'); return; }
2004
+ if (modelNotModified(req, res, cacheHeaders, modelCache.reload)) return;
2005
+ }
1924
2006
  const extension = extname(target).toLowerCase();
1925
2007
  const headers = {
1926
2008
  'content-type': MIME_TYPES[extension] ?? 'application/octet-stream',
@@ -1928,10 +2010,11 @@ function sendFile(req, res, target, root) {
1928
2010
  // The page is a different origin from this route in some shells, and the
1929
2011
  // bytes are the user's own local files, so a permissive read is correct.
1930
2012
  'access-control-allow-origin': '*',
1931
- // `no-store`: a user replacing a model file on disk must see the new one on
1932
- // reload, and these bytes are cheap to re-read locally.
2013
+ // Motion callers retain no-store; model/texture callers override this with
2014
+ // private storage and ETag validation so edited files still replace old bytes.
1933
2015
  'cache-control': 'no-store',
1934
2016
  'x-content-type-options': 'nosniff',
2017
+ ...cacheHeaders,
1935
2018
  };
1936
2019
 
1937
2020
  try {
@@ -2023,7 +2106,7 @@ export function createModelInfoHandler(store) {
2023
2106
  * through the server-held id map, so a crafted id cannot name a file outside it.
2024
2107
  */
2025
2108
  /**
2026
- * Unpacked model bytes, keyed by path + size + mtime — see the compressed branch in `createAssetHandler`.
2109
+ * Unpacked model bytes, keyed by file identity and metadata — see `modelCacheHeaders`.
2027
2110
  *
2028
2111
  * Bounded like the thumbnail cache, and for the same reason: a model is megabytes, so an unbounded map of them is a leak with
2029
2112
  * a friendly name. Two or three entries cover switching between the models someone actually uses.
@@ -2056,6 +2139,8 @@ export function createAssetHandler(store) {
2056
2139
  return;
2057
2140
  }
2058
2141
  }
2142
+ const reload = url.searchParams.has('v');
2143
+ if (!isInside(entry.root, target)) { sendStatus(res, 404, 'file not found'); return; }
2059
2144
  /**
2060
2145
  * A COMPRESSED model is unpacked HERE, the way a compressed clip is on the motion route: the renderer is always handed a
2061
2146
  * plain VRM/GLB, so nothing downstream — the scene, the loaders, the bytes' own header check — has to know about brotli.
@@ -2072,8 +2157,15 @@ export function createAssetHandler(store) {
2072
2157
  sendStatus(res, 404, `cannot read the model: ${error.message}`);
2073
2158
  return;
2074
2159
  }
2075
- const key = `${target}|${stats.size}|${stats.mtimeMs}`;
2076
- let plain = modelBytesCache.get(key);
2160
+ if (!stats.isFile()) { sendStatus(res, 404, 'not a file'); return; }
2161
+ if (stats.size > MAX_ASSET_BYTES) { sendStatus(res, 413, 'file exceeds the asset byte limit'); return; }
2162
+ let headers;
2163
+ try { headers = modelCacheHeaders(target, stats, reload); }
2164
+ catch { sendStatus(res, 404, 'file not found'); return; }
2165
+ // Revalidation needs neither a disk read nor Brotli decompression.
2166
+ if (modelNotModified(req, res, headers, reload)) return;
2167
+ const key = headers.etag;
2168
+ let plain = reload ? null : modelBytesCache.get(key);
2077
2169
  if (!plain) {
2078
2170
  try {
2079
2171
  plain = decompressBrotli(readFileSync(target));
@@ -2084,11 +2176,12 @@ export function createAssetHandler(store) {
2084
2176
  modelBytesCache.set(key, plain);
2085
2177
  while (modelBytesCache.size > MODEL_BYTES_CACHE_LIMIT) modelBytesCache.delete(modelBytesCache.keys().next().value);
2086
2178
  }
2087
- res.writeHead(200, { 'content-type': 'model/gltf-binary', 'content-length': String(plain.byteLength) });
2088
- res.end(Buffer.from(plain));
2179
+ res.writeHead(200, { ...headers, 'content-type': 'model/gltf-binary', 'content-length': String(plain.byteLength) });
2180
+ if (req.method === 'HEAD') { res.end(); return; }
2181
+ res.end(plain);
2089
2182
  return;
2090
2183
  }
2091
- sendFile(req, res, target, entry.root);
2184
+ sendFile(req, res, target, entry.root, { reload });
2092
2185
  };
2093
2186
  }
2094
2187
 
@@ -2278,6 +2371,8 @@ function sendBuffer(req, res, bytes, name) {
2278
2371
  * routes, the service and the stream listeners in one step.
2279
2372
  */
2280
2373
  export function apply(ctx, config = {}) {
2374
+ const gameLevelStore = createLevelStore();
2375
+ void gameLevelStore.start();
2281
2376
  const agents = ctx.get('agents');
2282
2377
  const hub = new VrmPet3DBoundaryHub({ agents });
2283
2378
 
@@ -2296,7 +2391,7 @@ export function apply(ctx, config = {}) {
2296
2391
  const whale = resolveWhaleMark({
2297
2392
  asar: envVar('DSH_ASAR') || undefined,
2298
2393
  resourcesPath: process.resourcesPath,
2299
- installRoot: findInstallRoot(dirname(process.execPath)),
2394
+ installRoot: findInstallRoot(dirname(process.execPath)) || installRootFromDshEntry(),
2300
2395
  localFile: envVar('WHALE_FILE') || join(PLUGIN_ROOT, 'assets', 'whale-tail.apng'),
2301
2396
  stateFile: join(importRootDir(), 'whale-tail.apng'),
2302
2397
  log: (message) => ctx.logger?.warn?.(message),
@@ -3683,6 +3778,8 @@ export function apply(ctx, config = {}) {
3683
3778
  * the note where it is read below.
3684
3779
  */
3685
3780
  const sayHandler = (req, res) => {
3781
+ // GET is retained for older clients, but it can invoke a paid AI provider too.
3782
+ if (!allowWriteRequest(req, res)) return;
3686
3783
  const run = (event, state, language, part, structure, chain, clipPrompt) => {
3687
3784
  const box = speechBoxFor(event, state);
3688
3785
  if (!box) {
@@ -3828,14 +3925,21 @@ export function apply(ctx, config = {}) {
3828
3925
  };
3829
3926
 
3830
3927
  /**
3831
- * Where a window's page lives, derived from the request so no port is guessed.
3832
- *
3833
- * The route is a parameter because there are two windows now, and both URLs come from the same host
3834
- * header — the one the caller actually reached the plugin on.
3928
+ * Native windows run on THIS host. Reach the actual listener directly so they do not
3929
+ * depend on the remote browser's proxy login cookies or its externally terminated TLS.
3930
+ * Forwarded headers are deliberately not used as a native navigation target.
3835
3931
  */
3836
3932
  const pageUrlFor = (req, route) => {
3837
- const host = req?.headers?.host;
3838
- return host ? `http://${host}${route || ROUTES.window}` : null;
3933
+ const address = req?.socket?.localAddress;
3934
+ const port = req?.socket?.localPort;
3935
+ if (address && Number.isInteger(port) && port > 0 && port <= 65535) {
3936
+ const ip = address.startsWith('::ffff:') && /^\d+\.\d+\.\d+\.\d+$/.test(address.slice(7)) ? address.slice(7) : address;
3937
+ const host = ip.includes(':') ? `[${ip}]` : ip;
3938
+ try { return new URL(route || ROUTES.window, `${req.socket.encrypted ? 'https' : 'http'}://${host}:${port}`).href; }
3939
+ catch { return null; }
3940
+ }
3941
+ // A native window cannot infer a trusted listener from a public Host header.
3942
+ return null;
3839
3943
  };
3840
3944
 
3841
3945
  const desktopPageHandler = (req, res) => {
@@ -3877,6 +3981,11 @@ export function apply(ctx, config = {}) {
3877
3981
  let html = null;
3878
3982
  try {
3879
3983
  html = readFileSync(join(PLUGIN_ROOT, 'lib', file));
3984
+ if (file === 'mini-game.html') {
3985
+ const data = JSON.stringify(gameLevelStore.snapshot()).replaceAll('<', '\\u003c');
3986
+ html = Buffer.from(html.toString('utf8').replace('<script id="game-level-data" type="application/json">null</script>',
3987
+ '<script id="game-level-data" type="application/json">' + data + '</script>'));
3988
+ }
3880
3989
  } catch (error) {
3881
3990
  sendStatus(res, 500, `cannot read the ${label}: ${error.message}`);
3882
3991
  return;
@@ -3910,6 +4019,7 @@ export function apply(ctx, config = {}) {
3910
4019
  * mounts is the only version of that which cannot drift.
3911
4020
  */
3912
4021
  const pickerPageHandler = settingsPageHandler('picker-page.html', 'model-picker page');
4022
+ const gamePageHandler = settingsPageHandler('mini-game.html', 'mini-game page');
3913
4023
 
3914
4024
  /**
3915
4025
  * The page's own sources, by NAME — the module-level `WINDOW_SOURCES`, so the verification suite reads the
@@ -4009,6 +4119,12 @@ export function apply(ctx, config = {}) {
4009
4119
  * way: numbers and short CSS lengths only, sanitised here the way every other report is.
4010
4120
  */
4011
4121
  let desktopPageMetrics = null;
4122
+ const gameFocusLeases = new Map();
4123
+ function gameHasFocus() {
4124
+ const now = Date.now();
4125
+ for (const [client, deadline] of gameFocusLeases) if (deadline <= now) gameFocusLeases.delete(client);
4126
+ return gameFocusLeases.size > 0;
4127
+ }
4012
4128
  function cleanPageMetrics(raw) {
4013
4129
  if (!raw || typeof raw !== 'object') return null;
4014
4130
  const out = {};
@@ -4043,6 +4159,7 @@ export function apply(ctx, config = {}) {
4043
4159
  if (req.method === 'GET' || req.method === 'HEAD') {
4044
4160
  sendJson(req, res, {
4045
4161
  ...desktopWindow.describe(),
4162
+ gameFocused: gameHasFocus(),
4046
4163
  /**
4047
4164
  * The WINDOW's OWN bubble measurements, posted by its page (action metrics below).
4048
4165
  *
@@ -4062,6 +4179,35 @@ export function apply(ctx, config = {}) {
4062
4179
  }
4063
4180
  readJsonBody(req, res, 16 * 1024, (body) => {
4064
4181
  const action = typeof body?.action === 'string' ? body.action : '';
4182
+ if (action === 'game-focus') {
4183
+ const clientId = body?.clientId;
4184
+ if (typeof clientId !== 'string' || !/^[a-zA-Z0-9-]{8,96}$/.test(clientId) || typeof body.active !== 'boolean') {
4185
+ sendStatus(res, 400, 'invalid game focus report'); return;
4186
+ }
4187
+ gameHasFocus();
4188
+ if (body.active && (gameFocusLeases.has(clientId) || gameFocusLeases.size < 64)) gameFocusLeases.set(clientId, Date.now() + 3500);
4189
+ else if (!body.active) gameFocusLeases.delete(clientId);
4190
+ sendJson(req, res, { ok: true, gameFocused: gameHasFocus() }); return;
4191
+ }
4192
+ if (action === 'game-session') {
4193
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : '';
4194
+ const allowed = petStateStale ? [] : [petStateWhy?.statusSessionId,
4195
+ ...(petStateWhy?.waitingSessionIds || []), ...(petStateWhy?.completedSessionIds || [])];
4196
+ if (!sessionId || !allowed.includes(sessionId)) { sendJson(req, res, { ok: false }); return; }
4197
+ navigation = { seq: navigation.seq + 1, sessionId };
4198
+ sendJson(req, res, { ok: true });
4199
+ return;
4200
+ }
4201
+ if (action === 'game') {
4202
+ const gameUrl = pageUrlFor(req, ROUTES.game);
4203
+ if (!gameUrl) { sendStatus(res, 400, 'local listener address is unavailable'); return; }
4204
+ const url = new URL(gameUrl);
4205
+ if (typeof body.modelId === 'string') url.searchParams.set('model', body.modelId.slice(0, 256));
4206
+ const opened = desktopWindow.openConfig({ url: url.href });
4207
+ if (!opened.ok) { sendStatus(res, 500, opened.error || 'game could not be opened'); return; }
4208
+ sendJson(req, res, { state: 'game-open', pid: opened.pid || null, url: url.href });
4209
+ return;
4210
+ }
4065
4211
  if (action === 'open-status-session' || action === 'open-completed-session') {
4066
4212
  let sessionId = petStateStale ? null : action === 'open-completed-session'
4067
4213
  ? (['working', 'waiting'].includes(petStateValue) && petStateUnread > 0 ? petStateWhy?.completedSessionId : null)
@@ -4110,7 +4256,7 @@ export function apply(ctx, config = {}) {
4110
4256
  if (action === 'setup') {
4111
4257
  const setupUrl = pageUrlFor(req, ROUTES.setup);
4112
4258
  if (!setupUrl) {
4113
- sendStatus(res, 400, 'no host header to build the page URL from');
4259
+ sendStatus(res, 400, 'local listener address is unavailable');
4114
4260
  return;
4115
4261
  }
4116
4262
  const opened = desktopWindow.openConfig({ url: setupUrl });
@@ -4131,7 +4277,7 @@ export function apply(ctx, config = {}) {
4131
4277
  */
4132
4278
  const pickerUrl = pageUrlFor(req, ROUTES.picker);
4133
4279
  if (!pickerUrl) {
4134
- sendStatus(res, 400, 'no host header to build the page URL from');
4280
+ sendStatus(res, 400, 'local listener address is unavailable');
4135
4281
  return;
4136
4282
  }
4137
4283
  const opened = desktopWindow.openConfig({ url: pickerUrl });
@@ -4160,11 +4306,12 @@ export function apply(ctx, config = {}) {
4160
4306
  // A model parameter window is ABOUT a model, so it carries one; the 通用设置 window has no model in its URL.
4161
4307
  if (page === 'model' && wanted) query.push('model=' + encodeURIComponent(wanted));
4162
4308
  const route = page === 'global' ? ROUTES.global : ROUTES.config;
4163
- const settingsUrl = pageUrlFor(req, route) + (query.length ? '?' + query.join('&') : '');
4164
- if (!settingsUrl) {
4165
- sendStatus(res, 400, 'no host header to build the page URL from');
4309
+ const settingsBase = pageUrlFor(req, route);
4310
+ if (!settingsBase) {
4311
+ sendStatus(res, 400, 'local listener address is unavailable');
4166
4312
  return;
4167
4313
  }
4314
+ const settingsUrl = settingsBase + (query.length ? '?' + query.join('&') : '');
4168
4315
  const opened = desktopWindow.openConfig({ url: settingsUrl });
4169
4316
  if (!opened.ok) {
4170
4317
  sendStatus(res, 500, opened.error || 'the settings window could not be opened');
@@ -4180,7 +4327,7 @@ export function apply(ctx, config = {}) {
4180
4327
  if (action === 'restart') desktopWindow.stop();
4181
4328
  const url = pageUrlFor(req);
4182
4329
  if (!url) {
4183
- sendStatus(res, 400, 'no host header to build the page URL from');
4330
+ sendStatus(res, 400, 'local listener address is unavailable');
4184
4331
  return;
4185
4332
  }
4186
4333
  /**
@@ -4256,6 +4403,7 @@ export function apply(ctx, config = {}) {
4256
4403
  disposers.push(webServer.register({ kind: 'exact', path: ROUTES.config, handler: configPageHandler }));
4257
4404
  disposers.push(webServer.register({ kind: 'exact', path: ROUTES.global, handler: globalPageHandler }));
4258
4405
  disposers.push(webServer.register({ kind: 'exact', path: ROUTES.picker, handler: pickerPageHandler }));
4406
+ disposers.push(webServer.register({ kind: 'exact', path: ROUTES.game, handler: gamePageHandler }));
4259
4407
  disposers.push(webServer.register({ kind: 'exact', path: ROUTES.settings, handler: settingsHandler }));
4260
4408
  disposers.push(webServer.register({ kind: 'exact', path: ROUTES.preview, handler: previewHandler }));
4261
4409
  disposers.push(webServer.register({ kind: 'exact', path: ROUTES.setup, handler: setupPageHandler }));
package/lib/menu.js CHANGED
@@ -60,6 +60,7 @@
60
60
  * whose effect ends by itself (the clip finishes, or the user double-clicks the pet, or work starts).
61
61
  */
62
62
  'dance',
63
+ 'miniGame',
63
64
  /**
64
65
  * 重载模型 — refresh the model on screen without touching anything else (operator, 2026-10-14: 「给内置和外置宠物的右键菜单
65
66
  * 新增“重载模型”的选项,用来刷新模型」).
@@ -102,6 +103,7 @@
102
103
  resetSize: 'Reset size',
103
104
  globalSettings: 'General settings',
104
105
  switchModel: 'Switch model',
106
+ miniGame: 'Pet breakout',
105
107
  reloadModel: 'Reload model',
106
108
  dance: 'Dance for me',
107
109
  modelSettings: "This model's parameters",
@@ -130,6 +132,7 @@
130
132
  resetSize: '重置大小',
131
133
  globalSettings: '通用设置',
132
134
  switchModel: '切换模型',
135
+ miniGame: '小游戏 · 吃白饭',
133
136
  reloadModel: '重载模型',
134
137
  dance: '跳支舞',
135
138
  modelSettings: '当前模型参数设置',