@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.
Files changed (50) hide show
  1. package/lib/client.js +127 -26
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +99 -4
  4. package/lib/types/client/gameplay-hud.d.ts +15 -0
  5. package/lib/types/client/gameplay-hud.d.ts.map +1 -1
  6. package/lib/types/client/gameplay-hud.js +48 -6
  7. package/lib/types/client/index.d.ts.map +1 -1
  8. package/lib/types/client/index.js +21 -13
  9. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -1
  10. package/lib/types/client/renderers/Frames2dVisualMount.js +5 -0
  11. package/lib/types/client/renderers/frames2d.d.ts +5 -4
  12. package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
  13. package/lib/types/client/renderers/frames2d.js +27 -18
  14. package/lib/types/client/work-tick-gate.d.ts +40 -0
  15. package/lib/types/client/work-tick-gate.d.ts.map +1 -0
  16. package/lib/types/client/work-tick-gate.js +49 -0
  17. package/lib/types/gameplay.d.ts +4 -0
  18. package/lib/types/gameplay.d.ts.map +1 -1
  19. package/lib/types/gameplay.js +11 -2
  20. package/lib/types/ledger.d.ts +8 -0
  21. package/lib/types/ledger.d.ts.map +1 -1
  22. package/lib/types/ledger.js +25 -0
  23. package/lib/types/persist.d.ts +6 -0
  24. package/lib/types/persist.d.ts.map +1 -1
  25. package/lib/types/persist.js +25 -1
  26. package/lib/types/routes.d.ts.map +1 -1
  27. package/lib/types/routes.js +6 -0
  28. package/lib/types/service.d.ts +24 -0
  29. package/lib/types/service.d.ts.map +1 -1
  30. package/lib/types/service.js +32 -0
  31. package/package.json +1 -1
  32. package/src/client/PetDockEntry.test.tsx +1 -0
  33. package/src/client/gameplay-hud.test.tsx +165 -2
  34. package/src/client/gameplay-hud.tsx +62 -6
  35. package/src/client/index.ts +23 -13
  36. package/src/client/pet.module.css +1 -1
  37. package/src/client/renderers/Frames2dVisualMount.test.tsx +73 -0
  38. package/src/client/renderers/Frames2dVisualMount.tsx +4 -0
  39. package/src/client/renderers/frames2d.test.ts +38 -6
  40. package/src/client/renderers/frames2d.ts +27 -19
  41. package/src/client/work-tick-gate.test.ts +53 -0
  42. package/src/client/work-tick-gate.ts +63 -0
  43. package/src/gameplay.test.ts +36 -0
  44. package/src/gameplay.ts +14 -2
  45. package/src/ledger.test.ts +19 -0
  46. package/src/ledger.ts +24 -0
  47. package/src/persist.test.ts +13 -0
  48. package/src/persist.ts +29 -1
  49. package/src/routes.ts +5 -0
  50. 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
- bus.setTrack?.(work.state)
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 value
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 (
@@ -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
- * Module-wide work-tick throttle (ms). Hot reloads can leave several
128
- * GameplayHud instances alive, each running its own 10s work interval;
129
- * without a shared gate every interval would call workTick and each stale
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
- let lastWorkTickAt = 0
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') lastWorkTickAt = 0
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
- const now = Date.now()
382
- if (now - lastWorkTickAt < 8500) {
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),
@@ -46,7 +46,7 @@
46
46
  background: rgba(3, 105, 161, 0.95);
47
47
  }
48
48
 
49
- /* 状态气泡:皮肤底色玻璃 + 皮肤描边微光 + 进场浮现。
49
+ /* 状态气泡:虎鲸蓝黑渐变玻璃 + DeepSeek 蓝描边微光 + 进场浮现。
50
50
  碎碎念不再单独渲染第二只气泡,也不换色调,而是以 .bubbleWhisper
51
51
  接管这同一只气泡的文案,气泡栈内所有气泡共用同一片玻璃。 */
52
52
  .bubbleStatus {
@@ -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
- // Warm pass decoded every configured frame exactly once; the first frame
238
- // of the idle track painted during mount.
239
- expect(bitmaps.length).toBe(6)
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('drains the warm pass through a bounded fetch pool', async () => {
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
- expect(bitmaps.length).toBe(24)
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
- // Warm pass fetches fail through the pool, resolve undefined and are
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): every frame is decoded exactly once into an ImageBitmap during
16
- * the warm pass and drawn onto one <canvas> - steady-state playback issues
17
- * zero DOM mutations and zero re-decodes (measured hotspot: swapping
18
- * <img>.src per frame drove image decode + invalidation every tick).
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
+ }
@@ -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 ticks = Math.floor(elapsedMs / manifest.passiveIncome.intervalMs)
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 ticks = Math.floor(elapsedMs / manifest.sleep.restore.intervalMs)
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)