@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/index.js CHANGED
@@ -2,11 +2,11 @@ import { a as AFFINITY_MAX, c as applyInteraction, d as emptyAffinity, f as rank
2
2
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
3
3
  import z from "schemastery";
4
4
  import { Service } from "@deepseek-ai/cordis";
5
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
- import { isAbsolute, join } from "node:path";
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
6
+ import { dirname, isAbsolute, join, resolve } from "node:path";
7
7
  import { homedir } from "node:os";
8
- import { readFile } from "node:fs/promises";
9
8
  import { fileURLToPath } from "node:url";
9
+ import { readFile } from "node:fs/promises";
10
10
  //#region src/event-projection.ts
11
11
  /** Fresh projection runtime for a newly seen session. */
12
12
  function emptyProjectionRuntime() {
@@ -259,11 +259,23 @@ var PetLedger = class {
259
259
  };
260
260
  this.dirty = true;
261
261
  }
262
- /** Replace the pet display name (validation stays a caller concern). */
263
- setName(name) {
262
+ /** Replace the selected pet id (validation stays a caller concern). */
263
+ setPetId(petId) {
264
+ if (this.current.petId === petId) return;
264
265
  this.current = {
265
266
  ...this.current,
266
- name
267
+ petId
268
+ };
269
+ this.dirty = true;
270
+ }
271
+ /** Replace one pet's display name (validation stays a caller concern). */
272
+ setPetName(petId, name) {
273
+ this.current = {
274
+ ...this.current,
275
+ names: {
276
+ ...this.current.names,
277
+ [petId]: name
278
+ }
267
279
  };
268
280
  this.dirty = true;
269
281
  }
@@ -327,7 +339,7 @@ var PetLedger = class {
327
339
  if (kind === "feed") {
328
340
  const consume = consumeTreat(this.current.treats);
329
341
  if (!consume.ok) return {
330
- reaction: "没有小鱼干了,多陪鲸鱼娘工作一会儿吧~",
342
+ reaction: "没有小鱼干了,多陪我工作一会儿吧~",
331
343
  delta: 0,
332
344
  affinity: this.affinityView(nowMs)
333
345
  };
@@ -401,11 +413,16 @@ const defaultDisplayConfig = {
401
413
  bottom: 20
402
414
  };
403
415
  const DISPLAY_INSET_MAX = 1e4;
404
- /** Default pet name (used until the user renames the pet). */
416
+ /** Pet id the legacy single-pet installs resolve to on migration. */
417
+ const DEFAULT_PET_ID = "whale-girl";
418
+ /** Default pet name (used only when a manifest carries no displayName). */
405
419
  const DEFAULT_PET_NAME = "鲸鱼娘";
420
+ /** Name constraints. */
421
+ const PET_NAME_MAX_LENGTH = 20;
406
422
  function emptyPersist() {
407
423
  return {
408
- name: DEFAULT_PET_NAME,
424
+ petId: DEFAULT_PET_ID,
425
+ names: {},
409
426
  affinity: emptyAffinity(),
410
427
  treats: emptyTreatLedger(),
411
428
  display: { ...defaultDisplayConfig }
@@ -423,6 +440,18 @@ function petHomeDir() {
423
440
  function finiteNum(value, fallback) {
424
441
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
425
442
  }
443
+ /** Sanitize the per-pet names map (string keys, non-empty trimmed values). */
444
+ function loadPetNames(parsed) {
445
+ const names = {};
446
+ if (typeof parsed.names !== "object" || parsed.names === null) return names;
447
+ for (const [id, value] of Object.entries(parsed.names)) {
448
+ if (id === "" || typeof value !== "string") continue;
449
+ const name = value.trim();
450
+ if (name === "") continue;
451
+ names[id] = name.slice(0, 20);
452
+ }
453
+ return names;
454
+ }
426
455
  /** Clamp one count/score into [0, max]. */
427
456
  function clamp(value, max) {
428
457
  return Math.min(max, Math.max(0, value));
@@ -455,8 +484,12 @@ function loadPetPersist(dir = petHomeDir()) {
455
484
  right: Math.round(clamp(finiteNum(rawDisplay.right, base.display.right), DISPLAY_INSET_MAX)),
456
485
  bottom: Math.round(clamp(finiteNum(rawDisplay.bottom, base.display.bottom), DISPLAY_INSET_MAX))
457
486
  };
487
+ const petId = typeof parsed.petId === "string" && parsed.petId.trim() !== "" ? parsed.petId.trim() : base.petId;
488
+ const names = loadPetNames(parsed);
489
+ if (typeof parsed.name === "string" && parsed.name.trim() !== "" && names[petId] === void 0) names[petId] = parsed.name.trim().slice(0, 20);
458
490
  return {
459
- name: typeof parsed.name === "string" && parsed.name.trim() !== "" ? parsed.name : base.name,
491
+ petId,
492
+ names,
460
493
  affinity,
461
494
  treats,
462
495
  display
@@ -473,6 +506,352 @@ function savePetPersist(data, dir = petHomeDir()) {
473
506
  writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
474
507
  renameSync(tmp, target);
475
508
  }
509
+ //#endregion
510
+ //#region src/registry.ts
511
+ /**
512
+ * Pet registry — the multi-pet contract. One pet is a directory holding a
513
+ * 'pet.json' manifest plus an atlas image; nothing else is required, and no
514
+ * host or client code changes when a pet is added. The registry scans three
515
+ * sources, later sources overriding earlier ones on an id collision:
516
+ *
517
+ * 1. the package's own 'assets' subdirectories (built-in pets);
518
+ * 2. '${CODEX_HOME:-~/.codex}/pets' subdirectories (hatch-pet custom pets);
519
+ * 3. 'PetConfig.pets' manifests composed by the embedding application
520
+ * (highest precedence).
521
+ *
522
+ * The manifest follows the Codex/hatch-pet contract (8 columns x 9 rows of
523
+ * 192x208 cells, the 9-state row order below). Legacy whale-girl manifests
524
+ * that only carry 'frames' keep working: geometry, per-row frame counts and
525
+ * per-track rhythm all fall back to the hatch-pet contract defaults, and the
526
+ * whale-girl manifest overrides its own durations.
527
+ * @module @linxin666/dsh-pet/registry
528
+ */
529
+ /** Fixed row order of the 9-state animation contract. */
530
+ const PET_ROW_ORDER = [
531
+ "idle",
532
+ "running-right",
533
+ "running-left",
534
+ "waving",
535
+ "jumping",
536
+ "failed",
537
+ "waiting",
538
+ "running",
539
+ "review"
540
+ ];
541
+ /** Atlas cell size in px (Codex/hatch-pet contract). */
542
+ const DEFAULT_PET_CELL = {
543
+ width: 192,
544
+ height: 208
545
+ };
546
+ /** Columns per row (max frames per track). */
547
+ const DEFAULT_PET_COLUMNS = 8;
548
+ /** Rows in the atlas (fixed by the animation contract). */
549
+ const DEFAULT_PET_ROW_COUNT = 9;
550
+ /**
551
+ * Per-row used-column counts from the hatch-pet contract table. Manifests
552
+ * that carry no 'frames' field (the Codex custom-pet shape) resolve here.
553
+ */
554
+ const DEFAULT_FRAME_COUNTS = [
555
+ 6,
556
+ 8,
557
+ 8,
558
+ 4,
559
+ 5,
560
+ 8,
561
+ 6,
562
+ 6,
563
+ 6
564
+ ];
565
+ /** Absolute package root, resolved from a module URL (lib/ or src/). */
566
+ function petPackageRoot(importMetaUrl) {
567
+ return fileURLToPath(new URL("../", importMetaUrl));
568
+ }
569
+ /** Resolve the hatch-pet custom pets directory (CODEX_HOME or ~/.codex). */
570
+ function codexPetsDir(env = process.env, home = homedir()) {
571
+ const raw = env.CODEX_HOME !== void 0 && env.CODEX_HOME.trim() !== "" ? env.CODEX_HOME.trim() : join(home, ".codex");
572
+ return join(raw === "~" ? home : raw.startsWith("~/") || raw.startsWith("~\\") ? join(home, raw.slice(2)) : raw, "pets");
573
+ }
574
+ /** Finite non-negative integer guard, else the fallback. */
575
+ function finiteInt(value, fallback, max) {
576
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= max ? value : fallback;
577
+ }
578
+ /** Build the browser URL of one pet asset. */
579
+ function assetUrl(prefix, id, file) {
580
+ const path = file.split("/").filter((segment) => segment !== "").join("/");
581
+ return prefix + "/" + encodeURIComponent(id) + "/" + path;
582
+ }
583
+ /** Default per-track rhythm (hatch-pet contract table). */
584
+ const DEFAULT_TRACK_PATTERNS = {
585
+ idle: {
586
+ durations: [
587
+ 280,
588
+ 110,
589
+ 110,
590
+ 140,
591
+ 140,
592
+ 320
593
+ ],
594
+ loop: true
595
+ },
596
+ "running-right": {
597
+ durations: [
598
+ 120,
599
+ 120,
600
+ 120,
601
+ 120,
602
+ 120,
603
+ 120,
604
+ 120,
605
+ 220
606
+ ],
607
+ loop: true
608
+ },
609
+ "running-left": {
610
+ durations: [
611
+ 120,
612
+ 120,
613
+ 120,
614
+ 120,
615
+ 120,
616
+ 120,
617
+ 120,
618
+ 220
619
+ ],
620
+ loop: true
621
+ },
622
+ waving: {
623
+ durations: [
624
+ 140,
625
+ 140,
626
+ 140,
627
+ 280
628
+ ],
629
+ loop: true
630
+ },
631
+ jumping: {
632
+ durations: [
633
+ 140,
634
+ 140,
635
+ 140,
636
+ 140,
637
+ 280
638
+ ],
639
+ loop: false,
640
+ fallback: "idle"
641
+ },
642
+ failed: {
643
+ durations: [
644
+ 140,
645
+ 140,
646
+ 140,
647
+ 140,
648
+ 140,
649
+ 140,
650
+ 140,
651
+ 240
652
+ ],
653
+ loop: false,
654
+ fallback: "idle"
655
+ },
656
+ waiting: {
657
+ durations: [
658
+ 150,
659
+ 150,
660
+ 150,
661
+ 150,
662
+ 150,
663
+ 260
664
+ ],
665
+ loop: true
666
+ },
667
+ running: {
668
+ durations: [
669
+ 120,
670
+ 120,
671
+ 120,
672
+ 120,
673
+ 120,
674
+ 220
675
+ ],
676
+ loop: true
677
+ },
678
+ review: {
679
+ durations: [
680
+ 150,
681
+ 150,
682
+ 150,
683
+ 150,
684
+ 150,
685
+ 280
686
+ ],
687
+ loop: true
688
+ }
689
+ };
690
+ /** Stable id charset: keeps asset URLs plain and filesystem-safe. */
691
+ const PET_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
692
+ /** Safe path-segment charset for atlas files. */
693
+ const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/;
694
+ const PET_NAME_MAX_LENGTH$1 = 80;
695
+ /**
696
+ * Normalize one parsed manifest into a renderable pet entry, or undefined
697
+ * (with a warning recorded) when the manifest violates the contract.
698
+ */
699
+ function resolvePetManifest(raw, dir, options = {}) {
700
+ const { assetPrefix = "/pet", warnings = [] } = options;
701
+ const warn = (message) => {
702
+ warnings.push(message);
703
+ };
704
+ if (typeof raw !== "object" || raw === null) {
705
+ warn("manifest is not an object");
706
+ return;
707
+ }
708
+ const source = raw;
709
+ const id = typeof source.id === "string" ? source.id.trim() : "";
710
+ if (!PET_ID_PATTERN.test(id)) {
711
+ warn("manifest id " + JSON.stringify(String(source.id)) + " is not a lowercase kebab id");
712
+ return;
713
+ }
714
+ const displayName = typeof source.displayName === "string" && source.displayName.trim() !== "" ? source.displayName.trim().slice(0, PET_NAME_MAX_LENGTH$1) : id;
715
+ const description = typeof source.description === "string" ? source.description.trim() : "";
716
+ const spritesheet = typeof source.spritesheetPath === "string" && source.spritesheetPath.trim() !== "" ? source.spritesheetPath.trim() : "spritesheet.webp";
717
+ const spritesheetPath = spritesheet.split("/").filter((segment) => segment !== "");
718
+ if (spritesheetPath.length === 0 || isAbsolute(spritesheet) || spritesheet.includes("\\") || spritesheetPath.some((segment) => segment === ".." || !PATH_SEGMENT_PATTERN.test(segment))) {
719
+ warn("manifest spritesheetPath " + JSON.stringify(spritesheet) + " is not a safe relative path");
720
+ return;
721
+ }
722
+ const rawCell = typeof source.cell === "object" && source.cell !== null ? source.cell : {};
723
+ const cell = {
724
+ width: finiteInt(rawCell.width, DEFAULT_PET_CELL.width, 2048),
725
+ height: finiteInt(rawCell.height, DEFAULT_PET_CELL.height, 2048)
726
+ };
727
+ const columns = finiteInt(source.columns, 8, 32);
728
+ const rows = DEFAULT_FRAME_COUNTS.map((fallback, index) => {
729
+ return finiteInt(Array.isArray(source.frames) ? source.frames[index] : void 0, fallback, columns);
730
+ });
731
+ const trackOverrides = typeof source.tracks === "object" && source.tracks !== null ? source.tracks : {};
732
+ const tracks = {};
733
+ for (const [row, animation] of PET_ROW_ORDER.entries()) {
734
+ const pattern = DEFAULT_TRACK_PATTERNS[animation];
735
+ const override = trackOverrides[animation];
736
+ const durations = Array.isArray(override?.durations) && override.durations.length > 0 ? override.durations.filter((value) => typeof value === "number" && Number.isFinite(value) && value > 0) : pattern.durations;
737
+ if (durations.length === 0) {
738
+ warn("manifest " + id + ": track " + animation + " carries no usable durations");
739
+ return;
740
+ }
741
+ const frameCount = Math.max(1, Math.min(rows[row], columns));
742
+ const sized = durations.length >= frameCount ? durations.slice(0, frameCount) : Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]);
743
+ tracks[animation] = {
744
+ frames: Array.from({ length: frameCount }, (_, index) => index),
745
+ durations: sized,
746
+ loop: typeof override?.loop === "boolean" ? override.loop : pattern.loop,
747
+ ...override?.fallback === void 0 ? pattern.fallback === void 0 ? {} : { fallback: pattern.fallback } : PET_ROW_ORDER.includes(override.fallback) ? { fallback: override.fallback } : pattern.fallback === void 0 ? {} : { fallback: pattern.fallback }
748
+ };
749
+ }
750
+ return {
751
+ id,
752
+ displayName,
753
+ description,
754
+ cell,
755
+ columns,
756
+ rows,
757
+ tracks,
758
+ atlasUrl: assetUrl(assetPrefix, id, spritesheet),
759
+ manifestUrl: assetUrl(assetPrefix, id, "pet.json"),
760
+ dir,
761
+ spritesheetPath: spritesheetPath.join("/")
762
+ };
763
+ }
764
+ /** Scan one directory of pet folders; entries come back in name order. */
765
+ function scanPetDir(dir, options) {
766
+ if (!existsSync(dir)) return [];
767
+ let names = [];
768
+ try {
769
+ names = readdirSync(dir).filter((name) => !name.startsWith("."));
770
+ } catch {
771
+ return [];
772
+ }
773
+ names.sort();
774
+ const entries = [];
775
+ for (const name of names) {
776
+ const manifestFile = join(dir, name, "pet.json");
777
+ if (!existsSync(manifestFile)) continue;
778
+ const parsed = readPetJson(manifestFile, options.warnings);
779
+ if (parsed === void 0) continue;
780
+ const entry = resolvePetManifest(parsed, join(dir, name), options);
781
+ if (entry !== void 0) entries.push(entry);
782
+ }
783
+ return entries;
784
+ }
785
+ /** Read and parse one manifest file; undefined (warning recorded) on failure. */
786
+ function readPetJson(file, warnings) {
787
+ try {
788
+ return JSON.parse(readFileSync(file, "utf8"));
789
+ } catch (error) {
790
+ warnings?.push("skipping " + file + ": " + (error instanceof Error ? error.message : String(error)));
791
+ return;
792
+ }
793
+ }
794
+ /**
795
+ * Load the pet registry: built-in 'assets/*' first, then the hatch-pet
796
+ * custom pets directory, then composed 'extra' manifests (each later source
797
+ * overrides an earlier one on id collision). The registry never throws on a
798
+ * bad manifest: it skips it and records a warning.
799
+ */
800
+ function loadPetRegistry(options) {
801
+ const { packageRoot, assetPrefix = "/pet" } = options;
802
+ const warnings = [];
803
+ const byId = /* @__PURE__ */ new Map();
804
+ const builtinIds = /* @__PURE__ */ new Set();
805
+ for (const entry of scanPetDir(join(packageRoot, "assets"), {
806
+ assetPrefix,
807
+ warnings
808
+ })) {
809
+ if (byId.has(entry.id)) {
810
+ warnings.push("duplicate built-in pet id " + entry.id + "; the first one wins");
811
+ continue;
812
+ }
813
+ byId.set(entry.id, entry);
814
+ builtinIds.add(entry.id);
815
+ }
816
+ const petsDir = options.petsDir ?? codexPetsDir();
817
+ if (petsDir !== "") for (const entry of scanPetDir(petsDir, {
818
+ assetPrefix,
819
+ warnings
820
+ })) {
821
+ if (byId.has(entry.id)) warnings.push("custom pet " + entry.id + " overrides the built-in one");
822
+ byId.set(entry.id, entry);
823
+ }
824
+ for (const manifest of options.extra ?? []) {
825
+ const entry = resolvePetManifest(manifest, manifest.spritesheetPath === void 0 || isAbsolute(manifest.spritesheetPath) ? join(packageRoot, "assets", "extra") : dirname(resolve(packageRoot, manifest.spritesheetPath)), {
826
+ assetPrefix,
827
+ warnings
828
+ });
829
+ if (entry === void 0) continue;
830
+ if (byId.has(entry.id)) warnings.push("composed pet " + entry.id + " overrides an earlier registration");
831
+ byId.set(entry.id, entry);
832
+ }
833
+ const entries = [...byId.values()];
834
+ return {
835
+ entries,
836
+ warnings,
837
+ byId: (id) => byId.get(id),
838
+ defaultEntry: () => entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0]
839
+ };
840
+ }
841
+ /** Strip host-only fields, leaving the client-visible definition. */
842
+ function petEntryView(entry) {
843
+ return {
844
+ id: entry.id,
845
+ displayName: entry.displayName,
846
+ description: entry.description,
847
+ cell: entry.cell,
848
+ columns: entry.columns,
849
+ rows: entry.rows,
850
+ tracks: entry.tracks,
851
+ atlasUrl: entry.atlasUrl,
852
+ manifestUrl: entry.manifestUrl
853
+ };
854
+ }
476
855
  /**
477
856
  * Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
478
857
  * written until an economic event or interaction arrives; event listeners
@@ -483,6 +862,7 @@ var PetService = class extends Service {
483
862
  static inject = [];
484
863
  machine;
485
864
  ledger;
865
+ registry;
486
866
  persistDir;
487
867
  enabled;
488
868
  disposeActivity;
@@ -492,11 +872,21 @@ var PetService = class extends Service {
492
872
  constructor(ctx, config = {}) {
493
873
  super(ctx, "pet");
494
874
  this.persistDir = config.persistDir ?? petHomeDir();
875
+ this.registry = config.registry ?? loadPetRegistry({
876
+ packageRoot: petPackageRoot(import.meta.url),
877
+ ...config.pets === void 0 ? {} : { extra: config.pets }
878
+ });
879
+ if (this.registry.entries.length === 0) throw new Error("[dsh-pet] no valid pet manifests found; nothing to render");
880
+ let persist = loadPetPersist(this.persistDir);
881
+ if (this.registry.byId(persist.petId) === void 0) persist = {
882
+ ...persist,
883
+ petId: this.registry.defaultEntry().id
884
+ };
495
885
  const ledgerConfig = {
496
886
  affinity: config.affinity,
497
887
  treats: config.treats
498
888
  };
499
- this.ledger = new PetLedger(loadPetPersist(this.persistDir), ledgerConfig);
889
+ this.ledger = new PetLedger(persist, ledgerConfig);
500
890
  this.machine = new PetStateMachine({
501
891
  ...defaultPetStateConfig,
502
892
  ...config.state ?? {}
@@ -516,9 +906,42 @@ var PetService = class extends Service {
516
906
  display() {
517
907
  return { ...this.ledger.snapshot.display };
518
908
  }
519
- /** Current persisted pet name (read-only view). */
520
- petName() {
521
- return this.ledger.snapshot.name;
909
+ /** RPC: the registry entries the browser half renders and selects from. */
910
+ async pets() {
911
+ return this.registry.entries.map(petEntryView);
912
+ }
913
+ /** The loaded registry (the asset routes serve its entries). */
914
+ registrySnapshot() {
915
+ return this.registry;
916
+ }
917
+ /** The selected pet's registry entry. */
918
+ activeEntry() {
919
+ return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry();
920
+ }
921
+ /** Currently selected pet id (persisted). */
922
+ selectedPetId() {
923
+ return this.ledger.snapshot.petId;
924
+ }
925
+ /** The display name of one pet (user rename or manifest displayName). */
926
+ petName(petId = this.selectedPetId()) {
927
+ const stored = this.ledger.snapshot.names[petId];
928
+ if (stored !== void 0 && stored.trim() !== "") return stored;
929
+ return this.registry.byId(petId)?.displayName ?? "鲸鱼娘";
930
+ }
931
+ /** RPC: switch the selected pet (persisted, settings document mirrored). */
932
+ async setPetId(petId) {
933
+ const entry = this.registry.byId(petId);
934
+ if (entry === void 0) return {
935
+ ok: false,
936
+ error: "unknown-pet"
937
+ };
938
+ this.ledger.setPetId(entry.id);
939
+ this.flush();
940
+ this.syncSettingsFromPet();
941
+ return {
942
+ ok: true,
943
+ petId: entry.id
944
+ };
522
945
  }
523
946
  /** Start or stop the session-activity listeners that drive the pet. */
524
947
  setEnabled(enabled) {
@@ -612,7 +1035,7 @@ var PetService = class extends Service {
612
1035
  display: this.ledger.snapshot.display
613
1036
  };
614
1037
  }
615
- /** RPC: rename the pet (trimmed, 1–20 chars). */
1038
+ /** RPC: rename the selected pet (trimmed, 1–20 chars, per-pet storage). */
616
1039
  async setName(name) {
617
1040
  const trimmed = name.trim();
618
1041
  if (trimmed === "") return {
@@ -623,28 +1046,28 @@ var PetService = class extends Service {
623
1046
  ok: false,
624
1047
  error: "name-too-long"
625
1048
  };
626
- this.ledger.setName(trimmed);
1049
+ this.ledger.setPetName(this.selectedPetId(), trimmed);
627
1050
  this.flush();
628
- this.syncSettingsFromPet();
629
1051
  return {
630
1052
  ok: true,
631
1053
  name: trimmed
632
1054
  };
633
1055
  }
634
1056
  /**
635
- * Apply a committed settings section to the persisted display config. Called
636
- * by the settings surface on every change; values are clamped exactly like
637
- * the setConfig RPC so both write paths converge.
1057
+ * Apply a committed settings section to the persisted selection and display
1058
+ * config. Called by the settings surface on every change; values are
1059
+ * clamped exactly like the setConfig RPC so both write paths converge.
638
1060
  * @param section - the resolved settings section.
639
1061
  */
640
1062
  applySettingsSection(section) {
1063
+ if (typeof section.petId === "string" && this.registry.byId(section.petId) !== void 0) this.ledger.setPetId(section.petId);
1064
+ else if (section.petId !== void 0) this.syncSettingsFromPet();
641
1065
  const next = { ...this.ledger.snapshot.display };
642
1066
  next.visible = section.visible && (section.enabled ?? true);
643
1067
  next.size = Math.round(Math.min(512, Math.max(32, section.size)));
644
1068
  next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)));
645
1069
  next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.bottom)));
646
1070
  this.ledger.setDisplay(next);
647
- this.ledger.setName(section.name.trim());
648
1071
  this.flush();
649
1072
  }
650
1073
  /** Mirror the persisted display config into the settings document (best-effort). */
@@ -657,7 +1080,7 @@ var PetService = class extends Service {
657
1080
  size: snapshot.display.size,
658
1081
  right: snapshot.display.right,
659
1082
  bottom: snapshot.display.bottom,
660
- name: snapshot.name
1083
+ petId: snapshot.petId
661
1084
  }).catch(() => {});
662
1085
  }
663
1086
  /** Award the turn reward once per completed turn (idempotent per session + turn). */
@@ -670,6 +1093,7 @@ var PetService = class extends Service {
670
1093
  }
671
1094
  view() {
672
1095
  const snapshot = this.machine.render();
1096
+ const entry = this.activeEntry();
673
1097
  return {
674
1098
  animation: snapshot.animation,
675
1099
  ...snapshot.bubble === void 0 ? {} : { bubble: snapshot.bubble },
@@ -677,7 +1101,12 @@ var PetService = class extends Service {
677
1101
  sessionActive: snapshot.sessionActive,
678
1102
  affinity: this.ledger.affinityView(Date.now()),
679
1103
  display: { ...this.ledger.snapshot.display },
680
- name: this.ledger.snapshot.name,
1104
+ pet: {
1105
+ id: entry.id,
1106
+ displayName: entry.displayName,
1107
+ description: entry.description
1108
+ },
1109
+ name: this.petName(),
681
1110
  treats: {
682
1111
  stocked: this.ledger.snapshot.treats.treats,
683
1112
  max: this.ledger.treatMax
@@ -692,21 +1121,36 @@ var PetService = class extends Service {
692
1121
  };
693
1122
  //#endregion
694
1123
  //#region src/routes.ts
1124
+ /**
1125
+ * Pet HTTP routes — the browser half talks to the host through plain
1126
+ * same-origin JSON endpoints ('/api/pet/*') and loads pet assets from
1127
+ * '/pet/<id>/*'. The '/plugins/' endpoint only serves client bundles and RPC
1128
+ * domains are platform-registered, so the pet serves its own API and media —
1129
+ * the same pattern as dsh-remote-web-ui's '/api/pair' family. The asset route
1130
+ * is one prefix registration serving every registry entry (manifest, atlas,
1131
+ * optional previews), so adding a pet never touches route wiring.
1132
+ * @module @linxin666/dsh-pet/routes
1133
+ */
695
1134
  /** Browser-facing base path of the pet API. */
696
1135
  const PET_API_PREFIX = "/api/pet";
697
- /** Browser-facing base path of the pet asset routes. */
698
- const PET_ASSET_PREFIX = "/pet/whale";
699
- /** Relative (to package root) asset files exposed under the prefix. */
700
- const ASSET_FILES = [{
701
- name: "spritesheet.webp",
702
- mime: "image/webp"
703
- }, {
704
- name: "pet.json",
705
- mime: "application/json"
706
- }];
707
- /** Absolute package root, resolved from this module's own location (lib/). */
708
- function petPackageRoot(importMetaUrl) {
709
- return fileURLToPath(new URL("../", importMetaUrl));
1136
+ /** Browser-facing base path of the pet asset routes ('/pet/<id>/...'). */
1137
+ const PET_ASSET_PREFIX = "/pet";
1138
+ const MANIFEST_FILE = "pet.json";
1139
+ const PREVIEW_DIR = "previews";
1140
+ const PREVIEW_PATTERN = /^[A-Za-z0-9._-]+$/;
1141
+ const MIME_BY_EXT = {
1142
+ ".webp": "image/webp",
1143
+ ".png": "image/png",
1144
+ ".gif": "image/gif",
1145
+ ".jpg": "image/jpeg",
1146
+ ".jpeg": "image/jpeg",
1147
+ ".json": "application/json"
1148
+ };
1149
+ /** Content type by file extension (safe fallback: octet-stream). */
1150
+ function mimeFor(file) {
1151
+ const dot = file.lastIndexOf(".");
1152
+ if (dot < 0) return "application/octet-stream";
1153
+ return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? "application/octet-stream";
710
1154
  }
711
1155
  /** Write one JSON response. */
712
1156
  function json(res, status, body) {
@@ -789,60 +1233,157 @@ function postRoute(path, run) {
789
1233
  }
790
1234
  };
791
1235
  }
792
- /** Build the full route family (API + assets) for one service + package root. */
1236
+ /** Legacy URL aliases: each entry's directory basename (e.g. 'whale'). */
1237
+ function dirAliases(registry) {
1238
+ const aliases = /* @__PURE__ */ new Map();
1239
+ for (const entry of registry.entries) {
1240
+ const alias = entry.dir.split(/[\\/]/).pop() ?? "";
1241
+ if (alias !== "" && !aliases.has(alias)) aliases.set(alias, entry);
1242
+ }
1243
+ return aliases;
1244
+ }
1245
+ /**
1246
+ * The one asset handler behind the '/pet' prefix. Resolves the pet by id (or
1247
+ * legacy directory alias), then serves exactly the files a manifest declares:
1248
+ * pet.json, the declared spritesheet path, and optional 'previews/<name>'
1249
+ * media. Composed pets without a manifest file get a synthesized pet.json.
1250
+ */
1251
+ function assetHandler(registry) {
1252
+ const aliases = dirAliases(registry);
1253
+ return (req, res) => {
1254
+ if (req.method !== "GET" && req.method !== "HEAD") {
1255
+ res.writeHead(405);
1256
+ res.end();
1257
+ return;
1258
+ }
1259
+ let pathname;
1260
+ try {
1261
+ pathname = new URL(req.url ?? "/", "http://pet.local").pathname;
1262
+ } catch {
1263
+ res.writeHead(400);
1264
+ res.end();
1265
+ return;
1266
+ }
1267
+ const segments = pathname.split("/").filter((segment) => segment !== "");
1268
+ if (segments[0] !== "pet" || segments[1] === void 0) {
1269
+ res.writeHead(404);
1270
+ res.end();
1271
+ return;
1272
+ }
1273
+ let id;
1274
+ try {
1275
+ id = decodeURIComponent(segments[1]);
1276
+ } catch {
1277
+ res.writeHead(400);
1278
+ res.end();
1279
+ return;
1280
+ }
1281
+ const entry = registry.byId(id) ?? aliases.get(id);
1282
+ if (entry === void 0) {
1283
+ res.writeHead(404);
1284
+ res.end();
1285
+ return;
1286
+ }
1287
+ const rest = [];
1288
+ for (const segment of segments.slice(2)) {
1289
+ let decoded;
1290
+ try {
1291
+ decoded = decodeURIComponent(segment);
1292
+ } catch {
1293
+ res.writeHead(400);
1294
+ res.end();
1295
+ return;
1296
+ }
1297
+ rest.push(decoded);
1298
+ }
1299
+ const rel = rest.join("/");
1300
+ let file;
1301
+ let synthesized = false;
1302
+ if (rest.length === 1 && rest[0] === MANIFEST_FILE) {
1303
+ const manifestFile = join(entry.dir, MANIFEST_FILE);
1304
+ file = existsSync(manifestFile) ? manifestFile : void 0;
1305
+ if (file === void 0) synthesized = true;
1306
+ } else if (rest.length > 0 && rel === entry.spritesheetPath) file = join(entry.dir, entry.spritesheetPath);
1307
+ else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1])) {
1308
+ const preview = join(entry.dir, PREVIEW_DIR, rest[1]);
1309
+ file = existsSync(preview) ? preview : void 0;
1310
+ }
1311
+ if (synthesized) {
1312
+ const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), "utf8");
1313
+ res.writeHead(200, {
1314
+ "content-type": "application/json; charset=utf-8",
1315
+ "content-length": String(body.byteLength),
1316
+ "cache-control": "no-cache"
1317
+ });
1318
+ if (req.method === "HEAD") {
1319
+ res.end();
1320
+ return;
1321
+ }
1322
+ res.end(body);
1323
+ return;
1324
+ }
1325
+ if (file === void 0) {
1326
+ res.writeHead(404);
1327
+ res.end();
1328
+ return;
1329
+ }
1330
+ const resolved = file;
1331
+ readFile(resolved).then((body) => {
1332
+ res.writeHead(200, {
1333
+ "content-type": mimeFor(resolved),
1334
+ "content-length": String(body.byteLength),
1335
+ "cache-control": "no-cache"
1336
+ });
1337
+ if (req.method === "HEAD") {
1338
+ res.end();
1339
+ return;
1340
+ }
1341
+ res.end(body);
1342
+ }, () => {
1343
+ res.writeHead(404);
1344
+ res.end();
1345
+ });
1346
+ };
1347
+ }
1348
+ /** Build the full route family (API + assets) for one service. */
793
1349
  function makePetRoutes(deps) {
794
- const { service, packageRoot } = deps;
1350
+ const { service } = deps;
795
1351
  const apiRoutes = [
796
- getRoute(`${PET_API_PREFIX}/state`, () => service.state()),
797
- postRoute(`${PET_API_PREFIX}/interact`, (body) => {
1352
+ getRoute("/api/pet/state", () => service.state()),
1353
+ getRoute("/api/pet/pets", () => service.pets()),
1354
+ postRoute("/api/pet/interact", (body) => {
798
1355
  const kind = body.kind;
799
1356
  if (kind !== "pet" && kind !== "feed") return Promise.reject(/* @__PURE__ */ new Error("invalid-kind"));
800
1357
  return service.interact(kind);
801
1358
  }),
802
- postRoute(`${PET_API_PREFIX}/set-visible`, (body) => {
1359
+ postRoute("/api/pet/set-visible", (body) => {
803
1360
  const visible = body.visible;
804
1361
  if (typeof visible !== "boolean") return Promise.reject(/* @__PURE__ */ new Error("invalid-visible"));
805
1362
  return service.setVisible(visible);
806
1363
  }),
807
- postRoute(`${PET_API_PREFIX}/set-config`, (body) => service.setConfig({
1364
+ postRoute("/api/pet/set-config", (body) => service.setConfig({
808
1365
  ...typeof body.size === "number" ? { size: body.size } : {},
809
1366
  ...typeof body.right === "number" ? { right: body.right } : {},
810
1367
  ...typeof body.bottom === "number" ? { bottom: body.bottom } : {},
811
1368
  ...typeof body.visible === "boolean" ? { visible: body.visible } : {}
812
1369
  })),
813
- postRoute(`${PET_API_PREFIX}/set-name`, (body) => {
1370
+ postRoute("/api/pet/set-name", (body) => {
814
1371
  const name = body.name;
815
1372
  if (typeof name !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-name"));
816
1373
  return service.setName(name);
1374
+ }),
1375
+ postRoute("/api/pet/set-pet", (body) => {
1376
+ const petId = body.petId;
1377
+ if (typeof petId !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-pet"));
1378
+ return service.setPetId(petId);
817
1379
  })
818
1380
  ];
819
- const assetRoutes = ASSET_FILES.map((file) => ({
820
- kind: "exact",
821
- path: `${PET_ASSET_PREFIX}/${file.name}`,
822
- handler: (req, res) => {
823
- if (req.method !== "GET" && req.method !== "HEAD") {
824
- res.writeHead(405);
825
- res.end();
826
- return;
827
- }
828
- return readFile(join(packageRoot, "assets", "whale", file.name)).then((body) => {
829
- res.writeHead(200, {
830
- "content-type": file.mime,
831
- "content-length": String(body.byteLength),
832
- "cache-control": "no-cache"
833
- });
834
- if (req.method === "HEAD") {
835
- res.end();
836
- return;
837
- }
838
- res.end(body);
839
- }, () => {
840
- res.writeHead(404);
841
- res.end();
842
- });
843
- }
844
- }));
845
- return [...apiRoutes, ...assetRoutes];
1381
+ const assetRoute = {
1382
+ kind: "prefix",
1383
+ path: PET_ASSET_PREFIX,
1384
+ handler: assetHandler(service.registrySnapshot())
1385
+ };
1386
+ return [...apiRoutes, assetRoute];
846
1387
  }
847
1388
  //#endregion
848
1389
  //#region src/index.ts
@@ -850,31 +1391,44 @@ function makePetRoutes(deps) {
850
1391
  const name = "pet";
851
1392
  /** Services required before the pet can mount its surfaces. */
852
1393
  const inject = ["webServer"];
853
- /** Settings section schema: the display fields and name the web settings surface edits. */
854
- const PET_SETTINGS_SCHEMA = z.object({
855
- visible: z.boolean().default(true),
856
- size: z.number().step(1).min(32).max(512).default(160),
857
- right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
858
- bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
859
- name: z.string().min(1).max(20).pattern(/\S/).default(DEFAULT_PET_NAME),
860
- enabled: z.boolean().default(true)
861
- });
1394
+ /**
1395
+ * Settings section schema: pet selection and display fields the web settings
1396
+ * surface edits. petId is a plain string on purpose: the service clamps the
1397
+ * resolved value against the registry, so a stored selection that points at
1398
+ * a removed pet cannot invalidate the section (a strict union would refuse
1399
+ * the whole registration). The settings card renders the actual registry
1400
+ * choices itself from '/api/pet/pets'.
1401
+ */
1402
+ function makePetSettingsSchema(fallbackPetId) {
1403
+ return z.object({
1404
+ visible: z.boolean().default(true),
1405
+ size: z.number().step(1).min(32).max(512).default(160),
1406
+ right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
1407
+ bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
1408
+ petId: z.string().default(fallbackPetId),
1409
+ enabled: z.boolean().default(true)
1410
+ });
1411
+ }
862
1412
  /** Register the pet service and its API + asset routes on the context. */
863
1413
  function apply(ctx, config = {}) {
864
- const service = new PetService(ctx, config);
1414
+ const registry = config.registry ?? loadPetRegistry({
1415
+ packageRoot: petPackageRoot(import.meta.url),
1416
+ ...config.pets === void 0 ? {} : { extra: config.pets }
1417
+ });
1418
+ const service = new PetService(ctx, {
1419
+ ...config,
1420
+ registry
1421
+ });
865
1422
  let current = () => base;
866
1423
  const base = {
867
1424
  visible: service.display().visible,
868
1425
  size: service.display().size,
869
1426
  right: service.display().right,
870
1427
  bottom: service.display().bottom,
871
- name: service.petName(),
1428
+ petId: service.selectedPetId(),
872
1429
  enabled: config.enabled ?? true
873
1430
  };
874
- const routes = makePetRoutes({
875
- service,
876
- packageRoot: petPackageRoot(import.meta.url)
877
- });
1431
+ const routes = makePetRoutes({ service });
878
1432
  let disposeRoutes;
879
1433
  const syncRoutes = () => {
880
1434
  const enabled = current().enabled ?? true;
@@ -889,7 +1443,7 @@ function apply(ctx, config = {}) {
889
1443
  disposeRoutes = void 0;
890
1444
  }
891
1445
  };
892
- installSettingsSection(ctx, settingsNamespace("pet"), PET_SETTINGS_SCHEMA, base, {
1446
+ installSettingsSection(ctx, settingsNamespace("pet"), makePetSettingsSchema(service.selectedPetId()), base, {
893
1447
  setSource: (source) => {
894
1448
  current = source;
895
1449
  },
@@ -903,4 +1457,4 @@ function apply(ctx, config = {}) {
903
1457
  syncRoutes();
904
1458
  }
905
1459
  //#endregion
906
- export { AFFINITY_MAX, AFFINITY_RANKS, PET_API_PREFIX, PET_ASSET_PREFIX, PET_SETTINGS_SCHEMA, PetService, PetStateMachine, animationForPhase, apply, applyInteraction, applyTurnReward, consumeTreat, defaultDisplayConfig, defaultTreatConfig, emptyAffinity, emptyPersist, emptyTreatLedger, inject, loadPetPersist, makePetRoutes, name, petHomeDir, petPackageRoot, rankOf, rowOf, savePetPersist, settleTreatGrants };
1460
+ export { AFFINITY_MAX, AFFINITY_RANKS, DEFAULT_FRAME_COUNTS, DEFAULT_PET_CELL, DEFAULT_PET_COLUMNS, DEFAULT_PET_ID, DEFAULT_PET_NAME, DEFAULT_PET_ROW_COUNT, DEFAULT_TRACK_PATTERNS, PET_API_PREFIX, PET_ASSET_PREFIX, PET_NAME_MAX_LENGTH, PET_ROW_ORDER, PetService, PetStateMachine, animationForPhase, apply, applyInteraction, applyTurnReward, codexPetsDir, consumeTreat, defaultDisplayConfig, defaultTreatConfig, emptyAffinity, emptyPersist, emptyTreatLedger, inject, loadPetPersist, loadPetRegistry, makePetRoutes, makePetSettingsSchema, name, petEntryView, petHomeDir, petPackageRoot, rankOf, resolvePetManifest, rowOf, savePetPersist, settleTreatGrants };