@linxin666/dsh-pet 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +129 -3
  3. package/README.zh.md +129 -3
  4. package/assets/decorations/whale/decoration.json +20 -0
  5. package/assets/decorations/whale/whale-frames.png +0 -0
  6. package/assets/whale/pet.json +169 -20
  7. package/assets/whale-refined/pet.json +17 -2
  8. package/contracts/pet-manifest-v2.schema.json +281 -0
  9. package/contracts/status-decoration-v1.schema.json +144 -0
  10. package/contracts/voice-pack-v1.schema.json +234 -0
  11. package/lib/client.js +581 -38
  12. package/lib/client.js.map +1 -1
  13. package/lib/index.js +1684 -59
  14. package/lib/live2d-vendor.js +995 -0
  15. package/lib/live2d-vendor.js.map +1 -0
  16. package/lib/types/chatter.d.ts +61 -3
  17. package/lib/types/chatter.d.ts.map +1 -1
  18. package/lib/types/chatter.js +71 -12
  19. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  20. package/lib/types/client/PetDockEntry.js +2 -1
  21. package/lib/types/client/PetSettingsCard.d.ts +14 -0
  22. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  23. package/lib/types/client/PetSettingsCard.js +24 -1
  24. package/lib/types/client/PetSprite.d.ts +7 -1
  25. package/lib/types/client/PetSprite.d.ts.map +1 -1
  26. package/lib/types/client/PetSprite.js +120 -12
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/index.js +5 -0
  29. package/lib/types/client/locales.d.ts +14 -0
  30. package/lib/types/client/locales.d.ts.map +1 -1
  31. package/lib/types/client/locales.js +14 -0
  32. package/lib/types/client/phase-stream.d.ts +22 -0
  33. package/lib/types/client/phase-stream.d.ts.map +1 -0
  34. package/lib/types/client/phase-stream.js +28 -0
  35. package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
  36. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
  37. package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
  38. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
  39. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
  40. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
  41. package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
  42. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
  43. package/lib/types/client/renderers/live2d/runtime.js +63 -0
  44. package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
  45. package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
  46. package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
  47. package/lib/types/client/renderers/live2d.d.ts +52 -0
  48. package/lib/types/client/renderers/live2d.d.ts.map +1 -0
  49. package/lib/types/client/renderers/live2d.js +192 -0
  50. package/lib/types/client/renderers/registry.d.ts +32 -0
  51. package/lib/types/client/renderers/registry.d.ts.map +1 -0
  52. package/lib/types/client/renderers/registry.js +49 -0
  53. package/lib/types/client/settings-form.d.ts.map +1 -1
  54. package/lib/types/client/settings-form.js +9 -6
  55. package/lib/types/contracts/renderer.d.ts +50 -0
  56. package/lib/types/contracts/renderer.d.ts.map +1 -0
  57. package/lib/types/contracts/renderer.js +15 -0
  58. package/lib/types/contracts/status-decoration.d.ts +85 -0
  59. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  60. package/lib/types/contracts/status-decoration.js +21 -0
  61. package/lib/types/decoration.d.ts +39 -0
  62. package/lib/types/decoration.d.ts.map +1 -0
  63. package/lib/types/decoration.js +210 -0
  64. package/lib/types/event-projection.d.ts +8 -3
  65. package/lib/types/event-projection.d.ts.map +1 -1
  66. package/lib/types/event-projection.js +9 -4
  67. package/lib/types/index.d.ts +2 -0
  68. package/lib/types/index.d.ts.map +1 -1
  69. package/lib/types/index.js +2 -0
  70. package/lib/types/manifest-v2.d.ts +129 -0
  71. package/lib/types/manifest-v2.d.ts.map +1 -0
  72. package/lib/types/manifest-v2.js +387 -0
  73. package/lib/types/model3.d.ts +26 -0
  74. package/lib/types/model3.d.ts.map +1 -0
  75. package/lib/types/model3.js +96 -0
  76. package/lib/types/registry.d.ts +112 -5
  77. package/lib/types/registry.d.ts.map +1 -1
  78. package/lib/types/registry.js +395 -38
  79. package/lib/types/routes.d.ts +39 -2
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +312 -13
  82. package/lib/types/service.d.ts +38 -1
  83. package/lib/types/service.d.ts.map +1 -1
  84. package/lib/types/service.js +46 -3
  85. package/lib/types/voice-pack.d.ts +98 -0
  86. package/lib/types/voice-pack.d.ts.map +1 -0
  87. package/lib/types/voice-pack.js +384 -0
  88. package/package.json +13 -10
  89. package/src/chatter.test.ts +89 -2
  90. package/src/chatter.ts +120 -14
  91. package/src/client/PetDockEntry.tsx +19 -11
  92. package/src/client/PetSettingsCard.tsx +57 -0
  93. package/src/client/PetSprite.test.tsx +265 -12
  94. package/src/client/PetSprite.tsx +165 -33
  95. package/src/client/index.ts +6 -0
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/phase-stream.test.ts +76 -0
  98. package/src/client/phase-stream.ts +39 -0
  99. package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
  100. package/src/client/renderers/PetRendererSwitch.tsx +48 -0
  101. package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
  102. package/src/client/renderers/live2d/runtime.test.ts +61 -0
  103. package/src/client/renderers/live2d/runtime.ts +119 -0
  104. package/src/client/renderers/live2d/vendor-entry.ts +14 -0
  105. package/src/client/renderers/live2d.test.ts +263 -0
  106. package/src/client/renderers/live2d.ts +231 -0
  107. package/src/client/renderers/registry.ts +58 -0
  108. package/src/client/settings-form.ts +8 -6
  109. package/src/client/settings-section.module.css +23 -0
  110. package/src/contracts/renderer.ts +54 -0
  111. package/src/contracts/status-decoration.ts +78 -0
  112. package/src/decoration.test.ts +178 -0
  113. package/src/decoration.ts +220 -0
  114. package/src/event-projection.ts +10 -5
  115. package/src/index.ts +2 -0
  116. package/src/manifest-v2.test.ts +251 -0
  117. package/src/manifest-v2.ts +414 -0
  118. package/src/model3.test.ts +79 -0
  119. package/src/model3.ts +91 -0
  120. package/src/registry.test.ts +438 -2
  121. package/src/registry.ts +470 -38
  122. package/src/routes.ts +328 -14
  123. package/src/service.ts +65 -3
  124. package/src/voice-pack.test.ts +216 -0
  125. package/src/voice-pack.ts +413 -0
@@ -11,6 +11,7 @@ import { t } from './locales.ts'
11
11
  import type { PetStateView } from '../service.ts'
12
12
  import type { PetDefinition, PetTrackDef } from '../registry.ts'
13
13
  import type { PetAnimation } from '../state.ts'
14
+ import type { DecorationView } from '../contracts/status-decoration.ts'
14
15
 
15
16
  /** A minimal pet definition (geometry + tracks) as served by the host. */
16
17
  function petDefinition(): PetDefinition {
@@ -24,6 +25,7 @@ function petDefinition(): PetDefinition {
24
25
  id: 'whale-girl',
25
26
  displayName: '鲸鱼娘',
26
27
  description: '测试用鲸鱼娘',
28
+ renderer: 'sprite2d',
27
29
  cell: { width: 192, height: 208 },
28
30
  columns: 8,
29
31
  rows: [6, 8, 8, 4, 5, 8, 6, 6, 6],
@@ -90,14 +92,9 @@ afterEach(() => {
90
92
  vi.restoreAllMocks()
91
93
  })
92
94
 
93
- /** Render the pet with mocked callbacks; returns the rename and open spys. */
94
- function renderPet(overrides: Partial<PetSpriteProps> = {}): {
95
- onRename: ReturnType<typeof vi.fn>
96
- onOpenSession: ReturnType<typeof vi.fn>
97
- } {
98
- const onRename = vi.fn()
99
- const onOpenSession = vi.fn()
100
- const props: PetSpriteProps = {
95
+ /** Build the mocked props for one render. */
96
+ function petProps(overrides: Partial<PetSpriteProps> = {}): PetSpriteProps {
97
+ return {
101
98
  snapshot,
102
99
  definition: petDefinition(),
103
100
  display: snapshot.display,
@@ -106,14 +103,24 @@ function renderPet(overrides: Partial<PetSpriteProps> = {}): {
106
103
  onFeed: vi.fn(),
107
104
  onHide: vi.fn(),
108
105
  onDragEnd: vi.fn(),
109
- onRename,
110
- onOpenSession,
106
+ onRename: vi.fn(),
107
+ onOpenSession: vi.fn(),
111
108
  onFeedbackDone: vi.fn(),
112
109
  t,
113
110
  ...overrides,
114
111
  }
115
- render(<PetSprite {...props} />)
116
- return { onRename, onOpenSession }
112
+ }
113
+
114
+ /** Render the pet with mocked callbacks; returns the rename/open spys + the RTL result. */
115
+ function renderPet(overrides: Partial<PetSpriteProps> = {}): {
116
+ onRename: ReturnType<typeof vi.fn>
117
+ onOpenSession: ReturnType<typeof vi.fn>
118
+ result: ReturnType<typeof render>
119
+ } {
120
+ const onRename = vi.fn()
121
+ const onOpenSession = vi.fn()
122
+ const result = render(<PetSprite {...petProps({ onRename, onOpenSession, ...overrides })} />)
123
+ return { onRename, onOpenSession, result }
117
124
  }
118
125
 
119
126
  /** Hover the sprite to open the panel, then click the rename button. */
@@ -142,6 +149,16 @@ function fireComposingKeydown(target: Element, key: string): void {
142
149
  fireEvent.compositionEnd(target)
143
150
  }
144
151
 
152
+ describe('PetSprite custom visual (pet-center M3)', () => {
153
+ it('renders the visual inside the sprite box and skips the atlas background', () => {
154
+ renderPet({ visual: <canvas data-testid="custom-visual" /> })
155
+ const spriteEl = screen.getByRole('button', { name: '鲸鱼娘' })
156
+ expect(spriteEl.querySelector('[data-testid="custom-visual"]')).toBeTruthy()
157
+ // The atlas background never applies while a renderer visual owns the box.
158
+ expect(spriteEl.style.backgroundImage).toBe('')
159
+ })
160
+ })
161
+
145
162
  describe('PetSprite rename input', () => {
146
163
  it('submits the draft on Enter outside composition', () => {
147
164
  const { onRename } = renderPet()
@@ -544,3 +561,239 @@ describe('PetSprite definition-driven render', () => {
544
561
  expect(sprite.style.backgroundPosition).toBe('0px -960px')
545
562
  })
546
563
  })
564
+
565
+ describe('PetSprite panel chrome from the voice pack (pet-center M4)', () => {
566
+ const voicedDefinition = (): PetDefinition => ({
567
+ ...petDefinition(),
568
+ panel: {
569
+ labels: { feed: '投喂', rename: '起名字', hide: '藏起来', confirm: '好的' },
570
+ stats: { rank: '好感 {rank}', treats: '鱼干 {n}', points: '{points} 分' },
571
+ },
572
+ })
573
+
574
+ it('renders pack labels and stat formats, falling back per slot', () => {
575
+ renderPet({ definition: voicedDefinition() })
576
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
577
+ expect(screen.getByText('投喂')).toBeDefined()
578
+ expect(screen.getByText('起名字')).toBeDefined()
579
+ expect(screen.getByText('藏起来')).toBeDefined()
580
+ expect(screen.getByText('好感 幼鲸')).toBeDefined()
581
+ expect(screen.getByText('鱼干 3')).toBeDefined()
582
+ expect(screen.getByText('0 分')).toBeDefined()
583
+ })
584
+
585
+ it('uses the pack confirm label inside the rename row', () => {
586
+ renderPet({ definition: voicedDefinition() })
587
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
588
+ fireEvent.click(screen.getByText('起名字'))
589
+ expect(screen.getByText('好的')).toBeDefined()
590
+ })
591
+
592
+ it('hides actions the pack omits', () => {
593
+ renderPet({
594
+ definition: {
595
+ ...petDefinition(),
596
+ panel: { labels: { feed: '投喂' }, actions: ['feed'] },
597
+ },
598
+ })
599
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
600
+ expect(screen.getByText('投喂')).toBeDefined()
601
+ expect(screen.queryByText('改名')).toBeNull()
602
+ expect(screen.queryByText('隐藏')).toBeNull()
603
+ })
604
+
605
+ it('renders no action buttons when the pack hides them all', () => {
606
+ renderPet({ definition: { ...petDefinition(), panel: { actions: [] } } })
607
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
608
+ expect(screen.queryByText('喂食')).toBeNull()
609
+ expect(screen.queryByText('改名')).toBeNull()
610
+ expect(screen.queryByText('隐藏')).toBeNull()
611
+ // The stat rows keep rendering.
612
+ expect(screen.getByText('亲密度 幼鲸')).toBeDefined()
613
+ expect(screen.getByText('小鱼干 ×3')).toBeDefined()
614
+ })
615
+
616
+ it('keeps the i18n copy when the pet carries no panel', () => {
617
+ renderPet()
618
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
619
+ expect(screen.getByText('喂食')).toBeDefined()
620
+ expect(screen.getByText('改名')).toBeDefined()
621
+ expect(screen.getByText('隐藏')).toBeDefined()
622
+ expect(screen.getByText('亲密度 幼鲸')).toBeDefined()
623
+ })
624
+
625
+ it('substitutes cross-slot placeholders in pack stat formats', () => {
626
+ renderPet({ definition: { ...petDefinition(), panel: { stats: { treats: '鱼干 {n}({points} 分,{rank})' } } } })
627
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
628
+ // The host whitelists {rank}/{n}/{points} in every stat slot, so a pack
629
+ // format may reference any of them; all three live values substitute.
630
+ expect(screen.getByText('鱼干 3(0 分,幼鲸)')).toBeDefined()
631
+ })
632
+ })
633
+ describe('PetSprite status decoration (pet-center M5, #567)', () => {
634
+ const decoration: DecorationView = {
635
+ apiVersion: 'x-org.linxin666.pet-center/status-decoration-v1',
636
+ id: 'whale',
637
+ assetBase: '/api/pet/decoration/whale',
638
+ entryUrl: '/api/pet/decoration/whale/whale-frames.png',
639
+ cell: { width: 64, height: 48 },
640
+ columns: 4,
641
+ durations: [160, 160, 160, 160],
642
+ loop: true,
643
+ phases: {
644
+ idle: 'hide',
645
+ waiting: { from: 0, to: 1 },
646
+ thinking: { from: 0, to: 3 },
647
+ done: { from: 2, to: 3 },
648
+ },
649
+ }
650
+
651
+ const ornament = (): HTMLElement | null => document.body.querySelector('[data-dsh-pet-decoration="whale"]')
652
+
653
+ it('renders an aria-hidden ornament inside the status bubble for a bound phase', () => {
654
+ renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
655
+ const el = ornament()
656
+ expect(el).not.toBeNull()
657
+ expect(el!.getAttribute('aria-hidden')).toBe('true')
658
+ expect(el!.style.backgroundImage).toContain('whale-frames.png')
659
+ // The bubble keeps its semantics beside the ornament.
660
+ const bubble = document.body.querySelector('[role="status"][aria-live="polite"]')
661
+ expect(bubble).not.toBeNull()
662
+ expect(bubble!.textContent).toContain('正在思考')
663
+ })
664
+
665
+ it('hides the ornament for phases bound to hide and for the idle default', () => {
666
+ renderPet({ snapshot: { ...snapshot, bubble: '等待', phase: 'idle', decoration } })
667
+ expect(ornament()).toBeNull()
668
+ })
669
+
670
+ it('holds the segment first frame under prefers-reduced-motion', () => {
671
+ renderPet({ snapshot: { ...snapshot, bubble: '完成', phase: 'done', decoration } })
672
+ const el = ornament()
673
+ expect(el).not.toBeNull()
674
+ // The harness matchMedia mock reports reduced motion, so the ornament
675
+ // rests on the segment's first frame (column 2 of a 24px-wide frame).
676
+ expect(el!.style.backgroundPosition).toBe('-48px 0px')
677
+ })
678
+
679
+ it('yields the bubble to the whisper (voice moment hides the ornament)', () => {
680
+ renderPet({ snapshot: { ...snapshot, phase: 'thinking', whisper: '冲了冲了', decoration } })
681
+ expect(ornament()).toBeNull()
682
+ expect(document.body.textContent).toContain('冲了冲了')
683
+ })
684
+
685
+ it('renders no ornament when the host serves no decoration', () => {
686
+ renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking' } })
687
+ expect(ornament()).toBeNull()
688
+ })
689
+
690
+ it('advances the ornament frames on the rAF loop and wraps when looping', () => {
691
+ vi.spyOn(window, 'matchMedia').mockReturnValue({
692
+ matches: false,
693
+ media: '(prefers-reduced-motion: reduce)',
694
+ onchange: null,
695
+ addEventListener: () => {},
696
+ removeEventListener: () => {},
697
+ addListener: () => {},
698
+ removeListener: () => {},
699
+ dispatchEvent: () => false,
700
+ })
701
+ vi.spyOn(performance, 'now').mockReturnValue(0)
702
+ const frames: FrameRequestCallback[] = []
703
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
704
+ frames.push(callback)
705
+ return frames.length
706
+ })
707
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
708
+ renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
709
+ const el = ornament()!
710
+ // Both the sprite loop and the ornament loop schedule frames; step every
711
+ // pending callback together (the sprite's idle track never moves).
712
+ const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
713
+ // frameWidth = round(64 * 18 / 48) = 24 px; thinking binds frames 0..3.
714
+ expect(el.style.backgroundPosition).toBe('0px 0px')
715
+ act(() => { step(161) })
716
+ expect(el.style.backgroundPosition).toBe('-24px 0px')
717
+ act(() => { step(322) })
718
+ expect(el.style.backgroundPosition).toBe('-48px 0px')
719
+ act(() => { step(483) })
720
+ expect(el.style.backgroundPosition).toBe('-72px 0px')
721
+ act(() => { step(644) })
722
+ // The looping segment wraps back to its first frame.
723
+ expect(el.style.backgroundPosition).toBe('0px 0px')
724
+ })
725
+
726
+ it('holds the segment last frame when the loop is off and stops scheduling', () => {
727
+ vi.spyOn(window, 'matchMedia').mockReturnValue({
728
+ matches: false,
729
+ media: '(prefers-reduced-motion: reduce)',
730
+ onchange: null,
731
+ addEventListener: () => {},
732
+ removeEventListener: () => {},
733
+ addListener: () => {},
734
+ removeListener: () => {},
735
+ dispatchEvent: () => false,
736
+ })
737
+ vi.spyOn(performance, 'now').mockReturnValue(0)
738
+ const frames: FrameRequestCallback[] = []
739
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
740
+ frames.push(callback)
741
+ return frames.length
742
+ })
743
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
744
+ renderPet({ snapshot: { ...snapshot, bubble: '完成', phase: 'done', decoration: { ...decoration, loop: false } } })
745
+ const el = ornament()!
746
+ const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
747
+ // done binds frames 2..3; the segment starts on frame 2.
748
+ expect(el.style.backgroundPosition).toBe('-48px 0px')
749
+ act(() => { step(161) })
750
+ expect(el.style.backgroundPosition).toBe('-72px 0px')
751
+ // The ornament stopped scheduling (only the sprite loop remains pending).
752
+ expect(frames).toHaveLength(1)
753
+ act(() => { step(161) })
754
+ // The last frame holds.
755
+ expect(el.style.backgroundPosition).toBe('-72px 0px')
756
+ })
757
+
758
+ it('does not restart the frame loop when an equal-content decoration re-renders', () => {
759
+ vi.spyOn(window, 'matchMedia').mockReturnValue({
760
+ matches: false,
761
+ media: '(prefers-reduced-motion: reduce)',
762
+ onchange: null,
763
+ addEventListener: () => {},
764
+ removeEventListener: () => {},
765
+ addListener: () => {},
766
+ removeListener: () => {},
767
+ dispatchEvent: () => false,
768
+ })
769
+ vi.spyOn(performance, 'now').mockReturnValue(0)
770
+ const frames: FrameRequestCallback[] = []
771
+ const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
772
+ frames.push(callback)
773
+ return frames.length
774
+ })
775
+ const cancelSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
776
+ // The definition comes from '/api/pet/pets', fetched once — a state
777
+ // poll never replaces it, so both renders share one definition object.
778
+ const definition = petDefinition()
779
+ const { result } = renderPet({ definition, snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
780
+ const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
781
+ act(() => { step(161) })
782
+ expect(ornament()!.style.backgroundPosition).toBe('-24px 0px')
783
+ const schedulesBefore = rafSpy.mock.calls.length
784
+ // The 2 s poll delivers a fresh JSON round-trip: identical content, new
785
+ // object identities everywhere. The loop must not cancel/restart.
786
+ const repolled: DecorationView = {
787
+ ...decoration,
788
+ cell: { ...decoration.cell },
789
+ durations: [...decoration.durations],
790
+ phases: { ...decoration.phases },
791
+ }
792
+ result.rerender(<PetSprite {...petProps({ definition, snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration: repolled } })} />)
793
+ act(() => { step(322) })
794
+ expect(ornament()!.style.backgroundPosition).toBe('-48px 0px')
795
+ expect(cancelSpy).not.toHaveBeenCalled()
796
+ // One reschedule per loop tick; no effect restart added new schedules.
797
+ expect(rafSpy.mock.calls.length).toBe(schedulesBefore + 2)
798
+ })
799
+ })
@@ -10,17 +10,18 @@
10
10
  */
11
11
 
12
12
  import { useEffect, useLayoutEffect, useRef, useState } from 'react'
13
- import type { CSSProperties, PointerEvent as ReactPointerEvent, 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
 
@@ -48,6 +49,12 @@ export interface PetSpriteProps {
48
49
  onOpenSession: (sessionId: string) => void
49
50
  /** Clear the reaction bubble (after its CSS animation). */
50
51
  onFeedbackDone: () => void
52
+ /**
53
+ * Custom visual replacing the sprite2d atlas animation (pet-center M3).
54
+ * The chrome (drag, bubbles, panel, tap economy) is untouched: the visual
55
+ * renders inside the sprite box, and the atlas load + frame loop skip.
56
+ */
57
+ visual?: ReactNode
51
58
  /** Locale translate seat (namespace-bound). */
52
59
  t: TranslateNS<typeof NS>
53
60
  }
@@ -57,6 +64,81 @@ function clampOffset(value: number, max: number): number {
57
64
  return Math.max(0, Math.min(max, value))
58
65
  }
59
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
+
60
142
  /**
61
143
  * The floating pet. The spritesheet frame advances on requestAnimationFrame
62
144
  * with per-frame durations from the definition's tracks; the atlas image is
@@ -104,10 +186,39 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
104
186
  const rows = definition.rows
105
187
  const tracks = definition.tracks
106
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)
107
216
 
108
217
  // Load the atlas once; the definition carries the authoritative per-row
109
- // frame counts and per-track durations, so nothing else is fetched.
218
+ // frame counts and per-track durations, so nothing else is fetched. A
219
+ // custom visual (pet-center M3) replaces the atlas entirely.
110
220
  useEffect(() => {
221
+ if (props.visual !== undefined) return
111
222
  let cancelled = false
112
223
  const img = new Image()
113
224
  img.onload = () => {
@@ -118,7 +229,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
118
229
  cancelled = true
119
230
  img.onload = null
120
231
  }
121
- }, [definition.atlasUrl])
232
+ }, [definition.atlasUrl, props.visual])
122
233
 
123
234
  // Frame loop: advance the current track and write background-position.
124
235
  // Offsets must be in SCALED coordinates (background-position applies to the
@@ -132,6 +243,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
132
243
  const scaleRef = useRef(spriteScale)
133
244
  scaleRef.current = spriteScale
134
245
  useEffect(() => {
246
+ if (props.visual !== undefined) return
135
247
  const reduceMotion = typeof window !== 'undefined'
136
248
  && window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
137
249
  const sequence = animation === animationForPhase(phase) ? sequences?.[phase] : undefined
@@ -200,7 +312,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
200
312
  }
201
313
  raf = requestAnimationFrame(tick)
202
314
  return () => cancelAnimationFrame(raf)
203
- }, [animation, phase, cell, columns, rows, tracks, sequences])
315
+ }, [animation, phase, cell, columns, rows, tracks, sequences, props.visual])
204
316
 
205
317
  // Auto-clear the feedback bubble after its CSS animation. The callback
206
318
  // rides a ref so re-renders never reset the timer: the 2s poll rebuilds
@@ -278,6 +390,8 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
278
390
  const whisper = feedback === null ? snapshot?.whisper : undefined
279
391
  const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
280
392
  const displayName = snapshot?.name ?? definition.displayName
393
+ // The host-served status decoration (M5, #567); absent = text-only bubbles.
394
+ const decoration = snapshot?.decoration
281
395
 
282
396
  // A settled session list can no longer stay pinned open.
283
397
  useEffect(() => {
@@ -343,10 +457,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
343
457
  style={{
344
458
  width: spriteWidth,
345
459
  height: spriteHeight,
346
- backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
347
- backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
348
- backgroundRepeat: 'no-repeat',
349
- backgroundPosition: '0 0',
460
+ ...(props.visual === undefined
461
+ ? {
462
+ backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
463
+ backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
464
+ backgroundRepeat: 'no-repeat',
465
+ backgroundPosition: '0 0',
466
+ }
467
+ : {}),
350
468
  cursor: dragRef.current === null ? 'grab' : 'grabbing',
351
469
  }}
352
470
  onPointerDown={onPointerDown}
@@ -360,7 +478,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
360
478
  }}
361
479
  role="button"
362
480
  aria-label={definition.displayName}
363
- />
481
+ >
482
+ {props.visual}
483
+ </div>
364
484
  {feedback !== null && (
365
485
  <div key={feedback.at} ref={bubbleRef} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
366
486
  {feedback.text}
@@ -393,6 +513,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
393
513
  title={props.t('pet.openSessionHint')}
394
514
  onClick={() => { props.onOpenSession(session.sessionId) }}
395
515
  >
516
+ {index === 0 && !speaksWhisper && decoration !== undefined && (
517
+ <StatusOrnament decoration={decoration} phase={phase} />
518
+ )}
396
519
  {speaksWhisper ? whisper : session.bubble}
397
520
  </button>
398
521
  )
@@ -431,6 +554,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
431
554
  role="status"
432
555
  aria-live="polite"
433
556
  >
557
+ {whisper === undefined && decoration !== undefined && (
558
+ <StatusOrnament decoration={decoration} phase={phase} />
559
+ )}
434
560
  {whisper ?? statusBubble}
435
561
  </div>
436
562
  )}
@@ -492,39 +618,45 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
492
618
  }
493
619
  }}
494
620
  >
495
- {props.t('pet.confirm')}
621
+ {panelLabel('confirm', props.t('pet.confirm'))}
496
622
  </button>
497
623
  </div>
498
624
  ) : (
499
625
  <>
500
626
  <div className={styles.rankRow}>
501
627
  <span className={styles.nameCell}>{displayName}</span>
502
- <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>
503
629
  </div>
504
630
  <div className={styles.rankRow}>
505
- <span className={styles.statTreats}>{props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
506
- <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>
507
633
  </div>
508
634
  <div className={styles.actions}>
509
- <button type="button" className={styles.action} onClick={props.onFeed}>
510
- {props.t('pet.feed')}
511
- </button>
512
- <button
513
- type="button"
514
- className={styles.action}
515
- onClick={() => {
516
- // Cancel any pending hide so the rename box cannot
517
- // unmount right as the user starts typing (#303).
518
- clearHideTimer()
519
- setNameDraft(displayName)
520
- setRenaming(true)
521
- }}
522
- >
523
- {props.t('pet.rename')}
524
- </button>
525
- <button type="button" className={styles.action} onClick={props.onHide}>
526
- {props.t('pet.hide')}
527
- </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
+ )}
528
660
  </div>
529
661
  </>
530
662
  )}
@@ -26,6 +26,8 @@ import { createElement } from 'react'
26
26
  import { createRoot } from 'react-dom/client'
27
27
  import { createPetStore, type PetStoreInstance } from './pet-store.ts'
28
28
  import { PetDockEntry, type PetInjected } from './PetDockEntry.tsx'
29
+ import { defaultPetRendererRegistry } from './renderers/registry.ts'
30
+ import { live2dRenderer } from './renderers/live2d.ts'
29
31
  import { PetSettingsSection, PetSettingsCardController, type PetSettings } from './PetSettingsCard.tsx'
30
32
  import { NS, en, zh, t } from './locales.ts'
31
33
 
@@ -103,6 +105,10 @@ declare module '@deepseek-ai/cordis' {
103
105
  export function apply(ctx: ClientContext): void {
104
106
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'pet: dictionaries')
105
107
 
108
+ // Built-in renderers dispatch through the plugin-wide registry (pet-center
109
+ // M3). Registration is idempotent (id wins), so re-applies stay clean.
110
+ defaultPetRendererRegistry.register(live2dRenderer)
111
+
106
112
  const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
107
113
  const settingsScope = binder.bind<PetSettings>({ namespace: PET_SETTINGS_NS })
108
114
  const enabled = (): boolean => {