@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
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { collectModel3References, model3HitAreas, model3MotionGroups } from './model3.ts'
3
+
4
+ /** Fixture mirroring the real Haru sample's FileReferences shape. */
5
+ const HARU_LIKE = {
6
+ Version: 3,
7
+ FileReferences: {
8
+ Moc: 'haru.moc3',
9
+ Textures: ['haru.2048/texture_00.png', 'haru.2048/texture_01.png'],
10
+ Physics: 'haru.physics3.json',
11
+ Pose: 'haru.pose3.json',
12
+ DisplayInfo: 'haru.cdi3.json',
13
+ Expressions: [
14
+ { Name: 'f01', File: 'expressions/f01.exp3.json' },
15
+ { Name: 'f02', File: 'expressions/f02.exp3.json' },
16
+ ],
17
+ Motions: {
18
+ Idle: [{ File: 'motions/idle_00.motion3.json' }],
19
+ TapBody: [{ File: 'motions/tap_00.motion3.json' }, { File: 'motions/tap_01.motion3.json' }],
20
+ },
21
+ UserData: 'haru.userdata3.json',
22
+ },
23
+ Groups: [{ Name: 'EyeBlink' }, { Name: 'LipSync' }],
24
+ HitAreas: [{ Id: 'Head', Name: 'Head' }, { Id: 'Body', Name: 'Body' }],
25
+ }
26
+
27
+ describe('collectModel3References', () => {
28
+ it('collects the complete reference closure of the eight Cubism file families', () => {
29
+ const { references, errors } = collectModel3References(HARU_LIKE)
30
+ expect(errors).toEqual([])
31
+ expect(references).toEqual([
32
+ 'expressions/f01.exp3.json',
33
+ 'expressions/f02.exp3.json',
34
+ 'haru.2048/texture_00.png',
35
+ 'haru.2048/texture_01.png',
36
+ 'haru.cdi3.json',
37
+ 'haru.moc3',
38
+ 'haru.physics3.json',
39
+ 'haru.pose3.json',
40
+ 'haru.userdata3.json',
41
+ 'motions/idle_00.motion3.json',
42
+ 'motions/tap_00.motion3.json',
43
+ 'motions/tap_01.motion3.json',
44
+ ])
45
+ })
46
+ it('rejects unsafe references instead of collecting them', () => {
47
+ const bad = {
48
+ FileReferences: {
49
+ Moc: '../escape.moc3',
50
+ Textures: ['/abs/path.png', 'ok.png'],
51
+ Motions: { Idle: [{ File: 'https://evil.example/x.motion3.json' }] },
52
+ },
53
+ }
54
+ const { references, errors } = collectModel3References(bad)
55
+ expect(references).toEqual(['ok.png'])
56
+ expect(errors).toHaveLength(3)
57
+ })
58
+ it('tolerates missing optional families', () => {
59
+ const minimal = { FileReferences: { Moc: 'a.moc3', Textures: ['a.png'] } }
60
+ const { references, errors } = collectModel3References(minimal)
61
+ expect(errors).toEqual([])
62
+ expect(references).toEqual(['a.moc3', 'a.png'])
63
+ })
64
+ it('reports malformed model3 roots', () => {
65
+ expect(collectModel3References(null).errors.length).toBeGreaterThan(0)
66
+ expect(collectModel3References({}).errors.length).toBeGreaterThan(0)
67
+ })
68
+ })
69
+
70
+ describe('model3MotionGroups / model3HitAreas', () => {
71
+ it('reads motion groups and hit areas for diagnostics', () => {
72
+ expect(model3MotionGroups(HARU_LIKE)).toEqual(['Idle', 'TapBody'])
73
+ expect(model3HitAreas(HARU_LIKE)).toEqual(['Body', 'Head'])
74
+ })
75
+ it('returns empty lists for malformed models', () => {
76
+ expect(model3MotionGroups(null)).toEqual([])
77
+ expect(model3HitAreas({})).toEqual([])
78
+ })
79
+ })
package/src/model3.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Live2D .model3.json reference closure — the set of files a model declares
3
+ * (pet-center M2, issue #623). The host asset route only ever serves a pet's
4
+ * declared manifest, its declared primary assets, and this closure; the CLI
5
+ * validator reuses the same extractor so an install-time check proves the
6
+ * serving set is complete.
7
+ *
8
+ * Cubism file family: Moc (.moc3), Textures (images), Physics (.physics3.json),
9
+ * Pose (.pose3.json), DisplayInfo (.cdi3.json), Expressions[].File
10
+ * (.exp3.json), Motions.<group>[].File (.motion3.json), UserData
11
+ * (.userdata3.json). Every reference must be a safe manifest-relative path
12
+ * (safeManifestPath); unsafe entries make the model unloadable.
13
+ *
14
+ * Erasable-syntax-only: scripts/ import this under node strip-only mode.
15
+ * @module @linxin666/dsh-pet/model3
16
+ */
17
+
18
+ import { safeManifestPath } from './manifest-v2.ts'
19
+
20
+ /** Collect the safe relative paths one model3.json references. */
21
+ export function collectModel3References(model3: unknown): { references: string[]; errors: string[] } {
22
+ const errors: string[] = []
23
+ if (typeof model3 !== 'object' || model3 === null) {
24
+ return { references: [], errors: ['model3.json is not an object'] }
25
+ }
26
+ const fileReferences = (model3 as Record<string, unknown>).FileReferences
27
+ if (typeof fileReferences !== 'object' || fileReferences === null) {
28
+ return { references: [], errors: ['model3.json has no FileReferences'] }
29
+ }
30
+ const refs = fileReferences as Record<string, unknown>
31
+ const collected = new Set<string>()
32
+ const push = (raw: unknown, field: string): void => {
33
+ const safe = safeManifestPath(raw)
34
+ if (safe === undefined) {
35
+ errors.push(field + ' is not a safe relative path: ' + JSON.stringify(String(raw)))
36
+ return
37
+ }
38
+ collected.add(safe)
39
+ }
40
+ if (refs.Moc !== undefined) push(refs.Moc, 'FileReferences.Moc')
41
+ if (Array.isArray(refs.Textures)) {
42
+ refs.Textures.forEach((texture, index) => push(texture, 'FileReferences.Textures[' + index + ']'))
43
+ }
44
+ for (const scalar of ['Physics', 'Pose', 'DisplayInfo', 'UserData'] as const) {
45
+ if (refs[scalar] !== undefined) push(refs[scalar], 'FileReferences.' + scalar)
46
+ }
47
+ if (Array.isArray(refs.Expressions)) {
48
+ refs.Expressions.forEach((expression, index) => {
49
+ const file = typeof expression === 'object' && expression !== null
50
+ ? (expression as Record<string, unknown>).File
51
+ : undefined
52
+ if (file !== undefined) push(file, 'FileReferences.Expressions[' + index + '].File')
53
+ })
54
+ }
55
+ if (typeof refs.Motions === 'object' && refs.Motions !== null) {
56
+ for (const [group, motions] of Object.entries(refs.Motions as Record<string, unknown>)) {
57
+ if (!Array.isArray(motions)) {
58
+ errors.push('FileReferences.Motions.' + group + ' is not an array')
59
+ continue
60
+ }
61
+ motions.forEach((motion, index) => {
62
+ const file = typeof motion === 'object' && motion !== null
63
+ ? (motion as Record<string, unknown>).File
64
+ : undefined
65
+ if (file !== undefined) push(file, 'FileReferences.Motions.' + group + '[' + index + '].File')
66
+ })
67
+ }
68
+ }
69
+ return { references: [...collected].sort(), errors }
70
+ }
71
+
72
+ /** The motion group names a model3.json declares (for CLI diagnostics). */
73
+ export function model3MotionGroups(model3: unknown): string[] {
74
+ if (typeof model3 !== 'object' || model3 === null) return []
75
+ const fileReferences = (model3 as Record<string, unknown>).FileReferences
76
+ if (typeof fileReferences !== 'object' || fileReferences === null) return []
77
+ const motions = (fileReferences as Record<string, unknown>).Motions
78
+ if (typeof motions !== 'object' || motions === null) return []
79
+ return Object.keys(motions as Record<string, unknown>).sort()
80
+ }
81
+
82
+ /** The hit area names a model3.json declares (top-level HitAreas). */
83
+ export function model3HitAreas(model3: unknown): string[] {
84
+ if (typeof model3 !== 'object' || model3 === null) return []
85
+ const hitAreas = (model3 as Record<string, unknown>).HitAreas
86
+ if (!Array.isArray(hitAreas)) return []
87
+ return hitAreas
88
+ .map(area => typeof area === 'object' && area !== null ? (area as Record<string, unknown>).Name : undefined)
89
+ .filter((name): name is string => typeof name === 'string')
90
+ .sort()
91
+ }
@@ -5,9 +5,11 @@ import { join } from 'node:path'
5
5
  import {
6
6
  DEFAULT_FRAME_COUNTS,
7
7
  DEFAULT_PET_CELL,
8
+ PET_SCAN_JSON_CAP,
8
9
  codexPetsDir,
9
10
  loadPetRegistry,
10
11
  petAtlasFile,
12
+ petEntryView,
11
13
  petPackageRoot,
12
14
  resolvePetManifest,
13
15
  } from './registry.ts'
@@ -152,6 +154,7 @@ describe('loadPetRegistry', () => {
152
154
  const registry = loadPetRegistry({
153
155
  packageRoot: petPackageRoot(import.meta.url),
154
156
  petsDir: '',
157
+ dshPetsDir: '',
155
158
  })
156
159
 
157
160
  expect(registry.entries.map(entry => entry.id)).toEqual([
@@ -184,7 +187,7 @@ describe('loadPetRegistry', () => {
184
187
  mkdirSync(join(petsDir, 'broken'), { recursive: true })
185
188
  writeFileSync(join(petsDir, 'broken', 'pet.json'), '{ not json', 'utf8')
186
189
 
187
- const registry = loadPetRegistry({ packageRoot: root, petsDir })
190
+ const registry = loadPetRegistry({ packageRoot: root, petsDir, dshPetsDir: '' })
188
191
  expect(registry.entries.map(entry => entry.id)).toEqual(['whale-girl', 'otter'])
189
192
  expect(registry.defaultEntry().id).toBe('whale-girl')
190
193
  expect(registry.warnings.some(warning => warning.includes('broken'))).toBe(true)
@@ -193,6 +196,7 @@ describe('loadPetRegistry', () => {
193
196
  const overridden = loadPetRegistry({
194
197
  packageRoot: root,
195
198
  petsDir,
199
+ dshPetsDir: '',
196
200
  extra: [{ id: 'whale-girl', displayName: '替换鲸', spritesheetPath: 'spritesheet.webp' }],
197
201
  })
198
202
  expect(overridden.byId('whale-girl')!.displayName).toBe('替换鲸')
@@ -211,6 +215,7 @@ describe('loadPetRegistry', () => {
211
215
  const registry = loadPetRegistry({
212
216
  packageRoot: root,
213
217
  petsDir: '',
218
+ dshPetsDir: '',
214
219
  extra: [{ id: 'otter', displayName: '水獭', spritesheetPath: 'pets/otter/spritesheet.webp' }],
215
220
  })
216
221
  const entry = registry.byId('otter')
@@ -236,7 +241,7 @@ describe('loadPetRegistry', () => {
236
241
  writeFileSync(join(petsDir, 'aardvark', 'pet.json'), JSON.stringify({
237
242
  id: 'aardvark', displayName: '土豚', spritesheetPath: 'spritesheet.webp',
238
243
  }), 'utf8')
239
- const registry = loadPetRegistry({ packageRoot: join(root, 'no-assets'), petsDir })
244
+ const registry = loadPetRegistry({ packageRoot: join(root, 'no-assets'), petsDir, dshPetsDir: '' })
240
245
  expect(registry.defaultEntry().id).toBe('aardvark')
241
246
  } finally {
242
247
  rmSync(root, { recursive: true, force: true })
@@ -252,3 +257,434 @@ describe('codexPetsDir', () => {
252
257
  expect(codexPetsDir({}, '/home/user')).toBe(join('/home/user', '.codex', 'pets'))
253
258
  })
254
259
  })
260
+
261
+ describe('loadPetRegistry pet-center v2 (issue #623)', () => {
262
+ function writePet(dir: string, name: string, manifest: Record<string, unknown>): void {
263
+ mkdirSync(join(dir, name), { recursive: true })
264
+ writeFileSync(join(dir, name, 'pet.json'), JSON.stringify(manifest), 'utf8')
265
+ writeFileSync(join(dir, name, 'spritesheet.webp'), 'webp', 'utf8')
266
+ }
267
+
268
+ it('resolves a v2 sprite2d pet with the same geometry as its v1 twin', () => {
269
+ const root = tempDir()
270
+ try {
271
+ const petsDir = join(root, 'pets')
272
+ writePet(petsDir, 'v1pet', { id: 'twin-v1', displayName: 'Twin V1', frames: [1, 2, 3, 4, 5, 6, 7, 8, 9] })
273
+ writePet(petsDir, 'v2pet', {
274
+ petManifestVersion: 2, id: 'twin-v2', displayName: 'Twin V2', license: 'CC0-1.0',
275
+ renderer: 'sprite2d', sprite2d: { spritesheetPath: 'spritesheet.webp', frames: [1, 2, 3, 4, 5, 6, 7, 8, 9] },
276
+ })
277
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
278
+ const v1 = registry.byId('twin-v1')!
279
+ const v2 = registry.byId('twin-v2')!
280
+ expect(v2.rows).toEqual(v1.rows)
281
+ expect(v2.tracks.idle.durations).toEqual(v1.tracks.idle.durations)
282
+ expect(registry.diagnostics.some(d => d.message.includes('v1 compat read'))).toBe(true)
283
+ } finally {
284
+ rmSync(root, { recursive: true, force: true })
285
+ }
286
+ })
287
+
288
+ it('round-trips atlasRows 11 through the v1 compat resolver', () => {
289
+ const root = tempDir()
290
+ try {
291
+ const petsDir = join(root, 'pets')
292
+ writePet(petsDir, 'looky', {
293
+ petManifestVersion: 2, id: 'looky', displayName: 'Looky', license: 'CC0-1.0',
294
+ renderer: 'sprite2d', sprite2d: { spritesheetPath: 'spritesheet.webp', atlasRows: 11 },
295
+ })
296
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
297
+ expect(registry.byId('looky')?.atlasRows).toBe(11)
298
+ } finally {
299
+ rmSync(root, { recursive: true, force: true })
300
+ }
301
+ })
302
+
303
+ it('rejects atlasRows values the v1 compat resolver cannot express', () => {
304
+ const root = tempDir()
305
+ try {
306
+ const petsDir = join(root, 'pets')
307
+ writePet(petsDir, 'odd', {
308
+ petManifestVersion: 2, id: 'odd', displayName: 'Odd', license: 'CC0-1.0',
309
+ renderer: 'sprite2d', sprite2d: { spritesheetPath: 'spritesheet.webp', atlasRows: 7 },
310
+ })
311
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
312
+ expect(registry.byId('odd')).toBeUndefined()
313
+ expect(registry.diagnostics.some(d => d.level === 'error' && d.message.includes('atlasRows'))).toBe(true)
314
+ } finally {
315
+ rmSync(root, { recursive: true, force: true })
316
+ }
317
+ })
318
+
319
+ it('lists live2d pets with the render block and the servable closure (M3)', () => {
320
+ const root = tempDir()
321
+ try {
322
+ const petsDir = join(root, 'pets')
323
+ mkdirSync(join(petsDir, 'haru', 'motions'), { recursive: true })
324
+ mkdirSync(join(petsDir, 'haru', 'textures'), { recursive: true })
325
+ writeFileSync(join(petsDir, 'haru', 'pet.json'), JSON.stringify({
326
+ petManifestVersion: 2, id: 'haru', displayName: 'Haru', license: 'Live2D-Sample',
327
+ renderer: 'live2d',
328
+ live2d: {
329
+ model: 'haru.model3.json',
330
+ scale: 1.2,
331
+ motions: { idle: 'Idle', thinking: 'TapBody' },
332
+ hitAreas: ['Head'],
333
+ },
334
+ }), 'utf8')
335
+ writeFileSync(join(petsDir, 'haru', 'haru.model3.json'), JSON.stringify({
336
+ Version: 3,
337
+ FileReferences: {
338
+ Moc: 'haru.moc3',
339
+ Textures: ['textures/texture_00.png'],
340
+ Motions: { Idle: [{ File: 'motions/idle_00.motion3.json' }] },
341
+ },
342
+ HitAreas: [{ Name: 'Head', Id: 'HitAreaHead' }],
343
+ }), 'utf8')
344
+ writeFileSync(join(petsDir, 'haru', 'haru.moc3'), 'moc', 'utf8')
345
+ writeFileSync(join(petsDir, 'haru', 'textures', 'texture_00.png'), 'png', 'utf8')
346
+ writeFileSync(join(petsDir, 'haru', 'motions', 'idle_00.motion3.json'), '{}', 'utf8')
347
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
348
+ const entry = registry.byId('haru')
349
+ expect(entry).toBeDefined()
350
+ expect(entry!.renderer).toBe('live2d')
351
+ expect(entry!.live2d).toBeDefined()
352
+ expect(entry!.live2d!.modelUrl).toBe('/pet/haru/haru.model3.json')
353
+ expect(entry!.live2d!.modelPath).toBe('haru.model3.json')
354
+ expect(entry!.live2d!.scale).toBe(1.2)
355
+ expect(entry!.live2d!.motions).toEqual({ idle: 'Idle', thinking: 'TapBody' })
356
+ expect(entry!.live2d!.hitAreas).toEqual(['Head'])
357
+ // The servable closure covers the model plus every referenced file.
358
+ expect(entry!.servable).toEqual([
359
+ 'haru.model3.json', 'haru.moc3', 'motions/idle_00.motion3.json', 'textures/texture_00.png',
360
+ ])
361
+ // Sprite fields carry harmless contract defaults (chrome sizing only).
362
+ expect(entry!.cell).toEqual(DEFAULT_PET_CELL)
363
+ expect(entry!.rows).toEqual([...DEFAULT_FRAME_COUNTS])
364
+ expect(entry!.tracks.idle.frames.length).toBe(entry!.rows[0])
365
+ // The client-visible view keeps the live2d block, drops host paths.
366
+ const view = petEntryView(entry!)
367
+ expect(view.live2d!.modelPath).toBe('haru.model3.json')
368
+ expect('servable' in view).toBe(false)
369
+ expect('dir' in view).toBe(false)
370
+ // A healthy live2d entry records no diagnostics.
371
+ expect(registry.diagnostics.filter(d => d.message.includes('haru'))).toEqual([])
372
+ } finally {
373
+ rmSync(root, { recursive: true, force: true })
374
+ }
375
+ })
376
+
377
+ it('rejects live2d pets whose model3.json is unreadable', () => {
378
+ const root = tempDir()
379
+ try {
380
+ const petsDir = join(root, 'pets')
381
+ writePet(petsDir, 'haru', {
382
+ petManifestVersion: 2, id: 'haru', displayName: 'Haru', license: 'Live2D-Sample',
383
+ renderer: 'live2d', live2d: { model: 'haru.model3.json', motions: { idle: 'Idle' } },
384
+ })
385
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
386
+ expect(registry.byId('haru')).toBeUndefined()
387
+ expect(registry.diagnostics.some(d => d.level === 'error' && d.message.includes('not readable'))).toBe(true)
388
+ } finally {
389
+ rmSync(root, { recursive: true, force: true })
390
+ }
391
+ })
392
+
393
+ it('rejects live2d pets whose model3.json declares unsafe references', () => {
394
+ const root = tempDir()
395
+ try {
396
+ const petsDir = join(root, 'pets')
397
+ mkdirSync(join(petsDir, 'haru'), { recursive: true })
398
+ writeFileSync(join(petsDir, 'haru', 'pet.json'), JSON.stringify({
399
+ petManifestVersion: 2, id: 'haru', displayName: 'Haru', license: 'Live2D-Sample',
400
+ renderer: 'live2d', live2d: { model: 'haru.model3.json', motions: { idle: 'Idle' } },
401
+ }), 'utf8')
402
+ writeFileSync(join(petsDir, 'haru', 'haru.model3.json'), JSON.stringify({
403
+ Version: 3,
404
+ FileReferences: { Moc: '../escape.moc3' },
405
+ }), 'utf8')
406
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
407
+ expect(registry.byId('haru')).toBeUndefined()
408
+ expect(registry.diagnostics.some(d => d.level === 'error' && d.message.includes('not a safe relative path'))).toBe(true)
409
+ } finally {
410
+ rmSync(root, { recursive: true, force: true })
411
+ }
412
+ })
413
+
414
+ it('warns on closure files missing on disk but keeps the live2d pet listed', () => {
415
+ const root = tempDir()
416
+ try {
417
+ const petsDir = join(root, 'pets')
418
+ mkdirSync(join(petsDir, 'haru'), { recursive: true })
419
+ writeFileSync(join(petsDir, 'haru', 'pet.json'), JSON.stringify({
420
+ petManifestVersion: 2, id: 'haru', displayName: 'Haru', license: 'Live2D-Sample',
421
+ renderer: 'live2d', live2d: { model: 'haru.model3.json', motions: { idle: 'Idle' } },
422
+ }), 'utf8')
423
+ writeFileSync(join(petsDir, 'haru', 'haru.model3.json'), JSON.stringify({
424
+ Version: 3,
425
+ FileReferences: { Moc: 'haru.moc3', Textures: ['missing.png'] },
426
+ }), 'utf8')
427
+ writeFileSync(join(petsDir, 'haru', 'haru.moc3'), 'moc', 'utf8')
428
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
429
+ expect(registry.byId('haru')).toBeDefined()
430
+ expect(registry.diagnostics.some(d => d.level === 'warning' && d.message.includes('closure file missing: missing.png'))).toBe(true)
431
+ } finally {
432
+ rmSync(root, { recursive: true, force: true })
433
+ }
434
+ })
435
+
436
+ it('fails closed on v2 manifests with unknown fields', () => {
437
+ const root = tempDir()
438
+ try {
439
+ const petsDir = join(root, 'pets')
440
+ writePet(petsDir, 'surprise', {
441
+ petManifestVersion: 2, id: 'surprise', displayName: 'S', license: 'CC0-1.0',
442
+ renderer: 'sprite2d', sprite2d: { spritesheetPath: 'spritesheet.webp' }, sneaky: true,
443
+ })
444
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
445
+ expect(registry.byId('surprise')).toBeUndefined()
446
+ expect(registry.diagnostics.some(d => d.level === 'error' && d.message.includes('sneaky'))).toBe(true)
447
+ } finally {
448
+ rmSync(root, { recursive: true, force: true })
449
+ }
450
+ })
451
+
452
+ it('ranks the DSH_HOME pets source above the legacy codex source', () => {
453
+ const root = tempDir()
454
+ try {
455
+ const legacy = join(root, 'legacy')
456
+ const dsh = join(root, 'dsh')
457
+ writePet(legacy, 'cat', { id: 'cat', displayName: 'Legacy Cat' })
458
+ writePet(dsh, 'cat', { id: 'cat', displayName: 'Home Cat' })
459
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir: legacy, dshPetsDir: dsh })
460
+ expect(registry.byId('cat')?.displayName).toBe('Home Cat')
461
+ // '' disables the source entirely.
462
+ const disabled = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir: '', dshPetsDir: dsh })
463
+ expect(disabled.entries.map(e => e.id)).toEqual(['cat'])
464
+ } finally {
465
+ rmSync(root, { recursive: true, force: true })
466
+ }
467
+ })
468
+ })
469
+
470
+ describe('voice packs (pet-center M4, issue #677)', () => {
471
+ function writeVoice(dir: string, name: string, pack: unknown): void {
472
+ mkdirSync(join(dir, name), { recursive: true })
473
+ writeFileSync(join(dir, name, 'pet.json'), JSON.stringify({ id: name, displayName: name, spritesheetPath: 'spritesheet.webp' }), 'utf8')
474
+ writeFileSync(join(dir, name, 'spritesheet.webp'), 'webp', 'utf8')
475
+ writeFileSync(join(dir, name, 'voice.json'), JSON.stringify(pack), 'utf8')
476
+ }
477
+
478
+ it('loads a pet voice.json and serves its panel slice to the browser view', () => {
479
+ const root = tempDir()
480
+ try {
481
+ const petsDir = join(root, 'pets')
482
+ writeVoice(petsDir, 'talker', {
483
+ status: { done: ['自定义完工'] },
484
+ panel: { labels: { feed: '投喂' }, stats: { rank: '好感 {rank}' }, actions: ['feed'] },
485
+ })
486
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
487
+ const entry = registry.byId('talker')!
488
+ expect(entry.voice?.overrides.status?.done).toEqual(['自定义完工'])
489
+ const view = petEntryView(entry)
490
+ expect(view.panel).toEqual({
491
+ labels: { feed: '投喂' },
492
+ stats: { rank: '好感 {rank}' },
493
+ actions: ['feed'],
494
+ })
495
+ // Host-only voice content never reaches the browser half.
496
+ expect('voice' in view).toBe(false)
497
+ // A healthy voice pack records no diagnostics of its own.
498
+ expect(registry.diagnostics.filter(d => d.message.includes('voice'))).toEqual([])
499
+ } finally {
500
+ rmSync(root, { recursive: true, force: true })
501
+ }
502
+ })
503
+
504
+ it('warns and drops a broken voice.json without rejecting the pet', () => {
505
+ const root = tempDir()
506
+ try {
507
+ const petsDir = join(root, 'pets')
508
+ mkdirSync(join(petsDir, 'mumbler'), { recursive: true })
509
+ writeFileSync(join(petsDir, 'mumbler', 'pet.json'), JSON.stringify({ id: 'mumbler', displayName: 'Mumbler', spritesheetPath: 'spritesheet.webp' }), 'utf8')
510
+ writeFileSync(join(petsDir, 'mumbler', 'spritesheet.webp'), 'webp', 'utf8')
511
+ writeFileSync(join(petsDir, 'mumbler', 'voice.json'), '{ not json', 'utf8')
512
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
513
+ expect(registry.byId('mumbler')).toBeDefined()
514
+ expect(registry.byId('mumbler')!.voice).toBeUndefined()
515
+ expect(registry.diagnostics.some(d => d.level === 'warning' && d.message.includes('voice pack is not valid JSON'))).toBe(true)
516
+ } finally {
517
+ rmSync(root, { recursive: true, force: true })
518
+ }
519
+ })
520
+
521
+ it('loads the global .voice.json override from the DSH_HOME pets dir', () => {
522
+ const root = tempDir()
523
+ try {
524
+ const petsDir = join(root, 'pets')
525
+ mkdirSync(join(petsDir, 'plain'), { recursive: true })
526
+ writeFileSync(join(petsDir, 'plain', 'pet.json'), JSON.stringify({ id: 'plain', displayName: 'Plain', spritesheetPath: 'spritesheet.webp' }), 'utf8')
527
+ writeFileSync(join(petsDir, 'plain', 'spritesheet.webp'), 'webp', 'utf8')
528
+ writeFileSync(join(petsDir, '.voice.json'), JSON.stringify({
529
+ status: { done: ['全局完工'] },
530
+ panel: { labels: { hide: '全局藏' } },
531
+ }), 'utf8')
532
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: petsDir })
533
+ expect(registry.globalVoice?.overrides.status?.done).toEqual(['全局完工'])
534
+ expect(registry.globalVoice?.panel?.labels).toEqual({ hide: '全局藏' })
535
+ // The dotfile itself is never scanned as a pet directory.
536
+ expect(registry.entries.map(e => e.id)).toEqual(['plain'])
537
+ } finally {
538
+ rmSync(root, { recursive: true, force: true })
539
+ }
540
+ })
541
+
542
+ it('serves the merged panel chrome (per-pet over global, per slot)', () => {
543
+ const root = tempDir()
544
+ try {
545
+ const petsDir = join(root, 'pets')
546
+ mkdirSync(join(petsDir, 'plain'), { recursive: true })
547
+ writeFileSync(join(petsDir, 'plain', 'pet.json'), JSON.stringify({ id: 'plain', displayName: 'Plain', spritesheetPath: 'spritesheet.webp' }), 'utf8')
548
+ writeFileSync(join(petsDir, 'plain', 'spritesheet.webp'), 'webp', 'utf8')
549
+ writeFileSync(join(petsDir, 'plain', 'voice.json'), JSON.stringify({
550
+ panel: { labels: { feed: '宠物投喂' }, stats: { treats: '宠物鱼干 {n}' } },
551
+ }), 'utf8')
552
+ writeFileSync(join(petsDir, '.voice.json'), JSON.stringify({
553
+ panel: { labels: { feed: '全局投喂', hide: '全局藏' } },
554
+ }), 'utf8')
555
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: petsDir })
556
+ const entry = registry.byId('plain')
557
+ expect(entry).toBeDefined()
558
+ const view = petEntryView(entry!, registry.globalVoice)
559
+ // The pet's own slot wins; untouched global slots layer underneath.
560
+ expect(view.panel?.labels).toEqual({ feed: '宠物投喂', hide: '全局藏' })
561
+ expect(view.panel?.stats?.treats).toBe('宠物鱼干 {n}')
562
+ // A pack-less pet would receive the global panel as-is.
563
+ const bare = resolvePetManifest({ id: 'bare', displayName: 'Bare', spritesheetPath: 'spritesheet.webp' }, join(tmpdir(), 'bare'))
564
+ expect(petEntryView(bare!, registry.globalVoice).panel?.labels).toEqual({ feed: '全局投喂', hide: '全局藏' })
565
+ } finally {
566
+ rmSync(root, { recursive: true, force: true })
567
+ }
568
+ })
569
+
570
+ it('skips an oversized voice.json with a warning instead of reading it', () => {
571
+ const root = tempDir()
572
+ try {
573
+ const petsDir = join(root, 'pets')
574
+ mkdirSync(join(petsDir, 'loud'), { recursive: true })
575
+ writeFileSync(join(petsDir, 'loud', 'pet.json'), JSON.stringify({ id: 'loud', displayName: 'Loud', spritesheetPath: 'spritesheet.webp' }), 'utf8')
576
+ writeFileSync(join(petsDir, 'loud', 'voice.json'), '{ ' + 'x'.repeat(PET_SCAN_JSON_CAP) + ' }', 'utf8')
577
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
578
+ expect(registry.byId('loud')!.voice).toBeUndefined()
579
+ expect(registry.warnings.some(w => w.includes('scan ceiling'))).toBe(true)
580
+ } finally {
581
+ rmSync(root, { recursive: true, force: true })
582
+ }
583
+ })
584
+
585
+ it('skips a non-regular voice.json with a warning', () => {
586
+ const root = tempDir()
587
+ try {
588
+ const petsDir = join(root, 'pets')
589
+ mkdirSync(join(petsDir, 'odd', 'voice.json'), { recursive: true })
590
+ writeFileSync(join(petsDir, 'odd', 'pet.json'), JSON.stringify({ id: 'odd', displayName: 'Odd', spritesheetPath: 'spritesheet.webp' }), 'utf8')
591
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
592
+ expect(registry.byId('odd')!.voice).toBeUndefined()
593
+ expect(registry.warnings.some(w => w.includes('not a regular file'))).toBe(true)
594
+ } finally {
595
+ rmSync(root, { recursive: true, force: true })
596
+ }
597
+ })
598
+ })
599
+ describe('status decorations (pet-center M5, #567)', () => {
600
+ function writeDecoration(dir: string, name: string, manifest: Record<string, unknown>, strip = 'whale-frames.png'): void {
601
+ mkdirSync(join(dir, name), { recursive: true })
602
+ writeFileSync(join(dir, name, 'decoration.json'), JSON.stringify(manifest), 'utf8')
603
+ writeFileSync(join(dir, name, strip), 'png', 'utf8')
604
+ }
605
+
606
+ const baseManifest = () => ({
607
+ decorationManifestVersion: 1,
608
+ id: 'whale',
609
+ displayName: '喷水鲸鱼',
610
+ license: 'MIT',
611
+ entry: 'whale-frames.png',
612
+ cell: { width: 64, height: 48 },
613
+ columns: 4,
614
+ phases: { idle: 'hide', thinking: { from: 0, to: 3 } },
615
+ })
616
+
617
+ it('scans built-in decorations and exposes the browser view fields', () => {
618
+ const root = tempDir()
619
+ try {
620
+ const assets = join(root, 'assets')
621
+ writeDecoration(join(assets, 'decorations'), 'whale', baseManifest())
622
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
623
+ const entry = registry.decorationById?.('whale')
624
+ expect(entry).toBeDefined()
625
+ expect(entry!.entryUrl).toBe('/api/pet/decoration/whale/whale-frames.png')
626
+ expect(entry!.servable).toEqual(['decoration.json', 'whale-frames.png'])
627
+ expect(entry!.phases.thinking).toEqual({ from: 0, to: 3 })
628
+ // The pet entries list is untouched by decorations.
629
+ expect(registry.entries).toEqual([])
630
+ } finally {
631
+ rmSync(root, { recursive: true, force: true })
632
+ }
633
+ })
634
+
635
+ it('lets a user decoration override the built-in by id', () => {
636
+ const root = tempDir()
637
+ try {
638
+ writeDecoration(join(root, 'assets', 'decorations'), 'whale', baseManifest())
639
+ const dsh = join(root, 'dsh')
640
+ writeDecoration(join(dsh, 'decorations'), 'whale', { ...baseManifest(), displayName: '家用鲸鱼' })
641
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: dsh })
642
+ expect(registry.decorationById?.('whale')?.id).toBe('whale')
643
+ expect(registry.warnings.some(w => w.includes('user decoration whale overrides'))).toBe(true)
644
+ expect(registry.decorationById?.('whale')?.entryUrl).toBe('/api/pet/decoration/whale/whale-frames.png')
645
+ } finally {
646
+ rmSync(root, { recursive: true, force: true })
647
+ }
648
+ })
649
+
650
+ it('warns and skips a broken descriptor without disturbing pets', () => {
651
+ const root = tempDir()
652
+ try {
653
+ mkdirSync(join(root, 'assets', 'decorations', 'broken'), { recursive: true })
654
+ writeFileSync(join(root, 'assets', 'decorations', 'broken', 'decoration.json'), '{ not json', 'utf8')
655
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
656
+ expect(registry.decorations).toEqual([])
657
+ expect(registry.diagnostics.some(d => d.level === 'error' && d.message.includes('broken'))).toBe(true)
658
+ } finally {
659
+ rmSync(root, { recursive: true, force: true })
660
+ }
661
+ })
662
+
663
+ it('skips an oversized decoration.json with a warning instead of reading it', () => {
664
+ const root = tempDir()
665
+ try {
666
+ mkdirSync(join(root, 'assets', 'decorations', 'huge'), { recursive: true })
667
+ writeFileSync(join(root, 'assets', 'decorations', 'huge', 'decoration.json'), '{ ' + 'x'.repeat(PET_SCAN_JSON_CAP) + ' }', 'utf8')
668
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
669
+ expect(registry.decorations).toEqual([])
670
+ expect(registry.warnings.some(w => w.includes('scan ceiling'))).toBe(true)
671
+ } finally {
672
+ rmSync(root, { recursive: true, force: true })
673
+ }
674
+ })
675
+
676
+ it('lists a decoration with a missing strip and warns about the file', () => {
677
+ const root = tempDir()
678
+ try {
679
+ const dir = join(root, 'assets', 'decorations', 'ghost')
680
+ mkdirSync(dir, { recursive: true })
681
+ writeFileSync(join(dir, 'decoration.json'), JSON.stringify(baseManifest()), 'utf8')
682
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
683
+ // The entry id comes from the descriptor (the directory name is free).
684
+ expect(registry.decorationById?.('whale')).toBeDefined()
685
+ expect(registry.warnings.some(w => w.includes('strip file missing'))).toBe(true)
686
+ } finally {
687
+ rmSync(root, { recursive: true, force: true })
688
+ }
689
+ })
690
+ })