@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.
- package/README.i18n.yaml +2 -2
- package/README.md +66 -3
- package/README.zh.md +66 -3
- package/assets/whale/pet.json +169 -20
- package/assets/whale-refined/pet.json +17 -2
- package/lib/client.js +438 -21
- package/lib/client.js.map +1 -1
- package/lib/index.js +752 -36
- package/lib/live2d-vendor.js +995 -0
- package/lib/live2d-vendor.js.map +1 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +2 -1
- package/lib/types/client/PetSettingsCard.d.ts +10 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +22 -1
- package/lib/types/client/PetSprite.d.ts +7 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +17 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +5 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +10 -0
- package/lib/types/client/phase-stream.d.ts +22 -0
- package/lib/types/client/phase-stream.d.ts.map +1 -0
- package/lib/types/client/phase-stream.js +28 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
- package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/runtime.js +63 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
- package/lib/types/client/renderers/live2d.d.ts +52 -0
- package/lib/types/client/renderers/live2d.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d.js +180 -0
- package/lib/types/client/renderers/registry.d.ts +32 -0
- package/lib/types/client/renderers/registry.d.ts.map +1 -0
- package/lib/types/client/renderers/registry.js +49 -0
- package/lib/types/contracts/renderer.d.ts +50 -0
- package/lib/types/contracts/renderer.d.ts.map +1 -0
- package/lib/types/contracts/renderer.js +15 -0
- package/lib/types/manifest-v2.d.ts +129 -0
- package/lib/types/manifest-v2.d.ts.map +1 -0
- package/lib/types/manifest-v2.js +387 -0
- package/lib/types/model3.d.ts +26 -0
- package/lib/types/model3.d.ts.map +1 -0
- package/lib/types/model3.js +96 -0
- package/lib/types/registry.d.ts +57 -3
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +208 -33
- package/lib/types/routes.d.ts +39 -2
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +183 -12
- package/lib/types/service.d.ts +5 -1
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +4 -0
- package/package.json +3 -1
- package/src/client/PetDockEntry.tsx +19 -11
- package/src/client/PetSettingsCard.tsx +39 -0
- package/src/client/PetSprite.test.tsx +11 -0
- package/src/client/PetSprite.tsx +24 -9
- package/src/client/index.ts +6 -0
- package/src/client/locales.ts +10 -0
- package/src/client/phase-stream.test.ts +76 -0
- package/src/client/phase-stream.ts +39 -0
- package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
- package/src/client/renderers/PetRendererSwitch.tsx +48 -0
- package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
- package/src/client/renderers/live2d/runtime.test.ts +61 -0
- package/src/client/renderers/live2d/runtime.ts +119 -0
- package/src/client/renderers/live2d/vendor-entry.ts +14 -0
- package/src/client/renderers/live2d.test.ts +242 -0
- package/src/client/renderers/live2d.ts +218 -0
- package/src/client/renderers/registry.ts +58 -0
- package/src/client/settings-section.module.css +23 -0
- package/src/contracts/renderer.ts +54 -0
- package/src/manifest-v2.test.ts +251 -0
- package/src/manifest-v2.ts +414 -0
- package/src/model3.test.ts +79 -0
- package/src/model3.ts +91 -0
- package/src/registry.test.ts +215 -2
- package/src/registry.ts +246 -34
- package/src/routes.ts +202 -13
- package/src/service.ts +6 -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
|
+
}
|
package/src/registry.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
codexPetsDir,
|
|
9
9
|
loadPetRegistry,
|
|
10
10
|
petAtlasFile,
|
|
11
|
+
petEntryView,
|
|
11
12
|
petPackageRoot,
|
|
12
13
|
resolvePetManifest,
|
|
13
14
|
} from './registry.ts'
|
|
@@ -152,6 +153,7 @@ describe('loadPetRegistry', () => {
|
|
|
152
153
|
const registry = loadPetRegistry({
|
|
153
154
|
packageRoot: petPackageRoot(import.meta.url),
|
|
154
155
|
petsDir: '',
|
|
156
|
+
dshPetsDir: '',
|
|
155
157
|
})
|
|
156
158
|
|
|
157
159
|
expect(registry.entries.map(entry => entry.id)).toEqual([
|
|
@@ -184,7 +186,7 @@ describe('loadPetRegistry', () => {
|
|
|
184
186
|
mkdirSync(join(petsDir, 'broken'), { recursive: true })
|
|
185
187
|
writeFileSync(join(petsDir, 'broken', 'pet.json'), '{ not json', 'utf8')
|
|
186
188
|
|
|
187
|
-
const registry = loadPetRegistry({ packageRoot: root, petsDir })
|
|
189
|
+
const registry = loadPetRegistry({ packageRoot: root, petsDir, dshPetsDir: '' })
|
|
188
190
|
expect(registry.entries.map(entry => entry.id)).toEqual(['whale-girl', 'otter'])
|
|
189
191
|
expect(registry.defaultEntry().id).toBe('whale-girl')
|
|
190
192
|
expect(registry.warnings.some(warning => warning.includes('broken'))).toBe(true)
|
|
@@ -193,6 +195,7 @@ describe('loadPetRegistry', () => {
|
|
|
193
195
|
const overridden = loadPetRegistry({
|
|
194
196
|
packageRoot: root,
|
|
195
197
|
petsDir,
|
|
198
|
+
dshPetsDir: '',
|
|
196
199
|
extra: [{ id: 'whale-girl', displayName: '替换鲸', spritesheetPath: 'spritesheet.webp' }],
|
|
197
200
|
})
|
|
198
201
|
expect(overridden.byId('whale-girl')!.displayName).toBe('替换鲸')
|
|
@@ -211,6 +214,7 @@ describe('loadPetRegistry', () => {
|
|
|
211
214
|
const registry = loadPetRegistry({
|
|
212
215
|
packageRoot: root,
|
|
213
216
|
petsDir: '',
|
|
217
|
+
dshPetsDir: '',
|
|
214
218
|
extra: [{ id: 'otter', displayName: '水獭', spritesheetPath: 'pets/otter/spritesheet.webp' }],
|
|
215
219
|
})
|
|
216
220
|
const entry = registry.byId('otter')
|
|
@@ -236,7 +240,7 @@ describe('loadPetRegistry', () => {
|
|
|
236
240
|
writeFileSync(join(petsDir, 'aardvark', 'pet.json'), JSON.stringify({
|
|
237
241
|
id: 'aardvark', displayName: '土豚', spritesheetPath: 'spritesheet.webp',
|
|
238
242
|
}), 'utf8')
|
|
239
|
-
const registry = loadPetRegistry({ packageRoot: join(root, 'no-assets'), petsDir })
|
|
243
|
+
const registry = loadPetRegistry({ packageRoot: join(root, 'no-assets'), petsDir, dshPetsDir: '' })
|
|
240
244
|
expect(registry.defaultEntry().id).toBe('aardvark')
|
|
241
245
|
} finally {
|
|
242
246
|
rmSync(root, { recursive: true, force: true })
|
|
@@ -252,3 +256,212 @@ describe('codexPetsDir', () => {
|
|
|
252
256
|
expect(codexPetsDir({}, '/home/user')).toBe(join('/home/user', '.codex', 'pets'))
|
|
253
257
|
})
|
|
254
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
|
+
})
|