@linxin666/dsh-pet 0.2.3 → 0.2.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 (87) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +65 -2
  3. package/README.zh.md +65 -2
  4. package/assets/decorations/whale/decoration.json +20 -0
  5. package/assets/decorations/whale/whale-frames.png +0 -0
  6. package/contracts/pet-manifest-v2.schema.json +281 -0
  7. package/contracts/status-decoration-v1.schema.json +144 -0
  8. package/contracts/voice-pack-v1.schema.json +234 -0
  9. package/lib/client.js +251 -43
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +1139 -100
  12. package/lib/types/access.d.ts +16 -0
  13. package/lib/types/access.d.ts.map +1 -0
  14. package/lib/types/access.js +20 -0
  15. package/lib/types/chatter.d.ts +61 -3
  16. package/lib/types/chatter.d.ts.map +1 -1
  17. package/lib/types/chatter.js +71 -12
  18. package/lib/types/client/PetSettingsCard.d.ts +4 -0
  19. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PetSettingsCard.js +11 -3
  21. package/lib/types/client/PetSprite.d.ts.map +1 -1
  22. package/lib/types/client/PetSprite.js +111 -4
  23. package/lib/types/client/locales.d.ts +4 -0
  24. package/lib/types/client/locales.d.ts.map +1 -1
  25. package/lib/types/client/locales.js +4 -0
  26. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -1
  27. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +1 -0
  28. package/lib/types/client/renderers/live2d/runtime.d.ts +13 -1
  29. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -1
  30. package/lib/types/client/renderers/live2d.d.ts.map +1 -1
  31. package/lib/types/client/renderers/live2d.js +133 -25
  32. package/lib/types/client/settings-form.d.ts.map +1 -1
  33. package/lib/types/client/settings-form.js +9 -6
  34. package/lib/types/contracts/status-decoration.d.ts +85 -0
  35. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  36. package/lib/types/contracts/status-decoration.js +21 -0
  37. package/lib/types/decoration.d.ts +39 -0
  38. package/lib/types/decoration.d.ts.map +1 -0
  39. package/lib/types/decoration.js +210 -0
  40. package/lib/types/event-projection.d.ts +8 -3
  41. package/lib/types/event-projection.d.ts.map +1 -1
  42. package/lib/types/event-projection.js +9 -4
  43. package/lib/types/image-dimensions.d.ts +26 -0
  44. package/lib/types/image-dimensions.d.ts.map +1 -0
  45. package/lib/types/image-dimensions.js +77 -0
  46. package/lib/types/index.d.ts +2 -0
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +3 -1
  49. package/lib/types/registry.d.ts +55 -2
  50. package/lib/types/registry.d.ts.map +1 -1
  51. package/lib/types/registry.js +240 -16
  52. package/lib/types/routes.d.ts +6 -1
  53. package/lib/types/routes.d.ts.map +1 -1
  54. package/lib/types/routes.js +163 -32
  55. package/lib/types/service.d.ts +33 -0
  56. package/lib/types/service.d.ts.map +1 -1
  57. package/lib/types/service.js +42 -3
  58. package/lib/types/voice-pack.d.ts +98 -0
  59. package/lib/types/voice-pack.d.ts.map +1 -0
  60. package/lib/types/voice-pack.js +384 -0
  61. package/package.json +11 -10
  62. package/src/access.ts +40 -0
  63. package/src/chatter.test.ts +89 -2
  64. package/src/chatter.ts +120 -14
  65. package/src/client/PetSettingsCard.tsx +26 -2
  66. package/src/client/PetSprite.test.tsx +325 -12
  67. package/src/client/PetSprite.tsx +150 -25
  68. package/src/client/locales.ts +4 -0
  69. package/src/client/renderers/live2d/Live2dVisualMount.test.tsx +72 -0
  70. package/src/client/renderers/live2d/Live2dVisualMount.tsx +1 -0
  71. package/src/client/renderers/live2d/runtime.ts +8 -2
  72. package/src/client/renderers/live2d.test.ts +233 -9
  73. package/src/client/renderers/live2d.ts +143 -30
  74. package/src/client/settings-form.ts +8 -6
  75. package/src/contracts/status-decoration.ts +78 -0
  76. package/src/decoration.test.ts +178 -0
  77. package/src/decoration.ts +220 -0
  78. package/src/event-projection.ts +10 -5
  79. package/src/image-dimensions.test.ts +85 -0
  80. package/src/image-dimensions.ts +76 -0
  81. package/src/index.ts +3 -1
  82. package/src/registry.test.ts +276 -0
  83. package/src/registry.ts +274 -15
  84. package/src/routes.ts +162 -33
  85. package/src/service.ts +59 -3
  86. package/src/voice-pack.test.ts +216 -0
  87. package/src/voice-pack.ts +413 -0
@@ -5,6 +5,7 @@ import { join } from 'node:path'
5
5
  import {
6
6
  DEFAULT_FRAME_COUNTS,
7
7
  DEFAULT_PET_CELL,
8
+ PET_SCAN_JSON_CAP,
8
9
  codexPetsDir,
9
10
  loadPetRegistry,
10
11
  petAtlasFile,
@@ -465,3 +466,278 @@ describe('loadPetRegistry pet-center v2 (issue #623)', () => {
465
466
  }
466
467
  })
467
468
  })
469
+
470
+ describe('voice packs (pet-center M4, issue #677)', () => {
471
+ function writeVoice(dir: string, name: string, pack: unknown): void {
472
+ mkdirSync(join(dir, name), { recursive: true })
473
+ writeFileSync(join(dir, name, 'pet.json'), JSON.stringify({ id: name, displayName: name, spritesheetPath: 'spritesheet.webp' }), 'utf8')
474
+ writeFileSync(join(dir, name, 'spritesheet.webp'), 'webp', 'utf8')
475
+ writeFileSync(join(dir, name, 'voice.json'), JSON.stringify(pack), 'utf8')
476
+ }
477
+
478
+ it('loads a pet voice.json and serves its panel slice to the browser view', () => {
479
+ const root = tempDir()
480
+ try {
481
+ const petsDir = join(root, 'pets')
482
+ writeVoice(petsDir, 'talker', {
483
+ status: { done: ['自定义完工'] },
484
+ panel: { labels: { feed: '投喂' }, stats: { rank: '好感 {rank}' }, actions: ['feed'] },
485
+ })
486
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
487
+ const entry = registry.byId('talker')!
488
+ expect(entry.voice?.overrides.status?.done).toEqual(['自定义完工'])
489
+ const view = petEntryView(entry)
490
+ expect(view.panel).toEqual({
491
+ labels: { feed: '投喂' },
492
+ stats: { rank: '好感 {rank}' },
493
+ actions: ['feed'],
494
+ })
495
+ // Host-only voice content never reaches the browser half.
496
+ expect('voice' in view).toBe(false)
497
+ // A healthy voice pack records no diagnostics of its own.
498
+ expect(registry.diagnostics.filter(d => d.message.includes('voice'))).toEqual([])
499
+ } finally {
500
+ rmSync(root, { recursive: true, force: true })
501
+ }
502
+ })
503
+
504
+ it('warns and drops a broken voice.json without rejecting the pet', () => {
505
+ const root = tempDir()
506
+ try {
507
+ const petsDir = join(root, 'pets')
508
+ mkdirSync(join(petsDir, 'mumbler'), { recursive: true })
509
+ writeFileSync(join(petsDir, 'mumbler', 'pet.json'), JSON.stringify({ id: 'mumbler', displayName: 'Mumbler', spritesheetPath: 'spritesheet.webp' }), 'utf8')
510
+ writeFileSync(join(petsDir, 'mumbler', 'spritesheet.webp'), 'webp', 'utf8')
511
+ writeFileSync(join(petsDir, 'mumbler', 'voice.json'), '{ not json', 'utf8')
512
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
513
+ expect(registry.byId('mumbler')).toBeDefined()
514
+ expect(registry.byId('mumbler')!.voice).toBeUndefined()
515
+ expect(registry.diagnostics.some(d => d.level === 'warning' && d.message.includes('voice pack is not valid JSON'))).toBe(true)
516
+ } finally {
517
+ rmSync(root, { recursive: true, force: true })
518
+ }
519
+ })
520
+
521
+ it('loads the global .voice.json override from the DSH_HOME pets dir', () => {
522
+ const root = tempDir()
523
+ try {
524
+ const petsDir = join(root, 'pets')
525
+ mkdirSync(join(petsDir, 'plain'), { recursive: true })
526
+ writeFileSync(join(petsDir, 'plain', 'pet.json'), JSON.stringify({ id: 'plain', displayName: 'Plain', spritesheetPath: 'spritesheet.webp' }), 'utf8')
527
+ writeFileSync(join(petsDir, 'plain', 'spritesheet.webp'), 'webp', 'utf8')
528
+ writeFileSync(join(petsDir, '.voice.json'), JSON.stringify({
529
+ status: { done: ['全局完工'] },
530
+ panel: { labels: { hide: '全局藏' } },
531
+ }), 'utf8')
532
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: petsDir })
533
+ expect(registry.globalVoice?.overrides.status?.done).toEqual(['全局完工'])
534
+ expect(registry.globalVoice?.panel?.labels).toEqual({ hide: '全局藏' })
535
+ // The dotfile itself is never scanned as a pet directory.
536
+ expect(registry.entries.map(e => e.id)).toEqual(['plain'])
537
+ } finally {
538
+ rmSync(root, { recursive: true, force: true })
539
+ }
540
+ })
541
+
542
+ it('serves the merged panel chrome (per-pet over global, per slot)', () => {
543
+ const root = tempDir()
544
+ try {
545
+ const petsDir = join(root, 'pets')
546
+ mkdirSync(join(petsDir, 'plain'), { recursive: true })
547
+ writeFileSync(join(petsDir, 'plain', 'pet.json'), JSON.stringify({ id: 'plain', displayName: 'Plain', spritesheetPath: 'spritesheet.webp' }), 'utf8')
548
+ writeFileSync(join(petsDir, 'plain', 'spritesheet.webp'), 'webp', 'utf8')
549
+ writeFileSync(join(petsDir, 'plain', 'voice.json'), JSON.stringify({
550
+ panel: { labels: { feed: '宠物投喂' }, stats: { treats: '宠物鱼干 {n}' } },
551
+ }), 'utf8')
552
+ writeFileSync(join(petsDir, '.voice.json'), JSON.stringify({
553
+ panel: { labels: { feed: '全局投喂', hide: '全局藏' } },
554
+ }), 'utf8')
555
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: petsDir })
556
+ const entry = registry.byId('plain')
557
+ expect(entry).toBeDefined()
558
+ const view = petEntryView(entry!, registry.globalVoice)
559
+ // The pet's own slot wins; untouched global slots layer underneath.
560
+ expect(view.panel?.labels).toEqual({ feed: '宠物投喂', hide: '全局藏' })
561
+ expect(view.panel?.stats?.treats).toBe('宠物鱼干 {n}')
562
+ // A pack-less pet would receive the global panel as-is.
563
+ const bare = resolvePetManifest({ id: 'bare', displayName: 'Bare', spritesheetPath: 'spritesheet.webp' }, join(tmpdir(), 'bare'))
564
+ expect(petEntryView(bare!, registry.globalVoice).panel?.labels).toEqual({ feed: '全局投喂', hide: '全局藏' })
565
+ } finally {
566
+ rmSync(root, { recursive: true, force: true })
567
+ }
568
+ })
569
+
570
+ it('skips an oversized voice.json with a warning instead of reading it', () => {
571
+ const root = tempDir()
572
+ try {
573
+ const petsDir = join(root, 'pets')
574
+ mkdirSync(join(petsDir, 'loud'), { recursive: true })
575
+ writeFileSync(join(petsDir, 'loud', 'pet.json'), JSON.stringify({ id: 'loud', displayName: 'Loud', spritesheetPath: 'spritesheet.webp' }), 'utf8')
576
+ writeFileSync(join(petsDir, 'loud', 'voice.json'), '{ ' + 'x'.repeat(PET_SCAN_JSON_CAP) + ' }', 'utf8')
577
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
578
+ expect(registry.byId('loud')!.voice).toBeUndefined()
579
+ expect(registry.warnings.some(w => w.includes('scan ceiling'))).toBe(true)
580
+ } finally {
581
+ rmSync(root, { recursive: true, force: true })
582
+ }
583
+ })
584
+
585
+ it('skips a non-regular voice.json with a warning', () => {
586
+ const root = tempDir()
587
+ try {
588
+ const petsDir = join(root, 'pets')
589
+ mkdirSync(join(petsDir, 'odd', 'voice.json'), { recursive: true })
590
+ writeFileSync(join(petsDir, 'odd', 'pet.json'), JSON.stringify({ id: 'odd', displayName: 'Odd', spritesheetPath: 'spritesheet.webp' }), 'utf8')
591
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
592
+ expect(registry.byId('odd')!.voice).toBeUndefined()
593
+ expect(registry.warnings.some(w => w.includes('not a regular file'))).toBe(true)
594
+ } finally {
595
+ rmSync(root, { recursive: true, force: true })
596
+ }
597
+ })
598
+ })
599
+ describe('status decorations (pet-center M5, #567)', () => {
600
+ /** A minimal valid PNG header (signature + IHDR) with the given size. */
601
+ function pngHeader(width: number, height: number): Buffer {
602
+ const buf = Buffer.alloc(26)
603
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0)
604
+ buf.writeUInt32BE(13, 8) // IHDR chunk length
605
+ buf.write('IHDR', 12) // chunk type
606
+ buf.writeUInt32BE(width, 16) // width
607
+ buf.writeUInt32BE(height, 20) // height
608
+ return buf
609
+ }
610
+
611
+ /** A strip whose pixel geometry matches baseManifest() (64x48 cell, 4 cols). */
612
+ function matchingStrip(): Buffer {
613
+ return pngHeader(64 * 4, 48)
614
+ }
615
+
616
+ function writeDecoration(dir: string, name: string, manifest: Record<string, unknown>, strip: Buffer | string = 'whale-frames.png'): void {
617
+ mkdirSync(join(dir, name), { recursive: true })
618
+ writeFileSync(join(dir, name, 'decoration.json'), JSON.stringify(manifest), 'utf8')
619
+ writeFileSync(join(dir, name, manifest.entry as string), strip)
620
+ }
621
+
622
+ const baseManifest = () => ({
623
+ decorationManifestVersion: 1,
624
+ id: 'whale',
625
+ displayName: '喷水鲸鱼',
626
+ license: 'MIT',
627
+ entry: 'whale-frames.png',
628
+ cell: { width: 64, height: 48 },
629
+ columns: 4,
630
+ phases: { idle: 'hide', thinking: { from: 0, to: 3 } },
631
+ })
632
+
633
+ it('scans built-in decorations and exposes the browser view fields', () => {
634
+ const root = tempDir()
635
+ try {
636
+ const assets = join(root, 'assets')
637
+ writeDecoration(join(assets, 'decorations'), 'whale', baseManifest(), matchingStrip())
638
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
639
+ const entry = registry.decorationById?.('whale')
640
+ expect(entry).toBeDefined()
641
+ expect(entry!.entryUrl).toBe('/api/pet/decoration/whale/whale-frames.png')
642
+ expect(entry!.servable).toEqual(['decoration.json', 'whale-frames.png'])
643
+ expect(entry!.phases.thinking).toEqual({ from: 0, to: 3 })
644
+ // The pet entries list is untouched by decorations.
645
+ expect(registry.entries).toEqual([])
646
+ } finally {
647
+ rmSync(root, { recursive: true, force: true })
648
+ }
649
+ })
650
+
651
+ it('lets a user decoration override the built-in by id', () => {
652
+ const root = tempDir()
653
+ try {
654
+ writeDecoration(join(root, 'assets', 'decorations'), 'whale', baseManifest(), matchingStrip())
655
+ const dsh = join(root, 'dsh')
656
+ writeDecoration(join(dsh, 'decorations'), 'whale', { ...baseManifest(), displayName: '家用鲸鱼' }, matchingStrip())
657
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: dsh })
658
+ expect(registry.decorationById?.('whale')?.id).toBe('whale')
659
+ expect(registry.warnings.some(w => w.includes('user decoration whale overrides'))).toBe(true)
660
+ expect(registry.decorationById?.('whale')?.entryUrl).toBe('/api/pet/decoration/whale/whale-frames.png')
661
+ } finally {
662
+ rmSync(root, { recursive: true, force: true })
663
+ }
664
+ })
665
+
666
+ it('warns and skips a broken descriptor without disturbing pets', () => {
667
+ const root = tempDir()
668
+ try {
669
+ mkdirSync(join(root, 'assets', 'decorations', 'broken'), { recursive: true })
670
+ writeFileSync(join(root, 'assets', 'decorations', 'broken', 'decoration.json'), '{ not json', 'utf8')
671
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
672
+ expect(registry.decorations).toEqual([])
673
+ expect(registry.diagnostics.some(d => d.level === 'error' && d.message.includes('broken'))).toBe(true)
674
+ } finally {
675
+ rmSync(root, { recursive: true, force: true })
676
+ }
677
+ })
678
+
679
+ it('skips an oversized decoration.json with a warning instead of reading it', () => {
680
+ const root = tempDir()
681
+ try {
682
+ mkdirSync(join(root, 'assets', 'decorations', 'huge'), { recursive: true })
683
+ writeFileSync(join(root, 'assets', 'decorations', 'huge', 'decoration.json'), '{ ' + 'x'.repeat(PET_SCAN_JSON_CAP) + ' }', 'utf8')
684
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
685
+ expect(registry.decorations).toEqual([])
686
+ expect(registry.warnings.some(w => w.includes('scan ceiling'))).toBe(true)
687
+ } finally {
688
+ rmSync(root, { recursive: true, force: true })
689
+ }
690
+ })
691
+
692
+ it('lists a decoration with a missing strip and warns about the file', () => {
693
+ const root = tempDir()
694
+ try {
695
+ const dir = join(root, 'assets', 'decorations', 'ghost')
696
+ mkdirSync(dir, { recursive: true })
697
+ writeFileSync(join(dir, 'decoration.json'), JSON.stringify(baseManifest()), 'utf8')
698
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
699
+ // The entry id comes from the descriptor (the directory name is free).
700
+ expect(registry.decorationById?.('whale')).toBeDefined()
701
+ expect(registry.warnings.some(w => w.includes('strip file missing'))).toBe(true)
702
+ } finally {
703
+ rmSync(root, { recursive: true, force: true })
704
+ }
705
+ })
706
+
707
+ it('warns and keeps a decoration whose strip geometry mismatches the descriptor', () => {
708
+ const root = tempDir()
709
+ try {
710
+ // Declared 64x48 cell x 4 columns = 256x48; the file is only 128x48
711
+ // (2 frames worth) — the client would silently render half the frames.
712
+ const dir = join(root, 'assets', 'decorations', 'short')
713
+ mkdirSync(dir, { recursive: true })
714
+ writeFileSync(join(dir, 'decoration.json'), JSON.stringify(baseManifest()), 'utf8')
715
+ writeFileSync(join(dir, 'whale-frames.png'), pngHeader(128, 48))
716
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
717
+ // Warn-and-keep: the entry still lists (mirroring the missing-strip
718
+ // discipline) and the warning names the mismatch.
719
+ expect(registry.decorationById?.('whale')).toBeDefined()
720
+ expect(registry.warnings.some(w => w.includes('does not match cell'))).toBe(true)
721
+ } finally {
722
+ rmSync(root, { recursive: true, force: true })
723
+ }
724
+ })
725
+
726
+ it('does not warn when a matching strip is undecodable (non-image bytes)', () => {
727
+ const root = tempDir()
728
+ try {
729
+ const dir = join(root, 'assets', 'decorations', 'opaque')
730
+ mkdirSync(dir, { recursive: true })
731
+ writeFileSync(join(dir, 'decoration.json'), JSON.stringify(baseManifest()), 'utf8')
732
+ // Unrecognized bytes: the header reader returns undefined, so the scan
733
+ // stays silent (cannot verify != mismatch). Only the missing-file check
734
+ // applies.
735
+ writeFileSync(join(dir, 'whale-frames.png'), Buffer.from('not-an-image'))
736
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
737
+ expect(registry.decorationById?.('whale')).toBeDefined()
738
+ expect(registry.warnings.some(w => w.includes('does not match cell'))).toBe(false)
739
+ } finally {
740
+ rmSync(root, { recursive: true, force: true })
741
+ }
742
+ })
743
+ })
package/src/registry.ts CHANGED
@@ -27,12 +27,16 @@
27
27
  * @module @linxin666/dsh-pet/registry
28
28
  */
29
29
 
30
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
30
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs'
31
31
  import { homedir } from 'node:os'
32
32
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
33
33
  import { fileURLToPath } from 'node:url'
34
34
  import type { ActivityPhase, PetAnimation } from './state.ts'
35
35
  import { normalizePetRemarks, type PetRemarks, type PetRemarksManifest } from './remarks.ts'
36
+ import { mergeVoicePacks, normalizeVoicePack, type PetPanelView, type VoicePack } from './voice-pack.ts'
37
+ import { parseDecorationManifest } from './decoration.ts'
38
+ import { imageDimensions } from './image-dimensions.ts'
39
+ import { PET_DECORATION_API_VERSION, type DecorationView } from './contracts/status-decoration.ts'
36
40
  import { dshHome } from './dsh-home.ts'
37
41
  import { parsePetManifest, type PetManifestLive2d, type PetManifestV2, type PetRendererKind } from './manifest-v2.ts'
38
42
  import { collectModel3References } from './model3.ts'
@@ -212,6 +216,8 @@ export interface PetDefinition {
212
216
  atlasUrl: string
213
217
  /** Browser URL of the manifest (served by the host asset route). */
214
218
  manifestUrl: string
219
+ /** Hover-panel chrome overrides (voice.json 'panel'; pet-center M4). */
220
+ panel?: PetPanelView
215
221
  }
216
222
 
217
223
  /** A resolved pet plus its host-side file location. */
@@ -228,6 +234,11 @@ export interface PetEntry extends PetDefinition {
228
234
  servable: readonly string[]
229
235
  /** Normalized per-pet remark pools (manifest 'remarks'), when declared. */
230
236
  remarks?: PetRemarks
237
+ /**
238
+ * Normalized per-pet voice pack (the directory's voice.json; pet-center
239
+ * M4). Host-side only — the browser half receives its 'panel' slice.
240
+ */
241
+ voice?: VoicePack
231
242
  }
232
243
 
233
244
  /** Registry load result: resolved entries plus load warnings. */
@@ -239,6 +250,32 @@ export interface PetRegistry {
239
250
  byId(id: string): PetEntry | undefined
240
251
  /** The pet an installation falls back to when the selection is unknown. */
241
252
  defaultEntry(): PetEntry
253
+ /**
254
+ * The global voice override ('$DSH_HOME/pets/.voice.json'), when present —
255
+ * layers under every per-pet pack and over the built-in pools (M4, #677).
256
+ */
257
+ globalVoice?: VoicePack
258
+ /**
259
+ * Status decorations (pet-center M5, #567): built-in 'assets/decorations'
260
+ * entries overridden by same-id user entries under
261
+ * '$DSH_HOME/pets/decorations'. Independent of the pet entries. Optional
262
+ * so prebuilt test registries without decorations keep compiling.
263
+ */
264
+ decorations?: DecorationEntry[]
265
+ /** Look up one decoration by id. */
266
+ decorationById?(id: string): DecorationEntry | undefined
267
+ }
268
+
269
+ /** One resolved status decoration plus its host-side file location. */
270
+ export interface DecorationEntry extends DecorationView {
271
+ /** Absolute directory holding the descriptor and strip. */
272
+ dir: string
273
+ /** Strip path relative to 'dir' (declared by the descriptor). */
274
+ entryPath: string
275
+ /** Descriptor-relative files the decoration asset route may serve. */
276
+ servable: readonly string[]
277
+ /** Asset license identifier (required by the descriptor). */
278
+ license: string
242
279
  }
243
280
 
244
281
  /** One structured registry diagnostic (manifest-v2 era). */
@@ -551,20 +588,23 @@ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: str
551
588
  options.warnings?.push(diagnostic.message)
552
589
  }
553
590
  if (!verdict.ok) continue
591
+ let entry: PetEntry | undefined
554
592
  if (verdict.manifest.renderer === 'live2d') {
555
- const entry = resolveLive2dEntry(verdict.manifest, entryDir, options)
556
- if (entry !== undefined) entries.push(entry)
557
- continue
593
+ entry = resolveLive2dEntry(verdict.manifest, entryDir, options)
594
+ } else {
595
+ const legacy = flattenV2Sprite2d(verdict.manifest)
596
+ if (legacy === undefined) {
597
+ const note = 'pet ' + verdict.manifest.id + ': sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver'
598
+ options.diagnostics?.push({ level: 'error', source: entryDir, message: note })
599
+ options.warnings?.push(note)
600
+ continue
601
+ }
602
+ entry = resolvePetManifest(legacy, entryDir, options)
558
603
  }
559
- const legacy = flattenV2Sprite2d(verdict.manifest)
560
- if (legacy === undefined) {
561
- const note = 'pet ' + verdict.manifest.id + ': sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver'
562
- options.diagnostics?.push({ level: 'error', source: entryDir, message: note })
563
- options.warnings?.push(note)
564
- continue
565
- }
566
- const entry = resolvePetManifest(legacy, entryDir, options)
567
- if (entry !== undefined) entries.push(entry)
604
+ if (entry === undefined) continue
605
+ // Optional voice pack (voice.json) pure content, warn-and-drop (M4).
606
+ const voice = loadVoicePackFile(join(entryDir, 'voice.json'), options)
607
+ entries.push({ ...entry, ...(voice === undefined ? {} : { voice }) })
568
608
  }
569
609
  return entries
570
610
  }
@@ -579,6 +619,176 @@ function readPetJson(file: string, warnings: string[] | undefined): unknown {
579
619
  }
580
620
  }
581
621
 
622
+ /**
623
+ * Scan-time read ceiling for user-authored JSON descriptors (voice.json,
624
+ * .voice.json, decoration.json): the registry reads these synchronously at
625
+ * plugin startup, and a pathological file — multi-GB, or a FIFO/device
626
+ * symlink — must not hang or exhaust the host before the warn-and-drop
627
+ * discipline can apply (review-spd follow-up, pet-center M4/M5).
628
+ */
629
+ export const PET_SCAN_JSON_CAP = 64 * 1024
630
+
631
+ /**
632
+ * Stat one scanned JSON descriptor with a regular-file + size guard, so a
633
+ * pathological user file is skipped with a warning instead of stalling or
634
+ * OOM-ing the host at startup. Returns the Stats, or undefined when the
635
+ * caller must skip the file (a warning was recorded).
636
+ */
637
+ function guardedScannedJsonStat(
638
+ file: string,
639
+ options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
640
+ what: string,
641
+ ): ReturnType<typeof statSync> | undefined {
642
+ let st: ReturnType<typeof statSync>
643
+ try {
644
+ st = statSync(file)
645
+ } catch {
646
+ return undefined
647
+ }
648
+ const warn = (message: string): void => {
649
+ options.warnings?.push(file + ': ' + message)
650
+ options.diagnostics?.push({ level: 'warning', source: file, message: file + ': ' + message })
651
+ }
652
+ if (!st.isFile()) {
653
+ warn(what + ' is not a regular file; ignored')
654
+ return undefined
655
+ }
656
+ if (st.size > PET_SCAN_JSON_CAP) {
657
+ warn(what + ' exceeds the ' + PET_SCAN_JSON_CAP + '-byte scan ceiling; ignored')
658
+ return undefined
659
+ }
660
+ return st
661
+ }
662
+
663
+ /**
664
+ * Load and normalize one optional voice.json (pet-center M4). A missing
665
+ * file is silent; a broken file warns and drops. The pack is pure content,
666
+ * so every issue stays a warning — a bad voice.json never rejects a pet.
667
+ */
668
+ function loadVoicePackFile(
669
+ file: string,
670
+ options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
671
+ ): VoicePack | undefined {
672
+ if (!existsSync(file)) return undefined
673
+ if (guardedScannedJsonStat(file, options, 'voice pack') === undefined) return undefined
674
+ const warn = (message: string): void => {
675
+ options.warnings?.push(file + ': ' + message)
676
+ options.diagnostics?.push({ level: 'warning', source: file, message: file + ': ' + message })
677
+ }
678
+ let raw: unknown
679
+ try {
680
+ raw = JSON.parse(readFileSync(file, 'utf8'))
681
+ } catch (error) {
682
+ warn('voice pack is not valid JSON; ignored: ' + (error instanceof Error ? error.message : String(error)))
683
+ return undefined
684
+ }
685
+ return normalizeVoicePack(raw, warn)
686
+ }
687
+
688
+ /** Decoration asset URL prefix (served by the decoration route, M5). */
689
+ export const DECORATION_ASSET_PREFIX = '/api/pet/decoration'
690
+
691
+ /** Read the pixel dimensions of a decoration strip (PNG/WebP), if decodable. */
692
+ function readImageDimensions(file: string): { width: number; height: number } | undefined {
693
+ let header: Buffer
694
+ try {
695
+ // Only the header is needed for dimensions; cap the read so a huge or
696
+ // corrupt strip cannot balloon memory during the registry scan.
697
+ const fd = openSync(file, 'r')
698
+ try {
699
+ header = Buffer.alloc(64)
700
+ const read = readSync(fd, header, 0, header.length, 0)
701
+ if (read < 0) return undefined
702
+ header = header.subarray(0, read)
703
+ } finally {
704
+ closeSync(fd)
705
+ }
706
+ } catch {
707
+ return undefined
708
+ }
709
+ return imageDimensions(header)
710
+ }
711
+
712
+ /**
713
+ * Scan one directory of decoration folders ('decoration.json' + strip).
714
+ * Later scans override earlier ones on id collision; a bad descriptor warns
715
+ * and skips — the never-throw philosophy holds for decorations too (M5).
716
+ */
717
+ function scanDecorationDir(dir: string, options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] }): DecorationEntry[] {
718
+ if (!existsSync(dir)) return []
719
+ let names: string[] = []
720
+ try {
721
+ names = readdirSync(dir).filter(name => !name.startsWith('.'))
722
+ } catch {
723
+ return []
724
+ }
725
+ names.sort()
726
+ const entries: DecorationEntry[] = []
727
+ for (const name of names) {
728
+ const entryDir = join(dir, name)
729
+ const manifestFile = join(entryDir, 'decoration.json')
730
+ if (!existsSync(manifestFile)) continue
731
+ if (guardedScannedJsonStat(manifestFile, options, 'decoration descriptor') === undefined) continue
732
+ let raw: unknown
733
+ try {
734
+ raw = JSON.parse(readFileSync(manifestFile, 'utf8'))
735
+ } catch (error) {
736
+ const message = 'skipping ' + manifestFile + ': ' + (error instanceof Error ? error.message : String(error))
737
+ options.warnings?.push(message)
738
+ options.diagnostics?.push({ level: 'error', source: entryDir, message })
739
+ continue
740
+ }
741
+ const verdict = parseDecorationManifest(raw, manifestFile)
742
+ for (const diagnostic of verdict.diagnostics) {
743
+ options.diagnostics?.push({ level: diagnostic.level, source: entryDir, message: diagnostic.message })
744
+ options.warnings?.push(diagnostic.message)
745
+ }
746
+ if (!verdict.ok) continue
747
+ const manifest = verdict.manifest
748
+ // A missing strip keeps the entry listed (mirroring the live2d closure
749
+ // discipline) but earns a diagnostic: the ornament will silently render
750
+ // nothing, and the warning names the file to fix.
751
+ if (!existsSync(join(entryDir, manifest.entry))) {
752
+ const message = 'decoration ' + manifest.id + ': strip file missing: ' + manifest.entry
753
+ options.warnings?.push(message)
754
+ options.diagnostics?.push({ level: 'warning', source: entryDir, message })
755
+ } else {
756
+ // Geometry check: the client renders the strip as a single row of
757
+ // 'columns' frames (background-position advances by frame width only),
758
+ // so the strip must be exactly cell.width * columns wide and cell.height
759
+ // tall. A mismatched strip silently shows the wrong/partial frames —
760
+ // warn-and-keep, mirroring the missing-strip discipline (never throw).
761
+ const actual = readImageDimensions(join(entryDir, manifest.entry))
762
+ if (actual !== undefined) {
763
+ const expectedWidth = manifest.cell.width * manifest.columns
764
+ if (actual.width !== expectedWidth || actual.height !== manifest.cell.height) {
765
+ const message = 'decoration ' + manifest.id + ': strip ' + actual.width + 'x' + actual.height
766
+ + ' does not match cell ' + manifest.cell.width + 'x' + manifest.cell.height + ' x ' + manifest.columns
767
+ + ' columns (expected ' + expectedWidth + 'x' + manifest.cell.height + '); frames will render wrong'
768
+ options.warnings?.push(message)
769
+ options.diagnostics?.push({ level: 'warning', source: entryDir, message })
770
+ }
771
+ }
772
+ }
773
+ entries.push({
774
+ apiVersion: PET_DECORATION_API_VERSION,
775
+ id: manifest.id,
776
+ dir: entryDir,
777
+ entryPath: manifest.entry,
778
+ servable: ['decoration.json', manifest.entry],
779
+ license: manifest.license,
780
+ assetBase: DECORATION_ASSET_PREFIX + '/' + encodeURIComponent(manifest.id),
781
+ entryUrl: DECORATION_ASSET_PREFIX + '/' + encodeURIComponent(manifest.id) + '/' + manifest.entry,
782
+ cell: manifest.cell,
783
+ columns: manifest.columns,
784
+ durations: manifest.durations,
785
+ loop: manifest.loop,
786
+ phases: manifest.phases,
787
+ })
788
+ }
789
+ return entries
790
+ }
791
+
582
792
  /**
583
793
  * Load the pet registry: built-in 'assets/*' first, then the hatch-pet
584
794
  * custom pets directory, then composed 'extra' manifests (each later source
@@ -611,11 +821,14 @@ export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
611
821
 
612
822
  // The pet-center user directory ranks above the legacy hatch-pet source.
613
823
  const dshPetsDir = options.dshPetsDir ?? join(dshHome(), 'pets')
824
+ let globalVoice: VoicePack | undefined
614
825
  if (dshPetsDir !== '') {
615
826
  for (const entry of scanPetDir(dshPetsDir, { assetPrefix, warnings, diagnostics })) {
616
827
  if (byId.has(entry.id)) warnings.push('user pet ' + entry.id + ' overrides an earlier registration')
617
828
  byId.set(entry.id, entry)
618
829
  }
830
+ // The global voice override layers under every per-pet pack (M4, #677).
831
+ globalVoice = loadVoicePackFile(join(dshPetsDir, '.voice.json'), { warnings, diagnostics })
619
832
  }
620
833
 
621
834
  for (const manifest of options.extra ?? []) {
@@ -636,18 +849,63 @@ export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
636
849
  byId.set(entry.id, entry)
637
850
  }
638
851
 
852
+ // Status decorations (pet-center M5, #567): built-in entries first,
853
+ // then same-id user entries under '$DSH_HOME/pets/decorations' override.
854
+ const decorationById = new Map<string, DecorationEntry>()
855
+ for (const entry of scanDecorationDir(join(packageRoot, 'assets', 'decorations'), { warnings, diagnostics })) {
856
+ decorationById.set(entry.id, entry)
857
+ }
858
+ if (dshPetsDir !== '') {
859
+ for (const entry of scanDecorationDir(join(dshPetsDir, 'decorations'), { warnings, diagnostics })) {
860
+ if (decorationById.has(entry.id)) {
861
+ warnings.push('user decoration ' + entry.id + ' overrides the built-in one')
862
+ }
863
+ decorationById.set(entry.id, entry)
864
+ }
865
+ }
866
+
639
867
  const entries = [...byId.values()]
868
+ const decorations = [...decorationById.values()]
640
869
  return {
641
870
  entries,
642
871
  warnings,
643
872
  diagnostics,
644
873
  byId: (id: string) => byId.get(id),
645
874
  defaultEntry: () => entries.find(entry => builtinIds.has(entry.id)) ?? entries[0]!,
875
+ ...(globalVoice === undefined ? {} : { globalVoice }),
876
+ decorations,
877
+ decorationById: (id: string) => decorationById.get(id),
646
878
  }
647
879
  }
648
880
 
649
- /** Strip host-only fields, leaving the client-visible definition. */
650
- export function petEntryView(entry: PetEntry): PetDefinition {
881
+ /** The built-in default decoration id (M5): the first reference ornament. */
882
+ export const DEFAULT_DECORATION_ID = 'whale'
883
+
884
+ /** Strip host-only fields, leaving the browser-visible decoration view. */
885
+ export function decorationView(entry: DecorationEntry): DecorationView {
886
+ return {
887
+ apiVersion: PET_DECORATION_API_VERSION,
888
+ id: entry.id,
889
+ assetBase: entry.assetBase,
890
+ entryUrl: entry.entryUrl,
891
+ cell: entry.cell,
892
+ columns: entry.columns,
893
+ durations: entry.durations,
894
+ loop: entry.loop,
895
+ phases: entry.phases,
896
+ }
897
+ }
898
+
899
+ /**
900
+ * Strip host-only fields, leaving the client-visible definition. When the
901
+ * registry carries a global voice pack, its panel chrome layers under the
902
+ * entry's own pack (per-slot merge, pet > global), mirroring the voice-pool
903
+ * layering (pet-center M4, issue #677).
904
+ */
905
+ export function petEntryView(entry: PetEntry, globalVoice?: VoicePack): PetDefinition {
906
+ const panel = globalVoice === undefined
907
+ ? entry.voice?.panel
908
+ : mergeVoicePacks(globalVoice, entry.voice)?.panel
651
909
  return {
652
910
  id: entry.id,
653
911
  displayName: entry.displayName,
@@ -662,6 +920,7 @@ export function petEntryView(entry: PetEntry): PetDefinition {
662
920
  ...(entry.sequences === undefined ? {} : { sequences: entry.sequences }),
663
921
  atlasUrl: entry.atlasUrl,
664
922
  manifestUrl: entry.manifestUrl,
923
+ ...(panel === undefined ? {} : { panel }),
665
924
  }
666
925
  }
667
926