@linxin666/dsh-pet 0.3.2 → 0.3.4
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/README.i18n.yaml +2 -2
- package/README.md +25 -7
- package/README.zh.md +25 -7
- package/assets/ouo-neko/pet.json +31 -0
- package/assets/ouo-neko/previews/failed.gif +0 -0
- package/assets/ouo-neko/previews/idle.gif +0 -0
- package/assets/ouo-neko/previews/jumping.gif +0 -0
- package/assets/ouo-neko/previews/review.gif +0 -0
- package/assets/ouo-neko/previews/running-left.gif +0 -0
- package/assets/ouo-neko/previews/running-right.gif +0 -0
- package/assets/ouo-neko/previews/running.gif +0 -0
- package/assets/ouo-neko/previews/waiting.gif +0 -0
- package/assets/ouo-neko/previews/waving.gif +0 -0
- package/assets/ouo-neko/spritesheet.webp +0 -0
- package/contracts/pet-manifest-v2.schema.json +102 -1
- package/lib/client.js +830 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +1226 -24
- package/lib/types/client/PetDockEntry.d.ts +3 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +18 -2
- package/lib/types/client/PetSprite.d.ts +23 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +34 -29
- package/lib/types/client/PluginSettingsCard.d.ts +2 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +2 -2
- package/lib/types/client/drag-stream.d.ts +19 -0
- package/lib/types/client/drag-stream.d.ts.map +1 -0
- package/lib/types/client/drag-stream.js +26 -0
- package/lib/types/client/gameplay-hud.d.ts +50 -0
- package/lib/types/client/gameplay-hud.d.ts.map +1 -0
- package/lib/types/client/gameplay-hud.js +274 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +16 -0
- package/lib/types/client/locales.d.ts +34 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -0
- package/lib/types/client/pet-store.d.ts +7 -1
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +4 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
- package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
- package/lib/types/client/renderers/frames2d.d.ts +36 -0
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
- package/lib/types/client/renderers/frames2d.js +196 -0
- package/lib/types/client/settings-form.d.ts +1 -1
- package/lib/types/client/settings-form.js +1 -1
- package/lib/types/client/telemetry.d.ts +27 -0
- package/lib/types/client/telemetry.d.ts.map +1 -0
- package/lib/types/client/telemetry.js +97 -0
- package/lib/types/defaults.d.ts +6 -0
- package/lib/types/defaults.d.ts.map +1 -0
- package/lib/types/defaults.js +5 -0
- package/lib/types/dsh-home.d.ts.map +1 -1
- package/lib/types/dsh-home.js +9 -3
- package/lib/types/gameplay.d.ts +182 -0
- package/lib/types/gameplay.d.ts.map +1 -0
- package/lib/types/gameplay.js +618 -0
- package/lib/types/http.d.ts +1 -1
- package/lib/types/http.js +1 -1
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.js +1 -1
- package/lib/types/ledger.d.ts +14 -0
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +30 -0
- package/lib/types/loopback.d.ts +1 -1
- package/lib/types/loopback.js +1 -1
- package/lib/types/manifest-v2.d.ts +38 -1
- package/lib/types/manifest-v2.d.ts.map +1 -1
- package/lib/types/manifest-v2.js +182 -2
- package/lib/types/mount-once.d.ts +1 -1
- package/lib/types/mount-once.js +2 -2
- package/lib/types/persist.d.ts +4 -4
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +41 -4
- package/lib/types/registry.d.ts +29 -0
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +170 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +22 -0
- package/lib/types/service.d.ts +56 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +181 -0
- package/package.json +6 -3
- package/src/client/PetDockEntry.test.tsx +6 -0
- package/src/client/PetDockEntry.tsx +32 -1
- package/src/client/PetSprite.test.tsx +36 -6
- package/src/client/PetSprite.tsx +42 -21
- package/src/client/PluginSettingsCard.tsx +4 -2
- package/src/client/drag-stream.ts +35 -0
- package/src/client/gameplay-hud.test.tsx +338 -0
- package/src/client/gameplay-hud.tsx +403 -0
- package/src/client/index.ts +23 -2
- package/src/client/locales.ts +36 -0
- package/src/client/pet-store.ts +10 -1
- package/src/client/pet.module.css +192 -29
- package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
- package/src/client/renderers/PetRendererSwitch.tsx +28 -1
- package/src/client/renderers/frames2d.test.ts +137 -0
- package/src/client/renderers/frames2d.ts +216 -0
- package/src/client/settings-card.module.css +5 -0
- package/src/client/settings-form.ts +1 -1
- package/src/client/telemetry.ts +105 -0
- package/src/defaults.ts +7 -0
- package/src/dsh-home.ts +9 -3
- package/src/gameplay.test.ts +239 -0
- package/src/gameplay.ts +692 -0
- package/src/http.ts +1 -1
- package/src/index.ts +1 -1
- package/src/ledger.test.ts +16 -0
- package/src/ledger.ts +31 -0
- package/src/loopback.ts +1 -1
- package/src/manifest-v2.test.ts +130 -1
- package/src/manifest-v2.ts +181 -2
- package/src/mount-once.ts +2 -2
- package/src/persist.test.ts +3 -0
- package/src/persist.ts +43 -6
- package/src/registry.test.ts +168 -1
- package/src/registry.ts +199 -1
- package/src/routes.ts +19 -0
- package/src/service.ts +208 -0
package/src/http.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* edit this shared source and re-run the sync instead of editing a copy.
|
|
12
12
|
* Consumer code is migrated onto it in follow-up waves; no call site changes
|
|
13
13
|
* belong in the same change as its introduction.
|
|
14
|
-
* @module dsh-web-
|
|
14
|
+
* @module dsh-web-shared/host/http
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* the package assets, the hatch-pet custom pets directory, and composed
|
|
7
7
|
* config entries; adding a pet means dropping a manifest + atlas into one of
|
|
8
8
|
* those sources, never touching host or client code. Install via
|
|
9
|
-
* 'dsh plugin --profile web add link:<dsh-web
|
|
9
|
+
* 'dsh plugin --profile web add link:<dsh-web>/packages/dsh-pet'; the
|
|
10
10
|
* cordis.patch.yml inserts this plugin row.
|
|
11
11
|
* @module @linxin666/dsh-pet
|
|
12
12
|
*/
|
package/src/ledger.test.ts
CHANGED
|
@@ -43,6 +43,22 @@ describe('PetLedger', () => {
|
|
|
43
43
|
expect(ledger.takeDirty()).toBe(false)
|
|
44
44
|
})
|
|
45
45
|
|
|
46
|
+
it('grants and spends gameplay treats through the shared stock, capped', () => {
|
|
47
|
+
const ledger = new PetLedger(emptyPersist())
|
|
48
|
+
expect(ledger.grantTreats(3)).toBe(true)
|
|
49
|
+
expect(ledger.snapshot.treats.treats).toBe(3)
|
|
50
|
+
// 3 + 19 exceeds the cap: the grant lands capped and counts as changed.
|
|
51
|
+
expect(ledger.grantTreats(19)).toBe(true)
|
|
52
|
+
expect(ledger.snapshot.treats.treats).toBe(defaultTreatConfig.maxTreats)
|
|
53
|
+
// Already at the cap: no change, no dirty write.
|
|
54
|
+
expect(ledger.grantTreats(5)).toBe(false)
|
|
55
|
+
expect(ledger.spendTreats(5)).toEqual({ ok: true })
|
|
56
|
+
expect(ledger.snapshot.treats.treats).toBe(defaultTreatConfig.maxTreats - 5)
|
|
57
|
+
expect(ledger.spendTreats(16)).toEqual({ ok: false })
|
|
58
|
+
expect(ledger.snapshot.treats.treats).toBe(defaultTreatConfig.maxTreats - 5)
|
|
59
|
+
expect(ledger.grantTreats(0)).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
46
62
|
it('feed consumes a treat and applies the feed reward', () => {
|
|
47
63
|
const ledger = new PetLedger(emptyPersist())
|
|
48
64
|
const n = 1_000_000
|
package/src/ledger.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from './treats.ts'
|
|
25
25
|
import { RemarkPicker, type PetRemarks } from './remarks.ts'
|
|
26
26
|
import type { PetDisplayConfig, PetPersist } from './persist.ts'
|
|
27
|
+
import type { PetGameplayState } from './gameplay.ts'
|
|
27
28
|
|
|
28
29
|
/** Tuning overrides for the affinity economy. */
|
|
29
30
|
export interface LedgerConfig {
|
|
@@ -109,6 +110,12 @@ export class PetLedger {
|
|
|
109
110
|
this.dirty = true
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
/** Replace one pet's gameplay state (validation/clamping stays a caller concern). */
|
|
114
|
+
setGameplay(petId: string, gameplay: PetGameplayState): void {
|
|
115
|
+
this.current = { ...this.current, gameplay: { ...this.current.gameplay, [petId]: gameplay } }
|
|
116
|
+
this.dirty = true
|
|
117
|
+
}
|
|
118
|
+
|
|
112
119
|
/** Replace one pet's display name (validation stays a caller concern). */
|
|
113
120
|
setPetName(petId: string, name: string): void {
|
|
114
121
|
this.current = { ...this.current, names: { ...this.current.names, [petId]: name } }
|
|
@@ -142,6 +149,30 @@ export class PetLedger {
|
|
|
142
149
|
return true
|
|
143
150
|
}
|
|
144
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Grant gameplay treats into the shared stock (capped by the treat cap).
|
|
154
|
+
* This is the unified gameplay currency (wallet removed): work rewards,
|
|
155
|
+
* passive income and lottery prizes land here so one balance feeds the
|
|
156
|
+
* shop and the feeding economy. Returns true when the snapshot changed.
|
|
157
|
+
*/
|
|
158
|
+
grantTreats(amount: number): boolean {
|
|
159
|
+
if (amount <= 0) return false
|
|
160
|
+
const capped = Math.min(this.treatConfig.maxTreats, this.current.treats.treats + amount)
|
|
161
|
+
if (capped === this.current.treats.treats) return false
|
|
162
|
+
this.current = { ...this.current, treats: { ...this.current.treats, treats: capped } }
|
|
163
|
+
this.dirty = true
|
|
164
|
+
return true
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Spend gameplay treats from the shared stock; refuses when unaffordable. */
|
|
168
|
+
spendTreats(amount: number): { ok: boolean } {
|
|
169
|
+
const stock = this.current.treats.treats
|
|
170
|
+
if (amount <= 0 || stock < amount) return { ok: false }
|
|
171
|
+
this.current = { ...this.current, treats: { ...this.current.treats, treats: stock - amount } }
|
|
172
|
+
this.dirty = true
|
|
173
|
+
return { ok: true }
|
|
174
|
+
}
|
|
175
|
+
|
|
145
176
|
/**
|
|
146
177
|
* Award the completed-turn reward once per session+turn (idempotent) and
|
|
147
178
|
* run the treat settlement that work output feeds. Returns true when the
|
package/src/loopback.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
9
9
|
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
10
10
|
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
11
|
-
* @module dsh-web-
|
|
11
|
+
* @module dsh-web-shared/host/loopback
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { IncomingMessage } from 'node:http'
|
package/src/manifest-v2.test.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import { readFileSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import {
|
|
5
|
+
KNOWN_FRAMES2D,
|
|
6
|
+
KNOWN_FRAMES2D_TRACK,
|
|
5
7
|
KNOWN_LIVE2D,
|
|
6
8
|
KNOWN_SPRITE2D,
|
|
7
9
|
KNOWN_TOP_LEVEL,
|
|
@@ -217,7 +219,130 @@ describe('parsePetManifest v2 fail-closed validation', () => {
|
|
|
217
219
|
describe('constants', () => {
|
|
218
220
|
it('locks the manifest version and renderer kinds', () => {
|
|
219
221
|
expect(PET_MANIFEST_V2).toBe(2)
|
|
220
|
-
expect(PET_RENDERER_KINDS).toEqual(['sprite2d', 'live2d'])
|
|
222
|
+
expect(PET_RENDERER_KINDS).toEqual(['sprite2d', 'live2d', 'frames2d'])
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
/** Minimal valid v2 frames2d manifest (miku-style directory tracks). */
|
|
227
|
+
function v2Frames2d(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
228
|
+
return {
|
|
229
|
+
petManifestVersion: 2,
|
|
230
|
+
id: 'miku',
|
|
231
|
+
displayName: 'Miku',
|
|
232
|
+
license: 'MIT',
|
|
233
|
+
renderer: 'frames2d',
|
|
234
|
+
frames2d: {
|
|
235
|
+
dir: 'thumb',
|
|
236
|
+
tracks: {
|
|
237
|
+
idle: { loop: true },
|
|
238
|
+
happy: { frames: ['happy_1_300.webp', 'happy_2_320.webp'], frameMs: [300, 320], loop: false, fallback: 'idle' },
|
|
239
|
+
},
|
|
240
|
+
phases: { idle: 'idle', done: 'happy' },
|
|
241
|
+
},
|
|
242
|
+
...overrides,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
describe('parsePetManifest v2 frames2d', () => {
|
|
247
|
+
it('accepts a minimal valid frames2d manifest', () => {
|
|
248
|
+
const result = parsePetManifest(v2Frames2d(), 'mem')
|
|
249
|
+
expect(result.ok).toBe(true)
|
|
250
|
+
if (!result.ok) return
|
|
251
|
+
expect(result.manifest.renderer).toBe('frames2d')
|
|
252
|
+
expect(result.manifest.frames2d?.dir).toBe('thumb')
|
|
253
|
+
expect(Object.keys(result.manifest.frames2d?.tracks ?? {}).sort()).toEqual(['happy', 'idle'])
|
|
254
|
+
expect(result.manifest.frames2d?.phases).toEqual({ idle: 'idle', done: 'happy' })
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('requires the frames2d block, tracks and phases.idle', () => {
|
|
258
|
+
expect(parsePetManifest({ ...v2Frames2d(), frames2d: undefined }, 'mem').ok).toBe(false)
|
|
259
|
+
const noTracks = v2Frames2d()
|
|
260
|
+
delete (noTracks.frames2d as Record<string, unknown>).tracks
|
|
261
|
+
expect(parsePetManifest(noTracks, 'mem').ok).toBe(false)
|
|
262
|
+
const noPhases = v2Frames2d()
|
|
263
|
+
delete (noPhases.frames2d as Record<string, unknown>).phases
|
|
264
|
+
expect(parsePetManifest(noPhases, 'mem').ok).toBe(false)
|
|
265
|
+
const noIdle = v2Frames2d({ })
|
|
266
|
+
;(noIdle.frames2d as Record<string, unknown>).phases = { done: 'happy' }
|
|
267
|
+
expect(parsePetManifest(noIdle, 'mem').ok).toBe(false)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('rejects mixing renderer blocks', () => {
|
|
271
|
+
const mixed = v2Frames2d()
|
|
272
|
+
;(mixed as Record<string, unknown>).sprite2d = { spritesheetPath: 'x.webp' }
|
|
273
|
+
expect(parsePetManifest(mixed, 'mem').ok).toBe(false)
|
|
274
|
+
const spriteWithFrames = v2Sprite()
|
|
275
|
+
;(spriteWithFrames as Record<string, unknown>).frames2d = (v2Frames2d() as { frames2d: unknown }).frames2d
|
|
276
|
+
expect(parsePetManifest(spriteWithFrames, 'mem').ok).toBe(false)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('rejects unknown block and track fields', () => {
|
|
280
|
+
const badBlock = v2Frames2d()
|
|
281
|
+
;(badBlock.frames2d as Record<string, unknown>).blink = { state: 'idle' }
|
|
282
|
+
expect(parsePetManifest(badBlock, 'mem').ok).toBe(false)
|
|
283
|
+
const badTrack = v2Frames2d()
|
|
284
|
+
;((badTrack.frames2d as Record<string, unknown>).tracks as Record<string, unknown>).idle = { loop: true, path: 'thumb/idle' }
|
|
285
|
+
expect(parsePetManifest(badTrack, 'mem').ok).toBe(false)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('rejects bad track names, dangling phases and dangling fallbacks', () => {
|
|
289
|
+
const badName = v2Frames2d()
|
|
290
|
+
;((badName.frames2d as Record<string, unknown>).tracks as Record<string, unknown>)['Bad Name'] = {}
|
|
291
|
+
expect(parsePetManifest(badName, 'mem').ok).toBe(false)
|
|
292
|
+
const badPhase = v2Frames2d()
|
|
293
|
+
;((badPhase.frames2d as Record<string, unknown>).phases as Record<string, unknown>).done = 'ghost'
|
|
294
|
+
expect(parsePetManifest(badPhase, 'mem').ok).toBe(false)
|
|
295
|
+
const badFallback = v2Frames2d()
|
|
296
|
+
;(((badFallback.frames2d as Record<string, unknown>).tracks as Record<string, unknown>).happy as Record<string, unknown>).fallback = 'ghost'
|
|
297
|
+
expect(parsePetManifest(badFallback, 'mem').ok).toBe(false)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('rejects unknown activity phases in frames2d.phases', () => {
|
|
301
|
+
const bad = v2Frames2d()
|
|
302
|
+
;((bad.frames2d as Record<string, unknown>).phases as Record<string, unknown>).sleeping = 'idle'
|
|
303
|
+
expect(parsePetManifest(bad, 'mem').ok).toBe(false)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('rejects unsafe frame names and non-image extensions', () => {
|
|
307
|
+
const traversal = v2Frames2d()
|
|
308
|
+
;(((traversal.frames2d as Record<string, unknown>).tracks as Record<string, unknown>).happy as Record<string, unknown>).frames = ['../evil.webp']
|
|
309
|
+
expect(parsePetManifest(traversal, 'mem').ok).toBe(false)
|
|
310
|
+
const notImage = v2Frames2d()
|
|
311
|
+
;(((notImage.frames2d as Record<string, unknown>).tracks as Record<string, unknown>).happy as Record<string, unknown>).frames = ['payload.exe']
|
|
312
|
+
expect(parsePetManifest(notImage, 'mem').ok).toBe(false)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('rejects frameMs without frames, length mismatch and out-of-range durations', () => {
|
|
316
|
+
const noFrames = v2Frames2d()
|
|
317
|
+
;((noFrames.frames2d as Record<string, unknown>).tracks as Record<string, unknown>).idle = { frameMs: [100] }
|
|
318
|
+
expect(parsePetManifest(noFrames, 'mem').ok).toBe(false)
|
|
319
|
+
const mismatch = v2Frames2d()
|
|
320
|
+
;(((mismatch.frames2d as Record<string, unknown>).tracks as Record<string, unknown>).happy as Record<string, unknown>).frameMs = [300]
|
|
321
|
+
expect(parsePetManifest(mismatch, 'mem').ok).toBe(false)
|
|
322
|
+
const tooSlow = v2Frames2d()
|
|
323
|
+
;(((tooSlow.frames2d as Record<string, unknown>).tracks as Record<string, unknown>).happy as Record<string, unknown>).frameMs = [300, 9000]
|
|
324
|
+
expect(parsePetManifest(tooSlow, 'mem').ok).toBe(false)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('rejects oversized track and frame counts', () => {
|
|
328
|
+
const manyTracks: Record<string, unknown> = {}
|
|
329
|
+
for (let i = 0; i < 65; i++) manyTracks['t' + i] = { loop: true }
|
|
330
|
+
const big = v2Frames2d()
|
|
331
|
+
;(big.frames2d as Record<string, unknown>).tracks = manyTracks
|
|
332
|
+
;((big.frames2d as Record<string, unknown>).phases as Record<string, unknown>).idle = 't0'
|
|
333
|
+
expect(parsePetManifest(big, 'mem').ok).toBe(false)
|
|
334
|
+
const manyFrames = v2Frames2d()
|
|
335
|
+
;(((manyFrames.frames2d as Record<string, unknown>).tracks as Record<string, unknown>).happy as Record<string, unknown>).frames = Array.from({ length: 65 }, (_, i) => 'f' + i + '.webp')
|
|
336
|
+
expect(parsePetManifest(manyFrames, 'mem').ok).toBe(false)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('rejects an unsafe frames2d.dir and out-of-range defaultFrameMs', () => {
|
|
340
|
+
const badDir = v2Frames2d()
|
|
341
|
+
;(badDir.frames2d as Record<string, unknown>).dir = '../elsewhere'
|
|
342
|
+
expect(parsePetManifest(badDir, 'mem').ok).toBe(false)
|
|
343
|
+
const badMs = v2Frames2d()
|
|
344
|
+
;(badMs.frames2d as Record<string, unknown>).defaultFrameMs = 9000
|
|
345
|
+
expect(parsePetManifest(badMs, 'mem').ok).toBe(false)
|
|
221
346
|
})
|
|
222
347
|
})
|
|
223
348
|
|
|
@@ -242,6 +367,10 @@ describe('schema file drift lock', () => {
|
|
|
242
367
|
it('locks the renderer block allow-lists', () => {
|
|
243
368
|
expect(new Set(Object.keys(schema.properties.sprite2d?.properties ?? {}))).toEqual(KNOWN_SPRITE2D)
|
|
244
369
|
expect(new Set(Object.keys(schema.properties.live2d?.properties ?? {}))).toEqual(KNOWN_LIVE2D)
|
|
370
|
+
expect(new Set(Object.keys(schema.properties.frames2d?.properties ?? {}))).toEqual(KNOWN_FRAMES2D)
|
|
371
|
+
const frames2d = schema.properties.frames2d as { properties?: Record<string, { additionalProperties?: { properties?: Record<string, unknown> } }> }
|
|
372
|
+
const trackProps = frames2d.properties?.tracks?.additionalProperties?.properties ?? {}
|
|
373
|
+
expect(new Set(Object.keys(trackProps))).toEqual(KNOWN_FRAMES2D_TRACK)
|
|
245
374
|
})
|
|
246
375
|
|
|
247
376
|
it('keeps the schema version const in sync', () => {
|
package/src/manifest-v2.ts
CHANGED
|
@@ -25,12 +25,13 @@
|
|
|
25
25
|
|
|
26
26
|
import { isAbsolute } from 'node:path'
|
|
27
27
|
import type { ActivityPhase, PetAnimation } from './state.ts'
|
|
28
|
+
import { parseGameplayManifest, type PetGameplayManifest } from './gameplay.ts'
|
|
28
29
|
|
|
29
30
|
/** Schema version this module validates. */
|
|
30
31
|
export const PET_MANIFEST_V2 = 2 as const
|
|
31
32
|
|
|
32
33
|
/** Renderer kinds the pet center knows how to dispatch (M1 §2). */
|
|
33
|
-
export const PET_RENDERER_KINDS = ['sprite2d', 'live2d'] as const
|
|
34
|
+
export const PET_RENDERER_KINDS = ['sprite2d', 'live2d', 'frames2d'] as const
|
|
34
35
|
export type PetRendererKind = (typeof PET_RENDERER_KINDS)[number]
|
|
35
36
|
|
|
36
37
|
/** The seven ActivityPhase semantics (pet-center owned; M1 §1). */
|
|
@@ -72,6 +73,35 @@ export interface PetManifestLive2d {
|
|
|
72
73
|
lipSync?: boolean
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
/** One frames2d track: an ordered frame sequence plus its rhythm. */
|
|
77
|
+
export interface PetManifestFrames2dTrack {
|
|
78
|
+
/** Explicit ordered frame file names inside <dir>/<track>/; omitted means "list the directory". */
|
|
79
|
+
frames?: string[]
|
|
80
|
+
/** Per-frame durations in ms, same length as frames; wins over filename-encoded ms. */
|
|
81
|
+
frameMs?: number[]
|
|
82
|
+
/** Loop the track (default true); false stops on the last frame, then enters fallback. */
|
|
83
|
+
loop?: boolean
|
|
84
|
+
/** Track entered when a non-loop track ends (default 'idle'). */
|
|
85
|
+
fallback?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* frames2d renderer block: free-form named frame-sequence tracks laid out as
|
|
90
|
+
* <dir>/<track>/<frame>.webp, with phase-direct state selection (the live2d
|
|
91
|
+
* motions precedent). Frame durations resolve frameMs[i] > filename-encoded
|
|
92
|
+
* <base>_<index>_<ms> suffix > defaultFrameMs.
|
|
93
|
+
*/
|
|
94
|
+
export interface PetManifestFrames2d {
|
|
95
|
+
/** Frame root directory relative to the manifest directory (default '.'). */
|
|
96
|
+
dir?: string
|
|
97
|
+
/** Default per-frame duration in ms (default 200). */
|
|
98
|
+
defaultFrameMs?: number
|
|
99
|
+
/** Named tracks (1..64, kebab ids). */
|
|
100
|
+
tracks: Record<string, PetManifestFrames2dTrack>
|
|
101
|
+
/** ActivityPhase -> track name; idle is required, unmapped phases fall back to it. */
|
|
102
|
+
phases: Partial<Record<ActivityPhase, string>> & { idle: string }
|
|
103
|
+
}
|
|
104
|
+
|
|
75
105
|
/** Normalized v2 manifest the registry consumes. */
|
|
76
106
|
export interface PetManifestV2 {
|
|
77
107
|
petManifestVersion: typeof PET_MANIFEST_V2
|
|
@@ -86,6 +116,9 @@ export interface PetManifestV2 {
|
|
|
86
116
|
renderer: PetRendererKind
|
|
87
117
|
sprite2d?: PetManifestSprite2d
|
|
88
118
|
live2d?: PetManifestLive2d
|
|
119
|
+
frames2d?: PetManifestFrames2d
|
|
120
|
+
/** Optional gameplay layer (frames2d pets only); fail-closed structure. */
|
|
121
|
+
gameplay?: PetGameplayManifest
|
|
89
122
|
sequences?: Partial<Record<ActivityPhase, PetAnimation[]>>
|
|
90
123
|
/** Pass-through for the registry's remarks normalizer (v1 shape). */
|
|
91
124
|
remarks?: unknown
|
|
@@ -112,12 +145,17 @@ const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/
|
|
|
112
145
|
*/
|
|
113
146
|
export const KNOWN_TOP_LEVEL = new Set([
|
|
114
147
|
'$schema', 'petManifestVersion', 'id', 'displayName', 'description', 'version',
|
|
115
|
-
'author', 'license', 'homepage', 'renderer', 'sprite2d', 'live2d', 'sequences', 'remarks',
|
|
148
|
+
'author', 'license', 'homepage', 'renderer', 'sprite2d', 'live2d', 'frames2d', 'sequences', 'remarks',
|
|
149
|
+
'gameplay',
|
|
116
150
|
])
|
|
117
151
|
/** sprite2d block field allow-list (drift-locked to the schema file). */
|
|
118
152
|
export const KNOWN_SPRITE2D = new Set(['spritesheetPath', 'cell', 'columns', 'atlasRows', 'frames', 'tracks'])
|
|
119
153
|
/** live2d block field allow-list (drift-locked to the schema file). */
|
|
120
154
|
export const KNOWN_LIVE2D = new Set(['model', 'scale', 'translate', 'motions', 'expressions', 'hitAreas', 'lipSync'])
|
|
155
|
+
/** frames2d block field allow-list (drift-locked to the schema file). */
|
|
156
|
+
export const KNOWN_FRAMES2D = new Set(['dir', 'defaultFrameMs', 'tracks', 'phases'])
|
|
157
|
+
/** frames2d track field allow-list (drift-locked to the schema file). */
|
|
158
|
+
export const KNOWN_FRAMES2D_TRACK = new Set(['frames', 'frameMs', 'loop', 'fallback'])
|
|
121
159
|
|
|
122
160
|
class Diagnostics {
|
|
123
161
|
readonly list: PetManifestDiagnostic[] = []
|
|
@@ -293,6 +331,129 @@ function parseLive2dBlock(raw: unknown, diag: Diagnostics): PetManifestLive2d |
|
|
|
293
331
|
return diag.hasErrors ? undefined : block
|
|
294
332
|
}
|
|
295
333
|
|
|
334
|
+
const FRAMES2D_TRACK_NAME = /^[a-z0-9][a-z0-9-]*$/
|
|
335
|
+
const FRAMES2D_MAX_TRACKS = 64
|
|
336
|
+
const FRAMES2D_MAX_FRAMES = 64
|
|
337
|
+
const FRAMES2D_MIN_FRAME_MS = 16
|
|
338
|
+
const FRAMES2D_MAX_FRAME_MS = 5_000
|
|
339
|
+
const FRAMES2D_IMAGE_EXTENSIONS = new Set(['.webp', '.png', '.gif', '.jpg', '.jpeg'])
|
|
340
|
+
|
|
341
|
+
/** Validate one frames2d track name (kebab id, at most 32 chars). */
|
|
342
|
+
function validTrackName(name: string): boolean {
|
|
343
|
+
return name.length <= 32 && FRAMES2D_TRACK_NAME.test(name)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** A frames2d frame entry is a single image file name inside the track directory. */
|
|
347
|
+
function safeFrameName(raw: unknown): string | undefined {
|
|
348
|
+
if (typeof raw !== 'string' || raw.trim() === '') return undefined
|
|
349
|
+
const value = raw.trim()
|
|
350
|
+
if (value.includes('/') || value.includes('\\') || !PATH_SEGMENT_PATTERN.test(value)) return undefined
|
|
351
|
+
const dot = value.lastIndexOf('.')
|
|
352
|
+
if (dot <= 0) return undefined
|
|
353
|
+
if (!FRAMES2D_IMAGE_EXTENSIONS.has(value.slice(dot).toLowerCase())) return undefined
|
|
354
|
+
return value
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function parseFrames2dTrack(raw: unknown, field: string, diag: Diagnostics): PetManifestFrames2dTrack | undefined {
|
|
358
|
+
if (!isRecord(raw)) {
|
|
359
|
+
diag.error(field + ' must be an object')
|
|
360
|
+
return undefined
|
|
361
|
+
}
|
|
362
|
+
const extra = unknownKeys(raw, KNOWN_FRAMES2D_TRACK)
|
|
363
|
+
if (extra.length > 0) diag.error(field + ': unknown field(s) ' + extra.map(k => JSON.stringify(k)).join(', '))
|
|
364
|
+
const track: PetManifestFrames2dTrack = {}
|
|
365
|
+
if (raw.frames !== undefined) {
|
|
366
|
+
if (!Array.isArray(raw.frames) || raw.frames.length === 0) {
|
|
367
|
+
diag.error(field + '.frames must be a non-empty array of frame file names')
|
|
368
|
+
} else if (raw.frames.length > FRAMES2D_MAX_FRAMES) {
|
|
369
|
+
diag.error(field + '.frames declares too many frames (' + raw.frames.length + ', max ' + FRAMES2D_MAX_FRAMES + ')')
|
|
370
|
+
} else {
|
|
371
|
+
const frames: string[] = []
|
|
372
|
+
for (const entry of raw.frames) {
|
|
373
|
+
const safe = safeFrameName(entry)
|
|
374
|
+
if (safe === undefined) diag.error(field + '.frames entry ' + JSON.stringify(String(entry)) + ' is not a safe image file name')
|
|
375
|
+
else frames.push(safe)
|
|
376
|
+
}
|
|
377
|
+
if (frames.length === (raw.frames as unknown[]).length) track.frames = frames
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (raw.frameMs !== undefined) {
|
|
381
|
+
if (!Array.isArray(raw.frameMs) || raw.frameMs.length === 0
|
|
382
|
+
|| raw.frameMs.some(v => typeof v !== 'number' || !Number.isInteger(v) || v < FRAMES2D_MIN_FRAME_MS || v > FRAMES2D_MAX_FRAME_MS)) {
|
|
383
|
+
diag.error(field + '.frameMs must be a non-empty array of integer ms in [' + FRAMES2D_MIN_FRAME_MS + ', ' + FRAMES2D_MAX_FRAME_MS + ']')
|
|
384
|
+
} else if (track.frames === undefined) {
|
|
385
|
+
diag.error(field + '.frameMs requires a valid explicit frames list (directory tracks use filename-encoded ms or defaultFrameMs)')
|
|
386
|
+
} else if (raw.frameMs.length !== track.frames.length) {
|
|
387
|
+
diag.error(field + '.frameMs must have the same length as frames')
|
|
388
|
+
} else {
|
|
389
|
+
track.frameMs = raw.frameMs as number[]
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (raw.loop !== undefined) {
|
|
393
|
+
if (typeof raw.loop !== 'boolean') diag.error(field + '.loop must be a boolean')
|
|
394
|
+
else track.loop = raw.loop
|
|
395
|
+
}
|
|
396
|
+
if (raw.fallback !== undefined) {
|
|
397
|
+
if (typeof raw.fallback !== 'string' || !validTrackName(raw.fallback)) diag.error(field + '.fallback must be a track name')
|
|
398
|
+
else track.fallback = raw.fallback
|
|
399
|
+
}
|
|
400
|
+
return diag.hasErrors ? undefined : track
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function parseFrames2dBlock(raw: unknown, diag: Diagnostics): PetManifestFrames2d | undefined {
|
|
404
|
+
if (!isRecord(raw)) {
|
|
405
|
+
diag.error('renderer frames2d requires a "frames2d" block object')
|
|
406
|
+
return undefined
|
|
407
|
+
}
|
|
408
|
+
const extra = unknownKeys(raw, KNOWN_FRAMES2D)
|
|
409
|
+
if (extra.length > 0) diag.error('frames2d: unknown field(s) ' + extra.map(k => JSON.stringify(k)).join(', '))
|
|
410
|
+
const block: PetManifestFrames2d = { tracks: {}, phases: { idle: '' } }
|
|
411
|
+
if (raw.dir !== undefined) {
|
|
412
|
+
const dir = safeManifestPath(raw.dir)
|
|
413
|
+
if (dir === undefined) diag.error('frames2d.dir must be a safe manifest-relative directory')
|
|
414
|
+
else block.dir = dir
|
|
415
|
+
}
|
|
416
|
+
if (raw.defaultFrameMs !== undefined) {
|
|
417
|
+
if (typeof raw.defaultFrameMs !== 'number' || !Number.isInteger(raw.defaultFrameMs) || raw.defaultFrameMs < FRAMES2D_MIN_FRAME_MS || raw.defaultFrameMs > FRAMES2D_MAX_FRAME_MS) {
|
|
418
|
+
diag.error('frames2d.defaultFrameMs must be an integer in [' + FRAMES2D_MIN_FRAME_MS + ', ' + FRAMES2D_MAX_FRAME_MS + ']')
|
|
419
|
+
} else block.defaultFrameMs = raw.defaultFrameMs
|
|
420
|
+
}
|
|
421
|
+
const tracks: Record<string, PetManifestFrames2dTrack> = {}
|
|
422
|
+
if (!isRecord(raw.tracks)) {
|
|
423
|
+
diag.error('frames2d.tracks is required and must be an object keyed by track name')
|
|
424
|
+
} else {
|
|
425
|
+
const entries = Object.entries(raw.tracks)
|
|
426
|
+
if (entries.length === 0) diag.error('frames2d.tracks must declare at least one track')
|
|
427
|
+
if (entries.length > FRAMES2D_MAX_TRACKS) diag.error('frames2d.tracks declares too many tracks (' + entries.length + ', max ' + FRAMES2D_MAX_TRACKS + ')')
|
|
428
|
+
for (const [name, value] of entries) {
|
|
429
|
+
if (!validTrackName(name)) {
|
|
430
|
+
diag.error('frames2d.tracks: invalid track name ' + JSON.stringify(name) + ' (lowercase kebab, at most 32 chars)')
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
const track = parseFrames2dTrack(value, 'frames2d.tracks.' + name, diag)
|
|
434
|
+
if (track !== undefined) tracks[name] = track
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
block.tracks = tracks
|
|
438
|
+
const phases = parsePhaseStringMap(raw.phases, 'frames2d.phases', diag)
|
|
439
|
+
if (raw.phases === undefined) diag.error('frames2d.phases is required (at least an "idle" mapping)')
|
|
440
|
+
else if (phases !== undefined && phases.idle === undefined) diag.error('frames2d.phases.idle is required (unmapped phases fall back to it)')
|
|
441
|
+
const phasesValid = phases !== undefined && phases.idle !== undefined
|
|
442
|
+
if (phasesValid) block.phases = phases as PetManifestFrames2d['phases']
|
|
443
|
+
// Dangling references (phases/fallback) are structure: fail closed.
|
|
444
|
+
if (phasesValid) {
|
|
445
|
+
for (const [phase, target] of Object.entries(block.phases)) {
|
|
446
|
+
if (target !== undefined && tracks[target] === undefined) diag.error('frames2d.phases.' + phase + ' references unknown track ' + JSON.stringify(target))
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
for (const [name, track] of Object.entries(tracks)) {
|
|
450
|
+
if (track.fallback !== undefined && tracks[track.fallback] === undefined) {
|
|
451
|
+
diag.error('frames2d.tracks.' + name + '.fallback references unknown track ' + JSON.stringify(track.fallback))
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return diag.hasErrors ? undefined : block
|
|
455
|
+
}
|
|
456
|
+
|
|
296
457
|
/** v1 compat read: map the legacy flat manifest onto the v2 sprite2d shape. */
|
|
297
458
|
function compatV1(source: Record<string, unknown>, diag: Diagnostics): PetManifestV2 | undefined {
|
|
298
459
|
const id = parseStringBlock(source, 'id', diag, true)
|
|
@@ -377,10 +538,28 @@ function parseV2(source: Record<string, unknown>, diag: Diagnostics): PetManifes
|
|
|
377
538
|
const block = parseSprite2dBlock(source.sprite2d, diag)
|
|
378
539
|
if (block !== undefined) manifest.sprite2d = block
|
|
379
540
|
if (source.live2d !== undefined) diag.error('renderer sprite2d must not declare a live2d block')
|
|
541
|
+
if (source.frames2d !== undefined) diag.error('renderer sprite2d must not declare a frames2d block')
|
|
380
542
|
} else if (renderer === 'live2d') {
|
|
381
543
|
const block = parseLive2dBlock(source.live2d, diag)
|
|
382
544
|
if (block !== undefined) manifest.live2d = block
|
|
383
545
|
if (source.sprite2d !== undefined) diag.error('renderer live2d must not declare a sprite2d block')
|
|
546
|
+
if (source.frames2d !== undefined) diag.error('renderer live2d must not declare a frames2d block')
|
|
547
|
+
} else if (renderer === 'frames2d') {
|
|
548
|
+
const block = parseFrames2dBlock(source.frames2d, diag)
|
|
549
|
+
if (block !== undefined) manifest.frames2d = block
|
|
550
|
+
if (source.sprite2d !== undefined) diag.error('renderer frames2d must not declare a sprite2d block')
|
|
551
|
+
if (source.live2d !== undefined) diag.error('renderer frames2d must not declare a live2d block')
|
|
552
|
+
}
|
|
553
|
+
if (source.gameplay !== undefined) {
|
|
554
|
+
if (renderer !== 'frames2d') {
|
|
555
|
+
diag.error('gameplay currently requires renderer frames2d (its state references name frames2d tracks)')
|
|
556
|
+
} else {
|
|
557
|
+
const gameplay = parseGameplayManifest(source.gameplay, {
|
|
558
|
+
stateNames: new Set(Object.keys(manifest.frames2d?.tracks ?? {})),
|
|
559
|
+
error: (message) => diag.error('gameplay: ' + message),
|
|
560
|
+
})
|
|
561
|
+
if (gameplay !== undefined) manifest.gameplay = gameplay
|
|
562
|
+
}
|
|
384
563
|
}
|
|
385
564
|
const sequences = parseSequences(source.sequences, diag)
|
|
386
565
|
if (sequences !== undefined) manifest.sequences = sequences
|
package/src/mount-once.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
2
|
/**
|
|
3
3
|
* Host single-instance guard shared by the plugin family. The family bundle
|
|
4
|
-
* (dsh-web-
|
|
4
|
+
* (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
5
5
|
* the loader accepts a standalone install of the same package side by side;
|
|
6
6
|
* without this guard the second instance would still re-register the same
|
|
7
7
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
const MOUNTED = Symbol.for('dsh-web
|
|
18
|
+
const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
|
|
19
19
|
|
|
20
20
|
interface MountRegistry {
|
|
21
21
|
[MOUNTED]?: Set<string>
|
package/src/persist.test.ts
CHANGED
|
@@ -52,6 +52,9 @@ describe('loadPetPersist', () => {
|
|
|
52
52
|
affinity: { ...emptyAffinity(), points: 42, pets: 3, feeds: 1, turns: 10 },
|
|
53
53
|
treats: { ...emptyTreatLedger(), treats: 7, lastTreatGrantAt: 1234, turnsAtLastTreatGrant: 9 },
|
|
54
54
|
display: { visible: false, size: 200, right: 10, bottom: 40 },
|
|
55
|
+
gameplay: {
|
|
56
|
+
otter: { stats: { hunger: 55.5 }, currencies: { coins: 12 }, mode: 'work' as const, settledAt: 777 },
|
|
57
|
+
},
|
|
55
58
|
}
|
|
56
59
|
savePetPersist(data, dir)
|
|
57
60
|
expect(loadPetPersist(dir)).toEqual(data)
|
package/src/persist.ts
CHANGED
|
@@ -10,6 +10,10 @@ import { join } from 'node:path'
|
|
|
10
10
|
import { dshHome } from './dsh-home.ts'
|
|
11
11
|
import { AFFINITY_MAX, emptyAffinity, type AffinityState } from './affinity.ts'
|
|
12
12
|
import { defaultTreatConfig, emptyTreatLedger, type TreatLedger } from './treats.ts'
|
|
13
|
+
import { DEFAULT_PET_ID, DEFAULT_PET_NAME } from './defaults.ts'
|
|
14
|
+
import type { PetGameplayState } from './gameplay.ts'
|
|
15
|
+
|
|
16
|
+
export { DEFAULT_PET_ID, DEFAULT_PET_NAME } from './defaults.ts'
|
|
13
17
|
|
|
14
18
|
/** Display configuration the user can tweak. */
|
|
15
19
|
export interface PetDisplayConfig {
|
|
@@ -48,14 +52,10 @@ export interface PetPersist {
|
|
|
48
52
|
/** Treat (小鱼干) stock ledger. */
|
|
49
53
|
treats: TreatLedger
|
|
50
54
|
display: PetDisplayConfig
|
|
55
|
+
/** Per-pet gameplay state (stats/currencies/mode), keyed by pet id. */
|
|
56
|
+
gameplay: Record<string, PetGameplayState>
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
/** Pet id the legacy single-pet installs resolve to on migration. */
|
|
54
|
-
export const DEFAULT_PET_ID = 'whale-girl'
|
|
55
|
-
|
|
56
|
-
/** Default pet name (used only when a manifest carries no displayName). */
|
|
57
|
-
export const DEFAULT_PET_NAME = '鲸鱼娘'
|
|
58
|
-
|
|
59
59
|
/** Name constraints. */
|
|
60
60
|
export const PET_NAME_MAX_LENGTH = 20
|
|
61
61
|
|
|
@@ -66,6 +66,7 @@ export function emptyPersist(): PetPersist {
|
|
|
66
66
|
affinity: emptyAffinity(),
|
|
67
67
|
treats: emptyTreatLedger(),
|
|
68
68
|
display: { ...defaultDisplayConfig },
|
|
69
|
+
gameplay: {},
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
|
|
@@ -104,6 +105,41 @@ function clamp(value: number, max: number): number {
|
|
|
104
105
|
return Math.min(max, Math.max(0, value))
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/** Absolute numeric ceilings applied at load (manifest clamps refine these). */
|
|
109
|
+
const GAMEPLAY_LOAD_STAT_CAP = 1_000_000
|
|
110
|
+
const GAMEPLAY_LOAD_CURRENCY_CAP = 9_999_999
|
|
111
|
+
|
|
112
|
+
/** Sanitize the persisted per-pet gameplay map. */
|
|
113
|
+
function loadGameplay(parsed: PetPersistDocument): Record<string, PetGameplayState> {
|
|
114
|
+
const result: Record<string, PetGameplayState> = {}
|
|
115
|
+
if (typeof parsed.gameplay !== 'object' || parsed.gameplay === null) return result
|
|
116
|
+
for (const [petId, raw] of Object.entries(parsed.gameplay as Record<string, unknown>)) {
|
|
117
|
+
if (petId === '' || typeof raw !== 'object' || raw === null) continue
|
|
118
|
+
const record = raw as Partial<PetGameplayState>
|
|
119
|
+
const stats: Record<string, number> = {}
|
|
120
|
+
if (typeof record.stats === 'object' && record.stats !== null) {
|
|
121
|
+
for (const [key, value] of Object.entries(record.stats)) {
|
|
122
|
+
if (key === '' || typeof value !== 'number' || !Number.isFinite(value)) continue
|
|
123
|
+
stats[key] = Math.min(GAMEPLAY_LOAD_STAT_CAP, Math.max(0, value))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const currencies: Record<string, number> = {}
|
|
127
|
+
if (typeof record.currencies === 'object' && record.currencies !== null) {
|
|
128
|
+
for (const [key, value] of Object.entries(record.currencies)) {
|
|
129
|
+
if (key === '' || typeof value !== 'number' || !Number.isFinite(value)) continue
|
|
130
|
+
currencies[key] = Math.min(GAMEPLAY_LOAD_CURRENCY_CAP, Math.max(0, Math.floor(value)))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
result[petId] = {
|
|
134
|
+
stats,
|
|
135
|
+
currencies,
|
|
136
|
+
mode: record.mode === 'work' || record.mode === 'sleep' ? record.mode : null,
|
|
137
|
+
settledAt: clamp(finiteNum(record.settledAt, 0), Number.MAX_SAFE_INTEGER),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return result
|
|
141
|
+
}
|
|
142
|
+
|
|
107
143
|
/** Load persisted state; missing or corrupt files fall back to defaults. */
|
|
108
144
|
export function loadPetPersist(dir: string = petHomeDir()): PetPersist {
|
|
109
145
|
try {
|
|
@@ -152,6 +188,7 @@ export function loadPetPersist(dir: string = petHomeDir()): PetPersist {
|
|
|
152
188
|
affinity,
|
|
153
189
|
treats,
|
|
154
190
|
display,
|
|
191
|
+
gameplay: loadGameplay(parsed),
|
|
155
192
|
}
|
|
156
193
|
} catch {
|
|
157
194
|
return emptyPersist()
|