@linxin666/dsh-pet 0.2.2 → 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 (125) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +129 -3
  3. package/README.zh.md +129 -3
  4. package/assets/decorations/whale/decoration.json +20 -0
  5. package/assets/decorations/whale/whale-frames.png +0 -0
  6. package/assets/whale/pet.json +169 -20
  7. package/assets/whale-refined/pet.json +17 -2
  8. package/contracts/pet-manifest-v2.schema.json +281 -0
  9. package/contracts/status-decoration-v1.schema.json +144 -0
  10. package/contracts/voice-pack-v1.schema.json +234 -0
  11. package/lib/client.js +581 -38
  12. package/lib/client.js.map +1 -1
  13. package/lib/index.js +1684 -59
  14. package/lib/live2d-vendor.js +995 -0
  15. package/lib/live2d-vendor.js.map +1 -0
  16. package/lib/types/chatter.d.ts +61 -3
  17. package/lib/types/chatter.d.ts.map +1 -1
  18. package/lib/types/chatter.js +71 -12
  19. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  20. package/lib/types/client/PetDockEntry.js +2 -1
  21. package/lib/types/client/PetSettingsCard.d.ts +14 -0
  22. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  23. package/lib/types/client/PetSettingsCard.js +24 -1
  24. package/lib/types/client/PetSprite.d.ts +7 -1
  25. package/lib/types/client/PetSprite.d.ts.map +1 -1
  26. package/lib/types/client/PetSprite.js +120 -12
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/index.js +5 -0
  29. package/lib/types/client/locales.d.ts +14 -0
  30. package/lib/types/client/locales.d.ts.map +1 -1
  31. package/lib/types/client/locales.js +14 -0
  32. package/lib/types/client/phase-stream.d.ts +22 -0
  33. package/lib/types/client/phase-stream.d.ts.map +1 -0
  34. package/lib/types/client/phase-stream.js +28 -0
  35. package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
  36. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
  37. package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
  38. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
  39. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
  40. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
  41. package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
  42. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
  43. package/lib/types/client/renderers/live2d/runtime.js +63 -0
  44. package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
  45. package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
  46. package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
  47. package/lib/types/client/renderers/live2d.d.ts +52 -0
  48. package/lib/types/client/renderers/live2d.d.ts.map +1 -0
  49. package/lib/types/client/renderers/live2d.js +192 -0
  50. package/lib/types/client/renderers/registry.d.ts +32 -0
  51. package/lib/types/client/renderers/registry.d.ts.map +1 -0
  52. package/lib/types/client/renderers/registry.js +49 -0
  53. package/lib/types/client/settings-form.d.ts.map +1 -1
  54. package/lib/types/client/settings-form.js +9 -6
  55. package/lib/types/contracts/renderer.d.ts +50 -0
  56. package/lib/types/contracts/renderer.d.ts.map +1 -0
  57. package/lib/types/contracts/renderer.js +15 -0
  58. package/lib/types/contracts/status-decoration.d.ts +85 -0
  59. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  60. package/lib/types/contracts/status-decoration.js +21 -0
  61. package/lib/types/decoration.d.ts +39 -0
  62. package/lib/types/decoration.d.ts.map +1 -0
  63. package/lib/types/decoration.js +210 -0
  64. package/lib/types/event-projection.d.ts +8 -3
  65. package/lib/types/event-projection.d.ts.map +1 -1
  66. package/lib/types/event-projection.js +9 -4
  67. package/lib/types/index.d.ts +2 -0
  68. package/lib/types/index.d.ts.map +1 -1
  69. package/lib/types/index.js +2 -0
  70. package/lib/types/manifest-v2.d.ts +129 -0
  71. package/lib/types/manifest-v2.d.ts.map +1 -0
  72. package/lib/types/manifest-v2.js +387 -0
  73. package/lib/types/model3.d.ts +26 -0
  74. package/lib/types/model3.d.ts.map +1 -0
  75. package/lib/types/model3.js +96 -0
  76. package/lib/types/registry.d.ts +112 -5
  77. package/lib/types/registry.d.ts.map +1 -1
  78. package/lib/types/registry.js +395 -38
  79. package/lib/types/routes.d.ts +39 -2
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +312 -13
  82. package/lib/types/service.d.ts +38 -1
  83. package/lib/types/service.d.ts.map +1 -1
  84. package/lib/types/service.js +46 -3
  85. package/lib/types/voice-pack.d.ts +98 -0
  86. package/lib/types/voice-pack.d.ts.map +1 -0
  87. package/lib/types/voice-pack.js +384 -0
  88. package/package.json +13 -10
  89. package/src/chatter.test.ts +89 -2
  90. package/src/chatter.ts +120 -14
  91. package/src/client/PetDockEntry.tsx +19 -11
  92. package/src/client/PetSettingsCard.tsx +57 -0
  93. package/src/client/PetSprite.test.tsx +265 -12
  94. package/src/client/PetSprite.tsx +165 -33
  95. package/src/client/index.ts +6 -0
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/phase-stream.test.ts +76 -0
  98. package/src/client/phase-stream.ts +39 -0
  99. package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
  100. package/src/client/renderers/PetRendererSwitch.tsx +48 -0
  101. package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
  102. package/src/client/renderers/live2d/runtime.test.ts +61 -0
  103. package/src/client/renderers/live2d/runtime.ts +119 -0
  104. package/src/client/renderers/live2d/vendor-entry.ts +14 -0
  105. package/src/client/renderers/live2d.test.ts +263 -0
  106. package/src/client/renderers/live2d.ts +231 -0
  107. package/src/client/renderers/registry.ts +58 -0
  108. package/src/client/settings-form.ts +8 -6
  109. package/src/client/settings-section.module.css +23 -0
  110. package/src/contracts/renderer.ts +54 -0
  111. package/src/contracts/status-decoration.ts +78 -0
  112. package/src/decoration.test.ts +178 -0
  113. package/src/decoration.ts +220 -0
  114. package/src/event-projection.ts +10 -5
  115. package/src/index.ts +2 -0
  116. package/src/manifest-v2.test.ts +251 -0
  117. package/src/manifest-v2.ts +414 -0
  118. package/src/model3.test.ts +79 -0
  119. package/src/model3.ts +91 -0
  120. package/src/registry.test.ts +438 -2
  121. package/src/registry.ts +470 -38
  122. package/src/routes.ts +328 -14
  123. package/src/service.ts +65 -3
  124. package/src/voice-pack.test.ts +216 -0
  125. package/src/voice-pack.ts +413 -0
package/lib/client.js CHANGED
@@ -220,6 +220,78 @@ window.__ModuleLoader__.load({
220
220
  return Math.max(0, Math.min(max, value));
221
221
  }
222
222
  /**
223
+ * The status decoration ornament (pet-center M5, #567). Renders the active
224
+ * phase's frame segment as a CSS-background strip at a compact bubble
225
+ * height; prefers-reduced-motion holds the segment's first frame, and a
226
+ * missing or undecodable asset simply paints nothing (CSS background
227
+ * failure) — the bubble text is never disturbed. The span is aria-hidden;
228
+ * the bubble keeps its own semantics untouched.
229
+ */
230
+ function StatusOrnament(props) {
231
+ const { decoration, phase } = props;
232
+ const segment = decoration.phases[phase];
233
+ const shown = segment !== void 0 && segment !== "hide";
234
+ const segmentKey = segment !== void 0 && segment !== "hide" ? segment.from + ":" + segment.to : "none";
235
+ const spanRef = (0, react.useRef)(null);
236
+ const scale = 18 / decoration.cell.height;
237
+ const frameWidth = Math.round(decoration.cell.width * scale);
238
+ const stripWidth = decoration.columns * frameWidth;
239
+ const durationsKey = decoration.durations.join(",");
240
+ (0, react.useEffect)(() => {
241
+ if (segment === void 0 || segment === "hide") return;
242
+ const el = spanRef.current;
243
+ if (el === null) return;
244
+ const position = (index) => -index * frameWidth + "px 0px";
245
+ const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true;
246
+ el.style.backgroundPosition = position(segment.from);
247
+ if (reduceMotion) return;
248
+ let raf = 0;
249
+ let index = segment.from;
250
+ let elapsed = 0;
251
+ let last = performance.now();
252
+ const tick = (ts) => {
253
+ const delta = ts - last;
254
+ last = ts;
255
+ elapsed += delta;
256
+ const duration = decoration.durations[index] ?? 160;
257
+ if (elapsed >= duration) {
258
+ elapsed = 0;
259
+ if (index < segment.to) index += 1;
260
+ else if (decoration.loop) index = segment.from;
261
+ }
262
+ el.style.backgroundPosition = position(index);
263
+ if (!decoration.loop && index === segment.to) return;
264
+ raf = requestAnimationFrame(tick);
265
+ };
266
+ raf = requestAnimationFrame(tick);
267
+ return () => cancelAnimationFrame(raf);
268
+ }, [
269
+ shown,
270
+ segmentKey,
271
+ frameWidth,
272
+ decoration.loop,
273
+ durationsKey
274
+ ]);
275
+ if (!shown) return null;
276
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
277
+ ref: spanRef,
278
+ "aria-hidden": "true",
279
+ "data-dsh-pet-decoration": decoration.id,
280
+ style: {
281
+ display: "inline-block",
282
+ width: frameWidth,
283
+ height: 18,
284
+ marginRight: 6,
285
+ verticalAlign: "middle",
286
+ flexShrink: 0,
287
+ backgroundImage: "url(" + decoration.entryUrl + ")",
288
+ backgroundSize: stripWidth + "px 18px",
289
+ backgroundRepeat: "no-repeat",
290
+ backgroundPosition: "0px 0px"
291
+ }
292
+ });
293
+ }
294
+ /**
223
295
  * The floating pet. The spritesheet frame advances on requestAnimationFrame
224
296
  * with per-frame durations from the definition's tracks; the atlas image is
225
297
  * loaded once and the background position is written straight to the sprite
@@ -253,7 +325,23 @@ window.__ModuleLoader__.load({
253
325
  const rows = definition.rows;
254
326
  const tracks = definition.tracks;
255
327
  const sequences = definition.sequences;
328
+ const panel = definition.panel;
329
+ const panelLabel = (slot, i18n) => panel?.labels?.[slot] ?? i18n;
330
+ const panelStat = (slot, i18nKey, values) => {
331
+ const format = panel?.stats?.[slot] ?? props.t(i18nKey, values);
332
+ if (panel?.stats?.[slot] === void 0) return format;
333
+ const all = {
334
+ rank: snapshot?.affinity.rank ?? "?",
335
+ n: snapshot?.treats.stocked ?? 0,
336
+ points: snapshot?.affinity.points ?? 0
337
+ };
338
+ let text = format;
339
+ for (const [name, value] of Object.entries(all)) text = text.replaceAll("{" + name + "}", String(value));
340
+ return text;
341
+ };
342
+ const panelShows = (action) => panel?.actions === void 0 || panel.actions.includes(action);
256
343
  (0, react.useEffect)(() => {
344
+ if (props.visual !== void 0) return;
257
345
  let cancelled = false;
258
346
  const img = new Image();
259
347
  img.onload = () => {
@@ -264,13 +352,14 @@ window.__ModuleLoader__.load({
264
352
  cancelled = true;
265
353
  img.onload = null;
266
354
  };
267
- }, [definition.atlasUrl]);
355
+ }, [definition.atlasUrl, props.visual]);
268
356
  const spriteScale = display.size / cell.height;
269
357
  const phase = snapshot?.phase ?? "idle";
270
358
  const animation = snapshot?.animation ?? "idle";
271
359
  const scaleRef = (0, react.useRef)(spriteScale);
272
360
  scaleRef.current = spriteScale;
273
361
  (0, react.useEffect)(() => {
362
+ if (props.visual !== void 0) return;
274
363
  const reduceMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true;
275
364
  const sequence = animation === animationForPhase(phase) ? sequences?.[phase] : void 0;
276
365
  const leadAnimation = sequence?.[0] ?? animation;
@@ -326,7 +415,8 @@ window.__ModuleLoader__.load({
326
415
  columns,
327
416
  rows,
328
417
  tracks,
329
- sequences
418
+ sequences,
419
+ props.visual
330
420
  ]);
331
421
  const feedbackDoneRef = (0, react.useRef)(props.onFeedbackDone);
332
422
  feedbackDoneRef.current = props.onFeedbackDone;
@@ -389,6 +479,7 @@ window.__ModuleLoader__.load({
389
479
  const whisper = feedback === null ? snapshot?.whisper : void 0;
390
480
  const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== void 0 || whisper !== void 0;
391
481
  const displayName = snapshot?.name ?? definition.displayName;
482
+ const decoration = snapshot?.decoration;
392
483
  (0, react.useEffect)(() => {
393
484
  if (sessionBubbles.length <= 1) setStackPinned(false);
394
485
  }, [sessionBubbles.length]);
@@ -447,10 +538,12 @@ window.__ModuleLoader__.load({
447
538
  style: {
448
539
  width: spriteWidth,
449
540
  height: spriteHeight,
450
- backgroundImage: imageReady ? "url(" + definition.atlasUrl + ")" : void 0,
451
- backgroundSize: cell.width * columns * spriteScale + "px " + cell.height * (definition.atlasRows ?? rows.length) * spriteScale + "px",
452
- backgroundRepeat: "no-repeat",
453
- backgroundPosition: "0 0",
541
+ ...props.visual === void 0 ? {
542
+ backgroundImage: imageReady ? "url(" + definition.atlasUrl + ")" : void 0,
543
+ backgroundSize: cell.width * columns * spriteScale + "px " + cell.height * (definition.atlasRows ?? rows.length) * spriteScale + "px",
544
+ backgroundRepeat: "no-repeat",
545
+ backgroundPosition: "0 0"
546
+ } : {},
454
547
  cursor: dragRef.current === null ? "grab" : "grabbing"
455
548
  },
456
549
  onPointerDown,
@@ -461,7 +554,8 @@ window.__ModuleLoader__.load({
461
554
  props.onPet();
462
555
  },
463
556
  role: "button",
464
- "aria-label": definition.displayName
557
+ "aria-label": definition.displayName,
558
+ children: props.visual
465
559
  }),
466
560
  feedback !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
467
561
  ref: bubbleRef,
@@ -475,14 +569,17 @@ window.__ModuleLoader__.load({
475
569
  onPointerLeave: () => setStackPeek(false),
476
570
  children: [visibleSessions.map((session, index) => {
477
571
  const speaksWhisper = index === 0 && whisper !== void 0;
478
- const bubble = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
572
+ const bubble = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
479
573
  type: "button",
480
574
  className: clsx(pet_module_css_default.bubble, pet_module_css_default.bubbleStatus, pet_module_css_default.bubbleClickable, speaksWhisper && pet_module_css_default.bubbleWhisper),
481
575
  title: props.t("pet.openSessionHint"),
482
576
  onClick: () => {
483
577
  props.onOpenSession(session.sessionId);
484
578
  },
485
- children: speaksWhisper ? whisper : session.bubble
579
+ children: [index === 0 && !speaksWhisper && decoration !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatusOrnament, {
580
+ decoration,
581
+ phase
582
+ }), speaksWhisper ? whisper : session.bubble]
486
583
  }, speaksWhisper ? "whisper:" + whisper : session.sessionId);
487
584
  if (index !== 0 || sessionBubbles.length <= 1) return bubble;
488
585
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
@@ -500,11 +597,14 @@ window.__ModuleLoader__.load({
500
597
  children: stackOpen ? "×" : "+" + String(sessionBubbles.length - 1)
501
598
  })]
502
599
  }, "primary");
503
- }), sessionBubbles.length === 0 && (statusBubble !== void 0 || whisper !== void 0) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
600
+ }), sessionBubbles.length === 0 && (statusBubble !== void 0 || whisper !== void 0) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
504
601
  className: clsx(pet_module_css_default.bubble, pet_module_css_default.bubbleStatus, whisper !== void 0 && pet_module_css_default.bubbleWhisper),
505
602
  role: "status",
506
603
  "aria-live": "polite",
507
- children: whisper ?? statusBubble
604
+ children: [whisper === void 0 && decoration !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatusOrnament, {
605
+ decoration,
606
+ phase
607
+ }), whisper ?? statusBubble]
508
608
  }, whisper === void 0 ? "status" : "whisper:" + whisper)]
509
609
  }),
510
610
  hovered && dragRef.current === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -550,7 +650,7 @@ window.__ModuleLoader__.load({
550
650
  setRenaming(false);
551
651
  }
552
652
  },
553
- children: props.t("pet.confirm")
653
+ children: panelLabel("confirm", props.t("pet.confirm"))
554
654
  })]
555
655
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
556
656
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -560,29 +660,29 @@ window.__ModuleLoader__.load({
560
660
  children: displayName
561
661
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
562
662
  className: pet_module_css_default.statRank,
563
- children: props.t("pet.rank", { rank: snapshot?.affinity.rank ?? "?" })
663
+ children: panelStat("rank", "pet.rank", { rank: snapshot?.affinity.rank ?? "?" })
564
664
  })]
565
665
  }),
566
666
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
567
667
  className: pet_module_css_default.rankRow,
568
668
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
569
669
  className: pet_module_css_default.statTreats,
570
- children: props.t("pet.treats", { n: snapshot?.treats.stocked ?? 0 })
670
+ children: panelStat("treats", "pet.treats", { n: snapshot?.treats.stocked ?? 0 })
571
671
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
572
672
  className: pet_module_css_default.statPoints,
573
- children: props.t("pet.points", { points: snapshot?.affinity.points ?? 0 })
673
+ children: panelStat("points", "pet.points", { points: snapshot?.affinity.points ?? 0 })
574
674
  })]
575
675
  }),
576
676
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
577
677
  className: pet_module_css_default.actions,
578
678
  children: [
579
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
679
+ panelShows("feed") && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
580
680
  type: "button",
581
681
  className: pet_module_css_default.action,
582
682
  onClick: props.onFeed,
583
- children: props.t("pet.feed")
683
+ children: panelLabel("feed", props.t("pet.feed"))
584
684
  }),
585
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
685
+ panelShows("rename") && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
586
686
  type: "button",
587
687
  className: pet_module_css_default.action,
588
688
  onClick: () => {
@@ -590,13 +690,13 @@ window.__ModuleLoader__.load({
590
690
  setNameDraft(displayName);
591
691
  setRenaming(true);
592
692
  },
593
- children: props.t("pet.rename")
693
+ children: panelLabel("rename", props.t("pet.rename"))
594
694
  }),
595
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
695
+ panelShows("hide") && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
596
696
  type: "button",
597
697
  className: pet_module_css_default.action,
598
698
  onClick: props.onHide,
599
- children: props.t("pet.hide")
699
+ children: panelLabel("hide", props.t("pet.hide"))
600
700
  })
601
701
  ]
602
702
  })
@@ -606,6 +706,181 @@ window.__ModuleLoader__.load({
606
706
  }), document.body);
607
707
  }
608
708
  //#endregion
709
+ //#region src/client/renderers/registry.ts
710
+ /** Renderer dispatch table. */
711
+ var RendererRegistry = class {
712
+ renderers = /* @__PURE__ */ new Map();
713
+ /** Register one renderer implementation (id wins on re-register). */
714
+ register(renderer) {
715
+ this.renderers.set(renderer.id, renderer);
716
+ }
717
+ /** Whether a renderer kind is available in this build. */
718
+ has(id) {
719
+ return this.renderers.has(id);
720
+ }
721
+ /** The registered renderer kinds (for diagnostics). */
722
+ kinds() {
723
+ return [...this.renderers.keys()].sort();
724
+ }
725
+ /** Remove every registration (tests; the client index registers once). */
726
+ clear() {
727
+ this.renderers.clear();
728
+ }
729
+ /**
730
+ * Mount a renderer for one activation. An unknown kind renders a clear
731
+ * diagnostic card into the container instead of failing silently.
732
+ */
733
+ mount(kind, ctx, config) {
734
+ const renderer = this.renderers.get(kind);
735
+ if (renderer === void 0) {
736
+ const note = document.createElement("div");
737
+ note.dataset.dshPetRendererFallback = kind;
738
+ note.textContent = "Pet renderer \"" + kind + "\" is not available in this build (supported: " + this.kinds().join(", ") + ").";
739
+ ctx.container.appendChild(note);
740
+ ctx.onCleanup(() => note.remove());
741
+ return { dispose: () => note.remove() };
742
+ }
743
+ return renderer.mount(ctx, renderer.validateConfig(config));
744
+ }
745
+ };
746
+ /**
747
+ * The plugin-wide renderer registry. The client entry registers the
748
+ * built-in renderers at apply time; the renderer switch and the live2d
749
+ * bridge dispatch through this instance.
750
+ */
751
+ const defaultPetRendererRegistry = new RendererRegistry();
752
+ //#endregion
753
+ //#region src/client/phase-stream.ts
754
+ /** Create the stream (one per pet entry lifetime, owned by the plugin body). */
755
+ function createPhaseStream(initial = "idle") {
756
+ let current = initial;
757
+ const listeners = /* @__PURE__ */ new Set();
758
+ return {
759
+ get: () => current,
760
+ subscribe(listener) {
761
+ listeners.add(listener);
762
+ return () => {
763
+ listeners.delete(listener);
764
+ };
765
+ },
766
+ push(phase) {
767
+ if (phase === current) return;
768
+ current = phase;
769
+ for (const listener of [...listeners]) listener(phase);
770
+ }
771
+ };
772
+ }
773
+ //#endregion
774
+ //#region src/client/renderers/live2d/Live2dVisualMount.tsx
775
+ /**
776
+ * Live2D visual mount (pet-center M3) — the React bridge between the pet
777
+ * center chrome and the imperative live2d renderer. The bridge owns the
778
+ * contract context (asset base, phase stream, interaction write-back,
779
+ * activation cleanups), feeds the polled phase into the stream, forwards
780
+ * sub-4px taps as hit-test coordinates, and renders the localized error
781
+ * card when the renderer reports a fatal boot failure.
782
+ * @module @linxin666/dsh-pet/client/renderers/live2d/Live2dVisualMount
783
+ */
784
+ /** Mount the live2d renderer as the sprite's visual (inside the chrome). */
785
+ function Live2dVisualMount(props) {
786
+ const containerRef = (0, react.useRef)(null);
787
+ const streamRef = (0, react.useRef)(null);
788
+ const handleRef = (0, react.useRef)(null);
789
+ const downRef = (0, react.useRef)(null);
790
+ const [error, setError] = (0, react.useState)(null);
791
+ (0, react.useEffect)(() => {
792
+ const container = containerRef.current;
793
+ const live2d = props.definition.live2d;
794
+ if (container === null || live2d === void 0) return void 0;
795
+ streamRef.current ??= createPhaseStream(props.phase);
796
+ const cleanups = [];
797
+ const ctx = {
798
+ petId: props.definition.id,
799
+ assetBase: "/pet/" + encodeURIComponent(props.definition.id),
800
+ container,
801
+ phase: streamRef.current,
802
+ interact: props.onPet,
803
+ onCleanup: (fn) => {
804
+ cleanups.push(fn);
805
+ }
806
+ };
807
+ let handle;
808
+ try {
809
+ handle = defaultPetRendererRegistry.mount("live2d", ctx, live2d);
810
+ } catch {
811
+ setError("load-failed");
812
+ return () => {
813
+ for (const fn of cleanups.splice(0)) fn();
814
+ };
815
+ }
816
+ handleRef.current = handle;
817
+ handle.onError?.(setError);
818
+ return () => {
819
+ handleRef.current = null;
820
+ for (const fn of cleanups.splice(0)) fn();
821
+ handle.dispose();
822
+ };
823
+ }, [props.definition]);
824
+ (0, react.useEffect)(() => {
825
+ streamRef.current?.push(props.phase);
826
+ }, [props.phase]);
827
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
828
+ ref: containerRef,
829
+ "data-dsh-pet-live2d": props.definition.id,
830
+ style: {
831
+ width: "100%",
832
+ height: "100%"
833
+ },
834
+ onPointerDown: (e) => {
835
+ downRef.current = {
836
+ x: e.clientX,
837
+ y: e.clientY
838
+ };
839
+ },
840
+ onPointerUp: (e) => {
841
+ const down = downRef.current;
842
+ downRef.current = null;
843
+ if (down === null) return;
844
+ if (Math.abs(e.clientX - down.x) > 4 || Math.abs(e.clientY - down.y) > 4) return;
845
+ const rect = e.currentTarget.getBoundingClientRect();
846
+ handleRef.current?.tap(e.clientX - rect.left, e.clientY - rect.top);
847
+ },
848
+ children: error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
849
+ "data-dsh-pet-live2d-error": error,
850
+ children: error === "core-missing" ? props.t("pet.live2d.core-missing") : error === "vendor-missing" ? props.t("pet.live2d.vendor-missing") : props.t("pet.live2d.load-failed")
851
+ })
852
+ });
853
+ }
854
+ //#endregion
855
+ //#region src/client/renderers/PetRendererSwitch.tsx
856
+ /**
857
+ * Renderer switch — the client dispatch seam of the pet center (issue #623,
858
+ * milestone M2 P5 / M3). The pet's manifest picks the renderer: sprite2d
859
+ * hands straight through to the sprite; live2d injects its visual INTO the
860
+ * sprite chrome (the dock, bubbles and panel belong to the pet center, not
861
+ * the renderer); a renderer this build cannot serve renders a clear
862
+ * diagnostic card instead of blanking.
863
+ * @module @linxin666/dsh-pet/client/renderers/PetRendererSwitch
864
+ */
865
+ /** Dispatch one pet definition to its renderer; unknown kinds get a card. */
866
+ function PetRendererSwitch(props) {
867
+ const renderer = props.definition.renderer ?? "sprite2d";
868
+ if (renderer === "sprite2d") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: props.children });
869
+ if (renderer === "live2d" && defaultPetRendererRegistry.has("live2d") && (0, react.isValidElement)(props.children)) {
870
+ const visual = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Live2dVisualMount, {
871
+ definition: props.definition,
872
+ phase: props.phase,
873
+ onPet: props.onPet,
874
+ t: props.t
875
+ });
876
+ return (0, react.cloneElement)(props.children, { visual });
877
+ }
878
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
879
+ "data-dsh-pet-renderer-fallback": renderer,
880
+ children: props.t("pet.renderer.unavailable", { renderer })
881
+ });
882
+ }
883
+ //#endregion
609
884
  //#region src/client/PetDockEntry.tsx
610
885
  /**
611
886
  * Global floating pet entry. The pet is host-global (its state, display and
@@ -645,19 +920,25 @@ window.__ModuleLoader__.load({
645
920
  if (visible) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
646
921
  "data-pet-dock": true,
647
922
  "data-testid": "pet-dock",
648
- children: snapshot === null || definition === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PetSprite, {
649
- snapshot,
923
+ children: snapshot === null || definition === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PetRendererSwitch, {
650
924
  definition,
651
- display: snapshot.display,
652
- feedback,
925
+ phase: snapshot?.phase ?? "idle",
653
926
  onPet: props.pet,
654
- onFeed: props.feed,
655
- onHide: props.hide,
656
- onDragEnd: props.dragEnd,
657
- onRename: props.rename,
658
- onOpenSession: props.openSession,
659
- onFeedbackDone: props.feedbackDone,
660
- t: props.t
927
+ t: props.t,
928
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PetSprite, {
929
+ snapshot,
930
+ definition,
931
+ display: snapshot.display,
932
+ feedback,
933
+ onPet: props.pet,
934
+ onFeed: props.feed,
935
+ onHide: props.hide,
936
+ onDragEnd: props.dragEnd,
937
+ onRename: props.rename,
938
+ onOpenSession: props.openSession,
939
+ onFeedbackDone: props.feedbackDone,
940
+ t: props.t
941
+ })
661
942
  })
662
943
  });
663
944
  const display = snapshot?.display ?? DEFAULT_DISPLAY;
@@ -677,6 +958,201 @@ window.__ModuleLoader__.load({
677
958
  });
678
959
  }
679
960
  //#endregion
961
+ //#region src/contracts/renderer.ts
962
+ /** Contract version renderers declare against (independent of the manifest). */
963
+ const PET_RENDERER_API_VERSION = "x-org.linxin666.pet-center/v1alpha1";
964
+ //#endregion
965
+ //#region src/client/renderers/live2d/runtime.ts
966
+ /**
967
+ * Live2D runtime loading (pet-center M3) — the two scripts a live2d mount
968
+ * needs, fetched lazily through the plugin's own runtime route: the
969
+ * user-supplied Cubism Core (proprietary; the plugin never bundles or
970
+ * downloads it — issue #623 M1 §0) and the plugin-shipped MIT vendor bundle
971
+ * (pixi.js + untitled-pixi-live2d-engine). Each loads at most once per page;
972
+ * concurrent mounts share the in-flight promise, and a failure is cached as
973
+ * 'absent' so a broken install stops retrying the network every mount.
974
+ *
975
+ * The vendor surface below is the structural slice the renderer consumes;
976
+ * the real objects come from 'window.__dshPetLive2d' (lib/live2d-vendor.js),
977
+ * so this module never imports pixi — the client bundle stays lean.
978
+ * @module @linxin666/dsh-pet/client/renderers/live2d/runtime
979
+ */
980
+ /** Runtime file URLs the host serves ('/api/pet/runtime/<name>', M3-2). */
981
+ const CORE_URL = "/api/pet/runtime/live2dcubismcore.min.js";
982
+ const VENDOR_URL = "/api/pet/runtime/live2d-vendor.js";
983
+ const defaultInjector = (src) => new Promise((resolve, reject) => {
984
+ const tag = document.createElement("script");
985
+ tag.src = src;
986
+ tag.onload = () => resolve();
987
+ tag.onerror = () => reject(/* @__PURE__ */ new Error("script failed to load: " + src));
988
+ document.head.appendChild(tag);
989
+ });
990
+ let corePromise;
991
+ let vendorPromise;
992
+ /**
993
+ * Ensure the Cubism Core global exists, injecting the runtime-route script
994
+ * once when absent. Resolves false when the user has not installed the core
995
+ * (a normal state — the renderer turns it into install guidance).
996
+ */
997
+ function ensureCubismCore(probe = {}) {
998
+ if (typeof window !== "undefined" && window.Live2DCubismCore !== void 0) return Promise.resolve(true);
999
+ if (probe.inject !== void 0) return probe.inject(CORE_URL).then(() => typeof window !== "undefined" && window.Live2DCubismCore !== void 0).catch(() => false);
1000
+ corePromise ??= defaultInjector(CORE_URL).then(() => typeof window !== "undefined" && window.Live2DCubismCore !== void 0).catch(() => false);
1001
+ return corePromise;
1002
+ }
1003
+ /** Ensure the plugin vendor bundle global exists (same caching discipline). */
1004
+ function ensureLive2dVendor(probe = {}) {
1005
+ if (typeof window !== "undefined" && window.__dshPetLive2d !== void 0) return Promise.resolve(window.__dshPetLive2d);
1006
+ if (probe.inject !== void 0) return probe.inject(VENDOR_URL).then(() => typeof window !== "undefined" ? window.__dshPetLive2d : void 0).catch(() => void 0);
1007
+ vendorPromise ??= defaultInjector(VENDOR_URL).then(() => typeof window !== "undefined" ? window.__dshPetLive2d : void 0).catch(() => void 0);
1008
+ return vendorPromise;
1009
+ }
1010
+ //#endregion
1011
+ //#region src/client/renderers/live2d.ts
1012
+ /** The de-facto tap-motion group of Cubism sample models. */
1013
+ const TAP_GROUP = "TapBody";
1014
+ /**
1015
+ * Keep one screen-appropriate atlas LOD instead of asking Pixi for the
1016
+ * engine's default full mip chain. A user model can legitimately carry an
1017
+ * 8192px texture while the pet itself is only a few hundred pixels tall;
1018
+ * `single-auto` preserves the source for larger renders and generates one
1019
+ * downsampled atlas only when the effective on-screen scale warrants it.
1020
+ */
1021
+ const TEXTURE_OPTIONS = { lod: "single-auto" };
1022
+ let vendorConfigured = false;
1023
+ /** Configure pixi extensions + the Cubism SDK once per page. */
1024
+ function configureOnce(vendor) {
1025
+ if (vendorConfigured) return;
1026
+ vendorConfigured = true;
1027
+ vendor.extensions.add(vendor.Live2DPlugin);
1028
+ vendor.configureCubismSDK({ memorySizeMB: 32 });
1029
+ }
1030
+ /** Fail-closed config validation (contract: unknown manifest block in). */
1031
+ function validateLive2dConfig(config) {
1032
+ if (typeof config !== "object" || config === null) throw new Error("live2d config is not an object");
1033
+ const source = config;
1034
+ if (typeof source.modelUrl !== "string" || source.modelUrl === "") throw new Error("live2d config modelUrl is required");
1035
+ const motions = source.motions;
1036
+ if (typeof motions !== "object" || motions === null || typeof motions.idle !== "string") throw new Error("live2d config motions.idle is required");
1037
+ return config;
1038
+ }
1039
+ /** The live2d renderer implementation. */
1040
+ const live2dRenderer = {
1041
+ id: "live2d",
1042
+ apiVersion: PET_RENDERER_API_VERSION,
1043
+ validateConfig: validateLive2dConfig,
1044
+ mount(ctx, config) {
1045
+ let disposed = false;
1046
+ let app;
1047
+ let model;
1048
+ let errorListener;
1049
+ let unsubscribe;
1050
+ /** The motion group the current phase maps to (resume target after taps). */
1051
+ let phaseGroup = config.motions.idle;
1052
+ let tapPlaying = false;
1053
+ const playGroup = (group) => {
1054
+ if (model === void 0) return;
1055
+ const groups = model.internalModel.settings.motions ?? {};
1056
+ const count = Array.isArray(groups[group]) ? groups[group].length : 0;
1057
+ if (count === 0) {
1058
+ if (group !== config.motions.idle) playGroup(config.motions.idle);
1059
+ return;
1060
+ }
1061
+ const index = count > 1 ? Math.floor(Math.random() * count) : 0;
1062
+ model.motion(group, index);
1063
+ };
1064
+ const applyPhase = (phase) => {
1065
+ phaseGroup = config.motions[phase] ?? config.motions.idle;
1066
+ playGroup(phaseGroup);
1067
+ const expression = config.expressions?.[phase];
1068
+ if (expression !== void 0 && model !== void 0) model.expression(expression);
1069
+ };
1070
+ const boot = async () => {
1071
+ if (!await ensureCubismCore()) {
1072
+ if (!disposed) errorListener?.("core-missing");
1073
+ return;
1074
+ }
1075
+ const vendor = await ensureLive2dVendor();
1076
+ if (vendor === void 0) {
1077
+ if (!disposed) errorListener?.("vendor-missing");
1078
+ return;
1079
+ }
1080
+ configureOnce(vendor);
1081
+ const pixiApp = new vendor.Application();
1082
+ await pixiApp.init({
1083
+ width: Math.max(1, ctx.container.clientWidth || 160),
1084
+ height: Math.max(1, ctx.container.clientHeight || 174),
1085
+ backgroundAlpha: 0,
1086
+ antialias: true,
1087
+ autoDensity: true,
1088
+ preference: "webgl"
1089
+ });
1090
+ if (disposed) {
1091
+ pixiApp.destroy(true, { children: true });
1092
+ return;
1093
+ }
1094
+ pixiApp.canvas.style.display = "block";
1095
+ pixiApp.canvas.style.width = "100%";
1096
+ pixiApp.canvas.style.height = "100%";
1097
+ ctx.container.appendChild(pixiApp.canvas);
1098
+ const loaded = await vendor.Live2DModel.from(config.modelUrl, {
1099
+ autoHitTest: false,
1100
+ autoFocus: false,
1101
+ textureOptions: TEXTURE_OPTIONS
1102
+ });
1103
+ if (disposed) {
1104
+ pixiApp.destroy(true, { children: true });
1105
+ return;
1106
+ }
1107
+ app = pixiApp;
1108
+ model = loaded;
1109
+ const fit = Math.min(pixiApp.renderer.width / Math.max(1, loaded.width), pixiApp.renderer.height / Math.max(1, loaded.height)) * .92;
1110
+ loaded.scale.set(fit * (config.scale ?? 1));
1111
+ loaded.anchor.set(.5);
1112
+ loaded.position.set(pixiApp.renderer.width / 2 + (config.translate?.x ?? 0), pixiApp.renderer.height / 2 + (config.translate?.y ?? 0));
1113
+ pixiApp.stage.addChild(loaded);
1114
+ loaded.on("motionFinish", () => {
1115
+ if (tapPlaying) {
1116
+ tapPlaying = false;
1117
+ playGroup(phaseGroup);
1118
+ }
1119
+ });
1120
+ applyPhase(ctx.phase.get());
1121
+ unsubscribe = ctx.phase.subscribe(applyPhase);
1122
+ };
1123
+ boot().catch(() => {
1124
+ if (!disposed) errorListener?.("load-failed");
1125
+ });
1126
+ return {
1127
+ dispose() {
1128
+ if (disposed) return;
1129
+ disposed = true;
1130
+ unsubscribe?.();
1131
+ model = void 0;
1132
+ if (app !== void 0) {
1133
+ app.destroy(true, { children: true });
1134
+ app = void 0;
1135
+ }
1136
+ },
1137
+ tap(x, y) {
1138
+ const current = model;
1139
+ if (disposed || current === void 0) return;
1140
+ const hits = current.hitTest(x, y);
1141
+ const allowed = config.hitAreas;
1142
+ if (!(allowed === void 0 ? hits.length > 0 : hits.some((name) => allowed.includes(name)))) return;
1143
+ const group = (current.internalModel.settings.motions ?? {})[TAP_GROUP];
1144
+ if (!Array.isArray(group) || group.length === 0) return;
1145
+ tapPlaying = true;
1146
+ const index = group.length > 1 ? Math.floor(Math.random() * group.length) : 0;
1147
+ current.motion(TAP_GROUP, index);
1148
+ },
1149
+ onError(listener) {
1150
+ errorListener = listener;
1151
+ }
1152
+ };
1153
+ }
1154
+ };
1155
+ //#endregion
680
1156
  //#region \0dsh-css:packages/dsh-pet/src/client/settings-card.module.css.mjs
681
1157
  const css$1 = ".kKk9aW_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.kKk9aW_card:hover{border-color:var(--dsw-alias-label-dimmed)}.kKk9aW_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.kKk9aW_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.kKk9aW_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.kKk9aW_headerStatic{border-radius:12px;align-items:center;gap:12px;width:100%;padding:14px 16px;display:flex}.kKk9aW_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.kKk9aW_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.kKk9aW_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.kKk9aW_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.kKk9aW_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.kKk9aW_chevronOpen{transform:rotate(180deg)}.kKk9aW_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.kKk9aW_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.kKk9aW_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.kKk9aW_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.kKk9aW_failed{min-width:0;color:var(--dsw-alias-label-error);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.kKk9aW_discard,.kKk9aW_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.kKk9aW_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.kKk9aW_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.kKk9aW_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.kKk9aW_discard:disabled,.kKk9aW_save:disabled{opacity:.4;cursor:default}.kKk9aW_discard:focus-visible,.kKk9aW_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.kKk9aW_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.kKk9aW_field+.kKk9aW_field{border-top:1px solid var(--dsw-alias-border-l2)}.kKk9aW_head{align-items:center;gap:8px;display:flex}.kKk9aW_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.kKk9aW_badges{align-items:center;gap:8px;display:inline-flex}.kKk9aW_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.kKk9aW_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.kKk9aW_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.kKk9aW_reset:disabled{cursor:default}.kKk9aW_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.kKk9aW_input,.kKk9aW_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.kKk9aW_input:focus-visible,.kKk9aW_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.kKk9aW_input:disabled,.kKk9aW_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.kKk9aW_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.kKk9aW_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.kKk9aW_selectWrap{position:relative}.kKk9aW_selectButton{appearance:none;text-align:left;cursor:pointer;justify-content:space-between;align-items:center;gap:8px;width:100%;display:flex}.kKk9aW_selectLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.kKk9aW_selectChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.kKk9aW_selectChevronOpen{transform:rotate(180deg)}.kKk9aW_selectPopup{z-index:40;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);max-height:240px;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-2);opacity:0;border-radius:8px;flex-direction:column;padding:4px;transition:opacity .1s,transform .1s;display:flex;position:absolute;top:calc(100% + 4px);left:0;right:0;overflow-y:auto;transform:translateY(-4px)}.kKk9aW_selectPopupOpen{opacity:1;transform:none}.kKk9aW_selectPopupClose{opacity:0;pointer-events:none;transform:translateY(-4px)}.kKk9aW_selectOption{color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;padding:6px 10px;font-size:13px;line-height:1.5;overflow:hidden}.kKk9aW_selectOption:hover,.kKk9aW_selectOptionActive{background:var(--dsw-alias-interactive-bg-hover)}.kKk9aW_selectOptionSelected{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);font-weight:500}.kKk9aW_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.kKk9aW_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.kKk9aW_card,.kKk9aW_header,.kKk9aW_chevron,.kKk9aW_chevronOpen,.kKk9aW_discard,.kKk9aW_save,.kKk9aW_selectChevron,.kKk9aW_selectChevronOpen,.kKk9aW_selectPopup{transition:none}}";
682
1158
  const tagId$1 = "@linxin666/dsh-pet/settings-card.module.css";
@@ -1344,7 +1820,8 @@ window.__ModuleLoader__.load({
1344
1820
  const valid = plan.filter((item) => item.run !== void 0);
1345
1821
  if (plan.length === 0 || this.saving || valid.length !== plan.length) return;
1346
1822
  const plannedWrites = valid.map((item) => item.op);
1347
- const fields = new Set(plan.map((item) => item.field));
1823
+ const pending = /* @__PURE__ */ new Map();
1824
+ for (const item of plan) pending.set(item.field, this.staged.get(item.field));
1348
1825
  this.saving = true;
1349
1826
  this.failed = false;
1350
1827
  this.failedReason = void 0;
@@ -1357,9 +1834,9 @@ window.__ModuleLoader__.load({
1357
1834
  for (const field of result.fields) if (field.landed) landed.add(field.field);
1358
1835
  } else this.failedReason = result.message;
1359
1836
  } else for (const item of valid) if (await item.run()) landed.add(item.field);
1360
- for (const field of fields) if (landed.has(field)) this.staged.delete(field);
1837
+ for (const [field, before] of pending) if (landed.has(field) && this.staged.get(field) === before) this.staged.delete(field);
1361
1838
  this.saving = false;
1362
- this.failed = landed.size !== fields.size;
1839
+ this.failed = landed.size !== pending.size;
1363
1840
  this.publish();
1364
1841
  }
1365
1842
  /** The scope's batch surface when it supports one; undefined conservatively otherwise. */
@@ -1461,7 +1938,7 @@ window.__ModuleLoader__.load({
1461
1938
  };
1462
1939
  //#endregion
1463
1940
  //#region \0dsh-css:packages/dsh-pet/src/client/settings-section.module.css.mjs
1464
- const css = ".t0P0pa_sectionList{margin:0;padding:0;list-style:none}";
1941
+ const css = ".t0P0pa_sectionList{margin:0;padding:0;list-style:none}.t0P0pa_diagnostics{border:1px dashed var(--dsw-alias-border-l2);color:var(--dsw-alias-label-dimmed);border-radius:8px;margin:8px 0;padding:8px 12px;font-size:12px}.t0P0pa_diagnosticsTitle{color:var(--dsw-alias-label);margin-bottom:4px;font-weight:600;display:block}.t0P0pa_diagnostics ul{margin:0;padding-left:16px}.t0P0pa_diagnostics li[data-level=error]{color:var(--dsw-alias-error,#c04848)}";
1465
1942
  const tagId = "@linxin666/dsh-pet/settings-section.module.css";
1466
1943
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
1467
1944
  const tag = document.createElement("style");
@@ -1470,7 +1947,11 @@ window.__ModuleLoader__.load({
1470
1947
  tag.textContent = css;
1471
1948
  document.head.appendChild(tag);
1472
1949
  }
1473
- var settings_section_module_css_default = { "sectionList": "t0P0pa_sectionList" };
1950
+ var settings_section_module_css_default = {
1951
+ "diagnostics": "t0P0pa_diagnostics",
1952
+ "diagnosticsTitle": "t0P0pa_diagnosticsTitle",
1953
+ "sectionList": "t0P0pa_sectionList"
1954
+ };
1474
1955
  //#endregion
1475
1956
  //#region src/client/PetSettingsCard.tsx
1476
1957
  /** Fetch the registry list (the same data the sprite renders from). */
@@ -1479,18 +1960,26 @@ window.__ModuleLoader__.load({
1479
1960
  if (!response.ok) throw new Error("pet pets failed: " + response.status);
1480
1961
  return await response.json();
1481
1962
  }
1963
+ /** Fetch the registry diagnostics (v1 migration hints, invalid entries). */
1964
+ async function fetchPetDiagnostics() {
1965
+ const response = await fetch("/api/pet/diagnostics");
1966
+ if (!response.ok) throw new Error("pet diagnostics failed: " + response.status);
1967
+ return (await response.json()).diagnostics ?? [];
1968
+ }
1482
1969
  /** Bridges the 'pet' scope onto the card's staged form. */
1483
1970
  var PetSettingsCardController = class {
1484
1971
  form;
1485
1972
  store;
1486
1973
  petChoices = [];
1487
1974
  petLabels = /* @__PURE__ */ new Map();
1975
+ diagnostics = [];
1488
1976
  loaded = false;
1489
1977
  attempts = 0;
1490
1978
  /** @param scope - the bound settings scope for the 'pet' namespace. */
1491
1979
  constructor(scope) {
1492
1980
  this.form = new CardForm(scope, [
1493
1981
  booleanField("enabled"),
1982
+ booleanField("decorationEnabled"),
1494
1983
  booleanField("visible"),
1495
1984
  numberField("size"),
1496
1985
  numberField("right"),
@@ -1499,6 +1988,16 @@ window.__ModuleLoader__.load({
1499
1988
  ]);
1500
1989
  this.store = this.form.bind(() => this.projection());
1501
1990
  this.loadPets();
1991
+ this.loadDiagnostics();
1992
+ }
1993
+ /** Fetch registry diagnostics once (soft-fail: an empty list on error). */
1994
+ async loadDiagnostics() {
1995
+ try {
1996
+ this.diagnostics = await fetchPetDiagnostics();
1997
+ this.store.set(this.projection());
1998
+ } catch {
1999
+ this.diagnostics = [];
2000
+ }
1502
2001
  }
1503
2002
  /** Resolve the registry choices once (retried a few times on failure). */
1504
2003
  async loadPets() {
@@ -1520,6 +2019,7 @@ window.__ModuleLoader__.load({
1520
2019
  return {
1521
2020
  ...this.form.shell(),
1522
2021
  enabled: this.form.field("enabled"),
2022
+ decorationEnabled: this.form.field("decorationEnabled"),
1523
2023
  visible: this.form.field("visible"),
1524
2024
  size: this.form.field("size"),
1525
2025
  right: this.form.field("right"),
@@ -1528,7 +2028,8 @@ window.__ModuleLoader__.load({
1528
2028
  petChoices: this.petChoices.map((id) => ({
1529
2029
  value: id,
1530
2030
  label: this.petLabels.get(id) ?? id
1531
- }))
2031
+ })),
2032
+ petDiagnostics: this.diagnostics
1532
2033
  };
1533
2034
  }
1534
2035
  /**
@@ -1589,6 +2090,22 @@ window.__ModuleLoader__.load({
1589
2090
  props.resetField("enabled");
1590
2091
  }
1591
2092
  }),
2093
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BooleanField, {
2094
+ id: "settings-pet-decoration",
2095
+ label: t("settings.decoration"),
2096
+ hint: t("settings.decorationHint"),
2097
+ inheritLabel: t("settings.inherit"),
2098
+ onLabel: t("settings.on"),
2099
+ offLabel: t("settings.off"),
2100
+ ...fieldProps,
2101
+ ...state.decorationEnabled,
2102
+ onEdit: (text) => {
2103
+ props.edit("decorationEnabled", text);
2104
+ },
2105
+ onReset: () => {
2106
+ props.resetField("decorationEnabled");
2107
+ }
2108
+ }),
1592
2109
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChoiceField, {
1593
2110
  id: "settings-pet-pet",
1594
2111
  label: t("settings.pet"),
@@ -1604,6 +2121,17 @@ window.__ModuleLoader__.load({
1604
2121
  props.resetField("petId");
1605
2122
  }
1606
2123
  }),
2124
+ state.petDiagnostics.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
2125
+ className: settings_section_module_css_default.diagnostics,
2126
+ "data-dsh-part": "diagnostics",
2127
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2128
+ className: settings_section_module_css_default.diagnosticsTitle,
2129
+ children: t("settings.diagnosticsTitle")
2130
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", { children: state.petDiagnostics.map((diagnostic, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
2131
+ "data-level": diagnostic.level,
2132
+ children: diagnostic.message
2133
+ }, index)) })]
2134
+ }),
1607
2135
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BooleanField, {
1608
2136
  id: "settings-pet-visible",
1609
2137
  label: t("settings.visible"),
@@ -1695,15 +2223,22 @@ window.__ModuleLoader__.load({
1695
2223
  "pet.treats": "小鱼干 ×{n}",
1696
2224
  "pet.state.loading": "宠物正在赶来…",
1697
2225
  "pet.state.error": "宠物迷路了(连接失败)",
2226
+ "pet.renderer.unavailable": "这只宠物需要的渲染器({renderer})在当前版本不可用。",
2227
+ "pet.live2d.core-missing": "Live2D 核心未安装:请把官方 live2dcubismcore.min.js 放入 $DSH_HOME/pets/.runtime/ 后刷新(步骤见宠物插件 README)。",
2228
+ "pet.live2d.vendor-missing": "Live2D 组件缺失,请升级宠物插件。",
2229
+ "pet.live2d.load-failed": "Live2D 模型加载失败,请检查该宠物目录的完整性。",
1698
2230
  "pet.openSessionHint": "点击跳转到对应会话",
1699
2231
  "pet.moreSessions": "展开其余 {n} 个会话的气泡",
1700
2232
  "pet.collapseSessions": "收起会话气泡",
1701
2233
  "settings.title": "宠物",
2234
+ "settings.diagnosticsTitle": "宠物目录诊断",
1702
2235
  "settings.description": "选择宠物并调整它的显示布局。",
1703
2236
  "settings.pet": "宠物",
1704
2237
  "settings.petHint": "选择显示哪只宠物;每只宠物独立命名,可在宠物悬浮面板改名。",
1705
2238
  "settings.enabled": "启用宠物",
1706
2239
  "settings.enabledHint": "关闭后隐藏宠物并停止轮询,可在设置里重新启用。",
2240
+ "settings.decoration": "状态装饰",
2241
+ "settings.decorationHint": "在宠物状态气泡里显示喷水鲸鱼等状态装饰;关闭后气泡只剩文字。",
1707
2242
  "settings.visible": "显示宠物",
1708
2243
  "settings.visibleHint": "关闭后宠物隐藏,可从聊天输入区重新召唤。",
1709
2244
  "settings.size": "大小(px)",
@@ -1741,15 +2276,22 @@ window.__ModuleLoader__.load({
1741
2276
  "pet.treats": "Treats ×{n}",
1742
2277
  "pet.state.loading": "The pet is on its way…",
1743
2278
  "pet.state.error": "The pet is lost (connection failed)",
2279
+ "pet.renderer.unavailable": "This pet needs a renderer ({renderer}) that is not available in this build.",
2280
+ "pet.live2d.core-missing": "Live2D Cubism Core is not installed: place the official live2dcubismcore.min.js under $DSH_HOME/pets/.runtime/ and refresh (see the pet plugin README).",
2281
+ "pet.live2d.vendor-missing": "The Live2D component is missing; please update the pet plugin.",
2282
+ "pet.live2d.load-failed": "The Live2D model failed to load; check the pet directory is complete.",
1744
2283
  "pet.openSessionHint": "Click to jump to this session",
1745
2284
  "pet.moreSessions": "Expand {n} more session bubbles",
1746
2285
  "pet.collapseSessions": "Collapse session bubbles",
1747
2286
  "settings.title": "Pet",
2287
+ "settings.diagnosticsTitle": "Pet directory diagnostics",
1748
2288
  "settings.description": "Pick a pet and tune its display layout.",
1749
2289
  "settings.pet": "Pet",
1750
2290
  "settings.petHint": "Choose which pet shows. Names are stored per pet; rename from the pet hover panel.",
1751
2291
  "settings.enabled": "Enable the pet",
1752
2292
  "settings.enabledHint": "When off, the pet hides and polling stops; re-enable it here.",
2293
+ "settings.decoration": "Status decoration",
2294
+ "settings.decorationHint": "Show ornaments like the spouting whale inside the pet status bubbles; when off, bubbles stay text-only.",
1753
2295
  "settings.visible": "Show the pet",
1754
2296
  "settings.visibleHint": "When off, the pet hides; summon it again from the input row.",
1755
2297
  "settings.size": "Size (px)",
@@ -1842,6 +2384,7 @@ window.__ModuleLoader__.load({
1842
2384
  zh,
1843
2385
  en
1844
2386
  }), "pet: dictionaries");
2387
+ defaultPetRendererRegistry.register(live2dRenderer);
1845
2388
  const settingsScope = (ctx.get("webUiSettings") ?? ctx.settingsScope).bind({ namespace: PET_SETTINGS_NS });
1846
2389
  const enabled = () => {
1847
2390
  const snapshot = settingsScope.getSnapshot();