@linxin666/dsh-pet 0.2.2 → 0.2.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.i18n.yaml +2 -2
- package/README.md +129 -3
- package/README.zh.md +129 -3
- package/assets/decorations/whale/decoration.json +20 -0
- package/assets/decorations/whale/whale-frames.png +0 -0
- package/assets/whale/pet.json +169 -20
- package/assets/whale-refined/pet.json +17 -2
- package/contracts/pet-manifest-v2.schema.json +281 -0
- package/contracts/status-decoration-v1.schema.json +144 -0
- package/contracts/voice-pack-v1.schema.json +234 -0
- package/lib/client.js +581 -38
- package/lib/client.js.map +1 -1
- package/lib/index.js +1684 -59
- package/lib/live2d-vendor.js +995 -0
- package/lib/live2d-vendor.js.map +1 -0
- package/lib/types/chatter.d.ts +61 -3
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +71 -12
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +2 -1
- package/lib/types/client/PetSettingsCard.d.ts +14 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +24 -1
- package/lib/types/client/PetSprite.d.ts +7 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +120 -12
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +5 -0
- package/lib/types/client/locales.d.ts +14 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +14 -0
- package/lib/types/client/phase-stream.d.ts +22 -0
- package/lib/types/client/phase-stream.d.ts.map +1 -0
- package/lib/types/client/phase-stream.js +28 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
- package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/runtime.js +63 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
- package/lib/types/client/renderers/live2d.d.ts +52 -0
- package/lib/types/client/renderers/live2d.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d.js +192 -0
- package/lib/types/client/renderers/registry.d.ts +32 -0
- package/lib/types/client/renderers/registry.d.ts.map +1 -0
- package/lib/types/client/renderers/registry.js +49 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- package/lib/types/contracts/renderer.d.ts +50 -0
- package/lib/types/contracts/renderer.d.ts.map +1 -0
- package/lib/types/contracts/renderer.js +15 -0
- package/lib/types/contracts/status-decoration.d.ts +85 -0
- package/lib/types/contracts/status-decoration.d.ts.map +1 -0
- package/lib/types/contracts/status-decoration.js +21 -0
- package/lib/types/decoration.d.ts +39 -0
- package/lib/types/decoration.d.ts.map +1 -0
- package/lib/types/decoration.js +210 -0
- package/lib/types/event-projection.d.ts +8 -3
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +9 -4
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +2 -0
- package/lib/types/manifest-v2.d.ts +129 -0
- package/lib/types/manifest-v2.d.ts.map +1 -0
- package/lib/types/manifest-v2.js +387 -0
- package/lib/types/model3.d.ts +26 -0
- package/lib/types/model3.d.ts.map +1 -0
- package/lib/types/model3.js +96 -0
- package/lib/types/registry.d.ts +112 -5
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +395 -38
- package/lib/types/routes.d.ts +39 -2
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +312 -13
- package/lib/types/service.d.ts +38 -1
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +46 -3
- package/lib/types/voice-pack.d.ts +98 -0
- package/lib/types/voice-pack.d.ts.map +1 -0
- package/lib/types/voice-pack.js +384 -0
- package/package.json +13 -10
- package/src/chatter.test.ts +89 -2
- package/src/chatter.ts +120 -14
- package/src/client/PetDockEntry.tsx +19 -11
- package/src/client/PetSettingsCard.tsx +57 -0
- package/src/client/PetSprite.test.tsx +265 -12
- package/src/client/PetSprite.tsx +165 -33
- package/src/client/index.ts +6 -0
- package/src/client/locales.ts +14 -0
- package/src/client/phase-stream.test.ts +76 -0
- package/src/client/phase-stream.ts +39 -0
- package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
- package/src/client/renderers/PetRendererSwitch.tsx +48 -0
- package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
- package/src/client/renderers/live2d/runtime.test.ts +61 -0
- package/src/client/renderers/live2d/runtime.ts +119 -0
- package/src/client/renderers/live2d/vendor-entry.ts +14 -0
- package/src/client/renderers/live2d.test.ts +263 -0
- package/src/client/renderers/live2d.ts +231 -0
- package/src/client/renderers/registry.ts +58 -0
- package/src/client/settings-form.ts +8 -6
- package/src/client/settings-section.module.css +23 -0
- package/src/contracts/renderer.ts +54 -0
- package/src/contracts/status-decoration.ts +78 -0
- package/src/decoration.test.ts +178 -0
- package/src/decoration.ts +220 -0
- package/src/event-projection.ts +10 -5
- package/src/index.ts +2 -0
- package/src/manifest-v2.test.ts +251 -0
- package/src/manifest-v2.ts +414 -0
- package/src/model3.test.ts +79 -0
- package/src/model3.ts +91 -0
- package/src/registry.test.ts +438 -2
- package/src/registry.ts +470 -38
- package/src/routes.ts +328 -14
- package/src/service.ts +65 -3
- package/src/voice-pack.test.ts +216 -0
- package/src/voice-pack.ts +413 -0
package/lib/types/registry.js
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pet registry — the multi-pet contract. One pet is a directory holding a
|
|
3
3
|
* 'pet.json' manifest plus an atlas image; nothing else is required, and no
|
|
4
|
-
* host or client code changes when a pet is added. The registry scans
|
|
4
|
+
* host or client code changes when a pet is added. The registry scans four
|
|
5
5
|
* sources, later sources overriding earlier ones on an id collision:
|
|
6
6
|
*
|
|
7
7
|
* 1. the package's own 'assets' subdirectories (built-in pets);
|
|
8
|
-
* 2. '${CODEX_HOME:-~/.codex}/pets' subdirectories (hatch-pet custom pets
|
|
9
|
-
*
|
|
8
|
+
* 2. '${CODEX_HOME:-~/.codex}/pets' subdirectories (hatch-pet custom pets,
|
|
9
|
+
* legacy source kept readable);
|
|
10
|
+
* 3. '$DSH_HOME/pets' subdirectories (the pet-center user directory);
|
|
11
|
+
* 4. 'PetConfig.pets' manifests composed by the embedding application
|
|
10
12
|
* (highest precedence).
|
|
11
13
|
*
|
|
14
|
+
* Manifests are parsed through manifest-v2 (pet-center M2, issue #623): v1
|
|
15
|
+
* manifests are compat-read as sprite2d, v2 manifests validate fail-closed,
|
|
16
|
+
* and structured diagnostics ride alongside the legacy warnings. Live2d
|
|
17
|
+
* entries (pet-center M3) list like any other pet: the entry carries the
|
|
18
|
+
* validated live2d block plus the model's reference closure (the servable
|
|
19
|
+
* set the asset route allows), and a model3.json that is unreadable or
|
|
20
|
+
* declares unsafe references rejects the entry with an error diagnostic.
|
|
21
|
+
*
|
|
12
22
|
* The manifest follows the Codex/hatch-pet contract (8 columns x 9 rows of
|
|
13
23
|
* 192x208 cells, the 9-state row order below). Legacy whale-girl manifests
|
|
14
24
|
* that only carry 'frames' keep working: geometry, per-row frame counts and
|
|
@@ -16,11 +26,17 @@
|
|
|
16
26
|
* whale-girl manifest overrides its own durations.
|
|
17
27
|
* @module @linxin666/dsh-pet/registry
|
|
18
28
|
*/
|
|
19
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
29
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
20
30
|
import { homedir } from 'node:os';
|
|
21
31
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
22
32
|
import { fileURLToPath } from 'node:url';
|
|
23
33
|
import { normalizePetRemarks } from './remarks.js';
|
|
34
|
+
import { mergeVoicePacks, normalizeVoicePack } from './voice-pack.js';
|
|
35
|
+
import { parseDecorationManifest } from './decoration.js';
|
|
36
|
+
import { PET_DECORATION_API_VERSION } from './contracts/status-decoration.js';
|
|
37
|
+
import { dshHome } from './dsh-home.js';
|
|
38
|
+
import { parsePetManifest } from './manifest-v2.js';
|
|
39
|
+
import { collectModel3References } from './model3.js';
|
|
24
40
|
/** Fixed row order of the 9-state animation contract. */
|
|
25
41
|
export const PET_ROW_ORDER = [
|
|
26
42
|
'idle',
|
|
@@ -115,6 +131,41 @@ function normalizeSequences(raw, id, warn) {
|
|
|
115
131
|
}
|
|
116
132
|
return Object.keys(sequences).length === 0 ? undefined : sequences;
|
|
117
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Build the fully resolved animation tracks from the contract defaults plus
|
|
136
|
+
* optional per-track overrides. Shared by the sprite2d resolver and the
|
|
137
|
+
* live2d entry builder (which fills the sprite fields with contract
|
|
138
|
+
* defaults so the flat PetDefinition shape holds for every renderer).
|
|
139
|
+
*/
|
|
140
|
+
function buildTracks(rows, columns, trackOverrides, warn) {
|
|
141
|
+
const tracks = {};
|
|
142
|
+
for (const [row, animation] of PET_ROW_ORDER.entries()) {
|
|
143
|
+
const pattern = DEFAULT_TRACK_PATTERNS[animation];
|
|
144
|
+
const override = trackOverrides[animation];
|
|
145
|
+
const durations = Array.isArray(override?.durations) && override.durations.length > 0
|
|
146
|
+
? override.durations.filter((value) => typeof value === 'number' && Number.isFinite(value) && value > 0)
|
|
147
|
+
: pattern.durations;
|
|
148
|
+
if (durations.length === 0) {
|
|
149
|
+
warn('track ' + animation + ' carries no usable durations');
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
const frameCount = Math.max(1, Math.min(rows[row], columns));
|
|
153
|
+
const sized = durations.length >= frameCount
|
|
154
|
+
? durations.slice(0, frameCount)
|
|
155
|
+
: Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]);
|
|
156
|
+
tracks[animation] = {
|
|
157
|
+
frames: Array.from({ length: frameCount }, (_, index) => index),
|
|
158
|
+
durations: sized,
|
|
159
|
+
loop: typeof override?.loop === 'boolean' ? override.loop : pattern.loop,
|
|
160
|
+
...(override?.fallback === undefined
|
|
161
|
+
? pattern.fallback === undefined ? {} : { fallback: pattern.fallback }
|
|
162
|
+
: PET_ROW_ORDER.includes(override.fallback)
|
|
163
|
+
? { fallback: override.fallback }
|
|
164
|
+
: pattern.fallback === undefined ? {} : { fallback: pattern.fallback }),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return tracks;
|
|
168
|
+
}
|
|
118
169
|
/**
|
|
119
170
|
* Normalize one parsed manifest into a renderable pet entry, or undefined
|
|
120
171
|
* (with a warning recorded) when the manifest violates the contract.
|
|
@@ -164,36 +215,15 @@ export function resolvePetManifest(raw, dir, options = {}) {
|
|
|
164
215
|
const remarks = normalizePetRemarks(source.remarks, message => warn('manifest ' + id + ': ' + message));
|
|
165
216
|
const sequences = normalizeSequences(source.sequences, id, warn);
|
|
166
217
|
const trackOverrides = (typeof source.tracks === 'object' && source.tracks !== null ? source.tracks : {});
|
|
167
|
-
const tracks =
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const durations = Array.isArray(override?.durations) && override.durations.length > 0
|
|
172
|
-
? override.durations.filter((value) => typeof value === 'number' && Number.isFinite(value) && value > 0)
|
|
173
|
-
: pattern.durations;
|
|
174
|
-
if (durations.length === 0) {
|
|
175
|
-
warn('manifest ' + id + ': track ' + animation + ' carries no usable durations');
|
|
176
|
-
return undefined;
|
|
177
|
-
}
|
|
178
|
-
const frameCount = Math.max(1, Math.min(rows[row], columns));
|
|
179
|
-
const sized = durations.length >= frameCount
|
|
180
|
-
? durations.slice(0, frameCount)
|
|
181
|
-
: Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]);
|
|
182
|
-
tracks[animation] = {
|
|
183
|
-
frames: Array.from({ length: frameCount }, (_, index) => index),
|
|
184
|
-
durations: sized,
|
|
185
|
-
loop: typeof override?.loop === 'boolean' ? override.loop : pattern.loop,
|
|
186
|
-
...(override?.fallback === undefined
|
|
187
|
-
? pattern.fallback === undefined ? {} : { fallback: pattern.fallback }
|
|
188
|
-
: PET_ROW_ORDER.includes(override.fallback)
|
|
189
|
-
? { fallback: override.fallback }
|
|
190
|
-
: pattern.fallback === undefined ? {} : { fallback: pattern.fallback }),
|
|
191
|
-
};
|
|
192
|
-
}
|
|
218
|
+
const tracks = buildTracks(rows, columns, trackOverrides, message => warn('manifest ' + id + ': ' + message));
|
|
219
|
+
if (tracks === undefined)
|
|
220
|
+
return undefined;
|
|
221
|
+
const sheet = spritesheetPath.join('/');
|
|
193
222
|
return {
|
|
194
223
|
id,
|
|
195
224
|
displayName,
|
|
196
225
|
description,
|
|
226
|
+
renderer: 'sprite2d',
|
|
197
227
|
cell,
|
|
198
228
|
columns,
|
|
199
229
|
rows,
|
|
@@ -203,7 +233,121 @@ export function resolvePetManifest(raw, dir, options = {}) {
|
|
|
203
233
|
atlasUrl: assetUrl(assetPrefix, id, spritesheet),
|
|
204
234
|
manifestUrl: assetUrl(assetPrefix, id, 'pet.json'),
|
|
205
235
|
dir,
|
|
206
|
-
spritesheetPath:
|
|
236
|
+
spritesheetPath: sheet,
|
|
237
|
+
servable: [sheet],
|
|
238
|
+
...(remarks === undefined ? {} : { remarks }),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Adapt a validated v2 manifest's sprite2d block onto the legacy flat shape
|
|
243
|
+
* the established resolver consumes (pet-center M2 P2). The legacy resolver
|
|
244
|
+
* only expresses 9-row (default) and 11-row (spriteVersionNumber 2) atlases,
|
|
245
|
+
* so other atlasRows values are rejected here with a diagnostic.
|
|
246
|
+
*/
|
|
247
|
+
function flattenV2Sprite2d(manifest) {
|
|
248
|
+
const block = manifest.sprite2d;
|
|
249
|
+
if (block === undefined)
|
|
250
|
+
return undefined;
|
|
251
|
+
const legacy = {
|
|
252
|
+
id: manifest.id,
|
|
253
|
+
displayName: manifest.displayName,
|
|
254
|
+
spritesheetPath: block.spritesheetPath,
|
|
255
|
+
};
|
|
256
|
+
if (manifest.description !== undefined)
|
|
257
|
+
legacy.description = manifest.description;
|
|
258
|
+
if (block.cell !== undefined)
|
|
259
|
+
legacy.cell = block.cell;
|
|
260
|
+
if (block.columns !== undefined)
|
|
261
|
+
legacy.columns = block.columns;
|
|
262
|
+
if (block.frames !== undefined)
|
|
263
|
+
legacy.frames = block.frames;
|
|
264
|
+
if (block.tracks !== undefined)
|
|
265
|
+
legacy.tracks = block.tracks;
|
|
266
|
+
if (block.atlasRows !== undefined) {
|
|
267
|
+
if (block.atlasRows === 11)
|
|
268
|
+
legacy.spriteVersionNumber = 2;
|
|
269
|
+
else if (block.atlasRows !== DEFAULT_PET_ROW_COUNT)
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
if (manifest.sequences !== undefined)
|
|
273
|
+
legacy.sequences = manifest.sequences;
|
|
274
|
+
if (manifest.remarks !== undefined)
|
|
275
|
+
legacy.remarks = manifest.remarks;
|
|
276
|
+
return legacy;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Resolve a validated live2d manifest into a renderable entry (pet-center
|
|
280
|
+
* M3). The model3.json is read at scan time: its reference closure becomes
|
|
281
|
+
* the entry's servable set (the asset route's allow-list), and a model that
|
|
282
|
+
* is unreadable or declares unsafe references rejects the entry fail-closed
|
|
283
|
+
* with an error diagnostic. Closure files missing on disk warn but keep the
|
|
284
|
+
* entry listed — the client renderer's diagnostic card reports the broken
|
|
285
|
+
* render, matching the registry's never-throw philosophy (install-time
|
|
286
|
+
* strictness belongs to the CLI validator). The sprite fields carry contract
|
|
287
|
+
* defaults: the chrome sizes live2d pets off 'display.size', not the atlas.
|
|
288
|
+
*/
|
|
289
|
+
function resolveLive2dEntry(manifest, dir, options) {
|
|
290
|
+
const assetPrefix = options.assetPrefix ?? '/pet';
|
|
291
|
+
const record = (level, message) => {
|
|
292
|
+
options.diagnostics?.push({ level, source: dir, message });
|
|
293
|
+
options.warnings?.push(message);
|
|
294
|
+
};
|
|
295
|
+
const block = manifest.live2d;
|
|
296
|
+
if (block === undefined) {
|
|
297
|
+
record('error', 'pet ' + manifest.id + ': renderer live2d requires a live2d block');
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
let model3;
|
|
301
|
+
try {
|
|
302
|
+
model3 = JSON.parse(readFileSync(join(dir, block.model), 'utf8'));
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
record('error', 'pet ' + manifest.id + ': live2d model ' + block.model + ' is not readable: '
|
|
306
|
+
+ (error instanceof Error ? error.message : String(error)));
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
const { references, errors } = collectModel3References(model3);
|
|
310
|
+
if (errors.length > 0) {
|
|
311
|
+
for (const message of errors) {
|
|
312
|
+
record('error', 'pet ' + manifest.id + ': live2d model ' + block.model + ': ' + message);
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
for (const reference of references) {
|
|
317
|
+
if (!existsSync(join(dir, reference))) {
|
|
318
|
+
record('warning', 'pet ' + manifest.id + ': live2d closure file missing: ' + reference);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const tracks = buildTracks(DEFAULT_FRAME_COUNTS, DEFAULT_PET_COLUMNS, {}, message => record('warning', 'pet ' + manifest.id + ': ' + message));
|
|
322
|
+
if (tracks === undefined)
|
|
323
|
+
return undefined;
|
|
324
|
+
const remarks = normalizePetRemarks(manifest.remarks, message => record('warning', 'pet ' + manifest.id + ': ' + message));
|
|
325
|
+
const modelUrl = assetUrl(assetPrefix, manifest.id, block.model);
|
|
326
|
+
const live2d = {
|
|
327
|
+
modelUrl,
|
|
328
|
+
modelPath: block.model,
|
|
329
|
+
...(block.scale === undefined ? {} : { scale: block.scale }),
|
|
330
|
+
...(block.translate === undefined ? {} : { translate: block.translate }),
|
|
331
|
+
motions: block.motions,
|
|
332
|
+
...(block.expressions === undefined ? {} : { expressions: block.expressions }),
|
|
333
|
+
...(block.hitAreas === undefined ? {} : { hitAreas: block.hitAreas }),
|
|
334
|
+
};
|
|
335
|
+
return {
|
|
336
|
+
id: manifest.id,
|
|
337
|
+
displayName: manifest.displayName,
|
|
338
|
+
description: manifest.description ?? '',
|
|
339
|
+
renderer: 'live2d',
|
|
340
|
+
live2d,
|
|
341
|
+
cell: { ...DEFAULT_PET_CELL },
|
|
342
|
+
columns: DEFAULT_PET_COLUMNS,
|
|
343
|
+
rows: [...DEFAULT_FRAME_COUNTS],
|
|
344
|
+
atlasRows: DEFAULT_PET_ROW_COUNT,
|
|
345
|
+
tracks,
|
|
346
|
+
atlasUrl: modelUrl,
|
|
347
|
+
manifestUrl: assetUrl(assetPrefix, manifest.id, 'pet.json'),
|
|
348
|
+
dir,
|
|
349
|
+
spritesheetPath: block.model,
|
|
350
|
+
servable: [block.model, ...references],
|
|
207
351
|
...(remarks === undefined ? {} : { remarks }),
|
|
208
352
|
};
|
|
209
353
|
}
|
|
@@ -227,9 +371,33 @@ function scanPetDir(dir, options) {
|
|
|
227
371
|
const parsed = readPetJson(manifestFile, options.warnings);
|
|
228
372
|
if (parsed === undefined)
|
|
229
373
|
continue;
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
374
|
+
const entryDir = join(dir, name);
|
|
375
|
+
const verdict = parsePetManifest(parsed, entryDir);
|
|
376
|
+
for (const diagnostic of verdict.diagnostics) {
|
|
377
|
+
options.diagnostics?.push({ level: diagnostic.level, source: entryDir, message: diagnostic.message });
|
|
378
|
+
options.warnings?.push(diagnostic.message);
|
|
379
|
+
}
|
|
380
|
+
if (!verdict.ok)
|
|
381
|
+
continue;
|
|
382
|
+
let entry;
|
|
383
|
+
if (verdict.manifest.renderer === 'live2d') {
|
|
384
|
+
entry = resolveLive2dEntry(verdict.manifest, entryDir, options);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
const legacy = flattenV2Sprite2d(verdict.manifest);
|
|
388
|
+
if (legacy === undefined) {
|
|
389
|
+
const note = 'pet ' + verdict.manifest.id + ': sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver';
|
|
390
|
+
options.diagnostics?.push({ level: 'error', source: entryDir, message: note });
|
|
391
|
+
options.warnings?.push(note);
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
entry = resolvePetManifest(legacy, entryDir, options);
|
|
395
|
+
}
|
|
396
|
+
if (entry === undefined)
|
|
397
|
+
continue;
|
|
398
|
+
// Optional voice pack (voice.json) — pure content, warn-and-drop (M4).
|
|
399
|
+
const voice = loadVoicePackFile(join(entryDir, 'voice.json'), options);
|
|
400
|
+
entries.push({ ...entry, ...(voice === undefined ? {} : { voice }) });
|
|
233
401
|
}
|
|
234
402
|
return entries;
|
|
235
403
|
}
|
|
@@ -243,6 +411,136 @@ function readPetJson(file, warnings) {
|
|
|
243
411
|
return undefined;
|
|
244
412
|
}
|
|
245
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* Scan-time read ceiling for user-authored JSON descriptors (voice.json,
|
|
416
|
+
* .voice.json, decoration.json): the registry reads these synchronously at
|
|
417
|
+
* plugin startup, and a pathological file — multi-GB, or a FIFO/device
|
|
418
|
+
* symlink — must not hang or exhaust the host before the warn-and-drop
|
|
419
|
+
* discipline can apply (review-spd follow-up, pet-center M4/M5).
|
|
420
|
+
*/
|
|
421
|
+
export const PET_SCAN_JSON_CAP = 64 * 1024;
|
|
422
|
+
/**
|
|
423
|
+
* Stat one scanned JSON descriptor with a regular-file + size guard, so a
|
|
424
|
+
* pathological user file is skipped with a warning instead of stalling or
|
|
425
|
+
* OOM-ing the host at startup. Returns the Stats, or undefined when the
|
|
426
|
+
* caller must skip the file (a warning was recorded).
|
|
427
|
+
*/
|
|
428
|
+
function guardedScannedJsonStat(file, options, what) {
|
|
429
|
+
let st;
|
|
430
|
+
try {
|
|
431
|
+
st = statSync(file);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
const warn = (message) => {
|
|
437
|
+
options.warnings?.push(file + ': ' + message);
|
|
438
|
+
options.diagnostics?.push({ level: 'warning', source: file, message: file + ': ' + message });
|
|
439
|
+
};
|
|
440
|
+
if (!st.isFile()) {
|
|
441
|
+
warn(what + ' is not a regular file; ignored');
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
if (st.size > PET_SCAN_JSON_CAP) {
|
|
445
|
+
warn(what + ' exceeds the ' + PET_SCAN_JSON_CAP + '-byte scan ceiling; ignored');
|
|
446
|
+
return undefined;
|
|
447
|
+
}
|
|
448
|
+
return st;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Load and normalize one optional voice.json (pet-center M4). A missing
|
|
452
|
+
* file is silent; a broken file warns and drops. The pack is pure content,
|
|
453
|
+
* so every issue stays a warning — a bad voice.json never rejects a pet.
|
|
454
|
+
*/
|
|
455
|
+
function loadVoicePackFile(file, options) {
|
|
456
|
+
if (!existsSync(file))
|
|
457
|
+
return undefined;
|
|
458
|
+
if (guardedScannedJsonStat(file, options, 'voice pack') === undefined)
|
|
459
|
+
return undefined;
|
|
460
|
+
const warn = (message) => {
|
|
461
|
+
options.warnings?.push(file + ': ' + message);
|
|
462
|
+
options.diagnostics?.push({ level: 'warning', source: file, message: file + ': ' + message });
|
|
463
|
+
};
|
|
464
|
+
let raw;
|
|
465
|
+
try {
|
|
466
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
warn('voice pack is not valid JSON; ignored: ' + (error instanceof Error ? error.message : String(error)));
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
return normalizeVoicePack(raw, warn);
|
|
473
|
+
}
|
|
474
|
+
/** Decoration asset URL prefix (served by the decoration route, M5). */
|
|
475
|
+
export const DECORATION_ASSET_PREFIX = '/api/pet/decoration';
|
|
476
|
+
/**
|
|
477
|
+
* Scan one directory of decoration folders ('decoration.json' + strip).
|
|
478
|
+
* Later scans override earlier ones on id collision; a bad descriptor warns
|
|
479
|
+
* and skips — the never-throw philosophy holds for decorations too (M5).
|
|
480
|
+
*/
|
|
481
|
+
function scanDecorationDir(dir, options) {
|
|
482
|
+
if (!existsSync(dir))
|
|
483
|
+
return [];
|
|
484
|
+
let names = [];
|
|
485
|
+
try {
|
|
486
|
+
names = readdirSync(dir).filter(name => !name.startsWith('.'));
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
names.sort();
|
|
492
|
+
const entries = [];
|
|
493
|
+
for (const name of names) {
|
|
494
|
+
const entryDir = join(dir, name);
|
|
495
|
+
const manifestFile = join(entryDir, 'decoration.json');
|
|
496
|
+
if (!existsSync(manifestFile))
|
|
497
|
+
continue;
|
|
498
|
+
if (guardedScannedJsonStat(manifestFile, options, 'decoration descriptor') === undefined)
|
|
499
|
+
continue;
|
|
500
|
+
let raw;
|
|
501
|
+
try {
|
|
502
|
+
raw = JSON.parse(readFileSync(manifestFile, 'utf8'));
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
const message = 'skipping ' + manifestFile + ': ' + (error instanceof Error ? error.message : String(error));
|
|
506
|
+
options.warnings?.push(message);
|
|
507
|
+
options.diagnostics?.push({ level: 'error', source: entryDir, message });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const verdict = parseDecorationManifest(raw, manifestFile);
|
|
511
|
+
for (const diagnostic of verdict.diagnostics) {
|
|
512
|
+
options.diagnostics?.push({ level: diagnostic.level, source: entryDir, message: diagnostic.message });
|
|
513
|
+
options.warnings?.push(diagnostic.message);
|
|
514
|
+
}
|
|
515
|
+
if (!verdict.ok)
|
|
516
|
+
continue;
|
|
517
|
+
const manifest = verdict.manifest;
|
|
518
|
+
// A missing strip keeps the entry listed (mirroring the live2d closure
|
|
519
|
+
// discipline) but earns a diagnostic: the ornament will silently render
|
|
520
|
+
// nothing, and the warning names the file to fix.
|
|
521
|
+
if (!existsSync(join(entryDir, manifest.entry))) {
|
|
522
|
+
const message = 'decoration ' + manifest.id + ': strip file missing: ' + manifest.entry;
|
|
523
|
+
options.warnings?.push(message);
|
|
524
|
+
options.diagnostics?.push({ level: 'warning', source: entryDir, message });
|
|
525
|
+
}
|
|
526
|
+
entries.push({
|
|
527
|
+
apiVersion: PET_DECORATION_API_VERSION,
|
|
528
|
+
id: manifest.id,
|
|
529
|
+
dir: entryDir,
|
|
530
|
+
entryPath: manifest.entry,
|
|
531
|
+
servable: ['decoration.json', manifest.entry],
|
|
532
|
+
license: manifest.license,
|
|
533
|
+
assetBase: DECORATION_ASSET_PREFIX + '/' + encodeURIComponent(manifest.id),
|
|
534
|
+
entryUrl: DECORATION_ASSET_PREFIX + '/' + encodeURIComponent(manifest.id) + '/' + manifest.entry,
|
|
535
|
+
cell: manifest.cell,
|
|
536
|
+
columns: manifest.columns,
|
|
537
|
+
durations: manifest.durations,
|
|
538
|
+
loop: manifest.loop,
|
|
539
|
+
phases: manifest.phases,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
return entries;
|
|
543
|
+
}
|
|
246
544
|
/**
|
|
247
545
|
* Load the pet registry: built-in 'assets/*' first, then the hatch-pet
|
|
248
546
|
* custom pets directory, then composed 'extra' manifests (each later source
|
|
@@ -252,9 +550,10 @@ function readPetJson(file, warnings) {
|
|
|
252
550
|
export function loadPetRegistry(options) {
|
|
253
551
|
const { packageRoot, assetPrefix = '/pet' } = options;
|
|
254
552
|
const warnings = [];
|
|
553
|
+
const diagnostics = [];
|
|
255
554
|
const byId = new Map();
|
|
256
555
|
const builtinIds = new Set();
|
|
257
|
-
for (const entry of scanPetDir(join(packageRoot, 'assets'), { assetPrefix, warnings })) {
|
|
556
|
+
for (const entry of scanPetDir(join(packageRoot, 'assets'), { assetPrefix, warnings, diagnostics })) {
|
|
258
557
|
if (byId.has(entry.id)) {
|
|
259
558
|
warnings.push('duplicate built-in pet id ' + entry.id + '; the first one wins');
|
|
260
559
|
continue;
|
|
@@ -264,12 +563,24 @@ export function loadPetRegistry(options) {
|
|
|
264
563
|
}
|
|
265
564
|
const petsDir = options.petsDir ?? codexPetsDir();
|
|
266
565
|
if (petsDir !== '') {
|
|
267
|
-
for (const entry of scanPetDir(petsDir, { assetPrefix, warnings })) {
|
|
566
|
+
for (const entry of scanPetDir(petsDir, { assetPrefix, warnings, diagnostics })) {
|
|
268
567
|
if (byId.has(entry.id))
|
|
269
568
|
warnings.push('custom pet ' + entry.id + ' overrides the built-in one');
|
|
270
569
|
byId.set(entry.id, entry);
|
|
271
570
|
}
|
|
272
571
|
}
|
|
572
|
+
// The pet-center user directory ranks above the legacy hatch-pet source.
|
|
573
|
+
const dshPetsDir = options.dshPetsDir ?? join(dshHome(), 'pets');
|
|
574
|
+
let globalVoice;
|
|
575
|
+
if (dshPetsDir !== '') {
|
|
576
|
+
for (const entry of scanPetDir(dshPetsDir, { assetPrefix, warnings, diagnostics })) {
|
|
577
|
+
if (byId.has(entry.id))
|
|
578
|
+
warnings.push('user pet ' + entry.id + ' overrides an earlier registration');
|
|
579
|
+
byId.set(entry.id, entry);
|
|
580
|
+
}
|
|
581
|
+
// The global voice override layers under every per-pet pack (M4, #677).
|
|
582
|
+
globalVoice = loadVoicePackFile(join(dshPetsDir, '.voice.json'), { warnings, diagnostics });
|
|
583
|
+
}
|
|
273
584
|
for (const manifest of options.extra ?? []) {
|
|
274
585
|
const raw = manifest.spritesheetPath;
|
|
275
586
|
const dir = raw === undefined || isAbsolute(raw)
|
|
@@ -289,20 +600,65 @@ export function loadPetRegistry(options) {
|
|
|
289
600
|
warnings.push('composed pet ' + entry.id + ' overrides an earlier registration');
|
|
290
601
|
byId.set(entry.id, entry);
|
|
291
602
|
}
|
|
603
|
+
// Status decorations (pet-center M5, #567): built-in entries first,
|
|
604
|
+
// then same-id user entries under '$DSH_HOME/pets/decorations' override.
|
|
605
|
+
const decorationById = new Map();
|
|
606
|
+
for (const entry of scanDecorationDir(join(packageRoot, 'assets', 'decorations'), { warnings, diagnostics })) {
|
|
607
|
+
decorationById.set(entry.id, entry);
|
|
608
|
+
}
|
|
609
|
+
if (dshPetsDir !== '') {
|
|
610
|
+
for (const entry of scanDecorationDir(join(dshPetsDir, 'decorations'), { warnings, diagnostics })) {
|
|
611
|
+
if (decorationById.has(entry.id)) {
|
|
612
|
+
warnings.push('user decoration ' + entry.id + ' overrides the built-in one');
|
|
613
|
+
}
|
|
614
|
+
decorationById.set(entry.id, entry);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
292
617
|
const entries = [...byId.values()];
|
|
618
|
+
const decorations = [...decorationById.values()];
|
|
293
619
|
return {
|
|
294
620
|
entries,
|
|
295
621
|
warnings,
|
|
622
|
+
diagnostics,
|
|
296
623
|
byId: (id) => byId.get(id),
|
|
297
624
|
defaultEntry: () => entries.find(entry => builtinIds.has(entry.id)) ?? entries[0],
|
|
625
|
+
...(globalVoice === undefined ? {} : { globalVoice }),
|
|
626
|
+
decorations,
|
|
627
|
+
decorationById: (id) => decorationById.get(id),
|
|
298
628
|
};
|
|
299
629
|
}
|
|
300
|
-
/**
|
|
301
|
-
export
|
|
630
|
+
/** The built-in default decoration id (M5): the first reference ornament. */
|
|
631
|
+
export const DEFAULT_DECORATION_ID = 'whale';
|
|
632
|
+
/** Strip host-only fields, leaving the browser-visible decoration view. */
|
|
633
|
+
export function decorationView(entry) {
|
|
634
|
+
return {
|
|
635
|
+
apiVersion: PET_DECORATION_API_VERSION,
|
|
636
|
+
id: entry.id,
|
|
637
|
+
assetBase: entry.assetBase,
|
|
638
|
+
entryUrl: entry.entryUrl,
|
|
639
|
+
cell: entry.cell,
|
|
640
|
+
columns: entry.columns,
|
|
641
|
+
durations: entry.durations,
|
|
642
|
+
loop: entry.loop,
|
|
643
|
+
phases: entry.phases,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Strip host-only fields, leaving the client-visible definition. When the
|
|
648
|
+
* registry carries a global voice pack, its panel chrome layers under the
|
|
649
|
+
* entry's own pack (per-slot merge, pet > global), mirroring the voice-pool
|
|
650
|
+
* layering (pet-center M4, issue #677).
|
|
651
|
+
*/
|
|
652
|
+
export function petEntryView(entry, globalVoice) {
|
|
653
|
+
const panel = globalVoice === undefined
|
|
654
|
+
? entry.voice?.panel
|
|
655
|
+
: mergeVoicePacks(globalVoice, entry.voice)?.panel;
|
|
302
656
|
return {
|
|
303
657
|
id: entry.id,
|
|
304
658
|
displayName: entry.displayName,
|
|
305
659
|
description: entry.description,
|
|
660
|
+
renderer: entry.renderer,
|
|
661
|
+
...(entry.live2d === undefined ? {} : { live2d: entry.live2d }),
|
|
306
662
|
cell: entry.cell,
|
|
307
663
|
columns: entry.columns,
|
|
308
664
|
rows: entry.rows,
|
|
@@ -311,6 +667,7 @@ export function petEntryView(entry) {
|
|
|
311
667
|
...(entry.sequences === undefined ? {} : { sequences: entry.sequences }),
|
|
312
668
|
atlasUrl: entry.atlasUrl,
|
|
313
669
|
manifestUrl: entry.manifestUrl,
|
|
670
|
+
...(panel === undefined ? {} : { panel }),
|
|
314
671
|
};
|
|
315
672
|
}
|
|
316
673
|
/** The absolute file a pet's atlas resolves to (host asset route). */
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -14,9 +14,46 @@ import type { PetService } from './service.ts';
|
|
|
14
14
|
export declare const PET_API_PREFIX = "/api/pet";
|
|
15
15
|
/** Browser-facing base path of the pet asset routes ('/pet/<id>/...'). */
|
|
16
16
|
export declare const PET_ASSET_PREFIX = "/pet";
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Per-class size ceilings for served pet assets, in bytes (pet-center M2 P3,
|
|
19
|
+
* issue #623). Constants are tested directly; makePetRoutes accepts an
|
|
20
|
+
* override so tests can exercise the 413 path with tiny caps.
|
|
21
|
+
*/
|
|
22
|
+
export declare const PET_ASSET_CAPS: {
|
|
23
|
+
/** pet.json manifest. */
|
|
24
|
+
readonly manifest: number;
|
|
25
|
+
/** Atlas, preview and Live2D texture imagery. */
|
|
26
|
+
readonly image: number;
|
|
27
|
+
/** Live2D model closure files (.moc3, motion/physics/expression JSON; M3). */
|
|
28
|
+
readonly model: number;
|
|
29
|
+
};
|
|
30
|
+
/** Size-cap profile the asset route enforces (test seam). */
|
|
31
|
+
export interface PetAssetCaps {
|
|
32
|
+
manifest: number;
|
|
33
|
+
image: number;
|
|
34
|
+
model: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* realpath containment: resolve both sides and require the candidate to stay
|
|
38
|
+
* inside the base directory. A pet directory (or an atlas/preview inside it)
|
|
39
|
+
* that is a symlink escaping its root is rejected, never followed.
|
|
40
|
+
*/
|
|
41
|
+
export declare function containedRealpath(base: string, candidate: string): string | undefined;
|
|
42
|
+
/** Browser-facing base path of the plugin runtime files (pet-center M3). */
|
|
43
|
+
export declare const PET_RUNTIME_PREFIX: string;
|
|
44
|
+
/** Size ceiling for one runtime file (the Cubism Core is ~200 KB today). */
|
|
45
|
+
export declare const PET_RUNTIME_CAP: number;
|
|
46
|
+
/** Runtime file roots (test seam; defaults resolve from the environment). */
|
|
47
|
+
export interface PetRuntimeRoots {
|
|
48
|
+
/** User-supplied runtime directory (defaults to '$DSH_HOME/pets/.runtime'). */
|
|
49
|
+
runtimeDir?: string;
|
|
50
|
+
/** Plugin vendor bundle directory (defaults to the package 'lib'). */
|
|
51
|
+
vendorDir?: string;
|
|
52
|
+
}
|
|
53
|
+
/** Build the full route family (API + assets + runtime) for one service. */
|
|
18
54
|
export declare function makePetRoutes(deps: {
|
|
19
55
|
service: PetService;
|
|
20
|
-
|
|
56
|
+
assetCaps?: PetAssetCaps;
|
|
57
|
+
} & PetRuntimeRoots): WebRoute[];
|
|
21
58
|
export { petPackageRoot } from './registry.ts';
|
|
22
59
|
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAM9C,+CAA+C;AAC/C,eAAO,MAAM,cAAc,aAAa,CAAA;AAExC,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,SAAS,CAAA;AAMtC;;;;GAIG;AACH,eAAO,MAAM,cAAc;IACzB,yBAAyB;aACzB,QAAQ;IACR,iDAAiD;aACjD,KAAK;IACL,8EAA8E;aAC9E,KAAK;CACG,CAAA;AAEV,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd;AAWD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUrF;AAsPD,4EAA4E;AAC5E,eAAO,MAAM,kBAAkB,QAA8B,CAAA;AAe7D,4EAA4E;AAC5E,eAAO,MAAM,eAAe,QAAmB,CAAA;AAE/C,6EAA6E;AAC7E,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAwMD,4EAA4E;AAC5E,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAAE,OAAO,EAAE,UAAU,CAAC;IAAC,SAAS,CAAC,EAAE,YAAY,CAAA;CAAE,GAAG,eAAe,GAAG,QAAQ,EAAE,CAwDnH;AAGD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA"}
|