@linxin666/dsh-pet 0.3.2 → 0.3.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 (127) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +25 -7
  3. package/README.zh.md +25 -7
  4. package/assets/ouo-neko/pet.json +31 -0
  5. package/assets/ouo-neko/previews/failed.gif +0 -0
  6. package/assets/ouo-neko/previews/idle.gif +0 -0
  7. package/assets/ouo-neko/previews/jumping.gif +0 -0
  8. package/assets/ouo-neko/previews/review.gif +0 -0
  9. package/assets/ouo-neko/previews/running-left.gif +0 -0
  10. package/assets/ouo-neko/previews/running-right.gif +0 -0
  11. package/assets/ouo-neko/previews/running.gif +0 -0
  12. package/assets/ouo-neko/previews/waiting.gif +0 -0
  13. package/assets/ouo-neko/previews/waving.gif +0 -0
  14. package/assets/ouo-neko/spritesheet.webp +0 -0
  15. package/contracts/pet-manifest-v2.schema.json +102 -1
  16. package/lib/client.js +830 -26
  17. package/lib/client.js.map +1 -1
  18. package/lib/index.js +1226 -24
  19. package/lib/types/client/PetDockEntry.d.ts +3 -0
  20. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  21. package/lib/types/client/PetDockEntry.js +18 -2
  22. package/lib/types/client/PetSprite.d.ts +23 -0
  23. package/lib/types/client/PetSprite.d.ts.map +1 -1
  24. package/lib/types/client/PetSprite.js +34 -29
  25. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  26. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  27. package/lib/types/client/PluginSettingsCard.js +2 -2
  28. package/lib/types/client/drag-stream.d.ts +19 -0
  29. package/lib/types/client/drag-stream.d.ts.map +1 -0
  30. package/lib/types/client/drag-stream.js +26 -0
  31. package/lib/types/client/gameplay-hud.d.ts +50 -0
  32. package/lib/types/client/gameplay-hud.d.ts.map +1 -0
  33. package/lib/types/client/gameplay-hud.js +274 -0
  34. package/lib/types/client/index.d.ts +1 -1
  35. package/lib/types/client/index.d.ts.map +1 -1
  36. package/lib/types/client/index.js +16 -0
  37. package/lib/types/client/locales.d.ts +34 -0
  38. package/lib/types/client/locales.d.ts.map +1 -1
  39. package/lib/types/client/locales.js +36 -0
  40. package/lib/types/client/pet-store.d.ts +7 -1
  41. package/lib/types/client/pet-store.d.ts.map +1 -1
  42. package/lib/types/client/pet-store.js +4 -0
  43. package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
  44. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
  45. package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
  46. package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
  47. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
  48. package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
  49. package/lib/types/client/renderers/frames2d.d.ts +36 -0
  50. package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
  51. package/lib/types/client/renderers/frames2d.js +196 -0
  52. package/lib/types/client/settings-form.d.ts +1 -1
  53. package/lib/types/client/settings-form.js +1 -1
  54. package/lib/types/client/telemetry.d.ts +27 -0
  55. package/lib/types/client/telemetry.d.ts.map +1 -0
  56. package/lib/types/client/telemetry.js +97 -0
  57. package/lib/types/defaults.d.ts +6 -0
  58. package/lib/types/defaults.d.ts.map +1 -0
  59. package/lib/types/defaults.js +5 -0
  60. package/lib/types/dsh-home.d.ts.map +1 -1
  61. package/lib/types/dsh-home.js +9 -3
  62. package/lib/types/gameplay.d.ts +182 -0
  63. package/lib/types/gameplay.d.ts.map +1 -0
  64. package/lib/types/gameplay.js +618 -0
  65. package/lib/types/http.d.ts +1 -1
  66. package/lib/types/http.js +1 -1
  67. package/lib/types/index.d.ts +1 -1
  68. package/lib/types/index.js +1 -1
  69. package/lib/types/ledger.d.ts +14 -0
  70. package/lib/types/ledger.d.ts.map +1 -1
  71. package/lib/types/ledger.js +30 -0
  72. package/lib/types/loopback.d.ts +1 -1
  73. package/lib/types/loopback.js +1 -1
  74. package/lib/types/manifest-v2.d.ts +38 -1
  75. package/lib/types/manifest-v2.d.ts.map +1 -1
  76. package/lib/types/manifest-v2.js +182 -2
  77. package/lib/types/mount-once.d.ts +1 -1
  78. package/lib/types/mount-once.js +2 -2
  79. package/lib/types/persist.d.ts +4 -4
  80. package/lib/types/persist.d.ts.map +1 -1
  81. package/lib/types/persist.js +41 -4
  82. package/lib/types/registry.d.ts +29 -0
  83. package/lib/types/registry.d.ts.map +1 -1
  84. package/lib/types/registry.js +170 -1
  85. package/lib/types/routes.d.ts.map +1 -1
  86. package/lib/types/routes.js +22 -0
  87. package/lib/types/service.d.ts +56 -0
  88. package/lib/types/service.d.ts.map +1 -1
  89. package/lib/types/service.js +181 -0
  90. package/package.json +6 -3
  91. package/src/client/PetDockEntry.test.tsx +6 -0
  92. package/src/client/PetDockEntry.tsx +32 -1
  93. package/src/client/PetSprite.test.tsx +36 -6
  94. package/src/client/PetSprite.tsx +42 -21
  95. package/src/client/PluginSettingsCard.tsx +4 -2
  96. package/src/client/drag-stream.ts +35 -0
  97. package/src/client/gameplay-hud.test.tsx +338 -0
  98. package/src/client/gameplay-hud.tsx +403 -0
  99. package/src/client/index.ts +23 -2
  100. package/src/client/locales.ts +36 -0
  101. package/src/client/pet-store.ts +10 -1
  102. package/src/client/pet.module.css +192 -29
  103. package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
  104. package/src/client/renderers/PetRendererSwitch.tsx +28 -1
  105. package/src/client/renderers/frames2d.test.ts +137 -0
  106. package/src/client/renderers/frames2d.ts +216 -0
  107. package/src/client/settings-card.module.css +5 -0
  108. package/src/client/settings-form.ts +1 -1
  109. package/src/client/telemetry.ts +105 -0
  110. package/src/defaults.ts +7 -0
  111. package/src/dsh-home.ts +9 -3
  112. package/src/gameplay.test.ts +239 -0
  113. package/src/gameplay.ts +692 -0
  114. package/src/http.ts +1 -1
  115. package/src/index.ts +1 -1
  116. package/src/ledger.test.ts +16 -0
  117. package/src/ledger.ts +31 -0
  118. package/src/loopback.ts +1 -1
  119. package/src/manifest-v2.test.ts +130 -1
  120. package/src/manifest-v2.ts +181 -2
  121. package/src/mount-once.ts +2 -2
  122. package/src/persist.test.ts +3 -0
  123. package/src/persist.ts +43 -6
  124. package/src/registry.test.ts +168 -1
  125. package/src/registry.ts +199 -1
  126. package/src/routes.ts +19 -0
  127. package/src/service.ts +208 -0
@@ -159,16 +159,17 @@ describe('PetSprite custom visual (pet-center M3)', () => {
159
159
  })
160
160
  })
161
161
 
162
- describe('PetSprite always-visible close control', () => {
163
- it('renders a corner close button and hides without petting', () => {
162
+ describe('PetSprite hide control (#1126)', () => {
163
+ it('does not render an intrusive corner close button and allows hiding via hover panel', () => {
164
164
  const onHide = vi.fn()
165
165
  const onPet = vi.fn()
166
166
  renderPet({ onHide, onPet })
167
167
 
168
- const close = screen.getByTestId('pet-close')
169
- expect(close.getAttribute('aria-label')).toBe('隐藏')
170
- expect(close.getAttribute('title')).toBe('隐藏')
171
- fireEvent.click(close)
168
+ expect(screen.queryByTestId('pet-close')).toBeNull()
169
+
170
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
171
+ const hideBtn = screen.getByText('隐藏')
172
+ fireEvent.click(hideBtn)
172
173
 
173
174
  expect(onHide).toHaveBeenCalledTimes(1)
174
175
  expect(onPet).not.toHaveBeenCalled()
@@ -677,6 +678,35 @@ describe('PetSprite panel chrome from the voice pack (pet-center M4)', () => {
677
678
  expect(screen.getByText('鱼干 3(0 分,幼鲸)')).toBeDefined()
678
679
  })
679
680
  })
681
+ describe('PetSprite gameplay entry in the hover panel (miku generalization)', () => {
682
+ it('renders a 玩法 action and routes clicks to the HUD channel', () => {
683
+ const onGameplayMenu = vi.fn()
684
+ renderPet({ onGameplayMenu })
685
+ // The entry lives in the hover panel; a closed panel shows no chrome.
686
+ expect(screen.queryByText('玩法')).toBeNull()
687
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
688
+ expect(screen.getByText('玩法')).toBeDefined()
689
+ fireEvent.click(screen.getByText('玩法'))
690
+ expect(onGameplayMenu).toHaveBeenCalledTimes(1)
691
+ })
692
+
693
+ it('omits the 玩法 action when the pet carries no gameplay HUD', () => {
694
+ renderPet()
695
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
696
+ expect(screen.queryByText('玩法')).toBeNull()
697
+ })
698
+
699
+ it('keeps the 玩法 entry when a voice pack hides every panel action', () => {
700
+ renderPet({
701
+ definition: { ...petDefinition(), panel: { actions: [] } },
702
+ onGameplayMenu: vi.fn(),
703
+ })
704
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
705
+ expect(screen.getByText('玩法')).toBeDefined()
706
+ expect(screen.queryByText('喂食')).toBeNull()
707
+ })
708
+ })
709
+
680
710
  describe('PetSprite status decoration (pet-center M5, #567)', () => {
681
711
  const decoration: DecorationView = {
682
712
  apiVersion: 'x-org.linxin666.pet-center/status-decoration-v1',
@@ -43,6 +43,8 @@ export interface PetSpriteProps {
43
43
  onHide: () => void
44
44
  /** Persist a drag position. */
45
45
  onDragEnd: (right: number, bottom: number) => void
46
+ /** Drag gesture notifications for renderers with a drag track (frames2d). */
47
+ onDraggingChange?: (dragging: boolean) => void
46
48
  /** Rename the selected pet (persisted by the host). */
47
49
  onRename: (name: string) => void
48
50
  /** Navigate to the session one status bubble reports on. */
@@ -55,6 +57,27 @@ export interface PetSpriteProps {
55
57
  * renders inside the sprite box, and the atlas load + frame loop skip.
56
58
  */
57
59
  visual?: ReactNode
60
+ /**
61
+ * Gameplay overlay (miku-pet generalization): rendered inside the float
62
+ * container so hover containment and stacking work unchanged. Absent for
63
+ * pets without a gameplay block.
64
+ */
65
+ hud?: ReactNode
66
+ /**
67
+ * Gameplay tap sink: receives the tap point as sprite-box fractions
68
+ * (0..1). When present the chrome reports the tap IN ADDITION to the
69
+ * affinity pet; the HUD decides zones/no-ops.
70
+ */
71
+ onGameplayTap?: (fractionX: number, fractionY: number) => void
72
+ /**
73
+ * Gameplay entry (miku-pet generalization): when present the hover panel
74
+ * renders a 玩法 action that opens/closes the gameplay card. The chrome
75
+ * wires it to the HUD through the per-pet bus (openCard), mirroring the
76
+ * onGameplayTap sink. Absent for pets without a gameplay block.
77
+ */
78
+ onGameplayMenu?: () => void
79
+ /** Disable the drag gesture (gameplay work mode blocks dragging). */
80
+ dragDisabled?: boolean
58
81
  /** Locale translate seat (namespace-bound). */
59
82
  t: TranslateNS<typeof NS>
60
83
  }
@@ -365,6 +388,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
365
388
  useEffect(() => () => clearHideTimer(), [])
366
389
 
367
390
  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>): void => {
391
+ if (props.dragDisabled === true) return
368
392
  e.preventDefault()
369
393
  ;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
370
394
  const current = dragPos ?? { right: display.right, bottom: display.bottom }
@@ -377,7 +401,10 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
377
401
  if (drag === null) return
378
402
  const dx = e.clientX - drag.startX
379
403
  const dy = e.clientY - drag.startY
380
- if (Math.abs(dx) > 4 || Math.abs(dy) > 4) draggedRef.current = true
404
+ if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
405
+ if (!draggedRef.current) props.onDraggingChange?.(true)
406
+ draggedRef.current = true
407
+ }
381
408
  const right = clampOffset(drag.right - dx, window.innerWidth - 40)
382
409
  const bottom = clampOffset(drag.bottom - dy, window.innerHeight - 40)
383
410
  setDragPos({ right, bottom })
@@ -385,6 +412,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
385
412
  const onPointerUp = (): void => {
386
413
  if (dragRef.current === null) return
387
414
  dragRef.current = null
415
+ if (draggedRef.current) props.onDraggingChange?.(false)
388
416
  if (dragPos !== null) props.onDragEnd(dragPos.right, dragPos.bottom)
389
417
  }
390
418
 
@@ -497,10 +525,16 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
497
525
  onPointerDown={onPointerDown}
498
526
  onPointerMove={onPointerMove}
499
527
  onPointerUp={onPointerUp}
500
- onClick={() => {
528
+ onClick={(e) => {
501
529
  // A pointer sequence that moved (dragged) still fires a trailing
502
530
  // click; skip the pet when that happened.
503
531
  if (draggedRef.current) return
532
+ if (props.onGameplayTap !== undefined && spriteRef.current !== null) {
533
+ const rect = spriteRef.current.getBoundingClientRect()
534
+ if (rect.width > 0 && rect.height > 0) {
535
+ props.onGameplayTap((e.clientX - rect.left) / rect.width, (e.clientY - rect.top) / rect.height)
536
+ }
537
+ }
504
538
  props.onPet()
505
539
  }}
506
540
  role="button"
@@ -508,26 +542,8 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
508
542
  >
509
543
  {props.visual}
510
544
  </div>
511
- <button
512
- type="button"
513
- className={styles.closeButton}
514
- aria-label={panelLabel('hide', props.t('pet.hide'))}
515
- title={panelLabel('hide', props.t('pet.hide'))}
516
- data-testid="pet-close"
517
- onPointerDown={(e) => {
518
- // Keep the close control from starting a drag on the sprite.
519
- e.stopPropagation()
520
- }}
521
- onClick={(e) => {
522
- // The close control sits beside the pet button; do not pet as a
523
- // side effect of closing the overlay.
524
- e.stopPropagation()
525
- props.onHide()
526
- }}
527
- >
528
- ×
529
- </button>
530
545
  </div>
546
+ {props.hud}
531
547
  {feedback !== null && (
532
548
  <div key={feedback.at} ref={bubbleRef} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
533
549
  {feedback.text}
@@ -704,6 +720,11 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
704
720
  {panelLabel('hide', props.t('pet.hide'))}
705
721
  </button>
706
722
  )}
723
+ {props.onGameplayMenu !== undefined && (
724
+ <button type="button" className={styles.action} onClick={props.onGameplayMenu}>
725
+ {props.t('pet.gameplay.menu')}
726
+ </button>
727
+ )}
707
728
  </div>
708
729
  </>
709
730
  )}
@@ -39,6 +39,8 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
39
39
  titleKey: TKey
40
40
  /** Locale key of the line describing what this plugin's settings govern. */
41
41
  descriptionKey: TKey
42
+ /** Optional rich header description rendered instead of the plain t(descriptionKey) text (the plain text stays as the tooltip). */
43
+ descriptionNode?: ReactNode
42
44
  /** The card's form state: availability, writability, and what a save would do. */
43
45
  state: CardShell
44
46
  /** Write every staged edit. */
@@ -87,7 +89,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
87
89
  <div className={css.headerStatic}>
88
90
  <span className={css.headText}>
89
91
  <span className={css.name} title={title}>{title}</span>
90
- <span className={css.description} title={description}>{description}</span>
92
+ <span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
91
93
  </span>
92
94
  {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
93
95
  </div>
@@ -102,7 +104,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
102
104
  >
103
105
  <span className={css.headText}>
104
106
  <span className={css.name} title={title}>{title}</span>
105
- <span className={css.description} title={description}>{description}</span>
107
+ <span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
106
108
  </span>
107
109
  {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
108
110
  <svg
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Drag stream — bridges the chrome's pointer drag state onto the renderer
3
+ * contract's { get, subscribe } shape, so renderers with a dedicated drag
4
+ * track (frames2d) can follow the chrome-owned drag gesture. Dispatch is
5
+ * change-only, mirroring the phase stream.
6
+ * @module @linxin666/dsh-pet/client/drag-stream
7
+ */
8
+
9
+ /** The renderer-facing drag stream. */
10
+ export interface DragStream {
11
+ /** Whether the chrome reports an active drag gesture. */
12
+ get(): boolean
13
+ /** Subscribe to drag state changes; returns the unsubscribe. */
14
+ subscribe(listener: (dragging: boolean) => void): () => void
15
+ /** Feed a fresh drag state; no-op when unchanged. */
16
+ push(dragging: boolean): void
17
+ }
18
+
19
+ /** Create the stream (one per pet activation, owned by the renderer switch). */
20
+ export function createDragStream(): DragStream {
21
+ let current = false
22
+ const listeners = new Set<(dragging: boolean) => void>()
23
+ return {
24
+ get: () => current,
25
+ subscribe(listener) {
26
+ listeners.add(listener)
27
+ return () => { listeners.delete(listener) }
28
+ },
29
+ push(dragging) {
30
+ if (dragging === current) return
31
+ current = dragging
32
+ for (const listener of [...listeners]) listener(dragging)
33
+ },
34
+ }
35
+ }
@@ -0,0 +1,338 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * GameplayHud behavior tests: menu pages, touch taps through the bus, the
4
+ * idle director, the work/sleep loops and shop purchases — all with a mock
5
+ * verb API and fake timers so every roll is deterministic.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
9
+ // The npm SDK's client half is a closure-factory bundle for the GUI's
10
+ // __ModuleLoader__ (not importable under vitest); provide the defineStore
11
+ // the pet store needs (same fake-store pattern as PetDockEntry.test.tsx).
12
+ vi.mock('@deepseek-ai/dsh-client-runtime/client', () => ({
13
+ defineStore: (spec: {
14
+ init: () => unknown
15
+ actions: Record<string, (draft: never, ...args: never[]) => void>
16
+ }) => ({
17
+ create: () => {
18
+ let value = spec.init()
19
+ const listeners = new Set<() => void>()
20
+ const actions: Record<string, (...args: unknown[]) => void> = {}
21
+ for (const [name, fn] of Object.entries(spec.actions)) {
22
+ actions[name] = (...args: unknown[]) => {
23
+ fn(value as never, ...(args as never[]))
24
+ // Re-identity the root so useSyncExternalStore actually re-renders
25
+ // (the real engine store produces a fresh state per action).
26
+ value = { ...(value as Record<string, unknown>) }
27
+ for (const listener of listeners) listener()
28
+ }
29
+ }
30
+ return {
31
+ getSnapshot: () => value,
32
+ subscribe: (listener: () => void) => {
33
+ listeners.add(listener)
34
+ return () => { listeners.delete(listener) }
35
+ },
36
+ actions,
37
+ }
38
+ },
39
+ }),
40
+ }))
41
+ import { GameplayHud, type GameplayApi, type GameplayBus } from './gameplay-hud.tsx'
42
+ import { createPetStore, type PetStoreInstance } from './pet-store.ts'
43
+ import { createDragStream } from './drag-stream.ts'
44
+ import { t } from './locales.ts'
45
+ import type { PetDefinition } from '../registry.ts'
46
+ import type { PetGameplayStateView, PetGameplayVerbResult, PetStateView } from '../service.ts'
47
+
48
+ const VIEW: PetGameplayStateView = {
49
+ stats: { hunger: 100, mood: 100, energy: 100, affection: 100 },
50
+ mode: null,
51
+ }
52
+
53
+ function gameplayView(patch?: Partial<PetGameplayStateView>): PetGameplayStateView {
54
+ return { stats: { ...VIEW.stats }, mode: null, ...patch }
55
+ }
56
+
57
+ function petDefinition(): PetDefinition {
58
+ return {
59
+ id: 'miku',
60
+ displayName: 'Miku',
61
+ description: 'frames2d gameplay pet',
62
+ renderer: 'frames2d',
63
+ cell: { width: 100, height: 100 },
64
+ columns: 8,
65
+ rows: [6, 8, 8, 4, 5, 8, 6, 6, 6],
66
+ atlasRows: 9,
67
+ tracks: {} as PetDefinition['tracks'],
68
+ atlasUrl: '/pet/miku/thumb/idle/idle_1_200.webp',
69
+ manifestUrl: '/pet/miku/pet.json',
70
+ frames2d: {
71
+ tracks: {
72
+ idle: { frames: ['/pet/miku/thumb/idle/idle_1_200.webp'], durations: [200], loop: true },
73
+ happy: { frames: ['/pet/miku/thumb/happy/happy_1_200.webp'], durations: [200], loop: false, fallback: 'idle' },
74
+ work: { frames: ['/pet/miku/thumb/work/work_1_200.webp'], durations: [200], loop: true },
75
+ success: { frames: ['/pet/miku/thumb/success/success_1_200.webp'], durations: [200], loop: false, fallback: 'idle' },
76
+ fail: { frames: ['/pet/miku/thumb/fail/fail_1_200.webp'], durations: [200], loop: false, fallback: 'idle' },
77
+ sleep: { frames: ['/pet/miku/thumb/sleep/sleep_1_200.webp'], durations: [200], loop: true },
78
+ eat: { frames: ['/pet/miku/thumb/eat/eat_1_200.webp'], durations: [200], loop: false, fallback: 'idle' },
79
+ },
80
+ phases: { idle: 'idle', done: 'success', failed: 'fail' },
81
+ },
82
+ gameplay: {
83
+ idleDirector: { intervalMs: 5000, maxMiss: 2, idleWeight: 0, acts: [{ track: 'eat', weight: 1 }] },
84
+ stats: {
85
+ hunger: { max: 100 },
86
+ mood: { max: 100 },
87
+ energy: { max: 100 },
88
+ affection: { max: 500, initial: 100 },
89
+ },
90
+ hitBox: { x0: 0, y0: 0, x1: 1, y1: 1 },
91
+ touch: {
92
+ zones: [
93
+ { name: 'head', y0: 0, y1: 0.55, branches: [{ probability: 1, effects: [{ stat: 'affection', amount: 5 }], state: 'happy', stateMs: 3000, phrases: ['happy!'] }] },
94
+ ],
95
+ clickBoost: { stat: 'mood', min: 0, max: 3 },
96
+ },
97
+ work: {
98
+ state: 'work', successState: 'success', failState: 'fail', tickMs: 10_000,
99
+ resultMs: { success: 1300, fail: 1900 }, successProbability: 0.5,
100
+ success: { effects: [{ currency: 'treats', amount: 1 }] },
101
+ },
102
+ sleep: { state: 'sleep', restore: { stat: 'energy', amount: 4, intervalMs: 30_000 } },
103
+ shop: {
104
+ items: [
105
+ { id: 'food1', label: 'Bread', image: '/pet/miku/thumb/shop/food.webp', price: 2, currency: 'treats', effects: [{ stat: 'hunger', amount: 40 }] },
106
+ { id: 'lottery', label: 'Ticket', price: 3, currency: 'treats', lottery: { currency: 'treats', tiers: [{ probability: 1, prize: 5 }] } },
107
+ ],
108
+ },
109
+ },
110
+ }
111
+ }
112
+
113
+ function snapshot(view: PetGameplayStateView): PetStateView {
114
+ return {
115
+ animation: 'idle',
116
+ phase: 'idle',
117
+ sessionActive: false,
118
+ sessions: [],
119
+ affinity: { points: 0, rank: '幼鲸', rankEmoji: '*', pets: 0, feeds: 0, turns: 0, petCooldown: false, feedCooldown: false },
120
+ display: { visible: true, size: 160, right: 24, bottom: 20 },
121
+ pet: { id: 'miku', displayName: 'Miku', description: '' },
122
+ name: 'Miku',
123
+ treats: { stocked: 0, max: 5 },
124
+ gameplay: view,
125
+ }
126
+ }
127
+
128
+ interface Harness {
129
+ store: PetStoreInstance
130
+ bus: GameplayBus
131
+ api: GameplayApi & { touch: ReturnType<typeof vi.fn>; setMode: ReturnType<typeof vi.fn>; workTick: ReturnType<typeof vi.fn>; buy: ReturnType<typeof vi.fn> }
132
+ setTrack: ReturnType<typeof vi.fn>
133
+ drag: ReturnType<typeof createDragStream>
134
+ setView: (view: PetGameplayStateView) => void
135
+ }
136
+
137
+ function harness(view: PetGameplayStateView = gameplayView()): Harness {
138
+ const store = createPetStore().create()
139
+ store.actions.setSnapshot(snapshot(view))
140
+ const bus: GameplayBus = {}
141
+ const setTrack = vi.fn()
142
+ bus.setTrack = setTrack
143
+ const drag = createDragStream()
144
+ const ok = (patch?: Partial<PetGameplayVerbResult>): PetGameplayVerbResult => ({ ok: true, ...patch })
145
+ const api = {
146
+ touch: vi.fn(async () => ok({ hit: true, state: 'happy', stateMs: 3000, phrase: 'happy!', view: gameplayView({ stats: { ...VIEW.stats, affection: 105 } }) })),
147
+ setMode: vi.fn(async (mode: 'work' | 'sleep' | null) => ok({ view: gameplayView({ mode }) })),
148
+ workTick: vi.fn(async () => ok({ outcome: 'success' as const, view: gameplayView({ mode: 'work' }) })),
149
+ buy: vi.fn(async () => ok({ view: gameplayView() })),
150
+ }
151
+ const setView = (next: PetGameplayStateView): void => store.actions.setSnapshot(snapshot(next))
152
+ render(<GameplayHud definition={petDefinition()} store={store} api={api} bus={bus} drag={drag} t={t} />)
153
+ return { store, bus, api, setTrack, drag, setView }
154
+ }
155
+
156
+ describe('GameplayHud', () => {
157
+ beforeEach(() => {
158
+ vi.useFakeTimers()
159
+ })
160
+ afterEach(() => {
161
+ cleanup()
162
+ vi.useRealTimers()
163
+ })
164
+
165
+ it('opens the menu card through the bus openCard channel and shows the shop page', () => {
166
+ const h = harness()
167
+ expect(h.bus.openCard).toBeDefined()
168
+ act(() => { h.bus.openCard?.() })
169
+ expect(screen.getByText('饱食')).toBeDefined()
170
+ expect(screen.getByText('好感')).toBeDefined()
171
+ fireEvent.click(screen.getByText('商店'))
172
+ expect(screen.getByText('Bread')).toBeDefined()
173
+ fireEvent.click(screen.getByText('返回'))
174
+ expect(screen.getByText('打工')).toBeDefined()
175
+ // The wallet page is gone: no wallet action anywhere in the card.
176
+ expect(screen.queryByText('钱包')).toBeNull()
177
+ })
178
+
179
+
180
+ it('opens the card beside the pet, on the right when the space allows', () => {
181
+ // The parent float box (160x160) sits away from the viewport edges, so
182
+ // the right side fits the measured card width and the card anchors to
183
+ // the sprite's right edge, clamped to the sprite height.
184
+ const rect = (w: number, h: number, x: number, y: number): DOMRect => ({
185
+ top: y, right: x + w, bottom: y + h, left: x, width: w, height: h, x, y, toJSON: () => ({}) } as DOMRect)
186
+ vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
187
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
188
+ if (this.className.includes('gameplayHud')) return rect(0, 0, 300, 600)
189
+ if (this.className.includes('gameplayCard')) return rect(240, 150, 0, 600)
190
+ return rect(160, 160, 300, 600)
191
+ })
192
+ const h = harness()
193
+ act(() => { h.bus.openCard?.() })
194
+ const card = document.querySelector('[data-page]') as HTMLElement
195
+ // Right side: sprite width + 8px gap; vertically centered (up by half
196
+ // the sprite height); clamped to the sprite height (scrolls inside).
197
+ expect(card.style.transform).toContain('translate(168px, -80px)')
198
+ expect(card.style.maxHeight).toBe('160px')
199
+ })
200
+
201
+ it('flips the card to the left when the pet is parked near the right edge', () => {
202
+ const rect = (w: number, h: number, x: number, y: number): DOMRect => ({
203
+ top: y, right: x + w, bottom: y + h, left: x, width: w, height: h, x, y, toJSON: () => ({}) } as DOMRect)
204
+ vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
205
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
206
+ if (this.className.includes('gameplayHud')) return rect(0, 0, 1080, 600)
207
+ if (this.className.includes('gameplayCard')) return rect(240, 150, 0, 600)
208
+ return rect(160, 160, 1080, 600)
209
+ })
210
+ const h = harness()
211
+ act(() => { h.bus.openCard?.() })
212
+ const card = document.querySelector('[data-page]') as HTMLElement
213
+ // Only 40px remain to the right of the pet: the card opens to the left
214
+ // of the sprite (measured card width + 8px gap).
215
+ expect(card.style.transform).toContain('translate(-248px, -80px)')
216
+ })
217
+
218
+ it('toggles the card through openCard and closes with the close button', () => {
219
+ const h = harness()
220
+ act(() => { h.bus.openCard?.() })
221
+ expect(screen.getByText('饱食')).toBeDefined()
222
+ // A no-argument call toggles (the panel 玩法 button is a toggle).
223
+ act(() => { h.bus.openCard?.() })
224
+ expect(screen.queryByText('饱食')).toBeNull()
225
+ // An explicit boolean pins the state; the card close button still works.
226
+ act(() => { h.bus.openCard?.(true) })
227
+ expect(screen.getByText('饱食')).toBeDefined()
228
+ fireEvent.click(screen.getByLabelText('返回'))
229
+ expect(screen.queryByText('饱食')).toBeNull()
230
+ })
231
+
232
+ it('runs a touch tap through the bus: zone verb, track hold, phrase bubble', async () => {
233
+ const h = harness()
234
+ expect(h.bus.tap).toBeDefined()
235
+ await act(async () => {
236
+ h.bus.tap!(0.5, 0.3)
237
+ })
238
+ expect(h.api.touch).toHaveBeenCalledWith('head')
239
+ expect(h.setTrack).toHaveBeenCalledWith('happy')
240
+ expect(h.store.getSnapshot().feedback?.text).toBe('happy!')
241
+ expect(h.store.getSnapshot().snapshot?.gameplay?.stats.affection).toBe(105)
242
+ // The hold releases after stateMs.
243
+ await act(async () => {
244
+ vi.advanceTimersByTime(3100)
245
+ })
246
+ expect(h.setTrack).toHaveBeenCalledWith(undefined)
247
+ // A tap during the hold is the plain-click boost (no zone argument).
248
+ await act(async () => {
249
+ h.bus.tap!(0.5, 0.3)
250
+ h.bus.tap!(0.5, 0.3)
251
+ })
252
+ await act(async () => {
253
+ vi.advanceTimersByTime(100)
254
+ })
255
+ // Second tap landed inside the lock window? First tap re-locked; third is the boost.
256
+ const calls = h.api.touch.mock.calls.map(args => args[0])
257
+ expect(calls[0]).toBe('head')
258
+ })
259
+
260
+ it('ignores taps outside the hit box', async () => {
261
+ const def = petDefinition()
262
+ def.gameplay!.hitBox = { x0: 0.2, y0: 0.2, x1: 0.4, y1: 0.4 }
263
+ const store = createPetStore().create()
264
+ store.actions.setSnapshot(snapshot(gameplayView()))
265
+ const api = { touch: vi.fn(), setMode: vi.fn(), workTick: vi.fn(), buy: vi.fn() } as unknown as Harness['api']
266
+ const bus: GameplayBus = { setTrack: vi.fn() }
267
+ render(<GameplayHud definition={def} store={store} api={api} bus={bus} drag={createDragStream()} t={t} />)
268
+ await act(async () => {
269
+ bus.tap!(0.9, 0.9)
270
+ })
271
+ expect(api.touch).not.toHaveBeenCalled()
272
+ })
273
+
274
+ it('runs the idle director: weighted act rolls on the interval', async () => {
275
+ const h = harness()
276
+ await act(async () => {
277
+ vi.advanceTimersByTime(5000)
278
+ })
279
+ expect(h.setTrack).toHaveBeenCalledWith('eat')
280
+ // A non-idle phase suppresses the roll.
281
+ h.store.actions.setSnapshot(snapshot(gameplayView()))
282
+ h.setTrack.mockClear()
283
+ const busy = snapshot(gameplayView())
284
+ busy.phase = 'thinking'
285
+ await act(async () => {
286
+ h.store.actions.setSnapshot(busy)
287
+ })
288
+ await act(async () => {
289
+ vi.advanceTimersByTime(5000)
290
+ })
291
+ expect(h.setTrack).not.toHaveBeenCalled()
292
+ })
293
+
294
+ it('drives the work loop: work track, adjudicated ticks, result hold', async () => {
295
+ const h = harness(gameplayView({ mode: 'work' }))
296
+ expect(h.setTrack).toHaveBeenCalledWith('work')
297
+ await act(async () => {
298
+ vi.advanceTimersByTime(10_000)
299
+ })
300
+ expect(h.api.workTick).toHaveBeenCalled()
301
+ expect(h.setTrack).toHaveBeenCalledWith('success')
302
+ // Result hold (1300ms) then back to the work loop track.
303
+ await act(async () => {
304
+ vi.advanceTimersByTime(1400)
305
+ })
306
+ expect(h.setTrack).toHaveBeenLastCalledWith('work')
307
+ // The tick view lands in the store (treats ride the panel ledger, not the view).
308
+ expect(h.store.getSnapshot().snapshot?.gameplay?.mode).toBe('work')
309
+ })
310
+
311
+ it('holds the sleep track and wakes on drag', async () => {
312
+ const h = harness(gameplayView({ mode: 'sleep' }))
313
+ expect(h.setTrack).toHaveBeenCalledWith('sleep')
314
+ await act(async () => {
315
+ h.drag.push(true)
316
+ })
317
+ expect(h.api.setMode).toHaveBeenCalledWith(null)
318
+ expect(h.store.getSnapshot().snapshot?.gameplay?.mode).toBeNull()
319
+ })
320
+
321
+ it('buys shop items and floats the lottery prize', async () => {
322
+ const h = harness()
323
+ h.api.buy.mockResolvedValueOnce({ ok: false, error: 'insufficient-funds', view: gameplayView() })
324
+ h.api.buy.mockResolvedValueOnce({ ok: true, prize: { amount: 5, currency: 'treats' }, view: gameplayView() })
325
+ act(() => { h.bus.openCard?.() })
326
+ fireEvent.click(screen.getByText('商店'))
327
+ expect(screen.getByText('Bread')).toBeDefined()
328
+ await act(async () => {
329
+ fireEvent.click(screen.getByText('Bread'))
330
+ })
331
+ expect(h.api.buy).toHaveBeenCalledWith('food1')
332
+ expect(screen.getByText('小鱼干不足')).toBeDefined()
333
+ await act(async () => {
334
+ fireEvent.click(screen.getByText('Ticket'))
335
+ })
336
+ expect(screen.getByText('中奖 +5 小鱼干')).toBeDefined()
337
+ })
338
+ })