@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.
Files changed (90) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +66 -3
  3. package/README.zh.md +66 -3
  4. package/assets/whale/pet.json +169 -20
  5. package/assets/whale-refined/pet.json +17 -2
  6. package/lib/client.js +438 -21
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +752 -36
  9. package/lib/live2d-vendor.js +995 -0
  10. package/lib/live2d-vendor.js.map +1 -0
  11. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  12. package/lib/types/client/PetDockEntry.js +2 -1
  13. package/lib/types/client/PetSettingsCard.d.ts +10 -0
  14. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  15. package/lib/types/client/PetSettingsCard.js +22 -1
  16. package/lib/types/client/PetSprite.d.ts +7 -1
  17. package/lib/types/client/PetSprite.d.ts.map +1 -1
  18. package/lib/types/client/PetSprite.js +17 -8
  19. package/lib/types/client/index.d.ts.map +1 -1
  20. package/lib/types/client/index.js +5 -0
  21. package/lib/types/client/locales.d.ts +10 -0
  22. package/lib/types/client/locales.d.ts.map +1 -1
  23. package/lib/types/client/locales.js +10 -0
  24. package/lib/types/client/phase-stream.d.ts +22 -0
  25. package/lib/types/client/phase-stream.d.ts.map +1 -0
  26. package/lib/types/client/phase-stream.js +28 -0
  27. package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
  28. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
  29. package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
  30. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
  31. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
  32. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
  33. package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
  34. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
  35. package/lib/types/client/renderers/live2d/runtime.js +63 -0
  36. package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
  37. package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
  38. package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
  39. package/lib/types/client/renderers/live2d.d.ts +52 -0
  40. package/lib/types/client/renderers/live2d.d.ts.map +1 -0
  41. package/lib/types/client/renderers/live2d.js +180 -0
  42. package/lib/types/client/renderers/registry.d.ts +32 -0
  43. package/lib/types/client/renderers/registry.d.ts.map +1 -0
  44. package/lib/types/client/renderers/registry.js +49 -0
  45. package/lib/types/contracts/renderer.d.ts +50 -0
  46. package/lib/types/contracts/renderer.d.ts.map +1 -0
  47. package/lib/types/contracts/renderer.js +15 -0
  48. package/lib/types/manifest-v2.d.ts +129 -0
  49. package/lib/types/manifest-v2.d.ts.map +1 -0
  50. package/lib/types/manifest-v2.js +387 -0
  51. package/lib/types/model3.d.ts +26 -0
  52. package/lib/types/model3.d.ts.map +1 -0
  53. package/lib/types/model3.js +96 -0
  54. package/lib/types/registry.d.ts +57 -3
  55. package/lib/types/registry.d.ts.map +1 -1
  56. package/lib/types/registry.js +208 -33
  57. package/lib/types/routes.d.ts +39 -2
  58. package/lib/types/routes.d.ts.map +1 -1
  59. package/lib/types/routes.js +183 -12
  60. package/lib/types/service.d.ts +5 -1
  61. package/lib/types/service.d.ts.map +1 -1
  62. package/lib/types/service.js +4 -0
  63. package/package.json +3 -1
  64. package/src/client/PetDockEntry.tsx +19 -11
  65. package/src/client/PetSettingsCard.tsx +39 -0
  66. package/src/client/PetSprite.test.tsx +11 -0
  67. package/src/client/PetSprite.tsx +24 -9
  68. package/src/client/index.ts +6 -0
  69. package/src/client/locales.ts +10 -0
  70. package/src/client/phase-stream.test.ts +76 -0
  71. package/src/client/phase-stream.ts +39 -0
  72. package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
  73. package/src/client/renderers/PetRendererSwitch.tsx +48 -0
  74. package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
  75. package/src/client/renderers/live2d/runtime.test.ts +61 -0
  76. package/src/client/renderers/live2d/runtime.ts +119 -0
  77. package/src/client/renderers/live2d/vendor-entry.ts +14 -0
  78. package/src/client/renderers/live2d.test.ts +242 -0
  79. package/src/client/renderers/live2d.ts +218 -0
  80. package/src/client/renderers/registry.ts +58 -0
  81. package/src/client/settings-section.module.css +23 -0
  82. package/src/contracts/renderer.ts +54 -0
  83. package/src/manifest-v2.test.ts +251 -0
  84. package/src/manifest-v2.ts +414 -0
  85. package/src/model3.test.ts +79 -0
  86. package/src/model3.ts +91 -0
  87. package/src/registry.test.ts +215 -2
  88. package/src/registry.ts +246 -34
  89. package/src/routes.ts +202 -13
  90. 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
+ }