@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.
Files changed (70) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +64 -1
  3. package/README.zh.md +64 -1
  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 +144 -18
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +979 -70
  12. package/lib/types/chatter.d.ts +61 -3
  13. package/lib/types/chatter.d.ts.map +1 -1
  14. package/lib/types/chatter.js +71 -12
  15. package/lib/types/client/PetSettingsCard.d.ts +4 -0
  16. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  17. package/lib/types/client/PetSettingsCard.js +3 -1
  18. package/lib/types/client/PetSprite.d.ts.map +1 -1
  19. package/lib/types/client/PetSprite.js +103 -4
  20. package/lib/types/client/locales.d.ts +4 -0
  21. package/lib/types/client/locales.d.ts.map +1 -1
  22. package/lib/types/client/locales.js +4 -0
  23. package/lib/types/client/renderers/live2d.d.ts.map +1 -1
  24. package/lib/types/client/renderers/live2d.js +13 -1
  25. package/lib/types/client/settings-form.d.ts.map +1 -1
  26. package/lib/types/client/settings-form.js +9 -6
  27. package/lib/types/contracts/status-decoration.d.ts +85 -0
  28. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  29. package/lib/types/contracts/status-decoration.js +21 -0
  30. package/lib/types/decoration.d.ts +39 -0
  31. package/lib/types/decoration.d.ts.map +1 -0
  32. package/lib/types/decoration.js +210 -0
  33. package/lib/types/event-projection.d.ts +8 -3
  34. package/lib/types/event-projection.d.ts.map +1 -1
  35. package/lib/types/event-projection.js +9 -4
  36. package/lib/types/index.d.ts +2 -0
  37. package/lib/types/index.d.ts.map +1 -1
  38. package/lib/types/index.js +2 -0
  39. package/lib/types/registry.d.ts +55 -2
  40. package/lib/types/registry.d.ts.map +1 -1
  41. package/lib/types/registry.js +198 -16
  42. package/lib/types/routes.d.ts.map +1 -1
  43. package/lib/types/routes.js +131 -3
  44. package/lib/types/service.d.ts +33 -0
  45. package/lib/types/service.d.ts.map +1 -1
  46. package/lib/types/service.js +42 -3
  47. package/lib/types/voice-pack.d.ts +98 -0
  48. package/lib/types/voice-pack.d.ts.map +1 -0
  49. package/lib/types/voice-pack.js +384 -0
  50. package/package.json +11 -10
  51. package/src/chatter.test.ts +89 -2
  52. package/src/chatter.ts +120 -14
  53. package/src/client/PetSettingsCard.tsx +18 -0
  54. package/src/client/PetSprite.test.tsx +254 -12
  55. package/src/client/PetSprite.tsx +142 -25
  56. package/src/client/locales.ts +4 -0
  57. package/src/client/renderers/live2d.test.ts +23 -2
  58. package/src/client/renderers/live2d.ts +14 -1
  59. package/src/client/settings-form.ts +8 -6
  60. package/src/contracts/status-decoration.ts +78 -0
  61. package/src/decoration.test.ts +178 -0
  62. package/src/decoration.ts +220 -0
  63. package/src/event-projection.ts +10 -5
  64. package/src/index.ts +2 -0
  65. package/src/registry.test.ts +223 -0
  66. package/src/registry.ts +235 -15
  67. package/src/routes.ts +128 -3
  68. package/src/service.ts +59 -3
  69. package/src/voice-pack.test.ts +216 -0
  70. package/src/voice-pack.ts +413 -0
@@ -26,11 +26,14 @@
26
26
  * whale-girl manifest overrides its own durations.
27
27
  * @module @linxin666/dsh-pet/registry
28
28
  */
29
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
29
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
30
30
  import { homedir } from 'node:os';
31
31
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
32
32
  import { fileURLToPath } from 'node:url';
33
33
  import { normalizePetRemarks } from './remarks.js';
34
+ import { mergeVoicePacks, normalizeVoicePack } from './voice-pack.js';
35
+ import { parseDecorationManifest } from './decoration.js';
36
+ import { PET_DECORATION_API_VERSION } from './contracts/status-decoration.js';
34
37
  import { dshHome } from './dsh-home.js';
35
38
  import { parsePetManifest } from './manifest-v2.js';
36
39
  import { collectModel3References } from './model3.js';
@@ -376,22 +379,25 @@ function scanPetDir(dir, options) {
376
379
  }
377
380
  if (!verdict.ok)
378
381
  continue;
382
+ let entry;
379
383
  if (verdict.manifest.renderer === 'live2d') {
380
- const entry = resolveLive2dEntry(verdict.manifest, entryDir, options);
381
- if (entry !== undefined)
382
- entries.push(entry);
383
- continue;
384
+ entry = resolveLive2dEntry(verdict.manifest, entryDir, options);
384
385
  }
385
- const legacy = flattenV2Sprite2d(verdict.manifest);
386
- if (legacy === undefined) {
387
- const note = 'pet ' + verdict.manifest.id + ': sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver';
388
- options.diagnostics?.push({ level: 'error', source: entryDir, message: note });
389
- options.warnings?.push(note);
390
- continue;
386
+ else {
387
+ const legacy = flattenV2Sprite2d(verdict.manifest);
388
+ if (legacy === undefined) {
389
+ const note = 'pet ' + verdict.manifest.id + ': sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver';
390
+ options.diagnostics?.push({ level: 'error', source: entryDir, message: note });
391
+ options.warnings?.push(note);
392
+ continue;
393
+ }
394
+ entry = resolvePetManifest(legacy, entryDir, options);
391
395
  }
392
- const entry = resolvePetManifest(legacy, entryDir, options);
393
- if (entry !== undefined)
394
- entries.push(entry);
396
+ if (entry === undefined)
397
+ continue;
398
+ // Optional voice pack (voice.json) — pure content, warn-and-drop (M4).
399
+ const voice = loadVoicePackFile(join(entryDir, 'voice.json'), options);
400
+ entries.push({ ...entry, ...(voice === undefined ? {} : { voice }) });
395
401
  }
396
402
  return entries;
397
403
  }
@@ -405,6 +411,136 @@ function readPetJson(file, warnings) {
405
411
  return undefined;
406
412
  }
407
413
  }
414
+ /**
415
+ * Scan-time read ceiling for user-authored JSON descriptors (voice.json,
416
+ * .voice.json, decoration.json): the registry reads these synchronously at
417
+ * plugin startup, and a pathological file — multi-GB, or a FIFO/device
418
+ * symlink — must not hang or exhaust the host before the warn-and-drop
419
+ * discipline can apply (review-spd follow-up, pet-center M4/M5).
420
+ */
421
+ export const PET_SCAN_JSON_CAP = 64 * 1024;
422
+ /**
423
+ * Stat one scanned JSON descriptor with a regular-file + size guard, so a
424
+ * pathological user file is skipped with a warning instead of stalling or
425
+ * OOM-ing the host at startup. Returns the Stats, or undefined when the
426
+ * caller must skip the file (a warning was recorded).
427
+ */
428
+ function guardedScannedJsonStat(file, options, what) {
429
+ let st;
430
+ try {
431
+ st = statSync(file);
432
+ }
433
+ catch {
434
+ return undefined;
435
+ }
436
+ const warn = (message) => {
437
+ options.warnings?.push(file + ': ' + message);
438
+ options.diagnostics?.push({ level: 'warning', source: file, message: file + ': ' + message });
439
+ };
440
+ if (!st.isFile()) {
441
+ warn(what + ' is not a regular file; ignored');
442
+ return undefined;
443
+ }
444
+ if (st.size > PET_SCAN_JSON_CAP) {
445
+ warn(what + ' exceeds the ' + PET_SCAN_JSON_CAP + '-byte scan ceiling; ignored');
446
+ return undefined;
447
+ }
448
+ return st;
449
+ }
450
+ /**
451
+ * Load and normalize one optional voice.json (pet-center M4). A missing
452
+ * file is silent; a broken file warns and drops. The pack is pure content,
453
+ * so every issue stays a warning — a bad voice.json never rejects a pet.
454
+ */
455
+ function loadVoicePackFile(file, options) {
456
+ if (!existsSync(file))
457
+ return undefined;
458
+ if (guardedScannedJsonStat(file, options, 'voice pack') === undefined)
459
+ return undefined;
460
+ const warn = (message) => {
461
+ options.warnings?.push(file + ': ' + message);
462
+ options.diagnostics?.push({ level: 'warning', source: file, message: file + ': ' + message });
463
+ };
464
+ let raw;
465
+ try {
466
+ raw = JSON.parse(readFileSync(file, 'utf8'));
467
+ }
468
+ catch (error) {
469
+ warn('voice pack is not valid JSON; ignored: ' + (error instanceof Error ? error.message : String(error)));
470
+ return undefined;
471
+ }
472
+ return normalizeVoicePack(raw, warn);
473
+ }
474
+ /** Decoration asset URL prefix (served by the decoration route, M5). */
475
+ export const DECORATION_ASSET_PREFIX = '/api/pet/decoration';
476
+ /**
477
+ * Scan one directory of decoration folders ('decoration.json' + strip).
478
+ * Later scans override earlier ones on id collision; a bad descriptor warns
479
+ * and skips — the never-throw philosophy holds for decorations too (M5).
480
+ */
481
+ function scanDecorationDir(dir, options) {
482
+ if (!existsSync(dir))
483
+ return [];
484
+ let names = [];
485
+ try {
486
+ names = readdirSync(dir).filter(name => !name.startsWith('.'));
487
+ }
488
+ catch {
489
+ return [];
490
+ }
491
+ names.sort();
492
+ const entries = [];
493
+ for (const name of names) {
494
+ const entryDir = join(dir, name);
495
+ const manifestFile = join(entryDir, 'decoration.json');
496
+ if (!existsSync(manifestFile))
497
+ continue;
498
+ if (guardedScannedJsonStat(manifestFile, options, 'decoration descriptor') === undefined)
499
+ continue;
500
+ let raw;
501
+ try {
502
+ raw = JSON.parse(readFileSync(manifestFile, 'utf8'));
503
+ }
504
+ catch (error) {
505
+ const message = 'skipping ' + manifestFile + ': ' + (error instanceof Error ? error.message : String(error));
506
+ options.warnings?.push(message);
507
+ options.diagnostics?.push({ level: 'error', source: entryDir, message });
508
+ continue;
509
+ }
510
+ const verdict = parseDecorationManifest(raw, manifestFile);
511
+ for (const diagnostic of verdict.diagnostics) {
512
+ options.diagnostics?.push({ level: diagnostic.level, source: entryDir, message: diagnostic.message });
513
+ options.warnings?.push(diagnostic.message);
514
+ }
515
+ if (!verdict.ok)
516
+ continue;
517
+ const manifest = verdict.manifest;
518
+ // A missing strip keeps the entry listed (mirroring the live2d closure
519
+ // discipline) but earns a diagnostic: the ornament will silently render
520
+ // nothing, and the warning names the file to fix.
521
+ if (!existsSync(join(entryDir, manifest.entry))) {
522
+ const message = 'decoration ' + manifest.id + ': strip file missing: ' + manifest.entry;
523
+ options.warnings?.push(message);
524
+ options.diagnostics?.push({ level: 'warning', source: entryDir, message });
525
+ }
526
+ entries.push({
527
+ apiVersion: PET_DECORATION_API_VERSION,
528
+ id: manifest.id,
529
+ dir: entryDir,
530
+ entryPath: manifest.entry,
531
+ servable: ['decoration.json', manifest.entry],
532
+ license: manifest.license,
533
+ assetBase: DECORATION_ASSET_PREFIX + '/' + encodeURIComponent(manifest.id),
534
+ entryUrl: DECORATION_ASSET_PREFIX + '/' + encodeURIComponent(manifest.id) + '/' + manifest.entry,
535
+ cell: manifest.cell,
536
+ columns: manifest.columns,
537
+ durations: manifest.durations,
538
+ loop: manifest.loop,
539
+ phases: manifest.phases,
540
+ });
541
+ }
542
+ return entries;
543
+ }
408
544
  /**
409
545
  * Load the pet registry: built-in 'assets/*' first, then the hatch-pet
410
546
  * custom pets directory, then composed 'extra' manifests (each later source
@@ -435,12 +571,15 @@ export function loadPetRegistry(options) {
435
571
  }
436
572
  // The pet-center user directory ranks above the legacy hatch-pet source.
437
573
  const dshPetsDir = options.dshPetsDir ?? join(dshHome(), 'pets');
574
+ let globalVoice;
438
575
  if (dshPetsDir !== '') {
439
576
  for (const entry of scanPetDir(dshPetsDir, { assetPrefix, warnings, diagnostics })) {
440
577
  if (byId.has(entry.id))
441
578
  warnings.push('user pet ' + entry.id + ' overrides an earlier registration');
442
579
  byId.set(entry.id, entry);
443
580
  }
581
+ // The global voice override layers under every per-pet pack (M4, #677).
582
+ globalVoice = loadVoicePackFile(join(dshPetsDir, '.voice.json'), { warnings, diagnostics });
444
583
  }
445
584
  for (const manifest of options.extra ?? []) {
446
585
  const raw = manifest.spritesheetPath;
@@ -461,17 +600,59 @@ export function loadPetRegistry(options) {
461
600
  warnings.push('composed pet ' + entry.id + ' overrides an earlier registration');
462
601
  byId.set(entry.id, entry);
463
602
  }
603
+ // Status decorations (pet-center M5, #567): built-in entries first,
604
+ // then same-id user entries under '$DSH_HOME/pets/decorations' override.
605
+ const decorationById = new Map();
606
+ for (const entry of scanDecorationDir(join(packageRoot, 'assets', 'decorations'), { warnings, diagnostics })) {
607
+ decorationById.set(entry.id, entry);
608
+ }
609
+ if (dshPetsDir !== '') {
610
+ for (const entry of scanDecorationDir(join(dshPetsDir, 'decorations'), { warnings, diagnostics })) {
611
+ if (decorationById.has(entry.id)) {
612
+ warnings.push('user decoration ' + entry.id + ' overrides the built-in one');
613
+ }
614
+ decorationById.set(entry.id, entry);
615
+ }
616
+ }
464
617
  const entries = [...byId.values()];
618
+ const decorations = [...decorationById.values()];
465
619
  return {
466
620
  entries,
467
621
  warnings,
468
622
  diagnostics,
469
623
  byId: (id) => byId.get(id),
470
624
  defaultEntry: () => entries.find(entry => builtinIds.has(entry.id)) ?? entries[0],
625
+ ...(globalVoice === undefined ? {} : { globalVoice }),
626
+ decorations,
627
+ decorationById: (id) => decorationById.get(id),
471
628
  };
472
629
  }
473
- /** Strip host-only fields, leaving the client-visible definition. */
474
- export function petEntryView(entry) {
630
+ /** The built-in default decoration id (M5): the first reference ornament. */
631
+ export const DEFAULT_DECORATION_ID = 'whale';
632
+ /** Strip host-only fields, leaving the browser-visible decoration view. */
633
+ export function decorationView(entry) {
634
+ return {
635
+ apiVersion: PET_DECORATION_API_VERSION,
636
+ id: entry.id,
637
+ assetBase: entry.assetBase,
638
+ entryUrl: entry.entryUrl,
639
+ cell: entry.cell,
640
+ columns: entry.columns,
641
+ durations: entry.durations,
642
+ loop: entry.loop,
643
+ phases: entry.phases,
644
+ };
645
+ }
646
+ /**
647
+ * Strip host-only fields, leaving the client-visible definition. When the
648
+ * registry carries a global voice pack, its panel chrome layers under the
649
+ * entry's own pack (per-slot merge, pet > global), mirroring the voice-pool
650
+ * layering (pet-center M4, issue #677).
651
+ */
652
+ export function petEntryView(entry, globalVoice) {
653
+ const panel = globalVoice === undefined
654
+ ? entry.voice?.panel
655
+ : mergeVoicePacks(globalVoice, entry.voice)?.panel;
475
656
  return {
476
657
  id: entry.id,
477
658
  displayName: entry.displayName,
@@ -486,6 +667,7 @@ export function petEntryView(entry) {
486
667
  ...(entry.sequences === undefined ? {} : { sequences: entry.sequences }),
487
668
  atlasUrl: entry.atlasUrl,
488
669
  manifestUrl: entry.manifestUrl,
670
+ ...(panel === undefined ? {} : { panel }),
489
671
  };
490
672
  }
491
673
  /** The absolute file a pet's atlas resolves to (host asset route). */
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAM9C,+CAA+C;AAC/C,eAAO,MAAM,cAAc,aAAa,CAAA;AAExC,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,SAAS,CAAA;AAMtC;;;;GAIG;AACH,eAAO,MAAM,cAAc;IACzB,yBAAyB;aACzB,QAAQ;IACR,iDAAiD;aACjD,KAAK;IACL,8EAA8E;aAC9E,KAAK;CACG,CAAA;AAEV,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd;AAWD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUrF;AAsPD,4EAA4E;AAC5E,eAAO,MAAM,kBAAkB,QAA8B,CAAA;AAe7D,4EAA4E;AAC5E,eAAO,MAAM,eAAe,QAAmB,CAAA;AAE/C,6EAA6E;AAC7E,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAiFD,4EAA4E;AAC5E,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAAE,OAAO,EAAE,UAAU,CAAC;IAAC,SAAS,CAAC,EAAE,YAAY,CAAA;CAAE,GAAG,eAAe,GAAG,QAAQ,EAAE,CAkDnH;AAGD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAM9C,+CAA+C;AAC/C,eAAO,MAAM,cAAc,aAAa,CAAA;AAExC,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,SAAS,CAAA;AAMtC;;;;GAIG;AACH,eAAO,MAAM,cAAc;IACzB,yBAAyB;aACzB,QAAQ;IACR,iDAAiD;aACjD,KAAK;IACL,8EAA8E;aAC9E,KAAK;CACG,CAAA;AAEV,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd;AAWD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUrF;AAsPD,4EAA4E;AAC5E,eAAO,MAAM,kBAAkB,QAA8B,CAAA;AAe7D,4EAA4E;AAC5E,eAAO,MAAM,eAAe,QAAmB,CAAA;AAE/C,6EAA6E;AAC7E,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAwMD,4EAA4E;AAC5E,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAAE,OAAO,EAAE,UAAU,CAAC;IAAC,SAAS,CAAC,EAAE,YAAY,CAAA;CAAE,GAAG,eAAe,GAAG,QAAQ,EAAE,CAwDnH;AAGD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA"}
@@ -11,7 +11,7 @@
11
11
  import { existsSync, realpathSync, statSync } from 'node:fs';
12
12
  import { readFile } from 'node:fs/promises';
13
13
  import { join, sep } from 'node:path';
14
- import { petEntryView, petPackageRoot } from './registry.js';
14
+ import { DECORATION_ASSET_PREFIX, petEntryView, petPackageRoot } from './registry.js';
15
15
  import { isLoopbackRequest } from './loopback.js';
16
16
  import { dshHome } from './dsh-home.js';
17
17
  /** Browser-facing base path of the pet API. */
@@ -248,7 +248,7 @@ function assetHandler(registry, caps) {
248
248
  file = existsSync(preview) ? preview : undefined;
249
249
  }
250
250
  if (synthesized) {
251
- const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), 'utf8');
251
+ const body = Buffer.from(JSON.stringify(petEntryView(entry, registry.globalVoice), null, 2), 'utf8');
252
252
  res.writeHead(200, {
253
253
  'content-type': 'application/json; charset=utf-8',
254
254
  'content-length': String(body.byteLength),
@@ -404,6 +404,129 @@ function runtimeHandler(roots) {
404
404
  });
405
405
  };
406
406
  }
407
+ /**
408
+ * The decoration asset handler behind '/api/pet/decoration/<id>/<file>'
409
+ * (pet-center M5, #567). Serves exactly the files a decoration descriptor
410
+ * declares — decoration.json and the PNG/WebP strip — by exact allow-list
411
+ * match, with realpath containment and the same size ceilings as pet
412
+ * assets. Crafted '..' or '.' segments never match the normalized closure.
413
+ */
414
+ function decorationHandler(registry, caps) {
415
+ return (req, res) => {
416
+ if (!guard(req, res))
417
+ return;
418
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
419
+ res.writeHead(405);
420
+ res.end();
421
+ return;
422
+ }
423
+ let pathname;
424
+ try {
425
+ pathname = new URL(req.url ?? '/', 'http://pet.local').pathname;
426
+ }
427
+ catch {
428
+ res.writeHead(400);
429
+ res.end();
430
+ return;
431
+ }
432
+ const segments = pathname.split('/').filter(segment => segment !== '');
433
+ const prefixSegments = DECORATION_ASSET_PREFIX.split('/').filter(segment => segment !== '');
434
+ if (segments.length < prefixSegments.length + 2) {
435
+ res.writeHead(404);
436
+ res.end();
437
+ return;
438
+ }
439
+ for (let i = 0; i < prefixSegments.length; i += 1) {
440
+ if (segments[i] !== prefixSegments[i]) {
441
+ res.writeHead(404);
442
+ res.end();
443
+ return;
444
+ }
445
+ }
446
+ let id;
447
+ try {
448
+ id = decodeURIComponent(segments[prefixSegments.length]);
449
+ }
450
+ catch {
451
+ res.writeHead(400);
452
+ res.end();
453
+ return;
454
+ }
455
+ const entry = registry.decorationById?.(id);
456
+ if (entry === undefined) {
457
+ res.writeHead(404);
458
+ res.end();
459
+ return;
460
+ }
461
+ const rest = [];
462
+ for (const segment of segments.slice(prefixSegments.length + 1)) {
463
+ let decoded;
464
+ try {
465
+ decoded = decodeURIComponent(segment);
466
+ }
467
+ catch {
468
+ res.writeHead(400);
469
+ res.end();
470
+ return;
471
+ }
472
+ rest.push(decoded);
473
+ }
474
+ const rel = rest.join('/');
475
+ if (!entry.servable.includes(rel)) {
476
+ res.writeHead(404);
477
+ res.end();
478
+ return;
479
+ }
480
+ const file = join(entry.dir, rel);
481
+ const resolved = containedRealpath(entry.dir, file);
482
+ if (resolved === undefined) {
483
+ res.writeHead(403);
484
+ res.end();
485
+ return;
486
+ }
487
+ const cap = rel === 'decoration.json' ? caps.manifest : caps.image;
488
+ let stat;
489
+ try {
490
+ stat = statSync(resolved);
491
+ if (stat.size > cap) {
492
+ res.writeHead(413);
493
+ res.end();
494
+ return;
495
+ }
496
+ }
497
+ catch {
498
+ res.writeHead(404);
499
+ res.end();
500
+ return;
501
+ }
502
+ // Weak ETag from size + mtime: 'no-cache' forces revalidation, and the
503
+ // validator lets repeat requests settle as 304 — the ornament remounts
504
+ // on whisper and display-session flips, and without a validator each
505
+ // remount would re-download the full strip body.
506
+ const etag = '"' + stat.size.toString(16) + '-' + Math.round(stat.mtimeMs).toString(16) + '"';
507
+ if (req.headers['if-none-match'] === etag) {
508
+ res.writeHead(304, { etag, 'cache-control': 'no-cache' });
509
+ res.end();
510
+ return;
511
+ }
512
+ readFile(resolved).then((body) => {
513
+ res.writeHead(200, {
514
+ 'content-type': mimeFor(resolved),
515
+ 'content-length': String(body.byteLength),
516
+ 'cache-control': 'no-cache',
517
+ etag,
518
+ });
519
+ if (req.method === 'HEAD') {
520
+ res.end();
521
+ return;
522
+ }
523
+ res.end(body);
524
+ }, () => {
525
+ res.writeHead(404);
526
+ res.end();
527
+ });
528
+ };
529
+ }
407
530
  /** Build the full route family (API + assets + runtime) for one service. */
408
531
  export function makePetRoutes(deps) {
409
532
  const { service } = deps;
@@ -455,7 +578,12 @@ export function makePetRoutes(deps) {
455
578
  vendorDir: deps.vendorDir ?? join(petPackageRoot(import.meta.url), 'lib'),
456
579
  }),
457
580
  };
458
- return [...apiRoutes, assetRoute, runtimeRoute];
581
+ const decorationRoute = {
582
+ kind: 'prefix',
583
+ path: DECORATION_ASSET_PREFIX,
584
+ handler: decorationHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
585
+ };
586
+ return [...apiRoutes, assetRoute, runtimeRoute, decorationRoute];
459
587
  }
460
588
  // Re-exported for the package surface (the registry owns the definition now).
461
589
  export { petPackageRoot } from './registry.js';
@@ -17,6 +17,7 @@ import type { TreatConfig } from './treats.ts';
17
17
  import { type LedgerInteractionResult } from './ledger.ts';
18
18
  import { type PetDisplayConfig } from './persist.ts';
19
19
  import { type PetDefinition, type PetManifest, type PetRegistry, type PetRegistryDiagnostic } from './registry.ts';
20
+ import type { DecorationView } from './contracts/status-decoration.ts';
20
21
  import { type PetStateConfig, type PetStateSnapshot } from './state.ts';
21
22
  /** Plugin configuration. */
22
23
  export interface PetConfig {
@@ -30,6 +31,8 @@ export interface PetConfig {
30
31
  persistDir?: string;
31
32
  /** Master switch for the plugin (browser half + host routes). */
32
33
  enabled?: boolean;
34
+ /** Status-decoration master switch (pet-center M5, #567); defaults to on. */
35
+ decorationEnabled?: boolean;
33
36
  /** Prebuilt registry (tests); defaults to scanning the package + user dirs. */
34
37
  registry?: PetRegistry;
35
38
  /** Extra manifest entries composed by the embedding application. */
@@ -55,6 +58,11 @@ export interface PetSettingsSection {
55
58
  bottom: number;
56
59
  /** Master switch for the plugin (browser half + host routes). */
57
60
  enabled?: boolean;
61
+ /**
62
+ * Status-decoration master switch (pet-center M5, #567). Defaults to on;
63
+ * the settings surface mirrors this field and can turn it off.
64
+ */
65
+ decorationEnabled?: boolean;
58
66
  }
59
67
  /** Settings namespace of the pet capability. Spelled here rather than imported: the browser half spells the same value. */
60
68
  export declare const PET_SETTINGS_NAMESPACE = "pet";
@@ -119,6 +127,12 @@ export interface PetStateView {
119
127
  * rendered by the client as a distinct whisper bubble.
120
128
  */
121
129
  whisper?: string;
130
+ /**
131
+ * The active status decoration (pet-center M5, #567), when the master
132
+ * switch is on and the default decoration entry exists. Absent means the
133
+ * browser half renders no ornament.
134
+ */
135
+ decoration?: DecorationView;
122
136
  }
123
137
  /** Result of `pet.interact`. */
124
138
  export type PetInteractResult = LedgerInteractionResult;
@@ -141,9 +155,17 @@ export declare class PetService extends Service {
141
155
  private readonly registry;
142
156
  private readonly persistDir;
143
157
  private enabled;
158
+ /** Status-decoration master switch (M5, #567); mirrored from settings. */
159
+ private decorationEnabled;
144
160
  private disposeActivity;
145
161
  /** Session whose most recent meaningful event currently drives the global pet. */
146
162
  private displaySession;
163
+ /**
164
+ * Effective voice-pack overrides for the currently selected pet (M4,
165
+ * #677). Cached per pet id; the registry is an immutable snapshot, so the
166
+ * global pack and each entry's pack cannot change behind the cache.
167
+ */
168
+ private voiceCache;
147
169
  /**
148
170
  * Per-session activity, most recent last (Map insertion order). Bounded by
149
171
  * MAX_SESSION_BUBBLES so a burst of sessions cannot grow it without bound;
@@ -151,6 +173,12 @@ export declare class PetService extends Service {
151
173
  */
152
174
  private readonly sessionActivity;
153
175
  constructor(ctx: Context, config?: PetConfig);
176
+ /**
177
+ * The draw-time voice-pool provider handed to every projection runtime.
178
+ * It re-resolves when the selected pet changes, so live engines re-voice
179
+ * on the next draw without being rebuilt (M4, #677).
180
+ */
181
+ private voicePools;
154
182
  /** Whether the pet service consumes session activity while enabled. */
155
183
  isEnabled(): boolean;
156
184
  /** RPC: current pet state snapshot. */
@@ -165,6 +193,11 @@ export declare class PetService extends Service {
165
193
  diagnostics(): Promise<{
166
194
  diagnostics: PetRegistryDiagnostic[];
167
195
  }>;
196
+ /**
197
+ * The active status decoration view (M5, #567): the default 'whale' entry
198
+ * (user directories override built-ins by id), gated by the master switch.
199
+ */
200
+ private activeDecoration;
168
201
  /** The selected pet's registry entry. */
169
202
  activeEntry(): NonNullable<PetRegistry['entries'][number]>;
170
203
  /** Currently selected pet id (persisted). */
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAEtD,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AACpF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAQ9C,OAAO,EAAgC,KAAK,uBAAuB,EAAE,MAAM,aAAa,CAAA;AACxF,OAAO,EASL,KAAK,gBAAgB,EACtB,MAAM,cAAc,CAAA;AACrB,OAAO,EAML,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,WAAW,EAChB,KAAK,qBAAqB,EAC3B,MAAM,eAAe,CAAA;AAGtB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kCAAkC,CAAA;AACtE,OAAO,EAGL,KAAK,cAAc,EAEnB,KAAK,gBAAgB,EACtB,MAAM,YAAY,CAAA;AAEnB,4BAA4B;AAC5B,MAAM,WAAW,SAAS;IACxB,uBAAuB;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,CAAA;IAClC,4BAA4B;IAC5B,KAAK,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,CAAA;IAC/B,4BAA4B;IAC5B,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAA;IAC7B,8DAA8D;IAC9D,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,iEAAiE;IACjE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,6EAA6E;IAC7E,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,WAAW,CAAA;IACtB,oEAAoE;IACpE,IAAI,CAAC,EAAE,SAAS,WAAW,EAAE,CAAA;CAC9B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IACjC,2EAA2E;IAC3E,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,qBAAqB;IACrB,OAAO,EAAE,OAAO,CAAA;IAChB,4DAA4D;IAC5D,IAAI,EAAE,MAAM,CAAA;IACZ,yDAAyD;IACzD,KAAK,EAAE,MAAM,CAAA;IACb,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAA;IACd,iEAAiE;IACjE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B;AAED,2HAA2H;AAC3H,eAAO,MAAM,sBAAsB,QAAQ,CAAA;AAE3C;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,2EAA2E;IAC3E,SAAS,EAAE,MAAM,CAAA;IACjB,uDAAuD;IACvD,SAAS,EAAE,gBAAgB,CAAC,WAAW,CAAC,CAAA;IACxC,yCAAyC;IACzC,MAAM,EAAE,MAAM,CAAA;IACd,yCAAyC;IACzC,KAAK,EAAE,gBAAgB,CAAC,OAAO,CAAC,CAAA;CACjC;AAED,gFAAgF;AAChF,eAAO,MAAM,mBAAmB,KAAK,CAAA;AAErC,wCAAwC;AACxC,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,gBAAgB,CAAC,WAAW,CAAC,CAAA;IACxC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,gBAAgB,CAAC,OAAO,CAAC,CAAA;IAChC,aAAa,EAAE,OAAO,CAAA;IACtB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAA;IAC3B,gCAAgC;IAChC,QAAQ,EAAE,eAAe,CAAA;IACzB,6BAA6B;IAC7B,OAAO,EAAE,gBAAgB,CAAA;IACzB,4CAA4C;IAC5C,GAAG,EAAE;QACH,mBAAmB;QACnB,EAAE,EAAE,MAAM,CAAA;QACV,iDAAiD;QACjD,WAAW,EAAE,MAAM,CAAA;QACnB,4BAA4B;QAC5B,WAAW,EAAE,MAAM,CAAA;KACpB,CAAA;IACD,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAA;IACZ,kCAAkC;IAClC,MAAM,EAAE;QACN,0BAA0B;QAC1B,OAAO,EAAE,MAAM,CAAA;QACf,iBAAiB;QACjB,GAAG,EAAE,MAAM,CAAA;KACZ,CAAA;IACD;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;OAIG;IACH,UAAU,CAAC,EAAE,cAAc,CAAA;CAC5B;AAED,gCAAgC;AAChC,MAAM,MAAM,iBAAiB,GAAG,uBAAuB,CAAA;AAEvD,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf,GAAG,EAAE,UAAU,CAAA;KAChB;CACF;AAiBD;;;;;GAKG;AACH,qBAAa,UAAW,SAAQ,OAAO;IACrC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAK;IAE5B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAiB;IACzC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAgB;IAC5C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAW;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAa;IACtC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAQ;IACnC,OAAO,CAAC,OAAO,CAAS;IACxB,0EAA0E;IAC1E,OAAO,CAAC,iBAAiB,CAAS;IAClC,OAAO,CAAC,eAAe,CAA0B;IACjD,kFAAkF;IAClF,OAAO,CAAC,cAAc,CAAqB;IAC3C;;;;OAIG;IACH,OAAO,CAAC,UAAU,CAA8D;IAChF;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAsC;IAEtE,YAAY,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,SAAc,EA8B/C;IAED;;;;OAIG;IACH,OAAO,CAAC,UAAU;IAYlB,uEAAuE;IACvE,SAAS,IAAI,OAAO,CAEnB;IAED,uCAAuC;IACjC,KAAK,IAAI,OAAO,CAAC,YAAY,CAAC,CAEnC;IAED,yDAAyD;IACzD,OAAO,IAAI,gBAAgB,CAE1B;IAED,2EAA2E;IACrE,IAAI,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAErC;IAED,gEAAgE;IAChE,gBAAgB,IAAI,WAAW,CAE9B;IAED,wEAAwE;IAClE,WAAW,IAAI,OAAO,CAAC;QAAE,WAAW,EAAE,qBAAqB,EAAE,CAAA;KAAE,CAAC,CAErE;IAED;;;OAGG;IACH,OAAO,CAAC,gBAAgB;IAMxB,yCAAyC;IACzC,WAAW,IAAI,WAAW,CAAC,WAAW,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,CAAC,CAEzD;IAED,6CAA6C;IAC7C,aAAa,IAAI,MAAM,CAEtB;IAED,yEAAyE;IACzE,OAAO,CAAC,KAAK,GAAE,MAA6B,GAAG,MAAM,CAIpD;IAED,4EAA4E;IACtE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,EAAE,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAQjG;IAED,uEAAuE;IACvE,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAGjC;IAED,OAAO,CAAC,YAAY;IA4DpB,0EAA0E;IAC1E,OAAO,CAAC,UAAU;IAYlB;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAqBrB,gCAAgC;IAC1B,QAAQ,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAK/D;IAED,iCAAiC;IAC3B,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,OAAO,EAAE,gBAAgB,CAAA;KAAE,CAAC,CAKnF;IAED,wFAAwF;IAClF,SAAS,CAAC,KAAK,EAAE,OAAO,CAAC,gBAAgB,CAAC,GAAG,OAAO,CAAC;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,OAAO,EAAE,gBAAgB,CAAA;KAAE,CAAC,CASlG;IAED,2EAA2E;IACrE,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,EAAE,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAO9F;IAED;;;;;OAKG;IACH,oBAAoB,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAkBtD;IAED,oFAAoF;IACpF,OAAO,CAAC,mBAAmB;IAe3B,qFAAqF;IACrF,OAAO,CAAC,UAAU;IAIlB,8EAA8E;IAC9E,OAAO,CAAC,gBAAgB;IAIxB,OAAO,CAAC,IAAI;IAyDZ,OAAO,CAAC,KAAK;CAOd"}
@@ -15,8 +15,9 @@ import { Service } from '@deepseek-ai/cordis';
15
15
  import { emptyProjectionRuntime, isActivityPhase, projectOfficialEvent, } from './event-projection.js';
16
16
  import { PetLedger } from './ledger.js';
17
17
  import { DEFAULT_PET_NAME, DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN, PET_NAME_MAX_LENGTH, loadPetPersist, petHomeDir, savePetPersist, } from './persist.js';
18
- import { loadPetRegistry, petEntryView, petPackageRoot, } from './registry.js';
18
+ import { DEFAULT_DECORATION_ID, decorationView, loadPetRegistry, petEntryView, petPackageRoot, } from './registry.js';
19
19
  import { WHISPER_TTL_MS } from './chatter.js';
20
+ import { mergeVoicePacks } from './voice-pack.js';
20
21
  import { defaultPetStateConfig, PetStateMachine, } from './state.js';
21
22
  /** Settings namespace of the pet capability. Spelled here rather than imported: the browser half spells the same value. */
22
23
  export const PET_SETTINGS_NAMESPACE = 'pet';
@@ -36,9 +37,17 @@ export class PetService extends Service {
36
37
  registry;
37
38
  persistDir;
38
39
  enabled;
40
+ /** Status-decoration master switch (M5, #567); mirrored from settings. */
41
+ decorationEnabled;
39
42
  disposeActivity;
40
43
  /** Session whose most recent meaningful event currently drives the global pet. */
41
44
  displaySession;
45
+ /**
46
+ * Effective voice-pack overrides for the currently selected pet (M4,
47
+ * #677). Cached per pet id; the registry is an immutable snapshot, so the
48
+ * global pack and each entry's pack cannot change behind the cache.
49
+ */
50
+ voiceCache;
42
51
  /**
43
52
  * Per-session activity, most recent last (Map insertion order). Bounded by
44
53
  * MAX_SESSION_BUBBLES so a burst of sessions cannot grow it without bound;
@@ -72,8 +81,25 @@ export class PetService extends Service {
72
81
  this.stateConfig = { ...defaultPetStateConfig, ...(config.state ?? {}) };
73
82
  this.machine = new PetStateMachine(this.stateConfig);
74
83
  this.enabled = config.enabled ?? true;
84
+ this.decorationEnabled = config.decorationEnabled ?? true;
75
85
  this.syncActivity();
76
86
  }
87
+ /**
88
+ * The draw-time voice-pool provider handed to every projection runtime.
89
+ * It re-resolves when the selected pet changes, so live engines re-voice
90
+ * on the next draw without being rebuilt (M4, #677).
91
+ */
92
+ voicePools() {
93
+ return () => {
94
+ const entry = this.activeEntry();
95
+ if (this.voiceCache !== undefined && this.voiceCache.petId === entry.id) {
96
+ return this.voiceCache.overrides;
97
+ }
98
+ const overrides = mergeVoicePacks(this.registry.globalVoice, entry.voice)?.overrides ?? {};
99
+ this.voiceCache = { petId: entry.id, overrides };
100
+ return overrides;
101
+ };
102
+ }
77
103
  /** Whether the pet service consumes session activity while enabled. */
78
104
  isEnabled() {
79
105
  return this.enabled;
@@ -88,7 +114,7 @@ export class PetService extends Service {
88
114
  }
89
115
  /** RPC: the registry entries the browser half renders and selects from. */
90
116
  async pets() {
91
- return this.registry.entries.map(petEntryView);
117
+ return this.registry.entries.map(entry => petEntryView(entry, this.registry.globalVoice));
92
118
  }
93
119
  /** The loaded registry (the asset routes serve its entries). */
94
120
  registrySnapshot() {
@@ -98,6 +124,16 @@ export class PetService extends Service {
98
124
  async diagnostics() {
99
125
  return { diagnostics: this.registry.diagnostics };
100
126
  }
127
+ /**
128
+ * The active status decoration view (M5, #567): the default 'whale' entry
129
+ * (user directories override built-ins by id), gated by the master switch.
130
+ */
131
+ activeDecoration() {
132
+ if (!this.decorationEnabled)
133
+ return undefined;
134
+ const entry = this.registry.decorationById?.(DEFAULT_DECORATION_ID);
135
+ return entry === undefined ? undefined : decorationView(entry);
136
+ }
101
137
  /** The selected pet's registry entry. */
102
138
  activeEntry() {
103
139
  return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry();
@@ -199,7 +235,7 @@ export class PetService extends Service {
199
235
  let activity = this.sessionActivity.get(session);
200
236
  if (activity === undefined) {
201
237
  activity = {
202
- runtime: emptyProjectionRuntime(),
238
+ runtime: emptyProjectionRuntime(this.voicePools()),
203
239
  machine: new PetStateMachine(this.stateConfig),
204
240
  };
205
241
  this.sessionActivity.set(session, activity);
@@ -277,6 +313,7 @@ export class PetService extends Service {
277
313
  * @param section - the resolved settings section.
278
314
  */
279
315
  applySettingsSection(section) {
316
+ this.decorationEnabled = section.decorationEnabled ?? true;
280
317
  const selected = typeof section.petId === 'string' ? this.registry.byId(section.petId) : undefined;
281
318
  if (selected !== undefined) {
282
319
  this.ledger.setPetId(selected.id);
@@ -355,6 +392,7 @@ export class PetService extends Service {
355
392
  const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
356
393
  ? whisper.text
357
394
  : undefined;
395
+ const decoration = this.activeDecoration();
358
396
  // Read-only: the ledger settles on economic events only, never on a read,
359
397
  // so polling the state cannot trigger pet.json writes.
360
398
  return {
@@ -364,6 +402,7 @@ export class PetService extends Service {
364
402
  sessionActive: snapshot.sessionActive,
365
403
  sessions,
366
404
  ...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
405
+ ...(decoration === undefined ? {} : { decoration }),
367
406
  affinity: this.ledger.affinityView(Date.now()),
368
407
  display: { ...this.ledger.snapshot.display },
369
408
  pet: {