@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.
- package/README.i18n.yaml +2 -2
- package/README.md +4 -3
- package/README.zh.md +4 -3
- package/icon.svg +3 -0
- package/lib/client.js +522 -194
- package/lib/client.js.map +1 -1
- package/lib/index.js +706 -422
- package/lib/live2d-vendor.js +96 -96
- package/lib/live2d-vendor.js.map +1 -1
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +1 -1
- package/lib/types/client/PetSettingsCard.d.ts +20 -9
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +116 -5
- package/lib/types/client/PetSprite.d.ts +6 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +95 -0
- package/lib/types/client/PluginSettingsCard.d.ts +4 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -2
- package/lib/types/client/gameplay-hud.d.ts +10 -1
- package/lib/types/client/gameplay-hud.d.ts.map +1 -1
- package/lib/types/client/gameplay-hud.js +137 -23
- package/lib/types/client/index.d.ts +8 -5
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +61 -27
- package/lib/types/client/main-session.d.ts +33 -0
- package/lib/types/client/main-session.d.ts.map +1 -0
- package/lib/types/client/main-session.js +34 -0
- package/lib/types/client/settings-form.d.ts +17 -17
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +30 -27
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +5 -3
- package/lib/types/gameplay.d.ts +79 -5
- package/lib/types/gameplay.d.ts.map +1 -1
- package/lib/types/gameplay.js +212 -17
- package/lib/types/http.d.ts +18 -1
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +22 -1
- package/lib/types/index.d.ts +100 -29
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +113 -64
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +3 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +3 -1
- package/lib/types/service.d.ts +8 -3
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +26 -4
- package/package.json +46 -26
- package/src/client/PetDockEntry.tsx +1 -0
- package/src/client/PetSettingsCard.test.tsx +103 -0
- package/src/client/PetSettingsCard.tsx +129 -26
- package/src/client/PetSprite.tsx +96 -0
- package/src/client/PluginSettingsCard.tsx +7 -2
- package/src/client/gameplay-hud.test.tsx +106 -8
- package/src/client/gameplay-hud.tsx +143 -28
- package/src/client/index.test.tsx +37 -4
- package/src/client/index.ts +74 -34
- package/src/client/main-session.ts +43 -0
- package/src/client/settings-form.ts +34 -31
- package/src/event-projection.ts +5 -3
- package/src/gameplay.test.ts +138 -1
- package/src/gameplay.ts +253 -16
- package/src/http.ts +23 -1
- package/src/index.test.ts +107 -11
- package/src/index.ts +162 -73
- package/src/persist.ts +3 -1
- package/src/registry.test.ts +3 -2
- package/src/routes.ts +3 -1
- 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
|
|
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:
|
|
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={
|
|
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
|
-
//
|
|
444
|
-
|
|
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).
|
|
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:
|
|
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<
|
|
105
|
+
const modeRef = useRef<string | null>(view?.mode ?? null)
|
|
99
106
|
modeRef.current = view?.mode ?? null
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
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 () =>
|
|
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
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
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
|
|
343
|
-
if (def === 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
|
-
|
|
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:
|
|
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
|
-
{
|
|
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
|
-
{
|
|
562
|
+
{menuModes.map(name => (
|
|
449
563
|
<button
|
|
564
|
+
key={name}
|
|
450
565
|
type="button"
|
|
451
566
|
className={styles.action}
|
|
452
|
-
onClick={() => setMode(mode ===
|
|
567
|
+
onClick={() => setMode(mode === name ? null : name)}
|
|
453
568
|
>
|
|
454
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
})
|