@linxin666/dsh-pet 0.2.3 → 0.2.5

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 (87) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +65 -2
  3. package/README.zh.md +65 -2
  4. package/assets/decorations/whale/decoration.json +20 -0
  5. package/assets/decorations/whale/whale-frames.png +0 -0
  6. package/contracts/pet-manifest-v2.schema.json +281 -0
  7. package/contracts/status-decoration-v1.schema.json +144 -0
  8. package/contracts/voice-pack-v1.schema.json +234 -0
  9. package/lib/client.js +251 -43
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +1139 -100
  12. package/lib/types/access.d.ts +16 -0
  13. package/lib/types/access.d.ts.map +1 -0
  14. package/lib/types/access.js +20 -0
  15. package/lib/types/chatter.d.ts +61 -3
  16. package/lib/types/chatter.d.ts.map +1 -1
  17. package/lib/types/chatter.js +71 -12
  18. package/lib/types/client/PetSettingsCard.d.ts +4 -0
  19. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PetSettingsCard.js +11 -3
  21. package/lib/types/client/PetSprite.d.ts.map +1 -1
  22. package/lib/types/client/PetSprite.js +111 -4
  23. package/lib/types/client/locales.d.ts +4 -0
  24. package/lib/types/client/locales.d.ts.map +1 -1
  25. package/lib/types/client/locales.js +4 -0
  26. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -1
  27. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +1 -0
  28. package/lib/types/client/renderers/live2d/runtime.d.ts +13 -1
  29. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -1
  30. package/lib/types/client/renderers/live2d.d.ts.map +1 -1
  31. package/lib/types/client/renderers/live2d.js +133 -25
  32. package/lib/types/client/settings-form.d.ts.map +1 -1
  33. package/lib/types/client/settings-form.js +9 -6
  34. package/lib/types/contracts/status-decoration.d.ts +85 -0
  35. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  36. package/lib/types/contracts/status-decoration.js +21 -0
  37. package/lib/types/decoration.d.ts +39 -0
  38. package/lib/types/decoration.d.ts.map +1 -0
  39. package/lib/types/decoration.js +210 -0
  40. package/lib/types/event-projection.d.ts +8 -3
  41. package/lib/types/event-projection.d.ts.map +1 -1
  42. package/lib/types/event-projection.js +9 -4
  43. package/lib/types/image-dimensions.d.ts +26 -0
  44. package/lib/types/image-dimensions.d.ts.map +1 -0
  45. package/lib/types/image-dimensions.js +77 -0
  46. package/lib/types/index.d.ts +2 -0
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +3 -1
  49. package/lib/types/registry.d.ts +55 -2
  50. package/lib/types/registry.d.ts.map +1 -1
  51. package/lib/types/registry.js +240 -16
  52. package/lib/types/routes.d.ts +6 -1
  53. package/lib/types/routes.d.ts.map +1 -1
  54. package/lib/types/routes.js +163 -32
  55. package/lib/types/service.d.ts +33 -0
  56. package/lib/types/service.d.ts.map +1 -1
  57. package/lib/types/service.js +42 -3
  58. package/lib/types/voice-pack.d.ts +98 -0
  59. package/lib/types/voice-pack.d.ts.map +1 -0
  60. package/lib/types/voice-pack.js +384 -0
  61. package/package.json +11 -10
  62. package/src/access.ts +40 -0
  63. package/src/chatter.test.ts +89 -2
  64. package/src/chatter.ts +120 -14
  65. package/src/client/PetSettingsCard.tsx +26 -2
  66. package/src/client/PetSprite.test.tsx +325 -12
  67. package/src/client/PetSprite.tsx +150 -25
  68. package/src/client/locales.ts +4 -0
  69. package/src/client/renderers/live2d/Live2dVisualMount.test.tsx +72 -0
  70. package/src/client/renderers/live2d/Live2dVisualMount.tsx +1 -0
  71. package/src/client/renderers/live2d/runtime.ts +8 -2
  72. package/src/client/renderers/live2d.test.ts +233 -9
  73. package/src/client/renderers/live2d.ts +143 -30
  74. package/src/client/settings-form.ts +8 -6
  75. package/src/contracts/status-decoration.ts +78 -0
  76. package/src/decoration.test.ts +178 -0
  77. package/src/decoration.ts +220 -0
  78. package/src/event-projection.ts +10 -5
  79. package/src/image-dimensions.test.ts +85 -0
  80. package/src/image-dimensions.ts +76 -0
  81. package/src/index.ts +3 -1
  82. package/src/registry.test.ts +276 -0
  83. package/src/registry.ts +274 -15
  84. package/src/routes.ts +162 -33
  85. package/src/service.ts +59 -3
  86. package/src/voice-pack.test.ts +216 -0
  87. package/src/voice-pack.ts +413 -0
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Status-decoration manifest tests (pet-center M5, #567): fail-closed
3
+ * structure, warn-and-drop content, duration normalization and the
4
+ * PNG/WebP entry discipline.
5
+ */
6
+ import { describe, expect, it } from 'vitest'
7
+ import { readFileSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import { KNOWN_DECORATION_TOP_LEVEL, parseDecorationManifest, safeDecorationEntry } from './decoration.ts'
10
+ import { PET_ACTIVITY_PHASES } from './manifest-v2.ts'
11
+ import { petPackageRoot } from './registry.ts'
12
+
13
+ function parse(raw: unknown) {
14
+ return parseDecorationManifest(raw, 'test/decoration.json')
15
+ }
16
+
17
+ function valid(): Record<string, unknown> {
18
+ return {
19
+ decorationManifestVersion: 1,
20
+ id: 'whale',
21
+ displayName: '喷水鲸鱼',
22
+ license: 'MIT',
23
+ entry: 'whale-frames.png',
24
+ cell: { width: 64, height: 48 },
25
+ columns: 4,
26
+ frameMs: 140,
27
+ loop: true,
28
+ phases: {
29
+ idle: 'hide',
30
+ waiting: { from: 0, to: 1 },
31
+ thinking: { from: 0, to: 3 },
32
+ },
33
+ }
34
+ }
35
+
36
+ describe('parseDecorationManifest structure', () => {
37
+ it('accepts a valid descriptor with duration and display-name defaults', () => {
38
+ const verdict = parse(valid())
39
+ expect(verdict.ok).toBe(true)
40
+ if (!verdict.ok) return
41
+ expect(verdict.manifest.durations).toEqual([140, 140, 140, 140])
42
+ expect(verdict.manifest.loop).toBe(true)
43
+ expect(verdict.manifest.phases.thinking).toEqual({ from: 0, to: 3 })
44
+ expect(verdict.manifest.phases.idle).toBe('hide')
45
+ })
46
+
47
+ it('defaults displayName to the id and frameMs to 120', () => {
48
+ const manifest = { ...valid() }
49
+ delete manifest.displayName
50
+ delete manifest.frameMs
51
+ const verdict = parse(manifest)
52
+ expect(verdict.ok).toBe(true)
53
+ if (!verdict.ok) return
54
+ expect(verdict.manifest.displayName).toBe('whale')
55
+ expect(verdict.manifest.durations).toEqual([120, 120, 120, 120])
56
+ })
57
+
58
+ it('rejects non-object roots, wrong versions and unknown top-level fields', () => {
59
+ expect(parse(['x']).ok).toBe(false)
60
+ const wrongVersion = { ...valid(), decorationManifestVersion: 2 }
61
+ const verdict = parse(wrongVersion)
62
+ expect(verdict.ok).toBe(false)
63
+ if (verdict.ok) return
64
+ expect(verdict.diagnostics.some(d => d.message.includes('decorationManifestVersion'))).toBe(true)
65
+ const unknown = { ...valid(), mystery: true }
66
+ expect(parse(unknown).ok).toBe(false)
67
+ })
68
+
69
+ it('rejects unsafe or non-image entries', () => {
70
+ for (const entry of ['../etc/passwd', '/abs.png', 'a.svg', 'a.css', 'x\\y.png']) {
71
+ const verdict = parse({ ...valid(), entry })
72
+ expect(verdict.ok).toBe(false)
73
+ }
74
+ })
75
+
76
+ it('rejects out-of-range geometry and missing license/id', () => {
77
+ expect(parse({ ...valid(), cell: { width: 999, height: 48 } }).ok).toBe(false)
78
+ expect(parse({ ...valid(), columns: 99 }).ok).toBe(false)
79
+ expect(parse({ ...valid(), license: '' }).ok).toBe(false)
80
+ expect(parse({ ...valid(), id: 'Bad Id' }).ok).toBe(false)
81
+ })
82
+ })
83
+
84
+ describe('parseDecorationManifest content (warn-and-drop)', () => {
85
+ it('drops unknown phase keys and out-of-range segments with warnings', () => {
86
+ const verdict = parse({
87
+ ...valid(),
88
+ phases: {
89
+ ...(valid().phases as Record<string, unknown>),
90
+ bogus: { from: 0, to: 1 },
91
+ waiting: { from: 2, to: 9 },
92
+ review: { from: 3, to: 1 },
93
+ },
94
+ })
95
+ expect(verdict.ok).toBe(true)
96
+ if (!verdict.ok) return
97
+ expect(verdict.manifest.phases.done).toBeUndefined()
98
+ expect(verdict.manifest.phases.waiting).toBeUndefined()
99
+ expect(verdict.manifest.phases.review).toBeUndefined()
100
+ expect(verdict.diagnostics.some(d => d.level === 'warning' && d.message.includes('ignored'))).toBe(true)
101
+ })
102
+
103
+ it('warns when no phase shows the ornament', () => {
104
+ const verdict = parse({ ...valid(), phases: { idle: 'hide' } })
105
+ expect(verdict.ok).toBe(true)
106
+ expect(verdict.diagnostics.some(d => d.level === 'warning' && d.message.includes('stays hidden'))).toBe(true)
107
+ })
108
+
109
+ it('falls back to the constant frameMs when durations has the wrong length', () => {
110
+ const verdict = parse({ ...valid(), durations: [100] })
111
+ expect(verdict.ok).toBe(true)
112
+ if (!verdict.ok) return
113
+ expect(verdict.manifest.durations).toEqual([140, 140, 140, 140])
114
+ expect(verdict.diagnostics.some(d => d.level === 'warning' && d.message.includes('durations'))).toBe(true)
115
+ })
116
+
117
+ it('keeps a well-sized durations array', () => {
118
+ const verdict = parse({ ...valid(), durations: [120, 130, 140, 150] })
119
+ expect(verdict.ok).toBe(true)
120
+ if (!verdict.ok) return
121
+ expect(verdict.manifest.durations).toEqual([120, 130, 140, 150])
122
+ })
123
+
124
+ it('warns and defaults to looping when loop is not a boolean', () => {
125
+ const verdict = parse({ ...valid(), loop: 'yes' })
126
+ expect(verdict.ok).toBe(true)
127
+ if (!verdict.ok) return
128
+ expect(verdict.manifest.loop).toBe(true)
129
+ expect(verdict.diagnostics.some(d => d.level === 'warning' && d.message.includes('loop'))).toBe(true)
130
+ const off = parse({ ...valid(), loop: false })
131
+ expect(off.ok && off.manifest.loop).toBe(false)
132
+ })
133
+ })
134
+
135
+ describe('safeDecorationEntry', () => {
136
+ it('accepts safe relative PNG/WebP paths and rejects everything else', () => {
137
+ expect(safeDecorationEntry('frames.webp')).toBe('frames.webp')
138
+ expect(safeDecorationEntry('a/b.png')).toBe('a/b.png')
139
+ expect(safeDecorationEntry('')).toBeUndefined()
140
+ expect(safeDecorationEntry('a/../b.png')).toBeUndefined()
141
+ expect(safeDecorationEntry('/tmp/x.png')).toBeUndefined()
142
+ expect(safeDecorationEntry('x.svg')).toBeUndefined()
143
+ expect(safeDecorationEntry('x')).toBeUndefined()
144
+ })
145
+
146
+ it('rejects case-mismatched extensions (the route serves paths verbatim)', () => {
147
+ // A lenient case check would pass validation here, but the asset route
148
+ // matches the declared path exactly and 403s on case-sensitive hosts.
149
+ expect(safeDecorationEntry('img/FRAMES.PNG')).toBeUndefined()
150
+ expect(safeDecorationEntry('img/frames.WebP')).toBeUndefined()
151
+ expect(safeDecorationEntry('img/frames.png')).toBe('img/frames.png')
152
+ })
153
+ })
154
+
155
+ describe('status-decoration schema file drift lock', () => {
156
+ const schema = JSON.parse(readFileSync(
157
+ join(petPackageRoot(import.meta.url), 'contracts', 'status-decoration-v1.schema.json'),
158
+ 'utf8',
159
+ )) as {
160
+ required: string[]
161
+ properties: Record<string, { const?: number; properties?: Record<string, unknown> }>
162
+ }
163
+
164
+ it('locks the schema top-level fields to the validator allow-list', () => {
165
+ expect(new Set(Object.keys(schema.properties))).toEqual(KNOWN_DECORATION_TOP_LEVEL)
166
+ })
167
+
168
+ it('locks the required set and the version const', () => {
169
+ expect([...schema.required].sort()).toEqual([
170
+ 'cell', 'columns', 'decorationManifestVersion', 'entry', 'id', 'license',
171
+ ])
172
+ expect(schema.properties.decorationManifestVersion?.const).toBe(1)
173
+ })
174
+
175
+ it('locks the phases key set to the ActivityPhase stream', () => {
176
+ expect(new Set(Object.keys(schema.properties.phases?.properties ?? {}))).toEqual(new Set(PET_ACTIVITY_PHASES))
177
+ })
178
+ })
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Status-decoration manifest v1 — fail-closed structure + warn-and-drop
3
+ * content, mirroring the pet manifest-v2 discipline (issue #623 M5,
4
+ * protocol #567). Unknown top-level fields, unsafe entry paths, out-of-
5
+ * range geometry and unknown renderer content reject the descriptor with
6
+ * human-readable diagnostics; per-phase binding issues drop that binding
7
+ * only. The JSON Schema twin lives at
8
+ * contracts/status-decoration-v1.schema.json; this hand-rolled parser is
9
+ * authoritative. Keep this file erasable-syntax-only (scripts/ import it
10
+ * under node's strip-only mode).
11
+ * @module @linxin666/dsh-pet/decoration
12
+ */
13
+
14
+ import { isAbsolute } from 'node:path'
15
+ import type { ActivityPhase } from './state.ts'
16
+ import { PET_ACTIVITY_PHASES } from './manifest-v2.ts'
17
+ import type {
18
+ DecorationDiagnostic,
19
+ DecorationManifest,
20
+ DecorationManifestParse,
21
+ PhaseBindings,
22
+ PhaseSegment,
23
+ } from './contracts/status-decoration.ts'
24
+
25
+ /** Geometry and content caps (the adopted PNG/WebP sprite-strip bounds). */
26
+ export const DECORATION_CELL_MAX = 256
27
+ export const DECORATION_COLUMNS_MAX = 16
28
+ export const DECORATION_DURATION_MAX_MS = 2000
29
+ export const DECORATION_ENTRY_EXTENSIONS = ['.webp', '.png'] as const
30
+ export const DECORATION_DISPLAY_NAME_MAX = 64
31
+
32
+ /** Field allow-list (drift-locked to the schema twin in tests). */
33
+ export const KNOWN_DECORATION_TOP_LEVEL = new Set([
34
+ '$schema', 'decorationManifestVersion', 'id', 'displayName', 'license',
35
+ 'entry', 'cell', 'columns', 'frameMs', 'durations', 'loop', 'phases',
36
+ ])
37
+
38
+ const PET_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/
39
+ const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/
40
+
41
+ class Diagnostics {
42
+ readonly list: DecorationDiagnostic[] = []
43
+ private readonly source: string
44
+ constructor(source: string) { this.source = source }
45
+ error(message: string): void { this.list.push({ level: 'error', message: this.source + ': ' + message }) }
46
+ warn(message: string): void { this.list.push({ level: 'warning', message: this.source + ': ' + message }) }
47
+ get hasErrors(): boolean { return this.list.some(d => d.level === 'error') }
48
+ }
49
+
50
+ function isRecord(value: unknown): value is Record<string, unknown> {
51
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
52
+ }
53
+
54
+ function unknownKeys(source: Record<string, unknown>, known: Set<string>): string[] {
55
+ return Object.keys(source).filter(key => !known.has(key))
56
+ }
57
+
58
+ /** Positive integer in [min, max], else undefined. */
59
+ function finiteInt(value: unknown, min: number, max: number): number | undefined {
60
+ return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max
61
+ ? value
62
+ : undefined
63
+ }
64
+
65
+ /**
66
+ * Validate a descriptor-relative entry path: no absolute paths, no
67
+ * backslashes, no traversal, plain safe segments only, and an exact
68
+ * lowercase PNG/WebP extension (the adopted entry discipline — SVG/CSS are
69
+ * not accepted). The extension match is case-sensitive on purpose: the
70
+ * asset route serves the declared path verbatim, so a case-mismatched
71
+ * suffix (frames.PNG vs frames.png) would pass a lenient check but 403 on
72
+ * case-sensitive filesystems.
73
+ * Returns the normalized path or undefined.
74
+ */
75
+ export function safeDecorationEntry(raw: unknown): string | undefined {
76
+ if (typeof raw !== 'string' || raw.trim() === '') return undefined
77
+ const value = raw.trim()
78
+ if (value.length > 256) return undefined
79
+ if (isAbsolute(value) || value.includes('\\') || /^[a-z][a-z0-9+.-]*:/i.test(value)) return undefined
80
+ const segments = value.split('/').filter(segment => segment !== '')
81
+ if (segments.length === 0) return undefined
82
+ if (segments.some(segment => segment === '.' || segment === '..' || !PATH_SEGMENT_PATTERN.test(segment))) return undefined
83
+ const last = segments[segments.length - 1]!
84
+ const dot = last.lastIndexOf('.')
85
+ if (dot <= 0 || !(DECORATION_ENTRY_EXTENSIONS as readonly string[]).includes(last.slice(dot))) return undefined
86
+ return segments.join('/')
87
+ }
88
+
89
+ /** Normalize one phase binding value; undefined (warning) on bad content. */
90
+ function normalizeSegment(
91
+ raw: unknown,
92
+ columns: number,
93
+ diag: Diagnostics,
94
+ ): PhaseSegment | undefined {
95
+ if (raw === 'hide') return 'hide'
96
+ if (!isRecord(raw)) {
97
+ diag.warn('phase binding must be "hide" or { from, to }; binding dropped')
98
+ return undefined
99
+ }
100
+ const from = finiteInt(raw.from, 0, columns - 1)
101
+ const to = finiteInt(raw.to, 0, columns - 1)
102
+ if (from === undefined || to === undefined || from > to) {
103
+ diag.warn('phase frame segment out of range; binding dropped')
104
+ return undefined
105
+ }
106
+ return { from, to }
107
+ }
108
+
109
+ /**
110
+ * Parse and validate one decoration.json document. Fail-closed over the
111
+ * structure (types, key sets, paths, ranges); phase-binding content issues
112
+ * drop that binding only (warn-and-drop, the registry never-throw rule).
113
+ */
114
+ export function parseDecorationManifest(
115
+ raw: unknown,
116
+ source: string = 'decoration.json',
117
+ ): DecorationManifestParse {
118
+ const diag = new Diagnostics(source)
119
+ if (!isRecord(raw)) {
120
+ diag.error('descriptor must be a JSON object')
121
+ return { ok: false, diagnostics: diag.list }
122
+ }
123
+ for (const key of unknownKeys(raw, KNOWN_DECORATION_TOP_LEVEL)) {
124
+ diag.error('unknown top-level field ' + JSON.stringify(key))
125
+ }
126
+ if (raw.decorationManifestVersion !== 1) {
127
+ diag.error('decorationManifestVersion must be 1')
128
+ }
129
+ const id = typeof raw.id === 'string' ? raw.id.trim() : ''
130
+ if (!PET_ID_PATTERN.test(id)) {
131
+ diag.error('id must be a lowercase kebab id')
132
+ }
133
+ if (id.length > 64) {
134
+ diag.error('id must be at most 64 characters')
135
+ }
136
+ const license = typeof raw.license === 'string' ? raw.license.trim() : ''
137
+ if (license === '') diag.error('license is required (asset provenance)')
138
+ if (license.length > 128) {
139
+ diag.error('license must be at most 128 characters')
140
+ }
141
+ const entry = safeDecorationEntry(raw.entry)
142
+ if (entry === undefined) {
143
+ diag.error('entry must be a safe relative PNG/WebP path')
144
+ }
145
+ const rawCell = isRecord(raw.cell) ? raw.cell : {}
146
+ for (const key of Object.keys(rawCell)) {
147
+ if (key !== 'width' && key !== 'height') diag.warn('unknown cell field ' + JSON.stringify(key) + ' ignored')
148
+ }
149
+ const cellWidth = finiteInt(rawCell.width, 1, DECORATION_CELL_MAX)
150
+ const cellHeight = finiteInt(rawCell.height, 1, DECORATION_CELL_MAX)
151
+ if (cellWidth === undefined || cellHeight === undefined) {
152
+ diag.error('cell width/height must be integers in [1, ' + DECORATION_CELL_MAX + ']')
153
+ }
154
+ const columns = finiteInt(raw.columns, 1, DECORATION_COLUMNS_MAX)
155
+ if (columns === undefined) {
156
+ diag.error('columns must be an integer in [1, ' + DECORATION_COLUMNS_MAX + ']')
157
+ }
158
+ if (diag.hasErrors || id === '' || entry === undefined || columns === undefined) {
159
+ return { ok: false, diagnostics: diag.list }
160
+ }
161
+ const displayName = typeof raw.displayName === 'string' && raw.displayName.trim() !== ''
162
+ ? raw.displayName.trim().slice(0, DECORATION_DISPLAY_NAME_MAX)
163
+ : id
164
+ let loop: boolean
165
+ if (raw.loop === undefined || typeof raw.loop === 'boolean') {
166
+ loop = raw.loop ?? true
167
+ } else {
168
+ diag.warn('loop must be a boolean; defaulting to true')
169
+ loop = true
170
+ }
171
+ const rawDurations = raw.durations
172
+ let durations: number[]
173
+ if (Array.isArray(rawDurations)) {
174
+ const usable = rawDurations.filter((v): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= DECORATION_DURATION_MAX_MS)
175
+ if (usable.length !== columns) {
176
+ diag.warn('durations length must equal columns; using the constant frameMs instead')
177
+ durations = []
178
+ } else {
179
+ durations = usable
180
+ }
181
+ } else if (rawDurations !== undefined) {
182
+ diag.warn('durations must be an array; using the constant frameMs instead')
183
+ durations = []
184
+ } else {
185
+ durations = []
186
+ }
187
+ if (durations.length === 0) {
188
+ const frameMs = finiteInt(raw.frameMs, 1, DECORATION_DURATION_MAX_MS) ?? 120
189
+ durations = Array.from({ length: columns }, () => frameMs)
190
+ }
191
+ const phases: PhaseBindings = {}
192
+ const rawPhases = raw.phases
193
+ if (isRecord(rawPhases)) {
194
+ for (const [key, value] of Object.entries(rawPhases)) {
195
+ if (!(PET_ACTIVITY_PHASES as readonly string[]).includes(key)) {
196
+ diag.warn('unknown phase ' + JSON.stringify(key) + '; binding ignored')
197
+ continue
198
+ }
199
+ const segment = normalizeSegment(value, columns, diag)
200
+ if (segment !== undefined) phases[key as ActivityPhase] = segment
201
+ }
202
+ } else if (rawPhases !== undefined) {
203
+ diag.warn('phases must be an object; all phases hide')
204
+ }
205
+ const visible = Object.values(phases).some(segment => segment !== 'hide')
206
+ if (!visible) diag.warn('no phase shows the ornament; the decoration stays hidden')
207
+ const manifest: DecorationManifest = {
208
+ decorationManifestVersion: 1,
209
+ id,
210
+ displayName,
211
+ license,
212
+ entry,
213
+ cell: { width: cellWidth!, height: cellHeight! },
214
+ columns,
215
+ durations,
216
+ loop,
217
+ phases,
218
+ }
219
+ return { ok: true, manifest, diagnostics: diag.list }
220
+ }
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { SessionEvent } from '@deepseek-ai/dsh-session'
15
15
  import type { PetStateInput } from './state.ts'
16
- import { StatusVoice, toolArgHint, WhisperEngine } from './chatter.ts'
16
+ import { StatusVoice, toolArgHint, WhisperEngine, type VoicePoolsProvider } from './chatter.ts'
17
17
 
18
18
  /** Runtime shape of the optional legacy activity event. */
19
19
  export interface ActivityStatusEventLike {
@@ -41,14 +41,19 @@ export interface PetActivityTransition {
41
41
  whisper?: string
42
42
  }
43
43
 
44
- /** Fresh projection runtime for a newly seen session. */
45
- export function emptyProjectionRuntime(): ProjectionRuntime {
44
+ /**
45
+ * Fresh projection runtime for a newly seen session. The optional voice-pack
46
+ * provider (pet-center M4, issue #677) hands both chatter engines their
47
+ * pools; engines resolve overrides at draw time, so swapping the provider's
48
+ * pack re-voices live runtimes without rebuilding them.
49
+ */
50
+ export function emptyProjectionRuntime(pools?: VoicePoolsProvider): ProjectionRuntime {
46
51
  return {
47
52
  activeTools: new Set(),
48
53
  officialEventsSeen: false,
49
54
  stepHadFailure: false,
50
- voice: new StatusVoice(),
51
- whispers: new WhisperEngine(),
55
+ voice: new StatusVoice(pools),
56
+ whispers: new WhisperEngine(pools),
52
57
  }
53
58
  }
54
59
 
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { imageDimensions } from './image-dimensions.ts'
3
+
4
+ /** Minimal PNG header (signature + IHDR) for the given size. */
5
+ function pngHeader(width: number, height: number): Buffer {
6
+ const buf = Buffer.alloc(26)
7
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0)
8
+ buf.writeUInt32BE(13, 8)
9
+ buf.write('IHDR', 12)
10
+ buf.writeUInt32BE(width, 16)
11
+ buf.writeUInt32BE(height, 20)
12
+ return buf
13
+ }
14
+
15
+ /** Minimal extended WebP header (RIFF + VP8X) for the given size. */
16
+ function webpExtendedHeader(width: number, height: number): Buffer {
17
+ const buf = Buffer.alloc(30)
18
+ buf.write('RIFF', 0)
19
+ buf.writeUInt32LE(30 - 8, 4)
20
+ buf.write('WEBP', 8)
21
+ buf.write('VP8X', 12)
22
+ buf[20] = 0 // flags: none
23
+ buf.writeUIntLE(width - 1, 24, 3)
24
+ buf.writeUIntLE(height - 1, 27, 3)
25
+ return buf
26
+ }
27
+
28
+ /** Minimal lossless WebP header (RIFF + VP8L). */
29
+ function webpLosslessHeader(width: number, height: number): Buffer {
30
+ const buf = Buffer.alloc(25)
31
+ buf.write('RIFF', 0)
32
+ buf.writeUInt32LE(25 - 8, 4)
33
+ buf.write('WEBP', 8)
34
+ buf.write('VP8L', 12)
35
+ buf[20] = 0x2f
36
+ const bits = (width - 1) | ((height - 1) << 14)
37
+ buf.writeUInt32LE(bits, 21)
38
+ return buf
39
+ }
40
+
41
+ /** Minimal lossy WebP header (RIFF + VP8 ) — low 14 bits carry the size. */
42
+ function webpLossyHeader(width: number, height: number): Buffer {
43
+ const buf = Buffer.alloc(30)
44
+ buf.write('RIFF', 0)
45
+ buf.writeUInt32LE(30 - 8, 4)
46
+ buf.write('WEBP', 8)
47
+ buf.write('VP8 ', 12)
48
+ buf.writeUInt16LE(width & 0x3fff, 26)
49
+ buf.writeUInt16LE(height & 0x3fff, 28)
50
+ return buf
51
+ }
52
+
53
+ describe('imageDimensions (decoration strip geometry)', () => {
54
+ it('reads a PNG header', () => {
55
+ expect(imageDimensions(pngHeader(256, 48))).toEqual({ width: 256, height: 48 })
56
+ expect(imageDimensions(pngHeader(64, 48))).toEqual({ width: 64, height: 48 })
57
+ })
58
+
59
+ it('reads an extended WebP header (VP8X)', () => {
60
+ expect(imageDimensions(webpExtendedHeader(128, 64))).toEqual({ width: 128, height: 64 })
61
+ })
62
+
63
+ it('reads a lossless WebP header (VP8L)', () => {
64
+ expect(imageDimensions(webpLosslessHeader(300, 200))).toEqual({ width: 300, height: 200 })
65
+ })
66
+
67
+ it('reads a lossy WebP header (VP8 )', () => {
68
+ expect(imageDimensions(webpLossyHeader(80, 40))).toEqual({ width: 80, height: 40 })
69
+ })
70
+
71
+ it('returns undefined for non-image bytes and truncated headers', () => {
72
+ expect(imageDimensions(Buffer.from('not-an-image'))).toBeUndefined()
73
+ expect(imageDimensions(Buffer.alloc(0))).toBeUndefined()
74
+ expect(imageDimensions(Buffer.from([0x89, 0x50]))).toBeUndefined()
75
+ // PNG magic but truncated before IHDR width/height.
76
+ expect(imageDimensions(pngHeader(10, 10).subarray(0, 10))).toBeUndefined()
77
+ })
78
+
79
+ it('returns undefined for a RIFF buffer that is not WebP', () => {
80
+ const riff = Buffer.alloc(16)
81
+ riff.write('RIFF', 0)
82
+ riff.write('AVI ', 8)
83
+ expect(imageDimensions(riff)).toBeUndefined()
84
+ })
85
+ })
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Minimal PNG/WebP dimension reader — header-only, no decoding, no
3
+ * dependencies. Used by the decoration registry to verify a strip's actual
4
+ * pixel geometry matches its descriptor (single-row sprite strip; the client
5
+ * renders by frame-column offsets, so a mismatched strip silently shows the
6
+ * wrong frames). Parsing is best-effort: an unrecognized or truncated header
7
+ * returns undefined (the caller decides whether to warn).
8
+ *
9
+ * PNG: signature (8) + IHDR chunk — length (4) + 'IHDR' (4) + width (4) +
10
+ * height (4), both big-endian uint32 at fixed offsets 16/20.
11
+ * WebP: RIFF header (12) + chunk — 'VP8X' extended (width-1/height-1 as
12
+ * little-endian uint24 at 24/27), 'VP8L' lossless (packed 14-bit dims at
13
+ * 21), or 'VP8 ' lossy (frame header, low 14 bits of the uint16 at 26/28).
14
+ * @module @linxin666/dsh-pet/image-dimensions
15
+ */
16
+
17
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
18
+
19
+ export interface ImageDimensions {
20
+ width: number
21
+ height: number
22
+ }
23
+
24
+ /** Read the pixel size of a PNG buffer, or undefined when unrecognized. */
25
+ function pngDimensions(buf: Buffer): ImageDimensions | undefined {
26
+ if (buf.length < 24) return undefined
27
+ if (!buf.subarray(0, 8).equals(PNG_SIGNATURE)) return undefined
28
+ if (buf.toString('ascii', 12, 16) !== 'IHDR') return undefined
29
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }
30
+ }
31
+
32
+ /** Read the pixel size of a WebP buffer, or undefined when unrecognized. */
33
+ function webpDimensions(buf: Buffer): ImageDimensions | undefined {
34
+ if (buf.length < 21) return undefined
35
+ if (buf.toString('ascii', 0, 4) !== 'RIFF') return undefined
36
+ if (buf.toString('ascii', 8, 12) !== 'WEBP') return undefined
37
+ const fourcc = buf.toString('ascii', 12, 16)
38
+ if (fourcc === 'VP8X') {
39
+ // Extended header: 1-byte flags at 20, then width-1/height-1 uint24 LE.
40
+ if (buf.length < 30) return undefined
41
+ return {
42
+ width: 1 + buf.readUIntLE(24, 3),
43
+ height: 1 + buf.readUIntLE(27, 3),
44
+ }
45
+ }
46
+ if (fourcc === 'VP8L') {
47
+ // Lossless header: 0x2f marker at 20, then 14-bit width / 14-bit height
48
+ // packed into the little-endian uint32 at 21.
49
+ if (buf.length < 25) return undefined
50
+ const bits = buf.readUInt32LE(21)
51
+ return {
52
+ width: 1 + (bits & 0x3fff),
53
+ height: 1 + ((bits >>> 14) & 0x3fff),
54
+ }
55
+ }
56
+ if (fourcc === 'VP8 ') {
57
+ // Lossy frame header: 3-byte tag + 3-byte start code, then width/height
58
+ // as uint16 LE whose low 14 bits carry the dimension.
59
+ if (buf.length < 30) return undefined
60
+ return {
61
+ width: buf.readUInt16LE(26) & 0x3fff,
62
+ height: buf.readUInt16LE(28) & 0x3fff,
63
+ }
64
+ }
65
+ return undefined
66
+ }
67
+
68
+ /**
69
+ * Read image pixel dimensions from a PNG or WebP buffer. Returns undefined
70
+ * for formats this reader does not recognize (never throws). Callers treat
71
+ * undefined as "cannot verify", not as an error.
72
+ */
73
+ export function imageDimensions(buf: Buffer): ImageDimensions | undefined {
74
+ if (buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF') return webpDimensions(buf)
75
+ return pngDimensions(buf)
76
+ }
package/src/index.ts CHANGED
@@ -134,6 +134,7 @@ export function makePetSettingsSchema(fallbackPetId: string) {
134
134
  bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
135
135
  petId: z.string().default(fallbackPetId),
136
136
  enabled: z.boolean().default(true),
137
+ decorationEnabled: z.boolean().default(true),
137
138
  })
138
139
  }
139
140
 
@@ -163,6 +164,7 @@ function applyImpl(ctx: Context, config: PetConfig = {}): void {
163
164
  bottom: service.display().bottom,
164
165
  petId: service.selectedPetId(),
165
166
  enabled: config.enabled ?? true,
167
+ decorationEnabled: config.decorationEnabled ?? true,
166
168
  }
167
169
  // The browser half talks to the pet through same-origin JSON endpoints and
168
170
  // loads each pet's atlas from the registry's own media route (RPC domains
@@ -170,7 +172,7 @@ function applyImpl(ctx: Context, config: PetConfig = {}): void {
170
172
  // pattern as dsh-remote-web-ui's /api/pair family). The routes are
171
173
  // registered while the plugin is enabled; toggling the setting off makes
172
174
  // the pet API disappear until it is re-enabled.
173
- const routes = makePetRoutes({ service })
175
+ const routes = makePetRoutes({ service, ctx })
174
176
  let disposeRoutes: (() => void) | undefined
175
177
  const syncRoutes = (): void => {
176
178
  const enabled = current().enabled ?? true