@linxin666/dsh-pet 0.3.20 → 0.3.22

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 (54) hide show
  1. package/lib/client.js +129 -27
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +99 -4
  4. package/lib/types/client/PetSprite.d.ts.map +1 -1
  5. package/lib/types/client/PetSprite.js +5 -1
  6. package/lib/types/client/gameplay-hud.d.ts +15 -0
  7. package/lib/types/client/gameplay-hud.d.ts.map +1 -1
  8. package/lib/types/client/gameplay-hud.js +48 -6
  9. package/lib/types/client/index.d.ts.map +1 -1
  10. package/lib/types/client/index.js +21 -13
  11. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -1
  12. package/lib/types/client/renderers/Frames2dVisualMount.js +5 -0
  13. package/lib/types/client/renderers/frames2d.d.ts +5 -4
  14. package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
  15. package/lib/types/client/renderers/frames2d.js +27 -18
  16. package/lib/types/client/work-tick-gate.d.ts +40 -0
  17. package/lib/types/client/work-tick-gate.d.ts.map +1 -0
  18. package/lib/types/client/work-tick-gate.js +49 -0
  19. package/lib/types/gameplay.d.ts +4 -0
  20. package/lib/types/gameplay.d.ts.map +1 -1
  21. package/lib/types/gameplay.js +11 -2
  22. package/lib/types/ledger.d.ts +8 -0
  23. package/lib/types/ledger.d.ts.map +1 -1
  24. package/lib/types/ledger.js +25 -0
  25. package/lib/types/persist.d.ts +6 -0
  26. package/lib/types/persist.d.ts.map +1 -1
  27. package/lib/types/persist.js +25 -1
  28. package/lib/types/routes.d.ts.map +1 -1
  29. package/lib/types/routes.js +6 -0
  30. package/lib/types/service.d.ts +24 -0
  31. package/lib/types/service.d.ts.map +1 -1
  32. package/lib/types/service.js +32 -0
  33. package/package.json +1 -1
  34. package/src/client/PetDockEntry.test.tsx +1 -0
  35. package/src/client/PetSprite.test.tsx +62 -0
  36. package/src/client/PetSprite.tsx +5 -1
  37. package/src/client/gameplay-hud.test.tsx +165 -2
  38. package/src/client/gameplay-hud.tsx +62 -6
  39. package/src/client/index.ts +23 -13
  40. package/src/client/pet.module.css +1 -1
  41. package/src/client/renderers/Frames2dVisualMount.test.tsx +73 -0
  42. package/src/client/renderers/Frames2dVisualMount.tsx +4 -0
  43. package/src/client/renderers/frames2d.test.ts +38 -6
  44. package/src/client/renderers/frames2d.ts +27 -19
  45. package/src/client/work-tick-gate.test.ts +53 -0
  46. package/src/client/work-tick-gate.ts +63 -0
  47. package/src/gameplay.test.ts +36 -0
  48. package/src/gameplay.ts +14 -2
  49. package/src/ledger.test.ts +19 -0
  50. package/src/ledger.ts +24 -0
  51. package/src/persist.test.ts +13 -0
  52. package/src/persist.ts +29 -1
  53. package/src/routes.ts +5 -0
  54. package/src/service.ts +38 -0
@@ -12,10 +12,11 @@
12
12
  * Frame presentation has two modes picked once at mount by capability
13
13
  * probing:
14
14
  * - Canvas bitmap buffer (default where createImageBitmap/fetch/2D context
15
- * exist): every frame is decoded exactly once into an ImageBitmap during
16
- * the warm pass and drawn onto one <canvas> - steady-state playback issues
17
- * zero DOM mutations and zero re-decodes (measured hotspot: swapping
18
- * <img>.src per frame drove image decode + invalidation every tick).
15
+ * exist): frames are decoded once into an ImageBitmap on demand, with a
16
+ * bounded look-ahead window over the playing track, and drawn onto one
17
+ * <canvas> - steady-state playback issues zero DOM mutations and zero
18
+ * re-decodes (measured hotspot: swapping <img>.src per frame drove image
19
+ * decode + invalidation every tick).
19
20
  * - Classic <img> fallback (jsdom/tests or missing APIs): identical to the
20
21
  * historical behavior - cache-warm Image elements plus guarded src swaps,
21
22
  * so environments without modern decoding keep working unchanged.
@@ -283,6 +284,27 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
283
284
  return job
284
285
  }
285
286
 
287
+ /**
288
+ * Bounded look-ahead window: decode only the frames playback is about to
289
+ * need. The historical warm pass decoded every frame of every track up
290
+ * front - a shipped pet carries ~1.1k 512x683 frames, so that pass pulls
291
+ * tens of megabytes and retains every decoded bitmap for the life of the
292
+ * page. Prefetching the playing track's next frames keeps loops and phase
293
+ * switches warm while unplayed tracks (and every unselected skin's
294
+ * frames) stay on demand, where playback jumps the queue anyway.
295
+ */
296
+ const PREFETCH_AHEAD = 12
297
+
298
+ const prefetchAhead = (trackId: string, index: number): void => {
299
+ const def = config.tracks[trackId]
300
+ if (def === undefined) return
301
+ const end = Math.min(def.frames.length, index + 1 + PREFETCH_AHEAD)
302
+ for (let ahead = index + 1; ahead < end; ahead += 1) {
303
+ const url = def.frames[ahead]
304
+ if (url !== undefined) void loadFrame(url)
305
+ }
306
+ }
307
+
286
308
  let disposed = false
287
309
  let timer: ReturnType<typeof setTimeout> | undefined
288
310
  let watchdog: ReturnType<typeof setInterval> | undefined
@@ -327,6 +349,7 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
327
349
  const def = config.tracks[trackId]
328
350
  const url = def?.frames[index]
329
351
  if (url === undefined) return
352
+ prefetchAhead(trackId, index)
330
353
  if (img !== null) {
331
354
  if (img.getAttribute('src') !== url) img.src = url
332
355
  return
@@ -400,21 +423,6 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
400
423
  }, WATCHDOG_MS)
401
424
  }
402
425
 
403
- // Warm pass: decode every frame up front (tiny same-origin webp files)
404
- // so loops and phase switches never wait on a first decode - same intent
405
- // as the historical Image-cache warm loop, now feeding the decode cache.
406
- // Phase-reachable tracks enqueue first so early switches never trail the
407
- // full warm backlog; demand loads jump the queue regardless.
408
- const warmTrackIds: string[] = [
409
- ...new Set([...Object.values(config.phases), config.phases.idle]),
410
- ]
411
- for (const warmTrack of [...warmTrackIds, ...Object.keys(config.tracks)].map(
412
- (id) => config.tracks[id],
413
- )) {
414
- if (warmTrack === undefined) continue
415
- for (const warmUrl of warmTrack.frames) void loadFrame(warmUrl)
416
- }
417
-
418
426
  play(track)
419
427
 
420
428
  let disposedOnce = false
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Work-tick gate tests: the window follows the configured cadence (clamped to
3
+ * the manifest bounds), one adjudication is admitted per window, and entering
4
+ * work mode re-arms the gate.
5
+ */
6
+ import { describe, expect, it } from 'vitest'
7
+ import { DEFAULT_WORK_TICK_MS, createWorkTickGate, workTickWindowMs } from './work-tick-gate.ts'
8
+
9
+ describe('workTickWindowMs', () => {
10
+ it('follows the configured cadence', () => {
11
+ expect(workTickWindowMs(2_000)).toBe(2_000)
12
+ expect(workTickWindowMs(10_000)).toBe(10_000)
13
+ expect(workTickWindowMs(60_000)).toBe(60_000)
14
+ })
15
+
16
+ it('clamps to the manifest bounds and defaults when absent', () => {
17
+ expect(workTickWindowMs(500)).toBe(1_000)
18
+ expect(workTickWindowMs(120_000)).toBe(60_000)
19
+ expect(workTickWindowMs(undefined)).toBe(DEFAULT_WORK_TICK_MS)
20
+ expect(workTickWindowMs(Number.NaN)).toBe(DEFAULT_WORK_TICK_MS)
21
+ })
22
+ })
23
+
24
+ describe('createWorkTickGate', () => {
25
+ it('admits one adjudication per configured window', () => {
26
+ let now = 1_000_000
27
+ const gate = createWorkTickGate(() => now)
28
+ expect(gate.allow(2_000)).toBe(true)
29
+ now += 1_999
30
+ expect(gate.allow(2_000)).toBe(false)
31
+ now += 1
32
+ expect(gate.allow(2_000)).toBe(true)
33
+ })
34
+
35
+ it('does not downgrade a pet whose cadence is shorter than the default', () => {
36
+ let now = 1_000_000
37
+ const gate = createWorkTickGate(() => now)
38
+ expect(gate.allow(2_000)).toBe(true)
39
+ for (let tick = 0; tick < 5; tick++) {
40
+ now += 2_000
41
+ expect(gate.allow(2_000)).toBe(true)
42
+ }
43
+ })
44
+
45
+ it('arms immediately after a reset (work mode entry)', () => {
46
+ let now = 1_000_000
47
+ const gate = createWorkTickGate(() => now)
48
+ expect(gate.allow(10_000)).toBe(true)
49
+ expect(gate.allow(10_000)).toBe(false)
50
+ gate.reset()
51
+ expect(gate.allow(10_000)).toBe(true)
52
+ })
53
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Page-wide work-tick gate for the pet's work mode. Hot reloads can leave
3
+ * several GameplayHud instances alive, each running its own work interval;
4
+ * without a shared gate every interval would call workTick and each stale call
5
+ * re-rolls, re-grants treats and re-plays the success/fail track, so the
6
+ * outcome appears to play several times per window. The gate accepts the first
7
+ * adjudication of a window and silently suppresses the duplicates that follow.
8
+ *
9
+ * The window is the active pet's own `gameplay.work.tickMs`, not a constant: a
10
+ * pet configured with a shorter cadence must actually adjudicate that often,
11
+ * while a fixed window would downgrade it without saying so (#1494).
12
+ * @module @linxin666/dsh-pet/client/work-tick-gate
13
+ */
14
+
15
+ /** Window used when the active pet declares no work cadence. */
16
+ export const DEFAULT_WORK_TICK_MS = 10_000
17
+
18
+ /** Manifest bounds for `gameplay.work.tickMs` (src/gameplay.ts). */
19
+ const MIN_WORK_TICK_MS = 1_000
20
+ const MAX_WORK_TICK_MS = 60_000
21
+
22
+ /**
23
+ * The gate window for one pet: its configured cadence, clamped to the manifest's
24
+ * own bounds so a malformed registry entry cannot disable or flood the gate.
25
+ * @param tickMs - the active definition's `gameplay.work.tickMs`, when it has one.
26
+ * @returns the window in milliseconds.
27
+ */
28
+ export function workTickWindowMs(tickMs: number | undefined): number {
29
+ if (typeof tickMs !== 'number' || !Number.isFinite(tickMs)) return DEFAULT_WORK_TICK_MS
30
+ return Math.min(MAX_WORK_TICK_MS, Math.max(MIN_WORK_TICK_MS, tickMs))
31
+ }
32
+
33
+ /** The shared work-tick gate. */
34
+ export interface WorkTickGate {
35
+ /**
36
+ * Admit one adjudication for the current window.
37
+ * @param tickMs - the active pet's configured work cadence.
38
+ * @returns true when this call may adjudicate, false for a duplicate.
39
+ */
40
+ allow: (tickMs: number | undefined) => boolean
41
+ /** Forget the last adjudication (used when work mode is entered). */
42
+ reset: () => void
43
+ }
44
+
45
+ /**
46
+ * Create a gate.
47
+ * @param now - the clock, injectable so tests control the window.
48
+ * @returns the gate over that clock.
49
+ */
50
+ export function createWorkTickGate(now: () => number = Date.now): WorkTickGate {
51
+ let lastAdjudicatedAt = 0
52
+ return {
53
+ allow: (tickMs) => {
54
+ const at = now()
55
+ if (at - lastAdjudicatedAt < workTickWindowMs(tickMs)) return false
56
+ lastAdjudicatedAt = at
57
+ return true
58
+ },
59
+ reset: () => {
60
+ lastAdjudicatedAt = 0
61
+ },
62
+ }
63
+ }
@@ -185,6 +185,42 @@ describe('gameplay engine', () => {
185
185
  expect(state.settledAt).toBe(600_000)
186
186
  })
187
187
 
188
+ it('preserves remainder carry across frequent polling intervals for passive income and sleep restore (#1478)', () => {
189
+ const state = initialGameplayState(def, 0)
190
+ state.stats.energy = 20
191
+ state.mode = 'sleep'
192
+ // Simulate 30 frequent settles every 2,000 ms (total 60,000 ms = 1 min).
193
+ // Passive income interval: 60,000 ms -> should grant 1 coin on 30th settle.
194
+ // Sleep restore interval: 30,000 ms (+4 energy each) -> should grant on 15th and 30th settle.
195
+ let now = 0
196
+ for (let i = 1; i <= 30; i++) {
197
+ now += 2_000
198
+ settleGameplay(state, def, now, { sessionActive: true })
199
+ if (i < 15) {
200
+ expect(state.stats.energy).toBeCloseTo(20 - (i * 2 / 60) * 0.25, 4)
201
+ expect(state.currencies.coins ?? 0).toBe(0)
202
+ } else if (i === 15) {
203
+ // At 30,000 ms: first sleep tick hits (+4 energy)
204
+ expect(state.stats.energy).toBeCloseTo(20 - 0.125 + 4, 4)
205
+ expect(state.currencies.coins ?? 0).toBe(0)
206
+ } else if (i < 30) {
207
+ expect(state.stats.energy).toBeCloseTo(20 - (i * 2 / 60) * 0.25 + 4, 4)
208
+ expect(state.currencies.coins ?? 0).toBe(0)
209
+ }
210
+ }
211
+ // At 60,000 ms (30th tick): second sleep tick hits (+4 energy = +8 total), passive income hits (+1 coin)
212
+ expect(state.stats.energy).toBeCloseTo(20 - 0.25 + 8, 4)
213
+ expect(state.currencies.coins).toBe(1)
214
+ expect(state.incomeCarryMs).toBe(0)
215
+ expect(state.restoreCarryMs).toBe(0)
216
+
217
+ // Leaving sleep mode clears restoreCarryMs
218
+ state.restoreCarryMs = 10_000
219
+ state.mode = null
220
+ settleGameplay(state, def, now + 1_000, { sessionActive: true })
221
+ expect(state.restoreCarryMs).toBe(0)
222
+ })
223
+
188
224
  it('applies the working decay variant while working and idle decay without a session', () => {
189
225
  const state = initialGameplayState(def, 0)
190
226
  state.mode = 'work'
package/src/gameplay.ts CHANGED
@@ -572,6 +572,10 @@ export interface PetGameplayState {
572
572
  mode: 'work' | 'sleep' | null
573
573
  /** Epoch ms of the last lazy settle. */
574
574
  settledAt: number
575
+ /** Accumulated remainder ms towards the next passive income tick. */
576
+ incomeCarryMs?: number
577
+ /** Accumulated remainder ms towards the next sleep restore tick. */
578
+ restoreCarryMs?: number
575
579
  }
576
580
 
577
581
  /** Fresh state for one pet: stats at their initial (default max), no currency. */
@@ -624,7 +628,10 @@ export function settleGameplay(
624
628
  }
625
629
  }
626
630
  if (manifest.passiveIncome !== undefined) {
627
- const ticks = Math.floor(elapsedMs / manifest.passiveIncome.intervalMs)
631
+ const incomeElapsed = elapsedMs + (state.incomeCarryMs ?? 0)
632
+ const interval = manifest.passiveIncome.intervalMs
633
+ const ticks = Math.floor(incomeElapsed / interval)
634
+ state.incomeCarryMs = incomeElapsed % interval
628
635
  if (ticks > 0) {
629
636
  const currency = manifest.passiveIncome.currency
630
637
  state.currencies[currency] = (state.currencies[currency] ?? 0) + ticks * manifest.passiveIncome.amount
@@ -632,12 +639,17 @@ export function settleGameplay(
632
639
  }
633
640
  }
634
641
  if (state.mode === 'sleep' && manifest.sleep !== undefined) {
635
- const ticks = Math.floor(elapsedMs / manifest.sleep.restore.intervalMs)
642
+ const restoreElapsed = elapsedMs + (state.restoreCarryMs ?? 0)
643
+ const interval = manifest.sleep.restore.intervalMs
644
+ const ticks = Math.floor(restoreElapsed / interval)
645
+ state.restoreCarryMs = restoreElapsed % interval
636
646
  if (ticks > 0) {
637
647
  const stat = manifest.sleep.restore.stat
638
648
  state.stats[stat] = (state.stats[stat] ?? 0) + ticks * manifest.sleep.restore.amount
639
649
  changed = true
640
650
  }
651
+ } else {
652
+ state.restoreCarryMs = 0
641
653
  }
642
654
  state.settledAt = now
643
655
  clampGameplay(state, manifest)
@@ -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,