@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/index.js CHANGED
@@ -1,12 +1,132 @@
1
- import { a as AFFINITY_MAX, c as applyTurnReward, d as rankOf, i as rowOf, l as defaultAffinityConfig, n as animationForPhase, o as AFFINITY_RANKS, r as defaultPetStateConfig, s as applyInteraction, t as PetStateMachine, u as emptyAffinity } from "./state-P-wsVJ6O.js";
1
+ import { a as AFFINITY_MAX, c as applyInteraction, d as emptyAffinity, f as rankOf, i as rowOf, l as applyTurnReward, n as animationForPhase, o as AFFINITY_RANKS, r as defaultPetStateConfig, s as affinityViewOf, t as PetStateMachine, u as defaultAffinityConfig } from "./state-CFyJv0sQ.js";
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 { 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
+ //#region src/event-projection.ts
11
+ /** Fresh projection runtime for a newly seen session. */
12
+ function emptyProjectionRuntime() {
13
+ return {
14
+ activeTools: /* @__PURE__ */ new Set(),
15
+ officialEventsSeen: false,
16
+ stepHadFailure: false
17
+ };
18
+ }
19
+ /** Keep tool names readable inside the compact status bubble. */
20
+ function displayToolName(name) {
21
+ const compact = name.replace(/\s+/g, " ").trim() || "工具";
22
+ return compact.length <= 24 ? compact : `${compact.slice(0, 21)}...`;
23
+ }
24
+ /** Whether a legacy phase is part of the pet's supported vocabulary. */
25
+ function isActivityPhase(phase) {
26
+ return [
27
+ "idle",
28
+ "waiting",
29
+ "thinking",
30
+ "tool",
31
+ "review",
32
+ "done",
33
+ "failed"
34
+ ].includes(phase);
35
+ }
36
+ /**
37
+ * Project the durable DSH session vocabulary into the pet's visual phases.
38
+ * Unknown and log-only events do not disturb the last meaningful activity.
39
+ */
40
+ function projectOfficialEvent(event, runtime) {
41
+ switch (event.type) {
42
+ case "turn/start":
43
+ runtime.activeTools.clear();
44
+ runtime.stepHadFailure = false;
45
+ return { input: {
46
+ phase: "waiting",
47
+ line: "准备开始"
48
+ } };
49
+ case "step/start":
50
+ runtime.activeTools.clear();
51
+ runtime.stepHadFailure = false;
52
+ return { input: {
53
+ phase: "waiting",
54
+ line: "等待模型响应"
55
+ } };
56
+ case "assistant/chunk": {
57
+ const { chunk } = event.data;
58
+ if (chunk.type === "reasoning-delta" && chunk.text.length > 0) return { input: {
59
+ phase: "thinking",
60
+ line: "正在思考"
61
+ } };
62
+ if (chunk.type === "text-delta" && chunk.text.length > 0) return { input: {
63
+ phase: "review",
64
+ line: "整理回复中"
65
+ } };
66
+ return;
67
+ }
68
+ case "assistant/message": return { input: {
69
+ phase: "review",
70
+ line: "整理回复中"
71
+ } };
72
+ case "tool/call":
73
+ runtime.activeTools.add(String(event.data.callId));
74
+ return { input: {
75
+ phase: "tool",
76
+ line: `正在使用 ${displayToolName(event.data.name)}`
77
+ } };
78
+ case "tool/result": {
79
+ const block = event.data.message.content[0];
80
+ runtime.activeTools.delete(String(event.data.message.source.callId));
81
+ runtime.stepHadFailure ||= event.data.error !== void 0 || block.isError === true;
82
+ if (runtime.activeTools.size > 0) return { input: {
83
+ phase: "tool",
84
+ line: `还有 ${runtime.activeTools.size} 个工具运行中`
85
+ } };
86
+ return runtime.stepHadFailure ? { input: {
87
+ phase: "failed",
88
+ line: "工具执行失败"
89
+ } } : { input: {
90
+ phase: "thinking",
91
+ line: "处理工具结果"
92
+ } };
93
+ }
94
+ case "turn/end":
95
+ runtime.activeTools.clear();
96
+ switch (event.data.reason.kind) {
97
+ case "completed": return {
98
+ input: {
99
+ phase: "done",
100
+ line: "完成啦"
101
+ },
102
+ completedTurn: event.data.turn
103
+ };
104
+ case "error": return { input: {
105
+ phase: "failed",
106
+ line: "执行失败"
107
+ } };
108
+ case "max-tokens": return { input: {
109
+ phase: "failed",
110
+ line: "达到输出上限"
111
+ } };
112
+ case "interrupted": return { input: {
113
+ phase: "failed",
114
+ line: "执行意外中断"
115
+ } };
116
+ case "blocked": return { input: {
117
+ phase: "waiting",
118
+ line: "等待继续"
119
+ } };
120
+ case "aborted": return { input: {
121
+ phase: "idle",
122
+ line: "已停止"
123
+ } };
124
+ default: return { input: { phase: "idle" } };
125
+ }
126
+ default: return;
127
+ }
128
+ }
129
+ //#endregion
10
130
  //#region src/treats.ts
11
131
  const defaultTreatConfig = {
12
132
  turnsPerTreat: 3,
@@ -80,6 +200,205 @@ function consumeTreat(ledger) {
80
200
  };
81
201
  }
82
202
  //#endregion
203
+ //#region src/ledger.ts
204
+ /**
205
+ * Pet affinity economy (ledger) — composes the pure affinity and treats
206
+ * modules with the cooldown/dedup bookkeeping and emits updated persistence
207
+ * snapshots, marking dirty so the owning facade decides when to flush. Read
208
+ * paths (view) no longer settle the economy; settlements happen on explicit
209
+ * economic events: completed-turn rewards (official or legacy) and feeds.
210
+ * @module @linxin666/dsh-pet/ledger
211
+ */
212
+ /**
213
+ * Holds the current persistence snapshot and all economy bookkeeping. Every
214
+ * mutating call flags takeDirty so the facade persists exactly once per
215
+ * batch of changes; read methods (snapshot, affinityView) never write.
216
+ */
217
+ var PetLedger = class {
218
+ affinityConfig;
219
+ treatConfig;
220
+ current;
221
+ /** Completed turns already rewarded, per session (turn numbers are per-session). */
222
+ rewardedTurns = /* @__PURE__ */ new Map();
223
+ lastLegacyTurnRewardAt = 0;
224
+ dirty = false;
225
+ constructor(persist, config = {}) {
226
+ this.affinityConfig = {
227
+ ...defaultAffinityConfig,
228
+ ...config.affinity ?? {}
229
+ };
230
+ this.treatConfig = {
231
+ ...defaultTreatConfig,
232
+ ...config.treats ?? {}
233
+ };
234
+ this.current = persist;
235
+ }
236
+ /** Affinity cooldown/rank tuning (read-only). */
237
+ get affinity() {
238
+ return this.affinityConfig;
239
+ }
240
+ /** The current persistence snapshot (trade a copy when mutating). */
241
+ get snapshot() {
242
+ return this.current;
243
+ }
244
+ /** Stock cap reported to clients. */
245
+ get treatMax() {
246
+ return this.treatConfig.maxTreats;
247
+ }
248
+ /** Consume the pending-write flag if any mutation occurred. */
249
+ takeDirty() {
250
+ const was = this.dirty;
251
+ this.dirty = false;
252
+ return was;
253
+ }
254
+ /** Replace the display block (clamping stays a caller concern). */
255
+ setDisplay(display) {
256
+ this.current = {
257
+ ...this.current,
258
+ display
259
+ };
260
+ this.dirty = true;
261
+ }
262
+ /** Replace the selected pet id (validation stays a caller concern). */
263
+ setPetId(petId) {
264
+ if (this.current.petId === petId) return;
265
+ this.current = {
266
+ ...this.current,
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
+ }
279
+ };
280
+ this.dirty = true;
281
+ }
282
+ /**
283
+ * Settle the treat economy (work + time output since the last settlement).
284
+ * A zero-gain first settlement still starts the time clock (anchor write),
285
+ * which is how the 30-minute time output can ever accrue. Returns true when
286
+ * the in-memory ledger changed and should be persisted.
287
+ */
288
+ settleTreats(nowMs) {
289
+ const settlement = settleTreatGrants(this.current.treats, this.current.affinity.turns, nowMs, this.treatConfig);
290
+ if (settlement.ledger === this.current.treats) return false;
291
+ this.current = {
292
+ ...this.current,
293
+ treats: settlement.ledger
294
+ };
295
+ this.dirty = true;
296
+ return true;
297
+ }
298
+ /**
299
+ * Award the completed-turn reward once per session+turn (idempotent) and
300
+ * run the treat settlement that work output feeds. Returns true when the
301
+ * snapshot changed.
302
+ */
303
+ rewardTurn(sessionId, turn, nowMs) {
304
+ if (turn <= (this.rewardedTurns.get(sessionId) ?? 0)) return false;
305
+ this.rewardedTurns.set(sessionId, turn);
306
+ let changed = this.applyTurnReward();
307
+ if (this.settleTreats(nowMs)) changed = true;
308
+ return changed;
309
+ }
310
+ /** Preserve turn rewards for installations that only emit legacy activity. */
311
+ rewardLegacyTurn(nowMs) {
312
+ if (nowMs - this.lastLegacyTurnRewardAt < 5e3) return false;
313
+ this.lastLegacyTurnRewardAt = nowMs;
314
+ let changed = this.applyTurnReward();
315
+ if (this.settleTreats(nowMs)) changed = true;
316
+ return changed;
317
+ }
318
+ applyTurnReward() {
319
+ this.current = {
320
+ ...this.current,
321
+ affinity: applyTurnReward(this.current.affinity, this.affinityConfig)
322
+ };
323
+ this.dirty = true;
324
+ return true;
325
+ }
326
+ /**
327
+ * Pet or feed the pet. Feeding settles first, then gates on the feed
328
+ * cooldown before spending stock — a feed inside the cooldown must not burn
329
+ * a treat for nothing.
330
+ */
331
+ interact(kind, nowMs) {
332
+ if (kind === "feed") this.settleTreats(nowMs);
333
+ const outcome = applyInteraction(this.current.affinity, kind, nowMs, this.affinityConfig);
334
+ if (kind === "feed" && !outcome.accepted) return {
335
+ reaction: outcome.reaction,
336
+ delta: 0,
337
+ affinity: this.affinityView(nowMs)
338
+ };
339
+ if (kind === "feed") {
340
+ const consume = consumeTreat(this.current.treats);
341
+ if (!consume.ok) return {
342
+ reaction: "没有小鱼干了,多陪我工作一会儿吧~",
343
+ delta: 0,
344
+ affinity: this.affinityView(nowMs)
345
+ };
346
+ this.current = {
347
+ ...this.current,
348
+ treats: consume.ledger
349
+ };
350
+ this.dirty = true;
351
+ }
352
+ if (outcome.accepted) {
353
+ this.current = {
354
+ ...this.current,
355
+ affinity: outcome.affinity
356
+ };
357
+ this.dirty = true;
358
+ }
359
+ return {
360
+ reaction: outcome.reaction,
361
+ delta: outcome.delta,
362
+ affinity: this.affinityView(nowMs)
363
+ };
364
+ }
365
+ /** Current affinity view for the RPC snapshot. */
366
+ affinityView(nowMs) {
367
+ return affinityViewOf(this.current.affinity, nowMs, this.affinityConfig);
368
+ }
369
+ };
370
+ //#endregion
371
+ //#region src/dsh-home.ts
372
+ /**
373
+ * DSH_HOME resolution shared by the plugin family's Host halves: the
374
+ * environment override wins, the platform home fallback follows. Mirrors
375
+ * what dsh-pet and dsh-liangshen each used to implement locally.
376
+ */
377
+ /** Expand a leading ~ (or ~user) in a path, platform-style. */
378
+ function expandHome(path, home = homedir()) {
379
+ if (path === "~") return home;
380
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
381
+ return path;
382
+ }
383
+ /**
384
+ * Resolve the DSH home directory.
385
+ * @param env - process environment to read DSH_HOME from.
386
+ * @param home - platform home directory fallback (test seam).
387
+ * @returns the absolute DSH home path.
388
+ */
389
+ function resolveDshHome(env = process.env, home = homedir()) {
390
+ const raw = env.DSH_HOME;
391
+ if (raw !== void 0 && raw.trim() !== "") {
392
+ const expanded = expandHome(raw.trim(), home);
393
+ return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded);
394
+ }
395
+ return join(home, ".dsh");
396
+ }
397
+ /** Resolve the DSH home directory from the live environment. */
398
+ function dshHome() {
399
+ return resolveDshHome();
400
+ }
401
+ //#endregion
83
402
  //#region src/persist.ts
84
403
  /**
85
404
  * Pet persistence — tiny JSON store for affinity + display config, written
@@ -94,24 +413,45 @@ const defaultDisplayConfig = {
94
413
  bottom: 20
95
414
  };
96
415
  const DISPLAY_INSET_MAX = 1e4;
97
- /** 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). */
98
419
  const DEFAULT_PET_NAME = "鲸鱼娘";
420
+ /** Name constraints. */
421
+ const PET_NAME_MAX_LENGTH = 20;
99
422
  function emptyPersist() {
100
423
  return {
101
- name: DEFAULT_PET_NAME,
424
+ petId: DEFAULT_PET_ID,
425
+ names: {},
102
426
  affinity: emptyAffinity(),
103
427
  treats: emptyTreatLedger(),
104
428
  display: { ...defaultDisplayConfig }
105
429
  };
106
430
  }
107
- /** Resolve the persistence directory ($DSH_HOME or ~/.dsh). */
431
+ /**
432
+ * Resolve the persistence directory ($DSH_HOME or ~/.dsh). Delegates to the
433
+ * shared {@link dshHome} resolution so the plugin family keeps one DSH_HOME
434
+ * definition (env override, ~ expansion, cwd-joined relative values).
435
+ */
108
436
  function petHomeDir() {
109
- return process.env.DSH_HOME ?? join(homedir(), ".dsh");
437
+ return dshHome();
110
438
  }
111
439
  /** Numeric field guard: finite numbers only, else the fallback. */
112
440
  function finiteNum(value, fallback) {
113
441
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
114
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
+ }
115
455
  /** Clamp one count/score into [0, max]. */
116
456
  function clamp(value, max) {
117
457
  return Math.min(max, Math.max(0, value));
@@ -144,8 +484,12 @@ function loadPetPersist(dir = petHomeDir()) {
144
484
  right: Math.round(clamp(finiteNum(rawDisplay.right, base.display.right), DISPLAY_INSET_MAX)),
145
485
  bottom: Math.round(clamp(finiteNum(rawDisplay.bottom, base.display.bottom), DISPLAY_INSET_MAX))
146
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);
147
490
  return {
148
- name: typeof parsed.name === "string" && parsed.name.trim() !== "" ? parsed.name : base.name,
491
+ petId,
492
+ names,
149
493
  affinity,
150
494
  treats,
151
495
  display
@@ -162,153 +506,391 @@ function savePetPersist(data, dir = petHomeDir()) {
162
506
  writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
163
507
  renameSync(tmp, target);
164
508
  }
165
- /** Keep tool names readable inside the compact status bubble. */
166
- function displayToolName(name) {
167
- const compact = name.replace(/\s+/g, " ").trim() || "工具";
168
- return compact.length <= 24 ? compact : `${compact.slice(0, 21)}...`;
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));
169
568
  }
170
- /** Whether a legacy phase is part of the pet's supported vocabulary. */
171
- function isActivityPhase(phase) {
172
- return [
173
- "idle",
174
- "waiting",
175
- "thinking",
176
- "tool",
177
- "review",
178
- "done",
179
- "failed"
180
- ].includes(phase);
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;
181
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;
182
695
  /**
183
- * Project the durable DSH session vocabulary into the pet's visual phases.
184
- * Unknown and log-only events do not disturb the last meaningful activity.
696
+ * Normalize one parsed manifest into a renderable pet entry, or undefined
697
+ * (with a warning recorded) when the manifest violates the contract.
185
698
  */
186
- function projectOfficialEvent(event, runtime) {
187
- switch (event.type) {
188
- case "turn/start":
189
- runtime.activeTools.clear();
190
- runtime.stepHadFailure = false;
191
- return { input: {
192
- phase: "waiting",
193
- line: "准备开始"
194
- } };
195
- case "step/start":
196
- runtime.activeTools.clear();
197
- runtime.stepHadFailure = false;
198
- return { input: {
199
- phase: "waiting",
200
- line: "等待模型响应"
201
- } };
202
- case "assistant/chunk": {
203
- const { chunk } = event.data;
204
- if (chunk.type === "reasoning-delta" && chunk.text.length > 0) return { input: {
205
- phase: "thinking",
206
- line: "正在思考"
207
- } };
208
- if (chunk.type === "text-delta" && chunk.text.length > 0) return { input: {
209
- phase: "review",
210
- line: "整理回复中"
211
- } };
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");
212
739
  return;
213
740
  }
214
- case "assistant/message": return { input: {
215
- phase: "review",
216
- line: "整理回复中"
217
- } };
218
- case "tool/call":
219
- runtime.activeTools.add(String(event.data.callId));
220
- return { input: {
221
- phase: "tool",
222
- line: `正在使用 ${displayToolName(event.data.name)}`
223
- } };
224
- case "tool/result": {
225
- const block = event.data.message.content[0];
226
- runtime.activeTools.delete(String(event.data.message.source.callId));
227
- runtime.stepHadFailure ||= event.data.error !== void 0 || block.isError === true;
228
- if (runtime.activeTools.size > 0) return { input: {
229
- phase: "tool",
230
- line: `还有 ${runtime.activeTools.size} 个工具运行中`
231
- } };
232
- return runtime.stepHadFailure ? { input: {
233
- phase: "failed",
234
- line: "工具执行失败"
235
- } } : { input: {
236
- phase: "thinking",
237
- line: "处理工具结果"
238
- } };
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;
239
812
  }
240
- case "turn/end":
241
- runtime.activeTools.clear();
242
- switch (event.data.reason.kind) {
243
- case "completed": return {
244
- input: {
245
- phase: "done",
246
- line: "完成啦"
247
- },
248
- completedTurn: event.data.turn
249
- };
250
- case "error": return { input: {
251
- phase: "failed",
252
- line: "执行失败"
253
- } };
254
- case "max-tokens": return { input: {
255
- phase: "failed",
256
- line: "达到输出上限"
257
- } };
258
- case "interrupted": return { input: {
259
- phase: "failed",
260
- line: "执行意外中断"
261
- } };
262
- case "blocked": return { input: {
263
- phase: "waiting",
264
- line: "等待继续"
265
- } };
266
- case "aborted": return { input: {
267
- phase: "idle",
268
- line: "已停止"
269
- } };
270
- default: return { input: { phase: "idle" } };
271
- }
272
- default: return;
813
+ byId.set(entry.id, entry);
814
+ builtinIds.add(entry.id);
273
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
+ };
274
854
  }
275
855
  /**
276
856
  * Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
277
- * written until a query or interaction arrives; event listeners update only
278
- * in-memory state, and persistence happens on interaction/config changes
279
- * plus every completed turn.
857
+ * written until an economic event or interaction arrives; event listeners
858
+ * update only in-memory state, and persistence happens on economic changes
859
+ * (turn rewards, feeds, config/name changes) — never on a read.
280
860
  */
281
861
  var PetService = class extends Service {
282
862
  static inject = [];
283
863
  machine;
284
- affinityConfig;
285
- treatConfig;
864
+ ledger;
865
+ registry;
286
866
  persistDir;
287
- persist;
288
- /** Completed turns already rewarded, per session (turn numbers are per-session). */
289
- rewardedTurns = /* @__PURE__ */ new Map();
290
867
  enabled;
291
868
  disposeActivity;
292
869
  /** Session whose most recent meaningful event currently drives the global pet. */
293
870
  displaySession;
294
871
  sessionActivity = /* @__PURE__ */ new WeakMap();
295
- lastLegacyTurnRewardAt = 0;
296
872
  constructor(ctx, config = {}) {
297
873
  super(ctx, "pet");
298
874
  this.persistDir = config.persistDir ?? petHomeDir();
299
- this.affinityConfig = {
300
- ...defaultAffinityConfig,
301
- ...config.affinity ?? {}
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
302
884
  };
303
- this.treatConfig = {
304
- ...defaultTreatConfig,
305
- ...config.treats ?? {}
885
+ const ledgerConfig = {
886
+ affinity: config.affinity,
887
+ treats: config.treats
306
888
  };
889
+ this.ledger = new PetLedger(persist, ledgerConfig);
307
890
  this.machine = new PetStateMachine({
308
891
  ...defaultPetStateConfig,
309
892
  ...config.state ?? {}
310
893
  });
311
- this.persist = loadPetPersist(this.persistDir);
312
894
  this.enabled = config.enabled ?? true;
313
895
  this.syncActivity();
314
896
  }
@@ -322,11 +904,44 @@ var PetService = class extends Service {
322
904
  }
323
905
  /** Current persisted display config (read-only view). */
324
906
  display() {
325
- return { ...this.persist.display };
907
+ return { ...this.ledger.snapshot.display };
908
+ }
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();
326
920
  }
327
- /** Current persisted pet name (read-only view). */
328
- petName() {
329
- return this.persist.name;
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
+ };
330
945
  }
331
946
  /** Start or stop the session-activity listeners that drive the pet. */
332
947
  setEnabled(enabled) {
@@ -372,11 +987,7 @@ var PetService = class extends Service {
372
987
  activityRuntime(session) {
373
988
  let runtime = this.sessionActivity.get(session);
374
989
  if (runtime === void 0) {
375
- runtime = {
376
- activeTools: /* @__PURE__ */ new Set(),
377
- officialEventsSeen: false,
378
- stepHadFailure: false
379
- };
990
+ runtime = emptyProjectionRuntime();
380
991
  this.sessionActivity.set(session, runtime);
381
992
  }
382
993
  return runtime;
@@ -390,76 +1001,41 @@ var PetService = class extends Service {
390
1001
  /** RPC: pet or feed the pet. */
391
1002
  async interact(kind) {
392
1003
  const nowMs = Date.now();
393
- if (kind === "feed") this.settleTreats(nowMs);
394
- const outcome = applyInteraction(this.persist.affinity, kind, nowMs, this.affinityConfig);
395
- if (kind === "feed" && !outcome.accepted) return {
396
- reaction: outcome.reaction,
397
- delta: 0,
398
- affinity: this.affinityView(this.persist.affinity)
399
- };
400
- if (kind === "feed") {
401
- const consume = consumeTreat(this.persist.treats);
402
- if (!consume.ok) return {
403
- reaction: "没有小鱼干了,多陪鲸鱼娘工作一会儿吧~",
404
- delta: 0,
405
- affinity: this.affinityView(this.persist.affinity)
406
- };
407
- this.persist = {
408
- ...this.persist,
409
- treats: consume.ledger
410
- };
411
- }
412
- if (outcome.accepted) {
413
- this.persist = {
414
- ...this.persist,
415
- affinity: outcome.affinity
416
- };
417
- this.flush();
418
- }
419
- const affinity = this.affinityView(outcome.affinity);
420
- return {
421
- reaction: outcome.reaction,
422
- delta: outcome.delta,
423
- affinity
424
- };
1004
+ const result = this.ledger.interact(kind, nowMs);
1005
+ if (this.ledger.takeDirty()) this.flush();
1006
+ return result;
425
1007
  }
426
1008
  /** RPC: show or hide the pet. */
427
1009
  async setVisible(visible) {
428
- this.persist = {
429
- ...this.persist,
430
- display: {
431
- ...this.persist.display,
432
- visible
433
- }
434
- };
1010
+ this.ledger.setDisplay({
1011
+ ...this.ledger.snapshot.display,
1012
+ visible
1013
+ });
435
1014
  this.flush();
436
1015
  this.syncSettingsFromPet();
437
1016
  return {
438
1017
  ok: true,
439
- display: this.persist.display
1018
+ display: this.ledger.snapshot.display
440
1019
  };
441
1020
  }
442
1021
  /** RPC: update display config (size / position). Values are clamped to whole pixels. */
443
1022
  async setConfig(patch) {
444
1023
  const next = {
445
- ...this.persist.display,
1024
+ ...this.ledger.snapshot.display,
446
1025
  ...patch
447
1026
  };
448
1027
  next.size = Math.round(Math.min(512, Math.max(32, next.size)));
449
1028
  next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.right)));
450
1029
  next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.bottom)));
451
- this.persist = {
452
- ...this.persist,
453
- display: next
454
- };
1030
+ this.ledger.setDisplay(next);
455
1031
  this.flush();
456
1032
  this.syncSettingsFromPet();
457
1033
  return {
458
1034
  ok: true,
459
- display: this.persist.display
1035
+ display: this.ledger.snapshot.display
460
1036
  };
461
1037
  }
462
- /** RPC: rename the pet (trimmed, 1–20 chars). */
1038
+ /** RPC: rename the selected pet (trimmed, 1–20 chars, per-pet storage). */
463
1039
  async setName(name) {
464
1040
  const trimmed = name.trim();
465
1041
  if (trimmed === "") return {
@@ -470,139 +1046,111 @@ var PetService = class extends Service {
470
1046
  ok: false,
471
1047
  error: "name-too-long"
472
1048
  };
473
- this.persist = {
474
- ...this.persist,
475
- name: trimmed
476
- };
1049
+ this.ledger.setPetName(this.selectedPetId(), trimmed);
477
1050
  this.flush();
478
- this.syncSettingsFromPet();
479
1051
  return {
480
1052
  ok: true,
481
1053
  name: trimmed
482
1054
  };
483
1055
  }
484
1056
  /**
485
- * Apply a committed settings section to the persisted display config. Called
486
- * by the settings surface on every change; values are clamped exactly like
487
- * 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.
488
1060
  * @param section - the resolved settings section.
489
1061
  */
490
1062
  applySettingsSection(section) {
491
- const next = { ...this.persist.display };
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();
1065
+ const next = { ...this.ledger.snapshot.display };
492
1066
  next.visible = section.visible && (section.enabled ?? true);
493
1067
  next.size = Math.round(Math.min(512, Math.max(32, section.size)));
494
1068
  next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)));
495
1069
  next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.bottom)));
496
- this.persist = {
497
- ...this.persist,
498
- display: next,
499
- name: section.name.trim()
500
- };
1070
+ this.ledger.setDisplay(next);
501
1071
  this.flush();
502
1072
  }
503
1073
  /** Mirror the persisted display config into the settings document (best-effort). */
504
1074
  syncSettingsFromPet() {
505
1075
  const settings = this.ctx.get("settings", false);
506
1076
  if (settings === void 0) return;
1077
+ const snapshot = this.ledger.snapshot;
507
1078
  settings.update("pet", {
508
- visible: this.persist.display.visible,
509
- size: this.persist.display.size,
510
- right: this.persist.display.right,
511
- bottom: this.persist.display.bottom,
512
- name: this.persist.name
1079
+ visible: snapshot.display.visible,
1080
+ size: snapshot.display.size,
1081
+ right: snapshot.display.right,
1082
+ bottom: snapshot.display.bottom,
1083
+ petId: snapshot.petId
513
1084
  }).catch(() => {});
514
1085
  }
515
1086
  /** Award the turn reward once per completed turn (idempotent per session + turn). */
516
1087
  rewardTurn(sessionId, turn) {
517
- if (turn <= (this.rewardedTurns.get(sessionId) ?? 0)) return;
518
- this.rewardedTurns.set(sessionId, turn);
519
- this.applyTurnReward();
1088
+ if (this.ledger.rewardTurn(sessionId, turn, Date.now())) this.flush();
520
1089
  }
521
1090
  /** Preserve turn rewards for installations that only emit legacy activity. */
522
1091
  rewardLegacyTurn() {
523
- const nowMs = Date.now();
524
- if (nowMs - this.lastLegacyTurnRewardAt < 5e3) return;
525
- this.lastLegacyTurnRewardAt = nowMs;
526
- this.applyTurnReward();
527
- }
528
- /** Persist one accepted completed-turn reward. */
529
- applyTurnReward() {
530
- this.persist = {
531
- ...this.persist,
532
- affinity: applyTurnReward(this.persist.affinity, this.affinityConfig)
533
- };
534
- this.flush();
535
- }
536
- /**
537
- * Settle the treat economy (work + time output since the last settlement)
538
- * and persist whenever the ledger changed. A zero-gain first settlement
539
- * still starts the time clock (anchor write), which is what lets the
540
- * 30-minute time output ever accrue.
541
- */
542
- settleTreats(nowMs) {
543
- const settlement = settleTreatGrants(this.persist.treats, this.persist.affinity.turns, nowMs, this.treatConfig);
544
- if (settlement.ledger !== this.persist.treats) {
545
- this.persist = {
546
- ...this.persist,
547
- treats: settlement.ledger
548
- };
549
- this.flush();
550
- }
1092
+ if (this.ledger.rewardLegacyTurn(Date.now())) this.flush();
551
1093
  }
552
1094
  view() {
553
1095
  const snapshot = this.machine.render();
554
- this.settleTreats(Date.now());
1096
+ const entry = this.activeEntry();
555
1097
  return {
556
1098
  animation: snapshot.animation,
557
1099
  ...snapshot.bubble === void 0 ? {} : { bubble: snapshot.bubble },
558
1100
  phase: snapshot.phase,
559
1101
  sessionActive: snapshot.sessionActive,
560
- affinity: this.affinityView(this.persist.affinity),
561
- display: { ...this.persist.display },
562
- name: this.persist.name,
1102
+ affinity: this.ledger.affinityView(Date.now()),
1103
+ display: { ...this.ledger.snapshot.display },
1104
+ pet: {
1105
+ id: entry.id,
1106
+ displayName: entry.displayName,
1107
+ description: entry.description
1108
+ },
1109
+ name: this.petName(),
563
1110
  treats: {
564
- stocked: this.persist.treats.treats,
565
- max: this.treatConfig.maxTreats
1111
+ stocked: this.ledger.snapshot.treats.treats,
1112
+ max: this.ledger.treatMax
566
1113
  }
567
1114
  };
568
1115
  }
569
- affinityView(affinity) {
570
- const nowMs = Date.now();
571
- const rank = rankOf(affinity.points);
572
- return {
573
- points: affinity.points,
574
- rank: rank.name,
575
- rankEmoji: rank.emoji,
576
- pets: affinity.pets,
577
- feeds: affinity.feeds,
578
- turns: affinity.turns,
579
- petCooldown: nowMs - affinity.lastPetAt < this.affinityConfig.petCooldownMs,
580
- feedCooldown: nowMs - affinity.lastFeedAt < this.affinityConfig.feedCooldownMs
581
- };
582
- }
583
1116
  flush() {
584
1117
  try {
585
- savePetPersist(this.persist, this.persistDir);
1118
+ savePetPersist(this.ledger.snapshot, this.persistDir);
586
1119
  } catch {}
587
1120
  }
588
1121
  };
589
1122
  //#endregion
590
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
+ */
591
1134
  /** Browser-facing base path of the pet API. */
592
1135
  const PET_API_PREFIX = "/api/pet";
593
- /** Browser-facing base path of the pet asset routes. */
594
- const PET_ASSET_PREFIX = "/pet/whale";
595
- /** Relative (to package root) asset files exposed under the prefix. */
596
- const ASSET_FILES = [{
597
- name: "spritesheet.webp",
598
- mime: "image/webp"
599
- }, {
600
- name: "pet.json",
601
- mime: "application/json"
602
- }];
603
- /** Absolute package root, resolved from this module's own location (lib/). */
604
- function petPackageRoot(importMetaUrl) {
605
- 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";
606
1154
  }
607
1155
  /** Write one JSON response. */
608
1156
  function json(res, status, body) {
@@ -685,60 +1233,157 @@ function postRoute(path, run) {
685
1233
  }
686
1234
  };
687
1235
  }
688
- /** 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. */
689
1349
  function makePetRoutes(deps) {
690
- const { service, packageRoot } = deps;
1350
+ const { service } = deps;
691
1351
  const apiRoutes = [
692
- getRoute(`${PET_API_PREFIX}/state`, () => service.state()),
693
- 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) => {
694
1355
  const kind = body.kind;
695
1356
  if (kind !== "pet" && kind !== "feed") return Promise.reject(/* @__PURE__ */ new Error("invalid-kind"));
696
1357
  return service.interact(kind);
697
1358
  }),
698
- postRoute(`${PET_API_PREFIX}/set-visible`, (body) => {
1359
+ postRoute("/api/pet/set-visible", (body) => {
699
1360
  const visible = body.visible;
700
1361
  if (typeof visible !== "boolean") return Promise.reject(/* @__PURE__ */ new Error("invalid-visible"));
701
1362
  return service.setVisible(visible);
702
1363
  }),
703
- postRoute(`${PET_API_PREFIX}/set-config`, (body) => service.setConfig({
1364
+ postRoute("/api/pet/set-config", (body) => service.setConfig({
704
1365
  ...typeof body.size === "number" ? { size: body.size } : {},
705
1366
  ...typeof body.right === "number" ? { right: body.right } : {},
706
1367
  ...typeof body.bottom === "number" ? { bottom: body.bottom } : {},
707
1368
  ...typeof body.visible === "boolean" ? { visible: body.visible } : {}
708
1369
  })),
709
- postRoute(`${PET_API_PREFIX}/set-name`, (body) => {
1370
+ postRoute("/api/pet/set-name", (body) => {
710
1371
  const name = body.name;
711
1372
  if (typeof name !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-name"));
712
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);
713
1379
  })
714
1380
  ];
715
- const assetRoutes = ASSET_FILES.map((file) => ({
716
- kind: "exact",
717
- path: `${PET_ASSET_PREFIX}/${file.name}`,
718
- handler: (req, res) => {
719
- if (req.method !== "GET" && req.method !== "HEAD") {
720
- res.writeHead(405);
721
- res.end();
722
- return;
723
- }
724
- return readFile(join(packageRoot, "assets", "whale", file.name)).then((body) => {
725
- res.writeHead(200, {
726
- "content-type": file.mime,
727
- "content-length": String(body.byteLength),
728
- "cache-control": "no-cache"
729
- });
730
- if (req.method === "HEAD") {
731
- res.end();
732
- return;
733
- }
734
- res.end(body);
735
- }, () => {
736
- res.writeHead(404);
737
- res.end();
738
- });
739
- }
740
- }));
741
- return [...apiRoutes, ...assetRoutes];
1381
+ const assetRoute = {
1382
+ kind: "prefix",
1383
+ path: PET_ASSET_PREFIX,
1384
+ handler: assetHandler(service.registrySnapshot())
1385
+ };
1386
+ return [...apiRoutes, assetRoute];
742
1387
  }
743
1388
  //#endregion
744
1389
  //#region src/index.ts
@@ -746,31 +1391,44 @@ function makePetRoutes(deps) {
746
1391
  const name = "pet";
747
1392
  /** Services required before the pet can mount its surfaces. */
748
1393
  const inject = ["webServer"];
749
- /** Settings section schema: the display fields and name the web settings surface edits. */
750
- const PET_SETTINGS_SCHEMA = z.object({
751
- visible: z.boolean().default(true),
752
- size: z.number().step(1).min(32).max(512).default(160),
753
- right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
754
- bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
755
- name: z.string().min(1).max(20).pattern(/\S/).default(DEFAULT_PET_NAME),
756
- enabled: z.boolean().default(true)
757
- });
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
+ }
758
1412
  /** Register the pet service and its API + asset routes on the context. */
759
1413
  function apply(ctx, config = {}) {
760
- 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
+ });
761
1422
  let current = () => base;
762
1423
  const base = {
763
1424
  visible: service.display().visible,
764
1425
  size: service.display().size,
765
1426
  right: service.display().right,
766
1427
  bottom: service.display().bottom,
767
- name: service.petName(),
1428
+ petId: service.selectedPetId(),
768
1429
  enabled: config.enabled ?? true
769
1430
  };
770
- const routes = makePetRoutes({
771
- service,
772
- packageRoot: petPackageRoot(import.meta.url)
773
- });
1431
+ const routes = makePetRoutes({ service });
774
1432
  let disposeRoutes;
775
1433
  const syncRoutes = () => {
776
1434
  const enabled = current().enabled ?? true;
@@ -785,7 +1443,7 @@ function apply(ctx, config = {}) {
785
1443
  disposeRoutes = void 0;
786
1444
  }
787
1445
  };
788
- installSettingsSection(ctx, settingsNamespace("pet"), PET_SETTINGS_SCHEMA, base, {
1446
+ installSettingsSection(ctx, settingsNamespace("pet"), makePetSettingsSchema(service.selectedPetId()), base, {
789
1447
  setSource: (source) => {
790
1448
  current = source;
791
1449
  },
@@ -799,4 +1457,4 @@ function apply(ctx, config = {}) {
799
1457
  syncRoutes();
800
1458
  }
801
1459
  //#endregion
802
- 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 };