@linxin666/dsh-pet 0.3.20 → 0.3.21
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/lib/client.js +127 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +99 -4
- package/lib/types/client/gameplay-hud.d.ts +15 -0
- package/lib/types/client/gameplay-hud.d.ts.map +1 -1
- package/lib/types/client/gameplay-hud.js +48 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +21 -13
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -1
- package/lib/types/client/renderers/Frames2dVisualMount.js +5 -0
- package/lib/types/client/renderers/frames2d.d.ts +5 -4
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
- package/lib/types/client/renderers/frames2d.js +27 -18
- package/lib/types/client/work-tick-gate.d.ts +40 -0
- package/lib/types/client/work-tick-gate.d.ts.map +1 -0
- package/lib/types/client/work-tick-gate.js +49 -0
- package/lib/types/gameplay.d.ts +4 -0
- package/lib/types/gameplay.d.ts.map +1 -1
- package/lib/types/gameplay.js +11 -2
- package/lib/types/ledger.d.ts +8 -0
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +25 -0
- package/lib/types/persist.d.ts +6 -0
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +25 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +6 -0
- package/lib/types/service.d.ts +24 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +32 -0
- package/package.json +1 -1
- package/src/client/PetDockEntry.test.tsx +1 -0
- package/src/client/gameplay-hud.test.tsx +165 -2
- package/src/client/gameplay-hud.tsx +62 -6
- package/src/client/index.ts +23 -13
- package/src/client/pet.module.css +1 -1
- package/src/client/renderers/Frames2dVisualMount.test.tsx +73 -0
- package/src/client/renderers/Frames2dVisualMount.tsx +4 -0
- package/src/client/renderers/frames2d.test.ts +38 -6
- package/src/client/renderers/frames2d.ts +27 -19
- package/src/client/work-tick-gate.test.ts +53 -0
- package/src/client/work-tick-gate.ts +63 -0
- package/src/gameplay.test.ts +36 -0
- package/src/gameplay.ts +14 -2
- package/src/ledger.test.ts +19 -0
- package/src/ledger.ts +24 -0
- package/src/persist.test.ts +13 -0
- package/src/persist.ts +29 -1
- package/src/routes.ts +5 -0
- package/src/service.ts +38 -0
|
@@ -25,6 +25,11 @@ export interface GameplayApi {
|
|
|
25
25
|
setMode: (mode: 'work' | 'sleep' | null) => Promise<PetGameplayVerbResult>
|
|
26
26
|
workTick: () => Promise<PetGameplayVerbResult>
|
|
27
27
|
buy: (item: string) => Promise<PetGameplayVerbResult>
|
|
28
|
+
/**
|
|
29
|
+
* Persist the selected skin for the current pet (host-authoritative;
|
|
30
|
+
* `undefined` restores the pet's default look).
|
|
31
|
+
*/
|
|
32
|
+
setSkin: (skin: string | undefined) => Promise<{ ok: boolean; error?: string }>
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/**
|
|
@@ -37,6 +42,13 @@ export interface GameplayBus {
|
|
|
37
42
|
setTrack?: (track?: string) => void
|
|
38
43
|
/** Swap the pet's base idle track (skin switch); undefined restores default. */
|
|
39
44
|
setIdleTrack?: (track?: string) => void
|
|
45
|
+
/**
|
|
46
|
+
* The base idle track the HUD wants right now, latched on the bus so a
|
|
47
|
+
* renderer that registers late (or remounts: hidden/summoned, StrictMode's
|
|
48
|
+
* double mount) still applies the restored skin instead of snapping back to
|
|
49
|
+
* the default look. Mutating it never requires a re-render.
|
|
50
|
+
*/
|
|
51
|
+
idleTrack?: string
|
|
40
52
|
tap?: (fx: number, fy: number) => void
|
|
41
53
|
/**
|
|
42
54
|
* Card open/close request from the chrome (the hover panel's 玩法 action):
|
|
@@ -70,6 +82,8 @@ export function GameplayHud(props: {
|
|
|
70
82
|
const def = definition.gameplay
|
|
71
83
|
const view = ui.snapshot?.gameplay
|
|
72
84
|
const phase = ui.snapshot?.phase ?? 'idle'
|
|
85
|
+
// Host-persisted skin selection for this pet (undefined = default look).
|
|
86
|
+
const persistedSkin = ui.snapshot?.skin
|
|
73
87
|
|
|
74
88
|
const [open, setOpen] = useState(false)
|
|
75
89
|
const [page, setPage] = useState<HudPage>('root')
|
|
@@ -284,24 +298,32 @@ export function GameplayHud(props: {
|
|
|
284
298
|
|
|
285
299
|
// Work loop: hold the work track, adjudicate one round per tick, play the
|
|
286
300
|
// result track for its hold window, then resume. Leaving the mode
|
|
287
|
-
// releases the override so the phase mapping takes over.
|
|
301
|
+
// releases the override so the phase mapping takes over. A skin that
|
|
302
|
+
// declares gameplayTracks for the work states plays its own art instead.
|
|
288
303
|
useEffect(() => {
|
|
289
304
|
const work = def?.work
|
|
290
305
|
if (def === undefined || work === undefined || view?.mode !== 'work') return undefined
|
|
291
|
-
|
|
306
|
+
const skinGameplay = definition.frames2d?.skins?.find(skin => skin.id === skinIdRef.current)?.gameplayTracks
|
|
307
|
+
/** The state's track, swapped for the skin's override when it declares one. */
|
|
308
|
+
const trackOf = (state: string): string => skinGameplay?.[state] ?? state
|
|
309
|
+
bus.setTrack?.(trackOf(work.state))
|
|
292
310
|
let resultTimer = 0
|
|
293
311
|
const timer = window.setInterval(() => {
|
|
294
312
|
if (busyRef.current) return
|
|
295
313
|
busyRef.current = true
|
|
296
314
|
void api.workTick().then((result) => {
|
|
297
315
|
busyRef.current = false
|
|
316
|
+
// Leaving work mode while the adjudication is in flight drops the late
|
|
317
|
+
// result: writing it back would show work rewards and play the result
|
|
318
|
+
// track for a mode the user has already left (#1495).
|
|
319
|
+
if (modeRef.current !== 'work') return
|
|
298
320
|
applyResult(result)
|
|
299
321
|
if (result.ok !== true || result.outcome === undefined) return
|
|
300
|
-
const resultTrack = result.outcome === 'success' ? work.successState : work.failState
|
|
322
|
+
const resultTrack = trackOf(result.outcome === 'success' ? work.successState : work.failState)
|
|
301
323
|
const hold = result.outcome === 'success' ? work.resultMs?.success ?? 1300 : work.resultMs?.fail ?? 1900
|
|
302
324
|
bus.setTrack?.(resultTrack)
|
|
303
325
|
resultTimer = window.setTimeout(() => {
|
|
304
|
-
if (modeRef.current === 'work') bus.setTrack?.(work.state)
|
|
326
|
+
if (modeRef.current === 'work') bus.setTrack?.(trackOf(work.state))
|
|
305
327
|
}, hold)
|
|
306
328
|
}, () => { busyRef.current = false })
|
|
307
329
|
}, work.tickMs)
|
|
@@ -310,8 +332,8 @@ export function GameplayHud(props: {
|
|
|
310
332
|
window.clearTimeout(resultTimer)
|
|
311
333
|
bus.setTrack?.(undefined)
|
|
312
334
|
}
|
|
313
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- the loop keys on the mode
|
|
314
|
-
}, [definition.id, def, view?.mode])
|
|
335
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- the loop keys on the mode and the selected skin
|
|
336
|
+
}, [definition.id, def, view?.mode, skinId])
|
|
315
337
|
|
|
316
338
|
// Sleep loop: hold the sleep track; restore is host-side (lazy settle).
|
|
317
339
|
// While a skin with a gameplayTracks.sleep override is selected, the skin's
|
|
@@ -352,10 +374,44 @@ export function GameplayHud(props: {
|
|
|
352
374
|
void api.setMode(next).then(applyResult, () => undefined)
|
|
353
375
|
}
|
|
354
376
|
|
|
377
|
+
// Skin selection is persisted host-side (per pet): re-seed the menu from
|
|
378
|
+
// every fresh state view, so a page reload or client restart keeps the last
|
|
379
|
+
// choice instead of snapping back to the default look.
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
setSkinId(persistedSkin)
|
|
382
|
+
}, [definition.id, persistedSkin])
|
|
383
|
+
|
|
384
|
+
// Push the resolved base idle track into the renderer whenever the pet or
|
|
385
|
+
// the selection changes. The value is latched on the bus first: the visual
|
|
386
|
+
// may register later, or remount later (hidden/summoned), and reads the
|
|
387
|
+
// latch back on activation so a restored skin never falls back to default.
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
const skin = definition.frames2d?.skins?.find(candidate => candidate.id === skinId)
|
|
390
|
+
bus.idleTrack = skin?.idleTrack
|
|
391
|
+
bus.setIdleTrack?.(skin?.idleTrack)
|
|
392
|
+
// persistedSkin rides the deps so the host's value also re-pushes on
|
|
393
|
+
// arrival (a renderer that mounted early still converges).
|
|
394
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- one push per selection
|
|
395
|
+
}, [definition.id, skinId, persistedSkin])
|
|
396
|
+
|
|
355
397
|
const skins = definition.frames2d?.skins
|
|
398
|
+
/** The base idle track one skin id resolves to (undefined = default look). */
|
|
399
|
+
const skinTrackOf = (id: string | undefined): string | undefined =>
|
|
400
|
+
id === undefined ? undefined : definition.frames2d?.skins?.find(candidate => candidate.id === id)?.idleTrack
|
|
401
|
+
|
|
356
402
|
const selectSkin = (skin: PetSkinDefinition | undefined): void => {
|
|
357
403
|
setSkinId(skin?.id)
|
|
358
404
|
bus.setIdleTrack?.(skin?.idleTrack)
|
|
405
|
+
// The host owns the choice: a refusal (unknown skin) restores both the
|
|
406
|
+
// menu highlight and the renderer to the value it still serves.
|
|
407
|
+
const restore = (): void => {
|
|
408
|
+
setSkinId(persistedSkin)
|
|
409
|
+
bus.setIdleTrack?.(skinTrackOf(persistedSkin))
|
|
410
|
+
}
|
|
411
|
+
void api.setSkin(skin?.id).then((result) => {
|
|
412
|
+
if (result.ok) return
|
|
413
|
+
restore()
|
|
414
|
+
}, restore)
|
|
359
415
|
}
|
|
360
416
|
|
|
361
417
|
return (
|
package/src/client/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type { PetDefinition } from '../registry.ts'
|
|
|
32
32
|
import { createElement } from 'react'
|
|
33
33
|
import { createRoot } from 'react-dom/client'
|
|
34
34
|
import { createPetStore, type PetStoreInstance } from './pet-store.ts'
|
|
35
|
+
import { createWorkTickGate } from './work-tick-gate.ts'
|
|
35
36
|
import { PetDockEntry, type PetInjected } from './PetDockEntry.tsx'
|
|
36
37
|
import { defaultPetRendererRegistry } from './renderers/registry.ts'
|
|
37
38
|
import { live2dRenderer } from './renderers/live2d.ts'
|
|
@@ -51,6 +52,7 @@ interface PetHttpApi {
|
|
|
51
52
|
setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }>
|
|
52
53
|
setName(name: string): Promise<{ ok: true; name: string } | { ok: false; error: string }>
|
|
53
54
|
setPet(petId: string): Promise<{ ok: true; petId: string } | { ok: false; error: string }>
|
|
55
|
+
setSkin(skin?: string): Promise<{ ok: boolean; error?: string; skin?: string }>
|
|
54
56
|
gameplayTouch(zone?: string): Promise<PetGameplayVerbResult>
|
|
55
57
|
gameplaySetMode(mode: 'work' | 'sleep' | null): Promise<PetGameplayVerbResult>
|
|
56
58
|
gameplayWorkTick(): Promise<PetGameplayVerbResult>
|
|
@@ -82,6 +84,7 @@ const petApi: PetHttpApi = {
|
|
|
82
84
|
setConfig: (patch) => petFetch('/api/pet/set-config', patch),
|
|
83
85
|
setName: (name) => petFetch('/api/pet/set-name', { name }),
|
|
84
86
|
setPet: (petId) => petFetch('/api/pet/set-pet', { petId }),
|
|
87
|
+
setSkin: (skin) => petFetch('/api/pet/set-skin', skin === undefined ? {} : { skin }),
|
|
85
88
|
gameplayTouch: (zone) => petFetch('/api/pet/gameplay/touch', zone === undefined ? {} : { zone }),
|
|
86
89
|
gameplaySetMode: (mode) => petFetch('/api/pet/gameplay/mode', { mode }),
|
|
87
90
|
gameplayWorkTick: () => petFetch('/api/pet/gameplay/work-tick', {}),
|
|
@@ -123,16 +126,19 @@ declare module '@deepseek-ai/cordis' {
|
|
|
123
126
|
* @param ctx - client root context.
|
|
124
127
|
*/
|
|
125
128
|
|
|
129
|
+
/** The page-wide work-tick gate; its window follows the active pet's cadence. */
|
|
130
|
+
const workTickGate = createWorkTickGate()
|
|
131
|
+
|
|
126
132
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* call re-rolls, re-grants treats and re-plays the success/fail track, so
|
|
131
|
-
* the outcome appears to play several times per window. This shared marker
|
|
132
|
-
* accepts the first adjudication of a window and silently suppresses the
|
|
133
|
-
* duplicates that follow. Reset when (re-)entering work mode.
|
|
133
|
+
* The work cadence the active pet declares, when its registry entry is known.
|
|
134
|
+
* @param store - the pet store holding the host snapshot and the registry list.
|
|
135
|
+
* @returns the configured `gameplay.work.tickMs`, or undefined when unknown.
|
|
134
136
|
*/
|
|
135
|
-
|
|
137
|
+
function activeWorkTickMs(store: PetStoreInstance): number | undefined {
|
|
138
|
+
const state = store.getSnapshot()
|
|
139
|
+
const definition = state.pets.find((entry) => entry.id === state.snapshot?.pet.id)
|
|
140
|
+
return definition?.gameplay?.work?.tickMs
|
|
141
|
+
}
|
|
136
142
|
|
|
137
143
|
export function apply(ctx: ClientContext): void {
|
|
138
144
|
// Anonymous install heartbeat (docs/telemetry.md): one beat per browser per
|
|
@@ -371,18 +377,22 @@ export function apply(ctx: ClientContext): void {
|
|
|
371
377
|
},
|
|
372
378
|
gameplay: {
|
|
373
379
|
touch: (zone) => petApi.gameplayTouch(zone),
|
|
380
|
+
setSkin: (skin) => petApi.setSkin(skin).then((result) => {
|
|
381
|
+
if (result.ok) pollNow()
|
|
382
|
+
return result
|
|
383
|
+
}, () => ({ ok: false, error: 'transport' })),
|
|
384
|
+
|
|
374
385
|
setMode: async (mode) => {
|
|
375
|
-
if (mode === 'work')
|
|
386
|
+
if (mode === 'work') workTickGate.reset()
|
|
376
387
|
return petApi.gameplaySetMode(mode)
|
|
377
388
|
},
|
|
378
389
|
workTick: async () => {
|
|
379
390
|
// One adjudication per tick window, page-wide: suppress stale
|
|
380
|
-
// duplicate intervals (HMR) re-playing the result track.
|
|
381
|
-
|
|
382
|
-
if (
|
|
391
|
+
// duplicate intervals (HMR) re-playing the result track. The window
|
|
392
|
+
// is the pet's own cadence, so a shorter tickMs is not downgraded.
|
|
393
|
+
if (!workTickGate.allow(activeWorkTickMs(petStore))) {
|
|
383
394
|
return { ok: true } as PetGameplayVerbResult
|
|
384
395
|
}
|
|
385
|
-
lastWorkTickAt = now
|
|
386
396
|
return petApi.gameplayWorkTick()
|
|
387
397
|
},
|
|
388
398
|
buy: (item) => petApi.gameplayBuy(item),
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* Frames2dVisualMount registration contract: the mount consumes the base idle
|
|
4
|
+
* the gameplay HUD latches on the bus. That covers a renderer which mounts
|
|
5
|
+
* after the HUD restored a skin from the host snapshot, and one that remounts
|
|
6
|
+
* later (hidden/summoned, StrictMode double mount) — the pet must repaint the
|
|
7
|
+
* selected skin instead of snapping back to the default look.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
+
import { cleanup, render } from '@testing-library/react'
|
|
11
|
+
import type { PetDefinition } from '../../registry.ts'
|
|
12
|
+
import { createDragStream } from '../drag-stream.ts'
|
|
13
|
+
import type { GameplayBus } from '../gameplay-hud.tsx'
|
|
14
|
+
import { t } from '../locales.ts'
|
|
15
|
+
import { Frames2dVisualMount } from './Frames2dVisualMount.tsx'
|
|
16
|
+
import { defaultPetRendererRegistry } from './registry.ts'
|
|
17
|
+
|
|
18
|
+
function definition(): PetDefinition {
|
|
19
|
+
return {
|
|
20
|
+
id: 'miku',
|
|
21
|
+
displayName: 'Miku',
|
|
22
|
+
description: '',
|
|
23
|
+
renderer: 'frames2d',
|
|
24
|
+
cell: { width: 100, height: 100 },
|
|
25
|
+
columns: 8,
|
|
26
|
+
rows: [],
|
|
27
|
+
atlasUrl: '/pet/miku/atlas.webp',
|
|
28
|
+
manifestUrl: '/pet/miku/pet.json',
|
|
29
|
+
tracks: {} as PetDefinition['tracks'],
|
|
30
|
+
frames2d: {
|
|
31
|
+
tracks: {
|
|
32
|
+
idle: { frames: ['/pet/miku/idle_1.webp'], durations: [200], loop: true },
|
|
33
|
+
skin: { frames: ['/pet/miku/skin_1.webp'], durations: [200], loop: true },
|
|
34
|
+
},
|
|
35
|
+
phases: { idle: 'idle' },
|
|
36
|
+
skins: [{ id: 'skin', label: 'Skin', idleTrack: 'skin' }],
|
|
37
|
+
},
|
|
38
|
+
} as unknown as PetDefinition
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mountWith(bus: GameplayBus): { setIdleTrack: ReturnType<typeof vi.fn> } {
|
|
42
|
+
const handle = { dispose: vi.fn(), setState: vi.fn(), setIdleTrack: vi.fn(), currentTrack: () => 'idle' }
|
|
43
|
+
vi.spyOn(defaultPetRendererRegistry, 'mount')
|
|
44
|
+
.mockReturnValue(handle as unknown as ReturnType<typeof defaultPetRendererRegistry.mount>)
|
|
45
|
+
render(
|
|
46
|
+
<Frames2dVisualMount
|
|
47
|
+
definition={definition()}
|
|
48
|
+
phase="idle"
|
|
49
|
+
onPet={() => undefined}
|
|
50
|
+
drag={createDragStream()}
|
|
51
|
+
bus={bus}
|
|
52
|
+
t={t}
|
|
53
|
+
/>,
|
|
54
|
+
)
|
|
55
|
+
return handle
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('Frames2dVisualMount', () => {
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
cleanup()
|
|
61
|
+
vi.restoreAllMocks()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('applies the base idle the HUD latched before this mount (restored skin)', () => {
|
|
65
|
+
const handle = mountWith({ idleTrack: 'skin' })
|
|
66
|
+
expect(handle.setIdleTrack).toHaveBeenCalledWith('skin')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('leaves the default look alone when no base idle is latched', () => {
|
|
70
|
+
const handle = mountWith({})
|
|
71
|
+
expect(handle.setIdleTrack).not.toHaveBeenCalled()
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -68,6 +68,10 @@ export function Frames2dVisualMount(props: {
|
|
|
68
68
|
const gameplayBus = props.bus
|
|
69
69
|
gameplayBus.setTrack = (track) => { handleRef.current?.setState(track) }
|
|
70
70
|
gameplayBus.setIdleTrack = (track) => { handleRef.current?.setIdleTrack(track) }
|
|
71
|
+
// The HUD latches the wanted base idle (skin selection, restored from the
|
|
72
|
+
// host snapshot): apply it on activation, so a late or repeated mount
|
|
73
|
+
// never repaints the pet with the default look.
|
|
74
|
+
if (gameplayBus.idleTrack !== undefined) handle.setIdleTrack(gameplayBus.idleTrack)
|
|
71
75
|
cleanups.push(() => {
|
|
72
76
|
gameplayBus.setTrack = undefined
|
|
73
77
|
gameplayBus.setIdleTrack = undefined
|
|
@@ -234,9 +234,9 @@ describe('frames2dRenderer canvas bitmap path', () => {
|
|
|
234
234
|
expect(container.querySelector('canvas')).not.toBeNull()
|
|
235
235
|
expect(container.querySelector('img')).toBeNull()
|
|
236
236
|
await flush()
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
expect(bitmaps.length).toBe(
|
|
237
|
+
// Only the playing track's look-ahead window decodes up front; unplayed
|
|
238
|
+
// tracks stay on demand. The idle track's first frame painted at mount.
|
|
239
|
+
expect(bitmaps.length).toBe(2)
|
|
240
240
|
const paintsAtMount = draws
|
|
241
241
|
expect(paintsAtMount).toBeGreaterThanOrEqual(1)
|
|
242
242
|
vi.advanceTimersByTime(100)
|
|
@@ -277,7 +277,7 @@ describe('frames2dRenderer canvas bitmap path', () => {
|
|
|
277
277
|
handle.dispose()
|
|
278
278
|
})
|
|
279
279
|
|
|
280
|
-
it('
|
|
280
|
+
it('bounds the decode window to the pool plus the look-ahead, not the whole track', async () => {
|
|
281
281
|
let inFlight = 0
|
|
282
282
|
let peak = 0
|
|
283
283
|
const gates: Array<() => void> = []
|
|
@@ -304,7 +304,39 @@ describe('frames2dRenderer canvas bitmap path', () => {
|
|
|
304
304
|
await flush()
|
|
305
305
|
}
|
|
306
306
|
expect(peak).toBeLessThanOrEqual(8)
|
|
307
|
-
|
|
307
|
+
// Frame 0 plus the 12-frame look-ahead window; the remaining frames of the
|
|
308
|
+
// 24-frame track are never decoded up front.
|
|
309
|
+
expect(bitmaps.length).toBe(13)
|
|
310
|
+
handle.dispose()
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('leaves unplayed tracks and unselected skins to on-demand playback', async () => {
|
|
314
|
+
const fetched: string[] = []
|
|
315
|
+
vi.stubGlobal('fetch', vi.fn(async (input: unknown) => {
|
|
316
|
+
fetched.push(String(input))
|
|
317
|
+
return { ok: true, blob: async () => ({}) }
|
|
318
|
+
}))
|
|
319
|
+
const skinConfig: PetFrames2dConfig = {
|
|
320
|
+
tracks: {
|
|
321
|
+
idle: { frames: ['/pet/miku/idle/1.webp'], durations: [100], loop: true },
|
|
322
|
+
'skin-a-idle': { frames: ['/pet/miku/skin-a/1.webp', '/pet/miku/skin-a/2.webp'], durations: [100, 100], loop: true },
|
|
323
|
+
'skin-b-idle': { frames: ['/pet/miku/skin-b/1.webp'], durations: [100], loop: true },
|
|
324
|
+
},
|
|
325
|
+
phases: { idle: 'idle' },
|
|
326
|
+
skins: [
|
|
327
|
+
{ id: 'a', label: 'A', idleTrack: 'skin-a-idle' },
|
|
328
|
+
{ id: 'b', label: 'B', idleTrack: 'skin-b-idle' },
|
|
329
|
+
],
|
|
330
|
+
}
|
|
331
|
+
const { ctx } = canvasSetup()
|
|
332
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(skinConfig)) as Frames2dRendererHandle
|
|
333
|
+
await flush()
|
|
334
|
+
expect(fetched).toEqual(['/pet/miku/idle/1.webp'])
|
|
335
|
+
// Selecting a skin pulls only that skin's track.
|
|
336
|
+
handle.setIdleTrack('skin-a-idle')
|
|
337
|
+
await flush()
|
|
338
|
+
expect(fetched).toContain('/pet/miku/skin-a/1.webp')
|
|
339
|
+
expect(fetched).not.toContain('/pet/miku/skin-b/1.webp')
|
|
308
340
|
handle.dispose()
|
|
309
341
|
})
|
|
310
342
|
|
|
@@ -361,7 +393,7 @@ describe('frames2dRenderer canvas bitmap path', () => {
|
|
|
361
393
|
vi.stubGlobal('Image', FailingImage)
|
|
362
394
|
const { ctx } = canvasSetup()
|
|
363
395
|
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
364
|
-
//
|
|
396
|
+
// Prefetch fetches fail through the pool, resolve undefined and are
|
|
365
397
|
// dropped from the memo; nothing throws.
|
|
366
398
|
await flush()
|
|
367
399
|
const memoFetches = (fetch as ReturnType<typeof vi.fn>).mock.calls.length
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
* Frame presentation has two modes picked once at mount by capability
|
|
13
13
|
* probing:
|
|
14
14
|
* - Canvas bitmap buffer (default where createImageBitmap/fetch/2D context
|
|
15
|
-
* exist):
|
|
16
|
-
* the
|
|
17
|
-
* zero DOM mutations and zero
|
|
18
|
-
* <img>.src per frame drove image
|
|
15
|
+
* exist): frames are decoded once into an ImageBitmap on demand, with a
|
|
16
|
+
* bounded look-ahead window over the playing track, and drawn onto one
|
|
17
|
+
* <canvas> - steady-state playback issues zero DOM mutations and zero
|
|
18
|
+
* re-decodes (measured hotspot: swapping <img>.src per frame drove image
|
|
19
|
+
* decode + invalidation every tick).
|
|
19
20
|
* - Classic <img> fallback (jsdom/tests or missing APIs): identical to the
|
|
20
21
|
* historical behavior - cache-warm Image elements plus guarded src swaps,
|
|
21
22
|
* so environments without modern decoding keep working unchanged.
|
|
@@ -283,6 +284,27 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
283
284
|
return job
|
|
284
285
|
}
|
|
285
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Bounded look-ahead window: decode only the frames playback is about to
|
|
289
|
+
* need. The historical warm pass decoded every frame of every track up
|
|
290
|
+
* front - a shipped pet carries ~1.1k 512x683 frames, so that pass pulls
|
|
291
|
+
* tens of megabytes and retains every decoded bitmap for the life of the
|
|
292
|
+
* page. Prefetching the playing track's next frames keeps loops and phase
|
|
293
|
+
* switches warm while unplayed tracks (and every unselected skin's
|
|
294
|
+
* frames) stay on demand, where playback jumps the queue anyway.
|
|
295
|
+
*/
|
|
296
|
+
const PREFETCH_AHEAD = 12
|
|
297
|
+
|
|
298
|
+
const prefetchAhead = (trackId: string, index: number): void => {
|
|
299
|
+
const def = config.tracks[trackId]
|
|
300
|
+
if (def === undefined) return
|
|
301
|
+
const end = Math.min(def.frames.length, index + 1 + PREFETCH_AHEAD)
|
|
302
|
+
for (let ahead = index + 1; ahead < end; ahead += 1) {
|
|
303
|
+
const url = def.frames[ahead]
|
|
304
|
+
if (url !== undefined) void loadFrame(url)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
286
308
|
let disposed = false
|
|
287
309
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
288
310
|
let watchdog: ReturnType<typeof setInterval> | undefined
|
|
@@ -327,6 +349,7 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
327
349
|
const def = config.tracks[trackId]
|
|
328
350
|
const url = def?.frames[index]
|
|
329
351
|
if (url === undefined) return
|
|
352
|
+
prefetchAhead(trackId, index)
|
|
330
353
|
if (img !== null) {
|
|
331
354
|
if (img.getAttribute('src') !== url) img.src = url
|
|
332
355
|
return
|
|
@@ -400,21 +423,6 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
400
423
|
}, WATCHDOG_MS)
|
|
401
424
|
}
|
|
402
425
|
|
|
403
|
-
// Warm pass: decode every frame up front (tiny same-origin webp files)
|
|
404
|
-
// so loops and phase switches never wait on a first decode - same intent
|
|
405
|
-
// as the historical Image-cache warm loop, now feeding the decode cache.
|
|
406
|
-
// Phase-reachable tracks enqueue first so early switches never trail the
|
|
407
|
-
// full warm backlog; demand loads jump the queue regardless.
|
|
408
|
-
const warmTrackIds: string[] = [
|
|
409
|
-
...new Set([...Object.values(config.phases), config.phases.idle]),
|
|
410
|
-
]
|
|
411
|
-
for (const warmTrack of [...warmTrackIds, ...Object.keys(config.tracks)].map(
|
|
412
|
-
(id) => config.tracks[id],
|
|
413
|
-
)) {
|
|
414
|
-
if (warmTrack === undefined) continue
|
|
415
|
-
for (const warmUrl of warmTrack.frames) void loadFrame(warmUrl)
|
|
416
|
-
}
|
|
417
|
-
|
|
418
426
|
play(track)
|
|
419
427
|
|
|
420
428
|
let disposedOnce = false
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work-tick gate tests: the window follows the configured cadence (clamped to
|
|
3
|
+
* the manifest bounds), one adjudication is admitted per window, and entering
|
|
4
|
+
* work mode re-arms the gate.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
import { DEFAULT_WORK_TICK_MS, createWorkTickGate, workTickWindowMs } from './work-tick-gate.ts'
|
|
8
|
+
|
|
9
|
+
describe('workTickWindowMs', () => {
|
|
10
|
+
it('follows the configured cadence', () => {
|
|
11
|
+
expect(workTickWindowMs(2_000)).toBe(2_000)
|
|
12
|
+
expect(workTickWindowMs(10_000)).toBe(10_000)
|
|
13
|
+
expect(workTickWindowMs(60_000)).toBe(60_000)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('clamps to the manifest bounds and defaults when absent', () => {
|
|
17
|
+
expect(workTickWindowMs(500)).toBe(1_000)
|
|
18
|
+
expect(workTickWindowMs(120_000)).toBe(60_000)
|
|
19
|
+
expect(workTickWindowMs(undefined)).toBe(DEFAULT_WORK_TICK_MS)
|
|
20
|
+
expect(workTickWindowMs(Number.NaN)).toBe(DEFAULT_WORK_TICK_MS)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('createWorkTickGate', () => {
|
|
25
|
+
it('admits one adjudication per configured window', () => {
|
|
26
|
+
let now = 1_000_000
|
|
27
|
+
const gate = createWorkTickGate(() => now)
|
|
28
|
+
expect(gate.allow(2_000)).toBe(true)
|
|
29
|
+
now += 1_999
|
|
30
|
+
expect(gate.allow(2_000)).toBe(false)
|
|
31
|
+
now += 1
|
|
32
|
+
expect(gate.allow(2_000)).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('does not downgrade a pet whose cadence is shorter than the default', () => {
|
|
36
|
+
let now = 1_000_000
|
|
37
|
+
const gate = createWorkTickGate(() => now)
|
|
38
|
+
expect(gate.allow(2_000)).toBe(true)
|
|
39
|
+
for (let tick = 0; tick < 5; tick++) {
|
|
40
|
+
now += 2_000
|
|
41
|
+
expect(gate.allow(2_000)).toBe(true)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('arms immediately after a reset (work mode entry)', () => {
|
|
46
|
+
let now = 1_000_000
|
|
47
|
+
const gate = createWorkTickGate(() => now)
|
|
48
|
+
expect(gate.allow(10_000)).toBe(true)
|
|
49
|
+
expect(gate.allow(10_000)).toBe(false)
|
|
50
|
+
gate.reset()
|
|
51
|
+
expect(gate.allow(10_000)).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-wide work-tick gate for the pet's work mode. Hot reloads can leave
|
|
3
|
+
* several GameplayHud instances alive, each running its own work interval;
|
|
4
|
+
* without a shared gate every interval would call workTick and each stale call
|
|
5
|
+
* re-rolls, re-grants treats and re-plays the success/fail track, so the
|
|
6
|
+
* outcome appears to play several times per window. The gate accepts the first
|
|
7
|
+
* adjudication of a window and silently suppresses the duplicates that follow.
|
|
8
|
+
*
|
|
9
|
+
* The window is the active pet's own `gameplay.work.tickMs`, not a constant: a
|
|
10
|
+
* pet configured with a shorter cadence must actually adjudicate that often,
|
|
11
|
+
* while a fixed window would downgrade it without saying so (#1494).
|
|
12
|
+
* @module @linxin666/dsh-pet/client/work-tick-gate
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Window used when the active pet declares no work cadence. */
|
|
16
|
+
export const DEFAULT_WORK_TICK_MS = 10_000
|
|
17
|
+
|
|
18
|
+
/** Manifest bounds for `gameplay.work.tickMs` (src/gameplay.ts). */
|
|
19
|
+
const MIN_WORK_TICK_MS = 1_000
|
|
20
|
+
const MAX_WORK_TICK_MS = 60_000
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The gate window for one pet: its configured cadence, clamped to the manifest's
|
|
24
|
+
* own bounds so a malformed registry entry cannot disable or flood the gate.
|
|
25
|
+
* @param tickMs - the active definition's `gameplay.work.tickMs`, when it has one.
|
|
26
|
+
* @returns the window in milliseconds.
|
|
27
|
+
*/
|
|
28
|
+
export function workTickWindowMs(tickMs: number | undefined): number {
|
|
29
|
+
if (typeof tickMs !== 'number' || !Number.isFinite(tickMs)) return DEFAULT_WORK_TICK_MS
|
|
30
|
+
return Math.min(MAX_WORK_TICK_MS, Math.max(MIN_WORK_TICK_MS, tickMs))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The shared work-tick gate. */
|
|
34
|
+
export interface WorkTickGate {
|
|
35
|
+
/**
|
|
36
|
+
* Admit one adjudication for the current window.
|
|
37
|
+
* @param tickMs - the active pet's configured work cadence.
|
|
38
|
+
* @returns true when this call may adjudicate, false for a duplicate.
|
|
39
|
+
*/
|
|
40
|
+
allow: (tickMs: number | undefined) => boolean
|
|
41
|
+
/** Forget the last adjudication (used when work mode is entered). */
|
|
42
|
+
reset: () => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create a gate.
|
|
47
|
+
* @param now - the clock, injectable so tests control the window.
|
|
48
|
+
* @returns the gate over that clock.
|
|
49
|
+
*/
|
|
50
|
+
export function createWorkTickGate(now: () => number = Date.now): WorkTickGate {
|
|
51
|
+
let lastAdjudicatedAt = 0
|
|
52
|
+
return {
|
|
53
|
+
allow: (tickMs) => {
|
|
54
|
+
const at = now()
|
|
55
|
+
if (at - lastAdjudicatedAt < workTickWindowMs(tickMs)) return false
|
|
56
|
+
lastAdjudicatedAt = at
|
|
57
|
+
return true
|
|
58
|
+
},
|
|
59
|
+
reset: () => {
|
|
60
|
+
lastAdjudicatedAt = 0
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/gameplay.test.ts
CHANGED
|
@@ -185,6 +185,42 @@ describe('gameplay engine', () => {
|
|
|
185
185
|
expect(state.settledAt).toBe(600_000)
|
|
186
186
|
})
|
|
187
187
|
|
|
188
|
+
it('preserves remainder carry across frequent polling intervals for passive income and sleep restore (#1478)', () => {
|
|
189
|
+
const state = initialGameplayState(def, 0)
|
|
190
|
+
state.stats.energy = 20
|
|
191
|
+
state.mode = 'sleep'
|
|
192
|
+
// Simulate 30 frequent settles every 2,000 ms (total 60,000 ms = 1 min).
|
|
193
|
+
// Passive income interval: 60,000 ms -> should grant 1 coin on 30th settle.
|
|
194
|
+
// Sleep restore interval: 30,000 ms (+4 energy each) -> should grant on 15th and 30th settle.
|
|
195
|
+
let now = 0
|
|
196
|
+
for (let i = 1; i <= 30; i++) {
|
|
197
|
+
now += 2_000
|
|
198
|
+
settleGameplay(state, def, now, { sessionActive: true })
|
|
199
|
+
if (i < 15) {
|
|
200
|
+
expect(state.stats.energy).toBeCloseTo(20 - (i * 2 / 60) * 0.25, 4)
|
|
201
|
+
expect(state.currencies.coins ?? 0).toBe(0)
|
|
202
|
+
} else if (i === 15) {
|
|
203
|
+
// At 30,000 ms: first sleep tick hits (+4 energy)
|
|
204
|
+
expect(state.stats.energy).toBeCloseTo(20 - 0.125 + 4, 4)
|
|
205
|
+
expect(state.currencies.coins ?? 0).toBe(0)
|
|
206
|
+
} else if (i < 30) {
|
|
207
|
+
expect(state.stats.energy).toBeCloseTo(20 - (i * 2 / 60) * 0.25 + 4, 4)
|
|
208
|
+
expect(state.currencies.coins ?? 0).toBe(0)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// At 60,000 ms (30th tick): second sleep tick hits (+4 energy = +8 total), passive income hits (+1 coin)
|
|
212
|
+
expect(state.stats.energy).toBeCloseTo(20 - 0.25 + 8, 4)
|
|
213
|
+
expect(state.currencies.coins).toBe(1)
|
|
214
|
+
expect(state.incomeCarryMs).toBe(0)
|
|
215
|
+
expect(state.restoreCarryMs).toBe(0)
|
|
216
|
+
|
|
217
|
+
// Leaving sleep mode clears restoreCarryMs
|
|
218
|
+
state.restoreCarryMs = 10_000
|
|
219
|
+
state.mode = null
|
|
220
|
+
settleGameplay(state, def, now + 1_000, { sessionActive: true })
|
|
221
|
+
expect(state.restoreCarryMs).toBe(0)
|
|
222
|
+
})
|
|
223
|
+
|
|
188
224
|
it('applies the working decay variant while working and idle decay without a session', () => {
|
|
189
225
|
const state = initialGameplayState(def, 0)
|
|
190
226
|
state.mode = 'work'
|
package/src/gameplay.ts
CHANGED
|
@@ -572,6 +572,10 @@ export interface PetGameplayState {
|
|
|
572
572
|
mode: 'work' | 'sleep' | null
|
|
573
573
|
/** Epoch ms of the last lazy settle. */
|
|
574
574
|
settledAt: number
|
|
575
|
+
/** Accumulated remainder ms towards the next passive income tick. */
|
|
576
|
+
incomeCarryMs?: number
|
|
577
|
+
/** Accumulated remainder ms towards the next sleep restore tick. */
|
|
578
|
+
restoreCarryMs?: number
|
|
575
579
|
}
|
|
576
580
|
|
|
577
581
|
/** Fresh state for one pet: stats at their initial (default max), no currency. */
|
|
@@ -624,7 +628,10 @@ export function settleGameplay(
|
|
|
624
628
|
}
|
|
625
629
|
}
|
|
626
630
|
if (manifest.passiveIncome !== undefined) {
|
|
627
|
-
const
|
|
631
|
+
const incomeElapsed = elapsedMs + (state.incomeCarryMs ?? 0)
|
|
632
|
+
const interval = manifest.passiveIncome.intervalMs
|
|
633
|
+
const ticks = Math.floor(incomeElapsed / interval)
|
|
634
|
+
state.incomeCarryMs = incomeElapsed % interval
|
|
628
635
|
if (ticks > 0) {
|
|
629
636
|
const currency = manifest.passiveIncome.currency
|
|
630
637
|
state.currencies[currency] = (state.currencies[currency] ?? 0) + ticks * manifest.passiveIncome.amount
|
|
@@ -632,12 +639,17 @@ export function settleGameplay(
|
|
|
632
639
|
}
|
|
633
640
|
}
|
|
634
641
|
if (state.mode === 'sleep' && manifest.sleep !== undefined) {
|
|
635
|
-
const
|
|
642
|
+
const restoreElapsed = elapsedMs + (state.restoreCarryMs ?? 0)
|
|
643
|
+
const interval = manifest.sleep.restore.intervalMs
|
|
644
|
+
const ticks = Math.floor(restoreElapsed / interval)
|
|
645
|
+
state.restoreCarryMs = restoreElapsed % interval
|
|
636
646
|
if (ticks > 0) {
|
|
637
647
|
const stat = manifest.sleep.restore.stat
|
|
638
648
|
state.stats[stat] = (state.stats[stat] ?? 0) + ticks * manifest.sleep.restore.amount
|
|
639
649
|
changed = true
|
|
640
650
|
}
|
|
651
|
+
} else {
|
|
652
|
+
state.restoreCarryMs = 0
|
|
641
653
|
}
|
|
642
654
|
state.settledAt = now
|
|
643
655
|
clampGameplay(state, manifest)
|