@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.
- package/lib/client.js +129 -27
- package/lib/client.js.map +1 -1
- package/lib/index.js +99 -4
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +5 -1
- 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 +27 -18
- 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/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 +32 -0
- package/package.json +1 -1
- package/src/client/PetDockEntry.test.tsx +1 -0
- package/src/client/PetSprite.test.tsx +62 -0
- package/src/client/PetSprite.tsx +5 -1
- 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 +38 -6
- package/src/client/renderers/frames2d.ts +27 -19
- package/src/client/work-tick-gate.test.ts +53 -0
- package/src/client/work-tick-gate.ts +63 -0
- 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 +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):
|
|
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.
|
|
@@ -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
|
+
}
|
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'
|
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
|
|
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
|
|
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)
|
package/src/ledger.test.ts
CHANGED
|
@@ -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.
|
package/src/persist.test.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|