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/README.md +1 -0
- package/assets/game/levels.json +2925 -0
- package/desktop/game-visibility.cjs +65 -0
- package/desktop/main.js +58 -2
- package/desktop/preload.js +10 -0
- package/lib/client.core.js +78 -33
- package/lib/client.js +41 -11
- package/lib/client.menu.js +3 -0
- package/lib/client.scene.js +3 -2
- package/lib/config-page.js +12 -12
- package/lib/core.js +78 -33
- package/lib/desktop-page.js +16 -7
- package/lib/index.js +182 -34
- package/lib/menu.js +3 -0
- package/lib/mini-game-core.js +329 -0
- package/lib/mini-game-level-store.js +37 -0
- package/lib/mini-game-page.js +615 -0
- package/lib/mini-game.css +19 -0
- package/lib/mini-game.html +50 -0
- package/lib/model-picker.js +4 -4
- package/lib/scene.js +3 -2
- package/lib/setup-page.js +3 -3
- package/package.json +3 -3
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
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
*
|
|
1901
|
-
|
|
1902
|
-
|
|
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
|
-
//
|
|
1932
|
-
//
|
|
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
|
|
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
|
-
|
|
2076
|
-
|
|
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(
|
|
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
|
-
*
|
|
3832
|
-
*
|
|
3833
|
-
*
|
|
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
|
|
3838
|
-
|
|
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, '
|
|
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, '
|
|
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
|
|
4164
|
-
if (!
|
|
4165
|
-
sendStatus(res, 400, '
|
|
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, '
|
|
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: '当前模型参数设置',
|