@linxin666/dsh-pet 0.2.2 → 0.2.3

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 (90) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +66 -3
  3. package/README.zh.md +66 -3
  4. package/assets/whale/pet.json +169 -20
  5. package/assets/whale-refined/pet.json +17 -2
  6. package/lib/client.js +438 -21
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +752 -36
  9. package/lib/live2d-vendor.js +995 -0
  10. package/lib/live2d-vendor.js.map +1 -0
  11. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  12. package/lib/types/client/PetDockEntry.js +2 -1
  13. package/lib/types/client/PetSettingsCard.d.ts +10 -0
  14. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  15. package/lib/types/client/PetSettingsCard.js +22 -1
  16. package/lib/types/client/PetSprite.d.ts +7 -1
  17. package/lib/types/client/PetSprite.d.ts.map +1 -1
  18. package/lib/types/client/PetSprite.js +17 -8
  19. package/lib/types/client/index.d.ts.map +1 -1
  20. package/lib/types/client/index.js +5 -0
  21. package/lib/types/client/locales.d.ts +10 -0
  22. package/lib/types/client/locales.d.ts.map +1 -1
  23. package/lib/types/client/locales.js +10 -0
  24. package/lib/types/client/phase-stream.d.ts +22 -0
  25. package/lib/types/client/phase-stream.d.ts.map +1 -0
  26. package/lib/types/client/phase-stream.js +28 -0
  27. package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
  28. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
  29. package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
  30. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
  31. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
  32. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
  33. package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
  34. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
  35. package/lib/types/client/renderers/live2d/runtime.js +63 -0
  36. package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
  37. package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
  38. package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
  39. package/lib/types/client/renderers/live2d.d.ts +52 -0
  40. package/lib/types/client/renderers/live2d.d.ts.map +1 -0
  41. package/lib/types/client/renderers/live2d.js +180 -0
  42. package/lib/types/client/renderers/registry.d.ts +32 -0
  43. package/lib/types/client/renderers/registry.d.ts.map +1 -0
  44. package/lib/types/client/renderers/registry.js +49 -0
  45. package/lib/types/contracts/renderer.d.ts +50 -0
  46. package/lib/types/contracts/renderer.d.ts.map +1 -0
  47. package/lib/types/contracts/renderer.js +15 -0
  48. package/lib/types/manifest-v2.d.ts +129 -0
  49. package/lib/types/manifest-v2.d.ts.map +1 -0
  50. package/lib/types/manifest-v2.js +387 -0
  51. package/lib/types/model3.d.ts +26 -0
  52. package/lib/types/model3.d.ts.map +1 -0
  53. package/lib/types/model3.js +96 -0
  54. package/lib/types/registry.d.ts +57 -3
  55. package/lib/types/registry.d.ts.map +1 -1
  56. package/lib/types/registry.js +208 -33
  57. package/lib/types/routes.d.ts +39 -2
  58. package/lib/types/routes.d.ts.map +1 -1
  59. package/lib/types/routes.js +183 -12
  60. package/lib/types/service.d.ts +5 -1
  61. package/lib/types/service.d.ts.map +1 -1
  62. package/lib/types/service.js +4 -0
  63. package/package.json +3 -1
  64. package/src/client/PetDockEntry.tsx +19 -11
  65. package/src/client/PetSettingsCard.tsx +39 -0
  66. package/src/client/PetSprite.test.tsx +11 -0
  67. package/src/client/PetSprite.tsx +24 -9
  68. package/src/client/index.ts +6 -0
  69. package/src/client/locales.ts +10 -0
  70. package/src/client/phase-stream.test.ts +76 -0
  71. package/src/client/phase-stream.ts +39 -0
  72. package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
  73. package/src/client/renderers/PetRendererSwitch.tsx +48 -0
  74. package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
  75. package/src/client/renderers/live2d/runtime.test.ts +61 -0
  76. package/src/client/renderers/live2d/runtime.ts +119 -0
  77. package/src/client/renderers/live2d/vendor-entry.ts +14 -0
  78. package/src/client/renderers/live2d.test.ts +242 -0
  79. package/src/client/renderers/live2d.ts +218 -0
  80. package/src/client/renderers/registry.ts +58 -0
  81. package/src/client/settings-section.module.css +23 -0
  82. package/src/contracts/renderer.ts +54 -0
  83. package/src/manifest-v2.test.ts +251 -0
  84. package/src/manifest-v2.ts +414 -0
  85. package/src/model3.test.ts +79 -0
  86. package/src/model3.ts +91 -0
  87. package/src/registry.test.ts +215 -2
  88. package/src/registry.ts +246 -34
  89. package/src/routes.ts +202 -13
  90. package/src/service.ts +6 -0
package/src/registry.ts 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 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
@@ -23,6 +33,9 @@ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
23
33
  import { fileURLToPath } from 'node:url'
24
34
  import type { ActivityPhase, PetAnimation } from './state.ts'
25
35
  import { normalizePetRemarks, type PetRemarks, type PetRemarksManifest } from './remarks.ts'
36
+ import { dshHome } from './dsh-home.ts'
37
+ import { parsePetManifest, type PetManifestLive2d, type PetManifestV2, type PetRendererKind } from './manifest-v2.ts'
38
+ import { collectModel3References } from './model3.ts'
26
39
 
27
40
  /** Fixed row order of the 9-state animation contract. */
28
41
  export const PET_ROW_ORDER: readonly PetAnimation[] = [
@@ -156,11 +169,33 @@ export interface PetTrackOverride {
156
169
  fallback?: PetAnimation
157
170
  }
158
171
 
172
+ /** The live2d renderer block as served to the browser half (pet-center M3). */
173
+ export interface PetLive2dDefinition {
174
+ /** Browser URL of the .model3.json (served by the host asset route). */
175
+ modelUrl: string
176
+ /** Manifest-relative model path (host route allow-list key). */
177
+ modelPath: string
178
+ /** Scale multiplier over the canvas auto-fit, (0, 10]. */
179
+ scale?: number
180
+ /** Model offset in canvas px from the center-bottom anchor. */
181
+ translate?: { x?: number; y?: number }
182
+ /** ActivityPhase -> motion group; unmapped phases fall back to idle. */
183
+ motions: Partial<Record<ActivityPhase, string>> & { idle: string }
184
+ /** Optional ActivityPhase -> expression name layered over the motion. */
185
+ expressions?: Partial<Record<ActivityPhase, string>>
186
+ /** Hit area names triggering the tap motion; defaults to every model HitArea. */
187
+ hitAreas?: string[]
188
+ }
189
+
159
190
  /** A normalized pet as served to the browser half. */
160
191
  export interface PetDefinition {
161
192
  id: string
162
193
  displayName: string
163
194
  description: string
195
+ /** The renderer this entry mounts with (pet-center M2). */
196
+ renderer: PetRendererKind
197
+ /** Live2d render block; present exactly when renderer is 'live2d' (M3). */
198
+ live2d?: PetLive2dDefinition
164
199
  /** Atlas cell size in px. */
165
200
  cell: PetCell
166
201
  /** Columns per row. */
@@ -185,6 +220,12 @@ export interface PetEntry extends PetDefinition {
185
220
  dir: string
186
221
  /** Atlas path relative to 'dir' (declared by the manifest). */
187
222
  spritesheetPath: string
223
+ /**
224
+ * Manifest-relative files the asset route may serve beyond pet.json and
225
+ * 'previews/*' (pet-center M3): the sprite2d atlas, or the live2d model
226
+ * plus its model3.json reference closure.
227
+ */
228
+ servable: readonly string[]
188
229
  /** Normalized per-pet remark pools (manifest 'remarks'), when declared. */
189
230
  remarks?: PetRemarks
190
231
  }
@@ -193,11 +234,21 @@ export interface PetEntry extends PetDefinition {
193
234
  export interface PetRegistry {
194
235
  entries: PetEntry[]
195
236
  warnings: string[]
237
+ /** Structured diagnostics from the manifest-v2 parse (superset detail of warnings). */
238
+ diagnostics: PetRegistryDiagnostic[]
196
239
  byId(id: string): PetEntry | undefined
197
240
  /** The pet an installation falls back to when the selection is unknown. */
198
241
  defaultEntry(): PetEntry
199
242
  }
200
243
 
244
+ /** One structured registry diagnostic (manifest-v2 era). */
245
+ export interface PetRegistryDiagnostic {
246
+ level: 'error' | 'warning'
247
+ /** Where the diagnostic originates (directory or file). */
248
+ source: string
249
+ message: string
250
+ }
251
+
201
252
  /** Registry sources. */
202
253
  export interface PetRegistryOptions {
203
254
  /** Absolute package root whose 'assets/*' hold built-in pets. */
@@ -206,6 +257,8 @@ export interface PetRegistryOptions {
206
257
  assetPrefix?: string
207
258
  /** Custom pet directory (defaults to '${CODEX_HOME:-~/.codex}/pets'). */
208
259
  petsDir?: string
260
+ /** Pet-center user directory (defaults to '$DSH_HOME/pets'; '' disables). */
261
+ dshPetsDir?: string
209
262
  /** Extra manifest entries composed by the embedding application. */
210
263
  extra?: readonly PetManifest[]
211
264
  }
@@ -249,6 +302,47 @@ function normalizeSequences(
249
302
  return Object.keys(sequences).length === 0 ? undefined : sequences
250
303
  }
251
304
 
305
+ /**
306
+ * Build the fully resolved animation tracks from the contract defaults plus
307
+ * optional per-track overrides. Shared by the sprite2d resolver and the
308
+ * live2d entry builder (which fills the sprite fields with contract
309
+ * defaults so the flat PetDefinition shape holds for every renderer).
310
+ */
311
+ function buildTracks(
312
+ rows: readonly number[],
313
+ columns: number,
314
+ trackOverrides: Partial<Record<PetAnimation, PetTrackOverride>>,
315
+ warn: (message: string) => void,
316
+ ): Record<PetAnimation, PetTrackDef> | undefined {
317
+ const tracks = {} as Record<PetAnimation, PetTrackDef>
318
+ for (const [row, animation] of PET_ROW_ORDER.entries()) {
319
+ const pattern = DEFAULT_TRACK_PATTERNS[animation]
320
+ const override = trackOverrides[animation]
321
+ const durations = Array.isArray(override?.durations) && override.durations.length > 0
322
+ ? override.durations.filter((value): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0)
323
+ : pattern.durations
324
+ if (durations.length === 0) {
325
+ warn('track ' + animation + ' carries no usable durations')
326
+ return undefined
327
+ }
328
+ const frameCount = Math.max(1, Math.min(rows[row]!, columns))
329
+ const sized = durations.length >= frameCount
330
+ ? durations.slice(0, frameCount)
331
+ : Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]!)
332
+ tracks[animation] = {
333
+ frames: Array.from({ length: frameCount }, (_, index) => index),
334
+ durations: sized,
335
+ loop: typeof override?.loop === 'boolean' ? override.loop : pattern.loop,
336
+ ...(override?.fallback === undefined
337
+ ? pattern.fallback === undefined ? {} : { fallback: pattern.fallback }
338
+ : PET_ROW_ORDER.includes(override.fallback)
339
+ ? { fallback: override.fallback }
340
+ : pattern.fallback === undefined ? {} : { fallback: pattern.fallback }),
341
+ }
342
+ }
343
+ return tracks
344
+ }
345
+
252
346
  /**
253
347
  * Normalize one parsed manifest into a renderable pet entry, or undefined
254
348
  * (with a warning recorded) when the manifest violates the contract.
@@ -304,36 +398,14 @@ export function resolvePetManifest(
304
398
  const remarks = normalizePetRemarks(source.remarks, message => warn('manifest ' + id + ': ' + message))
305
399
  const sequences = normalizeSequences(source.sequences, id, warn)
306
400
  const trackOverrides = (typeof source.tracks === 'object' && source.tracks !== null ? source.tracks : {}) as Partial<Record<PetAnimation, PetTrackOverride>>
307
- const tracks = {} as Record<PetAnimation, PetTrackDef>
308
- for (const [row, animation] of PET_ROW_ORDER.entries()) {
309
- const pattern = DEFAULT_TRACK_PATTERNS[animation]
310
- const override = trackOverrides[animation]
311
- const durations = Array.isArray(override?.durations) && override.durations.length > 0
312
- ? override.durations.filter((value): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0)
313
- : pattern.durations
314
- if (durations.length === 0) {
315
- warn('manifest ' + id + ': track ' + animation + ' carries no usable durations')
316
- return undefined
317
- }
318
- const frameCount = Math.max(1, Math.min(rows[row]!, columns))
319
- const sized = durations.length >= frameCount
320
- ? durations.slice(0, frameCount)
321
- : Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]!)
322
- tracks[animation] = {
323
- frames: Array.from({ length: frameCount }, (_, index) => index),
324
- durations: sized,
325
- loop: typeof override?.loop === 'boolean' ? override.loop : pattern.loop,
326
- ...(override?.fallback === undefined
327
- ? pattern.fallback === undefined ? {} : { fallback: pattern.fallback }
328
- : PET_ROW_ORDER.includes(override.fallback)
329
- ? { fallback: override.fallback }
330
- : pattern.fallback === undefined ? {} : { fallback: pattern.fallback }),
331
- }
332
- }
401
+ const tracks = buildTracks(rows, columns, trackOverrides, message => warn('manifest ' + id + ': ' + message))
402
+ if (tracks === undefined) return undefined
403
+ const sheet = spritesheetPath.join('/')
333
404
  return {
334
405
  id,
335
406
  displayName,
336
407
  description,
408
+ renderer: 'sprite2d' as const,
337
409
  cell,
338
410
  columns,
339
411
  rows,
@@ -343,13 +415,121 @@ export function resolvePetManifest(
343
415
  atlasUrl: assetUrl(assetPrefix, id, spritesheet),
344
416
  manifestUrl: assetUrl(assetPrefix, id, 'pet.json'),
345
417
  dir,
346
- spritesheetPath: spritesheetPath.join('/'),
418
+ spritesheetPath: sheet,
419
+ servable: [sheet],
420
+ ...(remarks === undefined ? {} : { remarks }),
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Adapt a validated v2 manifest's sprite2d block onto the legacy flat shape
426
+ * the established resolver consumes (pet-center M2 P2). The legacy resolver
427
+ * only expresses 9-row (default) and 11-row (spriteVersionNumber 2) atlases,
428
+ * so other atlasRows values are rejected here with a diagnostic.
429
+ */
430
+ function flattenV2Sprite2d(manifest: PetManifestV2): Record<string, unknown> | undefined {
431
+ const block = manifest.sprite2d
432
+ if (block === undefined) return undefined
433
+ const legacy: Record<string, unknown> = {
434
+ id: manifest.id,
435
+ displayName: manifest.displayName,
436
+ spritesheetPath: block.spritesheetPath,
437
+ }
438
+ if (manifest.description !== undefined) legacy.description = manifest.description
439
+ if (block.cell !== undefined) legacy.cell = block.cell
440
+ if (block.columns !== undefined) legacy.columns = block.columns
441
+ if (block.frames !== undefined) legacy.frames = block.frames
442
+ if (block.tracks !== undefined) legacy.tracks = block.tracks
443
+ if (block.atlasRows !== undefined) {
444
+ if (block.atlasRows === 11) legacy.spriteVersionNumber = 2
445
+ else if (block.atlasRows !== DEFAULT_PET_ROW_COUNT) return undefined
446
+ }
447
+ if (manifest.sequences !== undefined) legacy.sequences = manifest.sequences
448
+ if (manifest.remarks !== undefined) legacy.remarks = manifest.remarks
449
+ return legacy
450
+ }
451
+
452
+ /**
453
+ * Resolve a validated live2d manifest into a renderable entry (pet-center
454
+ * M3). The model3.json is read at scan time: its reference closure becomes
455
+ * the entry's servable set (the asset route's allow-list), and a model that
456
+ * is unreadable or declares unsafe references rejects the entry fail-closed
457
+ * with an error diagnostic. Closure files missing on disk warn but keep the
458
+ * entry listed — the client renderer's diagnostic card reports the broken
459
+ * render, matching the registry's never-throw philosophy (install-time
460
+ * strictness belongs to the CLI validator). The sprite fields carry contract
461
+ * defaults: the chrome sizes live2d pets off 'display.size', not the atlas.
462
+ */
463
+ function resolveLive2dEntry(
464
+ manifest: PetManifestV2,
465
+ dir: string,
466
+ options: { assetPrefix?: string; warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
467
+ ): PetEntry | undefined {
468
+ const assetPrefix = options.assetPrefix ?? '/pet'
469
+ const record = (level: 'error' | 'warning', message: string): void => {
470
+ options.diagnostics?.push({ level, source: dir, message })
471
+ options.warnings?.push(message)
472
+ }
473
+ const block = manifest.live2d as PetManifestLive2d | undefined
474
+ if (block === undefined) {
475
+ record('error', 'pet ' + manifest.id + ': renderer live2d requires a live2d block')
476
+ return undefined
477
+ }
478
+ let model3: unknown
479
+ try {
480
+ model3 = JSON.parse(readFileSync(join(dir, block.model), 'utf8'))
481
+ } catch (error) {
482
+ record('error', 'pet ' + manifest.id + ': live2d model ' + block.model + ' is not readable: '
483
+ + (error instanceof Error ? error.message : String(error)))
484
+ return undefined
485
+ }
486
+ const { references, errors } = collectModel3References(model3)
487
+ if (errors.length > 0) {
488
+ for (const message of errors) {
489
+ record('error', 'pet ' + manifest.id + ': live2d model ' + block.model + ': ' + message)
490
+ }
491
+ return undefined
492
+ }
493
+ for (const reference of references) {
494
+ if (!existsSync(join(dir, reference))) {
495
+ record('warning', 'pet ' + manifest.id + ': live2d closure file missing: ' + reference)
496
+ }
497
+ }
498
+ const tracks = buildTracks(DEFAULT_FRAME_COUNTS, DEFAULT_PET_COLUMNS, {}, message => record('warning', 'pet ' + manifest.id + ': ' + message))
499
+ if (tracks === undefined) return undefined
500
+ const remarks = normalizePetRemarks(manifest.remarks, message => record('warning', 'pet ' + manifest.id + ': ' + message))
501
+ const modelUrl = assetUrl(assetPrefix, manifest.id, block.model)
502
+ const live2d: PetLive2dDefinition = {
503
+ modelUrl,
504
+ modelPath: block.model,
505
+ ...(block.scale === undefined ? {} : { scale: block.scale }),
506
+ ...(block.translate === undefined ? {} : { translate: block.translate }),
507
+ motions: block.motions,
508
+ ...(block.expressions === undefined ? {} : { expressions: block.expressions }),
509
+ ...(block.hitAreas === undefined ? {} : { hitAreas: block.hitAreas }),
510
+ }
511
+ return {
512
+ id: manifest.id,
513
+ displayName: manifest.displayName,
514
+ description: manifest.description ?? '',
515
+ renderer: 'live2d' as const,
516
+ live2d,
517
+ cell: { ...DEFAULT_PET_CELL },
518
+ columns: DEFAULT_PET_COLUMNS,
519
+ rows: [...DEFAULT_FRAME_COUNTS],
520
+ atlasRows: DEFAULT_PET_ROW_COUNT,
521
+ tracks,
522
+ atlasUrl: modelUrl,
523
+ manifestUrl: assetUrl(assetPrefix, manifest.id, 'pet.json'),
524
+ dir,
525
+ spritesheetPath: block.model,
526
+ servable: [block.model, ...references],
347
527
  ...(remarks === undefined ? {} : { remarks }),
348
528
  }
349
529
  }
350
530
 
351
531
  /** Scan one directory of pet folders; entries come back in name order. */
352
- function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: string[] }): PetEntry[] {
532
+ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] }): PetEntry[] {
353
533
  if (!existsSync(dir)) return []
354
534
  let names: string[] = []
355
535
  try {
@@ -364,7 +544,26 @@ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: str
364
544
  if (!existsSync(manifestFile)) continue
365
545
  const parsed = readPetJson(manifestFile, options.warnings)
366
546
  if (parsed === undefined) continue
367
- const entry = resolvePetManifest(parsed, join(dir, name), options)
547
+ const entryDir = join(dir, name)
548
+ const verdict = parsePetManifest(parsed, entryDir)
549
+ for (const diagnostic of verdict.diagnostics) {
550
+ options.diagnostics?.push({ level: diagnostic.level, source: entryDir, message: diagnostic.message })
551
+ options.warnings?.push(diagnostic.message)
552
+ }
553
+ if (!verdict.ok) continue
554
+ if (verdict.manifest.renderer === 'live2d') {
555
+ const entry = resolveLive2dEntry(verdict.manifest, entryDir, options)
556
+ if (entry !== undefined) entries.push(entry)
557
+ continue
558
+ }
559
+ const legacy = flattenV2Sprite2d(verdict.manifest)
560
+ if (legacy === undefined) {
561
+ const note = 'pet ' + verdict.manifest.id + ': sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver'
562
+ options.diagnostics?.push({ level: 'error', source: entryDir, message: note })
563
+ options.warnings?.push(note)
564
+ continue
565
+ }
566
+ const entry = resolvePetManifest(legacy, entryDir, options)
368
567
  if (entry !== undefined) entries.push(entry)
369
568
  }
370
569
  return entries
@@ -389,10 +588,11 @@ function readPetJson(file: string, warnings: string[] | undefined): unknown {
389
588
  export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
390
589
  const { packageRoot, assetPrefix = '/pet' } = options
391
590
  const warnings: string[] = []
591
+ const diagnostics: PetRegistryDiagnostic[] = []
392
592
  const byId = new Map<string, PetEntry>()
393
593
  const builtinIds = new Set<string>()
394
594
 
395
- for (const entry of scanPetDir(join(packageRoot, 'assets'), { assetPrefix, warnings })) {
595
+ for (const entry of scanPetDir(join(packageRoot, 'assets'), { assetPrefix, warnings, diagnostics })) {
396
596
  if (byId.has(entry.id)) {
397
597
  warnings.push('duplicate built-in pet id ' + entry.id + '; the first one wins')
398
598
  continue
@@ -403,12 +603,21 @@ export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
403
603
 
404
604
  const petsDir = options.petsDir ?? codexPetsDir()
405
605
  if (petsDir !== '') {
406
- for (const entry of scanPetDir(petsDir, { assetPrefix, warnings })) {
606
+ for (const entry of scanPetDir(petsDir, { assetPrefix, warnings, diagnostics })) {
407
607
  if (byId.has(entry.id)) warnings.push('custom pet ' + entry.id + ' overrides the built-in one')
408
608
  byId.set(entry.id, entry)
409
609
  }
410
610
  }
411
611
 
612
+ // The pet-center user directory ranks above the legacy hatch-pet source.
613
+ const dshPetsDir = options.dshPetsDir ?? join(dshHome(), 'pets')
614
+ if (dshPetsDir !== '') {
615
+ for (const entry of scanPetDir(dshPetsDir, { assetPrefix, warnings, diagnostics })) {
616
+ if (byId.has(entry.id)) warnings.push('user pet ' + entry.id + ' overrides an earlier registration')
617
+ byId.set(entry.id, entry)
618
+ }
619
+ }
620
+
412
621
  for (const manifest of options.extra ?? []) {
413
622
  const raw = manifest.spritesheetPath
414
623
  const dir = raw === undefined || isAbsolute(raw)
@@ -431,6 +640,7 @@ export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
431
640
  return {
432
641
  entries,
433
642
  warnings,
643
+ diagnostics,
434
644
  byId: (id: string) => byId.get(id),
435
645
  defaultEntry: () => entries.find(entry => builtinIds.has(entry.id)) ?? entries[0]!,
436
646
  }
@@ -442,6 +652,8 @@ export function petEntryView(entry: PetEntry): PetDefinition {
442
652
  id: entry.id,
443
653
  displayName: entry.displayName,
444
654
  description: entry.description,
655
+ renderer: entry.renderer,
656
+ ...(entry.live2d === undefined ? {} : { live2d: entry.live2d }),
445
657
  cell: entry.cell,
446
658
  columns: entry.columns,
447
659
  rows: entry.rows,
package/src/routes.ts CHANGED
@@ -9,15 +9,16 @@
9
9
  * @module @linxin666/dsh-pet/routes
10
10
  */
11
11
 
12
- import { existsSync } from 'node:fs'
12
+ import { existsSync, realpathSync, statSync } from 'node:fs'
13
13
  import { readFile } from 'node:fs/promises'
14
- import { join } from 'node:path'
14
+ import { join, sep } from 'node:path'
15
15
  import type { IncomingMessage, ServerResponse } from 'node:http'
16
16
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
17
17
  import type { PetService } from './service.ts'
18
18
  import type { PetInteraction } from './affinity.ts'
19
- import { petEntryView, type PetEntry, type PetRegistry } from './registry.ts'
19
+ import { petEntryView, petPackageRoot, type PetEntry, type PetRegistry } from './registry.ts'
20
20
  import { isLoopbackRequest } from './loopback.ts'
21
+ import { dshHome } from './dsh-home.ts'
21
22
 
22
23
  /** Browser-facing base path of the pet API. */
23
24
  export const PET_API_PREFIX = '/api/pet'
@@ -29,6 +30,53 @@ const MANIFEST_FILE = 'pet.json'
29
30
  const PREVIEW_DIR = 'previews'
30
31
  const PREVIEW_PATTERN = /^[A-Za-z0-9._-]+$/
31
32
 
33
+ /**
34
+ * Per-class size ceilings for served pet assets, in bytes (pet-center M2 P3,
35
+ * issue #623). Constants are tested directly; makePetRoutes accepts an
36
+ * override so tests can exercise the 413 path with tiny caps.
37
+ */
38
+ export const PET_ASSET_CAPS = {
39
+ /** pet.json manifest. */
40
+ manifest: 64 * 1024,
41
+ /** Atlas, preview and Live2D texture imagery. */
42
+ image: 20 * 1024 * 1024,
43
+ /** Live2D model closure files (.moc3, motion/physics/expression JSON; M3). */
44
+ model: 32 * 1024 * 1024,
45
+ } as const
46
+
47
+ /** Size-cap profile the asset route enforces (test seam). */
48
+ export interface PetAssetCaps {
49
+ manifest: number
50
+ image: number
51
+ model: number
52
+ }
53
+
54
+ /** Imagery extensions classify into the image cap; everything else served from a closure is model-class. */
55
+ const IMAGE_EXTENSIONS: ReadonlySet<string> = new Set(['.webp', '.png', '.gif', '.jpg', '.jpeg'])
56
+
57
+ /** Lowercased file extension ('' when none). */
58
+ function extensionOf(file: string): string {
59
+ const dot = file.lastIndexOf('.')
60
+ return dot < 0 ? '' : file.slice(dot).toLowerCase()
61
+ }
62
+
63
+ /**
64
+ * realpath containment: resolve both sides and require the candidate to stay
65
+ * inside the base directory. A pet directory (or an atlas/preview inside it)
66
+ * that is a symlink escaping its root is rejected, never followed.
67
+ */
68
+ export function containedRealpath(base: string, candidate: string): string | undefined {
69
+ try {
70
+ const realBase = realpathSync(base)
71
+ const realCandidate = realpathSync(candidate)
72
+ return realCandidate === realBase || realCandidate.startsWith(realBase + sep)
73
+ ? realCandidate
74
+ : undefined
75
+ } catch {
76
+ return undefined
77
+ }
78
+ }
79
+
32
80
  const MIME_BY_EXT: Readonly<Record<string, string>> = {
33
81
  '.webp': 'image/webp',
34
82
  '.png': 'image/png',
@@ -145,10 +193,14 @@ function dirAliases(registry: PetRegistry): Map<string, PetEntry> {
145
193
  /**
146
194
  * The one asset handler behind the '/pet' prefix. Resolves the pet by id (or
147
195
  * legacy directory alias), then serves exactly the files a manifest declares:
148
- * pet.json, the declared spritesheet path, and optional 'previews/<name>'
149
- * media. Composed pets without a manifest file get a synthesized pet.json.
196
+ * pet.json, the entry's servable set (the sprite2d atlas, or the live2d
197
+ * model3.json plus its reference closure pet-center M3), and optional
198
+ * 'previews/<name>' media. The servable match is an exact string comparison
199
+ * against scan-time normalized paths, so crafted '..' or '.' segments never
200
+ * match; containedRealpath stays as the second layer. Composed pets without
201
+ * a manifest file get a synthesized pet.json.
150
202
  */
151
- function assetHandler(registry: PetRegistry): WebRoute['handler'] {
203
+ function assetHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['handler'] {
152
204
  const aliases = dirAliases(registry)
153
205
  return (req: IncomingMessage, res: ServerResponse): void => {
154
206
  if (!guard(req, res)) return
@@ -204,8 +256,8 @@ function assetHandler(registry: PetRegistry): WebRoute['handler'] {
204
256
  const manifestFile = join(entry.dir, MANIFEST_FILE)
205
257
  file = existsSync(manifestFile) ? manifestFile : undefined
206
258
  if (file === undefined) synthesized = true
207
- } else if (rest.length > 0 && rel === entry.spritesheetPath) {
208
- file = join(entry.dir, entry.spritesheetPath)
259
+ } else if (rest.length > 0 && entry.servable.includes(rel)) {
260
+ file = join(entry.dir, rel)
209
261
  } else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1]!)) {
210
262
  const preview = join(entry.dir, PREVIEW_DIR, rest[1]!)
211
263
  file = existsSync(preview) ? preview : undefined
@@ -229,7 +281,28 @@ function assetHandler(registry: PetRegistry): WebRoute['handler'] {
229
281
  res.end()
230
282
  return
231
283
  }
232
- const resolved = file
284
+ // realpath containment: a symlink escaping the pet directory is refused.
285
+ const resolved = containedRealpath(entry.dir, file)
286
+ if (resolved === undefined) {
287
+ res.writeHead(403)
288
+ res.end()
289
+ return
290
+ }
291
+ // Enforce the size ceiling before the file is read into memory.
292
+ const cap = rest.length === 1 && rest[0] === MANIFEST_FILE
293
+ ? caps.manifest
294
+ : IMAGE_EXTENSIONS.has(extensionOf(rel)) ? caps.image : caps.model
295
+ try {
296
+ if (statSync(resolved).size > cap) {
297
+ res.writeHead(413)
298
+ res.end()
299
+ return
300
+ }
301
+ } catch {
302
+ res.writeHead(404)
303
+ res.end()
304
+ return
305
+ }
233
306
  readFile(resolved).then((body) => {
234
307
  res.writeHead(200, {
235
308
  'content-type': mimeFor(resolved),
@@ -248,12 +321,119 @@ function assetHandler(registry: PetRegistry): WebRoute['handler'] {
248
321
  }
249
322
  }
250
323
 
251
- /** Build the full route family (API + assets) for one service. */
252
- export function makePetRoutes(deps: { service: PetService }): WebRoute[] {
324
+ /** Browser-facing base path of the plugin runtime files (pet-center M3). */
325
+ export const PET_RUNTIME_PREFIX = PET_API_PREFIX + '/runtime'
326
+
327
+ /**
328
+ * The runtime files the route may serve, by exact name (no slashes, no
329
+ * user-controlled path segments, so traversal is structurally impossible):
330
+ * the user-supplied Cubism Core from the pet runtime directory (the plugin
331
+ * never bundles or downloads it — issue #623 M1 §0) and the plugin-shipped
332
+ * MIT vendor bundle from the package lib directory.
333
+ */
334
+ const RUNTIME_FILES: Readonly<Record<string, { root: 'runtimeDir' | 'vendorDir' }>> = {
335
+ 'live2dcubismcore.min.js': { root: 'runtimeDir' },
336
+ 'live2d-vendor.js': { root: 'vendorDir' },
337
+ 'live2d-vendor.js.map': { root: 'vendorDir' },
338
+ }
339
+
340
+ /** Size ceiling for one runtime file (the Cubism Core is ~200 KB today). */
341
+ export const PET_RUNTIME_CAP = 16 * 1024 * 1024
342
+
343
+ /** Runtime file roots (test seam; defaults resolve from the environment). */
344
+ export interface PetRuntimeRoots {
345
+ /** User-supplied runtime directory (defaults to '$DSH_HOME/pets/.runtime'). */
346
+ runtimeDir?: string
347
+ /** Plugin vendor bundle directory (defaults to the package 'lib'). */
348
+ vendorDir?: string
349
+ }
350
+
351
+ /**
352
+ * The runtime handler behind '/api/pet/runtime/<name>'. A missing file
353
+ * answers 404 with a JSON marker the client renderer turns into install
354
+ * guidance (the Cubism Core is user-supplied, so its absence is a normal
355
+ * state, not an error).
356
+ */
357
+ function runtimeHandler(roots: { runtimeDir: string; vendorDir: string }): WebRoute['handler'] {
358
+ return (req: IncomingMessage, res: ServerResponse): void => {
359
+ if (!guard(req, res)) return
360
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
361
+ res.writeHead(405)
362
+ res.end()
363
+ return
364
+ }
365
+ let pathname: string
366
+ try {
367
+ pathname = new URL(req.url ?? '/', 'http://pet.local').pathname
368
+ } catch {
369
+ res.writeHead(400)
370
+ res.end()
371
+ return
372
+ }
373
+ const rest = pathname.slice(PET_RUNTIME_PREFIX.length).replace(/^\/+/, '')
374
+ let name: string
375
+ try {
376
+ name = decodeURIComponent(rest)
377
+ } catch {
378
+ res.writeHead(400)
379
+ res.end()
380
+ return
381
+ }
382
+ const spec = RUNTIME_FILES[name]
383
+ // Exact-name allow-list: anything with a path separator never matches.
384
+ if (spec === undefined) {
385
+ res.writeHead(404)
386
+ res.end()
387
+ return
388
+ }
389
+ const base = spec.root === 'runtimeDir' ? roots.runtimeDir : roots.vendorDir
390
+ const file = join(base, name)
391
+ if (!existsSync(file)) {
392
+ json(res, 404, { ok: false, error: 'runtime-file-missing', file: name })
393
+ return
394
+ }
395
+ const resolved = containedRealpath(base, file)
396
+ if (resolved === undefined) {
397
+ res.writeHead(403)
398
+ res.end()
399
+ return
400
+ }
401
+ try {
402
+ if (statSync(resolved).size > PET_RUNTIME_CAP) {
403
+ res.writeHead(413)
404
+ res.end()
405
+ return
406
+ }
407
+ } catch {
408
+ res.writeHead(404)
409
+ res.end()
410
+ return
411
+ }
412
+ readFile(resolved).then((body) => {
413
+ res.writeHead(200, {
414
+ 'content-type': name.endsWith('.map') ? 'application/json' : 'application/javascript; charset=utf-8',
415
+ 'content-length': String(body.byteLength),
416
+ 'cache-control': 'no-cache',
417
+ })
418
+ if (req.method === 'HEAD') {
419
+ res.end()
420
+ return
421
+ }
422
+ res.end(body)
423
+ }, () => {
424
+ res.writeHead(404)
425
+ res.end()
426
+ })
427
+ }
428
+ }
429
+
430
+ /** Build the full route family (API + assets + runtime) for one service. */
431
+ export function makePetRoutes(deps: { service: PetService; assetCaps?: PetAssetCaps } & PetRuntimeRoots): WebRoute[] {
253
432
  const { service } = deps
254
433
  const apiRoutes: WebRoute[] = [
255
434
  getRoute(PET_API_PREFIX + '/state', () => service.state()),
256
435
  getRoute(PET_API_PREFIX + '/pets', () => service.pets()),
436
+ getRoute(PET_API_PREFIX + '/diagnostics', () => service.diagnostics()),
257
437
  postRoute(PET_API_PREFIX + '/interact', (body) => {
258
438
  const kind = body.kind as PetInteraction | undefined
259
439
  if (kind !== 'pet' && kind !== 'feed') return Promise.reject(new Error('invalid-kind'))
@@ -285,10 +465,19 @@ export function makePetRoutes(deps: { service: PetService }): WebRoute[] {
285
465
  const assetRoute: WebRoute = {
286
466
  kind: 'prefix',
287
467
  path: PET_ASSET_PREFIX,
288
- handler: assetHandler(service.registrySnapshot()),
468
+ handler: assetHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
469
+ }
470
+
471
+ const runtimeRoute: WebRoute = {
472
+ kind: 'prefix',
473
+ path: PET_RUNTIME_PREFIX,
474
+ handler: runtimeHandler({
475
+ runtimeDir: deps.runtimeDir ?? join(dshHome(), 'pets', '.runtime'),
476
+ vendorDir: deps.vendorDir ?? join(petPackageRoot(import.meta.url), 'lib'),
477
+ }),
289
478
  }
290
479
 
291
- return [...apiRoutes, assetRoute]
480
+ return [...apiRoutes, assetRoute, runtimeRoute]
292
481
  }
293
482
 
294
483
  // Re-exported for the package surface (the registry owns the definition now).