@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.
Files changed (125) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +129 -3
  3. package/README.zh.md +129 -3
  4. package/assets/decorations/whale/decoration.json +20 -0
  5. package/assets/decorations/whale/whale-frames.png +0 -0
  6. package/assets/whale/pet.json +169 -20
  7. package/assets/whale-refined/pet.json +17 -2
  8. package/contracts/pet-manifest-v2.schema.json +281 -0
  9. package/contracts/status-decoration-v1.schema.json +144 -0
  10. package/contracts/voice-pack-v1.schema.json +234 -0
  11. package/lib/client.js +581 -38
  12. package/lib/client.js.map +1 -1
  13. package/lib/index.js +1684 -59
  14. package/lib/live2d-vendor.js +995 -0
  15. package/lib/live2d-vendor.js.map +1 -0
  16. package/lib/types/chatter.d.ts +61 -3
  17. package/lib/types/chatter.d.ts.map +1 -1
  18. package/lib/types/chatter.js +71 -12
  19. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  20. package/lib/types/client/PetDockEntry.js +2 -1
  21. package/lib/types/client/PetSettingsCard.d.ts +14 -0
  22. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  23. package/lib/types/client/PetSettingsCard.js +24 -1
  24. package/lib/types/client/PetSprite.d.ts +7 -1
  25. package/lib/types/client/PetSprite.d.ts.map +1 -1
  26. package/lib/types/client/PetSprite.js +120 -12
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/index.js +5 -0
  29. package/lib/types/client/locales.d.ts +14 -0
  30. package/lib/types/client/locales.d.ts.map +1 -1
  31. package/lib/types/client/locales.js +14 -0
  32. package/lib/types/client/phase-stream.d.ts +22 -0
  33. package/lib/types/client/phase-stream.d.ts.map +1 -0
  34. package/lib/types/client/phase-stream.js +28 -0
  35. package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
  36. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
  37. package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
  38. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
  39. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
  40. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
  41. package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
  42. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
  43. package/lib/types/client/renderers/live2d/runtime.js +63 -0
  44. package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
  45. package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
  46. package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
  47. package/lib/types/client/renderers/live2d.d.ts +52 -0
  48. package/lib/types/client/renderers/live2d.d.ts.map +1 -0
  49. package/lib/types/client/renderers/live2d.js +192 -0
  50. package/lib/types/client/renderers/registry.d.ts +32 -0
  51. package/lib/types/client/renderers/registry.d.ts.map +1 -0
  52. package/lib/types/client/renderers/registry.js +49 -0
  53. package/lib/types/client/settings-form.d.ts.map +1 -1
  54. package/lib/types/client/settings-form.js +9 -6
  55. package/lib/types/contracts/renderer.d.ts +50 -0
  56. package/lib/types/contracts/renderer.d.ts.map +1 -0
  57. package/lib/types/contracts/renderer.js +15 -0
  58. package/lib/types/contracts/status-decoration.d.ts +85 -0
  59. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  60. package/lib/types/contracts/status-decoration.js +21 -0
  61. package/lib/types/decoration.d.ts +39 -0
  62. package/lib/types/decoration.d.ts.map +1 -0
  63. package/lib/types/decoration.js +210 -0
  64. package/lib/types/event-projection.d.ts +8 -3
  65. package/lib/types/event-projection.d.ts.map +1 -1
  66. package/lib/types/event-projection.js +9 -4
  67. package/lib/types/index.d.ts +2 -0
  68. package/lib/types/index.d.ts.map +1 -1
  69. package/lib/types/index.js +2 -0
  70. package/lib/types/manifest-v2.d.ts +129 -0
  71. package/lib/types/manifest-v2.d.ts.map +1 -0
  72. package/lib/types/manifest-v2.js +387 -0
  73. package/lib/types/model3.d.ts +26 -0
  74. package/lib/types/model3.d.ts.map +1 -0
  75. package/lib/types/model3.js +96 -0
  76. package/lib/types/registry.d.ts +112 -5
  77. package/lib/types/registry.d.ts.map +1 -1
  78. package/lib/types/registry.js +395 -38
  79. package/lib/types/routes.d.ts +39 -2
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +312 -13
  82. package/lib/types/service.d.ts +38 -1
  83. package/lib/types/service.d.ts.map +1 -1
  84. package/lib/types/service.js +46 -3
  85. package/lib/types/voice-pack.d.ts +98 -0
  86. package/lib/types/voice-pack.d.ts.map +1 -0
  87. package/lib/types/voice-pack.js +384 -0
  88. package/package.json +13 -10
  89. package/src/chatter.test.ts +89 -2
  90. package/src/chatter.ts +120 -14
  91. package/src/client/PetDockEntry.tsx +19 -11
  92. package/src/client/PetSettingsCard.tsx +57 -0
  93. package/src/client/PetSprite.test.tsx +265 -12
  94. package/src/client/PetSprite.tsx +165 -33
  95. package/src/client/index.ts +6 -0
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/phase-stream.test.ts +76 -0
  98. package/src/client/phase-stream.ts +39 -0
  99. package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
  100. package/src/client/renderers/PetRendererSwitch.tsx +48 -0
  101. package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
  102. package/src/client/renderers/live2d/runtime.test.ts +61 -0
  103. package/src/client/renderers/live2d/runtime.ts +119 -0
  104. package/src/client/renderers/live2d/vendor-entry.ts +14 -0
  105. package/src/client/renderers/live2d.test.ts +263 -0
  106. package/src/client/renderers/live2d.ts +231 -0
  107. package/src/client/renderers/registry.ts +58 -0
  108. package/src/client/settings-form.ts +8 -6
  109. package/src/client/settings-section.module.css +23 -0
  110. package/src/contracts/renderer.ts +54 -0
  111. package/src/contracts/status-decoration.ts +78 -0
  112. package/src/decoration.test.ts +178 -0
  113. package/src/decoration.ts +220 -0
  114. package/src/event-projection.ts +10 -5
  115. package/src/index.ts +2 -0
  116. package/src/manifest-v2.test.ts +251 -0
  117. package/src/manifest-v2.ts +414 -0
  118. package/src/model3.test.ts +79 -0
  119. package/src/model3.ts +91 -0
  120. package/src/registry.test.ts +438 -2
  121. package/src/registry.ts +470 -38
  122. package/src/routes.ts +328 -14
  123. package/src/service.ts +65 -3
  124. package/src/voice-pack.test.ts +216 -0
  125. package/src/voice-pack.ts +413 -0
@@ -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 three
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
- * 3. 'PetConfig.pets' manifests composed by the embedding application
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
- for (const [row, animation] of PET_ROW_ORDER.entries()) {
169
- const pattern = DEFAULT_TRACK_PATTERNS[animation];
170
- const override = trackOverrides[animation];
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: spritesheetPath.join('/'),
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 entry = resolvePetManifest(parsed, join(dir, name), options);
231
- if (entry !== undefined)
232
- entries.push(entry);
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
- /** Strip host-only fields, leaving the client-visible definition. */
301
- export function petEntryView(entry) {
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). */
@@ -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
- /** Build the full route family (API + assets) for one service. */
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
- }): WebRoute[];
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;AAK9C,+CAA+C;AAC/C,eAAO,MAAM,cAAc,aAAa,CAAA;AAExC,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,SAAS,CAAA;AAiOtC,kEAAkE;AAClE,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAAE,OAAO,EAAE,UAAU,CAAA;CAAE,GAAG,QAAQ,EAAE,CAwCvE;AAGD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,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"}