@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
@@ -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
@@ -175,6 +175,12 @@ export interface PetStateView {
175
175
  }
176
176
  /** The selected pet's display name (user rename or manifest default). */
177
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
178
184
  /** Treat (小鱼干) stock snapshot. */
179
185
  treats: {
180
186
  /** Stocked treats now. */
@@ -720,6 +726,36 @@ export class PetService extends Service {
720
726
  return { ok: true, display: this.ledger.snapshot.display }
721
727
  }
722
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
+
723
759
  /** RPC: update display config (size / position). Values are clamped to whole pixels. */
724
760
  async setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }> {
725
761
  const next = { ...this.ledger.snapshot.display, ...patch }
@@ -850,6 +886,7 @@ export class PetService extends Service {
850
886
  const announcement = this.announcement !== undefined && announcementFresh(this.announcement, Date.now())
851
887
  ? this.announcement
852
888
  : undefined
889
+ const skin = this.persistedSkin(entry)
853
890
  return {
854
891
  animation: snapshot.animation,
855
892
  ...(snapshot.bubble === undefined ? {} : { bubble: snapshot.bubble }),
@@ -866,6 +903,7 @@ export class PetService extends Service {
866
903
  description: entry.description,
867
904
  },
868
905
  name: this.petName(),
906
+ ...(skin === undefined ? {} : { skin }),
869
907
  treats: {
870
908
  stocked: this.ledger.snapshot.treats.treats,
871
909
  max: this.ledger.treatMax,