@linxin666/dsh-pet 0.2.1 → 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 (95) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +78 -5
  3. package/README.zh.md +78 -5
  4. package/assets/whale/pet.json +170 -21
  5. package/assets/whale-refined/pet.json +22 -0
  6. package/assets/whale-refined/spritesheet.webp +0 -0
  7. package/lib/client.js +442 -25
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +752 -36
  10. package/lib/live2d-vendor.js +995 -0
  11. package/lib/live2d-vendor.js.map +1 -0
  12. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  13. package/lib/types/client/PetDockEntry.js +2 -1
  14. package/lib/types/client/PetSettingsCard.d.ts +10 -0
  15. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  16. package/lib/types/client/PetSettingsCard.js +22 -1
  17. package/lib/types/client/PetSprite.d.ts +7 -1
  18. package/lib/types/client/PetSprite.d.ts.map +1 -1
  19. package/lib/types/client/PetSprite.js +20 -11
  20. package/lib/types/client/index.d.ts.map +1 -1
  21. package/lib/types/client/index.js +5 -0
  22. package/lib/types/client/locales.d.ts +10 -0
  23. package/lib/types/client/locales.d.ts.map +1 -1
  24. package/lib/types/client/locales.js +10 -0
  25. package/lib/types/client/phase-stream.d.ts +22 -0
  26. package/lib/types/client/phase-stream.d.ts.map +1 -0
  27. package/lib/types/client/phase-stream.js +28 -0
  28. package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
  29. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
  30. package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
  31. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
  32. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
  33. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
  34. package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
  35. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
  36. package/lib/types/client/renderers/live2d/runtime.js +63 -0
  37. package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
  38. package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
  39. package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
  40. package/lib/types/client/renderers/live2d.d.ts +52 -0
  41. package/lib/types/client/renderers/live2d.d.ts.map +1 -0
  42. package/lib/types/client/renderers/live2d.js +180 -0
  43. package/lib/types/client/renderers/registry.d.ts +32 -0
  44. package/lib/types/client/renderers/registry.d.ts.map +1 -0
  45. package/lib/types/client/renderers/registry.js +49 -0
  46. package/lib/types/client/spritesheet.d.ts +1 -3
  47. package/lib/types/client/spritesheet.d.ts.map +1 -1
  48. package/lib/types/client/spritesheet.js +1 -5
  49. package/lib/types/contracts/renderer.d.ts +50 -0
  50. package/lib/types/contracts/renderer.d.ts.map +1 -0
  51. package/lib/types/contracts/renderer.js +15 -0
  52. package/lib/types/manifest-v2.d.ts +129 -0
  53. package/lib/types/manifest-v2.d.ts.map +1 -0
  54. package/lib/types/manifest-v2.js +387 -0
  55. package/lib/types/model3.d.ts +26 -0
  56. package/lib/types/model3.d.ts.map +1 -0
  57. package/lib/types/model3.js +96 -0
  58. package/lib/types/registry.d.ts +57 -5
  59. package/lib/types/registry.d.ts.map +1 -1
  60. package/lib/types/registry.js +208 -37
  61. package/lib/types/routes.d.ts +39 -2
  62. package/lib/types/routes.d.ts.map +1 -1
  63. package/lib/types/routes.js +183 -12
  64. package/lib/types/service.d.ts +5 -1
  65. package/lib/types/service.d.ts.map +1 -1
  66. package/lib/types/service.js +4 -0
  67. package/package.json +3 -2
  68. package/src/client/PetDockEntry.tsx +19 -11
  69. package/src/client/PetSettingsCard.tsx +39 -0
  70. package/src/client/PetSprite.test.tsx +11 -0
  71. package/src/client/PetSprite.tsx +27 -12
  72. package/src/client/index.ts +6 -0
  73. package/src/client/locales.ts +10 -0
  74. package/src/client/phase-stream.test.ts +76 -0
  75. package/src/client/phase-stream.ts +39 -0
  76. package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
  77. package/src/client/renderers/PetRendererSwitch.tsx +48 -0
  78. package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
  79. package/src/client/renderers/live2d/runtime.test.ts +61 -0
  80. package/src/client/renderers/live2d/runtime.ts +119 -0
  81. package/src/client/renderers/live2d/vendor-entry.ts +14 -0
  82. package/src/client/renderers/live2d.test.ts +242 -0
  83. package/src/client/renderers/live2d.ts +218 -0
  84. package/src/client/renderers/registry.ts +58 -0
  85. package/src/client/settings-section.module.css +23 -0
  86. package/src/client/spritesheet.ts +1 -6
  87. package/src/contracts/renderer.ts +54 -0
  88. package/src/manifest-v2.test.ts +251 -0
  89. package/src/manifest-v2.ts +414 -0
  90. package/src/model3.test.ts +79 -0
  91. package/src/model3.ts +91 -0
  92. package/src/registry.test.ts +236 -3
  93. package/src/registry.ts +246 -38
  94. package/src/routes.ts +202 -13
  95. package/src/service.ts +6 -1
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import {
@@ -8,6 +8,8 @@ import {
8
8
  codexPetsDir,
9
9
  loadPetRegistry,
10
10
  petAtlasFile,
11
+ petEntryView,
12
+ petPackageRoot,
11
13
  resolvePetManifest,
12
14
  } from './registry.ts'
13
15
 
@@ -147,6 +149,26 @@ describe('resolvePetManifest', () => {
147
149
  })
148
150
 
149
151
  describe('loadPetRegistry', () => {
152
+ it('ships the original and refined whale variants while keeping the original default', () => {
153
+ const registry = loadPetRegistry({
154
+ packageRoot: petPackageRoot(import.meta.url),
155
+ petsDir: '',
156
+ dshPetsDir: '',
157
+ })
158
+
159
+ expect(registry.entries.map(entry => entry.id)).toEqual([
160
+ 'whale-girl',
161
+ 'whale-girl-refined',
162
+ ])
163
+ expect(registry.byId('whale-girl')?.displayName).toBe('鲸鱼娘(原版)')
164
+ expect(registry.byId('whale-girl-refined')?.displayName).toBe('鲸鱼娘(精致版)')
165
+ expect(existsSync(petAtlasFile(registry.byId('whale-girl-refined')!))).toBe(true)
166
+ expect(readFileSync(petAtlasFile(registry.byId('whale-girl')!)).equals(
167
+ readFileSync(petAtlasFile(registry.byId('whale-girl-refined')!)),
168
+ )).toBe(false)
169
+ expect(registry.defaultEntry().id).toBe('whale-girl')
170
+ })
171
+
150
172
  it('scans built-in assets, the custom pets dir, and composed extras with precedence', () => {
151
173
  const root = tempDir()
152
174
  try {
@@ -164,7 +186,7 @@ describe('loadPetRegistry', () => {
164
186
  mkdirSync(join(petsDir, 'broken'), { recursive: true })
165
187
  writeFileSync(join(petsDir, 'broken', 'pet.json'), '{ not json', 'utf8')
166
188
 
167
- const registry = loadPetRegistry({ packageRoot: root, petsDir })
189
+ const registry = loadPetRegistry({ packageRoot: root, petsDir, dshPetsDir: '' })
168
190
  expect(registry.entries.map(entry => entry.id)).toEqual(['whale-girl', 'otter'])
169
191
  expect(registry.defaultEntry().id).toBe('whale-girl')
170
192
  expect(registry.warnings.some(warning => warning.includes('broken'))).toBe(true)
@@ -173,6 +195,7 @@ describe('loadPetRegistry', () => {
173
195
  const overridden = loadPetRegistry({
174
196
  packageRoot: root,
175
197
  petsDir,
198
+ dshPetsDir: '',
176
199
  extra: [{ id: 'whale-girl', displayName: '替换鲸', spritesheetPath: 'spritesheet.webp' }],
177
200
  })
178
201
  expect(overridden.byId('whale-girl')!.displayName).toBe('替换鲸')
@@ -191,6 +214,7 @@ describe('loadPetRegistry', () => {
191
214
  const registry = loadPetRegistry({
192
215
  packageRoot: root,
193
216
  petsDir: '',
217
+ dshPetsDir: '',
194
218
  extra: [{ id: 'otter', displayName: '水獭', spritesheetPath: 'pets/otter/spritesheet.webp' }],
195
219
  })
196
220
  const entry = registry.byId('otter')
@@ -216,7 +240,7 @@ describe('loadPetRegistry', () => {
216
240
  writeFileSync(join(petsDir, 'aardvark', 'pet.json'), JSON.stringify({
217
241
  id: 'aardvark', displayName: '土豚', spritesheetPath: 'spritesheet.webp',
218
242
  }), 'utf8')
219
- const registry = loadPetRegistry({ packageRoot: join(root, 'no-assets'), petsDir })
243
+ const registry = loadPetRegistry({ packageRoot: join(root, 'no-assets'), petsDir, dshPetsDir: '' })
220
244
  expect(registry.defaultEntry().id).toBe('aardvark')
221
245
  } finally {
222
246
  rmSync(root, { recursive: true, force: true })
@@ -232,3 +256,212 @@ describe('codexPetsDir', () => {
232
256
  expect(codexPetsDir({}, '/home/user')).toBe(join('/home/user', '.codex', 'pets'))
233
257
  })
234
258
  })
259
+
260
+ describe('loadPetRegistry pet-center v2 (issue #623)', () => {
261
+ function writePet(dir: string, name: string, manifest: Record<string, unknown>): void {
262
+ mkdirSync(join(dir, name), { recursive: true })
263
+ writeFileSync(join(dir, name, 'pet.json'), JSON.stringify(manifest), 'utf8')
264
+ writeFileSync(join(dir, name, 'spritesheet.webp'), 'webp', 'utf8')
265
+ }
266
+
267
+ it('resolves a v2 sprite2d pet with the same geometry as its v1 twin', () => {
268
+ const root = tempDir()
269
+ try {
270
+ const petsDir = join(root, 'pets')
271
+ writePet(petsDir, 'v1pet', { id: 'twin-v1', displayName: 'Twin V1', frames: [1, 2, 3, 4, 5, 6, 7, 8, 9] })
272
+ writePet(petsDir, 'v2pet', {
273
+ petManifestVersion: 2, id: 'twin-v2', displayName: 'Twin V2', license: 'CC0-1.0',
274
+ renderer: 'sprite2d', sprite2d: { spritesheetPath: 'spritesheet.webp', frames: [1, 2, 3, 4, 5, 6, 7, 8, 9] },
275
+ })
276
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
277
+ const v1 = registry.byId('twin-v1')!
278
+ const v2 = registry.byId('twin-v2')!
279
+ expect(v2.rows).toEqual(v1.rows)
280
+ expect(v2.tracks.idle.durations).toEqual(v1.tracks.idle.durations)
281
+ expect(registry.diagnostics.some(d => d.message.includes('v1 compat read'))).toBe(true)
282
+ } finally {
283
+ rmSync(root, { recursive: true, force: true })
284
+ }
285
+ })
286
+
287
+ it('round-trips atlasRows 11 through the v1 compat resolver', () => {
288
+ const root = tempDir()
289
+ try {
290
+ const petsDir = join(root, 'pets')
291
+ writePet(petsDir, 'looky', {
292
+ petManifestVersion: 2, id: 'looky', displayName: 'Looky', license: 'CC0-1.0',
293
+ renderer: 'sprite2d', sprite2d: { spritesheetPath: 'spritesheet.webp', atlasRows: 11 },
294
+ })
295
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
296
+ expect(registry.byId('looky')?.atlasRows).toBe(11)
297
+ } finally {
298
+ rmSync(root, { recursive: true, force: true })
299
+ }
300
+ })
301
+
302
+ it('rejects atlasRows values the v1 compat resolver cannot express', () => {
303
+ const root = tempDir()
304
+ try {
305
+ const petsDir = join(root, 'pets')
306
+ writePet(petsDir, 'odd', {
307
+ petManifestVersion: 2, id: 'odd', displayName: 'Odd', license: 'CC0-1.0',
308
+ renderer: 'sprite2d', sprite2d: { spritesheetPath: 'spritesheet.webp', atlasRows: 7 },
309
+ })
310
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
311
+ expect(registry.byId('odd')).toBeUndefined()
312
+ expect(registry.diagnostics.some(d => d.level === 'error' && d.message.includes('atlasRows'))).toBe(true)
313
+ } finally {
314
+ rmSync(root, { recursive: true, force: true })
315
+ }
316
+ })
317
+
318
+ it('lists live2d pets with the render block and the servable closure (M3)', () => {
319
+ const root = tempDir()
320
+ try {
321
+ const petsDir = join(root, 'pets')
322
+ mkdirSync(join(petsDir, 'haru', 'motions'), { recursive: true })
323
+ mkdirSync(join(petsDir, 'haru', 'textures'), { recursive: true })
324
+ writeFileSync(join(petsDir, 'haru', 'pet.json'), JSON.stringify({
325
+ petManifestVersion: 2, id: 'haru', displayName: 'Haru', license: 'Live2D-Sample',
326
+ renderer: 'live2d',
327
+ live2d: {
328
+ model: 'haru.model3.json',
329
+ scale: 1.2,
330
+ motions: { idle: 'Idle', thinking: 'TapBody' },
331
+ hitAreas: ['Head'],
332
+ },
333
+ }), 'utf8')
334
+ writeFileSync(join(petsDir, 'haru', 'haru.model3.json'), JSON.stringify({
335
+ Version: 3,
336
+ FileReferences: {
337
+ Moc: 'haru.moc3',
338
+ Textures: ['textures/texture_00.png'],
339
+ Motions: { Idle: [{ File: 'motions/idle_00.motion3.json' }] },
340
+ },
341
+ HitAreas: [{ Name: 'Head', Id: 'HitAreaHead' }],
342
+ }), 'utf8')
343
+ writeFileSync(join(petsDir, 'haru', 'haru.moc3'), 'moc', 'utf8')
344
+ writeFileSync(join(petsDir, 'haru', 'textures', 'texture_00.png'), 'png', 'utf8')
345
+ writeFileSync(join(petsDir, 'haru', 'motions', 'idle_00.motion3.json'), '{}', 'utf8')
346
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
347
+ const entry = registry.byId('haru')
348
+ expect(entry).toBeDefined()
349
+ expect(entry!.renderer).toBe('live2d')
350
+ expect(entry!.live2d).toBeDefined()
351
+ expect(entry!.live2d!.modelUrl).toBe('/pet/haru/haru.model3.json')
352
+ expect(entry!.live2d!.modelPath).toBe('haru.model3.json')
353
+ expect(entry!.live2d!.scale).toBe(1.2)
354
+ expect(entry!.live2d!.motions).toEqual({ idle: 'Idle', thinking: 'TapBody' })
355
+ expect(entry!.live2d!.hitAreas).toEqual(['Head'])
356
+ // The servable closure covers the model plus every referenced file.
357
+ expect(entry!.servable).toEqual([
358
+ 'haru.model3.json', 'haru.moc3', 'motions/idle_00.motion3.json', 'textures/texture_00.png',
359
+ ])
360
+ // Sprite fields carry harmless contract defaults (chrome sizing only).
361
+ expect(entry!.cell).toEqual(DEFAULT_PET_CELL)
362
+ expect(entry!.rows).toEqual([...DEFAULT_FRAME_COUNTS])
363
+ expect(entry!.tracks.idle.frames.length).toBe(entry!.rows[0])
364
+ // The client-visible view keeps the live2d block, drops host paths.
365
+ const view = petEntryView(entry!)
366
+ expect(view.live2d!.modelPath).toBe('haru.model3.json')
367
+ expect('servable' in view).toBe(false)
368
+ expect('dir' in view).toBe(false)
369
+ // A healthy live2d entry records no diagnostics.
370
+ expect(registry.diagnostics.filter(d => d.message.includes('haru'))).toEqual([])
371
+ } finally {
372
+ rmSync(root, { recursive: true, force: true })
373
+ }
374
+ })
375
+
376
+ it('rejects live2d pets whose model3.json is unreadable', () => {
377
+ const root = tempDir()
378
+ try {
379
+ const petsDir = join(root, 'pets')
380
+ writePet(petsDir, 'haru', {
381
+ petManifestVersion: 2, id: 'haru', displayName: 'Haru', license: 'Live2D-Sample',
382
+ renderer: 'live2d', live2d: { model: 'haru.model3.json', motions: { idle: 'Idle' } },
383
+ })
384
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
385
+ expect(registry.byId('haru')).toBeUndefined()
386
+ expect(registry.diagnostics.some(d => d.level === 'error' && d.message.includes('not readable'))).toBe(true)
387
+ } finally {
388
+ rmSync(root, { recursive: true, force: true })
389
+ }
390
+ })
391
+
392
+ it('rejects live2d pets whose model3.json declares unsafe references', () => {
393
+ const root = tempDir()
394
+ try {
395
+ const petsDir = join(root, 'pets')
396
+ mkdirSync(join(petsDir, 'haru'), { recursive: true })
397
+ writeFileSync(join(petsDir, 'haru', 'pet.json'), JSON.stringify({
398
+ petManifestVersion: 2, id: 'haru', displayName: 'Haru', license: 'Live2D-Sample',
399
+ renderer: 'live2d', live2d: { model: 'haru.model3.json', motions: { idle: 'Idle' } },
400
+ }), 'utf8')
401
+ writeFileSync(join(petsDir, 'haru', 'haru.model3.json'), JSON.stringify({
402
+ Version: 3,
403
+ FileReferences: { Moc: '../escape.moc3' },
404
+ }), 'utf8')
405
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
406
+ expect(registry.byId('haru')).toBeUndefined()
407
+ expect(registry.diagnostics.some(d => d.level === 'error' && d.message.includes('not a safe relative path'))).toBe(true)
408
+ } finally {
409
+ rmSync(root, { recursive: true, force: true })
410
+ }
411
+ })
412
+
413
+ it('warns on closure files missing on disk but keeps the live2d pet listed', () => {
414
+ const root = tempDir()
415
+ try {
416
+ const petsDir = join(root, 'pets')
417
+ mkdirSync(join(petsDir, 'haru'), { recursive: true })
418
+ writeFileSync(join(petsDir, 'haru', 'pet.json'), JSON.stringify({
419
+ petManifestVersion: 2, id: 'haru', displayName: 'Haru', license: 'Live2D-Sample',
420
+ renderer: 'live2d', live2d: { model: 'haru.model3.json', motions: { idle: 'Idle' } },
421
+ }), 'utf8')
422
+ writeFileSync(join(petsDir, 'haru', 'haru.model3.json'), JSON.stringify({
423
+ Version: 3,
424
+ FileReferences: { Moc: 'haru.moc3', Textures: ['missing.png'] },
425
+ }), 'utf8')
426
+ writeFileSync(join(petsDir, 'haru', 'haru.moc3'), 'moc', 'utf8')
427
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
428
+ expect(registry.byId('haru')).toBeDefined()
429
+ expect(registry.diagnostics.some(d => d.level === 'warning' && d.message.includes('closure file missing: missing.png'))).toBe(true)
430
+ } finally {
431
+ rmSync(root, { recursive: true, force: true })
432
+ }
433
+ })
434
+
435
+ it('fails closed on v2 manifests with unknown fields', () => {
436
+ const root = tempDir()
437
+ try {
438
+ const petsDir = join(root, 'pets')
439
+ writePet(petsDir, 'surprise', {
440
+ petManifestVersion: 2, id: 'surprise', displayName: 'S', license: 'CC0-1.0',
441
+ renderer: 'sprite2d', sprite2d: { spritesheetPath: 'spritesheet.webp' }, sneaky: true,
442
+ })
443
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
444
+ expect(registry.byId('surprise')).toBeUndefined()
445
+ expect(registry.diagnostics.some(d => d.level === 'error' && d.message.includes('sneaky'))).toBe(true)
446
+ } finally {
447
+ rmSync(root, { recursive: true, force: true })
448
+ }
449
+ })
450
+
451
+ it('ranks the DSH_HOME pets source above the legacy codex source', () => {
452
+ const root = tempDir()
453
+ try {
454
+ const legacy = join(root, 'legacy')
455
+ const dsh = join(root, 'dsh')
456
+ writePet(legacy, 'cat', { id: 'cat', displayName: 'Legacy Cat' })
457
+ writePet(dsh, 'cat', { id: 'cat', displayName: 'Home Cat' })
458
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir: legacy, dshPetsDir: dsh })
459
+ expect(registry.byId('cat')?.displayName).toBe('Home Cat')
460
+ // '' disables the source entirely.
461
+ const disabled = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir: '', dshPetsDir: dsh })
462
+ expect(disabled.entries.map(e => e.id)).toEqual(['cat'])
463
+ } finally {
464
+ rmSync(root, { recursive: true, force: true })
465
+ }
466
+ })
467
+ })