@linxin666/dsh-pet 0.3.19 → 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/README.i18n.yaml +2 -2
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +191 -53
- package/lib/client.js.map +1 -1
- package/lib/index.js +174 -61
- 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 +96 -39
- 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/event-projection.d.ts +14 -0
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +32 -18
- 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 +43 -1
- package/package.json +14 -14
- 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 +129 -7
- package/src/client/renderers/frames2d.ts +94 -36
- package/src/client/work-tick-gate.test.ts +53 -0
- package/src/client/work-tick-gate.ts +63 -0
- package/src/event-projection.ts +37 -18
- 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 +49 -0
|
@@ -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
|
|
@@ -182,10 +182,8 @@ describe('frames2dRenderer', () => {
|
|
|
182
182
|
describe('frames2dRenderer canvas bitmap path', () => {
|
|
183
183
|
// The canvas branch needs createImageBitmap + fetch + a real 2D context;
|
|
184
184
|
// jsdom has none of them, so each test installs fakes and restores after.
|
|
185
|
-
const flush = async (): Promise<void> => {
|
|
186
|
-
await Promise.resolve()
|
|
187
|
-
await Promise.resolve()
|
|
188
|
-
await Promise.resolve()
|
|
185
|
+
const flush = async (rounds = 12): Promise<void> => {
|
|
186
|
+
for (let i = 0; i < rounds; i += 1) await Promise.resolve()
|
|
189
187
|
}
|
|
190
188
|
|
|
191
189
|
let draws = 0
|
|
@@ -236,9 +234,9 @@ describe('frames2dRenderer canvas bitmap path', () => {
|
|
|
236
234
|
expect(container.querySelector('canvas')).not.toBeNull()
|
|
237
235
|
expect(container.querySelector('img')).toBeNull()
|
|
238
236
|
await flush()
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
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)
|
|
242
240
|
const paintsAtMount = draws
|
|
243
241
|
expect(paintsAtMount).toBeGreaterThanOrEqual(1)
|
|
244
242
|
vi.advanceTimersByTime(100)
|
|
@@ -278,4 +276,128 @@ describe('frames2dRenderer canvas bitmap path', () => {
|
|
|
278
276
|
expect(draws).toBeGreaterThanOrEqual(3)
|
|
279
277
|
handle.dispose()
|
|
280
278
|
})
|
|
279
|
+
|
|
280
|
+
it('bounds the decode window to the pool plus the look-ahead, not the whole track', async () => {
|
|
281
|
+
let inFlight = 0
|
|
282
|
+
let peak = 0
|
|
283
|
+
const gates: Array<() => void> = []
|
|
284
|
+
vi.stubGlobal('fetch', vi.fn(async () => {
|
|
285
|
+
inFlight += 1
|
|
286
|
+
peak = Math.max(peak, inFlight)
|
|
287
|
+
await new Promise<void>((resolve) => gates.push(resolve))
|
|
288
|
+
inFlight -= 1
|
|
289
|
+
return { ok: true, blob: async () => ({}) }
|
|
290
|
+
}))
|
|
291
|
+
const bigConfig: PetFrames2dConfig = {
|
|
292
|
+
tracks: {
|
|
293
|
+
idle: { frames: Array.from({ length: 24 }, (_, i) => `/pet/miku/idle/${i}.webp`), durations: Array.from({ length: 24 }, () => 100), loop: true },
|
|
294
|
+
},
|
|
295
|
+
phases: { idle: 'idle' },
|
|
296
|
+
}
|
|
297
|
+
const { ctx } = canvasSetup()
|
|
298
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(bigConfig)) as Frames2dRendererHandle
|
|
299
|
+
await flush()
|
|
300
|
+
expect(peak).toBeLessThanOrEqual(8)
|
|
301
|
+
expect(gates.length).toBe(8)
|
|
302
|
+
for (let i = 0; i < 64 && gates.length > 0; i += 1) {
|
|
303
|
+
gates.shift()!()
|
|
304
|
+
await flush()
|
|
305
|
+
}
|
|
306
|
+
expect(peak).toBeLessThanOrEqual(8)
|
|
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')
|
|
340
|
+
handle.dispose()
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('jumps a playback-demand frame ahead of the warm backlog', async () => {
|
|
344
|
+
const fetched: string[] = []
|
|
345
|
+
const held = new Map<string, () => void>()
|
|
346
|
+
vi.stubGlobal('fetch', vi.fn(async (input: unknown) => {
|
|
347
|
+
const url = String(input)
|
|
348
|
+
fetched.push(url)
|
|
349
|
+
if (fetched.length <= 8) {
|
|
350
|
+
await new Promise<void>((resolve) => { held.set(url, resolve) })
|
|
351
|
+
}
|
|
352
|
+
return { ok: true, blob: async () => ({}) }
|
|
353
|
+
}))
|
|
354
|
+
const wideConfig: PetFrames2dConfig = {
|
|
355
|
+
tracks: {
|
|
356
|
+
idle: { frames: Array.from({ length: 10 }, (_, i) => `/pet/miku/idle/${i}.webp`), durations: Array.from({ length: 10 }, () => 100), loop: true },
|
|
357
|
+
happy: { frames: ['/pet/miku/happy/1.webp'], durations: [100], loop: false, fallback: 'idle' },
|
|
358
|
+
},
|
|
359
|
+
phases: { idle: 'idle', done: 'happy' },
|
|
360
|
+
}
|
|
361
|
+
const { ctx } = canvasSetup()
|
|
362
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(wideConfig)) as Frames2dRendererHandle
|
|
363
|
+
await flush()
|
|
364
|
+
expect(fetched.length).toBe(8)
|
|
365
|
+
// The happy frame sits behind the idle backlog; playing it must pull its
|
|
366
|
+
// fetch ahead of the not-yet-started warm frames.
|
|
367
|
+
handle.setState('happy')
|
|
368
|
+
await flush()
|
|
369
|
+
// Free one pool slot: the released idle decode completes and the freed
|
|
370
|
+
// slot must start the jumped happy frame, not the next warm idle frame.
|
|
371
|
+
held.get(fetched[0])?.()
|
|
372
|
+
await flush()
|
|
373
|
+
expect(fetched[8]).toBe('/pet/miku/happy/1.webp')
|
|
374
|
+
for (const release of held.values()) release()
|
|
375
|
+
await flush()
|
|
376
|
+
handle.dispose()
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('retries a failed frame instead of memoizing the failure forever', async () => {
|
|
380
|
+
let failuresLeft = 1
|
|
381
|
+
vi.stubGlobal('fetch', vi.fn(async () => {
|
|
382
|
+
if (failuresLeft > 0) {
|
|
383
|
+
failuresLeft -= 1
|
|
384
|
+
throw new Error('transient')
|
|
385
|
+
}
|
|
386
|
+
return { ok: true, blob: async () => ({}) }
|
|
387
|
+
}))
|
|
388
|
+
class FailingImage {
|
|
389
|
+
onload: (() => void) | null = null
|
|
390
|
+
onerror: (() => void) | null = null
|
|
391
|
+
set src(_value: string) { queueMicrotask(() => this.onerror?.()) }
|
|
392
|
+
}
|
|
393
|
+
vi.stubGlobal('Image', FailingImage)
|
|
394
|
+
const { ctx } = canvasSetup()
|
|
395
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
396
|
+
// Prefetch fetches fail through the pool, resolve undefined and are
|
|
397
|
+
// dropped from the memo; nothing throws.
|
|
398
|
+
await flush()
|
|
399
|
+
const memoFetches = (fetch as ReturnType<typeof vi.fn>).mock.calls.length
|
|
400
|
+
expect(memoFetches).toBeGreaterThan(0)
|
|
401
|
+
handle.dispose()
|
|
402
|
+
})
|
|
281
403
|
})
|
|
@@ -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.
|
|
@@ -213,36 +214,97 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
213
214
|
// the last painted frame instead of breaking playback).
|
|
214
215
|
const decoding = new Map<string, Promise<DecodedFrame | undefined>>()
|
|
215
216
|
const decodedAll: Promise<void>[] = []
|
|
216
|
-
|
|
217
|
+
// All frame fetches funnel through a small pool. Full-library warm passes
|
|
218
|
+
// on large pets fire 1100+ requests at once, which trips the browser's
|
|
219
|
+
// in-flight request limit (net::ERR_INSUFFICIENT_RESOURCES) and fails
|
|
220
|
+
// whole batches of frames while starving the rest of the page. Playback
|
|
221
|
+
// demand jumps ahead of the warm backlog.
|
|
222
|
+
const FRAME_POOL_LIMIT = 8
|
|
223
|
+
const frameQueue: Array<{ url: string; release: () => void }> = []
|
|
224
|
+
let activeFrames = 0
|
|
225
|
+
|
|
226
|
+
const decodeFrame = async (url: string): Promise<DecodedFrame | undefined> => {
|
|
227
|
+
try {
|
|
228
|
+
const response = await fetch(url)
|
|
229
|
+
if (!response.ok) throw new Error('http ' + response.status)
|
|
230
|
+
const bitmap = await createImageBitmap(await response.blob())
|
|
231
|
+
return { source: bitmap, width: bitmap.width, height: bitmap.height }
|
|
232
|
+
} catch {
|
|
233
|
+
// Fail-open: classic Image decode keeps non-modern runtimes alive.
|
|
234
|
+
return await new Promise<DecodedFrame | undefined>((resolve) => {
|
|
235
|
+
try {
|
|
236
|
+
const pre = new Image()
|
|
237
|
+
pre.onload = (): void => {
|
|
238
|
+
resolve(pre.naturalWidth > 0 ? { source: pre, width: pre.naturalWidth, height: pre.naturalHeight } : undefined)
|
|
239
|
+
}
|
|
240
|
+
pre.onerror = (): void => resolve(undefined)
|
|
241
|
+
pre.src = url
|
|
242
|
+
} catch {
|
|
243
|
+
resolve(undefined)
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const pumpFrames = (): void => {
|
|
250
|
+
while (activeFrames < FRAME_POOL_LIMIT && frameQueue.length > 0) {
|
|
251
|
+
const queued = frameQueue.shift()!
|
|
252
|
+
activeFrames += 1
|
|
253
|
+
queued.release()
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const loadFrame = (url: string, jump = false): Promise<DecodedFrame | undefined> => {
|
|
258
|
+
// Jump first: warm-enqueued frames already carry their memo, so a
|
|
259
|
+
// playback demand must reorder the unstarted entry before the cache
|
|
260
|
+
// lookup short-circuits.
|
|
261
|
+
if (jump) {
|
|
262
|
+
const index = frameQueue.findIndex((queued) => queued.url === url)
|
|
263
|
+
if (index > 0) frameQueue.unshift(frameQueue.splice(index, 1)[0]!)
|
|
264
|
+
}
|
|
217
265
|
const cached = decoding.get(url)
|
|
218
266
|
if (cached !== undefined) return cached
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
pre.onload = (): void => {
|
|
231
|
-
resolve(pre.naturalWidth > 0 ? { source: pre, width: pre.naturalWidth, height: pre.naturalHeight } : undefined)
|
|
232
|
-
}
|
|
233
|
-
pre.onerror = (): void => resolve(undefined)
|
|
234
|
-
pre.src = url
|
|
235
|
-
} catch {
|
|
236
|
-
resolve(undefined)
|
|
237
|
-
}
|
|
238
|
-
})
|
|
239
|
-
}
|
|
240
|
-
})()
|
|
267
|
+
let release!: () => void
|
|
268
|
+
const gate = new Promise<void>((resolve) => { release = resolve })
|
|
269
|
+
const job: Promise<DecodedFrame | undefined> = gate.then(() => (disposed ? undefined : decodeFrame(url)))
|
|
270
|
+
job.then(
|
|
271
|
+
(frame) => { if (frame === undefined) decoding.delete(url) },
|
|
272
|
+
() => decoding.delete(url),
|
|
273
|
+
)
|
|
274
|
+
void job.finally(() => {
|
|
275
|
+
activeFrames -= 1
|
|
276
|
+
pumpFrames()
|
|
277
|
+
})
|
|
241
278
|
decoding.set(url, job)
|
|
242
279
|
decodedAll.push(job.then(() => undefined, () => undefined))
|
|
280
|
+
const entry = { url, release }
|
|
281
|
+
if (jump) frameQueue.unshift(entry)
|
|
282
|
+
else frameQueue.push(entry)
|
|
283
|
+
pumpFrames()
|
|
243
284
|
return job
|
|
244
285
|
}
|
|
245
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
|
+
|
|
246
308
|
let disposed = false
|
|
247
309
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
248
310
|
let watchdog: ReturnType<typeof setInterval> | undefined
|
|
@@ -268,7 +330,7 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
268
330
|
const paintCanvas = (url: string): void => {
|
|
269
331
|
if (context2d === null || canvas === null) return
|
|
270
332
|
const myToken = ++drawToken
|
|
271
|
-
void loadFrame(url).then((frame) => {
|
|
333
|
+
void loadFrame(url, true).then((frame) => {
|
|
272
334
|
if (disposed || frame === undefined || myToken !== drawToken) return
|
|
273
335
|
if (lastDrawnUrl === url) return
|
|
274
336
|
lastDrawnUrl = url
|
|
@@ -287,6 +349,7 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
287
349
|
const def = config.tracks[trackId]
|
|
288
350
|
const url = def?.frames[index]
|
|
289
351
|
if (url === undefined) return
|
|
352
|
+
prefetchAhead(trackId, index)
|
|
290
353
|
if (img !== null) {
|
|
291
354
|
if (img.getAttribute('src') !== url) img.src = url
|
|
292
355
|
return
|
|
@@ -360,13 +423,6 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
360
423
|
}, WATCHDOG_MS)
|
|
361
424
|
}
|
|
362
425
|
|
|
363
|
-
// Warm pass: decode every frame up front (tiny same-origin webp files)
|
|
364
|
-
// so loops and phase switches never wait on a first decode - same intent
|
|
365
|
-
// as the historical Image-cache warm loop, now feeding the decode cache.
|
|
366
|
-
for (const warmTrack of Object.values(config.tracks)) {
|
|
367
|
-
for (const warmUrl of warmTrack.frames) void loadFrame(warmUrl)
|
|
368
|
-
}
|
|
369
|
-
|
|
370
426
|
play(track)
|
|
371
427
|
|
|
372
428
|
let disposedOnce = false
|
|
@@ -378,7 +434,9 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
378
434
|
if (timer !== undefined) clearTimeout(timer)
|
|
379
435
|
if (watchdog !== undefined) clearInterval(watchdog)
|
|
380
436
|
// Release decoded bitmaps after pending decodes settle; close() is
|
|
381
|
-
// browser-only, so guard it for exotic hosts.
|
|
437
|
+
// browser-only, so guard it for exotic hosts. Queued-but-unstarted
|
|
438
|
+
// frames release immediately as no-ops so the settle barrier drains.
|
|
439
|
+
for (const queued of frameQueue.splice(0)) queued.release()
|
|
382
440
|
void Promise.allSettled(decodedAll).then(() => {
|
|
383
441
|
for (const job of decoding.values()) {
|
|
384
442
|
void job.then((frame) => {
|
|
@@ -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/event-projection.ts
CHANGED
|
@@ -11,9 +11,16 @@
|
|
|
11
11
|
* tool/result and turn/end — so the pet's inner voice always roughly knows
|
|
12
12
|
* what is going on and never mis-fires on output text. The wall clock is
|
|
13
13
|
* injected by the caller, keeping every projection reproducible.
|
|
14
|
+
*
|
|
15
|
+
* Since the 0.1.5-alpha.2 cohort the stream itself is no longer durable
|
|
16
|
+
* vocabulary: per-chunk phase input arrives through the process-local
|
|
17
|
+
* `agent/assistant-stream` publication ({@link projectAssistantStreamFrame}),
|
|
18
|
+
* while the durable log settles one `assistant/message` (or `assistant/attempt`)
|
|
19
|
+
* per attempt.
|
|
14
20
|
* @module @linxin666/dsh-pet/event-projection
|
|
15
21
|
*/
|
|
16
22
|
|
|
23
|
+
import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
|
|
17
24
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
18
25
|
import type { PetStateInput } from './state.ts'
|
|
19
26
|
import {
|
|
@@ -102,24 +109,6 @@ export function projectOfficialEvent(
|
|
|
102
109
|
runtime.activeTools.clear()
|
|
103
110
|
runtime.stepHadFailure = false
|
|
104
111
|
return { input: { phase: 'waiting', line: runtime.voice.scene('waiting', nowMs) } }
|
|
105
|
-
case 'assistant/chunk': {
|
|
106
|
-
const { chunk } = event.data
|
|
107
|
-
if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
|
|
108
|
-
const whisper = runtime.whispers.feed('thinking', nowMs)
|
|
109
|
-
return {
|
|
110
|
-
input: { phase: 'thinking', line: runtime.voice.scene('thinking', nowMs) },
|
|
111
|
-
...(whisper === undefined ? {} : { whisper }),
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
if (chunk.type === 'text-delta' && chunk.text.length > 0) {
|
|
115
|
-
const whisper = runtime.whispers.feed('writing', nowMs)
|
|
116
|
-
return {
|
|
117
|
-
input: { phase: 'review', line: runtime.voice.scene('review', nowMs) },
|
|
118
|
-
...(whisper === undefined ? {} : { whisper }),
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return undefined
|
|
122
|
-
}
|
|
123
112
|
case 'assistant/message':
|
|
124
113
|
return { input: { phase: 'review', line: runtime.voice.scene('review', nowMs) } }
|
|
125
114
|
case 'tool/call': {
|
|
@@ -207,3 +196,33 @@ export function projectOfficialEvent(
|
|
|
207
196
|
return undefined
|
|
208
197
|
}
|
|
209
198
|
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Project one live `agent/assistant-stream` publication into the pet's visual
|
|
202
|
+
* phases. Chunk frames are the alpha.2 replacement for the retired durable
|
|
203
|
+
* `assistant/chunk` event: a reasoning delta keeps the pet thinking, a text
|
|
204
|
+
* delta moves it to review; start, end, and non-delta chunks change nothing.
|
|
205
|
+
*/
|
|
206
|
+
export function projectAssistantStreamFrame(
|
|
207
|
+
frame: AssistantStreamFrame,
|
|
208
|
+
runtime: ProjectionRuntime,
|
|
209
|
+
nowMs: number = Date.now(),
|
|
210
|
+
): PetActivityTransition | undefined {
|
|
211
|
+
if (frame.type !== 'chunk') return undefined
|
|
212
|
+
const { chunk } = frame
|
|
213
|
+
if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
|
|
214
|
+
const whisper = runtime.whispers.feed('thinking', nowMs)
|
|
215
|
+
return {
|
|
216
|
+
input: { phase: 'thinking', line: runtime.voice.scene('thinking', nowMs) },
|
|
217
|
+
...(whisper === undefined ? {} : { whisper }),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (chunk.type === 'text-delta' && chunk.text.length > 0) {
|
|
221
|
+
const whisper = runtime.whispers.feed('writing', nowMs)
|
|
222
|
+
return {
|
|
223
|
+
input: { phase: 'review', line: runtime.voice.scene('review', nowMs) },
|
|
224
|
+
...(whisper === undefined ? {} : { whisper }),
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return undefined
|
|
228
|
+
}
|
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'
|