@linxin666/dsh-pet 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +64 -1
- package/README.zh.md +64 -1
- 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 +144 -18
- package/lib/client.js.map +1 -1
- package/lib/index.js +979 -70
- 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 +3 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +103 -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.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d.js +13 -1
- 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/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +2 -0
- package/lib/types/registry.d.ts +55 -2
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +198 -16
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +131 -3
- 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/chatter.test.ts +89 -2
- package/src/chatter.ts +120 -14
- package/src/client/PetSettingsCard.tsx +18 -0
- package/src/client/PetSprite.test.tsx +254 -12
- package/src/client/PetSprite.tsx +142 -25
- package/src/client/locales.ts +4 -0
- package/src/client/renderers/live2d.test.ts +23 -2
- package/src/client/renderers/live2d.ts +14 -1
- 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/index.ts +2 -0
- package/src/registry.test.ts +223 -0
- package/src/registry.ts +235 -15
- package/src/routes.ts +128 -3
- package/src/service.ts +59 -3
- package/src/voice-pack.test.ts +216 -0
- package/src/voice-pack.ts +413 -0
package/src/registry.ts
CHANGED
|
@@ -27,12 +27,15 @@
|
|
|
27
27
|
* @module @linxin666/dsh-pet/registry
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
30
|
+
import { existsSync, readdirSync, readFileSync, 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 { PET_DECORATION_API_VERSION, type DecorationView } from './contracts/status-decoration.ts'
|
|
36
39
|
import { dshHome } from './dsh-home.ts'
|
|
37
40
|
import { parsePetManifest, type PetManifestLive2d, type PetManifestV2, type PetRendererKind } from './manifest-v2.ts'
|
|
38
41
|
import { collectModel3References } from './model3.ts'
|
|
@@ -212,6 +215,8 @@ export interface PetDefinition {
|
|
|
212
215
|
atlasUrl: string
|
|
213
216
|
/** Browser URL of the manifest (served by the host asset route). */
|
|
214
217
|
manifestUrl: string
|
|
218
|
+
/** Hover-panel chrome overrides (voice.json 'panel'; pet-center M4). */
|
|
219
|
+
panel?: PetPanelView
|
|
215
220
|
}
|
|
216
221
|
|
|
217
222
|
/** A resolved pet plus its host-side file location. */
|
|
@@ -228,6 +233,11 @@ export interface PetEntry extends PetDefinition {
|
|
|
228
233
|
servable: readonly string[]
|
|
229
234
|
/** Normalized per-pet remark pools (manifest 'remarks'), when declared. */
|
|
230
235
|
remarks?: PetRemarks
|
|
236
|
+
/**
|
|
237
|
+
* Normalized per-pet voice pack (the directory's voice.json; pet-center
|
|
238
|
+
* M4). Host-side only — the browser half receives its 'panel' slice.
|
|
239
|
+
*/
|
|
240
|
+
voice?: VoicePack
|
|
231
241
|
}
|
|
232
242
|
|
|
233
243
|
/** Registry load result: resolved entries plus load warnings. */
|
|
@@ -239,6 +249,32 @@ export interface PetRegistry {
|
|
|
239
249
|
byId(id: string): PetEntry | undefined
|
|
240
250
|
/** The pet an installation falls back to when the selection is unknown. */
|
|
241
251
|
defaultEntry(): PetEntry
|
|
252
|
+
/**
|
|
253
|
+
* The global voice override ('$DSH_HOME/pets/.voice.json'), when present —
|
|
254
|
+
* layers under every per-pet pack and over the built-in pools (M4, #677).
|
|
255
|
+
*/
|
|
256
|
+
globalVoice?: VoicePack
|
|
257
|
+
/**
|
|
258
|
+
* Status decorations (pet-center M5, #567): built-in 'assets/decorations'
|
|
259
|
+
* entries overridden by same-id user entries under
|
|
260
|
+
* '$DSH_HOME/pets/decorations'. Independent of the pet entries. Optional
|
|
261
|
+
* so prebuilt test registries without decorations keep compiling.
|
|
262
|
+
*/
|
|
263
|
+
decorations?: DecorationEntry[]
|
|
264
|
+
/** Look up one decoration by id. */
|
|
265
|
+
decorationById?(id: string): DecorationEntry | undefined
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** One resolved status decoration plus its host-side file location. */
|
|
269
|
+
export interface DecorationEntry extends DecorationView {
|
|
270
|
+
/** Absolute directory holding the descriptor and strip. */
|
|
271
|
+
dir: string
|
|
272
|
+
/** Strip path relative to 'dir' (declared by the descriptor). */
|
|
273
|
+
entryPath: string
|
|
274
|
+
/** Descriptor-relative files the decoration asset route may serve. */
|
|
275
|
+
servable: readonly string[]
|
|
276
|
+
/** Asset license identifier (required by the descriptor). */
|
|
277
|
+
license: string
|
|
242
278
|
}
|
|
243
279
|
|
|
244
280
|
/** One structured registry diagnostic (manifest-v2 era). */
|
|
@@ -551,20 +587,23 @@ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: str
|
|
|
551
587
|
options.warnings?.push(diagnostic.message)
|
|
552
588
|
}
|
|
553
589
|
if (!verdict.ok) continue
|
|
590
|
+
let entry: PetEntry | undefined
|
|
554
591
|
if (verdict.manifest.renderer === 'live2d') {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
592
|
+
entry = resolveLive2dEntry(verdict.manifest, entryDir, options)
|
|
593
|
+
} else {
|
|
594
|
+
const legacy = flattenV2Sprite2d(verdict.manifest)
|
|
595
|
+
if (legacy === undefined) {
|
|
596
|
+
const note = 'pet ' + verdict.manifest.id + ': sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver'
|
|
597
|
+
options.diagnostics?.push({ level: 'error', source: entryDir, message: note })
|
|
598
|
+
options.warnings?.push(note)
|
|
599
|
+
continue
|
|
600
|
+
}
|
|
601
|
+
entry = resolvePetManifest(legacy, entryDir, options)
|
|
565
602
|
}
|
|
566
|
-
|
|
567
|
-
|
|
603
|
+
if (entry === undefined) continue
|
|
604
|
+
// Optional voice pack (voice.json) — pure content, warn-and-drop (M4).
|
|
605
|
+
const voice = loadVoicePackFile(join(entryDir, 'voice.json'), options)
|
|
606
|
+
entries.push({ ...entry, ...(voice === undefined ? {} : { voice }) })
|
|
568
607
|
}
|
|
569
608
|
return entries
|
|
570
609
|
}
|
|
@@ -579,6 +618,138 @@ function readPetJson(file: string, warnings: string[] | undefined): unknown {
|
|
|
579
618
|
}
|
|
580
619
|
}
|
|
581
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Scan-time read ceiling for user-authored JSON descriptors (voice.json,
|
|
623
|
+
* .voice.json, decoration.json): the registry reads these synchronously at
|
|
624
|
+
* plugin startup, and a pathological file — multi-GB, or a FIFO/device
|
|
625
|
+
* symlink — must not hang or exhaust the host before the warn-and-drop
|
|
626
|
+
* discipline can apply (review-spd follow-up, pet-center M4/M5).
|
|
627
|
+
*/
|
|
628
|
+
export const PET_SCAN_JSON_CAP = 64 * 1024
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Stat one scanned JSON descriptor with a regular-file + size guard, so a
|
|
632
|
+
* pathological user file is skipped with a warning instead of stalling or
|
|
633
|
+
* OOM-ing the host at startup. Returns the Stats, or undefined when the
|
|
634
|
+
* caller must skip the file (a warning was recorded).
|
|
635
|
+
*/
|
|
636
|
+
function guardedScannedJsonStat(
|
|
637
|
+
file: string,
|
|
638
|
+
options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
|
|
639
|
+
what: string,
|
|
640
|
+
): ReturnType<typeof statSync> | undefined {
|
|
641
|
+
let st: ReturnType<typeof statSync>
|
|
642
|
+
try {
|
|
643
|
+
st = statSync(file)
|
|
644
|
+
} catch {
|
|
645
|
+
return undefined
|
|
646
|
+
}
|
|
647
|
+
const warn = (message: string): void => {
|
|
648
|
+
options.warnings?.push(file + ': ' + message)
|
|
649
|
+
options.diagnostics?.push({ level: 'warning', source: file, message: file + ': ' + message })
|
|
650
|
+
}
|
|
651
|
+
if (!st.isFile()) {
|
|
652
|
+
warn(what + ' is not a regular file; ignored')
|
|
653
|
+
return undefined
|
|
654
|
+
}
|
|
655
|
+
if (st.size > PET_SCAN_JSON_CAP) {
|
|
656
|
+
warn(what + ' exceeds the ' + PET_SCAN_JSON_CAP + '-byte scan ceiling; ignored')
|
|
657
|
+
return undefined
|
|
658
|
+
}
|
|
659
|
+
return st
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Load and normalize one optional voice.json (pet-center M4). A missing
|
|
664
|
+
* file is silent; a broken file warns and drops. The pack is pure content,
|
|
665
|
+
* so every issue stays a warning — a bad voice.json never rejects a pet.
|
|
666
|
+
*/
|
|
667
|
+
function loadVoicePackFile(
|
|
668
|
+
file: string,
|
|
669
|
+
options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
|
|
670
|
+
): VoicePack | undefined {
|
|
671
|
+
if (!existsSync(file)) return undefined
|
|
672
|
+
if (guardedScannedJsonStat(file, options, 'voice pack') === undefined) return undefined
|
|
673
|
+
const warn = (message: string): void => {
|
|
674
|
+
options.warnings?.push(file + ': ' + message)
|
|
675
|
+
options.diagnostics?.push({ level: 'warning', source: file, message: file + ': ' + message })
|
|
676
|
+
}
|
|
677
|
+
let raw: unknown
|
|
678
|
+
try {
|
|
679
|
+
raw = JSON.parse(readFileSync(file, 'utf8'))
|
|
680
|
+
} catch (error) {
|
|
681
|
+
warn('voice pack is not valid JSON; ignored: ' + (error instanceof Error ? error.message : String(error)))
|
|
682
|
+
return undefined
|
|
683
|
+
}
|
|
684
|
+
return normalizeVoicePack(raw, warn)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Decoration asset URL prefix (served by the decoration route, M5). */
|
|
688
|
+
export const DECORATION_ASSET_PREFIX = '/api/pet/decoration'
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Scan one directory of decoration folders ('decoration.json' + strip).
|
|
692
|
+
* Later scans override earlier ones on id collision; a bad descriptor warns
|
|
693
|
+
* and skips — the never-throw philosophy holds for decorations too (M5).
|
|
694
|
+
*/
|
|
695
|
+
function scanDecorationDir(dir: string, options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] }): DecorationEntry[] {
|
|
696
|
+
if (!existsSync(dir)) return []
|
|
697
|
+
let names: string[] = []
|
|
698
|
+
try {
|
|
699
|
+
names = readdirSync(dir).filter(name => !name.startsWith('.'))
|
|
700
|
+
} catch {
|
|
701
|
+
return []
|
|
702
|
+
}
|
|
703
|
+
names.sort()
|
|
704
|
+
const entries: DecorationEntry[] = []
|
|
705
|
+
for (const name of names) {
|
|
706
|
+
const entryDir = join(dir, name)
|
|
707
|
+
const manifestFile = join(entryDir, 'decoration.json')
|
|
708
|
+
if (!existsSync(manifestFile)) continue
|
|
709
|
+
if (guardedScannedJsonStat(manifestFile, options, 'decoration descriptor') === undefined) continue
|
|
710
|
+
let raw: unknown
|
|
711
|
+
try {
|
|
712
|
+
raw = JSON.parse(readFileSync(manifestFile, 'utf8'))
|
|
713
|
+
} catch (error) {
|
|
714
|
+
const message = 'skipping ' + manifestFile + ': ' + (error instanceof Error ? error.message : String(error))
|
|
715
|
+
options.warnings?.push(message)
|
|
716
|
+
options.diagnostics?.push({ level: 'error', source: entryDir, message })
|
|
717
|
+
continue
|
|
718
|
+
}
|
|
719
|
+
const verdict = parseDecorationManifest(raw, manifestFile)
|
|
720
|
+
for (const diagnostic of verdict.diagnostics) {
|
|
721
|
+
options.diagnostics?.push({ level: diagnostic.level, source: entryDir, message: diagnostic.message })
|
|
722
|
+
options.warnings?.push(diagnostic.message)
|
|
723
|
+
}
|
|
724
|
+
if (!verdict.ok) continue
|
|
725
|
+
const manifest = verdict.manifest
|
|
726
|
+
// A missing strip keeps the entry listed (mirroring the live2d closure
|
|
727
|
+
// discipline) but earns a diagnostic: the ornament will silently render
|
|
728
|
+
// nothing, and the warning names the file to fix.
|
|
729
|
+
if (!existsSync(join(entryDir, manifest.entry))) {
|
|
730
|
+
const message = 'decoration ' + manifest.id + ': strip file missing: ' + manifest.entry
|
|
731
|
+
options.warnings?.push(message)
|
|
732
|
+
options.diagnostics?.push({ level: 'warning', source: entryDir, message })
|
|
733
|
+
}
|
|
734
|
+
entries.push({
|
|
735
|
+
apiVersion: PET_DECORATION_API_VERSION,
|
|
736
|
+
id: manifest.id,
|
|
737
|
+
dir: entryDir,
|
|
738
|
+
entryPath: manifest.entry,
|
|
739
|
+
servable: ['decoration.json', manifest.entry],
|
|
740
|
+
license: manifest.license,
|
|
741
|
+
assetBase: DECORATION_ASSET_PREFIX + '/' + encodeURIComponent(manifest.id),
|
|
742
|
+
entryUrl: DECORATION_ASSET_PREFIX + '/' + encodeURIComponent(manifest.id) + '/' + manifest.entry,
|
|
743
|
+
cell: manifest.cell,
|
|
744
|
+
columns: manifest.columns,
|
|
745
|
+
durations: manifest.durations,
|
|
746
|
+
loop: manifest.loop,
|
|
747
|
+
phases: manifest.phases,
|
|
748
|
+
})
|
|
749
|
+
}
|
|
750
|
+
return entries
|
|
751
|
+
}
|
|
752
|
+
|
|
582
753
|
/**
|
|
583
754
|
* Load the pet registry: built-in 'assets/*' first, then the hatch-pet
|
|
584
755
|
* custom pets directory, then composed 'extra' manifests (each later source
|
|
@@ -611,11 +782,14 @@ export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
|
|
|
611
782
|
|
|
612
783
|
// The pet-center user directory ranks above the legacy hatch-pet source.
|
|
613
784
|
const dshPetsDir = options.dshPetsDir ?? join(dshHome(), 'pets')
|
|
785
|
+
let globalVoice: VoicePack | undefined
|
|
614
786
|
if (dshPetsDir !== '') {
|
|
615
787
|
for (const entry of scanPetDir(dshPetsDir, { assetPrefix, warnings, diagnostics })) {
|
|
616
788
|
if (byId.has(entry.id)) warnings.push('user pet ' + entry.id + ' overrides an earlier registration')
|
|
617
789
|
byId.set(entry.id, entry)
|
|
618
790
|
}
|
|
791
|
+
// The global voice override layers under every per-pet pack (M4, #677).
|
|
792
|
+
globalVoice = loadVoicePackFile(join(dshPetsDir, '.voice.json'), { warnings, diagnostics })
|
|
619
793
|
}
|
|
620
794
|
|
|
621
795
|
for (const manifest of options.extra ?? []) {
|
|
@@ -636,18 +810,63 @@ export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
|
|
|
636
810
|
byId.set(entry.id, entry)
|
|
637
811
|
}
|
|
638
812
|
|
|
813
|
+
// Status decorations (pet-center M5, #567): built-in entries first,
|
|
814
|
+
// then same-id user entries under '$DSH_HOME/pets/decorations' override.
|
|
815
|
+
const decorationById = new Map<string, DecorationEntry>()
|
|
816
|
+
for (const entry of scanDecorationDir(join(packageRoot, 'assets', 'decorations'), { warnings, diagnostics })) {
|
|
817
|
+
decorationById.set(entry.id, entry)
|
|
818
|
+
}
|
|
819
|
+
if (dshPetsDir !== '') {
|
|
820
|
+
for (const entry of scanDecorationDir(join(dshPetsDir, 'decorations'), { warnings, diagnostics })) {
|
|
821
|
+
if (decorationById.has(entry.id)) {
|
|
822
|
+
warnings.push('user decoration ' + entry.id + ' overrides the built-in one')
|
|
823
|
+
}
|
|
824
|
+
decorationById.set(entry.id, entry)
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
639
828
|
const entries = [...byId.values()]
|
|
829
|
+
const decorations = [...decorationById.values()]
|
|
640
830
|
return {
|
|
641
831
|
entries,
|
|
642
832
|
warnings,
|
|
643
833
|
diagnostics,
|
|
644
834
|
byId: (id: string) => byId.get(id),
|
|
645
835
|
defaultEntry: () => entries.find(entry => builtinIds.has(entry.id)) ?? entries[0]!,
|
|
836
|
+
...(globalVoice === undefined ? {} : { globalVoice }),
|
|
837
|
+
decorations,
|
|
838
|
+
decorationById: (id: string) => decorationById.get(id),
|
|
646
839
|
}
|
|
647
840
|
}
|
|
648
841
|
|
|
649
|
-
/**
|
|
650
|
-
export
|
|
842
|
+
/** The built-in default decoration id (M5): the first reference ornament. */
|
|
843
|
+
export const DEFAULT_DECORATION_ID = 'whale'
|
|
844
|
+
|
|
845
|
+
/** Strip host-only fields, leaving the browser-visible decoration view. */
|
|
846
|
+
export function decorationView(entry: DecorationEntry): DecorationView {
|
|
847
|
+
return {
|
|
848
|
+
apiVersion: PET_DECORATION_API_VERSION,
|
|
849
|
+
id: entry.id,
|
|
850
|
+
assetBase: entry.assetBase,
|
|
851
|
+
entryUrl: entry.entryUrl,
|
|
852
|
+
cell: entry.cell,
|
|
853
|
+
columns: entry.columns,
|
|
854
|
+
durations: entry.durations,
|
|
855
|
+
loop: entry.loop,
|
|
856
|
+
phases: entry.phases,
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Strip host-only fields, leaving the client-visible definition. When the
|
|
862
|
+
* registry carries a global voice pack, its panel chrome layers under the
|
|
863
|
+
* entry's own pack (per-slot merge, pet > global), mirroring the voice-pool
|
|
864
|
+
* layering (pet-center M4, issue #677).
|
|
865
|
+
*/
|
|
866
|
+
export function petEntryView(entry: PetEntry, globalVoice?: VoicePack): PetDefinition {
|
|
867
|
+
const panel = globalVoice === undefined
|
|
868
|
+
? entry.voice?.panel
|
|
869
|
+
: mergeVoicePacks(globalVoice, entry.voice)?.panel
|
|
651
870
|
return {
|
|
652
871
|
id: entry.id,
|
|
653
872
|
displayName: entry.displayName,
|
|
@@ -662,6 +881,7 @@ export function petEntryView(entry: PetEntry): PetDefinition {
|
|
|
662
881
|
...(entry.sequences === undefined ? {} : { sequences: entry.sequences }),
|
|
663
882
|
atlasUrl: entry.atlasUrl,
|
|
664
883
|
manifestUrl: entry.manifestUrl,
|
|
884
|
+
...(panel === undefined ? {} : { panel }),
|
|
665
885
|
}
|
|
666
886
|
}
|
|
667
887
|
|
package/src/routes.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
|
16
16
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
17
17
|
import type { PetService } from './service.ts'
|
|
18
18
|
import type { PetInteraction } from './affinity.ts'
|
|
19
|
-
import { petEntryView, petPackageRoot, type PetEntry, type PetRegistry } from './registry.ts'
|
|
19
|
+
import { DECORATION_ASSET_PREFIX, petEntryView, petPackageRoot, type PetEntry, type PetRegistry } from './registry.ts'
|
|
20
20
|
import { isLoopbackRequest } from './loopback.ts'
|
|
21
21
|
import { dshHome } from './dsh-home.ts'
|
|
22
22
|
|
|
@@ -263,7 +263,7 @@ function assetHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['hand
|
|
|
263
263
|
file = existsSync(preview) ? preview : undefined
|
|
264
264
|
}
|
|
265
265
|
if (synthesized) {
|
|
266
|
-
const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), 'utf8')
|
|
266
|
+
const body = Buffer.from(JSON.stringify(petEntryView(entry, registry.globalVoice), null, 2), 'utf8')
|
|
267
267
|
res.writeHead(200, {
|
|
268
268
|
'content-type': 'application/json; charset=utf-8',
|
|
269
269
|
'content-length': String(body.byteLength),
|
|
@@ -427,6 +427,125 @@ function runtimeHandler(roots: { runtimeDir: string; vendorDir: string }): WebRo
|
|
|
427
427
|
}
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
+
/**
|
|
431
|
+
* The decoration asset handler behind '/api/pet/decoration/<id>/<file>'
|
|
432
|
+
* (pet-center M5, #567). Serves exactly the files a decoration descriptor
|
|
433
|
+
* declares — decoration.json and the PNG/WebP strip — by exact allow-list
|
|
434
|
+
* match, with realpath containment and the same size ceilings as pet
|
|
435
|
+
* assets. Crafted '..' or '.' segments never match the normalized closure.
|
|
436
|
+
*/
|
|
437
|
+
function decorationHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['handler'] {
|
|
438
|
+
return (req: IncomingMessage, res: ServerResponse): void => {
|
|
439
|
+
if (!guard(req, res)) return
|
|
440
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
441
|
+
res.writeHead(405)
|
|
442
|
+
res.end()
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
let pathname: string
|
|
446
|
+
try {
|
|
447
|
+
pathname = new URL(req.url ?? '/', 'http://pet.local').pathname
|
|
448
|
+
} catch {
|
|
449
|
+
res.writeHead(400)
|
|
450
|
+
res.end()
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
const segments = pathname.split('/').filter(segment => segment !== '')
|
|
454
|
+
const prefixSegments = DECORATION_ASSET_PREFIX.split('/').filter(segment => segment !== '')
|
|
455
|
+
if (segments.length < prefixSegments.length + 2) {
|
|
456
|
+
res.writeHead(404)
|
|
457
|
+
res.end()
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
for (let i = 0; i < prefixSegments.length; i += 1) {
|
|
461
|
+
if (segments[i] !== prefixSegments[i]) {
|
|
462
|
+
res.writeHead(404)
|
|
463
|
+
res.end()
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
let id: string
|
|
468
|
+
try {
|
|
469
|
+
id = decodeURIComponent(segments[prefixSegments.length])
|
|
470
|
+
} catch {
|
|
471
|
+
res.writeHead(400)
|
|
472
|
+
res.end()
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
const entry = registry.decorationById?.(id)
|
|
476
|
+
if (entry === undefined) {
|
|
477
|
+
res.writeHead(404)
|
|
478
|
+
res.end()
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
const rest: string[] = []
|
|
482
|
+
for (const segment of segments.slice(prefixSegments.length + 1)) {
|
|
483
|
+
let decoded: string
|
|
484
|
+
try {
|
|
485
|
+
decoded = decodeURIComponent(segment)
|
|
486
|
+
} catch {
|
|
487
|
+
res.writeHead(400)
|
|
488
|
+
res.end()
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
rest.push(decoded)
|
|
492
|
+
}
|
|
493
|
+
const rel = rest.join('/')
|
|
494
|
+
if (!entry.servable.includes(rel)) {
|
|
495
|
+
res.writeHead(404)
|
|
496
|
+
res.end()
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
const file = join(entry.dir, rel)
|
|
500
|
+
const resolved = containedRealpath(entry.dir, file)
|
|
501
|
+
if (resolved === undefined) {
|
|
502
|
+
res.writeHead(403)
|
|
503
|
+
res.end()
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
const cap = rel === 'decoration.json' ? caps.manifest : caps.image
|
|
507
|
+
let stat: ReturnType<typeof statSync>
|
|
508
|
+
try {
|
|
509
|
+
stat = statSync(resolved)
|
|
510
|
+
if (stat.size > cap) {
|
|
511
|
+
res.writeHead(413)
|
|
512
|
+
res.end()
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
} catch {
|
|
516
|
+
res.writeHead(404)
|
|
517
|
+
res.end()
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
// Weak ETag from size + mtime: 'no-cache' forces revalidation, and the
|
|
521
|
+
// validator lets repeat requests settle as 304 — the ornament remounts
|
|
522
|
+
// on whisper and display-session flips, and without a validator each
|
|
523
|
+
// remount would re-download the full strip body.
|
|
524
|
+
const etag = '"' + stat.size.toString(16) + '-' + Math.round(stat.mtimeMs).toString(16) + '"'
|
|
525
|
+
if (req.headers['if-none-match'] === etag) {
|
|
526
|
+
res.writeHead(304, { etag, 'cache-control': 'no-cache' })
|
|
527
|
+
res.end()
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
readFile(resolved).then((body) => {
|
|
531
|
+
res.writeHead(200, {
|
|
532
|
+
'content-type': mimeFor(resolved),
|
|
533
|
+
'content-length': String(body.byteLength),
|
|
534
|
+
'cache-control': 'no-cache',
|
|
535
|
+
etag,
|
|
536
|
+
})
|
|
537
|
+
if (req.method === 'HEAD') {
|
|
538
|
+
res.end()
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
res.end(body)
|
|
542
|
+
}, () => {
|
|
543
|
+
res.writeHead(404)
|
|
544
|
+
res.end()
|
|
545
|
+
})
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
430
549
|
/** Build the full route family (API + assets + runtime) for one service. */
|
|
431
550
|
export function makePetRoutes(deps: { service: PetService; assetCaps?: PetAssetCaps } & PetRuntimeRoots): WebRoute[] {
|
|
432
551
|
const { service } = deps
|
|
@@ -477,7 +596,13 @@ export function makePetRoutes(deps: { service: PetService; assetCaps?: PetAssetC
|
|
|
477
596
|
}),
|
|
478
597
|
}
|
|
479
598
|
|
|
480
|
-
|
|
599
|
+
const decorationRoute: WebRoute = {
|
|
600
|
+
kind: 'prefix',
|
|
601
|
+
path: DECORATION_ASSET_PREFIX,
|
|
602
|
+
handler: decorationHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return [...apiRoutes, assetRoute, runtimeRoute, decorationRoute]
|
|
481
606
|
}
|
|
482
607
|
|
|
483
608
|
// Re-exported for the package surface (the registry owns the definition now).
|
package/src/service.ts
CHANGED
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
type PetDisplayConfig,
|
|
37
37
|
} from './persist.ts'
|
|
38
38
|
import {
|
|
39
|
+
DEFAULT_DECORATION_ID,
|
|
40
|
+
decorationView,
|
|
39
41
|
loadPetRegistry,
|
|
40
42
|
petEntryView,
|
|
41
43
|
petPackageRoot,
|
|
@@ -44,7 +46,9 @@ import {
|
|
|
44
46
|
type PetRegistry,
|
|
45
47
|
type PetRegistryDiagnostic,
|
|
46
48
|
} from './registry.ts'
|
|
47
|
-
import { WHISPER_TTL_MS } from './chatter.ts'
|
|
49
|
+
import { WHISPER_TTL_MS, type VoicePackOverrides, type VoicePoolsProvider } from './chatter.ts'
|
|
50
|
+
import { mergeVoicePacks } from './voice-pack.ts'
|
|
51
|
+
import type { DecorationView } from './contracts/status-decoration.ts'
|
|
48
52
|
import {
|
|
49
53
|
defaultPetStateConfig,
|
|
50
54
|
PetStateMachine,
|
|
@@ -65,6 +69,8 @@ export interface PetConfig {
|
|
|
65
69
|
persistDir?: string
|
|
66
70
|
/** Master switch for the plugin (browser half + host routes). */
|
|
67
71
|
enabled?: boolean
|
|
72
|
+
/** Status-decoration master switch (pet-center M5, #567); defaults to on. */
|
|
73
|
+
decorationEnabled?: boolean
|
|
68
74
|
/** Prebuilt registry (tests); defaults to scanning the package + user dirs. */
|
|
69
75
|
registry?: PetRegistry
|
|
70
76
|
/** Extra manifest entries composed by the embedding application. */
|
|
@@ -91,6 +97,11 @@ export interface PetSettingsSection {
|
|
|
91
97
|
bottom: number
|
|
92
98
|
/** Master switch for the plugin (browser half + host routes). */
|
|
93
99
|
enabled?: boolean
|
|
100
|
+
/**
|
|
101
|
+
* Status-decoration master switch (pet-center M5, #567). Defaults to on;
|
|
102
|
+
* the settings surface mirrors this field and can turn it off.
|
|
103
|
+
*/
|
|
104
|
+
decorationEnabled?: boolean
|
|
94
105
|
}
|
|
95
106
|
|
|
96
107
|
/** Settings namespace of the pet capability. Spelled here rather than imported: the browser half spells the same value. */
|
|
@@ -159,6 +170,12 @@ export interface PetStateView {
|
|
|
159
170
|
* rendered by the client as a distinct whisper bubble.
|
|
160
171
|
*/
|
|
161
172
|
whisper?: string
|
|
173
|
+
/**
|
|
174
|
+
* The active status decoration (pet-center M5, #567), when the master
|
|
175
|
+
* switch is on and the default decoration entry exists. Absent means the
|
|
176
|
+
* browser half renders no ornament.
|
|
177
|
+
*/
|
|
178
|
+
decoration?: DecorationView
|
|
162
179
|
}
|
|
163
180
|
|
|
164
181
|
/** Result of `pet.interact`. */
|
|
@@ -200,9 +217,17 @@ export class PetService extends Service {
|
|
|
200
217
|
private readonly registry: PetRegistry
|
|
201
218
|
private readonly persistDir: string
|
|
202
219
|
private enabled: boolean
|
|
220
|
+
/** Status-decoration master switch (M5, #567); mirrored from settings. */
|
|
221
|
+
private decorationEnabled: boolean
|
|
203
222
|
private disposeActivity: (() => void) | undefined
|
|
204
223
|
/** Session whose most recent meaningful event currently drives the global pet. */
|
|
205
224
|
private displaySession: Session | undefined
|
|
225
|
+
/**
|
|
226
|
+
* Effective voice-pack overrides for the currently selected pet (M4,
|
|
227
|
+
* #677). Cached per pet id; the registry is an immutable snapshot, so the
|
|
228
|
+
* global pack and each entry's pack cannot change behind the cache.
|
|
229
|
+
*/
|
|
230
|
+
private voiceCache: { petId: string; overrides: VoicePackOverrides } | undefined
|
|
206
231
|
/**
|
|
207
232
|
* Per-session activity, most recent last (Map insertion order). Bounded by
|
|
208
233
|
* MAX_SESSION_BUBBLES so a burst of sessions cannot grow it without bound;
|
|
@@ -237,10 +262,28 @@ export class PetService extends Service {
|
|
|
237
262
|
this.stateConfig = { ...defaultPetStateConfig, ...(config.state ?? {}) }
|
|
238
263
|
this.machine = new PetStateMachine(this.stateConfig)
|
|
239
264
|
this.enabled = config.enabled ?? true
|
|
265
|
+
this.decorationEnabled = config.decorationEnabled ?? true
|
|
240
266
|
|
|
241
267
|
this.syncActivity()
|
|
242
268
|
}
|
|
243
269
|
|
|
270
|
+
/**
|
|
271
|
+
* The draw-time voice-pool provider handed to every projection runtime.
|
|
272
|
+
* It re-resolves when the selected pet changes, so live engines re-voice
|
|
273
|
+
* on the next draw without being rebuilt (M4, #677).
|
|
274
|
+
*/
|
|
275
|
+
private voicePools(): VoicePoolsProvider {
|
|
276
|
+
return () => {
|
|
277
|
+
const entry = this.activeEntry()
|
|
278
|
+
if (this.voiceCache !== undefined && this.voiceCache.petId === entry.id) {
|
|
279
|
+
return this.voiceCache.overrides
|
|
280
|
+
}
|
|
281
|
+
const overrides = mergeVoicePacks(this.registry.globalVoice, entry.voice)?.overrides ?? {}
|
|
282
|
+
this.voiceCache = { petId: entry.id, overrides }
|
|
283
|
+
return overrides
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
244
287
|
/** Whether the pet service consumes session activity while enabled. */
|
|
245
288
|
isEnabled(): boolean {
|
|
246
289
|
return this.enabled
|
|
@@ -258,7 +301,7 @@ export class PetService extends Service {
|
|
|
258
301
|
|
|
259
302
|
/** RPC: the registry entries the browser half renders and selects from. */
|
|
260
303
|
async pets(): Promise<PetDefinition[]> {
|
|
261
|
-
return this.registry.entries.map(petEntryView)
|
|
304
|
+
return this.registry.entries.map(entry => petEntryView(entry, this.registry.globalVoice))
|
|
262
305
|
}
|
|
263
306
|
|
|
264
307
|
/** The loaded registry (the asset routes serve its entries). */
|
|
@@ -271,6 +314,16 @@ export class PetService extends Service {
|
|
|
271
314
|
return { diagnostics: this.registry.diagnostics }
|
|
272
315
|
}
|
|
273
316
|
|
|
317
|
+
/**
|
|
318
|
+
* The active status decoration view (M5, #567): the default 'whale' entry
|
|
319
|
+
* (user directories override built-ins by id), gated by the master switch.
|
|
320
|
+
*/
|
|
321
|
+
private activeDecoration(): DecorationView | undefined {
|
|
322
|
+
if (!this.decorationEnabled) return undefined
|
|
323
|
+
const entry = this.registry.decorationById?.(DEFAULT_DECORATION_ID)
|
|
324
|
+
return entry === undefined ? undefined : decorationView(entry)
|
|
325
|
+
}
|
|
326
|
+
|
|
274
327
|
/** The selected pet's registry entry. */
|
|
275
328
|
activeEntry(): NonNullable<PetRegistry['entries'][number]> {
|
|
276
329
|
return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry()
|
|
@@ -370,7 +423,7 @@ export class PetService extends Service {
|
|
|
370
423
|
let activity = this.sessionActivity.get(session)
|
|
371
424
|
if (activity === undefined) {
|
|
372
425
|
activity = {
|
|
373
|
-
runtime: emptyProjectionRuntime(),
|
|
426
|
+
runtime: emptyProjectionRuntime(this.voicePools()),
|
|
374
427
|
machine: new PetStateMachine(this.stateConfig),
|
|
375
428
|
}
|
|
376
429
|
this.sessionActivity.set(session, activity)
|
|
@@ -449,6 +502,7 @@ export class PetService extends Service {
|
|
|
449
502
|
* @param section - the resolved settings section.
|
|
450
503
|
*/
|
|
451
504
|
applySettingsSection(section: PetSettingsSection): void {
|
|
505
|
+
this.decorationEnabled = section.decorationEnabled ?? true
|
|
452
506
|
const selected = typeof section.petId === 'string' ? this.registry.byId(section.petId) : undefined
|
|
453
507
|
if (selected !== undefined) {
|
|
454
508
|
this.ledger.setPetId(selected.id)
|
|
@@ -524,6 +578,7 @@ export class PetService extends Service {
|
|
|
524
578
|
const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
|
|
525
579
|
? whisper.text
|
|
526
580
|
: undefined
|
|
581
|
+
const decoration = this.activeDecoration()
|
|
527
582
|
// Read-only: the ledger settles on economic events only, never on a read,
|
|
528
583
|
// so polling the state cannot trigger pet.json writes.
|
|
529
584
|
return {
|
|
@@ -533,6 +588,7 @@ export class PetService extends Service {
|
|
|
533
588
|
sessionActive: snapshot.sessionActive,
|
|
534
589
|
sessions,
|
|
535
590
|
...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
|
|
591
|
+
...(decoration === undefined ? {} : { decoration }),
|
|
536
592
|
affinity: this.ledger.affinityView(Date.now()),
|
|
537
593
|
display: { ...this.ledger.snapshot.display },
|
|
538
594
|
pet: {
|