@linxin666/dsh-pet 0.1.14 → 0.1.16

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 (90) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +73 -38
  3. package/README.zh.md +91 -55
  4. package/assets/whale/pet.json +12 -1
  5. package/cordis.patch.yml +4 -4
  6. package/lib/client.js +296 -347
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +975 -317
  9. package/lib/invariant.js +1 -1
  10. package/lib/{state-P-wsVJ6O.js → state-CFyJv0sQ.js} +15 -1
  11. package/lib/types/affinity.d.ts +15 -0
  12. package/lib/types/affinity.d.ts.map +1 -1
  13. package/lib/types/affinity.js +14 -0
  14. package/lib/types/client/PetDockEntry.d.ts +14 -12
  15. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  16. package/lib/types/client/PetDockEntry.js +13 -8
  17. package/lib/types/client/PetSettingsCard.d.ts +22 -9
  18. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/PetSettingsCard.js +42 -7
  20. package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
  21. package/lib/types/client/PetSprite.d.ts.map +1 -0
  22. package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
  23. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  24. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  25. package/lib/types/client/PluginSettingsCard.js +30 -8
  26. package/lib/types/client/index.d.ts +11 -8
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/index.js +32 -14
  29. package/lib/types/client/locales.d.ts +10 -10
  30. package/lib/types/client/locales.js +10 -10
  31. package/lib/types/client/pet-store.d.ts +5 -0
  32. package/lib/types/client/pet-store.d.ts.map +1 -1
  33. package/lib/types/client/pet-store.js +4 -0
  34. package/lib/types/client/settings-form.d.ts +76 -5
  35. package/lib/types/client/settings-form.d.ts.map +1 -1
  36. package/lib/types/client/settings-form.js +97 -20
  37. package/lib/types/client/spritesheet.d.ts +14 -48
  38. package/lib/types/client/spritesheet.d.ts.map +1 -1
  39. package/lib/types/client/spritesheet.js +13 -81
  40. package/lib/types/dsh-home.d.ts +17 -0
  41. package/lib/types/dsh-home.d.ts.map +1 -0
  42. package/lib/types/dsh-home.js +34 -0
  43. package/lib/types/event-projection.d.ts +36 -0
  44. package/lib/types/event-projection.d.ts.map +1 -0
  45. package/lib/types/event-projection.js +98 -0
  46. package/lib/types/index.d.ts +24 -11
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +53 -33
  49. package/lib/types/ledger.d.ts +79 -0
  50. package/lib/types/ledger.d.ts.map +1 -0
  51. package/lib/types/ledger.js +146 -0
  52. package/lib/types/persist.d.ts +15 -4
  53. package/lib/types/persist.d.ts.map +1 -1
  54. package/lib/types/persist.js +39 -8
  55. package/lib/types/registry.d.ts +158 -0
  56. package/lib/types/registry.d.ts.map +1 -0
  57. package/lib/types/registry.js +276 -0
  58. package/lib/types/routes.d.ts +10 -10
  59. package/lib/types/routes.d.ts.map +1 -1
  60. package/lib/types/routes.js +169 -51
  61. package/lib/types/service.d.ts +59 -57
  62. package/lib/types/service.d.ts.map +1 -1
  63. package/lib/types/service.js +111 -211
  64. package/package.json +2 -2
  65. package/src/affinity.ts +33 -0
  66. package/src/client/PetDockEntry.tsx +34 -26
  67. package/src/client/PetSettingsCard.tsx +67 -22
  68. package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
  69. package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
  70. package/src/client/PluginSettingsCard.tsx +90 -18
  71. package/src/client/index.ts +38 -16
  72. package/src/client/locales.ts +10 -10
  73. package/src/client/pet-store.ts +9 -0
  74. package/src/client/settings-card.module.css +9 -0
  75. package/src/client/settings-form.ts +153 -19
  76. package/src/client/spritesheet.ts +16 -92
  77. package/src/dsh-home.test.ts +35 -0
  78. package/src/dsh-home.ts +36 -0
  79. package/src/event-projection.ts +128 -0
  80. package/src/index.test.ts +23 -0
  81. package/src/index.ts +89 -45
  82. package/src/ledger.test.ts +69 -0
  83. package/src/ledger.ts +190 -0
  84. package/src/persist.test.ts +52 -3
  85. package/src/persist.ts +49 -11
  86. package/src/registry.test.ts +132 -0
  87. package/src/registry.ts +400 -0
  88. package/src/routes.ts +166 -53
  89. package/src/service.ts +153 -285
  90. package/lib/types/client/WhalePet.d.ts.map +0 -1
package/lib/client.js CHANGED
@@ -22,6 +22,7 @@ window.__ModuleLoader__.load({
22
22
  return (0, _deepseek_ai_dsh_client_runtime_client.defineStore)({
23
23
  init: () => ({
24
24
  snapshot: null,
25
+ pets: [],
25
26
  state: "loading",
26
27
  error: null,
27
28
  feedback: null
@@ -32,6 +33,9 @@ window.__ModuleLoader__.load({
32
33
  draft.state = "ready";
33
34
  draft.error = null;
34
35
  },
36
+ setPets: (draft, pets) => {
37
+ draft.pets = pets;
38
+ },
35
39
  setState: (draft, state, error) => {
36
40
  draft.state = state;
37
41
  draft.error = error;
@@ -42,194 +46,24 @@ window.__ModuleLoader__.load({
42
46
  }
43
47
  });
44
48
  }
45
- /**
46
- * Track definitions for the whale-girl. Durations are tuned for a soft,
47
- * slow-healing feel (roughly 2.5× the earlier fast draft — the pet should
48
- * breathe, not race); calibrate frame counts against the hatch-pet run when
49
- * the asset lands (rows may carry 4–8 frames).
50
- */
51
- const TRACKS = {
52
- idle: {
53
- frames: [
54
- 0,
55
- 1,
56
- 2,
57
- 3,
58
- 4,
59
- 5
60
- ],
61
- durations: [
62
- 400,
63
- 400,
64
- 500,
65
- 400,
66
- 400,
67
- 500
68
- ],
69
- loop: true
70
- },
71
- "running-right": {
72
- frames: [
73
- 0,
74
- 1,
75
- 2,
76
- 3,
77
- 4,
78
- 5,
79
- 6,
80
- 7
81
- ],
82
- durations: [
83
- 225,
84
- 225,
85
- 225,
86
- 225,
87
- 225,
88
- 225,
89
- 225,
90
- 225
91
- ],
92
- loop: true
93
- },
94
- "running-left": {
95
- frames: [
96
- 0,
97
- 1,
98
- 2,
99
- 3,
100
- 4,
101
- 5,
102
- 6,
103
- 7
104
- ],
105
- durations: [
106
- 225,
107
- 225,
108
- 225,
109
- 225,
110
- 225,
111
- 225,
112
- 225,
113
- 225
114
- ],
115
- loop: true
116
- },
117
- waving: {
118
- frames: [
119
- 0,
120
- 1,
121
- 2,
122
- 3
123
- ],
124
- durations: [
125
- 350,
126
- 350,
127
- 350,
128
- 350
129
- ],
130
- loop: true
131
- },
132
- jumping: {
133
- frames: [
134
- 0,
135
- 1,
136
- 2,
137
- 3,
138
- 4
139
- ],
140
- durations: [
141
- 300,
142
- 300,
143
- 300,
144
- 350,
145
- 350
146
- ],
147
- loop: false,
148
- fallback: "idle"
149
- },
150
- failed: {
151
- frames: [
152
- 0,
153
- 1,
154
- 2,
155
- 3,
156
- 4,
157
- 5,
158
- 6,
159
- 7
160
- ],
161
- durations: [
162
- 450,
163
- 450,
164
- 450,
165
- 500,
166
- 550,
167
- 600,
168
- 450,
169
- 450
170
- ],
171
- loop: false,
172
- fallback: "idle"
173
- },
174
- waiting: {
175
- frames: [
176
- 0,
177
- 1,
178
- 2,
179
- 3,
180
- 4,
181
- 5
182
- ],
183
- durations: [
184
- 450,
185
- 450,
186
- 500,
187
- 450,
188
- 450,
189
- 500
190
- ],
191
- loop: true
192
- },
193
- running: {
194
- frames: [
195
- 0,
196
- 1,
197
- 2,
198
- 3,
199
- 4,
200
- 5
201
- ],
202
- durations: [
203
- 250,
204
- 250,
205
- 250,
206
- 250,
207
- 250,
208
- 250
209
- ],
210
- loop: true
211
- },
212
- review: {
213
- frames: [
214
- 0,
215
- 1,
216
- 2,
217
- 3,
218
- 4,
219
- 5
220
- ],
221
- durations: [
222
- 550,
223
- 550,
224
- 550,
225
- 550,
226
- 550,
227
- 550
228
- ],
229
- loop: true
230
- }
231
- };
232
- /** Row index of one animation track (mirrors state.ts rowOf). */
49
+ //#endregion
50
+ //#region ../../node_modules/.pnpm/clsx@2.1.1/node_modules/clsx/dist/clsx.mjs
51
+ function r(e) {
52
+ var t, f, n = "";
53
+ if ("string" == typeof e || "number" == typeof e) n += e;
54
+ else if ("object" == typeof e) if (Array.isArray(e)) {
55
+ var o = e.length;
56
+ for (t = 0; t < o; t++) e[t] && (f = r(e[t])) && (n && (n += " "), n += f);
57
+ } else for (f in e) e[f] && (n && (n += " "), n += f);
58
+ return n;
59
+ }
60
+ function clsx() {
61
+ for (var e, t, f = 0, n = "", o = arguments.length; f < o; f++) (e = arguments[f]) && (t = r(e)) && (n && (n += " "), n += t);
62
+ return n;
63
+ }
64
+ //#endregion
65
+ //#region src/client/spritesheet.ts
66
+ /** Row index of one animation track (the fixed 9-row contract). */
233
67
  function rowOfTrack(animation) {
234
68
  return {
235
69
  idle: 0,
@@ -250,52 +84,20 @@ window.__ModuleLoader__.load({
250
84
  * atlas coordinates here would drift each frame by the scale factor and
251
85
  * render torn/overlapping frames.
252
86
  */
253
- function framePosition(row, col, scale = 1) {
87
+ function framePosition(cell, columns, row, col, scale = 1) {
254
88
  return {
255
- x: -col * 192 * scale,
256
- y: -row * 208 * scale
89
+ x: -col * cell.width * scale,
90
+ y: -row * cell.height * scale
257
91
  };
258
92
  }
259
93
  /**
260
- * Detect how many frames each row actually carries by scanning the decoded
261
- * atlas for non-transparent cells (hatch-pet rows may hold 4–8 frames; the
262
- * unused trailing cells are fully transparent). Rows whose every sample is
263
- * transparent report 0.
264
- * @param image - the fully decoded spritesheet (natural size 1536×1872).
265
- * @returns per-row frame counts, length 9.
266
- */
267
- function detectFrameCounts(image) {
268
- const canvas = document.createElement("canvas");
269
- canvas.width = image.naturalWidth;
270
- canvas.height = image.naturalHeight;
271
- const ctx = canvas.getContext("2d");
272
- if (ctx === null) return Array.from({ length: 9 }, () => 8);
273
- ctx.drawImage(image, 0, 0);
274
- const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
275
- const counts = [];
276
- const stride = 1536;
277
- const probeStep = 8;
278
- const margin = 12;
279
- for (let row = 0; row < 9; row++) {
280
- let count = 0;
281
- for (let col = 0; col < 8; col++) {
282
- let hasContent = false;
283
- const x0 = col * 192;
284
- const y0 = row * 208;
285
- for (let y = y0 + margin; y < y0 + 208 - margin && !hasContent; y += probeStep) for (let x = x0 + margin; x < x0 + 192 - margin && !hasContent; x += probeStep) if ((data[(y * stride + x) * 4 + 3] ?? 0) > 8) hasContent = true;
286
- if (hasContent) count += 1;
287
- }
288
- counts.push(count);
289
- }
290
- return counts;
291
- }
292
- /**
293
- * Trim a track to the actual frame count of its row. A row with 0 detected
294
- * frames degrades to the first frame (the atlas is still loading or corrupt)
295
- * so the pet never renders blank.
94
+ * Trim a track to the actual frame count of its row (the manifest's per-row
95
+ * counts are authoritative; this is a last-line guard against a definition
96
+ * whose row count disagrees with its track table). A row with 0 detected
97
+ * frames degrades to the first frame so the pet never renders blank.
296
98
  */
297
99
  function trimTrack(track, frameCount) {
298
- const n = Math.max(1, Math.min(frameCount, track.frames.length));
100
+ const n = Math.max(1, Math.min(frameCount, track.frames.length, track.durations.length));
299
101
  return {
300
102
  frames: track.frames.slice(0, n),
301
103
  durations: track.durations.slice(0, n),
@@ -332,35 +134,32 @@ window.__ModuleLoader__.load({
332
134
  "summon": "kz2Bea_summon"
333
135
  };
334
136
  //#endregion
335
- //#region src/client/WhalePet.tsx
137
+ //#region src/client/PetSprite.tsx
336
138
  /**
337
- * Whale-girl companion component — the browser half's centerpiece. Renders a
139
+ * Pet sprite companion component — the browser half's centerpiece. Renders a
338
140
  * fixed-position floating sprite (React portal onto document.body), plays
339
- * the spritesheet track matching the host animation snapshot, and exposes
340
- * the interaction surface: click to pet, hover panel with feed/hide, drag to
341
- * reposition (persisted via setConfig).
342
- * @module @linxin666/dsh-pet/client/WhalePet
141
+ * the track matching the host animation snapshot, and exposes the
142
+ * interaction surface: click to pet, hover panel with feed/rename/hide, drag
143
+ * to reposition (persisted via setConfig). Everything visual comes from the
144
+ * pet definition the host serves ('/api/pet/pets' + the state snapshot's
145
+ * pet id), so one component renders every registry entry.
146
+ * @module @linxin666/dsh-pet/client/PetSprite
343
147
  */
344
- /** Browser URL of the whale-girl atlas (served by the host half's own route). */
345
- const PET_SPRITESHEET_URL = "/pet/whale/spritesheet.webp";
346
- /** Browser URL of the whale-girl manifest (authoritative per-row frame counts). */
347
- const PET_MANIFEST_URL = "/pet/whale/pet.json";
348
148
  /** Clamp a drag offset inside the viewport with a margin. */
349
149
  function clampOffset(value, max) {
350
150
  return Math.max(0, Math.min(max, value));
351
151
  }
352
152
  /**
353
153
  * The floating pet. The spritesheet frame advances on requestAnimationFrame
354
- * with per-frame durations from TRACKS; the atlas image is loaded once and
355
- * the background position is written straight to the sprite element (no
356
- * per-frame React state).
154
+ * with per-frame durations from the definition's tracks; the atlas image is
155
+ * loaded once and the background position is written straight to the sprite
156
+ * element (no per-frame React state).
357
157
  */
358
- function WhalePet(props) {
359
- const { snapshot, display, feedback } = props;
158
+ function PetSprite(props) {
159
+ const { snapshot, definition, display, feedback } = props;
360
160
  const spriteRef = (0, react.useRef)(null);
361
161
  const floatRef = (0, react.useRef)(null);
362
162
  const [imageReady, setImageReady] = (0, react.useState)(false);
363
- const [frameCounts, setFrameCounts] = (0, react.useState)(null);
364
163
  const [hovered, setHovered] = (0, react.useState)(false);
365
164
  const [renaming, setRenaming] = (0, react.useState)(false);
366
165
  const [nameDraft, setNameDraft] = (0, react.useState)("");
@@ -372,37 +171,32 @@ window.__ModuleLoader__.load({
372
171
  index: 0,
373
172
  elapsed: 0
374
173
  });
174
+ const cell = definition.cell;
175
+ const columns = definition.columns;
176
+ const rows = definition.rows;
177
+ const tracks = definition.tracks;
375
178
  (0, react.useEffect)(() => {
376
179
  let cancelled = false;
377
180
  const img = new Image();
378
181
  img.onload = () => {
379
- if (cancelled) return;
380
- setImageReady(true);
381
- fetch(PET_MANIFEST_URL).then((res) => res.ok ? res.json() : Promise.resolve({})).then((manifest) => {
382
- if (cancelled) return;
383
- const frames = manifest.frames;
384
- if (Array.isArray(frames) && frames.length === 9 && frames.every((n) => typeof n === "number")) setFrameCounts(frames);
385
- else setFrameCounts(detectFrameCounts(img));
386
- }).catch(() => {
387
- if (!cancelled) setFrameCounts(detectFrameCounts(img));
388
- });
182
+ if (!cancelled) setImageReady(true);
389
183
  };
390
- img.src = PET_SPRITESHEET_URL;
184
+ img.src = definition.atlasUrl;
391
185
  return () => {
392
186
  cancelled = true;
393
187
  img.onload = null;
394
188
  };
395
- }, []);
396
- const spriteScale = display.size / 208;
189
+ }, [definition.atlasUrl]);
190
+ const spriteScale = display.size / cell.height;
397
191
  const animation = snapshot?.animation ?? "idle";
398
192
  const scaleRef = (0, react.useRef)(spriteScale);
399
193
  scaleRef.current = spriteScale;
400
194
  (0, react.useEffect)(() => {
401
195
  const reduceMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true;
402
196
  const row = rowOfTrack(animation);
403
- const leadCol = (frameCounts === null ? TRACKS[animation] : trimTrack(TRACKS[animation], frameCounts[row] ?? TRACKS[animation].frames.length)).frames[0];
404
- const lead = framePosition(row, leadCol, scaleRef.current);
405
- if (spriteRef.current !== null) spriteRef.current.style.backgroundPosition = `${lead.x}px ${lead.y}px`;
197
+ const leadCol = trimTrack(tracks[animation], rows[row] ?? tracks[animation].frames.length).frames[0];
198
+ const lead = framePosition(cell, columns, row, leadCol, scaleRef.current);
199
+ if (spriteRef.current !== null) spriteRef.current.style.backgroundPosition = lead.x + "px " + lead.y + "px";
406
200
  if (reduceMotion) return;
407
201
  let raf = 0;
408
202
  let last = performance.now();
@@ -410,7 +204,7 @@ window.__ModuleLoader__.load({
410
204
  const delta = ts - last;
411
205
  last = ts;
412
206
  const row = rowOfTrack(animation);
413
- const track = frameCounts === null ? TRACKS[animation] : trimTrack(TRACKS[animation], frameCounts[row] ?? TRACKS[animation].frames.length);
207
+ const track = trimTrack(tracks[animation], rows[row] ?? tracks[animation].frames.length);
414
208
  const st = frameRef.current;
415
209
  if (st.track !== animation) {
416
210
  st.track = animation;
@@ -428,13 +222,19 @@ window.__ModuleLoader__.load({
428
222
  st.index = 0;
429
223
  } else st.index = maxIndex;
430
224
  const col = track.frames[st.index];
431
- const { x, y } = framePosition(row, col, scaleRef.current);
432
- if (spriteRef.current !== null) spriteRef.current.style.backgroundPosition = `${x}px ${y}px`;
225
+ const pos = framePosition(cell, columns, row, col, scaleRef.current);
226
+ if (spriteRef.current !== null) spriteRef.current.style.backgroundPosition = pos.x + "px " + pos.y + "px";
433
227
  raf = requestAnimationFrame(tick);
434
228
  };
435
229
  raf = requestAnimationFrame(tick);
436
230
  return () => cancelAnimationFrame(raf);
437
- }, [animation, frameCounts]);
231
+ }, [
232
+ animation,
233
+ cell,
234
+ columns,
235
+ rows,
236
+ tracks
237
+ ]);
438
238
  const feedbackDoneRef = (0, react.useRef)(props.onFeedbackDone);
439
239
  feedbackDoneRef.current = props.onFeedbackDone;
440
240
  (0, react.useEffect)(() => {
@@ -486,9 +286,10 @@ window.__ModuleLoader__.load({
486
286
  right: display.right,
487
287
  bottom: display.bottom
488
288
  };
489
- const spriteWidth = Math.round(192 * spriteScale);
490
- const spriteHeight = Math.round(208 * spriteScale);
289
+ const spriteWidth = Math.round(cell.width * spriteScale);
290
+ const spriteHeight = Math.round(cell.height * spriteScale);
491
291
  const statusBubble = feedback === null && !hovered ? snapshot?.bubble : void 0;
292
+ const displayName = snapshot?.name ?? definition.displayName;
492
293
  return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
493
294
  ref: floatRef,
494
295
  className: pet_module_css_default.float,
@@ -514,8 +315,8 @@ window.__ModuleLoader__.load({
514
315
  style: {
515
316
  width: spriteWidth,
516
317
  height: spriteHeight,
517
- backgroundImage: imageReady ? `url(${PET_SPRITESHEET_URL})` : void 0,
518
- backgroundSize: `${1536 * spriteScale}px ${1872 * spriteScale}px`,
318
+ backgroundImage: imageReady ? "url(" + definition.atlasUrl + ")" : void 0,
319
+ backgroundSize: cell.width * columns * spriteScale + "px " + cell.height * rows.length * spriteScale + "px",
519
320
  backgroundRepeat: "no-repeat",
520
321
  backgroundPosition: "0 0",
521
322
  cursor: dragRef.current === null ? "grab" : "grabbing"
@@ -528,14 +329,14 @@ window.__ModuleLoader__.load({
528
329
  props.onPet();
529
330
  },
530
331
  role: "button",
531
- "aria-label": "whale girl"
332
+ "aria-label": definition.displayName
532
333
  }),
533
334
  feedback !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
534
- className: `${pet_module_css_default.bubble} ${feedback.kind === "feed" ? pet_module_css_default.bubbleFeed : pet_module_css_default.bubblePet}`,
335
+ className: clsx(pet_module_css_default.bubble, feedback.kind === "feed" ? pet_module_css_default.bubbleFeed : pet_module_css_default.bubblePet),
535
336
  children: feedback.text
536
337
  }, feedback.at),
537
338
  statusBubble !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
538
- className: `${pet_module_css_default.bubble} ${pet_module_css_default.bubbleStatus}`,
339
+ className: clsx(pet_module_css_default.bubble, pet_module_css_default.bubbleStatus),
539
340
  role: "status",
540
341
  "aria-live": "polite",
541
342
  children: statusBubble
@@ -581,7 +382,7 @@ window.__ModuleLoader__.load({
581
382
  className: pet_module_css_default.rankRow,
582
383
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
583
384
  className: pet_module_css_default.nameCell,
584
- children: snapshot?.name ?? "鲸鱼娘"
385
+ children: displayName
585
386
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: props.t("pet.rank", { rank: snapshot?.affinity.rank ?? "?" }) })]
586
387
  }),
587
388
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -601,7 +402,7 @@ window.__ModuleLoader__.load({
601
402
  type: "button",
602
403
  className: pet_module_css_default.action,
603
404
  onClick: () => {
604
- setNameDraft(snapshot?.name ?? "");
405
+ setNameDraft(displayName);
605
406
  setRenaming(true);
606
407
  },
607
408
  children: props.t("pet.rename")
@@ -623,12 +424,14 @@ window.__ModuleLoader__.load({
623
424
  //#region src/client/PetDockEntry.tsx
624
425
  /**
625
426
  * Global floating pet entry. The pet is host-global (its state, display and
626
- * interactions live on `/api/pet/*` endpoints with no session dimension), so
427
+ * interactions live on '/api/pet/*' endpoints with no session dimension), so
627
428
  * it must not ride a session-scoped slot — on the new-conversation screen no
628
429
  * session exists to scope a slot by, and the pet would vanish (issue #48).
629
- * The client half therefore mounts this entry straight onto `document.body`
630
- * (see index.ts): while visible it renders the floating WhalePet (a portal),
631
- * while hidden it renders a fixed-position summon button.
430
+ * The client half therefore mounts this entry straight onto 'document.body'
431
+ * (see index.ts): while visible it renders the floating PetSprite (a
432
+ * portal), while hidden it renders a fixed-position summon button. Which
433
+ * sprite renders is decided by the host snapshot's pet id resolved against
434
+ * the registry list — no per-pet component exists.
632
435
  * @module @linxin666/dsh-pet/client/PetDockEntry
633
436
  */
634
437
  const DEFAULT_DISPLAY = {
@@ -638,7 +441,7 @@ window.__ModuleLoader__.load({
638
441
  bottom: 20
639
442
  };
640
443
  /**
641
- * Dock entry: while the pet is visible, mount the floating WhalePet (it
444
+ * Dock entry: while the pet is visible, mount the floating PetSprite (it
642
445
  * portals itself onto document.body); while hidden, render the summon
643
446
  * button so the pet can always come back. The store is the plugin-owned
644
447
  * single instance — the slot system provides none because the pet is
@@ -649,6 +452,7 @@ window.__ModuleLoader__.load({
649
452
  const ui = (0, react.useSyncExternalStore)(store.subscribe, store.getSnapshot);
650
453
  const snapshot = ui.snapshot;
651
454
  const feedback = ui.feedback;
455
+ const definition = ui.pets.find((entry) => entry.id === snapshot?.pet.id) ?? null;
652
456
  const visible = snapshot?.display.visible ?? true;
653
457
  (0, react.useEffect)(() => {
654
458
  ensure();
@@ -656,9 +460,10 @@ window.__ModuleLoader__.load({
656
460
  if (visible) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
657
461
  "data-pet-dock": true,
658
462
  "data-testid": "pet-dock",
659
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WhalePet, {
463
+ children: snapshot === null || definition === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PetSprite, {
660
464
  snapshot,
661
- display: snapshot?.display ?? DEFAULT_DISPLAY,
465
+ definition,
466
+ display: snapshot.display,
662
467
  feedback,
663
468
  onPet: props.pet,
664
469
  onFeed: props.feed,
@@ -681,12 +486,12 @@ window.__ModuleLoader__.load({
681
486
  },
682
487
  onClick: props.summon,
683
488
  "data-testid": "pet-summon",
684
- children: props.t("pet.summon", { name: snapshot?.name ?? "鲸鱼娘" })
489
+ children: props.t("pet.summon", { name: snapshot?.name ?? "" })
685
490
  });
686
491
  }
687
492
  //#endregion
688
493
  //#region \0dsh-css:packages/dsh-pet/src/client/settings-card.module.css.mjs
689
- const css = ".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_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);flex:1;margin:0;font-size:12px;line-height:1.5}.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}.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_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{transition:none}}";
494
+ const css = ".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_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_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{transition:none}}";
690
495
  const tagId = "@linxin666/dsh-pet/settings-card.module.css";
691
496
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
692
497
  const tag = document.createElement("style");
@@ -727,12 +532,12 @@ window.__ModuleLoader__.load({
727
532
  //#endregion
728
533
  //#region src/client/PluginSettingsCard.tsx
729
534
  /**
730
- * Shared chrome for the plugin settings card: a disclosure header naming the
731
- * plugin and what its settings govern, the controls inside, and the save that
732
- * writes them. Renders nothing while the namespace is unavailable — a
535
+ * Family-shared chrome for plugin settings cards: a disclosure header naming
536
+ * the plugin and what its settings govern, the controls inside, and the save
537
+ * that writes them. Renders nothing while the namespace is unavailable — a
733
538
  * deployment that does not compose the owning plugin should show no trace of
734
- * it. Mirrors the official ui-plugin-config PluginCard in a self-contained
735
- * slice (this package must not depend on a sibling UI package).
539
+ * it. Inlined into each consumer's client bundle; mirrors the official
540
+ * ui-plugin-config PluginCard in a self-contained slice.
736
541
  */
737
542
  /**
738
543
  * Render one plugin settings card.
@@ -744,6 +549,7 @@ window.__ModuleLoader__.load({
744
549
  const { state } = props;
745
550
  if (!state.available) return null;
746
551
  const title = props.t(props.titleKey);
552
+ const description = props.t(props.descriptionKey);
747
553
  const blocked = !state.dirty || state.invalid || state.saving;
748
554
  const cardClass = open ? `${settings_card_module_css_default.cardOpen} ${settings_card_module_css_default.card}` : settings_card_module_css_default.card;
749
555
  if (!state.exposed) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
@@ -760,10 +566,12 @@ window.__ModuleLoader__.load({
760
566
  className: settings_card_module_css_default.headText,
761
567
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
762
568
  className: settings_card_module_css_default.name,
569
+ title,
763
570
  children: title
764
571
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
765
572
  className: settings_card_module_css_default.description,
766
- children: props.t(props.descriptionKey)
573
+ title: description,
574
+ children: description
767
575
  })]
768
576
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
769
577
  width: "14",
@@ -801,14 +609,17 @@ window.__ModuleLoader__.load({
801
609
  className: settings_card_module_css_default.headText,
802
610
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
803
611
  className: settings_card_module_css_default.name,
612
+ title,
804
613
  children: title
805
614
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
806
615
  className: settings_card_module_css_default.description,
807
- children: props.t(props.descriptionKey)
616
+ title: description,
617
+ children: description
808
618
  })]
809
619
  }),
810
620
  state.dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
811
621
  className: settings_card_module_css_default.pending,
622
+ title: props.t("settings.unsaved"),
812
623
  children: props.t("settings.unsaved")
813
624
  }) : null,
814
625
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
@@ -836,10 +647,10 @@ window.__ModuleLoader__.load({
836
647
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
837
648
  className: settings_card_module_css_default.footer,
838
649
  children: [
839
- state.failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
650
+ state.failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
840
651
  className: settings_card_module_css_default.failed,
841
652
  role: "status",
842
- children: props.t("settings.saveFailed")
653
+ children: [props.t("settings.saveFailed"), state.failedReason ? " - " + state.failedReason : ""]
843
654
  }) : null,
844
655
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
845
656
  type: "button",
@@ -961,10 +772,59 @@ window.__ModuleLoader__.load({
961
772
  ]
962
773
  });
963
774
  }
775
+ /** A staged enumerated field rendered as a select. */
776
+ function ChoiceField(props) {
777
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
778
+ className: settings_card_module_css_default.field,
779
+ children: [
780
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
781
+ className: settings_card_module_css_default.head,
782
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
783
+ className: settings_card_module_css_default.label,
784
+ htmlFor: props.id,
785
+ children: props.label
786
+ }), props.overridden ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
787
+ className: settings_card_module_css_default.badges,
788
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
789
+ className: settings_card_module_css_default.badge,
790
+ children: props.overriddenLabel
791
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
792
+ type: "button",
793
+ className: settings_card_module_css_default.reset,
794
+ disabled: props.disabled,
795
+ onClick: props.onReset,
796
+ children: props.resetLabel
797
+ })]
798
+ }) : null]
799
+ }),
800
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
801
+ id: props.id,
802
+ className: settings_card_module_css_default.select,
803
+ value: props.text,
804
+ disabled: props.disabled,
805
+ onChange: (event) => {
806
+ props.onEdit(event.target.value);
807
+ },
808
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
809
+ value: "",
810
+ children: props.inheritLabel
811
+ }), props.choices.map((choice) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
812
+ value: choice.value,
813
+ children: choice.label
814
+ }, choice.value))]
815
+ }),
816
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
817
+ className: props.invalid ? settings_card_module_css_default.invalid : settings_card_module_css_default.hint,
818
+ children: props.invalid ? props.invalidLabel : props.hint
819
+ })
820
+ ]
821
+ });
822
+ }
964
823
  //#endregion
965
824
  //#region src/client/settings-form.ts
966
- /** A whole-number field. An empty draft clears the field; any other draft that is not a finite number blocks the save. */
967
- function numberField(field) {
825
+ /** A whole- or decimal-number field. An empty draft clears the field; any other draft that is not a finite number within the constraints blocks the save. */
826
+ function numberField(field, constraints = {}) {
827
+ const { integer = false, min } = constraints;
968
828
  return {
969
829
  field,
970
830
  format: (value) => typeof value === "number" ? String(value) : "",
@@ -972,23 +832,12 @@ window.__ModuleLoader__.load({
972
832
  const trimmed = text.trim();
973
833
  if (trimmed === "") return { kind: "clear" };
974
834
  const parsed = Number(trimmed);
975
- return Number.isFinite(parsed) ? {
835
+ if (!Number.isFinite(parsed)) return void 0;
836
+ if (integer && !Number.isInteger(parsed)) return void 0;
837
+ if (min !== void 0 && parsed < min) return void 0;
838
+ return {
976
839
  kind: "set",
977
840
  value: parsed
978
- } : void 0;
979
- }
980
- };
981
- }
982
- /** A free-text field. An empty draft clears the field. */
983
- function textField(field) {
984
- return {
985
- field,
986
- format: (value) => typeof value === "string" ? value : "",
987
- parse: (text) => {
988
- const trimmed = text.trim();
989
- return trimmed === "" ? { kind: "clear" } : {
990
- kind: "set",
991
- value: trimmed
992
841
  };
993
842
  }
994
843
  };
@@ -999,17 +848,33 @@ window.__ModuleLoader__.load({
999
848
  field,
1000
849
  format: (value) => typeof value === "boolean" ? String(value) : "",
1001
850
  parse: (text) => {
1002
- if (text === "true") return {
851
+ const trimmed = text.trim();
852
+ if (trimmed === "") return { kind: "clear" };
853
+ if (trimmed === "true") return {
1003
854
  kind: "set",
1004
855
  value: true
1005
856
  };
1006
- if (text === "false") return {
857
+ if (trimmed === "false") return {
1007
858
  kind: "set",
1008
859
  value: false
1009
860
  };
1010
861
  }
1011
862
  };
1012
863
  }
864
+ /** An enumerated string field; only the listed choices are accepted. An empty draft clears the field. */
865
+ function choiceField(field, choices) {
866
+ return {
867
+ field,
868
+ format: (value) => typeof value === "string" && choices.includes(value) ? value : "",
869
+ parse: (text) => {
870
+ if (text === "") return { kind: "clear" };
871
+ return choices.includes(text) ? {
872
+ kind: "set",
873
+ value: text
874
+ } : void 0;
875
+ }
876
+ };
877
+ }
1013
878
  /**
1014
879
  * Stages one card's edits over one settings namespace and writes them on save.
1015
880
  *
@@ -1025,6 +890,7 @@ window.__ModuleLoader__.load({
1025
890
  listeners = /* @__PURE__ */ new Set();
1026
891
  saving = false;
1027
892
  failed = false;
893
+ failedReason;
1028
894
  /** @param scope - the bound settings scope for this card's namespace. */
1029
895
  constructor(scope, specs) {
1030
896
  this.scope = scope;
@@ -1052,7 +918,8 @@ window.__ModuleLoader__.load({
1052
918
  dirty: plan.length > 0,
1053
919
  invalid: plan.some((item) => item.run === void 0),
1054
920
  saving: this.saving,
1055
- failed: this.failed
921
+ failed: this.failed,
922
+ ...this.failedReason === void 0 ? {} : { failedReason: this.failedReason }
1056
923
  };
1057
924
  }
1058
925
  /** Read one field's state from the effective section and its staged draft. */
@@ -1093,28 +960,50 @@ window.__ModuleLoader__.load({
1093
960
  if (this.staged.size === 0 && !this.failed) return;
1094
961
  this.staged.clear();
1095
962
  this.failed = false;
963
+ this.failedReason = void 0;
1096
964
  this.publish();
1097
965
  }
1098
966
  };
1099
967
  }
1100
968
  /**
1101
969
  * Write every staged edit, then re-seed from what the Host accepted.
970
+ *
971
+ * When the scope carries the optional batch surface (the dsh-web-ui
972
+ * bridge scope), every planned write rides one mutation so cross-field
973
+ * validate hooks (baseURL+model) judge the batch as a unit instead of
974
+ * deadlocking on per-field writes. Otherwise the per-field loop runs.
975
+ * A field lands only when the Host reports it held the staged value; a
976
+ * landed field's draft is dropped, a failed one stays staged for the user.
1102
977
  * @returns settlement after every write and the read-back.
1103
978
  */
1104
979
  async save() {
1105
980
  const plan = this.plan();
1106
- const writes = plan.flatMap((item) => item.run === void 0 ? [] : [item.run]);
1107
- if (plan.length === 0 || this.saving || writes.length !== plan.length) return;
981
+ const valid = plan.filter((item) => item.run !== void 0);
982
+ if (plan.length === 0 || this.saving || valid.length !== plan.length) return;
983
+ const plannedWrites = valid.map((item) => item.op);
984
+ const fields = new Set(plan.map((item) => item.field));
1108
985
  this.saving = true;
1109
986
  this.failed = false;
987
+ this.failedReason = void 0;
1110
988
  this.publish();
1111
- let landed = true;
1112
- for (const write of writes) landed = await write() && landed;
1113
- if (landed) this.staged.clear();
989
+ const landed = /* @__PURE__ */ new Set();
990
+ const batch = this.batchedScope();
991
+ if (batch !== void 0) {
992
+ const result = await batch.mutate(plannedWrites);
993
+ if (result.ok) {
994
+ for (const field of result.fields) if (field.landed) landed.add(field.field);
995
+ } else this.failedReason = result.message;
996
+ } else for (const item of valid) if (await item.run()) landed.add(item.field);
997
+ for (const field of fields) if (landed.has(field)) this.staged.delete(field);
1114
998
  this.saving = false;
1115
- this.failed = !landed;
999
+ this.failed = landed.size !== fields.size;
1116
1000
  this.publish();
1117
1001
  }
1002
+ /** The scope's batch surface when it supports one; undefined conservatively otherwise. */
1003
+ batchedScope() {
1004
+ const candidate = this.scope;
1005
+ return typeof candidate?.mutate === "function" ? candidate : void 0;
1006
+ }
1118
1007
  /**
1119
1008
  * Every staged edit a save would write. An entry whose draft is not a value
1120
1009
  * its field accepts carries no write: the form is still dirty, and the save
@@ -1129,6 +1018,10 @@ window.__ModuleLoader__.load({
1129
1018
  if (staged.clear) {
1130
1019
  if (this.stored(field)) plan.push({
1131
1020
  field,
1021
+ op: {
1022
+ field,
1023
+ op: "unset"
1024
+ },
1132
1025
  run: () => this.clear(field)
1133
1026
  });
1134
1027
  continue;
@@ -1137,14 +1030,27 @@ window.__ModuleLoader__.load({
1137
1030
  const write = spec.parse(staged.text);
1138
1031
  if (write === void 0) plan.push({
1139
1032
  field,
1033
+ op: {
1034
+ field,
1035
+ op: "unset"
1036
+ },
1140
1037
  run: void 0
1141
1038
  });
1142
1039
  else if (write.kind === "clear") plan.push({
1143
1040
  field,
1041
+ op: {
1042
+ field,
1043
+ op: "unset"
1044
+ },
1144
1045
  run: () => this.clear(field)
1145
1046
  });
1146
1047
  else plan.push({
1147
1048
  field,
1049
+ op: {
1050
+ field,
1051
+ op: "set",
1052
+ value: write.value
1053
+ },
1148
1054
  run: () => this.store(field, write.value)
1149
1055
  });
1150
1056
  }
@@ -1156,11 +1062,13 @@ window.__ModuleLoader__.load({
1156
1062
  }
1157
1063
  async store(field, value) {
1158
1064
  await this.scope.set(field, value);
1065
+ if (this.specOf(field).secret) return true;
1159
1066
  return this.userLayer()?.[field] === value;
1160
1067
  }
1161
1068
  stage(field, edit) {
1162
1069
  this.staged.set(field, edit);
1163
1070
  this.failed = false;
1071
+ this.failedReason = void 0;
1164
1072
  this.publish();
1165
1073
  }
1166
1074
  specOf(field) {
@@ -1190,11 +1098,21 @@ window.__ModuleLoader__.load({
1190
1098
  };
1191
1099
  //#endregion
1192
1100
  //#region src/client/PetSettingsCard.tsx
1193
- /** Bridges the `pet` scope onto the card's staged form. */
1101
+ /** Fetch the registry list (the same data the sprite renders from). */
1102
+ async function fetchPetChoices() {
1103
+ const response = await fetch("/api/pet/pets");
1104
+ if (!response.ok) throw new Error("pet pets failed: " + response.status);
1105
+ return await response.json();
1106
+ }
1107
+ /** Bridges the 'pet' scope onto the card's staged form. */
1194
1108
  var PetSettingsCardController = class {
1195
1109
  form;
1196
1110
  store;
1197
- /** @param scope - the bound settings scope for the `pet` namespace. */
1111
+ petChoices = [];
1112
+ petLabels = /* @__PURE__ */ new Map();
1113
+ loaded = false;
1114
+ attempts = 0;
1115
+ /** @param scope - the bound settings scope for the 'pet' namespace. */
1198
1116
  constructor(scope) {
1199
1117
  this.form = new CardForm(scope, [
1200
1118
  booleanField("enabled"),
@@ -1202,9 +1120,26 @@ window.__ModuleLoader__.load({
1202
1120
  numberField("size"),
1203
1121
  numberField("right"),
1204
1122
  numberField("bottom"),
1205
- textField("name")
1123
+ choiceField("petId", this.petChoices)
1206
1124
  ]);
1207
1125
  this.store = this.form.bind(() => this.projection());
1126
+ this.loadPets();
1127
+ }
1128
+ /** Resolve the registry choices once (retried a few times on failure). */
1129
+ async loadPets() {
1130
+ if (this.loaded) return;
1131
+ try {
1132
+ const list = await fetchPetChoices();
1133
+ this.petChoices.splice(0, this.petChoices.length, ...list.map((choice) => choice.id));
1134
+ for (const choice of list) this.petLabels.set(choice.id, choice.displayName);
1135
+ this.loaded = true;
1136
+ this.store.set(this.projection());
1137
+ } catch {
1138
+ this.attempts += 1;
1139
+ if (this.attempts < 3) window.setTimeout(() => {
1140
+ this.loadPets();
1141
+ }, 3e3);
1142
+ }
1208
1143
  }
1209
1144
  projection() {
1210
1145
  return {
@@ -1214,7 +1149,11 @@ window.__ModuleLoader__.load({
1214
1149
  size: this.form.field("size"),
1215
1150
  right: this.form.field("right"),
1216
1151
  bottom: this.form.field("bottom"),
1217
- name: this.form.field("name")
1152
+ petId: this.form.field("petId"),
1153
+ petChoices: this.petChoices.map((id) => ({
1154
+ value: id,
1155
+ label: this.petLabels.get(id) ?? id
1156
+ }))
1218
1157
  };
1219
1158
  }
1220
1159
  /**
@@ -1267,6 +1206,21 @@ window.__ModuleLoader__.load({
1267
1206
  props.resetField("enabled");
1268
1207
  }
1269
1208
  }),
1209
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChoiceField, {
1210
+ id: "settings-pet-pet",
1211
+ label: t("settings.pet"),
1212
+ hint: t("settings.petHint"),
1213
+ inheritLabel: t("settings.inherit"),
1214
+ ...fieldProps,
1215
+ ...state.petId,
1216
+ choices: state.petChoices,
1217
+ onEdit: (text) => {
1218
+ props.edit("petId", text);
1219
+ },
1220
+ onReset: () => {
1221
+ props.resetField("petId");
1222
+ }
1223
+ }),
1270
1224
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BooleanField, {
1271
1225
  id: "settings-pet-visible",
1272
1226
  label: t("settings.visible"),
@@ -1324,19 +1278,6 @@ window.__ModuleLoader__.load({
1324
1278
  onReset: () => {
1325
1279
  props.resetField("bottom");
1326
1280
  }
1327
- }),
1328
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ValueField, {
1329
- id: "settings-pet-name",
1330
- label: t("settings.name"),
1331
- hint: t("settings.nameHint"),
1332
- ...fieldProps,
1333
- ...state.name,
1334
- onEdit: (text) => {
1335
- props.edit("name", text);
1336
- },
1337
- onReset: () => {
1338
- props.resetField("name");
1339
- }
1340
1281
  })
1341
1282
  ]
1342
1283
  });
@@ -1354,10 +1295,12 @@ window.__ModuleLoader__.load({
1354
1295
  "pet.rank": "亲密度 {rank}",
1355
1296
  "pet.points": "{points} 点",
1356
1297
  "pet.treats": "小鱼干 ×{n}",
1357
- "pet.state.loading": "鲸鱼娘正在赶来…",
1358
- "pet.state.error": "鲸鱼娘迷路了(连接失败)",
1298
+ "pet.state.loading": "宠物正在赶来…",
1299
+ "pet.state.error": "宠物迷路了(连接失败)",
1359
1300
  "settings.title": "宠物",
1360
- "settings.description": "鲸鱼娘的显示布局与名字。",
1301
+ "settings.description": "选择宠物并调整它的显示布局。",
1302
+ "settings.pet": "宠物",
1303
+ "settings.petHint": "选择显示哪只宠物;每只宠物独立命名,可在宠物悬浮面板改名。",
1361
1304
  "settings.enabled": "启用宠物",
1362
1305
  "settings.enabledHint": "关闭后隐藏宠物并停止轮询,可在设置里重新启用。",
1363
1306
  "settings.visible": "显示宠物",
@@ -1368,8 +1311,6 @@ window.__ModuleLoader__.load({
1368
1311
  "settings.rightHint": "距视口右边缘的水平内缩距离。",
1369
1312
  "settings.bottom": "距底部(px)",
1370
1313
  "settings.bottomHint": "距视口底边的垂直内缩距离。",
1371
- "settings.name": "名字",
1372
- "settings.nameHint": "宠物显示名,1–20 个字符。",
1373
1314
  "settings.inherit": "继承",
1374
1315
  "settings.on": "开",
1375
1316
  "settings.off": "关",
@@ -1397,10 +1338,12 @@ window.__ModuleLoader__.load({
1397
1338
  "pet.rank": "Affinity {rank}",
1398
1339
  "pet.points": "{points} pts",
1399
1340
  "pet.treats": "Treats ×{n}",
1400
- "pet.state.loading": "The whale girl is on her way…",
1401
- "pet.state.error": "The whale girl is lost (connection failed)",
1341
+ "pet.state.loading": "The pet is on its way…",
1342
+ "pet.state.error": "The pet is lost (connection failed)",
1402
1343
  "settings.title": "Pet",
1403
- "settings.description": "The whale girl’s display layout and name.",
1344
+ "settings.description": "Pick a pet and tune its display layout.",
1345
+ "settings.pet": "Pet",
1346
+ "settings.petHint": "Choose which pet shows. Names are stored per pet; rename from the pet hover panel.",
1404
1347
  "settings.enabled": "Enable the pet",
1405
1348
  "settings.enabledHint": "When off, the pet hides and polling stops; re-enable it here.",
1406
1349
  "settings.visible": "Show the pet",
@@ -1411,8 +1354,6 @@ window.__ModuleLoader__.load({
1411
1354
  "settings.rightHint": "Horizontal inset from the viewport right edge.",
1412
1355
  "settings.bottom": "Bottom inset (px)",
1413
1356
  "settings.bottomHint": "Vertical inset from the viewport bottom edge.",
1414
- "settings.name": "Name",
1415
- "settings.nameHint": "The pet’s display name, 1–20 characters.",
1416
1357
  "settings.inherit": "Inherit",
1417
1358
  "settings.on": "On",
1418
1359
  "settings.off": "Off",
@@ -1460,19 +1401,21 @@ window.__ModuleLoader__.load({
1460
1401
  headers: { "content-type": "application/json" },
1461
1402
  body: JSON.stringify(body)
1462
1403
  });
1463
- if (!response.ok) throw new Error(`pet ${path} failed: ${response.status}`);
1404
+ if (!response.ok) throw new Error("pet " + path + " failed: " + response.status);
1464
1405
  return await response.json();
1465
1406
  }
1466
1407
  /** The live host API instance (always defined; failures surface per call). */
1467
1408
  const petApi = {
1468
1409
  state: () => petFetch("/api/pet/state"),
1410
+ pets: () => petFetch("/api/pet/pets"),
1469
1411
  interact: (kind) => petFetch("/api/pet/interact", { kind }),
1470
1412
  setVisible: (visible) => petFetch("/api/pet/set-visible", { visible }),
1471
1413
  setConfig: (patch) => petFetch("/api/pet/set-config", patch),
1472
- setName: (name) => petFetch("/api/pet/set-name", { name })
1414
+ setName: (name) => petFetch("/api/pet/set-name", { name }),
1415
+ setPet: (petId) => petFetch("/api/pet/set-pet", { petId })
1473
1416
  };
1474
1417
  /** Poll interval for the host snapshot. */
1475
- const POLL_MS = 800;
1418
+ const POLL_MS = 2e3;
1476
1419
  /** Settings namespace the pet settings card edits (the Host plugin registers it). */
1477
1420
  const PET_SETTINGS_NS = "pet";
1478
1421
  /** Required services. */
@@ -1512,9 +1455,15 @@ window.__ModuleLoader__.load({
1512
1455
  if (enabled() && disposeUi === void 0) {
1513
1456
  const petStore = createPetStore().create();
1514
1457
  const setSnapshot = petStore.actions.setSnapshot;
1458
+ const setPets = petStore.actions.setPets;
1515
1459
  const setState = petStore.actions.setState;
1516
1460
  const setFeedback = petStore.actions.setFeedback;
1461
+ let petsLoaded = false;
1517
1462
  const pollNow = () => {
1463
+ if (!petsLoaded) petApi.pets().then((list) => {
1464
+ petsLoaded = true;
1465
+ setPets(list);
1466
+ }, () => {});
1518
1467
  petApi.state().then((snapshot) => {
1519
1468
  setSnapshot(snapshot);
1520
1469
  }, () => {