@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
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)
@@ -143,6 +143,25 @@ describe('PetLedger', () => {
143
143
  expect(ledger.takeDirty()).toBe(true)
144
144
  })
145
145
 
146
+ it('stores and clears the per-pet skin selection', () => {
147
+ const ledger = new PetLedger(emptyPersist())
148
+ expect(ledger.petSkin('jyn')).toBeUndefined()
149
+ ledger.setPetSkin('jyn', 'bingjing-gongzhu')
150
+ expect(ledger.petSkin('jyn')).toBe('bingjing-gongzhu')
151
+ expect(ledger.snapshot.skins).toEqual({ jyn: 'bingjing-gongzhu' })
152
+ expect(ledger.takeDirty()).toBe(true)
153
+ // Same value again: nothing changed, so no extra persist.
154
+ ledger.setPetSkin('jyn', 'bingjing-gongzhu')
155
+ expect(ledger.takeDirty()).toBe(false)
156
+ // undefined clears the entry back to the pet's default look.
157
+ ledger.setPetSkin('jyn', undefined)
158
+ expect(ledger.petSkin('jyn')).toBeUndefined()
159
+ expect(ledger.snapshot.skins).toEqual({})
160
+ expect(ledger.takeDirty()).toBe(true)
161
+ ledger.setPetSkin('jyn', undefined)
162
+ expect(ledger.takeDirty()).toBe(false)
163
+ })
164
+
146
165
  it('exposes the treat stock cap and display/pet/name setters', () => {
147
166
  const ledger = new PetLedger(emptyPersist())
148
167
  expect(ledger.treatMax).toBe(defaultTreatConfig.maxTreats)
package/src/ledger.ts CHANGED
@@ -124,6 +124,30 @@ export class PetLedger {
124
124
  this.dirty = true
125
125
  }
126
126
 
127
+ /**
128
+ * Select one pet's frames2d skin; `undefined` clears the choice back to the
129
+ * pet's default look. Manifest validation stays a caller concern, exactly
130
+ * like setPetName's length check.
131
+ */
132
+ setPetSkin(petId: string, skinId: string | undefined): void {
133
+ const skins = this.current.skins
134
+ if (skinId === undefined) {
135
+ if (skins[petId] === undefined) return
136
+ const next = { ...skins }
137
+ delete next[petId]
138
+ this.current = { ...this.current, skins: next }
139
+ } else {
140
+ if (skins[petId] === skinId) return
141
+ this.current = { ...this.current, skins: { ...skins, [petId]: skinId } }
142
+ }
143
+ this.dirty = true
144
+ }
145
+
146
+ /** The persisted skin id for one pet (undefined = the pet's default look). */
147
+ petSkin(petId: string): string | undefined {
148
+ return this.current.skins[petId]
149
+ }
150
+
127
151
  /**
128
152
  * Swap the reaction pools to another pet's custom remarks (called on pet
129
153
  * selection). Slots the pet does not declare fall back to voice packs or built-ins.
@@ -49,6 +49,7 @@ describe('loadPetPersist', () => {
49
49
  const data = {
50
50
  petId: 'otter',
51
51
  names: { otter: '泡泡', 'whale-girl': '鲸鱼娘' },
52
+ skins: { otter: 'lanhainishang' },
52
53
  affinity: { ...emptyAffinity(), points: 42, pets: 3, feeds: 1, turns: 10 },
53
54
  treats: { ...emptyTreatLedger(), treats: 7, lastTreatGrantAt: 1234, turnsAtLastTreatGrant: 9 },
54
55
  display: { visible: false, size: 200, right: 10, bottom: 40 },
@@ -111,6 +112,18 @@ describe('loadPetPersist', () => {
111
112
  }
112
113
  })
113
114
 
115
+ it('sanitizes the per-pet skin map', () => {
116
+ const dir = tempDir()
117
+ try {
118
+ writeFileSync(join(dir, 'pet.json'), JSON.stringify({
119
+ skins: { otter: ' lanhainishang ', blank: ' ', numeric: 7, '': 'x' },
120
+ }), 'utf8')
121
+ expect(loadPetPersist(dir).skins).toEqual({ otter: 'lanhainishang' })
122
+ } finally {
123
+ rmSync(dir, { recursive: true, force: true })
124
+ }
125
+ })
126
+
114
127
  it('clamps out-of-range and non-numeric fields', () => {
115
128
  const dir = tempDir()
116
129
  try {
package/src/persist.ts CHANGED
@@ -48,6 +48,12 @@ export interface PetPersist {
48
48
  * to its manifest displayName, so only user renames are stored here.
49
49
  */
50
50
  names: Record<string, string>
51
+ /**
52
+ * Per-pet selected frames2d skin id (keyed by pet id). Skin ids are manifest
53
+ * data, so a stale entry (skin renamed or removed, pet swapped) is ignored
54
+ * when the state view is built instead of pinning an unresolvable track.
55
+ */
56
+ skins: Record<string, string>
51
57
  affinity: AffinityState
52
58
  /** Treat (小鱼干) stock ledger. */
53
59
  treats: TreatLedger
@@ -63,6 +69,7 @@ export function emptyPersist(): PetPersist {
63
69
  return {
64
70
  petId: DEFAULT_PET_ID,
65
71
  names: {},
72
+ skins: {},
66
73
  affinity: emptyAffinity(),
67
74
  treats: emptyTreatLedger(),
68
75
  display: { ...defaultDisplayConfig },
@@ -100,6 +107,19 @@ function loadPetNames(parsed: PetPersistDocument): Record<string, string> {
100
107
  return names
101
108
  }
102
109
 
110
+ /** Sanitize the per-pet skin selection map (string keys, non-empty trimmed values). */
111
+ function loadPetSkins(parsed: PetPersistDocument): Record<string, string> {
112
+ const skins: Record<string, string> = {}
113
+ if (typeof parsed.skins !== 'object' || parsed.skins === null) return skins
114
+ for (const [id, value] of Object.entries(parsed.skins as Record<string, unknown>)) {
115
+ if (id === '' || typeof value !== 'string') continue
116
+ const skin = value.trim()
117
+ if (skin === '') continue
118
+ skins[id] = skin
119
+ }
120
+ return skins
121
+ }
122
+
103
123
  /** Clamp one count/score into [0, max]. */
104
124
  function clamp(value: number, max: number): number {
105
125
  return Math.min(max, Math.max(0, value))
@@ -130,12 +150,19 @@ function loadGameplay(parsed: PetPersistDocument): Record<string, PetGameplaySta
130
150
  currencies[key] = Math.min(GAMEPLAY_LOAD_CURRENCY_CAP, Math.max(0, Math.floor(value)))
131
151
  }
132
152
  }
133
- result[petId] = {
153
+ const item: PetGameplayState = {
134
154
  stats,
135
155
  currencies,
136
156
  mode: record.mode === 'work' || record.mode === 'sleep' ? record.mode : null,
137
157
  settledAt: clamp(finiteNum(record.settledAt, 0), Number.MAX_SAFE_INTEGER),
138
158
  }
159
+ if (typeof record.incomeCarryMs === 'number' && Number.isFinite(record.incomeCarryMs)) {
160
+ item.incomeCarryMs = Math.max(0, record.incomeCarryMs)
161
+ }
162
+ if (typeof record.restoreCarryMs === 'number' && Number.isFinite(record.restoreCarryMs)) {
163
+ item.restoreCarryMs = Math.max(0, record.restoreCarryMs)
164
+ }
165
+ result[petId] = item
139
166
  }
140
167
  return result
141
168
  }
@@ -185,6 +212,7 @@ export function loadPetPersist(dir: string = petHomeDir()): PetPersist {
185
212
  return {
186
213
  petId,
187
214
  names,
215
+ skins: loadPetSkins(parsed),
188
216
  affinity,
189
217
  treats,
190
218
  display,
package/src/routes.ts CHANGED
@@ -603,6 +603,11 @@ export function makePetRoutes(deps: { service: PetService; ctx: Context; assetCa
603
603
  if (typeof name !== 'string') return Promise.reject(new Error('invalid-name'))
604
604
  return service.setName(name)
605
605
  }),
606
+ postRoute(ctx, PET_API_PREFIX + '/set-skin', (body) => {
607
+ const skin = body.skin
608
+ if (skin !== undefined && typeof skin !== 'string') return Promise.reject(new Error('invalid-skin'))
609
+ return service.setSkin(skin === undefined || skin === '' ? undefined : skin)
610
+ }),
606
611
  postRoute(ctx, PET_API_PREFIX + '/set-pet', (body) => {
607
612
  const petId = body.petId
608
613
  if (typeof petId !== 'string') return Promise.reject(new Error('invalid-pet'))
package/src/service.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { Context, Service } from '@deepseek-ai/cordis'
16
+ import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
16
17
  import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
17
18
  import type { AffinityConfig, PetAffinityView, PetInteraction } from './affinity.ts'
18
19
  import { announcementFresh, parseAnnouncement, type PetAnnouncement } from './announce.ts'
@@ -20,6 +21,7 @@ import type { TreatConfig } from './treats.ts'
20
21
  import {
21
22
  emptyProjectionRuntime,
22
23
  isActivityPhase,
24
+ projectAssistantStreamFrame,
23
25
  projectOfficialEvent,
24
26
  type ActivityStatusEventLike,
25
27
  type ProjectionRuntime,
@@ -173,6 +175,12 @@ export interface PetStateView {
173
175
  }
174
176
  /** The selected pet's display name (user rename or manifest default). */
175
177
  name: string
178
+ /**
179
+ * The selected frames2d skin id for this pet (absent = the pet's default
180
+ * look). Persisted host-side, so a page reload or client restart restores
181
+ * the user's last choice instead of falling back to the default look.
182
+ */
183
+ skin?: string
176
184
  /** Treat (小鱼干) stock snapshot. */
177
185
  treats: {
178
186
  /** Stocked treats now. */
@@ -466,6 +474,15 @@ export class PetService extends Service {
466
474
  this.rewardTurn(String(session.id), transition.completedTurn)
467
475
  }
468
476
  }),
477
+ this.ctx.on('agent/assistant-stream', ({ agent, frame }: { agent: { session: Session }; frame: AssistantStreamFrame }) => {
478
+ const session = agent.session
479
+ const runtime = this.activityOf(session).runtime
480
+ const transition = projectAssistantStreamFrame(frame, runtime)
481
+ if (transition === undefined) return
482
+ runtime.officialEventsSeen = true
483
+ this.officialEventSessions.add(session)
484
+ this.applyActivity(session, transition.input, transition.whisper)
485
+ }),
469
486
  this.ctx.on('session/disposed', (session: Session) => {
470
487
  this.ledger.forgetSession(String(session.id))
471
488
  this.officialEventSessions.delete(session)
@@ -709,6 +726,36 @@ export class PetService extends Service {
709
726
  return { ok: true, display: this.ledger.snapshot.display }
710
727
  }
711
728
 
729
+ /**
730
+ * The persisted skin for one entry, when the manifest still declares it: a
731
+ * stale id (skin removed from the manifest, pet swapped) reads as "default"
732
+ * rather than pinning a track the browser half cannot resolve.
733
+ */
734
+ private persistedSkin(entry: NonNullable<PetRegistry['entries'][number]>): string | undefined {
735
+ const stored = this.ledger.petSkin(entry.id)
736
+ if (stored === undefined) return undefined
737
+ return entry.frames2d?.skins?.some(skin => skin.id === stored) === true ? stored : undefined
738
+ }
739
+
740
+ /**
741
+ * RPC: select the current pet's frames2d skin (`undefined` restores the
742
+ * pet's default look). The choice is stored per pet, so every later state
743
+ * view (reload, client restart, pet re-selection) serves it back.
744
+ */
745
+ async setSkin(skin: string | undefined): Promise<{ ok: true; skin?: string } | { ok: false; error: string }> {
746
+ const entry = this.activeEntry()
747
+ const declared = entry.frames2d?.skins ?? []
748
+ if (skin === undefined) {
749
+ this.ledger.setPetSkin(entry.id, undefined)
750
+ this.flush()
751
+ return { ok: true }
752
+ }
753
+ if (!declared.some(candidate => candidate.id === skin)) return { ok: false, error: 'unknown-skin' }
754
+ this.ledger.setPetSkin(entry.id, skin)
755
+ this.flush()
756
+ return { ok: true, skin }
757
+ }
758
+
712
759
  /** RPC: update display config (size / position). Values are clamped to whole pixels. */
713
760
  async setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }> {
714
761
  const next = { ...this.ledger.snapshot.display, ...patch }
@@ -839,6 +886,7 @@ export class PetService extends Service {
839
886
  const announcement = this.announcement !== undefined && announcementFresh(this.announcement, Date.now())
840
887
  ? this.announcement
841
888
  : undefined
889
+ const skin = this.persistedSkin(entry)
842
890
  return {
843
891
  animation: snapshot.animation,
844
892
  ...(snapshot.bubble === undefined ? {} : { bubble: snapshot.bubble }),
@@ -855,6 +903,7 @@ export class PetService extends Service {
855
903
  description: entry.description,
856
904
  },
857
905
  name: this.petName(),
906
+ ...(skin === undefined ? {} : { skin }),
858
907
  treats: {
859
908
  stocked: this.ledger.snapshot.treats.treats,
860
909
  max: this.ledger.treatMax,