@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,251 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
KNOWN_LIVE2D,
|
|
6
|
+
KNOWN_SPRITE2D,
|
|
7
|
+
KNOWN_TOP_LEVEL,
|
|
8
|
+
PET_MANIFEST_V2,
|
|
9
|
+
PET_RENDERER_KINDS,
|
|
10
|
+
parsePetManifest,
|
|
11
|
+
safeManifestPath,
|
|
12
|
+
} from './manifest-v2.ts'
|
|
13
|
+
import { petPackageRoot } from './registry.ts'
|
|
14
|
+
|
|
15
|
+
/** Minimal valid v2 sprite2d manifest. */
|
|
16
|
+
function v2Sprite(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
17
|
+
return {
|
|
18
|
+
petManifestVersion: 2,
|
|
19
|
+
id: 'harbor-cat',
|
|
20
|
+
displayName: 'Harbor Cat',
|
|
21
|
+
license: 'CC0-1.0',
|
|
22
|
+
renderer: 'sprite2d',
|
|
23
|
+
sprite2d: { spritesheetPath: 'spritesheet.webp' },
|
|
24
|
+
...overrides,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Minimal valid v2 live2d manifest. */
|
|
29
|
+
function v2Live2d(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
30
|
+
return {
|
|
31
|
+
petManifestVersion: 2,
|
|
32
|
+
id: 'haru',
|
|
33
|
+
displayName: 'Haru',
|
|
34
|
+
license: 'Live2D-Sample-Model',
|
|
35
|
+
renderer: 'live2d',
|
|
36
|
+
live2d: {
|
|
37
|
+
model: 'haru.model3.json',
|
|
38
|
+
motions: { idle: 'Idle', thinking: 'Think', done: 'Happy' },
|
|
39
|
+
},
|
|
40
|
+
...overrides,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('safeManifestPath', () => {
|
|
45
|
+
it('accepts plain relative paths', () => {
|
|
46
|
+
expect(safeManifestPath('spritesheet.webp')).toBe('spritesheet.webp')
|
|
47
|
+
expect(safeManifestPath('motions/idle.motion3.json')).toBe('motions/idle.motion3.json')
|
|
48
|
+
})
|
|
49
|
+
it('rejects traversal, absolute, backslash and URL forms', () => {
|
|
50
|
+
expect(safeManifestPath('../evil.png')).toBeUndefined()
|
|
51
|
+
expect(safeManifestPath('/etc/passwd')).toBeUndefined()
|
|
52
|
+
expect(safeManifestPath('a\\b.png')).toBeUndefined()
|
|
53
|
+
expect(safeManifestPath('https://evil.example/x.png')).toBeUndefined()
|
|
54
|
+
expect(safeManifestPath('a/bad name.png')).toBeUndefined()
|
|
55
|
+
expect(safeManifestPath('.')).toBeUndefined()
|
|
56
|
+
expect(safeManifestPath('a/./b.png')).toBeUndefined()
|
|
57
|
+
expect(safeManifestPath('')).toBeUndefined()
|
|
58
|
+
expect(safeManifestPath(42)).toBeUndefined()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('parsePetManifest v1 compat read', () => {
|
|
63
|
+
it('maps a bare Codex manifest onto a sprite2d v2 shape with a migration hint', () => {
|
|
64
|
+
const result = parsePetManifest({ id: 'whale-girl', displayName: '鲸鱼娘' }, 'assets/whale')
|
|
65
|
+
expect(result.ok).toBe(true)
|
|
66
|
+
if (!result.ok) return
|
|
67
|
+
expect(result.migrated).toBe('v1-compat')
|
|
68
|
+
expect(result.manifest.renderer).toBe('sprite2d')
|
|
69
|
+
expect(result.manifest.sprite2d?.spritesheetPath).toBe('spritesheet.webp')
|
|
70
|
+
const warnings = result.diagnostics.filter(d => d.level === 'warning').map(d => d.message)
|
|
71
|
+
expect(warnings.some(m => m.includes('v1 compat read'))).toBe(true)
|
|
72
|
+
expect(warnings.some(m => m.includes('license'))).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('preserves v1 geometry, tracks, sequences and remarks verbatim', () => {
|
|
76
|
+
const v1 = {
|
|
77
|
+
id: 'whale-girl',
|
|
78
|
+
displayName: 'Whale Girl',
|
|
79
|
+
spritesheetPath: 'atlas/main.webp',
|
|
80
|
+
cell: { width: 192, height: 208 },
|
|
81
|
+
columns: 8,
|
|
82
|
+
frames: [6, 8, 8, 4, 5, 8, 6, 6, 6],
|
|
83
|
+
tracks: { idle: { durations: [400, 400] } },
|
|
84
|
+
sequences: { idle: ['idle', 'waving', 'idle', 'waiting', 'idle'] },
|
|
85
|
+
remarks: { tap: ['hi'] },
|
|
86
|
+
spriteVersionNumber: 2,
|
|
87
|
+
}
|
|
88
|
+
const result = parsePetManifest(v1, 'mem')
|
|
89
|
+
expect(result.ok).toBe(true)
|
|
90
|
+
if (!result.ok) return
|
|
91
|
+
expect(result.manifest.sprite2d).toMatchObject({
|
|
92
|
+
spritesheetPath: 'atlas/main.webp',
|
|
93
|
+
cell: { width: 192, height: 208 },
|
|
94
|
+
columns: 8,
|
|
95
|
+
frames: [6, 8, 8, 4, 5, 8, 6, 6, 6],
|
|
96
|
+
atlasRows: 11,
|
|
97
|
+
})
|
|
98
|
+
expect(result.manifest.sequences?.idle).toHaveLength(5)
|
|
99
|
+
expect(result.manifest.remarks).toEqual({ tap: ['hi'] })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('rejects a v1 manifest with an unsafe spritesheetPath', () => {
|
|
103
|
+
const result = parsePetManifest({ id: 'bad', spritesheetPath: '../evil.webp' }, 'mem')
|
|
104
|
+
expect(result.ok).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('rejects a v1 manifest without a usable id', () => {
|
|
108
|
+
expect(parsePetManifest({ displayName: 'No Id' }, 'mem').ok).toBe(false)
|
|
109
|
+
expect(parsePetManifest({ id: 'Bad_Id' }, 'mem').ok).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('parsePetManifest v2 fail-closed validation', () => {
|
|
114
|
+
it('accepts a minimal sprite2d manifest and defaults the renderer', () => {
|
|
115
|
+
const { renderer, ...rest } = v2Sprite()
|
|
116
|
+
const result = parsePetManifest(rest, 'mem')
|
|
117
|
+
expect(result.ok).toBe(true)
|
|
118
|
+
if (!result.ok) return
|
|
119
|
+
expect(result.migrated).toBeUndefined()
|
|
120
|
+
expect(result.manifest.renderer).toBe('sprite2d')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('accepts a full live2d manifest', () => {
|
|
124
|
+
const result = parsePetManifest(v2Live2d({
|
|
125
|
+
live2d: {
|
|
126
|
+
model: 'models/haru.model3.json',
|
|
127
|
+
scale: 1.2,
|
|
128
|
+
translate: { x: 0, y: -10 },
|
|
129
|
+
motions: { idle: 'Idle', tool: 'Work' },
|
|
130
|
+
expressions: { done: 'smile' },
|
|
131
|
+
hitAreas: ['Head', 'Body'],
|
|
132
|
+
lipSync: false,
|
|
133
|
+
},
|
|
134
|
+
}), 'mem')
|
|
135
|
+
expect(result.ok).toBe(true)
|
|
136
|
+
if (!result.ok) return
|
|
137
|
+
expect(result.manifest.live2d?.motions.idle).toBe('Idle')
|
|
138
|
+
expect(result.manifest.live2d?.scale).toBe(1.2)
|
|
139
|
+
expect(result.manifest.live2d?.hitAreas).toEqual(['Head', 'Body'])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('fails closed on unknown top-level fields', () => {
|
|
143
|
+
const result = parsePetManifest(v2Sprite({ surprise: true }), 'mem')
|
|
144
|
+
expect(result.ok).toBe(false)
|
|
145
|
+
if (result.ok) return
|
|
146
|
+
expect(result.diagnostics.some(d => d.message.includes('surprise'))).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('rejects unknown renderer kinds with a human-readable diagnostic', () => {
|
|
150
|
+
const result = parsePetManifest(v2Sprite({ renderer: 'spine' }), 'mem')
|
|
151
|
+
expect(result.ok).toBe(false)
|
|
152
|
+
if (result.ok) return
|
|
153
|
+
expect(result.diagnostics.some(d => d.message.includes(PET_RENDERER_KINDS[0]))).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('requires license and displayName in v2', () => {
|
|
157
|
+
const { license, ...noLicense } = v2Sprite()
|
|
158
|
+
expect(parsePetManifest(noLicense, 'mem').ok).toBe(false)
|
|
159
|
+
const { displayName, ...noName } = v2Sprite()
|
|
160
|
+
expect(parsePetManifest(noName, 'mem').ok).toBe(false)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('requires the conditional block matching the renderer', () => {
|
|
164
|
+
const { sprite2d, ...noBlock } = v2Sprite()
|
|
165
|
+
expect(parsePetManifest(noBlock, 'mem').ok).toBe(false)
|
|
166
|
+
const { live2d, ...noLiveBlock } = v2Live2d()
|
|
167
|
+
expect(parsePetManifest(noLiveBlock, 'mem').ok).toBe(false)
|
|
168
|
+
expect(parsePetManifest(v2Sprite({ live2d: { model: 'x.model3.json', motions: { idle: 'i' } } }), 'mem').ok).toBe(false)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('requires live2d.motions.idle and a .model3.json model path', () => {
|
|
172
|
+
const noIdle = v2Live2d()
|
|
173
|
+
;(noIdle.live2d as Record<string, unknown>).motions = { thinking: 'Think' }
|
|
174
|
+
expect(parsePetManifest(noIdle, 'mem').ok).toBe(false)
|
|
175
|
+
const badModel = v2Live2d()
|
|
176
|
+
;(badModel.live2d as Record<string, unknown>).model = 'haru.json'
|
|
177
|
+
expect(parsePetManifest(badModel, 'mem').ok).toBe(false)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('rejects unsupported manifest versions explicitly', () => {
|
|
181
|
+
expect(parsePetManifest(v2Sprite({ petManifestVersion: 3 }), 'mem').ok).toBe(false)
|
|
182
|
+
expect(parsePetManifest(v2Sprite({ petManifestVersion: '2' }), 'mem').ok).toBe(false)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('rejects non-object manifests without throwing', () => {
|
|
186
|
+
expect(parsePetManifest(null, 'mem').ok).toBe(false)
|
|
187
|
+
expect(parsePetManifest([1, 2], 'mem').ok).toBe(false)
|
|
188
|
+
expect(parsePetManifest('pet', 'mem').ok).toBe(false)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('keeps sequence content warn-and-drop while structure stays strict', () => {
|
|
192
|
+
const result = parsePetManifest(v2Sprite({
|
|
193
|
+
sequences: {
|
|
194
|
+
idle: ['idle', 'waving', 'idle', 'waiting', 'idle'],
|
|
195
|
+
bogus: ['idle', 'idle', 'idle', 'idle', 'idle'],
|
|
196
|
+
tool: ['running'],
|
|
197
|
+
},
|
|
198
|
+
}), 'mem')
|
|
199
|
+
expect(result.ok).toBe(true)
|
|
200
|
+
if (!result.ok) return
|
|
201
|
+
expect(result.manifest.sequences?.idle).toHaveLength(5)
|
|
202
|
+
expect(result.manifest.sequences && 'bogus' in result.manifest.sequences).toBe(false)
|
|
203
|
+
expect(result.manifest.sequences && 'tool' in result.manifest.sequences).toBe(false)
|
|
204
|
+
expect(result.diagnostics.filter(d => d.level === 'warning').length).toBe(2)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('rejects out-of-range live2d scale and malformed hitAreas', () => {
|
|
208
|
+
const badScale = v2Live2d()
|
|
209
|
+
;(badScale.live2d as Record<string, unknown>).scale = 0
|
|
210
|
+
expect(parsePetManifest(badScale, 'mem').ok).toBe(false)
|
|
211
|
+
const badHits = v2Live2d()
|
|
212
|
+
;(badHits.live2d as Record<string, unknown>).hitAreas = ['Head', '']
|
|
213
|
+
expect(parsePetManifest(badHits, 'mem').ok).toBe(false)
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('constants', () => {
|
|
218
|
+
it('locks the manifest version and renderer kinds', () => {
|
|
219
|
+
expect(PET_MANIFEST_V2).toBe(2)
|
|
220
|
+
expect(PET_RENDERER_KINDS).toEqual(['sprite2d', 'live2d'])
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('schema file drift lock', () => {
|
|
225
|
+
const schema = JSON.parse(readFileSync(
|
|
226
|
+
join(petPackageRoot(import.meta.url), 'contracts', 'pet-manifest-v2.schema.json'),
|
|
227
|
+
'utf8',
|
|
228
|
+
)) as {
|
|
229
|
+
required: string[]
|
|
230
|
+
properties: Record<string, { enum?: string[]; properties?: Record<string, unknown> }>
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
it('locks the schema file top-level fields to the validator allow-list', () => {
|
|
234
|
+
expect(new Set(Object.keys(schema.properties))).toEqual(KNOWN_TOP_LEVEL)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('locks the required set and the renderer enum', () => {
|
|
238
|
+
expect([...schema.required].sort()).toEqual(['displayName', 'id', 'license', 'petManifestVersion'])
|
|
239
|
+
expect(schema.properties.renderer?.enum).toEqual([...PET_RENDERER_KINDS])
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('locks the renderer block allow-lists', () => {
|
|
243
|
+
expect(new Set(Object.keys(schema.properties.sprite2d?.properties ?? {}))).toEqual(KNOWN_SPRITE2D)
|
|
244
|
+
expect(new Set(Object.keys(schema.properties.live2d?.properties ?? {}))).toEqual(KNOWN_LIVE2D)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('keeps the schema version const in sync', () => {
|
|
248
|
+
const version = (schema.properties.petManifestVersion as { const: number }).const
|
|
249
|
+
expect(version).toBe(PET_MANIFEST_V2)
|
|
250
|
+
})
|
|
251
|
+
})
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pet manifest v2 — parsing, fail-closed validation, and the v1 compat read
|
|
3
|
+
* (issue #623, milestone M1/M2). This module is the single entry point every
|
|
4
|
+
* manifest parse flows through: manifests without 'petManifestVersion' are
|
|
5
|
+
* compat-read as v1 sprite2d pets (with a migration hint diagnostic), v2
|
|
6
|
+
* manifests are validated fail-closed (unknown top-level keys, unknown
|
|
7
|
+
* renderer kinds, missing conditional blocks and unsafe paths all reject the
|
|
8
|
+
* manifest with human-readable diagnostics).
|
|
9
|
+
*
|
|
10
|
+
* Discipline split: STRUCTURE is fail-closed (types, key sets, paths,
|
|
11
|
+
* required fields); CONTENT stays warn-and-drop (a bad sequence entry drops
|
|
12
|
+
* the entry, never the pet) — matching the registry's long-standing
|
|
13
|
+
* never-throw philosophy. Deep normalization of sequences/remarks stays with
|
|
14
|
+
* the registry's existing normalizers; this module only gates structure.
|
|
15
|
+
*
|
|
16
|
+
* The JSON Schema twin lives at contracts/pet-manifest-v2.schema.json for
|
|
17
|
+
* documentation, the CLI, and external tooling; the hand-rolled validator
|
|
18
|
+
* here is authoritative (the repository ships no schema-validator runtime).
|
|
19
|
+
*
|
|
20
|
+
* This file is imported directly by scripts/ (dsh-pet-migrate-v2) under
|
|
21
|
+
* node's strip-only TypeScript mode: keep it erasable-syntax-only (no
|
|
22
|
+
* parameter properties, enums, or namespaces).
|
|
23
|
+
* @module @linxin666/dsh-pet/manifest-v2
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { isAbsolute } from 'node:path'
|
|
27
|
+
import type { ActivityPhase, PetAnimation } from './state.ts'
|
|
28
|
+
|
|
29
|
+
/** Schema version this module validates. */
|
|
30
|
+
export const PET_MANIFEST_V2 = 2 as const
|
|
31
|
+
|
|
32
|
+
/** Renderer kinds the pet center knows how to dispatch (M1 §2). */
|
|
33
|
+
export const PET_RENDERER_KINDS = ['sprite2d', 'live2d'] as const
|
|
34
|
+
export type PetRendererKind = (typeof PET_RENDERER_KINDS)[number]
|
|
35
|
+
|
|
36
|
+
/** The seven ActivityPhase semantics (pet-center owned; M1 §1). */
|
|
37
|
+
export const PET_ACTIVITY_PHASES: readonly ActivityPhase[] = [
|
|
38
|
+
'idle', 'waiting', 'thinking', 'tool', 'review', 'done', 'failed',
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
/** sprite2d renderer block (v2 nested shape of the v1 atlas contract). */
|
|
42
|
+
export interface PetManifestSprite2d {
|
|
43
|
+
/** Atlas path relative to the manifest directory (safe segments only). */
|
|
44
|
+
spritesheetPath: string
|
|
45
|
+
/** Atlas cell size in px; defaults resolved by the registry. */
|
|
46
|
+
cell?: { width?: number; height?: number }
|
|
47
|
+
/** Columns per row; defaults to 8. */
|
|
48
|
+
columns?: number
|
|
49
|
+
/** Total atlas rows (9 classic; 11 for v2 look-row atlases). */
|
|
50
|
+
atlasRows?: number
|
|
51
|
+
/** Per-row used frame counts. */
|
|
52
|
+
frames?: number[]
|
|
53
|
+
/** Per-track rhythm overrides (durations/loop/fallback), v1 shape. */
|
|
54
|
+
tracks?: Record<string, unknown>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** live2d renderer block (Cubism Core is always user-supplied; M1 §0). */
|
|
58
|
+
export interface PetManifestLive2d {
|
|
59
|
+
/** Path of the .model3.json relative to the manifest directory. */
|
|
60
|
+
model: string
|
|
61
|
+
/** Model scale, (0, 10]; defaults to 1. */
|
|
62
|
+
scale?: number
|
|
63
|
+
/** Model offset in canvas space. */
|
|
64
|
+
translate?: { x?: number; y?: number }
|
|
65
|
+
/** ActivityPhase -> motion group name; idle is required, unmapped phases fall back to idle. */
|
|
66
|
+
motions: Partial<Record<ActivityPhase, string>> & { idle: string }
|
|
67
|
+
/** Optional ActivityPhase -> expression name layered over the motion. */
|
|
68
|
+
expressions?: Partial<Record<ActivityPhase, string>>
|
|
69
|
+
/** Hit area names triggering pet.interact; defaults to every model HitArea. */
|
|
70
|
+
hitAreas?: string[]
|
|
71
|
+
/** Whisper-paced lip sync (post-M3). */
|
|
72
|
+
lipSync?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Normalized v2 manifest the registry consumes. */
|
|
76
|
+
export interface PetManifestV2 {
|
|
77
|
+
petManifestVersion: typeof PET_MANIFEST_V2
|
|
78
|
+
id: string
|
|
79
|
+
displayName: string
|
|
80
|
+
description?: string
|
|
81
|
+
version?: string
|
|
82
|
+
author?: string
|
|
83
|
+
/** Required by v2; v1 compat reads may lack it (warning, not rejection). */
|
|
84
|
+
license?: string
|
|
85
|
+
homepage?: string
|
|
86
|
+
renderer: PetRendererKind
|
|
87
|
+
sprite2d?: PetManifestSprite2d
|
|
88
|
+
live2d?: PetManifestLive2d
|
|
89
|
+
sequences?: Partial<Record<ActivityPhase, PetAnimation[]>>
|
|
90
|
+
/** Pass-through for the registry's remarks normalizer (v1 shape). */
|
|
91
|
+
remarks?: unknown
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One structured diagnostic emitted while parsing a manifest. */
|
|
95
|
+
export interface PetManifestDiagnostic {
|
|
96
|
+
level: 'error' | 'warning'
|
|
97
|
+
message: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Parse outcome: a usable manifest plus diagnostics, or rejection. */
|
|
101
|
+
export type PetManifestParse =
|
|
102
|
+
| { ok: true; manifest: PetManifestV2; migrated: 'v1-compat' | undefined; diagnostics: PetManifestDiagnostic[] }
|
|
103
|
+
| { ok: false; diagnostics: PetManifestDiagnostic[] }
|
|
104
|
+
|
|
105
|
+
const PET_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/
|
|
106
|
+
const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/
|
|
107
|
+
const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/
|
|
108
|
+
/**
|
|
109
|
+
* Field allow-lists mirroring contracts/pet-manifest-v2.schema.json. Exported
|
|
110
|
+
* so the drift test can lock the schema file and this validator together;
|
|
111
|
+
* the CLI reuses parsePetManifest instead of these.
|
|
112
|
+
*/
|
|
113
|
+
export const KNOWN_TOP_LEVEL = new Set([
|
|
114
|
+
'$schema', 'petManifestVersion', 'id', 'displayName', 'description', 'version',
|
|
115
|
+
'author', 'license', 'homepage', 'renderer', 'sprite2d', 'live2d', 'sequences', 'remarks',
|
|
116
|
+
])
|
|
117
|
+
/** sprite2d block field allow-list (drift-locked to the schema file). */
|
|
118
|
+
export const KNOWN_SPRITE2D = new Set(['spritesheetPath', 'cell', 'columns', 'atlasRows', 'frames', 'tracks'])
|
|
119
|
+
/** live2d block field allow-list (drift-locked to the schema file). */
|
|
120
|
+
export const KNOWN_LIVE2D = new Set(['model', 'scale', 'translate', 'motions', 'expressions', 'hitAreas', 'lipSync'])
|
|
121
|
+
|
|
122
|
+
class Diagnostics {
|
|
123
|
+
readonly list: PetManifestDiagnostic[] = []
|
|
124
|
+
private readonly source: string
|
|
125
|
+
constructor(source: string) { this.source = source }
|
|
126
|
+
error(message: string): void { this.list.push({ level: 'error', message: this.source + ': ' + message }) }
|
|
127
|
+
warn(message: string): void { this.list.push({ level: 'warning', message: this.source + ': ' + message }) }
|
|
128
|
+
get hasErrors(): boolean { return this.list.some(d => d.level === 'error') }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
132
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function unknownKeys(source: Record<string, unknown>, known: Set<string>): string[] {
|
|
136
|
+
return Object.keys(source).filter(key => !known.has(key))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Validate a manifest-relative asset path: no absolute paths, no backslashes,
|
|
141
|
+
* no traversal, plain safe segments only. Returns the normalized path.
|
|
142
|
+
*/
|
|
143
|
+
export function safeManifestPath(raw: unknown): string | undefined {
|
|
144
|
+
if (typeof raw !== 'string' || raw.trim() === '') return undefined
|
|
145
|
+
const value = raw.trim()
|
|
146
|
+
if (isAbsolute(value) || value.includes('\\') || /^[a-z][a-z0-9+.-]*:/i.test(value)) return undefined
|
|
147
|
+
const segments = value.split('/').filter(segment => segment !== '')
|
|
148
|
+
if (segments.length === 0) return undefined
|
|
149
|
+
if (segments.some(segment => segment === '.' || segment === '..' || !PATH_SEGMENT_PATTERN.test(segment))) return undefined
|
|
150
|
+
return segments.join('/')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseStringBlock(record: Record<string, unknown>, key: string, diag: Diagnostics, required: boolean): string | undefined {
|
|
154
|
+
const value = record[key]
|
|
155
|
+
if (value === undefined) {
|
|
156
|
+
if (required) diag.error('missing required field ' + JSON.stringify(key))
|
|
157
|
+
return undefined
|
|
158
|
+
}
|
|
159
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
160
|
+
diag.error('field ' + JSON.stringify(key) + ' must be a non-empty string')
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
return value.trim()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Validate the phase-keyed string map shape shared by motions/expressions. */
|
|
167
|
+
function parsePhaseStringMap(
|
|
168
|
+
raw: unknown, field: string, diag: Diagnostics,
|
|
169
|
+
): Partial<Record<ActivityPhase, string>> | undefined {
|
|
170
|
+
if (raw === undefined) return undefined
|
|
171
|
+
if (!isRecord(raw)) {
|
|
172
|
+
diag.error('field ' + JSON.stringify(field) + ' must be an object keyed by activity phase')
|
|
173
|
+
return undefined
|
|
174
|
+
}
|
|
175
|
+
const result: Partial<Record<ActivityPhase, string>> = {}
|
|
176
|
+
for (const [phase, value] of Object.entries(raw)) {
|
|
177
|
+
if (!PET_ACTIVITY_PHASES.includes(phase as ActivityPhase)) {
|
|
178
|
+
diag.error(field + ': unknown activity phase ' + JSON.stringify(phase))
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
182
|
+
diag.error(field + '.' + phase + ' must be a non-empty string')
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
result[phase as ActivityPhase] = value.trim()
|
|
186
|
+
}
|
|
187
|
+
return result
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Structural gate for sequences: content stays warn-and-drop (registry's job). */
|
|
191
|
+
function parseSequences(
|
|
192
|
+
raw: unknown, diag: Diagnostics,
|
|
193
|
+
): Partial<Record<ActivityPhase, PetAnimation[]>> | undefined {
|
|
194
|
+
if (raw === undefined) return undefined
|
|
195
|
+
if (!isRecord(raw)) {
|
|
196
|
+
diag.warn('sequences must be an object keyed by activity phase; ignoring')
|
|
197
|
+
return undefined
|
|
198
|
+
}
|
|
199
|
+
const sequences: Partial<Record<ActivityPhase, PetAnimation[]>> = {}
|
|
200
|
+
for (const [phase, value] of Object.entries(raw)) {
|
|
201
|
+
if (!PET_ACTIVITY_PHASES.includes(phase as ActivityPhase)) {
|
|
202
|
+
diag.warn('sequences: unknown activity phase ' + JSON.stringify(phase) + '; entry dropped')
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
if (!Array.isArray(value) || value.length < 5 || value.some(item => typeof item !== 'string')) {
|
|
206
|
+
diag.warn('sequences.' + phase + ' must be an array of at least 5 animation names; entry dropped')
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
sequences[phase as ActivityPhase] = value as PetAnimation[]
|
|
210
|
+
}
|
|
211
|
+
return Object.keys(sequences).length === 0 ? undefined : sequences
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseSprite2dBlock(raw: unknown, diag: Diagnostics): PetManifestSprite2d | undefined {
|
|
215
|
+
if (!isRecord(raw)) {
|
|
216
|
+
diag.error('renderer sprite2d requires a "sprite2d" block object')
|
|
217
|
+
return undefined
|
|
218
|
+
}
|
|
219
|
+
const extra = unknownKeys(raw, KNOWN_SPRITE2D)
|
|
220
|
+
if (extra.length > 0) diag.error('sprite2d: unknown field(s) ' + extra.map(k => JSON.stringify(k)).join(', '))
|
|
221
|
+
const spritesheetPath = safeManifestPath(raw.spritesheetPath)
|
|
222
|
+
if (spritesheetPath === undefined) {
|
|
223
|
+
diag.error('sprite2d.spritesheetPath must be a safe manifest-relative path')
|
|
224
|
+
}
|
|
225
|
+
const block: PetManifestSprite2d = { spritesheetPath: spritesheetPath ?? '' }
|
|
226
|
+
if (raw.cell !== undefined) {
|
|
227
|
+
if (!isRecord(raw.cell)) diag.error('sprite2d.cell must be an object { width?, height? }')
|
|
228
|
+
else block.cell = raw.cell as { width?: number; height?: number }
|
|
229
|
+
}
|
|
230
|
+
if (raw.columns !== undefined) {
|
|
231
|
+
if (typeof raw.columns !== 'number' || !Number.isInteger(raw.columns) || raw.columns < 1) diag.error('sprite2d.columns must be a positive integer')
|
|
232
|
+
else block.columns = raw.columns
|
|
233
|
+
}
|
|
234
|
+
if (raw.atlasRows !== undefined) {
|
|
235
|
+
if (typeof raw.atlasRows !== 'number' || !Number.isInteger(raw.atlasRows) || raw.atlasRows < 1) diag.error('sprite2d.atlasRows must be a positive integer')
|
|
236
|
+
else block.atlasRows = raw.atlasRows
|
|
237
|
+
}
|
|
238
|
+
if (raw.frames !== undefined) {
|
|
239
|
+
if (!Array.isArray(raw.frames) || raw.frames.some(v => typeof v !== 'number' || !Number.isInteger(v) || v < 0)) {
|
|
240
|
+
diag.error('sprite2d.frames must be an array of non-negative integers')
|
|
241
|
+
} else block.frames = raw.frames as number[]
|
|
242
|
+
}
|
|
243
|
+
if (raw.tracks !== undefined) {
|
|
244
|
+
if (!isRecord(raw.tracks)) diag.error('sprite2d.tracks must be an object keyed by animation')
|
|
245
|
+
else block.tracks = raw.tracks
|
|
246
|
+
}
|
|
247
|
+
return diag.hasErrors ? undefined : block
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function parseLive2dBlock(raw: unknown, diag: Diagnostics): PetManifestLive2d | undefined {
|
|
251
|
+
if (!isRecord(raw)) {
|
|
252
|
+
diag.error('renderer live2d requires a "live2d" block object')
|
|
253
|
+
return undefined
|
|
254
|
+
}
|
|
255
|
+
const extra = unknownKeys(raw, KNOWN_LIVE2D)
|
|
256
|
+
if (extra.length > 0) diag.error('live2d: unknown field(s) ' + extra.map(k => JSON.stringify(k)).join(', '))
|
|
257
|
+
const model = safeManifestPath(raw.model)
|
|
258
|
+
if (model === undefined) {
|
|
259
|
+
diag.error('live2d.model must be a safe manifest-relative path to a .model3.json')
|
|
260
|
+
} else if (!model.endsWith('.model3.json')) {
|
|
261
|
+
diag.error('live2d.model must point at a .model3.json file')
|
|
262
|
+
}
|
|
263
|
+
const motions = parsePhaseStringMap(raw.motions, 'live2d.motions', diag)
|
|
264
|
+
if (raw.motions === undefined) diag.error('live2d.motions is required (at least an "idle" group)')
|
|
265
|
+
else if (motions !== undefined && motions.idle === undefined) diag.error('live2d.motions.idle is required (unmapped phases fall back to it)')
|
|
266
|
+
const block: PetManifestLive2d = {
|
|
267
|
+
model: model ?? '',
|
|
268
|
+
motions: (motions ?? { idle: '' }) as PetManifestLive2d['motions'],
|
|
269
|
+
}
|
|
270
|
+
if (raw.scale !== undefined) {
|
|
271
|
+
if (typeof raw.scale !== 'number' || !Number.isFinite(raw.scale) || raw.scale <= 0 || raw.scale > 10) {
|
|
272
|
+
diag.error('live2d.scale must be a number in (0, 10]')
|
|
273
|
+
} else block.scale = raw.scale
|
|
274
|
+
}
|
|
275
|
+
if (raw.translate !== undefined) {
|
|
276
|
+
if (!isRecord(raw.translate)
|
|
277
|
+
|| (raw.translate.x !== undefined && typeof raw.translate.x !== 'number')
|
|
278
|
+
|| (raw.translate.y !== undefined && typeof raw.translate.y !== 'number')) {
|
|
279
|
+
diag.error('live2d.translate must be an object { x?: number, y?: number }')
|
|
280
|
+
} else block.translate = raw.translate as { x?: number; y?: number }
|
|
281
|
+
}
|
|
282
|
+
const expressions = parsePhaseStringMap(raw.expressions, 'live2d.expressions', diag)
|
|
283
|
+
if (expressions !== undefined) block.expressions = expressions
|
|
284
|
+
if (raw.hitAreas !== undefined) {
|
|
285
|
+
if (!Array.isArray(raw.hitAreas) || raw.hitAreas.some(v => typeof v !== 'string' || v.trim() === '')) {
|
|
286
|
+
diag.error('live2d.hitAreas must be an array of non-empty strings')
|
|
287
|
+
} else block.hitAreas = raw.hitAreas as string[]
|
|
288
|
+
}
|
|
289
|
+
if (raw.lipSync !== undefined) {
|
|
290
|
+
if (typeof raw.lipSync !== 'boolean') diag.error('live2d.lipSync must be a boolean')
|
|
291
|
+
else block.lipSync = raw.lipSync
|
|
292
|
+
}
|
|
293
|
+
return diag.hasErrors ? undefined : block
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** v1 compat read: map the legacy flat manifest onto the v2 sprite2d shape. */
|
|
297
|
+
function compatV1(source: Record<string, unknown>, diag: Diagnostics): PetManifestV2 | undefined {
|
|
298
|
+
const id = parseStringBlock(source, 'id', diag, true)
|
|
299
|
+
if (id !== undefined && !PET_ID_PATTERN.test(id)) {
|
|
300
|
+
diag.error('id ' + JSON.stringify(id) + ' is not a lowercase kebab id')
|
|
301
|
+
}
|
|
302
|
+
const displayName = typeof source.displayName === 'string' && source.displayName.trim() !== ''
|
|
303
|
+
? source.displayName.trim()
|
|
304
|
+
: id
|
|
305
|
+
const spritesheetRaw = source.spritesheetPath === undefined ? 'spritesheet.webp' : source.spritesheetPath
|
|
306
|
+
const spritesheetPath = safeManifestPath(spritesheetRaw)
|
|
307
|
+
if (spritesheetPath === undefined) {
|
|
308
|
+
diag.error('spritesheetPath ' + JSON.stringify(String(source.spritesheetPath)) + ' is not a safe relative path')
|
|
309
|
+
}
|
|
310
|
+
if (source.license === undefined) {
|
|
311
|
+
diag.warn('v1 compat read: no license field; run scripts/dsh-pet-migrate-v2 to migrate this pet')
|
|
312
|
+
}
|
|
313
|
+
const sprite2d: PetManifestSprite2d = { spritesheetPath: spritesheetPath ?? 'spritesheet.webp' }
|
|
314
|
+
if (isRecord(source.cell)) sprite2d.cell = source.cell as { width?: number; height?: number }
|
|
315
|
+
if (typeof source.columns === 'number') sprite2d.columns = source.columns
|
|
316
|
+
if (Array.isArray(source.frames)) sprite2d.frames = source.frames as number[]
|
|
317
|
+
if (isRecord(source.tracks)) sprite2d.tracks = source.tracks
|
|
318
|
+
// v2 spritesheet atlases (spriteVersionNumber 2) carry 11 rows: 9 + 2 look rows.
|
|
319
|
+
if (source.spriteVersionNumber === 2) sprite2d.atlasRows = 11
|
|
320
|
+
const manifest: PetManifestV2 = {
|
|
321
|
+
petManifestVersion: PET_MANIFEST_V2,
|
|
322
|
+
id: id ?? '',
|
|
323
|
+
displayName: displayName ?? '',
|
|
324
|
+
renderer: 'sprite2d',
|
|
325
|
+
sprite2d,
|
|
326
|
+
}
|
|
327
|
+
if (typeof source.description === 'string' && source.description.trim() !== '') manifest.description = source.description.trim()
|
|
328
|
+
if (typeof source.license === 'string' && source.license.trim() !== '') manifest.license = source.license.trim()
|
|
329
|
+
const sequences = parseSequences(source.sequences, diag)
|
|
330
|
+
if (sequences !== undefined) manifest.sequences = sequences
|
|
331
|
+
if (source.remarks !== undefined) manifest.remarks = source.remarks
|
|
332
|
+
return diag.hasErrors ? undefined : manifest
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Strict v2 validation (fail-closed on structure). */
|
|
336
|
+
function parseV2(source: Record<string, unknown>, diag: Diagnostics): PetManifestV2 | undefined {
|
|
337
|
+
const extra = unknownKeys(source, KNOWN_TOP_LEVEL)
|
|
338
|
+
if (extra.length > 0) diag.error('unknown top-level field(s) ' + extra.map(k => JSON.stringify(k)).join(', '))
|
|
339
|
+
if (source.petManifestVersion !== PET_MANIFEST_V2) {
|
|
340
|
+
diag.error('petManifestVersion must be 2 (got ' + JSON.stringify(source.petManifestVersion) + ')')
|
|
341
|
+
}
|
|
342
|
+
const id = parseStringBlock(source, 'id', diag, true)
|
|
343
|
+
if (id !== undefined && (!PET_ID_PATTERN.test(id) || id.length > 64)) {
|
|
344
|
+
diag.error('id ' + JSON.stringify(id) + ' must be a lowercase kebab id of at most 64 chars')
|
|
345
|
+
}
|
|
346
|
+
const displayName = parseStringBlock(source, 'displayName', diag, true)
|
|
347
|
+
const license = parseStringBlock(source, 'license', diag, true)
|
|
348
|
+
const rendererRaw = source.renderer === undefined ? 'sprite2d' : source.renderer
|
|
349
|
+
if (!PET_RENDERER_KINDS.includes(rendererRaw as PetRendererKind)) {
|
|
350
|
+
diag.error('unknown renderer ' + JSON.stringify(rendererRaw) + '; expected one of ' + PET_RENDERER_KINDS.join(', '))
|
|
351
|
+
}
|
|
352
|
+
const renderer = rendererRaw as PetRendererKind
|
|
353
|
+
const manifest: PetManifestV2 = {
|
|
354
|
+
petManifestVersion: PET_MANIFEST_V2,
|
|
355
|
+
id: id ?? '',
|
|
356
|
+
displayName: displayName ?? '',
|
|
357
|
+
renderer,
|
|
358
|
+
}
|
|
359
|
+
if (license !== undefined) manifest.license = license
|
|
360
|
+
if (source.description !== undefined) {
|
|
361
|
+
if (typeof source.description !== 'string' || source.description.length > 500) diag.error('description must be a string of at most 500 chars')
|
|
362
|
+
else manifest.description = source.description
|
|
363
|
+
}
|
|
364
|
+
if (source.version !== undefined) {
|
|
365
|
+
if (typeof source.version !== 'string' || !SEMVER_PATTERN.test(source.version)) diag.error('version must be a semver string (x.y.z)')
|
|
366
|
+
else manifest.version = source.version
|
|
367
|
+
}
|
|
368
|
+
if (source.author !== undefined) {
|
|
369
|
+
if (typeof source.author !== 'string' || source.author.length > 128) diag.error('author must be a string of at most 128 chars')
|
|
370
|
+
else manifest.author = source.author
|
|
371
|
+
}
|
|
372
|
+
if (source.homepage !== undefined) {
|
|
373
|
+
if (typeof source.homepage !== 'string') diag.error('homepage must be a string URL')
|
|
374
|
+
else manifest.homepage = source.homepage
|
|
375
|
+
}
|
|
376
|
+
if (renderer === 'sprite2d') {
|
|
377
|
+
const block = parseSprite2dBlock(source.sprite2d, diag)
|
|
378
|
+
if (block !== undefined) manifest.sprite2d = block
|
|
379
|
+
if (source.live2d !== undefined) diag.error('renderer sprite2d must not declare a live2d block')
|
|
380
|
+
} else if (renderer === 'live2d') {
|
|
381
|
+
const block = parseLive2dBlock(source.live2d, diag)
|
|
382
|
+
if (block !== undefined) manifest.live2d = block
|
|
383
|
+
if (source.sprite2d !== undefined) diag.error('renderer live2d must not declare a sprite2d block')
|
|
384
|
+
}
|
|
385
|
+
const sequences = parseSequences(source.sequences, diag)
|
|
386
|
+
if (sequences !== undefined) manifest.sequences = sequences
|
|
387
|
+
if (source.remarks !== undefined && !isRecord(source.remarks)) diag.error('remarks must be an object of remark pools')
|
|
388
|
+
else if (source.remarks !== undefined) manifest.remarks = source.remarks
|
|
389
|
+
return diag.hasErrors ? undefined : manifest
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Parse one pet manifest: v1 (no petManifestVersion) is compat-read as a
|
|
394
|
+
* sprite2d pet with a migration hint; v2 is validated fail-closed. The parse
|
|
395
|
+
* never throws — every failure comes back as structured diagnostics.
|
|
396
|
+
* @param raw - the parsed pet.json value.
|
|
397
|
+
* @param sourceLabel - human-readable origin for diagnostics (dir or file).
|
|
398
|
+
*/
|
|
399
|
+
export function parsePetManifest(raw: unknown, sourceLabel: string): PetManifestParse {
|
|
400
|
+
const diag = new Diagnostics(sourceLabel)
|
|
401
|
+
if (!isRecord(raw)) {
|
|
402
|
+
diag.error('manifest is not an object')
|
|
403
|
+
return { ok: false, diagnostics: diag.list }
|
|
404
|
+
}
|
|
405
|
+
if (raw.petManifestVersion === undefined) {
|
|
406
|
+
const manifest = compatV1(raw, diag)
|
|
407
|
+
if (manifest === undefined) return { ok: false, diagnostics: diag.list }
|
|
408
|
+
diag.warn('v1 compat read: manifest treated as renderer "sprite2d"; run scripts/dsh-pet-migrate-v2 to migrate')
|
|
409
|
+
return { ok: true, manifest, migrated: 'v1-compat', diagnostics: diag.list }
|
|
410
|
+
}
|
|
411
|
+
const manifest = parseV2(raw, diag)
|
|
412
|
+
if (manifest === undefined) return { ok: false, diagnostics: diag.list }
|
|
413
|
+
return { ok: true, manifest, migrated: undefined, diagnostics: diag.list }
|
|
414
|
+
}
|