@linxin666/dsh-pet 0.3.3 → 0.3.5

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 (123) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +30 -13
  3. package/README.zh.md +30 -13
  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/contracts/voice-pack-v1.schema.json +23 -28
  17. package/lib/client.js +781 -40
  18. package/lib/client.js.map +1 -1
  19. package/lib/index.js +1567 -482
  20. package/lib/types/chatter.d.ts +68 -46
  21. package/lib/types/chatter.d.ts.map +1 -1
  22. package/lib/types/chatter.js +243 -301
  23. package/lib/types/client/PetDockEntry.d.ts +3 -0
  24. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  25. package/lib/types/client/PetDockEntry.js +18 -2
  26. package/lib/types/client/PetSprite.d.ts +23 -0
  27. package/lib/types/client/PetSprite.d.ts.map +1 -1
  28. package/lib/types/client/PetSprite.js +49 -45
  29. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  30. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  31. package/lib/types/client/PluginSettingsCard.js +2 -2
  32. package/lib/types/client/drag-stream.d.ts +19 -0
  33. package/lib/types/client/drag-stream.d.ts.map +1 -0
  34. package/lib/types/client/drag-stream.js +26 -0
  35. package/lib/types/client/gameplay-hud.d.ts +50 -0
  36. package/lib/types/client/gameplay-hud.d.ts.map +1 -0
  37. package/lib/types/client/gameplay-hud.js +274 -0
  38. package/lib/types/client/index.d.ts.map +1 -1
  39. package/lib/types/client/index.js +38 -9
  40. package/lib/types/client/locales.d.ts +34 -0
  41. package/lib/types/client/locales.d.ts.map +1 -1
  42. package/lib/types/client/locales.js +36 -0
  43. package/lib/types/client/pet-store.d.ts +7 -1
  44. package/lib/types/client/pet-store.d.ts.map +1 -1
  45. package/lib/types/client/pet-store.js +4 -0
  46. package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
  47. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
  48. package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
  49. package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
  50. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
  51. package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
  52. package/lib/types/client/renderers/frames2d.d.ts +36 -0
  53. package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
  54. package/lib/types/client/renderers/frames2d.js +196 -0
  55. package/lib/types/defaults.d.ts +6 -0
  56. package/lib/types/defaults.d.ts.map +1 -0
  57. package/lib/types/defaults.js +5 -0
  58. package/lib/types/dsh-home.d.ts.map +1 -1
  59. package/lib/types/dsh-home.js +9 -3
  60. package/lib/types/event-projection.d.ts +9 -4
  61. package/lib/types/event-projection.d.ts.map +1 -1
  62. package/lib/types/event-projection.js +46 -14
  63. package/lib/types/gameplay.d.ts +182 -0
  64. package/lib/types/gameplay.d.ts.map +1 -0
  65. package/lib/types/gameplay.js +618 -0
  66. package/lib/types/ledger.d.ts +14 -0
  67. package/lib/types/ledger.d.ts.map +1 -1
  68. package/lib/types/ledger.js +30 -0
  69. package/lib/types/manifest-v2.d.ts +38 -1
  70. package/lib/types/manifest-v2.d.ts.map +1 -1
  71. package/lib/types/manifest-v2.js +182 -2
  72. package/lib/types/persist.d.ts +4 -4
  73. package/lib/types/persist.d.ts.map +1 -1
  74. package/lib/types/persist.js +41 -4
  75. package/lib/types/registry.d.ts +29 -0
  76. package/lib/types/registry.d.ts.map +1 -1
  77. package/lib/types/registry.js +170 -1
  78. package/lib/types/routes.d.ts.map +1 -1
  79. package/lib/types/routes.js +30 -3
  80. package/lib/types/service.d.ts +63 -11
  81. package/lib/types/service.d.ts.map +1 -1
  82. package/lib/types/service.js +208 -19
  83. package/lib/types/voice-pack.d.ts +6 -7
  84. package/lib/types/voice-pack.d.ts.map +1 -1
  85. package/lib/types/voice-pack.js +44 -47
  86. package/package.json +5 -2
  87. package/src/chatter.test.ts +68 -38
  88. package/src/chatter.ts +269 -316
  89. package/src/client/PetDockEntry.test.tsx +6 -0
  90. package/src/client/PetDockEntry.tsx +32 -1
  91. package/src/client/PetSprite.test.tsx +68 -21
  92. package/src/client/PetSprite.tsx +63 -45
  93. package/src/client/PluginSettingsCard.tsx +4 -2
  94. package/src/client/drag-stream.ts +35 -0
  95. package/src/client/gameplay-hud.test.tsx +338 -0
  96. package/src/client/gameplay-hud.tsx +403 -0
  97. package/src/client/index.test.tsx +10 -1
  98. package/src/client/index.ts +46 -11
  99. package/src/client/locales.ts +36 -0
  100. package/src/client/pet-store.ts +10 -1
  101. package/src/client/pet.module.css +192 -29
  102. package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
  103. package/src/client/renderers/PetRendererSwitch.tsx +28 -1
  104. package/src/client/renderers/frames2d.test.ts +137 -0
  105. package/src/client/renderers/frames2d.ts +216 -0
  106. package/src/client/settings-card.module.css +5 -0
  107. package/src/defaults.ts +7 -0
  108. package/src/dsh-home.ts +9 -3
  109. package/src/event-projection.ts +57 -16
  110. package/src/gameplay.test.ts +239 -0
  111. package/src/gameplay.ts +692 -0
  112. package/src/ledger.test.ts +16 -0
  113. package/src/ledger.ts +31 -0
  114. package/src/manifest-v2.test.ts +130 -1
  115. package/src/manifest-v2.ts +181 -2
  116. package/src/persist.test.ts +3 -0
  117. package/src/persist.ts +43 -6
  118. package/src/registry.test.ts +168 -1
  119. package/src/registry.ts +199 -1
  120. package/src/routes.ts +28 -4
  121. package/src/service.ts +240 -29
  122. package/src/voice-pack.test.ts +48 -25
  123. package/src/voice-pack.ts +50 -47
@@ -152,18 +152,185 @@ describe('resolvePetManifest', () => {
152
152
  })
153
153
  })
154
154
 
155
+ describe('loadPetRegistry frames2d', () => {
156
+ /** Write a frames2d pet fixture: pet.json + thumb/<track>/<frame> files. */
157
+ function writeFrames2dPet(root: string, id: string, manifest: Record<string, unknown>, files: Record<string, string[]>): string {
158
+ const dir = join(root, 'assets', id)
159
+ mkdirSync(dir, { recursive: true })
160
+ writeFileSync(join(dir, 'pet.json'), JSON.stringify({
161
+ petManifestVersion: 2,
162
+ id,
163
+ displayName: id,
164
+ license: 'MIT',
165
+ renderer: 'frames2d',
166
+ ...manifest,
167
+ }), 'utf8')
168
+ for (const [trackDir, frames] of Object.entries(files)) {
169
+ mkdirSync(join(dir, trackDir), { recursive: true })
170
+ for (const frame of frames) writeFileSync(join(dir, trackDir, frame), 'webp', 'utf8')
171
+ }
172
+ return dir
173
+ }
174
+
175
+ it('resolves directory-listed tracks with filename-encoded durations', () => {
176
+ const root = tempDir()
177
+ try {
178
+ writeFrames2dPet(root, 'miku', {
179
+ frames2d: {
180
+ dir: 'thumb',
181
+ tracks: { idle: { loop: true }, happy: { loop: false } },
182
+ phases: { idle: 'idle', done: 'happy' },
183
+ },
184
+ }, {
185
+ 'thumb/idle': ['miku_1_200.webp', 'miku_2_260.webp'],
186
+ 'thumb/happy': ['miku-happy_1_300.webp'],
187
+ })
188
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
189
+ const entry = registry.byId('miku')
190
+ expect(entry?.renderer).toBe('frames2d')
191
+ expect(entry?.frames2d?.phases).toEqual({ idle: 'idle', done: 'happy' })
192
+ expect(entry?.frames2d?.tracks.idle).toMatchObject({ loop: true, durations: [200, 260] })
193
+ expect(entry?.frames2d?.tracks.idle?.frames).toEqual([
194
+ '/pet/miku/thumb/idle/miku_1_200.webp',
195
+ '/pet/miku/thumb/idle/miku_2_260.webp',
196
+ ])
197
+ expect(entry?.frames2d?.tracks.happy).toMatchObject({ loop: false, fallback: 'idle', durations: [300] })
198
+ expect([...(entry?.servable ?? [])].sort()).toEqual([
199
+ 'thumb/happy/miku-happy_1_300.webp',
200
+ 'thumb/idle/miku_1_200.webp',
201
+ 'thumb/idle/miku_2_260.webp',
202
+ ])
203
+ expect(entry?.atlasUrl).toBe('/pet/miku/thumb/idle/miku_1_200.webp')
204
+ // The browser view carries the frames2d block.
205
+ const view = petEntryView(entry!)
206
+ expect(view.frames2d?.tracks.idle?.frames).toHaveLength(2)
207
+ } finally {
208
+ rmSync(root, { recursive: true, force: true })
209
+ }
210
+ })
211
+
212
+ it('orders directory-scanned frames by their trailing index (10+ frames)', () => {
213
+ const root = tempDir()
214
+ try {
215
+ const names = Array.from({ length: 12 }, (_, index) => 'miku-pet-eat' + String(index + 1) + '.webp')
216
+ writeFrames2dPet(root, 'miku', {
217
+ frames2d: { dir: 'thumb', tracks: { eat: { loop: false } }, phases: { idle: 'eat' } },
218
+ }, { 'thumb/eat': names })
219
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
220
+ const frames = registry.byId('miku')?.frames2d?.tracks.eat?.frames ?? []
221
+ expect(frames).toHaveLength(12)
222
+ expect(frames[1]).toBe('/pet/miku/thumb/eat/miku-pet-eat2.webp')
223
+ expect(frames[9]).toBe('/pet/miku/thumb/eat/miku-pet-eat10.webp')
224
+ expect(frames[11]).toBe('/pet/miku/thumb/eat/miku-pet-eat12.webp')
225
+ } finally {
226
+ rmSync(root, { recursive: true, force: true })
227
+ }
228
+ })
229
+
230
+ it('honours explicit frames order and frameMs over filename-encoded durations', () => {
231
+ const root = tempDir()
232
+ try {
233
+ writeFrames2dPet(root, 'fox', {
234
+ frames2d: {
235
+ dir: 'thumb',
236
+ tracks: { idle: { frames: ['b_100.webp', 'a_100.webp'], frameMs: [111, 222], loop: true } },
237
+ phases: { idle: 'idle' },
238
+ },
239
+ }, { 'thumb/idle': ['a_100.webp', 'b_100.webp'] })
240
+ const entry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' }).byId('fox')
241
+ expect(entry?.frames2d?.tracks.idle?.frames).toEqual([
242
+ '/pet/fox/thumb/idle/b_100.webp',
243
+ '/pet/fox/thumb/idle/a_100.webp',
244
+ ])
245
+ expect(entry?.frames2d?.tracks.idle?.durations).toEqual([111, 222])
246
+ } finally {
247
+ rmSync(root, { recursive: true, force: true })
248
+ }
249
+ })
250
+
251
+ it('drops an empty track and remaps its phases to idle with a warning', () => {
252
+ const root = tempDir()
253
+ try {
254
+ writeFrames2dPet(root, 'miku', {
255
+ frames2d: {
256
+ dir: 'thumb',
257
+ tracks: { idle: {}, work: {} },
258
+ phases: { idle: 'idle', thinking: 'work' },
259
+ },
260
+ }, { 'thumb/idle': ['miku_1_200.webp'] })
261
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
262
+ const entry = registry.byId('miku')
263
+ expect(entry?.frames2d?.tracks.work).toBeUndefined()
264
+ expect(entry?.frames2d?.phases.thinking).toBe('idle')
265
+ expect(registry.warnings.some(warning => warning.includes('work'))).toBe(true)
266
+ } finally {
267
+ rmSync(root, { recursive: true, force: true })
268
+ }
269
+ })
270
+
271
+ it('rejects the entry fail-closed when the idle track has no frames on disk', () => {
272
+ const root = tempDir()
273
+ try {
274
+ writeFrames2dPet(root, 'miku', {
275
+ frames2d: {
276
+ dir: 'thumb',
277
+ tracks: { idle: {}, happy: {} },
278
+ phases: { idle: 'idle', done: 'happy' },
279
+ },
280
+ }, { 'thumb/happy': ['miku_1_200.webp'] })
281
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
282
+ expect(registry.byId('miku')).toBeUndefined()
283
+ expect(registry.diagnostics.some(d => d.level === 'error' && d.message.includes('idle'))).toBe(true)
284
+ } finally {
285
+ rmSync(root, { recursive: true, force: true })
286
+ }
287
+ })
288
+
289
+ it('warns and skips a missing explicit frame', () => {
290
+ const root = tempDir()
291
+ try {
292
+ writeFrames2dPet(root, 'miku', {
293
+ frames2d: {
294
+ dir: 'thumb',
295
+ tracks: { idle: { frames: ['a_100.webp', 'gone_100.webp'] } },
296
+ phases: { idle: 'idle' },
297
+ },
298
+ }, { 'thumb/idle': ['a_100.webp'] })
299
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
300
+ const entry = registry.byId('miku')
301
+ expect(entry?.frames2d?.tracks.idle?.frames).toEqual(['/pet/miku/thumb/idle/a_100.webp'])
302
+ expect(registry.warnings.some(warning => warning.includes('gone_100.webp'))).toBe(true)
303
+ } finally {
304
+ rmSync(root, { recursive: true, force: true })
305
+ }
306
+ })
307
+ })
308
+
155
309
  describe('loadPetRegistry', () => {
156
- it('ships the original and refined whale variants while keeping the original default', () => {
310
+ it('ships every built-in pet while keeping the original whale as default', () => {
157
311
  const registry = loadPetRegistry({
158
312
  packageRoot: petPackageRoot(import.meta.url),
159
313
  petsDir: '',
160
314
  dshPetsDir: '',
161
315
  })
162
316
 
317
+ // The repo checkout also resolves miku (frames2d gameplay pet) from
318
+ // assets/; the npm files whitelist excludes it (Workshop delivery), so
319
+ // npm installs see the three atlas pets until a Workshop install lands
320
+ // miku under $DSH_HOME/pets.
163
321
  expect(registry.entries.map(entry => entry.id)).toEqual([
322
+ 'miku',
323
+ 'ouo-neko',
164
324
  'whale-girl',
165
325
  'whale-girl-refined',
166
326
  ])
327
+ expect(registry.byId('ouo-neko')).toMatchObject({
328
+ displayName: 'OUO Neko',
329
+ atlasRows: 11,
330
+ columns: 8,
331
+ rows: [6, 8, 8, 4, 5, 8, 6, 6, 6],
332
+ })
333
+ expect(existsSync(petAtlasFile(registry.byId('ouo-neko')!))).toBe(true)
167
334
  expect(registry.byId('whale-girl')?.displayName).toBe('鲸鱼娘(原版)')
168
335
  expect(registry.byId('whale-girl-refined')?.displayName).toBe('鲸鱼娘(精致版)')
169
336
  expect(existsSync(petAtlasFile(registry.byId('whale-girl-refined')!))).toBe(true)
package/src/registry.ts CHANGED
@@ -39,7 +39,9 @@ import { imageDimensions } from './image-dimensions.ts'
39
39
  import { PET_DECORATION_API_VERSION, type DecorationView } from './contracts/status-decoration.ts'
40
40
  import { dshHome } from './dsh-home.ts'
41
41
  import { parsePetManifest, type PetManifestLive2d, type PetManifestV2, type PetRendererKind } from './manifest-v2.ts'
42
+ import type { PetGameplayManifest } from './gameplay.ts'
42
43
  import { collectModel3References } from './model3.ts'
44
+ import { DEFAULT_PET_ID } from './defaults.ts'
43
45
 
44
46
  /** Fixed row order of the 9-state animation contract. */
45
47
  export const PET_ROW_ORDER: readonly PetAnimation[] = [
@@ -195,6 +197,26 @@ export interface PetLive2dDefinition {
195
197
  hitAreas?: string[]
196
198
  }
197
199
 
200
+ /** One frames2d track as served to the browser half. */
201
+ export interface PetFrames2dTrackView {
202
+ /** Browser URLs of the frames in play order. */
203
+ frames: string[]
204
+ /** Per-frame durations in ms; same length as frames. */
205
+ durations: number[]
206
+ /** Whether the track loops; a non-looping track ends into fallback. */
207
+ loop: boolean
208
+ /** Track entered when a non-looping track finishes (defaults to the idle track). */
209
+ fallback?: string
210
+ }
211
+
212
+ /** The frames2d renderer block as served to the browser half. */
213
+ export interface PetFrames2dDefinition {
214
+ /** Named tracks keyed by track id. */
215
+ tracks: Record<string, PetFrames2dTrackView>
216
+ /** ActivityPhase -> track id; unmapped phases fall back to idle. */
217
+ phases: Partial<Record<ActivityPhase, string>> & { idle: string }
218
+ }
219
+
198
220
  /** A normalized pet as served to the browser half. */
199
221
  export interface PetDefinition {
200
222
  id: string
@@ -204,6 +226,14 @@ export interface PetDefinition {
204
226
  renderer: PetRendererKind
205
227
  /** Live2d render block; present exactly when renderer is 'live2d' (M3). */
206
228
  live2d?: PetLive2dDefinition
229
+ /** Frames2d render block; present exactly when renderer is 'frames2d'. */
230
+ frames2d?: PetFrames2dDefinition
231
+ /**
232
+ * The pet's gameplay layer (miku-pet generalization), present when the
233
+ * manifest declares 'gameplay'. Shop item images are served as browser
234
+ * URLs; every other field is the validated manifest block verbatim.
235
+ */
236
+ gameplay?: PetGameplayManifest
207
237
  /** Atlas cell size in px. */
208
238
  cell: PetCell
209
239
  /** Columns per row. */
@@ -580,6 +610,168 @@ function resolveLive2dEntry(
580
610
  }
581
611
  }
582
612
 
613
+ /** Filename-encoded frame duration tail ('<base>_<index>_<ms>.webp'). */
614
+ const FRAMES2D_FILENAME_MS = /_(\d+)\.[^.]+$/
615
+ /** Trailing frame index, allowing an optional '_<ms>' duration tail after it. */
616
+ const FRAMES2D_FRAME_INDEX = /(\d+)(?:_\d+)?\.[^.]+$/
617
+ /** Default per-frame duration when neither frameMs nor the filename encodes one. */
618
+ const FRAMES2D_DEFAULT_FRAME_MS = 200
619
+ /** Image extensions a frames2d track directory may list. */
620
+ const FRAMES2D_IMAGE_EXTENSIONS = new Set(['.webp', '.png', '.gif', '.jpg', '.jpeg'])
621
+
622
+ /**
623
+ * Resolve a validated frames2d manifest into a renderable entry. Track frame
624
+ * lists come from the manifest's explicit list or from listing
625
+ * '<dir>/<track>/' in filename order; durations resolve frameMs[i] >
626
+ * filename-encoded '_<ms>' tail > defaultFrameMs. Missing frames warn and
627
+ * skip (the live2d closure discipline); a track left with zero frames is
628
+ * dropped, and if the idle-mapped track ends up empty the entry is rejected
629
+ * fail-closed. The sprite fields carry contract defaults: the chrome sizes
630
+ * frames2d pets off 'display.size', not the atlas.
631
+ */
632
+ function resolveFrames2dEntry(
633
+ manifest: PetManifestV2,
634
+ dir: string,
635
+ options: { assetPrefix?: string; warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
636
+ ): PetEntry | undefined {
637
+ const assetPrefix = options.assetPrefix ?? '/pet'
638
+ const record = (level: 'error' | 'warning', message: string): void => {
639
+ options.diagnostics?.push({ level, source: dir, message })
640
+ options.warnings?.push(message)
641
+ }
642
+ const block = manifest.frames2d
643
+ if (block === undefined) {
644
+ record('error', 'pet ' + manifest.id + ': renderer frames2d requires a frames2d block')
645
+ return undefined
646
+ }
647
+ const root = block.dir ?? '.'
648
+ const defaultMs = block.defaultFrameMs ?? FRAMES2D_DEFAULT_FRAME_MS
649
+ const idleTrack = block.phases.idle
650
+ const tracks: Record<string, PetFrames2dTrackView> = {}
651
+ const servable: string[] = []
652
+ const firstFrameRel: Record<string, string> = {}
653
+ for (const [name, track] of Object.entries(block.tracks)) {
654
+ const trackDir = root === '.' ? name : root + '/' + name
655
+ let relFrames: string[] = []
656
+ if (track.frames !== undefined) {
657
+ for (const frame of track.frames) {
658
+ const rel = trackDir + '/' + frame
659
+ if (!existsSync(join(dir, rel))) {
660
+ record('warning', 'pet ' + manifest.id + ': frames2d frame missing: ' + rel)
661
+ continue
662
+ }
663
+ relFrames.push(rel)
664
+ }
665
+ } else {
666
+ let files: string[] = []
667
+ try {
668
+ files = readdirSync(join(dir, trackDir)).filter(file => {
669
+ if (file.startsWith('.')) return false
670
+ const dot = file.lastIndexOf('.')
671
+ return dot > 0 && FRAMES2D_IMAGE_EXTENSIONS.has(file.slice(dot).toLowerCase())
672
+ })
673
+ } catch {
674
+ files = []
675
+ }
676
+ // Natural frame order: the trailing index (before any _<ms> duration
677
+ // suffix) sorts numerically, so 'eat10' comes after 'eat9' — plain
678
+ // lexicographic order would break any track with 10+ frames.
679
+ files.sort((a, b) => {
680
+ const ia = FRAMES2D_FRAME_INDEX.exec(a)?.[1]
681
+ const ib = FRAMES2D_FRAME_INDEX.exec(b)?.[1]
682
+ if (ia !== undefined && ib !== undefined && ia !== ib) return Number(ia) - Number(ib)
683
+ return a < b ? -1 : a > b ? 1 : 0
684
+ })
685
+ relFrames = files.map(file => trackDir + '/' + file)
686
+ }
687
+ if (relFrames.length === 0) {
688
+ record('warning', 'pet ' + manifest.id + ': frames2d track ' + JSON.stringify(name) + ' has no frames on disk; dropped')
689
+ continue
690
+ }
691
+ const durations = relFrames.map((rel, index) => {
692
+ if (track.frameMs !== undefined) return track.frameMs[index] ?? defaultMs
693
+ const match = FRAMES2D_FILENAME_MS.exec(rel)
694
+ if (match !== null) {
695
+ const ms = Number(match[1])
696
+ if (Number.isInteger(ms) && ms >= 16 && ms <= 5000) return ms
697
+ }
698
+ return defaultMs
699
+ })
700
+ const loop = track.loop ?? true
701
+ const view: PetFrames2dTrackView = {
702
+ frames: relFrames.map(rel => assetUrl(assetPrefix, manifest.id, rel)),
703
+ durations,
704
+ loop,
705
+ ...(loop ? {} : { fallback: track.fallback ?? idleTrack }),
706
+ }
707
+ tracks[name] = view
708
+ firstFrameRel[name] = relFrames[0]!
709
+ servable.push(...relFrames)
710
+ }
711
+ if (tracks[idleTrack] === undefined) {
712
+ record('error', 'pet ' + manifest.id + ': frames2d idle track ' + JSON.stringify(idleTrack) + ' has no frames on disk')
713
+ return undefined
714
+ }
715
+ const phases = { ...block.phases }
716
+ for (const [phase, target] of Object.entries(phases)) {
717
+ if (target !== undefined && tracks[target] === undefined) {
718
+ record('warning', 'pet ' + manifest.id + ': frames2d phase ' + phase + ' maps to dropped track ' + JSON.stringify(target) + '; using idle')
719
+ phases[phase as ActivityPhase] = idleTrack
720
+ }
721
+ }
722
+ const remarks = normalizePetRemarks(manifest.remarks, message => record('warning', 'pet ' + manifest.id + ': ' + message))
723
+ // Gameplay layer: shop item icons are manifest-relative frame paths —
724
+ // verify them, promote them to browser URLs, and add them to the servable
725
+ // allow-list so the asset route can serve them.
726
+ let gameplay: PetGameplayManifest | undefined
727
+ if (manifest.gameplay !== undefined) {
728
+ gameplay = manifest.gameplay
729
+ if (gameplay.shop !== undefined) {
730
+ const items = []
731
+ for (const item of gameplay.shop.items) {
732
+ if (item.image === undefined) {
733
+ items.push(item)
734
+ continue
735
+ }
736
+ if (!existsSync(join(dir, item.image))) {
737
+ record('warning', 'pet ' + manifest.id + ': gameplay shop item ' + item.id + ' image missing: ' + item.image)
738
+ const { image, ...rest } = item
739
+ items.push(rest)
740
+ continue
741
+ }
742
+ servable.push(item.image)
743
+ items.push({ ...item, image: assetUrl(assetPrefix, manifest.id, item.image) })
744
+ }
745
+ gameplay = { ...gameplay, shop: { ...gameplay.shop, items } }
746
+ }
747
+ }
748
+ const flatTracks = buildTracks(DEFAULT_FRAME_COUNTS, DEFAULT_PET_COLUMNS, {}, message => record('warning', 'pet ' + manifest.id + ': ' + message))
749
+ if (flatTracks === undefined) return undefined
750
+ // The render box comes from the first idle frame when decodable.
751
+ const firstAbs = join(dir, firstFrameRel[idleTrack]!)
752
+ const dims = existsSync(firstAbs) ? readImageDimensions(firstAbs) : undefined
753
+ const cell = dims !== undefined && dims.width >= 1 && dims.height >= 1 ? dims : { ...DEFAULT_PET_CELL }
754
+ return {
755
+ id: manifest.id,
756
+ displayName: manifest.displayName,
757
+ description: manifest.description ?? '',
758
+ renderer: 'frames2d' as const,
759
+ frames2d: { tracks, phases },
760
+ ...(gameplay === undefined ? {} : { gameplay }),
761
+ cell,
762
+ columns: DEFAULT_PET_COLUMNS,
763
+ rows: [...DEFAULT_FRAME_COUNTS],
764
+ atlasRows: DEFAULT_PET_ROW_COUNT,
765
+ tracks: flatTracks,
766
+ atlasUrl: tracks[idleTrack]!.frames[0]!,
767
+ manifestUrl: assetUrl(assetPrefix, manifest.id, 'pet.json'),
768
+ dir,
769
+ spritesheetPath: firstFrameRel[idleTrack]!,
770
+ servable,
771
+ ...(remarks === undefined ? {} : { remarks }),
772
+ }
773
+ }
774
+
583
775
  /** Scan one directory of pet folders; entries come back in name order. */
584
776
  function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] }): PetEntry[] {
585
777
  if (!existsSync(dir)) return []
@@ -606,6 +798,8 @@ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: str
606
798
  let entry: PetEntry | undefined
607
799
  if (verdict.manifest.renderer === 'live2d') {
608
800
  entry = resolveLive2dEntry(verdict.manifest, entryDir, options)
801
+ } else if (verdict.manifest.renderer === 'frames2d') {
802
+ entry = resolveFrames2dEntry(verdict.manifest, entryDir, options)
609
803
  } else {
610
804
  const legacy = flattenV2Sprite2d(verdict.manifest)
611
805
  if (legacy === undefined) {
@@ -907,7 +1101,9 @@ export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
907
1101
  warnings,
908
1102
  diagnostics,
909
1103
  byId: (id: string) => byId.get(id),
910
- defaultEntry: () => entries.find(entry => builtinIds.has(entry.id)) ?? entries[0]!,
1104
+ defaultEntry: () => entries.find(entry => (
1105
+ entry.id === DEFAULT_PET_ID && builtinIds.has(entry.id)
1106
+ )) ?? entries.find(entry => builtinIds.has(entry.id)) ?? entries[0]!,
911
1107
  ...(globalVoice === undefined ? {} : { globalVoice }),
912
1108
  decorations,
913
1109
  decorationById: (id: string) => decorationById.get(id),
@@ -948,6 +1144,8 @@ export function petEntryView(entry: PetEntry, globalVoice?: VoicePack): PetDefin
948
1144
  description: entry.description,
949
1145
  renderer: entry.renderer,
950
1146
  ...(entry.live2d === undefined ? {} : { live2d: entry.live2d }),
1147
+ ...(entry.frames2d === undefined ? {} : { frames2d: entry.frames2d }),
1148
+ ...(entry.gameplay === undefined ? {} : { gameplay: entry.gameplay }),
951
1149
  cell: entry.cell,
952
1150
  columns: entry.columns,
953
1151
  rows: entry.rows,
package/src/routes.ts CHANGED
@@ -112,15 +112,15 @@ function guard(ctx: Context, req: IncomingMessage, res: ServerResponse): boolean
112
112
  return false
113
113
  }
114
114
 
115
- /** Wrap one async service call as a GET JSON route. */
116
- function getRoute(ctx: Context, path: string, run: () => Promise<unknown>): WebRoute {
115
+ /** Wrap one async service call as a GET JSON route (request passed through for query params). */
116
+ function getRoute(ctx: Context, path: string, run: (req: IncomingMessage) => Promise<unknown>): WebRoute {
117
117
  return {
118
118
  kind: 'exact',
119
119
  path,
120
120
  handler: (req: IncomingMessage, res: ServerResponse): void => {
121
121
  if (!guard(ctx, req, res)) return
122
122
  if (!requireMethod(req, res, 'GET')) return
123
- run().then((value) => writeJson(res, 200, value), (error) => {
123
+ run(req).then((value) => writeJson(res, 200, value), (error) => {
124
124
  writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
125
125
  })
126
126
  },
@@ -525,7 +525,12 @@ function decorationHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCa
525
525
  export function makePetRoutes(deps: { service: PetService; ctx: Context; assetCaps?: PetAssetCaps } & PetRuntimeRoots): WebRoute[] {
526
526
  const { service, ctx } = deps
527
527
  const apiRoutes: WebRoute[] = [
528
- getRoute(ctx, PET_API_PREFIX + '/state', () => service.state()),
528
+ getRoute(ctx, PET_API_PREFIX + '/state', (req) => {
529
+ // The browser half reports the GUI's current session id so the bubble
530
+ // stack can lead with the session the user is actually looking at.
531
+ const current = new URL(req.url ?? '/', 'http://pet.local').searchParams.get('current')
532
+ return service.state(current === null || current === '' ? undefined : current)
533
+ }),
529
534
  getRoute(ctx, PET_API_PREFIX + '/pets', () => service.pets()),
530
535
  getRoute(ctx, PET_API_PREFIX + '/diagnostics', () => service.diagnostics()),
531
536
  postRoute(ctx, PET_API_PREFIX + '/interact', (body) => {
@@ -554,6 +559,25 @@ export function makePetRoutes(deps: { service: PetService; ctx: Context; assetCa
554
559
  if (typeof petId !== 'string') return Promise.reject(new Error('invalid-pet'))
555
560
  return service.setPetId(petId)
556
561
  }),
562
+ // Gameplay verbs (miku-pet generalization): touch rolls a named zone's
563
+ // branch; the omitted-zone form is the plain-click boost during a touch
564
+ // animation. Mode/tick/buy drive the work, sleep and shop loops.
565
+ postRoute(ctx, PET_API_PREFIX + '/gameplay/touch', (body) => {
566
+ const zone = body.zone
567
+ if (zone !== undefined && typeof zone !== 'string') return Promise.reject(new Error('invalid-zone'))
568
+ return service.gameplayTouch(zone)
569
+ }),
570
+ postRoute(ctx, PET_API_PREFIX + '/gameplay/mode', (body) => {
571
+ const mode = body.mode
572
+ if (mode !== null && mode !== 'work' && mode !== 'sleep') return Promise.reject(new Error('invalid-mode'))
573
+ return service.gameplaySetMode(mode)
574
+ }),
575
+ postRoute(ctx, PET_API_PREFIX + '/gameplay/work-tick', () => service.gameplayWorkTick()),
576
+ postRoute(ctx, PET_API_PREFIX + '/gameplay/buy', (body) => {
577
+ const item = body.item
578
+ if (typeof item !== 'string') return Promise.reject(new Error('invalid-item'))
579
+ return service.gameplayBuy(item)
580
+ }),
557
581
  ]
558
582
 
559
583
  const assetRoute: WebRoute = {