@linxin666/dsh-pet 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +64 -1
  3. package/README.zh.md +64 -1
  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 +144 -18
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +979 -70
  12. package/lib/types/chatter.d.ts +61 -3
  13. package/lib/types/chatter.d.ts.map +1 -1
  14. package/lib/types/chatter.js +71 -12
  15. package/lib/types/client/PetSettingsCard.d.ts +4 -0
  16. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  17. package/lib/types/client/PetSettingsCard.js +3 -1
  18. package/lib/types/client/PetSprite.d.ts.map +1 -1
  19. package/lib/types/client/PetSprite.js +103 -4
  20. package/lib/types/client/locales.d.ts +4 -0
  21. package/lib/types/client/locales.d.ts.map +1 -1
  22. package/lib/types/client/locales.js +4 -0
  23. package/lib/types/client/renderers/live2d.d.ts.map +1 -1
  24. package/lib/types/client/renderers/live2d.js +13 -1
  25. package/lib/types/client/settings-form.d.ts.map +1 -1
  26. package/lib/types/client/settings-form.js +9 -6
  27. package/lib/types/contracts/status-decoration.d.ts +85 -0
  28. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  29. package/lib/types/contracts/status-decoration.js +21 -0
  30. package/lib/types/decoration.d.ts +39 -0
  31. package/lib/types/decoration.d.ts.map +1 -0
  32. package/lib/types/decoration.js +210 -0
  33. package/lib/types/event-projection.d.ts +8 -3
  34. package/lib/types/event-projection.d.ts.map +1 -1
  35. package/lib/types/event-projection.js +9 -4
  36. package/lib/types/index.d.ts +2 -0
  37. package/lib/types/index.d.ts.map +1 -1
  38. package/lib/types/index.js +2 -0
  39. package/lib/types/registry.d.ts +55 -2
  40. package/lib/types/registry.d.ts.map +1 -1
  41. package/lib/types/registry.js +198 -16
  42. package/lib/types/routes.d.ts.map +1 -1
  43. package/lib/types/routes.js +131 -3
  44. package/lib/types/service.d.ts +33 -0
  45. package/lib/types/service.d.ts.map +1 -1
  46. package/lib/types/service.js +42 -3
  47. package/lib/types/voice-pack.d.ts +98 -0
  48. package/lib/types/voice-pack.d.ts.map +1 -0
  49. package/lib/types/voice-pack.js +384 -0
  50. package/package.json +11 -10
  51. package/src/chatter.test.ts +89 -2
  52. package/src/chatter.ts +120 -14
  53. package/src/client/PetSettingsCard.tsx +18 -0
  54. package/src/client/PetSprite.test.tsx +254 -12
  55. package/src/client/PetSprite.tsx +142 -25
  56. package/src/client/locales.ts +4 -0
  57. package/src/client/renderers/live2d.test.ts +23 -2
  58. package/src/client/renderers/live2d.ts +14 -1
  59. package/src/client/settings-form.ts +8 -6
  60. package/src/contracts/status-decoration.ts +78 -0
  61. package/src/decoration.test.ts +178 -0
  62. package/src/decoration.ts +220 -0
  63. package/src/event-projection.ts +10 -5
  64. package/src/index.ts +2 -0
  65. package/src/registry.test.ts +223 -0
  66. package/src/registry.ts +235 -15
  67. package/src/routes.ts +128 -3
  68. package/src/service.ts +59 -3
  69. package/src/voice-pack.test.ts +216 -0
  70. package/src/voice-pack.ts +413 -0
@@ -10,17 +10,18 @@
10
10
  */
11
11
 
12
12
  import { useEffect, useLayoutEffect, useRef, useState } from 'react'
13
- import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode, ReactPortal } from 'react'
13
+ import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactElement, ReactNode, ReactPortal } from 'react'
14
14
  import { createPortal } from 'react-dom'
15
15
  import clsx from 'clsx'
16
16
  import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
17
17
  import type { PetDisplayConfig } from '../persist.ts'
18
18
  import type { PetStateView } from '../service.ts'
19
19
  import type { PetDefinition } from '../registry.ts'
20
+ import type { DecorationView } from '../contracts/status-decoration.ts'
20
21
  import type { PetFeedback } from './pet-store.ts'
21
22
  import { framePosition, rowOfTrack, trimTrack } from './spritesheet.ts'
22
23
  import { sequenceFrameAt } from './sequences.ts'
23
- import { animationForPhase, type PetAnimation } from '../state.ts'
24
+ import { animationForPhase, type ActivityPhase, type PetAnimation } from '../state.ts'
24
25
  import { NS } from './locales.ts'
25
26
  import styles from './pet.module.css'
26
27
 
@@ -63,6 +64,81 @@ function clampOffset(value: number, max: number): number {
63
64
  return Math.max(0, Math.min(max, value))
64
65
  }
65
66
 
67
+ /**
68
+ * The status decoration ornament (pet-center M5, #567). Renders the active
69
+ * phase's frame segment as a CSS-background strip at a compact bubble
70
+ * height; prefers-reduced-motion holds the segment's first frame, and a
71
+ * missing or undecodable asset simply paints nothing (CSS background
72
+ * failure) — the bubble text is never disturbed. The span is aria-hidden;
73
+ * the bubble keeps its own semantics untouched.
74
+ */
75
+ function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhase }): ReactElement | null {
76
+ const { decoration, phase } = props
77
+ const segment = decoration.phases[phase]
78
+ const shown = segment !== undefined && segment !== 'hide'
79
+ const segmentKey = segment !== undefined && segment !== 'hide' ? segment.from + ':' + segment.to : 'none'
80
+ const spanRef = useRef<HTMLSpanElement | null>(null)
81
+ const scale = 18 / decoration.cell.height
82
+ const frameWidth = Math.round(decoration.cell.width * scale)
83
+ const stripWidth = decoration.columns * frameWidth
84
+ // Value-stable dependency key: the host serves a fresh DecorationView
85
+ // object on every state poll (2 s), so the effect must not depend on the
86
+ // object identity — otherwise each poll would cancel and restart the
87
+ // frame loop and the animation would jump back to its first frame.
88
+ const durationsKey = decoration.durations.join(',')
89
+ useEffect(() => {
90
+ if (segment === undefined || segment === 'hide') return
91
+ const el = spanRef.current
92
+ if (el === null) return
93
+ const position = (index: number): string => (-index * frameWidth) + 'px 0px'
94
+ const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
95
+ el.style.backgroundPosition = position(segment.from)
96
+ if (reduceMotion) return
97
+ let raf = 0
98
+ let index = segment.from
99
+ let elapsed = 0
100
+ let last = performance.now()
101
+ const tick = (ts: number): void => {
102
+ const delta = ts - last
103
+ last = ts
104
+ elapsed += delta
105
+ const duration = decoration.durations[index] ?? 160
106
+ if (elapsed >= duration) {
107
+ elapsed = 0
108
+ if (index < segment.to) index += 1
109
+ else if (decoration.loop) index = segment.from
110
+ }
111
+ el.style.backgroundPosition = position(index)
112
+ // A non-looping segment settles on its last frame; stop scheduling
113
+ // instead of repainting the same position every frame.
114
+ if (!decoration.loop && index === segment.to) return
115
+ raf = requestAnimationFrame(tick)
116
+ }
117
+ raf = requestAnimationFrame(tick)
118
+ return () => cancelAnimationFrame(raf)
119
+ }, [shown, segmentKey, frameWidth, decoration.loop, durationsKey])
120
+ if (!shown) return null
121
+ return (
122
+ <span
123
+ ref={spanRef}
124
+ aria-hidden="true"
125
+ data-dsh-pet-decoration={decoration.id}
126
+ style={{
127
+ display: 'inline-block',
128
+ width: frameWidth,
129
+ height: 18,
130
+ marginRight: 6,
131
+ verticalAlign: 'middle',
132
+ flexShrink: 0,
133
+ backgroundImage: 'url(' + decoration.entryUrl + ')',
134
+ backgroundSize: stripWidth + 'px 18px',
135
+ backgroundRepeat: 'no-repeat',
136
+ backgroundPosition: '0px 0px',
137
+ }}
138
+ />
139
+ )
140
+ }
141
+
66
142
  /**
67
143
  * The floating pet. The spritesheet frame advances on requestAnimationFrame
68
144
  * with per-frame durations from the definition's tracks; the atlas image is
@@ -110,6 +186,33 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
110
186
  const rows = definition.rows
111
187
  const tracks = definition.tracks
112
188
  const sequences = definition.sequences
189
+ // Hover-panel chrome from the pet's voice pack (pet-center M4, issue
190
+ // #677): every slot falls back to the i18n dictionary when unset. Stat
191
+ // formats carry {rank}/{n}/{points} placeholders the host validated.
192
+ const panel = definition.panel
193
+ const panelLabel = (slot: 'feed' | 'rename' | 'hide' | 'confirm', i18n: string): string =>
194
+ panel?.labels?.[slot] ?? i18n
195
+ const panelStat = (
196
+ slot: 'rank' | 'treats' | 'points',
197
+ i18nKey: 'pet.rank' | 'pet.treats' | 'pet.points',
198
+ values: Record<string, string | number>,
199
+ ): string => {
200
+ const format = panel?.stats?.[slot] ?? props.t(i18nKey, values)
201
+ if (panel?.stats?.[slot] === undefined) return format
202
+ // The host whitelists {rank}/{n}/{points} in every stat slot, so a pack
203
+ // format may reference any of them; substitute all three live values
204
+ // (the slot's own value plus the siblings) instead of only the slot's.
205
+ const all: Record<string, string | number> = {
206
+ rank: snapshot?.affinity.rank ?? '?',
207
+ n: snapshot?.treats.stocked ?? 0,
208
+ points: snapshot?.affinity.points ?? 0,
209
+ }
210
+ let text = format
211
+ for (const [name, value] of Object.entries(all)) text = text.replaceAll('{' + name + '}', String(value))
212
+ return text
213
+ }
214
+ const panelShows = (action: 'feed' | 'rename' | 'hide'): boolean =>
215
+ panel?.actions === undefined || panel.actions.includes(action)
113
216
 
114
217
  // Load the atlas once; the definition carries the authoritative per-row
115
218
  // frame counts and per-track durations, so nothing else is fetched. A
@@ -287,6 +390,8 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
287
390
  const whisper = feedback === null ? snapshot?.whisper : undefined
288
391
  const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
289
392
  const displayName = snapshot?.name ?? definition.displayName
393
+ // The host-served status decoration (M5, #567); absent = text-only bubbles.
394
+ const decoration = snapshot?.decoration
290
395
 
291
396
  // A settled session list can no longer stay pinned open.
292
397
  useEffect(() => {
@@ -408,6 +513,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
408
513
  title={props.t('pet.openSessionHint')}
409
514
  onClick={() => { props.onOpenSession(session.sessionId) }}
410
515
  >
516
+ {index === 0 && !speaksWhisper && decoration !== undefined && (
517
+ <StatusOrnament decoration={decoration} phase={phase} />
518
+ )}
411
519
  {speaksWhisper ? whisper : session.bubble}
412
520
  </button>
413
521
  )
@@ -446,6 +554,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
446
554
  role="status"
447
555
  aria-live="polite"
448
556
  >
557
+ {whisper === undefined && decoration !== undefined && (
558
+ <StatusOrnament decoration={decoration} phase={phase} />
559
+ )}
449
560
  {whisper ?? statusBubble}
450
561
  </div>
451
562
  )}
@@ -507,39 +618,45 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
507
618
  }
508
619
  }}
509
620
  >
510
- {props.t('pet.confirm')}
621
+ {panelLabel('confirm', props.t('pet.confirm'))}
511
622
  </button>
512
623
  </div>
513
624
  ) : (
514
625
  <>
515
626
  <div className={styles.rankRow}>
516
627
  <span className={styles.nameCell}>{displayName}</span>
517
- <span className={styles.statRank}>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
628
+ <span className={styles.statRank}>{panelStat('rank', 'pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
518
629
  </div>
519
630
  <div className={styles.rankRow}>
520
- <span className={styles.statTreats}>{props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
521
- <span className={styles.statPoints}>{props.t('pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
631
+ <span className={styles.statTreats}>{panelStat('treats', 'pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
632
+ <span className={styles.statPoints}>{panelStat('points', 'pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
522
633
  </div>
523
634
  <div className={styles.actions}>
524
- <button type="button" className={styles.action} onClick={props.onFeed}>
525
- {props.t('pet.feed')}
526
- </button>
527
- <button
528
- type="button"
529
- className={styles.action}
530
- onClick={() => {
531
- // Cancel any pending hide so the rename box cannot
532
- // unmount right as the user starts typing (#303).
533
- clearHideTimer()
534
- setNameDraft(displayName)
535
- setRenaming(true)
536
- }}
537
- >
538
- {props.t('pet.rename')}
539
- </button>
540
- <button type="button" className={styles.action} onClick={props.onHide}>
541
- {props.t('pet.hide')}
542
- </button>
635
+ {panelShows('feed') && (
636
+ <button type="button" className={styles.action} onClick={props.onFeed}>
637
+ {panelLabel('feed', props.t('pet.feed'))}
638
+ </button>
639
+ )}
640
+ {panelShows('rename') && (
641
+ <button
642
+ type="button"
643
+ className={styles.action}
644
+ onClick={() => {
645
+ // Cancel any pending hide so the rename box cannot
646
+ // unmount right as the user starts typing (#303).
647
+ clearHideTimer()
648
+ setNameDraft(displayName)
649
+ setRenaming(true)
650
+ }}
651
+ >
652
+ {panelLabel('rename', props.t('pet.rename'))}
653
+ </button>
654
+ )}
655
+ {panelShows('hide') && (
656
+ <button type="button" className={styles.action} onClick={props.onHide}>
657
+ {panelLabel('hide', props.t('pet.hide'))}
658
+ </button>
659
+ )}
543
660
  </div>
544
661
  </>
545
662
  )}
@@ -34,6 +34,8 @@ export const zh = {
34
34
  'settings.petHint': '选择显示哪只宠物;每只宠物独立命名,可在宠物悬浮面板改名。',
35
35
  'settings.enabled': '启用宠物',
36
36
  'settings.enabledHint': '关闭后隐藏宠物并停止轮询,可在设置里重新启用。',
37
+ 'settings.decoration': '状态装饰',
38
+ 'settings.decorationHint': '在宠物状态气泡里显示喷水鲸鱼等状态装饰;关闭后气泡只剩文字。',
37
39
  'settings.visible': '显示宠物',
38
40
  'settings.visibleHint': '关闭后宠物隐藏,可从聊天输入区重新召唤。',
39
41
  'settings.size': '大小(px)',
@@ -87,6 +89,8 @@ export const en = {
87
89
  'settings.petHint': 'Choose which pet shows. Names are stored per pet; rename from the pet hover panel.',
88
90
  'settings.enabled': 'Enable the pet',
89
91
  'settings.enabledHint': 'When off, the pet hides and polling stops; re-enable it here.',
92
+ 'settings.decoration': 'Status decoration',
93
+ 'settings.decorationHint': 'Show ornaments like the spouting whale inside the pet status bubbles; when off, bubbles stay text-only.',
90
94
  'settings.visible': 'Show the pet',
91
95
  'settings.visibleHint': 'When off, the pet hides; summon it again from the input row.',
92
96
  'settings.size': 'Size (px)',
@@ -75,13 +75,17 @@ function fakeApp(): FakeApp {
75
75
  return app
76
76
  }
77
77
 
78
- function fakeVendor(model: FakeModel, app: FakeApp): unknown {
78
+ function fakeVendor(
79
+ model: FakeModel,
80
+ app: FakeApp,
81
+ from: (source: string, options?: Record<string, unknown>) => Promise<FakeModel> = () => Promise.resolve(model),
82
+ ): unknown {
79
83
  return {
80
84
  Application: class { constructor() { return app } },
81
85
  extensions: { add: () => {} },
82
86
  Live2DPlugin: {},
83
87
  configureCubismSDK: () => {},
84
- Live2DModel: { from: () => Promise.resolve(model) },
88
+ Live2DModel: { from },
85
89
  }
86
90
  }
87
91
 
@@ -142,6 +146,23 @@ describe('live2dRenderer', () => {
142
146
  expect(app.destroyed).toBe(1)
143
147
  })
144
148
 
149
+ it('uses one automatic texture LOD instead of the default full mip chain', async () => {
150
+ const model = fakeModel()
151
+ const app = fakeApp()
152
+ const from = vi.fn(async () => model)
153
+ runtime.vendor = fakeVendor(model, app, from)
154
+ const { ctx } = makeCtx()
155
+
156
+ live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
157
+ await flush()
158
+
159
+ expect(from).toHaveBeenCalledWith(CONFIG.modelUrl, {
160
+ autoHitTest: false,
161
+ autoFocus: false,
162
+ textureOptions: { lod: 'single-auto' },
163
+ })
164
+ })
165
+
145
166
  it('falls back to the idle group when a mapped group is absent from the model', async () => {
146
167
  const model = fakeModel({ Idle: [{}] })
147
168
  runtime.vendor = fakeVendor(model, fakeApp())
@@ -60,6 +60,15 @@ export interface Live2dRendererHandle extends PetRendererHandle {
60
60
  /** The de-facto tap-motion group of Cubism sample models. */
61
61
  const TAP_GROUP = 'TapBody'
62
62
 
63
+ /**
64
+ * Keep one screen-appropriate atlas LOD instead of asking Pixi for the
65
+ * engine's default full mip chain. A user model can legitimately carry an
66
+ * 8192px texture while the pet itself is only a few hundred pixels tall;
67
+ * `single-auto` preserves the source for larger renders and generates one
68
+ * downsampled atlas only when the effective on-screen scale warrants it.
69
+ */
70
+ const TEXTURE_OPTIONS = { lod: 'single-auto' } as const
71
+
63
72
  let vendorConfigured = false
64
73
 
65
74
  /** Configure pixi extensions + the Cubism SDK once per page. */
@@ -150,7 +159,11 @@ export const live2dRenderer: PetRenderer<PetLive2dConfig> = {
150
159
  pixiApp.canvas.style.width = '100%'
151
160
  pixiApp.canvas.style.height = '100%'
152
161
  ctx.container.appendChild(pixiApp.canvas)
153
- const loaded = await vendor.Live2DModel.from(config.modelUrl, { autoHitTest: false, autoFocus: false })
162
+ const loaded = await vendor.Live2DModel.from(config.modelUrl, {
163
+ autoHitTest: false,
164
+ autoFocus: false,
165
+ textureOptions: TEXTURE_OPTIONS,
166
+ })
154
167
  if (disposed) {
155
168
  pixiApp.destroy(true, { children: true })
156
169
  return
@@ -333,9 +333,11 @@ export class CardForm<T> {
333
333
  const valid = plan.filter(item => item.run !== undefined)
334
334
  if (plan.length === 0 || this.saving || valid.length !== plan.length) return
335
335
  const plannedWrites = valid.map(item => item.op)
336
- // Snapshot the fields this save writes, so edits staged while it is in
337
- // flight survive: only the staged keys this save actually wrote are cleared.
338
- const fields = new Set(plan.map(item => item.field))
336
+ // Snapshot the staged entries this save writes, so an edit staged while it
337
+ // is in flight (which replaces the same key) survives: only delete the key
338
+ // when the entry is still the one this save started from.
339
+ const pending = new Map<string, StagedEdit | undefined>()
340
+ for (const item of plan) pending.set(item.field, this.staged.get(item.field))
339
341
  this.saving = true
340
342
  this.failed = false
341
343
  this.failedReason = undefined
@@ -356,11 +358,11 @@ export class CardForm<T> {
356
358
  if (await item.run!()) landed.add(item.field)
357
359
  }
358
360
  }
359
- for (const field of fields) {
360
- if (landed.has(field)) this.staged.delete(field)
361
+ for (const [field, before] of pending) {
362
+ if (landed.has(field) && this.staged.get(field) === before) this.staged.delete(field)
361
363
  }
362
364
  this.saving = false
363
- this.failed = landed.size !== fields.size
365
+ this.failed = landed.size !== pending.size
364
366
  this.publish()
365
367
  }
366
368
 
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Status-decoration contract — the L3 extension slot for small status
3
+ * ornaments (issue #623 milestone M5, protocol #567, first reference
4
+ * implementation #463). A decoration is an INDEPENDENT content entry (own
5
+ * id, own directory, own descriptor) whose PNG/WebP sprite strip ornaments
6
+ * the pet center's status bubble chrome; it never touches the pet
7
+ * manifests and never changes the bubble's semantics.
8
+ *
9
+ * Adopted disciplines (#623): entry assets are PNG/WebP sprite strips only
10
+ * (no SVG, no CSS animation); the bubble's own role=status/aria-live (or
11
+ * session-bubble button semantics) always stays intact and the ornament is
12
+ * aria-hidden; load failure or prefers-reduced-motion degrades to no
13
+ * ornament or the static first frame.
14
+ *
15
+ * The ActivityPhase stream the pet center owns drives the ornament: each
16
+ * phase binds to a frame segment (inclusive from/to indices into the
17
+ * strip) or to 'hide' (no ornament for that phase; the default).
18
+ * @module @linxin666/dsh-pet/contracts/status-decoration
19
+ */
20
+
21
+ import type { ActivityPhase } from '../state.ts'
22
+
23
+ /** Contract version decorations declare against (independent of manifests). */
24
+ export const PET_DECORATION_API_VERSION = 'x-org.linxin666.pet-center/status-decoration-v1' as const
25
+
26
+ /** One phase binding: a frame segment, or hidden. */
27
+ export type PhaseSegment = { from: number; to: number } | 'hide'
28
+
29
+ /** The ActivityPhase -> frame-segment binding table (unmapped phases hide). */
30
+ export type PhaseBindings = Partial<Record<ActivityPhase, PhaseSegment>>
31
+
32
+ /** Normalized decoration descriptor as the registry consumes it. */
33
+ export interface DecorationManifest {
34
+ decorationManifestVersion: 1
35
+ id: string
36
+ displayName: string
37
+ license: string
38
+ /** Strip path relative to the descriptor directory (PNG/WebP). */
39
+ entry: string
40
+ cell: { width: number; height: number }
41
+ columns: number
42
+ /** Per-frame duration ms (same length as columns, or a single constant). */
43
+ durations: number[]
44
+ loop: boolean
45
+ phases: PhaseBindings
46
+ }
47
+
48
+ /** One structured diagnostic emitted while parsing a descriptor. */
49
+ export interface DecorationDiagnostic {
50
+ level: 'error' | 'warning'
51
+ message: string
52
+ }
53
+
54
+ /** Parse outcome: a usable descriptor plus diagnostics, or rejection. */
55
+ export type DecorationManifestParse =
56
+ | { ok: true; manifest: DecorationManifest; diagnostics: DecorationDiagnostic[] }
57
+ | { ok: false; diagnostics: DecorationDiagnostic[] }
58
+
59
+ /**
60
+ * The decoration block the host serves inside the pet state view — exactly
61
+ * what the browser half needs to render the ornament, nothing more. The
62
+ * apiVersion rides the wire so a future protocol revision can be detected
63
+ * and negotiated by clients (review-spd follow-up, pet-center M5).
64
+ */
65
+ export interface DecorationView {
66
+ /** The protocol version this view speaks (PET_DECORATION_API_VERSION). */
67
+ apiVersion: typeof PET_DECORATION_API_VERSION
68
+ id: string
69
+ /** Same-origin URL prefix of the decoration's assets. */
70
+ assetBase: string
71
+ /** Browser URL of the strip. */
72
+ entryUrl: string
73
+ cell: { width: number; height: number }
74
+ columns: number
75
+ durations: number[]
76
+ loop: boolean
77
+ phases: PhaseBindings
78
+ }
@@ -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
+ })