@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.
- package/README.i18n.yaml +2 -2
- package/README.md +129 -3
- package/README.zh.md +129 -3
- package/assets/decorations/whale/decoration.json +20 -0
- package/assets/decorations/whale/whale-frames.png +0 -0
- package/assets/whale/pet.json +169 -20
- package/assets/whale-refined/pet.json +17 -2
- package/contracts/pet-manifest-v2.schema.json +281 -0
- package/contracts/status-decoration-v1.schema.json +144 -0
- package/contracts/voice-pack-v1.schema.json +234 -0
- package/lib/client.js +581 -38
- package/lib/client.js.map +1 -1
- package/lib/index.js +1684 -59
- package/lib/live2d-vendor.js +995 -0
- package/lib/live2d-vendor.js.map +1 -0
- package/lib/types/chatter.d.ts +61 -3
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +71 -12
- 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 +14 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +24 -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 +120 -12
- 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 +14 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +14 -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 +192 -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/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- 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/contracts/status-decoration.d.ts +85 -0
- package/lib/types/contracts/status-decoration.d.ts.map +1 -0
- package/lib/types/contracts/status-decoration.js +21 -0
- package/lib/types/decoration.d.ts +39 -0
- package/lib/types/decoration.d.ts.map +1 -0
- package/lib/types/decoration.js +210 -0
- package/lib/types/event-projection.d.ts +8 -3
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +9 -4
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +2 -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 +112 -5
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +395 -38
- package/lib/types/routes.d.ts +39 -2
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +312 -13
- package/lib/types/service.d.ts +38 -1
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +46 -3
- package/lib/types/voice-pack.d.ts +98 -0
- package/lib/types/voice-pack.d.ts.map +1 -0
- package/lib/types/voice-pack.js +384 -0
- package/package.json +13 -10
- package/src/chatter.test.ts +89 -2
- package/src/chatter.ts +120 -14
- package/src/client/PetDockEntry.tsx +19 -11
- package/src/client/PetSettingsCard.tsx +57 -0
- package/src/client/PetSprite.test.tsx +265 -12
- package/src/client/PetSprite.tsx +165 -33
- package/src/client/index.ts +6 -0
- package/src/client/locales.ts +14 -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 +263 -0
- package/src/client/renderers/live2d.ts +231 -0
- package/src/client/renderers/registry.ts +58 -0
- package/src/client/settings-form.ts +8 -6
- package/src/client/settings-section.module.css +23 -0
- package/src/contracts/renderer.ts +54 -0
- package/src/contracts/status-decoration.ts +78 -0
- package/src/decoration.test.ts +178 -0
- package/src/decoration.ts +220 -0
- package/src/event-projection.ts +10 -5
- package/src/index.ts +2 -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 +438 -2
- package/src/registry.ts +470 -38
- package/src/routes.ts +328 -14
- package/src/service.ts +65 -3
- package/src/voice-pack.test.ts +216 -0
- 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
|
+
}
|
package/src/registry.test.ts
CHANGED
|
@@ -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
|
+
})
|