@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.
Files changed (57) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +1 -1
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +191 -53
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +174 -61
  7. package/lib/types/client/gameplay-hud.d.ts +15 -0
  8. package/lib/types/client/gameplay-hud.d.ts.map +1 -1
  9. package/lib/types/client/gameplay-hud.js +48 -6
  10. package/lib/types/client/index.d.ts.map +1 -1
  11. package/lib/types/client/index.js +21 -13
  12. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -1
  13. package/lib/types/client/renderers/Frames2dVisualMount.js +5 -0
  14. package/lib/types/client/renderers/frames2d.d.ts +5 -4
  15. package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
  16. package/lib/types/client/renderers/frames2d.js +96 -39
  17. package/lib/types/client/work-tick-gate.d.ts +40 -0
  18. package/lib/types/client/work-tick-gate.d.ts.map +1 -0
  19. package/lib/types/client/work-tick-gate.js +49 -0
  20. package/lib/types/event-projection.d.ts +14 -0
  21. package/lib/types/event-projection.d.ts.map +1 -1
  22. package/lib/types/event-projection.js +32 -18
  23. package/lib/types/gameplay.d.ts +4 -0
  24. package/lib/types/gameplay.d.ts.map +1 -1
  25. package/lib/types/gameplay.js +11 -2
  26. package/lib/types/ledger.d.ts +8 -0
  27. package/lib/types/ledger.d.ts.map +1 -1
  28. package/lib/types/ledger.js +25 -0
  29. package/lib/types/persist.d.ts +6 -0
  30. package/lib/types/persist.d.ts.map +1 -1
  31. package/lib/types/persist.js +25 -1
  32. package/lib/types/routes.d.ts.map +1 -1
  33. package/lib/types/routes.js +6 -0
  34. package/lib/types/service.d.ts +24 -0
  35. package/lib/types/service.d.ts.map +1 -1
  36. package/lib/types/service.js +43 -1
  37. package/package.json +14 -14
  38. package/src/client/PetDockEntry.test.tsx +1 -0
  39. package/src/client/gameplay-hud.test.tsx +165 -2
  40. package/src/client/gameplay-hud.tsx +62 -6
  41. package/src/client/index.ts +23 -13
  42. package/src/client/pet.module.css +1 -1
  43. package/src/client/renderers/Frames2dVisualMount.test.tsx +73 -0
  44. package/src/client/renderers/Frames2dVisualMount.tsx +4 -0
  45. package/src/client/renderers/frames2d.test.ts +129 -7
  46. package/src/client/renderers/frames2d.ts +94 -36
  47. package/src/client/work-tick-gate.test.ts +53 -0
  48. package/src/client/work-tick-gate.ts +63 -0
  49. package/src/event-projection.ts +37 -18
  50. package/src/gameplay.test.ts +36 -0
  51. package/src/gameplay.ts +14 -2
  52. package/src/ledger.test.ts +19 -0
  53. package/src/ledger.ts +24 -0
  54. package/src/persist.test.ts +13 -0
  55. package/src/persist.ts +29 -1
  56. package/src/routes.ts +5 -0
  57. package/src/service.ts +49 -0
@@ -117,6 +117,12 @@ export interface PetStateView {
117
117
  };
118
118
  /** The selected pet's display name (user rename or manifest default). */
119
119
  name: string;
120
+ /**
121
+ * The selected frames2d skin id for this pet (absent = the pet's default
122
+ * look). Persisted host-side, so a page reload or client restart restores
123
+ * the user's last choice instead of falling back to the default look.
124
+ */
125
+ skin?: string;
120
126
  /** Treat (小鱼干) stock snapshot. */
121
127
  treats: {
122
128
  /** Stocked treats now. */
@@ -308,6 +314,24 @@ export declare class PetService extends Service {
308
314
  ok: true;
309
315
  display: PetDisplayConfig;
310
316
  }>;
317
+ /**
318
+ * The persisted skin for one entry, when the manifest still declares it: a
319
+ * stale id (skin removed from the manifest, pet swapped) reads as "default"
320
+ * rather than pinning a track the browser half cannot resolve.
321
+ */
322
+ private persistedSkin;
323
+ /**
324
+ * RPC: select the current pet's frames2d skin (`undefined` restores the
325
+ * pet's default look). The choice is stored per pet, so every later state
326
+ * view (reload, client restart, pet re-selection) serves it back.
327
+ */
328
+ setSkin(skin: string | undefined): Promise<{
329
+ ok: true;
330
+ skin?: string;
331
+ } | {
332
+ ok: false;
333
+ error: string;
334
+ }>;
311
335
  /** RPC: update display config (size / position). Values are clamped to whole pixels. */
312
336
  setConfig(patch: Partial<PetDisplayConfig>): Promise<{
313
337
  ok: true;
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/service.ts"],"names":[],"mappings":"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"}
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { Service } from '@deepseek-ai/cordis';
15
15
  import { announcementFresh, parseAnnouncement } from "./announce.js";
16
- import { emptyProjectionRuntime, isActivityPhase, projectOfficialEvent, } from "./event-projection.js";
16
+ import { emptyProjectionRuntime, isActivityPhase, projectAssistantStreamFrame, projectOfficialEvent, } from "./event-projection.js";
17
17
  import { PetLedger } from "./ledger.js";
18
18
  import { DEFAULT_PET_NAME, DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN, PET_NAME_MAX_LENGTH, loadPetPersist, petHomeDir, savePetPersist, } from "./persist.js";
19
19
  import { DEFAULT_DECORATION_ID, decorationView, loadPetRegistry, petEntryView, petPackageRoot, } from "./registry.js";
@@ -236,6 +236,16 @@ export class PetService extends Service {
236
236
  this.rewardTurn(String(session.id), transition.completedTurn);
237
237
  }
238
238
  }),
239
+ this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
240
+ const session = agent.session;
241
+ const runtime = this.activityOf(session).runtime;
242
+ const transition = projectAssistantStreamFrame(frame, runtime);
243
+ if (transition === undefined)
244
+ return;
245
+ runtime.officialEventsSeen = true;
246
+ this.officialEventSessions.add(session);
247
+ this.applyActivity(session, transition.input, transition.whisper);
248
+ }),
239
249
  this.ctx.on('session/disposed', (session) => {
240
250
  this.ledger.forgetSession(String(session.id));
241
251
  this.officialEventSessions.delete(session);
@@ -492,6 +502,36 @@ export class PetService extends Service {
492
502
  this.syncSettingsFromPet();
493
503
  return { ok: true, display: this.ledger.snapshot.display };
494
504
  }
505
+ /**
506
+ * The persisted skin for one entry, when the manifest still declares it: a
507
+ * stale id (skin removed from the manifest, pet swapped) reads as "default"
508
+ * rather than pinning a track the browser half cannot resolve.
509
+ */
510
+ persistedSkin(entry) {
511
+ const stored = this.ledger.petSkin(entry.id);
512
+ if (stored === undefined)
513
+ return undefined;
514
+ return entry.frames2d?.skins?.some(skin => skin.id === stored) === true ? stored : undefined;
515
+ }
516
+ /**
517
+ * RPC: select the current pet's frames2d skin (`undefined` restores the
518
+ * pet's default look). The choice is stored per pet, so every later state
519
+ * view (reload, client restart, pet re-selection) serves it back.
520
+ */
521
+ async setSkin(skin) {
522
+ const entry = this.activeEntry();
523
+ const declared = entry.frames2d?.skins ?? [];
524
+ if (skin === undefined) {
525
+ this.ledger.setPetSkin(entry.id, undefined);
526
+ this.flush();
527
+ return { ok: true };
528
+ }
529
+ if (!declared.some(candidate => candidate.id === skin))
530
+ return { ok: false, error: 'unknown-skin' };
531
+ this.ledger.setPetSkin(entry.id, skin);
532
+ this.flush();
533
+ return { ok: true, skin };
534
+ }
495
535
  /** RPC: update display config (size / position). Values are clamped to whole pixels. */
496
536
  async setConfig(patch) {
497
537
  const next = { ...this.ledger.snapshot.display, ...patch };
@@ -626,6 +666,7 @@ export class PetService extends Service {
626
666
  const announcement = this.announcement !== undefined && announcementFresh(this.announcement, Date.now())
627
667
  ? this.announcement
628
668
  : undefined;
669
+ const skin = this.persistedSkin(entry);
629
670
  return {
630
671
  animation: snapshot.animation,
631
672
  ...(snapshot.bubble === undefined ? {} : { bubble: snapshot.bubble }),
@@ -642,6 +683,7 @@ export class PetService extends Service {
642
683
  description: entry.description,
643
684
  },
644
685
  name: this.petName(),
686
+ ...(skin === undefined ? {} : { skin }),
645
687
  treats: {
646
688
  stocked: this.ledger.snapshot.treats.treats,
647
689
  max: this.ledger.treatMax,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-pet",
3
3
  "description": "Multi-pet companion plugin for the dsh web GUI: a registry-driven floating pet that reacts to model activity, with per-pet naming, petting/feeding interactions and an affinity score",
4
- "version": "0.3.19",
4
+ "version": "0.3.21",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -41,7 +41,7 @@
41
41
  "license": "Apache-2.0",
42
42
  "dsh": {
43
43
  "engines": {
44
- "dsh": ">=0.1.2-rc.1"
44
+ "dsh": ">=0.1.5-rc.1"
45
45
  },
46
46
  "bundle": {
47
47
  "patch": "./cordis.patch.yml"
@@ -66,18 +66,18 @@
66
66
  },
67
67
  "devDependencies": {
68
68
  "@deepseek-ai/cordis": "^4.0.2",
69
- "@deepseek-ai/dsh-api-remotes": "^0.1.2-rc.1",
70
- "@deepseek-ai/dsh-api-session-controller": "^0.1.2-rc.1",
71
- "@deepseek-ai/dsh-api-workspace-controller": "^0.1.2-rc.1",
72
- "@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
73
- "@deepseek-ai/dsh-client-store": "^0.1.2-rc.1",
74
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-rc.1",
75
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-rc.1",
76
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1",
77
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1",
78
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1",
79
- "@deepseek-ai/dsh-session": "^0.1.2-rc.1",
80
- "@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
69
+ "@deepseek-ai/dsh-api-remotes": "^0.1.5-rc.1",
70
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.1",
71
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.1.5-rc.1",
72
+ "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
73
+ "@deepseek-ai/dsh-client-store": "^0.1.5-rc.1",
74
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.1",
75
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
76
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
77
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
78
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
79
+ "@deepseek-ai/dsh-session": "^0.1.5-rc.1",
80
+ "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
81
81
  "@testing-library/dom": "^10.4.1",
82
82
  "@testing-library/react": "^16.3.2",
83
83
  "@types/node": "^22.20.0",
@@ -84,6 +84,7 @@ function injected(): PetInjected {
84
84
  setMode: vi.fn(),
85
85
  workTick: vi.fn(),
86
86
  buy: vi.fn(),
87
+ setSkin: vi.fn(async () => ({ ok: true })),
87
88
  },
88
89
  }
89
90
  }
@@ -110,7 +110,7 @@ function petDefinition(): PetDefinition {
110
110
  }
111
111
  }
112
112
 
113
- function snapshot(view: PetGameplayStateView): PetStateView {
113
+ function snapshot(view: PetGameplayStateView, skin?: string): PetStateView {
114
114
  return {
115
115
  animation: 'idle',
116
116
  phase: 'idle',
@@ -121,6 +121,7 @@ function snapshot(view: PetGameplayStateView): PetStateView {
121
121
  pet: { id: 'miku', displayName: 'Miku', description: '' },
122
122
  name: 'Miku',
123
123
  treats: { stocked: 0, max: 5 },
124
+ ...(skin === undefined ? {} : { skin }),
124
125
  gameplay: view,
125
126
  }
126
127
  }
@@ -128,7 +129,7 @@ function snapshot(view: PetGameplayStateView): PetStateView {
128
129
  interface Harness {
129
130
  store: PetStoreInstance
130
131
  bus: GameplayBus
131
- api: GameplayApi & { touch: ReturnType<typeof vi.fn>; setMode: ReturnType<typeof vi.fn>; workTick: ReturnType<typeof vi.fn>; buy: ReturnType<typeof vi.fn> }
132
+ api: GameplayApi & { touch: ReturnType<typeof vi.fn>; setMode: ReturnType<typeof vi.fn>; workTick: ReturnType<typeof vi.fn>; buy: ReturnType<typeof vi.fn>; setSkin: ReturnType<typeof vi.fn> }
132
133
  setTrack: ReturnType<typeof vi.fn>
133
134
  drag: ReturnType<typeof createDragStream>
134
135
  setView: (view: PetGameplayStateView) => void
@@ -147,6 +148,7 @@ function harness(view: PetGameplayStateView = gameplayView()): Harness {
147
148
  setMode: vi.fn(async (mode: 'work' | 'sleep' | null) => ok({ view: gameplayView({ mode }) })),
148
149
  workTick: vi.fn(async () => ok({ outcome: 'success' as const, view: gameplayView({ mode: 'work' }) })),
149
150
  buy: vi.fn(async () => ok({ view: gameplayView() })),
151
+ setSkin: vi.fn(async () => ({ ok: true })),
150
152
  }
151
153
  const setView = (next: PetGameplayStateView): void => store.actions.setSnapshot(snapshot(next))
152
154
  render(<GameplayHud definition={petDefinition()} store={store} api={api} bus={bus} drag={drag} t={t} />)
@@ -284,6 +286,7 @@ describe('GameplayHud', () => {
284
286
  const api = {
285
287
  touch: vi.fn(async () => ({ ok: true, view: gameplayView() })),
286
288
  setMode: vi.fn(), workTick: vi.fn(), buy: vi.fn(),
289
+ setSkin: vi.fn(async () => ({ ok: true })),
287
290
  } as unknown as Harness['api']
288
291
  const setTrack = vi.fn()
289
292
  const bus: GameplayBus = { setTrack }
@@ -316,6 +319,7 @@ describe('GameplayHud', () => {
316
319
  const api = {
317
320
  touch: vi.fn(async () => ({ ok: true, view: gameplayView() })),
318
321
  setMode: vi.fn(), workTick: vi.fn(), buy: vi.fn(),
322
+ setSkin: vi.fn(async () => ({ ok: true })),
319
323
  } as unknown as Harness['api']
320
324
  const setTrack = vi.fn()
321
325
  const bus: GameplayBus = { setTrack }
@@ -335,6 +339,101 @@ describe('GameplayHud', () => {
335
339
  expect(api.touch).not.toHaveBeenCalledWith('head')
336
340
  })
337
341
 
342
+ it('restores the host-persisted skin and pushes its idle track on mount', () => {
343
+ // Reload / client restart: the state view carries the last choice, so the
344
+ // renderer repaints the selected skin without any interaction.
345
+ const def = petDefinition()
346
+ def.frames2d!.skins = [
347
+ { id: 'lanhainishang', label: '蓝海霓裳', idleTrack: 'lanhainishang-idle' },
348
+ ]
349
+ const store = createPetStore().create()
350
+ store.actions.setSnapshot(snapshot(gameplayView(), 'lanhainishang'))
351
+ const setSkin = vi.fn(async () => ({ ok: true }))
352
+ const api = { touch: vi.fn(), setMode: vi.fn(), workTick: vi.fn(), buy: vi.fn(), setSkin } as unknown as Harness['api']
353
+ const setIdleTrack = vi.fn()
354
+ const bus: GameplayBus = { setTrack: vi.fn(), setIdleTrack }
355
+ render(<GameplayHud definition={def} store={store} api={api} bus={bus} drag={createDragStream()} t={t} />)
356
+ expect(setIdleTrack).toHaveBeenLastCalledWith('lanhainishang-idle')
357
+ // Latched on the bus too, so a later (re)mount re-applies it.
358
+ expect(bus.idleTrack).toBe('lanhainishang-idle')
359
+ // Restoring is read-only: mounting never writes the choice back.
360
+ expect(setSkin).not.toHaveBeenCalled()
361
+ })
362
+
363
+ it('persists the skin selection through the host API', async () => {
364
+ const def = petDefinition()
365
+ def.frames2d!.skins = [
366
+ { id: 'lanhainishang', label: '蓝海霓裳', idleTrack: 'lanhainishang-idle' },
367
+ ]
368
+ const store = createPetStore().create()
369
+ store.actions.setSnapshot(snapshot(gameplayView()))
370
+ const setSkin = vi.fn(async () => ({ ok: true }))
371
+ const api = { touch: vi.fn(), setMode: vi.fn(), workTick: vi.fn(), buy: vi.fn(), setSkin } as unknown as Harness['api']
372
+ const setIdleTrack = vi.fn()
373
+ const bus: GameplayBus = { setTrack: vi.fn(), setIdleTrack }
374
+ render(<GameplayHud definition={def} store={store} api={api} bus={bus} drag={createDragStream()} t={t} />)
375
+ await act(async () => {
376
+ bus.openCard?.()
377
+ })
378
+ fireEvent.click(screen.getByText('皮肤'))
379
+ await act(async () => {
380
+ fireEvent.click(screen.getByText('蓝海霓裳'))
381
+ })
382
+ expect(setIdleTrack).toHaveBeenLastCalledWith('lanhainishang-idle')
383
+ expect(setSkin).toHaveBeenCalledWith('lanhainishang')
384
+ })
385
+
386
+ it('clears the persisted skin when the default look is picked', async () => {
387
+ const def = petDefinition()
388
+ def.frames2d!.skins = [
389
+ { id: 'lanhainishang', label: '蓝海霓裳', idleTrack: 'lanhainishang-idle' },
390
+ ]
391
+ const store = createPetStore().create()
392
+ store.actions.setSnapshot(snapshot(gameplayView(), 'lanhainishang'))
393
+ const setSkin = vi.fn(async () => ({ ok: true }))
394
+ const api = { touch: vi.fn(), setMode: vi.fn(), workTick: vi.fn(), buy: vi.fn(), setSkin } as unknown as Harness['api']
395
+ const setIdleTrack = vi.fn()
396
+ const bus: GameplayBus = { setTrack: vi.fn(), setIdleTrack }
397
+ render(<GameplayHud definition={def} store={store} api={api} bus={bus} drag={createDragStream()} t={t} />)
398
+ await act(async () => {
399
+ bus.openCard?.()
400
+ })
401
+ fireEvent.click(screen.getByText('皮肤'))
402
+ await act(async () => {
403
+ fireEvent.click(screen.getByText('默认'))
404
+ })
405
+ expect(setSkin).toHaveBeenCalledWith(undefined)
406
+ expect(setIdleTrack).toHaveBeenLastCalledWith(undefined)
407
+ })
408
+
409
+ it('falls back to the served skin when the host rejects the choice', async () => {
410
+ const def = petDefinition()
411
+ def.frames2d!.skins = [
412
+ { id: 'lanhainishang', label: '蓝海霓裳', idleTrack: 'lanhainishang-idle' },
413
+ ]
414
+ const store = createPetStore().create()
415
+ store.actions.setSnapshot(snapshot(gameplayView()))
416
+ const setSkin = vi.fn(async () => ({ ok: false, error: 'unknown-skin' }))
417
+ const api = { touch: vi.fn(), setMode: vi.fn(), workTick: vi.fn(), buy: vi.fn(), setSkin } as unknown as Harness['api']
418
+ const setIdleTrack = vi.fn()
419
+ const bus: GameplayBus = { setTrack: vi.fn(), setIdleTrack }
420
+ render(<GameplayHud definition={def} store={store} api={api} bus={bus} drag={createDragStream()} t={t} />)
421
+ await act(async () => {
422
+ bus.openCard?.()
423
+ })
424
+ fireEvent.click(screen.getByText('皮肤'))
425
+ await act(async () => {
426
+ fireEvent.click(screen.getByText('蓝海霓裳'))
427
+ // The rejected write settles on a later microtask; flush it so the
428
+ // rollback render reaches the bus before the assertion.
429
+ await Promise.resolve()
430
+ await Promise.resolve()
431
+ })
432
+ expect(setSkin).toHaveBeenCalledWith('lanhainishang')
433
+ // The rejected optimistic swap rolls back to what the host still serves.
434
+ expect(setIdleTrack).toHaveBeenLastCalledWith(undefined)
435
+ })
436
+
338
437
  it('runs the idle director: weighted act rolls on the interval', async () => {
339
438
  const h = harness()
340
439
  await act(async () => {
@@ -372,6 +471,70 @@ describe('GameplayHud', () => {
372
471
  expect(h.store.getSnapshot().snapshot?.gameplay?.mode).toBe('work')
373
472
  })
374
473
 
474
+ it('drops a late work adjudication once the mode has been left', async () => {
475
+ // #1495: the tick RPC can still be in flight when the user exits work mode;
476
+ // its result must not write the work view back or play the result track.
477
+ const h = harness(gameplayView({ mode: 'work' }))
478
+ let resolveTick: ((result: PetGameplayVerbResult) => void) | undefined
479
+ h.api.workTick.mockImplementationOnce(() => new Promise<PetGameplayVerbResult>((resolve) => { resolveTick = resolve }))
480
+ await act(async () => {
481
+ vi.advanceTimersByTime(10_000)
482
+ })
483
+ expect(h.api.workTick).toHaveBeenCalledTimes(1)
484
+ await act(async () => {
485
+ h.setView(gameplayView({ mode: null }))
486
+ })
487
+ await act(async () => {
488
+ resolveTick?.({ ok: true, outcome: 'success', view: gameplayView({ mode: 'work' }) })
489
+ await Promise.resolve()
490
+ })
491
+ expect(h.store.getSnapshot().snapshot?.gameplay?.mode).toBeNull()
492
+ expect(h.setTrack).not.toHaveBeenCalledWith('success')
493
+ })
494
+
495
+ it('drives the skin work loop and its skin result animation on the shared 10s rule', async () => {
496
+ // A skin supplies its own work / result art, but the adjudication rule
497
+ // (10s tick, 50% roll, result hold) stays the pet-level gameplay.work rule.
498
+ const def = petDefinition()
499
+ def.frames2d!.tracks['skin-work'] = { frames: ['/pet/miku/skin-work_1.webp'], durations: [200], loop: true }
500
+ def.frames2d!.tracks['skin-success'] = { frames: ['/pet/miku/skin-ok_1.webp'], durations: [200], loop: false, fallback: 'skin-work' }
501
+ def.frames2d!.skins = [
502
+ {
503
+ id: 'skin', label: 'Skin', idleTrack: 'idle',
504
+ // The override key is the manifest's successState ('success' in this fixture).
505
+ gameplayTracks: { work: 'skin-work', success: 'skin-success' },
506
+ },
507
+ ]
508
+ const store = createPetStore().create()
509
+ store.actions.setSnapshot(snapshot(gameplayView({ mode: 'work' }), 'skin'))
510
+ const setTrack = vi.fn()
511
+ const bus: GameplayBus = { setTrack }
512
+ const api = {
513
+ touch: vi.fn(), setMode: vi.fn(),
514
+ workTick: vi.fn(async () => ({ ok: true, outcome: 'success' as const, view: gameplayView({ mode: 'work' }) })),
515
+ buy: vi.fn(), setSkin: vi.fn(async () => ({ ok: true })),
516
+ } as unknown as Harness['api']
517
+ render(<GameplayHud definition={def} store={store} api={api} bus={bus} drag={createDragStream()} t={t} />)
518
+ // The mode hold uses the skin's own work loop, not the manifest track.
519
+ expect(setTrack).toHaveBeenCalledWith('skin-work')
520
+ await act(async () => {
521
+ vi.advanceTimersByTime(10_000)
522
+ })
523
+ // One adjudication per 10s window, then the skin's own result animation.
524
+ expect(api.workTick).toHaveBeenCalledTimes(1)
525
+ expect(setTrack).toHaveBeenCalledWith('skin-success')
526
+ await act(async () => {
527
+ vi.advanceTimersByTime(1300)
528
+ })
529
+ // The result holds for resultMs, then the skin work loop resumes ...
530
+ expect(setTrack).toHaveBeenLastCalledWith('skin-work')
531
+ await act(async () => {
532
+ vi.advanceTimersByTime(10_000)
533
+ })
534
+ // ... and the next 10s round adjudicates again.
535
+ expect(api.workTick).toHaveBeenCalledTimes(2)
536
+ })
537
+
375
538
  it('holds the sleep track and wakes on drag', async () => {
376
539
  const h = harness(gameplayView({ mode: 'sleep' }))
377
540
  expect(h.setTrack).toHaveBeenCalledWith('sleep')
@@ -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 {