@linxin666/dsh-pet 0.3.24 → 0.3.25

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 (72) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -3
  3. package/README.zh.md +4 -3
  4. package/icon.svg +3 -0
  5. package/lib/client.js +522 -194
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +706 -422
  8. package/lib/live2d-vendor.js +96 -96
  9. package/lib/live2d-vendor.js.map +1 -1
  10. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  11. package/lib/types/client/PetDockEntry.js +1 -1
  12. package/lib/types/client/PetSettingsCard.d.ts +20 -9
  13. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PetSettingsCard.js +116 -5
  15. package/lib/types/client/PetSprite.d.ts +6 -0
  16. package/lib/types/client/PetSprite.d.ts.map +1 -1
  17. package/lib/types/client/PetSprite.js +95 -0
  18. package/lib/types/client/PluginSettingsCard.d.ts +4 -0
  19. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PluginSettingsCard.js +3 -2
  21. package/lib/types/client/gameplay-hud.d.ts +10 -1
  22. package/lib/types/client/gameplay-hud.d.ts.map +1 -1
  23. package/lib/types/client/gameplay-hud.js +137 -23
  24. package/lib/types/client/index.d.ts +8 -5
  25. package/lib/types/client/index.d.ts.map +1 -1
  26. package/lib/types/client/index.js +61 -27
  27. package/lib/types/client/main-session.d.ts +33 -0
  28. package/lib/types/client/main-session.d.ts.map +1 -0
  29. package/lib/types/client/main-session.js +34 -0
  30. package/lib/types/client/settings-form.d.ts +17 -17
  31. package/lib/types/client/settings-form.d.ts.map +1 -1
  32. package/lib/types/client/settings-form.js +30 -27
  33. package/lib/types/event-projection.d.ts.map +1 -1
  34. package/lib/types/event-projection.js +5 -3
  35. package/lib/types/gameplay.d.ts +79 -5
  36. package/lib/types/gameplay.d.ts.map +1 -1
  37. package/lib/types/gameplay.js +212 -17
  38. package/lib/types/http.d.ts +18 -1
  39. package/lib/types/http.d.ts.map +1 -1
  40. package/lib/types/http.js +22 -1
  41. package/lib/types/index.d.ts +100 -29
  42. package/lib/types/index.d.ts.map +1 -1
  43. package/lib/types/index.js +113 -64
  44. package/lib/types/persist.d.ts.map +1 -1
  45. package/lib/types/persist.js +3 -1
  46. package/lib/types/routes.d.ts.map +1 -1
  47. package/lib/types/routes.js +3 -1
  48. package/lib/types/service.d.ts +8 -3
  49. package/lib/types/service.d.ts.map +1 -1
  50. package/lib/types/service.js +26 -4
  51. package/package.json +46 -26
  52. package/src/client/PetDockEntry.tsx +1 -0
  53. package/src/client/PetSettingsCard.test.tsx +103 -0
  54. package/src/client/PetSettingsCard.tsx +129 -26
  55. package/src/client/PetSprite.tsx +96 -0
  56. package/src/client/PluginSettingsCard.tsx +7 -2
  57. package/src/client/gameplay-hud.test.tsx +106 -8
  58. package/src/client/gameplay-hud.tsx +143 -28
  59. package/src/client/index.test.tsx +37 -4
  60. package/src/client/index.ts +74 -34
  61. package/src/client/main-session.ts +43 -0
  62. package/src/client/settings-form.ts +34 -31
  63. package/src/event-projection.ts +5 -3
  64. package/src/gameplay.test.ts +138 -1
  65. package/src/gameplay.ts +253 -16
  66. package/src/http.ts +23 -1
  67. package/src/index.test.ts +107 -11
  68. package/src/index.ts +162 -73
  69. package/src/persist.ts +3 -1
  70. package/src/registry.test.ts +3 -2
  71. package/src/routes.ts +3 -1
  72. package/src/service.ts +27 -5
@@ -64,6 +64,10 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
64
64
  * edits, so the footer would sit there permanently disabled.
65
65
  */
66
66
  hideFooter?: boolean
67
+ /** Render children even when the settings namespace is not exposed by the host. */
68
+ renderChildrenWhenNotExposed?: boolean
69
+ /** Hide the 'not exposed' warning banner when the settings namespace is not exposed. */
70
+ hideNotExposedNotice?: boolean
67
71
  /** The plugin's controls. */
68
72
  children: ReactNode
69
73
  }
@@ -126,14 +130,15 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
126
130
  // official settings allowlist omits third-party namespaces): show a card
127
131
  // that explains the gap instead of vanishing, so a missing card never
128
132
  // reads as a missing plugin.
129
- if (!state.exposed) {
133
+ if (!state.exposed && props.renderChildrenWhenNotExposed !== true) {
134
+ const showNotice = props.hideNotExposedNotice !== true
130
135
  return (
131
136
  <li className={cardClass}>
132
137
  {header}
133
138
  {expanded
134
139
  ? (
135
140
  <div className={css.body}>
136
- <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
141
+ {showNotice ? <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p> : null}
137
142
  </div>
138
143
  )
139
144
  : null}
@@ -44,6 +44,7 @@ import { createDragStream } from './drag-stream.ts'
44
44
  import { t } from './locales.ts'
45
45
  import type { PetDefinition } from '../registry.ts'
46
46
  import type { PetGameplayStateView, PetGameplayVerbResult, PetStateView } from '../service.ts'
47
+ import type { ActivityPhase } from '../state.ts'
47
48
 
48
49
  const VIEW: PetGameplayStateView = {
49
50
  stats: { hunger: 100, mood: 100, energy: 100, affection: 100 },
@@ -110,10 +111,10 @@ function petDefinition(): PetDefinition {
110
111
  }
111
112
  }
112
113
 
113
- function snapshot(view: PetGameplayStateView, skin?: string): PetStateView {
114
+ function snapshot(view: PetGameplayStateView, skin?: string, phase: ActivityPhase = 'idle'): PetStateView {
114
115
  return {
115
116
  animation: 'idle',
116
- phase: 'idle',
117
+ phase,
117
118
  sessionActive: false,
118
119
  sessions: [],
119
120
  affinity: { points: 0, rank: '幼鲸', rankEmoji: '*', pets: 0, feeds: 0, turns: 0, petCooldown: false, feedCooldown: false },
@@ -135,7 +136,7 @@ interface Harness {
135
136
  setView: (view: PetGameplayStateView) => void
136
137
  }
137
138
 
138
- function harness(view: PetGameplayStateView = gameplayView()): Harness {
139
+ function harness(view: PetGameplayStateView = gameplayView(), definition: PetDefinition = petDefinition()): Harness {
139
140
  const store = createPetStore().create()
140
141
  store.actions.setSnapshot(snapshot(view))
141
142
  const bus: GameplayBus = {}
@@ -145,13 +146,13 @@ function harness(view: PetGameplayStateView = gameplayView()): Harness {
145
146
  const ok = (patch?: Partial<PetGameplayVerbResult>): PetGameplayVerbResult => ({ ok: true, ...patch })
146
147
  const api = {
147
148
  touch: vi.fn(async () => ok({ hit: true, state: 'happy', stateMs: 3000, phrase: 'happy!', view: gameplayView({ stats: { ...VIEW.stats, affection: 105 } }) })),
148
- setMode: vi.fn(async (mode: 'work' | 'sleep' | null) => ok({ view: gameplayView({ mode }) })),
149
+ setMode: vi.fn(async (mode: string | null) => ok({ view: gameplayView({ mode }) })),
149
150
  workTick: vi.fn(async () => ok({ outcome: 'success' as const, view: gameplayView({ mode: 'work' }) })),
150
151
  buy: vi.fn(async () => ok({ view: gameplayView() })),
151
152
  setSkin: vi.fn(async () => ({ ok: true })),
152
153
  }
153
154
  const setView = (next: PetGameplayStateView): void => store.actions.setSnapshot(snapshot(next))
154
- render(<GameplayHud definition={petDefinition()} store={store} api={api} bus={bus} drag={drag} t={t} />)
155
+ render(<GameplayHud definition={definition} store={store} api={api} bus={bus} drag={drag} t={t} />)
155
156
  return { store, bus, api, setTrack, drag, setView }
156
157
  }
157
158
 
@@ -164,6 +165,102 @@ describe('GameplayHud', () => {
164
165
  vi.useRealTimers()
165
166
  })
166
167
 
168
+ it('user sees the pet roam on its interval and hold the walk track', () => {
169
+ // Given a pet whose roam always fires; when the roam interval elapses; then
170
+ // the bus walk runs, the walk track is held, then released.
171
+ const definition = petDefinition()
172
+ definition.gameplay = {
173
+ ...definition.gameplay,
174
+ // Park the director far away so this test isolates the roam roll (an act
175
+ // firing on the same tick owns the visual and skips the roam).
176
+ idleDirector: { intervalMs: 60_000, maxMiss: 2, idleWeight: 0, acts: [{ track: 'eat', weight: 1 }] },
177
+ roam: { state: 'happy', intervalMs: 5000, probability: 1, distanceMin: 100, distanceMax: 100, speed: 100 },
178
+ }
179
+ const h = harness(gameplayView(), definition)
180
+ const walk = vi.fn((_direction: string, _distance: number, _speed: number) => 100)
181
+ h.bus.walk = walk
182
+ act(() => { vi.advanceTimersByTime(5000) })
183
+ expect(walk).toHaveBeenCalledTimes(1)
184
+ const first = walk.mock.calls[0] ?? ['left', 0, 0]
185
+ expect(['up', 'down', 'left', 'right']).toContain(first[0])
186
+ expect(first[1]).toBe(100)
187
+ expect(first[2]).toBe(100)
188
+ expect(h.setTrack).toHaveBeenCalledWith('happy')
189
+ // The hold is exactly the travel time (100 px at 100 px/s), then released.
190
+ act(() => { vi.advanceTimersByTime(1000) })
191
+ expect(h.setTrack).toHaveBeenLastCalledWith(undefined)
192
+ // A pet with no room to move does not hold the walk art at all (the
193
+ // idle director's own setTrack calls are ignored here).
194
+ walk.mockReturnValue(0)
195
+ act(() => { vi.advanceTimersByTime(5000) })
196
+ expect(walk).toHaveBeenCalledTimes(2)
197
+ expect(h.setTrack.mock.calls.filter(call => call[0] === 'happy')).toHaveLength(1)
198
+ })
199
+
200
+ it('user sees the pet roam outside the idle phase too', () => {
201
+ // Given the agent is mid-tool-call; when the roam interval elapses; then the
202
+ // pet still wanders (only the director's acts are idle-gated).
203
+ const definition = petDefinition()
204
+ definition.gameplay = {
205
+ ...definition.gameplay,
206
+ idleDirector: { intervalMs: 60_000, maxMiss: 2, idleWeight: 0, acts: [{ track: 'eat', weight: 1 }] },
207
+ roam: { state: 'happy', intervalMs: 5000, probability: 1, distanceMin: 100, distanceMax: 100, speed: 100 },
208
+ }
209
+ const h = harness(gameplayView(), definition)
210
+ // The agent is mid-tool-call: the pet still wanders (only the director's
211
+ // acts are idle-gated).
212
+ h.store.actions.setSnapshot(snapshot(gameplayView(), undefined, 'tool'))
213
+ const walk = vi.fn((_direction: string, _distance: number, _speed: number) => 100)
214
+ h.bus.walk = walk
215
+ act(() => { vi.advanceTimersByTime(5000) })
216
+ expect(walk).toHaveBeenCalledTimes(1)
217
+ const first = walk.mock.calls[0] ?? ['left', 0, 0]
218
+ expect(first[1]).toBe(100)
219
+ })
220
+
221
+ it('user switching to sleep keeps the sleep track when a walk ends', () => {
222
+ // Given a walk holds the walk track; when the user enters sleep mid-walk;
223
+ // then the finished walk must not release the sleep owner's track.
224
+ const definition = petDefinition()
225
+ definition.gameplay = {
226
+ ...definition.gameplay,
227
+ idleDirector: { intervalMs: 60_000, maxMiss: 2, idleWeight: 0, acts: [{ track: 'eat', weight: 1 }] },
228
+ roam: { state: 'happy', intervalMs: 5000, probability: 1, distanceMin: 100, distanceMax: 100, speed: 100 },
229
+ }
230
+ const h = harness(gameplayView(), definition)
231
+ h.bus.walk = vi.fn((_direction: string, _distance: number, _speed: number) => 100)
232
+ // The staggered first roam tick starts a 1 s walk and holds the track.
233
+ act(() => { vi.advanceTimersByTime(2500) })
234
+ expect(h.setTrack).toHaveBeenCalledWith('happy')
235
+ // The user enters sleep mid-walk: the mode loop takes the slot.
236
+ act(() => { h.store.actions.setSnapshot(snapshot(gameplayView({ mode: 'sleep' }))) })
237
+ expect(h.setTrack).toHaveBeenLastCalledWith('sleep')
238
+ // The walk's hold window elapses: it must not clear the sleep track.
239
+ act(() => { vi.advanceTimersByTime(3000) })
240
+ expect(h.setTrack).toHaveBeenLastCalledWith('sleep')
241
+ })
242
+
243
+ it('user cannot see the roam cut into an idle-director act', () => {
244
+ // Given an act that owns the visual; when the roam's staggered tick lands;
245
+ // then the walk is skipped.
246
+ const definition = petDefinition()
247
+ definition.frames2d!.tracks.eat!.durations = [60_000]
248
+ definition.gameplay = {
249
+ ...definition.gameplay,
250
+ idleDirector: { intervalMs: 2000, maxMiss: 2, idleWeight: 0, acts: [{ track: 'eat', weight: 1 }] },
251
+ roam: { state: 'happy', intervalMs: 6000, probability: 1, distanceMin: 100, distanceMax: 100, speed: 100 },
252
+ }
253
+ const h = harness(gameplayView(), definition)
254
+ const walk = vi.fn((_direction: string, _distance: number, _speed: number) => 100)
255
+ h.bus.walk = walk
256
+ // 2 s: the director fires its act, which owns the visual for its duration.
257
+ act(() => { vi.advanceTimersByTime(2000) })
258
+ expect(h.setTrack).toHaveBeenCalledWith('eat')
259
+ // 3 s: the roam's staggered first tick lands, but the act still owns the pet.
260
+ act(() => { vi.advanceTimersByTime(1000) })
261
+ expect(walk).not.toHaveBeenCalled()
262
+ })
263
+
167
264
  it('opens the menu card through the bus openCard channel and shows the shop page', () => {
168
265
  const h = harness()
169
266
  expect(h.bus.openCard).toBeDefined()
@@ -440,8 +537,9 @@ describe('GameplayHud', () => {
440
537
  vi.advanceTimersByTime(5000)
441
538
  })
442
539
  expect(h.setTrack).toHaveBeenCalledWith('eat')
443
- // A non-idle phase suppresses the roll.
444
- h.store.actions.setSnapshot(snapshot(gameplayView()))
540
+ // Ambient acts are not phase-gated: while the agent works (thinking/tool)
541
+ // the roll still fires, otherwise she would only ever act when nobody is
542
+ // busy -- the idle phase is rare while an active session is running.
445
543
  h.setTrack.mockClear()
446
544
  const busy = snapshot(gameplayView())
447
545
  busy.phase = 'thinking'
@@ -451,7 +549,7 @@ describe('GameplayHud', () => {
451
549
  await act(async () => {
452
550
  vi.advanceTimersByTime(5000)
453
551
  })
454
- expect(h.setTrack).not.toHaveBeenCalled()
552
+ expect(h.setTrack).toHaveBeenCalledWith('eat')
455
553
  })
456
554
 
457
555
  it('drives the work loop: work track, adjudicated ticks, result hold', async () => {
@@ -13,7 +13,7 @@ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, typ
13
13
  import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
14
14
  import type { PetDefinition, PetSkinDefinition } from '../registry.ts'
15
15
  import type { PetGameplayVerbResult } from '../service.ts'
16
- import { touchZoneAt } from '../gameplay.ts'
16
+ import { declaredModes, modeStateOf, PET_ROAM_DIRECTIONS, touchZoneAt, type PetRoamDirection } from '../gameplay.ts'
17
17
  import type { PetStoreInstance } from './pet-store.ts'
18
18
  import type { DragStream } from './drag-stream.ts'
19
19
  import type { NS } from './locales.ts'
@@ -22,7 +22,7 @@ import styles from './pet.module.css'
22
22
  /** The gameplay verb API the plugin apply body injects (host-authoritative). */
23
23
  export interface GameplayApi {
24
24
  touch: (zone?: string) => Promise<PetGameplayVerbResult>
25
- setMode: (mode: 'work' | 'sleep' | null) => Promise<PetGameplayVerbResult>
25
+ setMode: (mode: string | null) => Promise<PetGameplayVerbResult>
26
26
  workTick: () => Promise<PetGameplayVerbResult>
27
27
  buy: (item: string) => Promise<PetGameplayVerbResult>
28
28
  /**
@@ -56,6 +56,14 @@ export interface GameplayBus {
56
56
  * while a boolean pins the state (same chrome -> HUD direction as tap).
57
57
  */
58
58
  openCard?: (open?: boolean) => void
59
+ /**
60
+ * Walk the sprite in `direction` by `distance` px at `speed` px/s, clamped
61
+ * to the viewport, and persist the resting spot like a drag. Returns the
62
+ * distance actually travelled (0 when it cannot move), so the caller can
63
+ * hold the walk art for exactly that long. Registered by the chrome
64
+ * (PetSprite); absent while the sprite is unmounted.
65
+ */
66
+ walk?: (direction: PetRoamDirection, distance: number, speed: number) => number
59
67
  }
60
68
 
61
69
  type HudPage = 'root' | 'shop' | 'skins'
@@ -81,7 +89,6 @@ export function GameplayHud(props: {
81
89
  const ui = useSyncExternalStore(store.subscribe, store.getSnapshot)
82
90
  const def = definition.gameplay
83
91
  const view = ui.snapshot?.gameplay
84
- const phase = ui.snapshot?.phase ?? 'idle'
85
92
  // Host-persisted skin selection for this pet (undefined = default look).
86
93
  const persistedSkin = ui.snapshot?.skin
87
94
 
@@ -95,14 +102,21 @@ export function GameplayHud(props: {
95
102
  const cardRef = useRef<HTMLDivElement | null>(null)
96
103
  const [floats, setFloats] = useState<HudFloat[]>([])
97
104
  // Mutable driver state (refs so intervals never re-arm on a poll tick).
98
- const modeRef = useRef<'work' | 'sleep' | null>(view?.mode ?? null)
105
+ const modeRef = useRef<string | null>(view?.mode ?? null)
99
106
  modeRef.current = view?.mode ?? null
100
- const phaseRef = useRef(phase)
101
- phaseRef.current = phase
107
+ /** Live gameplay view for the interval loops (def identity is stable, view is not). */
108
+ const viewRef = useRef(view)
109
+ viewRef.current = view
102
110
  const draggingRef = useRef(false)
103
111
  const touchLockUntilRef = useRef(0)
104
112
  const missRef = useRef(0)
105
113
  const busyRef = useRef(false)
114
+ /** A roam walk owns the visual right now (the idle director must not steal it). */
115
+ const roamHeldRef = useRef(false)
116
+ /** A one-shot idle-director act owns the visual right now (the roam must not cut in). */
117
+ const actHeldRef = useRef(false)
118
+ /** Pending release of the roam's walk track (cleared when another owner takes it). */
119
+ const roamTimerRef = useRef(0)
106
120
 
107
121
  // Dynamic-key lookups (stat ids / currency ids are manifest data).
108
122
  const tr = props.t as unknown as (key: string, values?: Record<string, string | number>) => string
@@ -121,6 +135,20 @@ export function GameplayHud(props: {
121
135
  if (result.view !== undefined) store.actions.setGameplayView(result.view)
122
136
  }
123
137
 
138
+ /**
139
+ * Give up the roam's claim on the shared track-override slot. Every other
140
+ * owner (a mode, a touch reaction, a drag) calls this before it takes the
141
+ * slot, so a walk that is still in flight can never release someone else's
142
+ * track when its own hold window elapses.
143
+ */
144
+ const yieldRoam = (): void => {
145
+ if (roamTimerRef.current !== 0) {
146
+ window.clearTimeout(roamTimerRef.current)
147
+ roamTimerRef.current = 0
148
+ }
149
+ roamHeldRef.current = false
150
+ }
151
+
124
152
  // Tap handling (registered on the bus; PetSprite reports sprite-box
125
153
  // fractions). Sleep wakes on tap; work blocks taps; a held touch
126
154
  // animation turns taps into the plain-click boost.
@@ -134,6 +162,7 @@ export function GameplayHud(props: {
134
162
  // consecutive taps cannot retrigger mid-play (skin click actions and
135
163
  // default touch-zone reactions share this path).
136
164
  const holdTrack = (track: string, holdMs: number): void => {
165
+ yieldRoam()
137
166
  bus.setTrack?.(track)
138
167
  touchLockUntilRef.current = Date.now() + holdMs
139
168
  window.setTimeout(() => {
@@ -151,7 +180,9 @@ export function GameplayHud(props: {
151
180
  const trackDuration = (track: string): number =>
152
181
  definition.frames2d?.tracks[track]?.durations.reduce((sum, ms) => sum + ms, 0) ?? 0
153
182
  bus.tap = (fx, fy) => {
154
- if (modeRef.current === 'sleep') {
183
+ // Any non-work mode ends on a tap (sleep wakes up, a bath steps out);
184
+ // work keeps swallowing taps while its round is running.
185
+ if (modeRef.current !== null && modeRef.current !== 'work') {
155
186
  void api.setMode(null).then(applyResult, () => undefined)
156
187
  return
157
188
  }
@@ -239,12 +270,13 @@ export function GameplayHud(props: {
239
270
  // eslint-disable-next-line react-hooks/exhaustive-deps -- one placement per open/page change
240
271
  }, [open, page])
241
272
 
242
- // Drag gestures wake a sleeping pet (miku behavior); the drag stream also
243
- // feeds the idle director's suppression check.
273
+ // Drag gestures end any non-work mode (sleep wakes, a bath steps out); the
274
+ // drag stream also feeds the idle director's suppression check.
244
275
  useEffect(() => {
245
276
  return props.drag.subscribe((dragging) => {
246
277
  draggingRef.current = dragging
247
- if (dragging && modeRef.current === 'sleep') {
278
+ if (dragging) yieldRoam()
279
+ if (dragging && modeRef.current !== null && modeRef.current !== 'work') {
248
280
  void api.setMode(null).then(applyResult, () => undefined)
249
281
  }
250
282
  })
@@ -252,18 +284,22 @@ export function GameplayHud(props: {
252
284
  }, [props.drag])
253
285
 
254
286
  // Idle director: weighted rolls between staying idle and playing an act.
255
- // Runs only while the phase mapping owns the visual (idle phase, no mode,
256
- // no drag, no held touch animation); maxMiss forces an act after too many
257
- // idle rolls in a row.
287
+ // Rolls in every phase -- an ambient act is not a waiting-room performance,
288
+ // and the idle phase only appears when neither the agent nor the user is busy
289
+ // -- but only while nothing else owns the visual: no mode, no drag, no held
290
+ // touch animation, no roam walk and no act already playing. maxMiss forces an
291
+ // act after too many idle rolls in a row.
258
292
  useEffect(() => {
259
293
  const director = def?.idleDirector
260
294
  if (def === undefined || director === undefined) return undefined
261
295
  const total = director.idleWeight + director.acts.reduce((sum, act) => sum + act.weight, 0)
262
296
  if (total <= 0) return undefined
297
+ let actTimer = 0
263
298
  const timer = window.setInterval(() => {
264
- if (phaseRef.current !== 'idle') return
265
299
  if (modeRef.current !== null || draggingRef.current) return
266
300
  if (Date.now() < touchLockUntilRef.current) return
301
+ if (roamHeldRef.current) return // a roam walk owns the visual
302
+ if (actHeldRef.current) return // the previous act is still playing
267
303
  let pickedAct: { track: string; weight: number; phrases?: string[] } | undefined
268
304
  if (missRef.current >= director.maxMiss) {
269
305
  // Forced act: pick among the acts only.
@@ -285,14 +321,24 @@ export function GameplayHud(props: {
285
321
  return
286
322
  }
287
323
  missRef.current = 0
324
+ actHeldRef.current = true
288
325
  bus.setTrack?.(pickedAct.track)
326
+ // A one-shot act owns the visual until its own duration elapses (the
327
+ // renderer then falls back to the phase map); the roam must not cut in.
328
+ const holdMs = definition.frames2d?.tracks[pickedAct.track]?.durations.reduce((sum, ms) => sum + ms, 0) ?? 0
329
+ window.clearTimeout(actTimer)
330
+ actTimer = window.setTimeout(() => { actHeldRef.current = false }, holdMs > 0 ? holdMs : 3000)
289
331
  // Acts with a phrase pool speak one line while they play (miku parity).
290
332
  if (pickedAct.phrases !== undefined && pickedAct.phrases.length > 0) {
291
333
  const phrase = pickedAct.phrases[Math.floor(Math.random() * pickedAct.phrases.length)]!
292
334
  store.actions.setFeedback({ text: phrase, kind: 'none', at: Date.now() })
293
335
  }
294
336
  }, director.intervalMs)
295
- return () => window.clearInterval(timer)
337
+ return () => {
338
+ window.clearInterval(timer)
339
+ window.clearTimeout(actTimer)
340
+ actHeldRef.current = false
341
+ }
296
342
  // eslint-disable-next-line react-hooks/exhaustive-deps -- one director per pet definition
297
343
  }, [definition.id, def])
298
344
 
@@ -335,25 +381,93 @@ export function GameplayHud(props: {
335
381
  // eslint-disable-next-line react-hooks/exhaustive-deps -- the loop keys on the mode and the selected skin
336
382
  }, [definition.id, def, view?.mode, skinId])
337
383
 
338
- // Sleep loop: hold the sleep track; restore is host-side (lazy settle).
339
- // While a skin with a gameplayTracks.sleep override is selected, the skin's
340
- // own track replaces the default sleep track (e.g. a skin-specific doze).
384
+ // Mode loop: hold the active non-work mode's track (sleep, a bath, any
385
+ // extra 'modes' entry). The stat restore is host-side (lazy settle). While a
386
+ // skin with a matching gameplayTracks override is selected, the skin's own
387
+ // track replaces the default one (e.g. a skin-specific doze).
341
388
  useEffect(() => {
342
- const sleep = def?.sleep
343
- if (def === undefined || sleep === undefined || view?.mode !== 'sleep') return undefined
389
+ const active = view?.mode
390
+ if (def === undefined || active === undefined || active === null || active === 'work') return undefined
391
+ const hold = modeStateOf(def, active)
392
+ if (hold === undefined) return undefined
344
393
  const skinGameplay = definition.frames2d?.skins?.find(skin => skin.id === skinIdRef.current)?.gameplayTracks
345
- const hold = skinGameplay?.['sleep'] ?? sleep.state
346
- bus.setTrack?.(hold)
394
+ yieldRoam()
395
+ bus.setTrack?.(skinGameplay?.[active] ?? hold)
347
396
  return () => bus.setTrack?.(undefined)
348
397
  // eslint-disable-next-line react-hooks/exhaustive-deps -- the loop keys on the mode value
349
398
  }, [definition.id, def, view?.mode, skinId])
350
399
 
400
+ // Random roaming (manifest 'roam'): on a slow interval -- in any phase, as long
401
+ // as no mode, drag, touch animation or director act owns the pet -- roll a
402
+ // wander. The chrome walks the sprite and persists the new spot; this side
403
+ // holds the walk track for exactly the travel time.
404
+ useEffect(() => {
405
+ const roam = def?.roam
406
+ if (def === undefined || roam === undefined) return undefined
407
+ const roll = (): void => {
408
+ // Every phase: a wandering pet is ambient, not a performance, so she
409
+ // keeps walking while the agent works (the idle director's acts stay
410
+ // idle-only). A mode, a drag, a touch animation and a director
411
+ // act all still own the pet and suppress the roll.
412
+ if (modeRef.current !== null || draggingRef.current) return
413
+ if (Date.now() < touchLockUntilRef.current) return
414
+ if (roamHeldRef.current || actHeldRef.current) return
415
+ if (Math.random() >= roam.probability) return
416
+ const span = roam.distanceMax - roam.distanceMin
417
+ const distance = roam.distanceMin + Math.random() * span
418
+ const directions = roam.directions ?? PET_ROAM_DIRECTIONS
419
+ const direction = directions[Math.floor(Math.random() * directions.length)] ?? 'left'
420
+ const travelled = bus.walk?.(direction, distance, roam.speed) ?? 0
421
+ if (travelled === 0) return
422
+ roamHeldRef.current = true
423
+ bus.setTrack?.(roam.state)
424
+ roamTimerRef.current = window.setTimeout(() => {
425
+ roamTimerRef.current = 0
426
+ if (!roamHeldRef.current) return // another owner already took the slot
427
+ roamHeldRef.current = false
428
+ bus.setTrack?.(undefined)
429
+ }, Math.max(150, (Math.abs(travelled) / roam.speed) * 1000))
430
+ }
431
+ // The two decision timers must never share a tick. The roam starts half an
432
+ // interval late, so its ticks interleave with the idle director's instead
433
+ // of landing on the same instant (10 s and 15 s coincide every 30 s).
434
+ let timer = 0
435
+ const lead = window.setTimeout(() => {
436
+ roll()
437
+ timer = window.setInterval(roll, roam.intervalMs)
438
+ }, Math.round(roam.intervalMs / 2))
439
+ return () => {
440
+ window.clearTimeout(lead)
441
+ window.clearInterval(timer)
442
+ yieldRoam()
443
+ }
444
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one director per pet definition
445
+ }, [definition.id, def])
446
+
351
447
  if (def === undefined || view === undefined) return null
352
448
 
353
449
  const mode = view.mode
354
450
  const stats = def.stats ?? {}
355
451
  const shop = def.shop
356
452
 
453
+ // Every menu mode beyond work: sleep plus any extra 'modes' the pet declares.
454
+ const menuModes = declaredModes(def)
455
+
456
+ /** Action-button label for one mode (active = the button that leaves it). */
457
+ const modeLabel = (name: string, active: boolean): string => {
458
+ if (name === 'sleep') return tr(active ? 'pet.gameplay.wake' : 'pet.gameplay.sleep')
459
+ const declared = def.modes?.[name]
460
+ return (active ? declared?.activeLabel ?? declared?.label : declared?.label) ?? tr('pet.gameplay.' + name)
461
+ }
462
+
463
+ /** Chip label for the mode the pet is in right now. */
464
+ const modeChip = (name: string): string => {
465
+ if (name === 'work') return tr('pet.gameplay.working')
466
+ if (name === 'sleep') return tr('pet.gameplay.sleeping')
467
+ const declared = def.modes?.[name]
468
+ return declared?.activeLabel ?? declared?.label ?? tr('pet.gameplay.' + name)
469
+ }
470
+
357
471
  const buy = (itemId: string): void => {
358
472
  void api.buy(itemId).then((result) => {
359
473
  applyResult(result)
@@ -370,7 +484,7 @@ export function GameplayHud(props: {
370
484
  }, () => undefined)
371
485
  }
372
486
 
373
- const setMode = (next: 'work' | 'sleep' | null): void => {
487
+ const setMode = (next: string | null): void => {
374
488
  void api.setMode(next).then(applyResult, () => undefined)
375
489
  }
376
490
 
@@ -421,7 +535,7 @@ export function GameplayHud(props: {
421
535
  ))}
422
536
  {mode !== null && (
423
537
  <div className={styles.gameplayModeChip}>
424
- {tr(mode === 'work' ? 'pet.gameplay.working' : 'pet.gameplay.sleeping')}
538
+ {modeChip(mode)}
425
539
  </div>
426
540
  )}
427
541
  {open && (
@@ -445,15 +559,16 @@ export function GameplayHud(props: {
445
559
  })}
446
560
  </div>
447
561
  <div className={styles.gameplayActions}>
448
- {def.sleep !== undefined && (
562
+ {menuModes.map(name => (
449
563
  <button
564
+ key={name}
450
565
  type="button"
451
566
  className={styles.action}
452
- onClick={() => setMode(mode === 'sleep' ? null : 'sleep')}
567
+ onClick={() => setMode(mode === name ? null : name)}
453
568
  >
454
- {tr(mode === 'sleep' ? 'pet.gameplay.wake' : 'pet.gameplay.sleep')}
569
+ {modeLabel(name, mode === name)}
455
570
  </button>
456
- )}
571
+ ))}
457
572
  {shop !== undefined && (
458
573
  <button type="button" className={styles.action} onClick={() => setPage('shop')}>
459
574
  {tr('pet.gameplay.shop')}
@@ -63,6 +63,8 @@ interface FakeClientLifecycle {
63
63
  emitSettings(): void
64
64
  sessionsListenerCount(): number
65
65
  setEnabled(enabled: boolean): void
66
+ /** Namespaces the family settings binder was asked for (empty without one). */
67
+ boundNamespaces(): string[]
66
68
  }
67
69
 
68
70
  const activeLifecycles: FakeClientLifecycle[] = []
@@ -72,11 +74,19 @@ afterEach(() => {
72
74
  document.body.replaceChildren()
73
75
  })
74
76
 
75
- function fakeContext(): FakeClientLifecycle {
77
+ interface FakeContextOptions {
78
+ /** Provide the dsh-web-settings family binder, as the group plugin does. */
79
+ familyBinder?: boolean
80
+ /** Initial value of the settings section the bound form answers with. */
81
+ enabled?: boolean
82
+ }
83
+
84
+ function fakeContext(options: FakeContextOptions = {}): FakeClientLifecycle {
76
85
  const disposers: (() => void)[] = []
77
86
  const settingsListeners = new Set<() => void>()
78
87
  const sessionListeners = new Set<() => void>()
79
- let settingsValue: { enabled?: boolean } | undefined
88
+ const boundNamespaces: string[] = []
89
+ let settingsValue: { enabled?: boolean } | undefined = options.enabled === undefined ? undefined : { enabled: options.enabled }
80
90
  const scope = {
81
91
  getSnapshot: () => ({
82
92
  status: 'ready',
@@ -92,6 +102,12 @@ function fakeContext(): FakeClientLifecycle {
92
102
  return () => { settingsListeners.delete(listener) }
93
103
  },
94
104
  }
105
+ const familyBinder = {
106
+ bind: (spec: { namespace: string }) => {
107
+ boundNamespaces.push(spec.namespace)
108
+ return scope
109
+ },
110
+ }
95
111
  const ctx = {
96
112
  effect: (fn: () => unknown, _label?: string) => {
97
113
  const dispose = fn()
@@ -101,8 +117,10 @@ function fakeContext(): FakeClientLifecycle {
101
117
  return cleanup
102
118
  },
103
119
  locale: { register: () => () => {} },
104
- get: () => undefined,
105
- settingsScope: { bind: () => scope },
120
+ // The family binder is optional (dsh-web-settings may be absent); both
121
+ // answers are the shared per-entry form of this plugin's own profile entry.
122
+ get: (name: string) => name === 'webUiSettings' && options.familyBinder === true ? familyBinder : undefined,
123
+ configForms: { get: () => scope },
106
124
  slots: {
107
125
  // Cordis runs the factory when the slot mounts and its returned
108
126
  // disposer when the fiber disposes; mirror that so slot content
@@ -139,6 +157,7 @@ function fakeContext(): FakeClientLifecycle {
139
157
  },
140
158
  sessionsListenerCount: () => sessionListeners.size,
141
159
  setEnabled: (enabled: boolean) => { settingsValue = { enabled } },
160
+ boundNamespaces: () => boundNamespaces,
142
161
  }
143
162
  activeLifecycles.push(lifecycle)
144
163
  return lifecycle
@@ -236,4 +255,18 @@ describe('pet client apply', () => {
236
255
  expect(lifecycle.sessionsListenerCount()).toBe(1)
237
256
  expect(document.body.querySelectorAll('[data-dsh-pet-root]')).toHaveLength(1)
238
257
  })
258
+
259
+ it('user gets the pet settings form through the family binder when the group plugin is mounted', () => {
260
+ // Given a client context that provides the dsh-web-settings family binder
261
+ // and answers 'disabled' through the form that binder returns
262
+ const lifecycle = fakeContext({ familyBinder: true, enabled: false })
263
+
264
+ // When the pet client plugin applies
265
+ apply(lifecycle.ctx)
266
+
267
+ // Then the card bound the 'pet' namespace through that binder...
268
+ expect(lifecycle.boundNamespaces()).toEqual(['pet'])
269
+ // ...and the pet surface followed the form it answered (disabled -> hidden)
270
+ expect(document.body.querySelectorAll('[data-dsh-pet-root]')).toHaveLength(0)
271
+ })
239
272
  })