@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.
Files changed (127) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +25 -7
  3. package/README.zh.md +25 -7
  4. package/assets/ouo-neko/pet.json +31 -0
  5. package/assets/ouo-neko/previews/failed.gif +0 -0
  6. package/assets/ouo-neko/previews/idle.gif +0 -0
  7. package/assets/ouo-neko/previews/jumping.gif +0 -0
  8. package/assets/ouo-neko/previews/review.gif +0 -0
  9. package/assets/ouo-neko/previews/running-left.gif +0 -0
  10. package/assets/ouo-neko/previews/running-right.gif +0 -0
  11. package/assets/ouo-neko/previews/running.gif +0 -0
  12. package/assets/ouo-neko/previews/waiting.gif +0 -0
  13. package/assets/ouo-neko/previews/waving.gif +0 -0
  14. package/assets/ouo-neko/spritesheet.webp +0 -0
  15. package/contracts/pet-manifest-v2.schema.json +102 -1
  16. package/lib/client.js +830 -26
  17. package/lib/client.js.map +1 -1
  18. package/lib/index.js +1226 -24
  19. package/lib/types/client/PetDockEntry.d.ts +3 -0
  20. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  21. package/lib/types/client/PetDockEntry.js +18 -2
  22. package/lib/types/client/PetSprite.d.ts +23 -0
  23. package/lib/types/client/PetSprite.d.ts.map +1 -1
  24. package/lib/types/client/PetSprite.js +34 -29
  25. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  26. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  27. package/lib/types/client/PluginSettingsCard.js +2 -2
  28. package/lib/types/client/drag-stream.d.ts +19 -0
  29. package/lib/types/client/drag-stream.d.ts.map +1 -0
  30. package/lib/types/client/drag-stream.js +26 -0
  31. package/lib/types/client/gameplay-hud.d.ts +50 -0
  32. package/lib/types/client/gameplay-hud.d.ts.map +1 -0
  33. package/lib/types/client/gameplay-hud.js +274 -0
  34. package/lib/types/client/index.d.ts +1 -1
  35. package/lib/types/client/index.d.ts.map +1 -1
  36. package/lib/types/client/index.js +16 -0
  37. package/lib/types/client/locales.d.ts +34 -0
  38. package/lib/types/client/locales.d.ts.map +1 -1
  39. package/lib/types/client/locales.js +36 -0
  40. package/lib/types/client/pet-store.d.ts +7 -1
  41. package/lib/types/client/pet-store.d.ts.map +1 -1
  42. package/lib/types/client/pet-store.js +4 -0
  43. package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
  44. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
  45. package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
  46. package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
  47. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
  48. package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
  49. package/lib/types/client/renderers/frames2d.d.ts +36 -0
  50. package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
  51. package/lib/types/client/renderers/frames2d.js +196 -0
  52. package/lib/types/client/settings-form.d.ts +1 -1
  53. package/lib/types/client/settings-form.js +1 -1
  54. package/lib/types/client/telemetry.d.ts +27 -0
  55. package/lib/types/client/telemetry.d.ts.map +1 -0
  56. package/lib/types/client/telemetry.js +97 -0
  57. package/lib/types/defaults.d.ts +6 -0
  58. package/lib/types/defaults.d.ts.map +1 -0
  59. package/lib/types/defaults.js +5 -0
  60. package/lib/types/dsh-home.d.ts.map +1 -1
  61. package/lib/types/dsh-home.js +9 -3
  62. package/lib/types/gameplay.d.ts +182 -0
  63. package/lib/types/gameplay.d.ts.map +1 -0
  64. package/lib/types/gameplay.js +618 -0
  65. package/lib/types/http.d.ts +1 -1
  66. package/lib/types/http.js +1 -1
  67. package/lib/types/index.d.ts +1 -1
  68. package/lib/types/index.js +1 -1
  69. package/lib/types/ledger.d.ts +14 -0
  70. package/lib/types/ledger.d.ts.map +1 -1
  71. package/lib/types/ledger.js +30 -0
  72. package/lib/types/loopback.d.ts +1 -1
  73. package/lib/types/loopback.js +1 -1
  74. package/lib/types/manifest-v2.d.ts +38 -1
  75. package/lib/types/manifest-v2.d.ts.map +1 -1
  76. package/lib/types/manifest-v2.js +182 -2
  77. package/lib/types/mount-once.d.ts +1 -1
  78. package/lib/types/mount-once.js +2 -2
  79. package/lib/types/persist.d.ts +4 -4
  80. package/lib/types/persist.d.ts.map +1 -1
  81. package/lib/types/persist.js +41 -4
  82. package/lib/types/registry.d.ts +29 -0
  83. package/lib/types/registry.d.ts.map +1 -1
  84. package/lib/types/registry.js +170 -1
  85. package/lib/types/routes.d.ts.map +1 -1
  86. package/lib/types/routes.js +22 -0
  87. package/lib/types/service.d.ts +56 -0
  88. package/lib/types/service.d.ts.map +1 -1
  89. package/lib/types/service.js +181 -0
  90. package/package.json +6 -3
  91. package/src/client/PetDockEntry.test.tsx +6 -0
  92. package/src/client/PetDockEntry.tsx +32 -1
  93. package/src/client/PetSprite.test.tsx +36 -6
  94. package/src/client/PetSprite.tsx +42 -21
  95. package/src/client/PluginSettingsCard.tsx +4 -2
  96. package/src/client/drag-stream.ts +35 -0
  97. package/src/client/gameplay-hud.test.tsx +338 -0
  98. package/src/client/gameplay-hud.tsx +403 -0
  99. package/src/client/index.ts +23 -2
  100. package/src/client/locales.ts +36 -0
  101. package/src/client/pet-store.ts +10 -1
  102. package/src/client/pet.module.css +192 -29
  103. package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
  104. package/src/client/renderers/PetRendererSwitch.tsx +28 -1
  105. package/src/client/renderers/frames2d.test.ts +137 -0
  106. package/src/client/renderers/frames2d.ts +216 -0
  107. package/src/client/settings-card.module.css +5 -0
  108. package/src/client/settings-form.ts +1 -1
  109. package/src/client/telemetry.ts +105 -0
  110. package/src/defaults.ts +7 -0
  111. package/src/dsh-home.ts +9 -3
  112. package/src/gameplay.test.ts +239 -0
  113. package/src/gameplay.ts +692 -0
  114. package/src/http.ts +1 -1
  115. package/src/index.ts +1 -1
  116. package/src/ledger.test.ts +16 -0
  117. package/src/ledger.ts +31 -0
  118. package/src/loopback.ts +1 -1
  119. package/src/manifest-v2.test.ts +130 -1
  120. package/src/manifest-v2.ts +181 -2
  121. package/src/mount-once.ts +2 -2
  122. package/src/persist.test.ts +3 -0
  123. package/src/persist.ts +43 -6
  124. package/src/registry.test.ts +168 -1
  125. package/src/registry.ts +199 -1
  126. package/src/routes.ts +19 -0
  127. 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-ui-shared/host/http
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-ui>/packages/dsh-pet'; the
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
  */
@@ -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-ui-shared/host/loopback
11
+ * @module dsh-web-shared/host/loopback
12
12
  */
13
13
 
14
14
  import type { IncomingMessage } from 'node:http'
@@ -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', () => {
@@ -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-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
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-ui.mounted-plugins')
18
+ const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
19
19
 
20
20
  interface MountRegistry {
21
21
  [MOUNTED]?: Set<string>
@@ -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()