@linxin666/dsh-pet 0.1.15 → 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 (74) 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 +270 -334
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +641 -87
  9. package/lib/types/client/PetDockEntry.d.ts +14 -12
  10. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  11. package/lib/types/client/PetDockEntry.js +13 -8
  12. package/lib/types/client/PetSettingsCard.d.ts +22 -9
  13. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PetSettingsCard.js +42 -7
  15. package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
  16. package/lib/types/client/PetSprite.d.ts.map +1 -0
  17. package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
  18. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/PluginSettingsCard.js +3 -1
  20. package/lib/types/client/index.d.ts +11 -8
  21. package/lib/types/client/index.d.ts.map +1 -1
  22. package/lib/types/client/index.js +30 -12
  23. package/lib/types/client/locales.d.ts +10 -10
  24. package/lib/types/client/locales.js +10 -10
  25. package/lib/types/client/pet-store.d.ts +5 -0
  26. package/lib/types/client/pet-store.d.ts.map +1 -1
  27. package/lib/types/client/pet-store.js +4 -0
  28. package/lib/types/client/settings-form.d.ts +62 -0
  29. package/lib/types/client/settings-form.d.ts.map +1 -1
  30. package/lib/types/client/settings-form.js +61 -12
  31. package/lib/types/client/spritesheet.d.ts +14 -48
  32. package/lib/types/client/spritesheet.d.ts.map +1 -1
  33. package/lib/types/client/spritesheet.js +13 -81
  34. package/lib/types/index.d.ts +24 -11
  35. package/lib/types/index.d.ts.map +1 -1
  36. package/lib/types/index.js +53 -33
  37. package/lib/types/ledger.d.ts +4 -2
  38. package/lib/types/ledger.d.ts.map +1 -1
  39. package/lib/types/ledger.js +11 -4
  40. package/lib/types/persist.d.ts +10 -3
  41. package/lib/types/persist.d.ts.map +1 -1
  42. package/lib/types/persist.js +32 -5
  43. package/lib/types/registry.d.ts +158 -0
  44. package/lib/types/registry.d.ts.map +1 -0
  45. package/lib/types/registry.js +276 -0
  46. package/lib/types/routes.d.ts +10 -10
  47. package/lib/types/routes.d.ts.map +1 -1
  48. package/lib/types/routes.js +169 -51
  49. package/lib/types/service.d.ts +46 -13
  50. package/lib/types/service.d.ts.map +1 -1
  51. package/lib/types/service.js +73 -16
  52. package/package.json +2 -2
  53. package/src/client/PetDockEntry.tsx +34 -26
  54. package/src/client/PetSettingsCard.tsx +67 -22
  55. package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
  56. package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
  57. package/src/client/PluginSettingsCard.tsx +7 -1
  58. package/src/client/index.ts +36 -14
  59. package/src/client/locales.ts +10 -10
  60. package/src/client/pet-store.ts +9 -0
  61. package/src/client/settings-card.module.css +3 -0
  62. package/src/client/settings-form.ts +114 -12
  63. package/src/client/spritesheet.ts +16 -92
  64. package/src/index.test.ts +23 -0
  65. package/src/index.ts +89 -45
  66. package/src/ledger.test.ts +5 -3
  67. package/src/ledger.ts +11 -4
  68. package/src/persist.test.ts +52 -3
  69. package/src/persist.ts +42 -8
  70. package/src/registry.test.ts +132 -0
  71. package/src/registry.ts +400 -0
  72. package/src/routes.ts +166 -53
  73. package/src/service.ts +102 -20
  74. 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;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");
@@ -842,10 +647,10 @@ window.__ModuleLoader__.load({
842
647
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
843
648
  className: settings_card_module_css_default.footer,
844
649
  children: [
845
- state.failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
650
+ state.failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
846
651
  className: settings_card_module_css_default.failed,
847
652
  role: "status",
848
- children: props.t("settings.saveFailed")
653
+ children: [props.t("settings.saveFailed"), state.failedReason ? " - " + state.failedReason : ""]
849
654
  }) : null,
850
655
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
851
656
  type: "button",
@@ -967,6 +772,54 @@ window.__ModuleLoader__.load({
967
772
  ]
968
773
  });
969
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
+ }
970
823
  //#endregion
971
824
  //#region src/client/settings-form.ts
972
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. */
@@ -989,20 +842,6 @@ window.__ModuleLoader__.load({
989
842
  }
990
843
  };
991
844
  }
992
- /** A free-text field. An empty draft clears the field. */
993
- function textField(field) {
994
- return {
995
- field,
996
- format: (value) => typeof value === "string" ? value : "",
997
- parse: (text) => {
998
- const trimmed = text.trim();
999
- return trimmed === "" ? { kind: "clear" } : {
1000
- kind: "set",
1001
- value: trimmed
1002
- };
1003
- }
1004
- };
1005
- }
1006
845
  /** A boolean field, edited through true/false draft text. */
1007
846
  function booleanField(field) {
1008
847
  return {
@@ -1022,6 +861,20 @@ window.__ModuleLoader__.load({
1022
861
  }
1023
862
  };
1024
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
+ }
1025
878
  /**
1026
879
  * Stages one card's edits over one settings namespace and writes them on save.
1027
880
  *
@@ -1037,6 +890,7 @@ window.__ModuleLoader__.load({
1037
890
  listeners = /* @__PURE__ */ new Set();
1038
891
  saving = false;
1039
892
  failed = false;
893
+ failedReason;
1040
894
  /** @param scope - the bound settings scope for this card's namespace. */
1041
895
  constructor(scope, specs) {
1042
896
  this.scope = scope;
@@ -1064,7 +918,8 @@ window.__ModuleLoader__.load({
1064
918
  dirty: plan.length > 0,
1065
919
  invalid: plan.some((item) => item.run === void 0),
1066
920
  saving: this.saving,
1067
- failed: this.failed
921
+ failed: this.failed,
922
+ ...this.failedReason === void 0 ? {} : { failedReason: this.failedReason }
1068
923
  };
1069
924
  }
1070
925
  /** Read one field's state from the effective section and its staged draft. */
@@ -1105,29 +960,50 @@ window.__ModuleLoader__.load({
1105
960
  if (this.staged.size === 0 && !this.failed) return;
1106
961
  this.staged.clear();
1107
962
  this.failed = false;
963
+ this.failedReason = void 0;
1108
964
  this.publish();
1109
965
  }
1110
966
  };
1111
967
  }
1112
968
  /**
1113
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.
1114
977
  * @returns settlement after every write and the read-back.
1115
978
  */
1116
979
  async save() {
1117
980
  const plan = this.plan();
1118
- const writes = plan.flatMap((item) => item.run === void 0 ? [] : [item.run]);
1119
- 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);
1120
984
  const fields = new Set(plan.map((item) => item.field));
1121
985
  this.saving = true;
1122
986
  this.failed = false;
987
+ this.failedReason = void 0;
1123
988
  this.publish();
1124
- let landed = true;
1125
- for (const write of writes) landed = await write() && landed;
1126
- if (landed) for (const field of fields) this.staged.delete(field);
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);
1127
998
  this.saving = false;
1128
- this.failed = !landed;
999
+ this.failed = landed.size !== fields.size;
1129
1000
  this.publish();
1130
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
+ }
1131
1007
  /**
1132
1008
  * Every staged edit a save would write. An entry whose draft is not a value
1133
1009
  * its field accepts carries no write: the form is still dirty, and the save
@@ -1142,6 +1018,10 @@ window.__ModuleLoader__.load({
1142
1018
  if (staged.clear) {
1143
1019
  if (this.stored(field)) plan.push({
1144
1020
  field,
1021
+ op: {
1022
+ field,
1023
+ op: "unset"
1024
+ },
1145
1025
  run: () => this.clear(field)
1146
1026
  });
1147
1027
  continue;
@@ -1150,14 +1030,27 @@ window.__ModuleLoader__.load({
1150
1030
  const write = spec.parse(staged.text);
1151
1031
  if (write === void 0) plan.push({
1152
1032
  field,
1033
+ op: {
1034
+ field,
1035
+ op: "unset"
1036
+ },
1153
1037
  run: void 0
1154
1038
  });
1155
1039
  else if (write.kind === "clear") plan.push({
1156
1040
  field,
1041
+ op: {
1042
+ field,
1043
+ op: "unset"
1044
+ },
1157
1045
  run: () => this.clear(field)
1158
1046
  });
1159
1047
  else plan.push({
1160
1048
  field,
1049
+ op: {
1050
+ field,
1051
+ op: "set",
1052
+ value: write.value
1053
+ },
1161
1054
  run: () => this.store(field, write.value)
1162
1055
  });
1163
1056
  }
@@ -1169,11 +1062,13 @@ window.__ModuleLoader__.load({
1169
1062
  }
1170
1063
  async store(field, value) {
1171
1064
  await this.scope.set(field, value);
1065
+ if (this.specOf(field).secret) return true;
1172
1066
  return this.userLayer()?.[field] === value;
1173
1067
  }
1174
1068
  stage(field, edit) {
1175
1069
  this.staged.set(field, edit);
1176
1070
  this.failed = false;
1071
+ this.failedReason = void 0;
1177
1072
  this.publish();
1178
1073
  }
1179
1074
  specOf(field) {
@@ -1203,11 +1098,21 @@ window.__ModuleLoader__.load({
1203
1098
  };
1204
1099
  //#endregion
1205
1100
  //#region src/client/PetSettingsCard.tsx
1206
- /** 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. */
1207
1108
  var PetSettingsCardController = class {
1208
1109
  form;
1209
1110
  store;
1210
- /** @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. */
1211
1116
  constructor(scope) {
1212
1117
  this.form = new CardForm(scope, [
1213
1118
  booleanField("enabled"),
@@ -1215,9 +1120,26 @@ window.__ModuleLoader__.load({
1215
1120
  numberField("size"),
1216
1121
  numberField("right"),
1217
1122
  numberField("bottom"),
1218
- textField("name")
1123
+ choiceField("petId", this.petChoices)
1219
1124
  ]);
1220
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
+ }
1221
1143
  }
1222
1144
  projection() {
1223
1145
  return {
@@ -1227,7 +1149,11 @@ window.__ModuleLoader__.load({
1227
1149
  size: this.form.field("size"),
1228
1150
  right: this.form.field("right"),
1229
1151
  bottom: this.form.field("bottom"),
1230
- 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
+ }))
1231
1157
  };
1232
1158
  }
1233
1159
  /**
@@ -1280,6 +1206,21 @@ window.__ModuleLoader__.load({
1280
1206
  props.resetField("enabled");
1281
1207
  }
1282
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
+ }),
1283
1224
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BooleanField, {
1284
1225
  id: "settings-pet-visible",
1285
1226
  label: t("settings.visible"),
@@ -1337,19 +1278,6 @@ window.__ModuleLoader__.load({
1337
1278
  onReset: () => {
1338
1279
  props.resetField("bottom");
1339
1280
  }
1340
- }),
1341
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ValueField, {
1342
- id: "settings-pet-name",
1343
- label: t("settings.name"),
1344
- hint: t("settings.nameHint"),
1345
- ...fieldProps,
1346
- ...state.name,
1347
- onEdit: (text) => {
1348
- props.edit("name", text);
1349
- },
1350
- onReset: () => {
1351
- props.resetField("name");
1352
- }
1353
1281
  })
1354
1282
  ]
1355
1283
  });
@@ -1367,10 +1295,12 @@ window.__ModuleLoader__.load({
1367
1295
  "pet.rank": "亲密度 {rank}",
1368
1296
  "pet.points": "{points} 点",
1369
1297
  "pet.treats": "小鱼干 ×{n}",
1370
- "pet.state.loading": "鲸鱼娘正在赶来…",
1371
- "pet.state.error": "鲸鱼娘迷路了(连接失败)",
1298
+ "pet.state.loading": "宠物正在赶来…",
1299
+ "pet.state.error": "宠物迷路了(连接失败)",
1372
1300
  "settings.title": "宠物",
1373
- "settings.description": "鲸鱼娘的显示布局与名字。",
1301
+ "settings.description": "选择宠物并调整它的显示布局。",
1302
+ "settings.pet": "宠物",
1303
+ "settings.petHint": "选择显示哪只宠物;每只宠物独立命名,可在宠物悬浮面板改名。",
1374
1304
  "settings.enabled": "启用宠物",
1375
1305
  "settings.enabledHint": "关闭后隐藏宠物并停止轮询,可在设置里重新启用。",
1376
1306
  "settings.visible": "显示宠物",
@@ -1381,8 +1311,6 @@ window.__ModuleLoader__.load({
1381
1311
  "settings.rightHint": "距视口右边缘的水平内缩距离。",
1382
1312
  "settings.bottom": "距底部(px)",
1383
1313
  "settings.bottomHint": "距视口底边的垂直内缩距离。",
1384
- "settings.name": "名字",
1385
- "settings.nameHint": "宠物显示名,1–20 个字符。",
1386
1314
  "settings.inherit": "继承",
1387
1315
  "settings.on": "开",
1388
1316
  "settings.off": "关",
@@ -1410,10 +1338,12 @@ window.__ModuleLoader__.load({
1410
1338
  "pet.rank": "Affinity {rank}",
1411
1339
  "pet.points": "{points} pts",
1412
1340
  "pet.treats": "Treats ×{n}",
1413
- "pet.state.loading": "The whale girl is on her way…",
1414
- "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)",
1415
1343
  "settings.title": "Pet",
1416
- "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.",
1417
1347
  "settings.enabled": "Enable the pet",
1418
1348
  "settings.enabledHint": "When off, the pet hides and polling stops; re-enable it here.",
1419
1349
  "settings.visible": "Show the pet",
@@ -1424,8 +1354,6 @@ window.__ModuleLoader__.load({
1424
1354
  "settings.rightHint": "Horizontal inset from the viewport right edge.",
1425
1355
  "settings.bottom": "Bottom inset (px)",
1426
1356
  "settings.bottomHint": "Vertical inset from the viewport bottom edge.",
1427
- "settings.name": "Name",
1428
- "settings.nameHint": "The pet’s display name, 1–20 characters.",
1429
1357
  "settings.inherit": "Inherit",
1430
1358
  "settings.on": "On",
1431
1359
  "settings.off": "Off",
@@ -1473,16 +1401,18 @@ window.__ModuleLoader__.load({
1473
1401
  headers: { "content-type": "application/json" },
1474
1402
  body: JSON.stringify(body)
1475
1403
  });
1476
- if (!response.ok) throw new Error(`pet ${path} failed: ${response.status}`);
1404
+ if (!response.ok) throw new Error("pet " + path + " failed: " + response.status);
1477
1405
  return await response.json();
1478
1406
  }
1479
1407
  /** The live host API instance (always defined; failures surface per call). */
1480
1408
  const petApi = {
1481
1409
  state: () => petFetch("/api/pet/state"),
1410
+ pets: () => petFetch("/api/pet/pets"),
1482
1411
  interact: (kind) => petFetch("/api/pet/interact", { kind }),
1483
1412
  setVisible: (visible) => petFetch("/api/pet/set-visible", { visible }),
1484
1413
  setConfig: (patch) => petFetch("/api/pet/set-config", patch),
1485
- 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 })
1486
1416
  };
1487
1417
  /** Poll interval for the host snapshot. */
1488
1418
  const POLL_MS = 2e3;
@@ -1525,9 +1455,15 @@ window.__ModuleLoader__.load({
1525
1455
  if (enabled() && disposeUi === void 0) {
1526
1456
  const petStore = createPetStore().create();
1527
1457
  const setSnapshot = petStore.actions.setSnapshot;
1458
+ const setPets = petStore.actions.setPets;
1528
1459
  const setState = petStore.actions.setState;
1529
1460
  const setFeedback = petStore.actions.setFeedback;
1461
+ let petsLoaded = false;
1530
1462
  const pollNow = () => {
1463
+ if (!petsLoaded) petApi.pets().then((list) => {
1464
+ petsLoaded = true;
1465
+ setPets(list);
1466
+ }, () => {});
1531
1467
  petApi.state().then((snapshot) => {
1532
1468
  setSnapshot(snapshot);
1533
1469
  }, () => {