@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
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Gameplay HUD — the client half of the manifest 'gameplay' block (miku-pet
3
+ * generalization). One component owns everything the block needs: the stat
4
+ * bars and shop card (menu / shop pages), the touch-zone tap
5
+ * handling, the idle director rolls, the work and sleep loops, and the
6
+ * float-text toasts. It talks to the host through the injected verb API,
7
+ * writes results straight back into the store (the 2 s poll stays the
8
+ * backstop), and steers the frames2d renderer through the per-pet bus.
9
+ * @module @linxin666/dsh-pet/client/GameplayHud
10
+ */
11
+
12
+ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactElement } from 'react'
13
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
14
+ import type { PetDefinition } from '../registry.ts'
15
+ import type { PetGameplayVerbResult } from '../service.ts'
16
+ import { touchZoneAt } from '../gameplay.ts'
17
+ import type { PetStoreInstance } from './pet-store.ts'
18
+ import type { DragStream } from './drag-stream.ts'
19
+ import type { NS } from './locales.ts'
20
+ import styles from './pet.module.css'
21
+
22
+ /** The gameplay verb API the plugin apply body injects (host-authoritative). */
23
+ export interface GameplayApi {
24
+ touch: (zone?: string) => Promise<PetGameplayVerbResult>
25
+ setMode: (mode: 'work' | 'sleep' | null) => Promise<PetGameplayVerbResult>
26
+ workTick: () => Promise<PetGameplayVerbResult>
27
+ buy: (item: string) => Promise<PetGameplayVerbResult>
28
+ }
29
+
30
+ /**
31
+ * The per-pet coordination bus. The frames2d visual mount registers the
32
+ * track override (setTrack); the HUD registers the sprite tap handler; the
33
+ * chrome calls into whatever is registered. Optional chaining everywhere —
34
+ * either side may be mid-remount during a hot reload.
35
+ */
36
+ export interface GameplayBus {
37
+ setTrack?: (track?: string) => void
38
+ tap?: (fx: number, fy: number) => void
39
+ /**
40
+ * Card open/close request from the chrome (the hover panel's 玩法 action):
41
+ * the HUD registers this, and calling it with no argument toggles the card
42
+ * while a boolean pins the state (same chrome -> HUD direction as tap).
43
+ */
44
+ openCard?: (open?: boolean) => void
45
+ }
46
+
47
+ type HudPage = 'root' | 'shop'
48
+
49
+ /** One floating toast (prize / insufficient funds). */
50
+ interface HudFloat {
51
+ id: number
52
+ text: string
53
+ }
54
+
55
+ let floatSeq = 0
56
+
57
+ /** The gameplay overlay for one frames2d pet that declares 'gameplay'. */
58
+ export function GameplayHud(props: {
59
+ definition: PetDefinition
60
+ store: PetStoreInstance
61
+ api: GameplayApi
62
+ bus: GameplayBus
63
+ drag: DragStream
64
+ t: PropsLocale<typeof NS>['t']
65
+ }): ReactElement | null {
66
+ const { definition, store, api, bus } = props
67
+ const ui = useSyncExternalStore(store.subscribe, store.getSnapshot)
68
+ const def = definition.gameplay
69
+ const view = ui.snapshot?.gameplay
70
+ const phase = ui.snapshot?.phase ?? 'idle'
71
+
72
+ const [open, setOpen] = useState(false)
73
+ const [page, setPage] = useState<HudPage>('root')
74
+ const hudRef = useRef<HTMLDivElement | null>(null)
75
+ const cardRef = useRef<HTMLDivElement | null>(null)
76
+ const [floats, setFloats] = useState<HudFloat[]>([])
77
+ // Mutable driver state (refs so intervals never re-arm on a poll tick).
78
+ const modeRef = useRef<'work' | 'sleep' | null>(view?.mode ?? null)
79
+ modeRef.current = view?.mode ?? null
80
+ const phaseRef = useRef(phase)
81
+ phaseRef.current = phase
82
+ const draggingRef = useRef(false)
83
+ const touchLockUntilRef = useRef(0)
84
+ const missRef = useRef(0)
85
+ const busyRef = useRef(false)
86
+
87
+ // Dynamic-key lookups (stat ids / currency ids are manifest data).
88
+ const tr = props.t as unknown as (key: string, values?: Record<string, string | number>) => string
89
+ const statLabel = (name: string): string => tr('pet.gameplay.stat.' + name)
90
+ const currencyLabel = (name: string): string => tr('pet.gameplay.currency.' + name)
91
+
92
+ const pushFloat = (text: string): void => {
93
+ const id = ++floatSeq
94
+ setFloats(list => [...list.slice(-3), { id, text }])
95
+ window.setTimeout(() => {
96
+ setFloats(list => list.filter(entry => entry.id !== id))
97
+ }, 1100)
98
+ }
99
+
100
+ const applyResult = (result: PetGameplayVerbResult): void => {
101
+ if (result.view !== undefined) store.actions.setGameplayView(result.view)
102
+ }
103
+
104
+ // Tap handling (registered on the bus; PetSprite reports sprite-box
105
+ // fractions). Sleep wakes on tap; work blocks taps; a held touch
106
+ // animation turns taps into the plain-click boost.
107
+ useEffect(() => {
108
+ if (def === undefined) return undefined
109
+ bus.tap = (fx, fy) => {
110
+ if (modeRef.current === 'sleep') {
111
+ void api.setMode(null).then(applyResult, () => undefined)
112
+ return
113
+ }
114
+ if (modeRef.current === 'work') return
115
+ const box = def.hitBox ?? { x0: 0, y0: 0, x1: 1, y1: 1 }
116
+ const hx = (fx - box.x0) / (box.x1 - box.x0)
117
+ const hy = (fy - box.y0) / (box.y1 - box.y0)
118
+ if (hx < 0 || hx > 1 || hy < 0 || hy > 1) return
119
+ if (Date.now() < touchLockUntilRef.current) {
120
+ void api.touch().then(applyResult, () => undefined)
121
+ return
122
+ }
123
+ const zone = def.touch === undefined ? undefined : touchZoneAt(def.touch, hy)
124
+ if (zone === undefined) return
125
+ void api.touch(zone.name).then((result) => {
126
+ applyResult(result)
127
+ if (result.hit !== true) return
128
+ if (result.state !== undefined) {
129
+ bus.setTrack?.(result.state)
130
+ const holdMs = result.stateMs ?? 3000
131
+ touchLockUntilRef.current = Date.now() + holdMs
132
+ window.setTimeout(() => {
133
+ if (Date.now() >= touchLockUntilRef.current) bus.setTrack?.(undefined)
134
+ }, holdMs)
135
+ }
136
+ if (result.phrase !== undefined) {
137
+ store.actions.setFeedback({ text: result.phrase, kind: 'none', at: Date.now() })
138
+ }
139
+ }, () => undefined)
140
+ }
141
+ return () => { bus.tap = undefined }
142
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one registration per pet definition
143
+ }, [definition.id, def])
144
+
145
+ // Panel entry (chrome -> HUD): the hover panel's 玩法 button drives the
146
+ // card through this channel so the chrome never needs the card's state.
147
+ useEffect(() => {
148
+ bus.openCard = (next?: boolean) => {
149
+ setOpen(prev => next ?? !prev)
150
+ setPage('root')
151
+ }
152
+ return () => { bus.openCard = undefined }
153
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one registration per bus
154
+ }, [bus])
155
+
156
+ // Card placement: the card must not enter the bubble band above the sprite
157
+ // or the hover-panel band below it, so it opens beside the sprite instead
158
+ // of growing upward from the pet's feet. It is vertically centered on the
159
+ // sprite box, clamped to the sprite's height, and the side is chosen by
160
+ // the space available to each side (right first; a pet parked near the
161
+ // right viewport edge flips the card to the left).
162
+ useLayoutEffect(() => {
163
+ if (!open) return undefined
164
+ const hud = hudRef.current
165
+ const card = cardRef.current
166
+ if (hud === null || card === null) return undefined
167
+ const place = (): void => {
168
+ const box = hud.parentElement?.getBoundingClientRect()
169
+ if (box === undefined) return
170
+ const gap = 8
171
+ const width = card.getBoundingClientRect().width
172
+ const toRight = window.innerWidth - box.right
173
+ const x = toRight >= width + gap ? box.width + gap : -(width + gap)
174
+ card.style.transform = 'translate(' + Math.round(x) + 'px, ' + Math.round(-box.height / 2) + 'px) translateY(50%)'
175
+ card.style.maxHeight = Math.round(box.height) + 'px'
176
+ }
177
+ place()
178
+ window.addEventListener('resize', place)
179
+ return () => { window.removeEventListener('resize', place) }
180
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one placement per open/page change
181
+ }, [open, page])
182
+
183
+ // Drag gestures wake a sleeping pet (miku behavior); the drag stream also
184
+ // feeds the idle director's suppression check.
185
+ useEffect(() => {
186
+ return props.drag.subscribe((dragging) => {
187
+ draggingRef.current = dragging
188
+ if (dragging && modeRef.current === 'sleep') {
189
+ void api.setMode(null).then(applyResult, () => undefined)
190
+ }
191
+ })
192
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one subscription per drag stream
193
+ }, [props.drag])
194
+
195
+ // Idle director: weighted rolls between staying idle and playing an act.
196
+ // Runs only while the phase mapping owns the visual (idle phase, no mode,
197
+ // no drag, no held touch animation); maxMiss forces an act after too many
198
+ // idle rolls in a row.
199
+ useEffect(() => {
200
+ const director = def?.idleDirector
201
+ if (def === undefined || director === undefined) return undefined
202
+ const total = director.idleWeight + director.acts.reduce((sum, act) => sum + act.weight, 0)
203
+ if (total <= 0) return undefined
204
+ const timer = window.setInterval(() => {
205
+ if (phaseRef.current !== 'idle') return
206
+ if (modeRef.current !== null || draggingRef.current) return
207
+ if (Date.now() < touchLockUntilRef.current) return
208
+ let pickedAct: { track: string; weight: number; phrases?: string[] } | undefined
209
+ if (missRef.current >= director.maxMiss) {
210
+ // Forced act: pick among the acts only.
211
+ const actTotal = director.acts.reduce((sum, act) => sum + act.weight, 0)
212
+ let actRoll = Math.random() * actTotal
213
+ for (const act of director.acts) {
214
+ actRoll -= act.weight
215
+ if (actRoll < 0) { pickedAct = act; break }
216
+ }
217
+ } else {
218
+ let roll = Math.random() * total
219
+ for (const act of director.acts) {
220
+ roll -= act.weight
221
+ if (roll < 0) { pickedAct = act; break }
222
+ }
223
+ }
224
+ if (pickedAct === undefined) {
225
+ missRef.current += 1
226
+ return
227
+ }
228
+ missRef.current = 0
229
+ bus.setTrack?.(pickedAct.track)
230
+ // Acts with a phrase pool speak one line while they play (miku parity).
231
+ if (pickedAct.phrases !== undefined && pickedAct.phrases.length > 0) {
232
+ const phrase = pickedAct.phrases[Math.floor(Math.random() * pickedAct.phrases.length)]!
233
+ store.actions.setFeedback({ text: phrase, kind: 'none', at: Date.now() })
234
+ }
235
+ }, director.intervalMs)
236
+ return () => window.clearInterval(timer)
237
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one director per pet definition
238
+ }, [definition.id, def])
239
+
240
+ // Work loop: hold the work track, adjudicate one round per tick, play the
241
+ // result track for its hold window, then resume. Leaving the mode
242
+ // releases the override so the phase mapping takes over.
243
+ useEffect(() => {
244
+ const work = def?.work
245
+ if (def === undefined || work === undefined || view?.mode !== 'work') return undefined
246
+ bus.setTrack?.(work.state)
247
+ let resultTimer = 0
248
+ const timer = window.setInterval(() => {
249
+ if (busyRef.current) return
250
+ busyRef.current = true
251
+ void api.workTick().then((result) => {
252
+ busyRef.current = false
253
+ applyResult(result)
254
+ if (result.ok !== true || result.outcome === undefined) return
255
+ const resultTrack = result.outcome === 'success' ? work.successState : work.failState
256
+ const hold = result.outcome === 'success' ? work.resultMs?.success ?? 1300 : work.resultMs?.fail ?? 1900
257
+ bus.setTrack?.(resultTrack)
258
+ resultTimer = window.setTimeout(() => {
259
+ if (modeRef.current === 'work') bus.setTrack?.(work.state)
260
+ }, hold)
261
+ }, () => { busyRef.current = false })
262
+ }, work.tickMs)
263
+ return () => {
264
+ window.clearInterval(timer)
265
+ window.clearTimeout(resultTimer)
266
+ bus.setTrack?.(undefined)
267
+ }
268
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the loop keys on the mode value
269
+ }, [definition.id, def, view?.mode])
270
+
271
+ // Sleep loop: hold the sleep track; restore is host-side (lazy settle).
272
+ useEffect(() => {
273
+ const sleep = def?.sleep
274
+ if (def === undefined || sleep === undefined || view?.mode !== 'sleep') return undefined
275
+ bus.setTrack?.(sleep.state)
276
+ return () => bus.setTrack?.(undefined)
277
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the loop keys on the mode value
278
+ }, [definition.id, def, view?.mode])
279
+
280
+ if (def === undefined || view === undefined) return null
281
+
282
+ const mode = view.mode
283
+ const stats = def.stats ?? {}
284
+ const shop = def.shop
285
+
286
+ const buy = (itemId: string): void => {
287
+ void api.buy(itemId).then((result) => {
288
+ applyResult(result)
289
+ if (result.ok !== true) {
290
+ if (result.error === 'insufficient-funds') {
291
+ const item = shop?.items.find(entry => entry.id === itemId)
292
+ pushFloat(tr('pet.gameplay.insufficient', { currency: currencyLabel(item?.currency ?? 'treats') }))
293
+ }
294
+ return
295
+ }
296
+ if (result.prize !== undefined) {
297
+ pushFloat(tr('pet.gameplay.prize', { amount: result.prize.amount, currency: currencyLabel(result.prize.currency) }))
298
+ }
299
+ }, () => undefined)
300
+ }
301
+
302
+ const setMode = (next: 'work' | 'sleep' | null): void => {
303
+ void api.setMode(next).then(applyResult, () => undefined)
304
+ }
305
+
306
+ return (
307
+ <div ref={hudRef} className={styles.gameplayHud} data-dsh-pet-gameplay={definition.id}>
308
+ {floats.map(entry => (
309
+ <div key={entry.id} className={styles.gameplayFloat}>{entry.text}</div>
310
+ ))}
311
+ {mode !== null && (
312
+ <div className={styles.gameplayModeChip}>
313
+ {tr(mode === 'work' ? 'pet.gameplay.working' : 'pet.gameplay.sleeping')}
314
+ </div>
315
+ )}
316
+ {open && (
317
+ <div ref={cardRef} className={styles.gameplayCard} data-page={page}>
318
+ {page === 'root' && (
319
+ <>
320
+ <div className={styles.gameplayBars}>
321
+ {Object.entries(stats).map(([name, stat]) => {
322
+ const value = view.stats[name] ?? 0
323
+ return (
324
+ <div key={name} className={styles.gameplayBarRow} title={statLabel(name) + ' ' + String(value) + '/' + String(stat.max)}>
325
+ <span className={styles.gameplayBarLabel}>{statLabel(name)}</span>
326
+ <span className={styles.gameplayBarTrack}>
327
+ <span
328
+ className={styles.gameplayBarFill}
329
+ style={{ width: Math.round((value / stat.max) * 100) + '%' }}
330
+ />
331
+ </span>
332
+ </div>
333
+ )
334
+ })}
335
+ </div>
336
+ <div className={styles.gameplayActions}>
337
+ {def.work !== undefined && (
338
+ <button
339
+ type="button"
340
+ className={styles.action}
341
+ onClick={() => setMode(mode === 'work' ? null : 'work')}
342
+ >
343
+ {tr(mode === 'work' ? 'pet.gameplay.stopWork' : 'pet.gameplay.work')}
344
+ </button>
345
+ )}
346
+ {def.sleep !== undefined && (
347
+ <button
348
+ type="button"
349
+ className={styles.action}
350
+ onClick={() => setMode(mode === 'sleep' ? null : 'sleep')}
351
+ >
352
+ {tr(mode === 'sleep' ? 'pet.gameplay.wake' : 'pet.gameplay.sleep')}
353
+ </button>
354
+ )}
355
+ {shop !== undefined && (
356
+ <button type="button" className={styles.action} onClick={() => setPage('shop')}>
357
+ {tr('pet.gameplay.shop')}
358
+ </button>
359
+ )}
360
+ </div>
361
+ </>
362
+ )}
363
+ {page === 'shop' && shop !== undefined && (
364
+ <>
365
+ <div className={styles.gameplayShopItems}>
366
+ {shop.items.map(item => (
367
+ <button
368
+ key={item.id}
369
+ type="button"
370
+ className={styles.gameplayShopItem}
371
+ onClick={() => buy(item.id)}
372
+ title={item.label + ' — ' + String(item.price) + ' ' + currencyLabel(item.currency)}
373
+ >
374
+ {item.image !== undefined && (
375
+ <img className={styles.gameplayShopItemImage} src={item.image} alt="" draggable={false} />
376
+ )}
377
+ <span className={styles.gameplayShopItemLabel}>{item.label}</span>
378
+ <span className={styles.gameplayShopItemPrice}>
379
+ {item.price} {currencyLabel(item.currency)}
380
+ </span>
381
+ </button>
382
+ ))}
383
+ </div>
384
+ <div className={styles.gameplayActions}>
385
+ <button type="button" className={styles.action} onClick={() => setPage('root')}>
386
+ {tr('pet.gameplay.back')}
387
+ </button>
388
+ </div>
389
+ </>
390
+ )}
391
+ <button
392
+ type="button"
393
+ className={styles.gameplayClose}
394
+ aria-label={tr('pet.gameplay.back')}
395
+ onClick={() => setOpen(false)}
396
+ >
397
+ ×
398
+ </button>
399
+ </div>
400
+ )}
401
+ </div>
402
+ )
403
+ }
@@ -19,7 +19,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
19
19
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
20
20
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
21
21
  import type { PetDisplayConfig } from '../persist.ts'
22
- import type { PetInteractResult, PetStateView } from '../service.ts'
22
+ import type { PetGameplayVerbResult, PetInteractResult, PetStateView } from '../service.ts'
23
23
  import type { PetInteraction } from '../affinity.ts'
24
24
  import type { PetDefinition } from '../registry.ts'
25
25
  import { createElement } from 'react'
@@ -28,9 +28,11 @@ import { createPetStore, type PetStoreInstance } from './pet-store.ts'
28
28
  import { PetDockEntry, type PetInjected } from './PetDockEntry.tsx'
29
29
  import { defaultPetRendererRegistry } from './renderers/registry.ts'
30
30
  import { live2dRenderer } from './renderers/live2d.ts'
31
+ import { frames2dRenderer } from './renderers/frames2d.ts'
31
32
  import { registerPetUiTeardown, takeoverPetUiTeardown } from './ui-teardown.ts'
32
33
  import { PetSettingsSection, PetSettingsCardController, type PetSettings } from './PetSettingsCard.tsx'
33
34
  import { NS, en, zh, t } from './locales.ts'
35
+ import { reportDailyHeartbeat } from './telemetry.ts'
34
36
 
35
37
  /** The host pet API as the browser sees it (same-origin JSON endpoints). */
36
38
  interface PetHttpApi {
@@ -41,6 +43,10 @@ interface PetHttpApi {
41
43
  setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }>
42
44
  setName(name: string): Promise<{ ok: true; name: string } | { ok: false; error: string }>
43
45
  setPet(petId: string): Promise<{ ok: true; petId: string } | { ok: false; error: string }>
46
+ gameplayTouch(zone?: string): Promise<PetGameplayVerbResult>
47
+ gameplaySetMode(mode: 'work' | 'sleep' | null): Promise<PetGameplayVerbResult>
48
+ gameplayWorkTick(): Promise<PetGameplayVerbResult>
49
+ gameplayBuy(item: string): Promise<PetGameplayVerbResult>
44
50
  }
45
51
 
46
52
  /** Same-origin JSON fetch helper (GET without body, POST with JSON body). */
@@ -67,6 +73,10 @@ const petApi: PetHttpApi = {
67
73
  setConfig: (patch) => petFetch('/api/pet/set-config', patch),
68
74
  setName: (name) => petFetch('/api/pet/set-name', { name }),
69
75
  setPet: (petId) => petFetch('/api/pet/set-pet', { petId }),
76
+ gameplayTouch: (zone) => petFetch('/api/pet/gameplay/touch', zone === undefined ? {} : { zone }),
77
+ gameplaySetMode: (mode) => petFetch('/api/pet/gameplay/mode', { mode }),
78
+ gameplayWorkTick: () => petFetch('/api/pet/gameplay/work-tick', {}),
79
+ gameplayBuy: (item) => petFetch('/api/pet/gameplay/buy', { item }),
70
80
  }
71
81
 
72
82
  /** Poll interval for the host snapshot. */
@@ -89,7 +99,7 @@ export type { PetDefinition } from '../registry.ts'
89
99
  declare module '@deepseek-ai/cordis' {
90
100
  interface Context {
91
101
  /**
92
- * Optional rc.6 compatibility binder provided by dsh-web-ui-settings;
102
+ * Optional rc.6 compatibility binder provided by dsh-web-settings;
93
103
  * absent when that group plugin is not installed, so callers fall back to
94
104
  * the official settings scope.
95
105
  */
@@ -104,6 +114,10 @@ declare module '@deepseek-ai/cordis' {
104
114
  * @param ctx - client root context.
105
115
  */
106
116
  export function apply(ctx: ClientContext): void {
117
+ // Anonymous install heartbeat (docs/telemetry.md): one beat per browser per
118
+ // UTC day, package name only, silent failure.
119
+ reportDailyHeartbeat([{ name: '@linxin666/dsh-pet' }])
120
+
107
121
  ctx.effect(() => {
108
122
  try {
109
123
  return ctx.locale.register(NS, { zh, en })
@@ -115,6 +129,7 @@ export function apply(ctx: ClientContext): void {
115
129
  // Built-in renderers dispatch through the plugin-wide registry (pet-center
116
130
  // M3). Registration is idempotent (id wins), so re-applies stay clean.
117
131
  defaultPetRendererRegistry.register(live2dRenderer)
132
+ defaultPetRendererRegistry.register(frames2dRenderer)
118
133
 
119
134
  const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
120
135
  const settingsScope = binder.bind<PetSettings>({ namespace: PET_SETTINGS_NS })
@@ -316,6 +331,12 @@ export function apply(ctx: ClientContext): void {
316
331
  feedbackDone: () => {
317
332
  setFeedback(null)
318
333
  },
334
+ gameplay: {
335
+ touch: (zone) => petApi.gameplayTouch(zone),
336
+ setMode: (mode) => petApi.gameplaySetMode(mode),
337
+ workTick: () => petApi.gameplayWorkTick(),
338
+ buy: (item) => petApi.gameplayBuy(item),
339
+ },
319
340
  })
320
341
 
321
342
  // The pet is host-global (its state/display/interactions have no session
@@ -24,6 +24,24 @@ export const zh = {
24
24
  'pet.live2d.vendor-missing': 'Live2D 组件缺失,请升级宠物插件。',
25
25
  'pet.live2d.load-failed': 'Live2D 模型加载失败,请检查该宠物目录的完整性。',
26
26
  'pet.openSessionHint': '点击跳转到对应会话',
27
+ // 玩法 HUD(miku-pet 泛化:属性条 / 打工 / 睡觉 / 商店;统一小鱼干经济)。
28
+ 'pet.gameplay.menu': '玩法',
29
+ 'pet.gameplay.work': '打工',
30
+ 'pet.gameplay.stopWork': '收工',
31
+ 'pet.gameplay.sleep': '睡觉',
32
+ 'pet.gameplay.wake': '起床',
33
+ 'pet.gameplay.shop': '商店',
34
+ 'pet.gameplay.back': '返回',
35
+ 'pet.gameplay.buy': '购买',
36
+ 'pet.gameplay.insufficient': '{currency}不足',
37
+ 'pet.gameplay.prize': '中奖 +{amount} {currency}',
38
+ 'pet.gameplay.working': '打工中',
39
+ 'pet.gameplay.sleeping': '睡觉中',
40
+ 'pet.gameplay.stat.hunger': '饱食',
41
+ 'pet.gameplay.stat.mood': '心情',
42
+ 'pet.gameplay.stat.energy': '精力',
43
+ 'pet.gameplay.stat.affection': '好感',
44
+ 'pet.gameplay.currency.treats': '小鱼干',
27
45
  'pet.moreSessions': '展开其余 {n} 个会话的气泡',
28
46
  'pet.collapseSessions': '收起会话气泡',
29
47
  // 一级设置页(settings.section 席位)。
@@ -79,6 +97,24 @@ export const en = {
79
97
  'pet.live2d.vendor-missing': 'The Live2D component is missing; please update the pet plugin.',
80
98
  'pet.live2d.load-failed': 'The Live2D model failed to load; check the pet directory is complete.',
81
99
  'pet.openSessionHint': 'Click to jump to this session',
100
+ // Gameplay HUD (miku-pet generalization: stat bars / work / sleep / shop; unified treats economy).
101
+ 'pet.gameplay.menu': 'Play',
102
+ 'pet.gameplay.work': 'Work',
103
+ 'pet.gameplay.stopWork': 'Stop work',
104
+ 'pet.gameplay.sleep': 'Sleep',
105
+ 'pet.gameplay.wake': 'Wake up',
106
+ 'pet.gameplay.shop': 'Shop',
107
+ 'pet.gameplay.back': 'Back',
108
+ 'pet.gameplay.buy': 'Buy',
109
+ 'pet.gameplay.insufficient': 'Not enough {currency}',
110
+ 'pet.gameplay.prize': 'Prize +{amount} {currency}',
111
+ 'pet.gameplay.working': 'Working',
112
+ 'pet.gameplay.sleeping': 'Sleeping',
113
+ 'pet.gameplay.stat.hunger': 'Hunger',
114
+ 'pet.gameplay.stat.mood': 'Mood',
115
+ 'pet.gameplay.stat.energy': 'Energy',
116
+ 'pet.gameplay.stat.affection': 'Affection',
117
+ 'pet.gameplay.currency.treats': 'Treats',
82
118
  'pet.moreSessions': 'Expand {n} more session bubbles',
83
119
  'pet.collapseSessions': 'Collapse session bubbles',
84
120
  // First-level settings section (the `settings.section` seat).
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
10
10
  import type { EngineStoreHandle, EngineStoreInstance } from '@deepseek-ai/dsh-client-runtime/client'
11
- import type { PetStateView } from '../service.ts'
11
+ import type { PetGameplayStateView, PetStateView } from '../service.ts'
12
12
  import type { PetInteraction } from '../affinity.ts'
13
13
  import type { PetDefinition } from '../registry.ts'
14
14
 
@@ -46,6 +46,12 @@ export type PetUiActions = {
46
46
  setState: (draft: PetUiState, state: PetUiState['state'], error: string | null) => void
47
47
  /** Show a reaction bubble. */
48
48
  setFeedback: (draft: PetUiState, feedback: PetFeedback | null) => void
49
+ /**
50
+ * Patch the gameplay slice of the snapshot (verb write-back), so the HUD
51
+ * reflects a touch/mode/buy result immediately instead of waiting one
52
+ * poll tick (2 s).
53
+ */
54
+ setGameplayView: (draft: PetUiState, view: PetGameplayStateView) => void
49
55
  }
50
56
 
51
57
  /** Create the pet store handle (apply world only; never module-level). */
@@ -74,6 +80,9 @@ export function createPetStore(): EngineStoreHandle<PetUiState, PetUiActions> {
74
80
  setFeedback: (draft, feedback) => {
75
81
  draft.feedback = feedback
76
82
  },
83
+ setGameplayView: (draft, view) => {
84
+ if (draft.snapshot !== null) draft.snapshot = { ...draft.snapshot, gameplay: view }
85
+ },
77
86
  },
78
87
  })
79
88
  }