@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.
- package/README.i18n.yaml +2 -2
- package/README.md +65 -2
- package/README.zh.md +65 -2
- package/assets/decorations/whale/decoration.json +20 -0
- package/assets/decorations/whale/whale-frames.png +0 -0
- package/contracts/pet-manifest-v2.schema.json +281 -0
- package/contracts/status-decoration-v1.schema.json +144 -0
- package/contracts/voice-pack-v1.schema.json +234 -0
- package/lib/client.js +251 -43
- package/lib/client.js.map +1 -1
- package/lib/index.js +1139 -100
- package/lib/types/access.d.ts +16 -0
- package/lib/types/access.d.ts.map +1 -0
- package/lib/types/access.js +20 -0
- package/lib/types/chatter.d.ts +61 -3
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +71 -12
- package/lib/types/client/PetSettingsCard.d.ts +4 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +11 -3
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +111 -4
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d/Live2dVisualMount.js +1 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts +13 -1
- package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d.js +133 -25
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- package/lib/types/contracts/status-decoration.d.ts +85 -0
- package/lib/types/contracts/status-decoration.d.ts.map +1 -0
- package/lib/types/contracts/status-decoration.js +21 -0
- package/lib/types/decoration.d.ts +39 -0
- package/lib/types/decoration.d.ts.map +1 -0
- package/lib/types/decoration.js +210 -0
- package/lib/types/event-projection.d.ts +8 -3
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +9 -4
- package/lib/types/image-dimensions.d.ts +26 -0
- package/lib/types/image-dimensions.d.ts.map +1 -0
- package/lib/types/image-dimensions.js +77 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +3 -1
- package/lib/types/registry.d.ts +55 -2
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +240 -16
- package/lib/types/routes.d.ts +6 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +163 -32
- package/lib/types/service.d.ts +33 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +42 -3
- package/lib/types/voice-pack.d.ts +98 -0
- package/lib/types/voice-pack.d.ts.map +1 -0
- package/lib/types/voice-pack.js +384 -0
- package/package.json +11 -10
- package/src/access.ts +40 -0
- package/src/chatter.test.ts +89 -2
- package/src/chatter.ts +120 -14
- package/src/client/PetSettingsCard.tsx +26 -2
- package/src/client/PetSprite.test.tsx +325 -12
- package/src/client/PetSprite.tsx +150 -25
- package/src/client/locales.ts +4 -0
- package/src/client/renderers/live2d/Live2dVisualMount.test.tsx +72 -0
- package/src/client/renderers/live2d/Live2dVisualMount.tsx +1 -0
- package/src/client/renderers/live2d/runtime.ts +8 -2
- package/src/client/renderers/live2d.test.ts +233 -9
- package/src/client/renderers/live2d.ts +143 -30
- package/src/client/settings-form.ts +8 -6
- package/src/contracts/status-decoration.ts +78 -0
- package/src/decoration.test.ts +178 -0
- package/src/decoration.ts +220 -0
- package/src/event-projection.ts +10 -5
- package/src/image-dimensions.test.ts +85 -0
- package/src/image-dimensions.ts +76 -0
- package/src/index.ts +3 -1
- package/src/registry.test.ts +276 -0
- package/src/registry.ts +274 -15
- package/src/routes.ts +162 -33
- package/src/service.ts +59 -3
- package/src/voice-pack.test.ts +216 -0
- package/src/voice-pack.ts +413 -0
package/src/registry.test.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
/**
|
|
650
|
-
export
|
|
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
|
|