@linxin666/dsh-pet 0.1.1

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 (79) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +106 -0
  3. package/assets/whale/pet.json +7 -0
  4. package/assets/whale/previews/failed.gif +0 -0
  5. package/assets/whale/previews/idle.gif +0 -0
  6. package/assets/whale/previews/jumping.gif +0 -0
  7. package/assets/whale/previews/review.gif +0 -0
  8. package/assets/whale/previews/running-left.gif +0 -0
  9. package/assets/whale/previews/running-right.gif +0 -0
  10. package/assets/whale/previews/running.gif +0 -0
  11. package/assets/whale/previews/waiting.gif +0 -0
  12. package/assets/whale/previews/waving.gif +0 -0
  13. package/assets/whale/spritesheet.webp +0 -0
  14. package/cordis.patch.yml +10 -0
  15. package/lib/client.js +1515 -0
  16. package/lib/index.js +642 -0
  17. package/lib/invariant.js +34 -0
  18. package/lib/types/affinity.d.ts +83 -0
  19. package/lib/types/affinity.d.ts.map +1 -0
  20. package/lib/types/client/PetDockEntry.d.ts +44 -0
  21. package/lib/types/client/PetDockEntry.d.ts.map +1 -0
  22. package/lib/types/client/PetSettingsCard.d.ts +67 -0
  23. package/lib/types/client/PetSettingsCard.d.ts.map +1 -0
  24. package/lib/types/client/PluginSettingsCard.d.ts +78 -0
  25. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  26. package/lib/types/client/WhalePet.d.ts +49 -0
  27. package/lib/types/client/WhalePet.d.ts.map +1 -0
  28. package/lib/types/client/index.d.ts +46 -0
  29. package/lib/types/client/index.d.ts.map +1 -0
  30. package/lib/types/client/locales.d.ts +101 -0
  31. package/lib/types/client/locales.d.ts.map +1 -0
  32. package/lib/types/client/pet-store.d.ts +49 -0
  33. package/lib/types/client/pet-store.d.ts.map +1 -0
  34. package/lib/types/client/settings-form.d.ts +119 -0
  35. package/lib/types/client/settings-form.d.ts.map +1 -0
  36. package/lib/types/client/slots-augment.d.ts +19 -0
  37. package/lib/types/client/slots-augment.d.ts.map +1 -0
  38. package/lib/types/client/spritesheet.d.ts +69 -0
  39. package/lib/types/client/spritesheet.d.ts.map +1 -0
  40. package/lib/types/index.d.ts +44 -0
  41. package/lib/types/index.d.ts.map +1 -0
  42. package/lib/types/invariant.d.ts +10 -0
  43. package/lib/types/invariant.d.ts.map +1 -0
  44. package/lib/types/persist.d.ts +45 -0
  45. package/lib/types/persist.d.ts.map +1 -0
  46. package/lib/types/routes.d.ts +22 -0
  47. package/lib/types/routes.d.ts.map +1 -0
  48. package/lib/types/service.d.ts +161 -0
  49. package/lib/types/service.d.ts.map +1 -0
  50. package/lib/types/state.d.ts +80 -0
  51. package/lib/types/state.d.ts.map +1 -0
  52. package/lib/types/treats.d.ts +59 -0
  53. package/lib/types/treats.d.ts.map +1 -0
  54. package/package.json +100 -0
  55. package/src/affinity.test.ts +74 -0
  56. package/src/affinity.ts +144 -0
  57. package/src/client/PetDockEntry.tsx +95 -0
  58. package/src/client/PetSettingsCard.tsx +186 -0
  59. package/src/client/PluginSettingsCard.tsx +207 -0
  60. package/src/client/WhalePet.tsx +342 -0
  61. package/src/client/css-modules.d.ts +6 -0
  62. package/src/client/index.ts +261 -0
  63. package/src/client/locales.ts +108 -0
  64. package/src/client/pet-store.ts +80 -0
  65. package/src/client/pet.module.css +185 -0
  66. package/src/client/settings-card.module.css +277 -0
  67. package/src/client/settings-form.ts +294 -0
  68. package/src/client/slots-augment.ts +27 -0
  69. package/src/client/spritesheet.ts +138 -0
  70. package/src/index.ts +148 -0
  71. package/src/invariant.ts +40 -0
  72. package/src/persist.test.ts +96 -0
  73. package/src/persist.ts +128 -0
  74. package/src/routes.ts +171 -0
  75. package/src/service.ts +389 -0
  76. package/src/state.test.ts +65 -0
  77. package/src/state.ts +156 -0
  78. package/src/treats.test.ts +86 -0
  79. package/src/treats.ts +101 -0
package/lib/index.js ADDED
@@ -0,0 +1,642 @@
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-IyVnKymD.js";
2
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
3
+ import z from "schemastery";
4
+ import { Service } from "@deepseek-ai/cordis";
5
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import { readFile } from "node:fs/promises";
9
+ import { fileURLToPath } from "node:url";
10
+ //#region src/treats.ts
11
+ const defaultTreatConfig = {
12
+ turnsPerTreat: 3,
13
+ timeTreatMs: 30 * 6e4,
14
+ maxTreats: 20
15
+ };
16
+ function emptyTreatLedger() {
17
+ return {
18
+ treats: 0,
19
+ lastTreatGrantAt: 0,
20
+ turnsAtLastTreatGrant: 0
21
+ };
22
+ }
23
+ function cap(treats, max) {
24
+ return Math.min(max, Math.max(0, treats));
25
+ }
26
+ /**
27
+ * Settle treat grants from both sources against one ledger snapshot.
28
+ * Work output counts whole periods since the last work settlement
29
+ * (turnsDelta / turnsPerTreat) and advances only the work anchor;
30
+ * time output counts whole periods since the time anchor
31
+ * (`lastTreatGrantAt`) and advances only the time anchor. The two sources
32
+ * are independent so a continuously working user still earns time treats.
33
+ * 0 time history never backfills — the clock starts at the first settlement.
34
+ * Both sources are clamped by the stock cap.
35
+ */
36
+ function settleTreatGrants(ledger, turns, nowMs, config = defaultTreatConfig) {
37
+ const turnDelta = Math.max(0, turns - ledger.turnsAtLastTreatGrant);
38
+ const workGrants = Math.floor(turnDelta / config.turnsPerTreat);
39
+ const timeAnchor = ledger.lastTreatGrantAt === 0 ? nowMs : ledger.lastTreatGrantAt;
40
+ const timeGrants = Math.floor(Math.max(0, nowMs - timeAnchor) / config.timeTreatMs);
41
+ const gained = workGrants + timeGrants;
42
+ if (gained <= 0) return {
43
+ ledger,
44
+ gained: 0
45
+ };
46
+ return {
47
+ ledger: {
48
+ treats: cap(ledger.treats + gained, config.maxTreats),
49
+ lastTreatGrantAt: timeGrants > 0 ? timeAnchor + timeGrants * config.timeTreatMs : timeAnchor,
50
+ turnsAtLastTreatGrant: workGrants > 0 ? turns - turnDelta % config.turnsPerTreat : ledger.turnsAtLastTreatGrant
51
+ },
52
+ gained
53
+ };
54
+ }
55
+ /**
56
+ * Consume one treat for a feed. Returns the outcome; a feed with no stocked
57
+ * treats is refused.
58
+ */
59
+ function consumeTreat(ledger) {
60
+ if (ledger.treats <= 0) return { ok: false };
61
+ return {
62
+ ok: true,
63
+ ledger: {
64
+ ...ledger,
65
+ treats: ledger.treats - 1
66
+ }
67
+ };
68
+ }
69
+ //#endregion
70
+ //#region src/persist.ts
71
+ /**
72
+ * Pet persistence — tiny JSON store for affinity + display config, written
73
+ * under $DSH_HOME (defaults to ~/.dsh) as `pet.json`. Deliberately minimal:
74
+ * one file, atomic rename write, tolerant read (corrupt file → defaults).
75
+ * @module @linxin666/dsh-pet/persist
76
+ */
77
+ const defaultDisplayConfig = {
78
+ visible: true,
79
+ size: 160,
80
+ right: 24,
81
+ bottom: 20
82
+ };
83
+ const DISPLAY_INSET_MAX = 1e4;
84
+ /** Default pet name (used until the user renames the pet). */
85
+ const DEFAULT_PET_NAME = "鲸鱼娘";
86
+ function emptyPersist() {
87
+ return {
88
+ name: DEFAULT_PET_NAME,
89
+ affinity: emptyAffinity(),
90
+ treats: emptyTreatLedger(),
91
+ display: { ...defaultDisplayConfig }
92
+ };
93
+ }
94
+ /** Resolve the persistence directory ($DSH_HOME or ~/.dsh). */
95
+ function petHomeDir() {
96
+ return process.env.DSH_HOME ?? join(homedir(), ".dsh");
97
+ }
98
+ /** Numeric field guard: finite numbers only, else the fallback. */
99
+ function finiteNum(value, fallback) {
100
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
101
+ }
102
+ /** Clamp one count/score into [0, max]. */
103
+ function clamp(value, max) {
104
+ return Math.min(max, Math.max(0, value));
105
+ }
106
+ /** Load persisted state; missing or corrupt files fall back to defaults. */
107
+ function loadPetPersist(dir = petHomeDir()) {
108
+ try {
109
+ const raw = readFileSync(join(dir, "pet.json"), "utf8");
110
+ const parsed = JSON.parse(raw);
111
+ const base = emptyPersist();
112
+ const rawAffinity = parsed.affinity ?? {};
113
+ const affinity = {
114
+ points: clamp(finiteNum(rawAffinity.points, 0), 100),
115
+ lastPetAt: clamp(finiteNum(rawAffinity.lastPetAt, 0), Number.MAX_SAFE_INTEGER),
116
+ lastFeedAt: clamp(finiteNum(rawAffinity.lastFeedAt, 0), Number.MAX_SAFE_INTEGER),
117
+ pets: clamp(finiteNum(rawAffinity.pets, 0), Number.MAX_SAFE_INTEGER),
118
+ feeds: clamp(finiteNum(rawAffinity.feeds, 0), Number.MAX_SAFE_INTEGER),
119
+ turns: clamp(finiteNum(rawAffinity.turns, 0), Number.MAX_SAFE_INTEGER)
120
+ };
121
+ const rawTreats = parsed.treats ?? {};
122
+ const treats = {
123
+ treats: clamp(finiteNum(rawTreats.treats, 0), defaultTreatConfig.maxTreats),
124
+ lastTreatGrantAt: clamp(finiteNum(rawTreats.lastTreatGrantAt, 0), Number.MAX_SAFE_INTEGER),
125
+ turnsAtLastTreatGrant: clamp(finiteNum(rawTreats.turnsAtLastTreatGrant, 0), Number.MAX_SAFE_INTEGER)
126
+ };
127
+ const rawDisplay = parsed.display ?? {};
128
+ const display = {
129
+ visible: typeof rawDisplay.visible === "boolean" ? rawDisplay.visible : base.display.visible,
130
+ size: Math.round(Math.min(512, Math.max(32, finiteNum(rawDisplay.size, base.display.size)))),
131
+ right: Math.round(clamp(finiteNum(rawDisplay.right, base.display.right), DISPLAY_INSET_MAX)),
132
+ bottom: Math.round(clamp(finiteNum(rawDisplay.bottom, base.display.bottom), DISPLAY_INSET_MAX))
133
+ };
134
+ return {
135
+ name: typeof parsed.name === "string" && parsed.name.trim() !== "" ? parsed.name : base.name,
136
+ affinity,
137
+ treats,
138
+ display
139
+ };
140
+ } catch {
141
+ return emptyPersist();
142
+ }
143
+ }
144
+ /** Atomically persist state (write temp + rename). */
145
+ function savePetPersist(data, dir = petHomeDir()) {
146
+ mkdirSync(dir, { recursive: true });
147
+ const target = join(dir, "pet.json");
148
+ const tmp = `${target}.tmp`;
149
+ writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
150
+ renameSync(tmp, target);
151
+ }
152
+ /**
153
+ * Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
154
+ * written until a query or interaction arrives; event listeners update only
155
+ * in-memory state, and persistence happens on interaction/config changes
156
+ * plus every completed turn.
157
+ */
158
+ var PetService = class extends Service {
159
+ static inject = [];
160
+ machine;
161
+ affinityConfig;
162
+ treatConfig;
163
+ persistDir;
164
+ persist;
165
+ lastTurnRewardAt = 0;
166
+ enabled;
167
+ disposeActivity;
168
+ constructor(ctx, config = {}) {
169
+ super(ctx, "pet");
170
+ this.persistDir = config.persistDir ?? petHomeDir();
171
+ this.affinityConfig = {
172
+ ...defaultAffinityConfig,
173
+ ...config.affinity ?? {}
174
+ };
175
+ this.treatConfig = {
176
+ ...defaultTreatConfig,
177
+ ...config.treats ?? {}
178
+ };
179
+ this.machine = new PetStateMachine({
180
+ ...defaultPetStateConfig,
181
+ ...config.state ?? {}
182
+ });
183
+ this.persist = loadPetPersist(this.persistDir);
184
+ this.enabled = config.enabled ?? true;
185
+ this.syncActivity();
186
+ }
187
+ /** Whether the pet service consumes session activity while enabled. */
188
+ isEnabled() {
189
+ return this.enabled;
190
+ }
191
+ /** RPC: current pet state snapshot. */
192
+ async state() {
193
+ return this.view();
194
+ }
195
+ /** Current persisted display config (read-only view). */
196
+ display() {
197
+ return { ...this.persist.display };
198
+ }
199
+ /** Current persisted pet name (read-only view). */
200
+ petName() {
201
+ return this.persist.name;
202
+ }
203
+ /** Start or stop the session-activity listeners that drive the pet. */
204
+ setEnabled(enabled) {
205
+ this.enabled = enabled;
206
+ this.syncActivity();
207
+ }
208
+ syncActivity() {
209
+ if (this.disposeActivity !== void 0) {
210
+ this.disposeActivity();
211
+ this.disposeActivity = void 0;
212
+ }
213
+ if (!this.enabled) return;
214
+ this.disposeActivity = (() => {
215
+ const disposers = [this.ctx.on("session/event", (_session, event) => {
216
+ if (event.type !== "activity/status") return;
217
+ const payload = event.data ?? {};
218
+ if (payload.phase === void 0) return;
219
+ const phase = payload.phase;
220
+ if (![
221
+ "idle",
222
+ "waiting",
223
+ "thinking",
224
+ "tool",
225
+ "done"
226
+ ].includes(phase)) return;
227
+ this.machine.onActivityStatus({
228
+ phase,
229
+ ...typeof payload.line === "string" ? { line: payload.line } : {},
230
+ ...typeof payload.phrase === "string" ? { phrase: payload.phrase } : {}
231
+ });
232
+ this.machine.onSessionActive();
233
+ if (phase === "done") this.rewardTurn();
234
+ }), this.ctx.on("session/disposed", () => {
235
+ this.machine.onSessionDisposed();
236
+ })];
237
+ return () => {
238
+ for (const dispose of disposers) dispose();
239
+ };
240
+ })();
241
+ }
242
+ /** RPC: pet or feed the pet. */
243
+ async interact(kind) {
244
+ const nowMs = Date.now();
245
+ if (kind === "feed") this.settleTreats(nowMs);
246
+ const outcome = applyInteraction(this.persist.affinity, kind, nowMs, this.affinityConfig);
247
+ if (kind === "feed" && !outcome.accepted) return {
248
+ reaction: outcome.reaction,
249
+ delta: 0,
250
+ affinity: this.affinityView(this.persist.affinity)
251
+ };
252
+ if (kind === "feed") {
253
+ const consume = consumeTreat(this.persist.treats);
254
+ if (!consume.ok) return {
255
+ reaction: "没有小鱼干了,多陪鲸鱼娘工作一会儿吧~",
256
+ delta: 0,
257
+ affinity: this.affinityView(this.persist.affinity)
258
+ };
259
+ this.persist = {
260
+ ...this.persist,
261
+ treats: consume.ledger
262
+ };
263
+ }
264
+ if (outcome.accepted) {
265
+ this.persist = {
266
+ ...this.persist,
267
+ affinity: outcome.affinity
268
+ };
269
+ this.flush();
270
+ }
271
+ const affinity = this.affinityView(outcome.affinity);
272
+ return {
273
+ reaction: outcome.reaction,
274
+ delta: outcome.delta,
275
+ affinity
276
+ };
277
+ }
278
+ /** RPC: show or hide the pet. */
279
+ async setVisible(visible) {
280
+ this.persist = {
281
+ ...this.persist,
282
+ display: {
283
+ ...this.persist.display,
284
+ visible
285
+ }
286
+ };
287
+ this.flush();
288
+ this.syncSettingsFromPet();
289
+ return {
290
+ ok: true,
291
+ display: this.persist.display
292
+ };
293
+ }
294
+ /** RPC: update display config (size / position). Values are clamped to whole pixels. */
295
+ async setConfig(patch) {
296
+ const next = {
297
+ ...this.persist.display,
298
+ ...patch
299
+ };
300
+ next.size = Math.round(Math.min(512, Math.max(32, next.size)));
301
+ next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.right)));
302
+ next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.bottom)));
303
+ this.persist = {
304
+ ...this.persist,
305
+ display: next
306
+ };
307
+ this.flush();
308
+ this.syncSettingsFromPet();
309
+ return {
310
+ ok: true,
311
+ display: this.persist.display
312
+ };
313
+ }
314
+ /** RPC: rename the pet (trimmed, 1–20 chars). */
315
+ async setName(name) {
316
+ const trimmed = name.trim();
317
+ if (trimmed === "") return {
318
+ ok: false,
319
+ error: "name-empty"
320
+ };
321
+ if (trimmed.length > 20) return {
322
+ ok: false,
323
+ error: "name-too-long"
324
+ };
325
+ this.persist = {
326
+ ...this.persist,
327
+ name: trimmed
328
+ };
329
+ this.flush();
330
+ this.syncSettingsFromPet();
331
+ return {
332
+ ok: true,
333
+ name: trimmed
334
+ };
335
+ }
336
+ /**
337
+ * Apply a committed settings section to the persisted display config. Called
338
+ * by the settings surface on every change; values are clamped exactly like
339
+ * the setConfig RPC so both write paths converge.
340
+ * @param section - the resolved settings section.
341
+ */
342
+ applySettingsSection(section) {
343
+ const next = { ...this.persist.display };
344
+ next.visible = section.visible && (section.enabled ?? true);
345
+ next.size = Math.round(Math.min(512, Math.max(32, section.size)));
346
+ next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)));
347
+ next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.bottom)));
348
+ this.persist = {
349
+ ...this.persist,
350
+ display: next,
351
+ name: section.name.trim()
352
+ };
353
+ this.flush();
354
+ }
355
+ /** Mirror the persisted display config into the settings document (best-effort). */
356
+ syncSettingsFromPet() {
357
+ const settings = this.ctx.get("settings", false);
358
+ if (settings === void 0) return;
359
+ settings.update("pet", {
360
+ visible: this.persist.display.visible,
361
+ size: this.persist.display.size,
362
+ right: this.persist.display.right,
363
+ bottom: this.persist.display.bottom,
364
+ name: this.persist.name
365
+ }).catch(() => {});
366
+ }
367
+ /** Award the turn reward once per done phase (idempotent per transition). */
368
+ rewardTurn() {
369
+ const nowMs = Date.now();
370
+ if (nowMs - this.lastTurnRewardAt < 5e3) return;
371
+ this.lastTurnRewardAt = nowMs;
372
+ this.persist = {
373
+ ...this.persist,
374
+ affinity: applyTurnReward(this.persist.affinity, this.affinityConfig)
375
+ };
376
+ this.flush();
377
+ }
378
+ /**
379
+ * Settle the treat economy (work + time output since the last
380
+ * settlement); persists only when treats were actually granted.
381
+ */
382
+ settleTreats(nowMs) {
383
+ const settlement = settleTreatGrants(this.persist.treats, this.persist.affinity.turns, nowMs, this.treatConfig);
384
+ if (settlement.gained > 0) {
385
+ this.persist = {
386
+ ...this.persist,
387
+ treats: settlement.ledger
388
+ };
389
+ this.flush();
390
+ }
391
+ }
392
+ view() {
393
+ const snapshot = this.machine.render();
394
+ this.settleTreats(Date.now());
395
+ return {
396
+ animation: snapshot.animation,
397
+ ...snapshot.bubble === void 0 ? {} : { bubble: snapshot.bubble },
398
+ phase: snapshot.phase,
399
+ sessionActive: snapshot.sessionActive,
400
+ affinity: this.affinityView(this.persist.affinity),
401
+ display: { ...this.persist.display },
402
+ name: this.persist.name,
403
+ treats: {
404
+ stocked: this.persist.treats.treats,
405
+ max: this.treatConfig.maxTreats
406
+ }
407
+ };
408
+ }
409
+ affinityView(affinity) {
410
+ const nowMs = Date.now();
411
+ const rank = rankOf(affinity.points);
412
+ return {
413
+ points: affinity.points,
414
+ rank: rank.name,
415
+ rankEmoji: rank.emoji,
416
+ pets: affinity.pets,
417
+ feeds: affinity.feeds,
418
+ turns: affinity.turns,
419
+ petCooldown: nowMs - affinity.lastPetAt < this.affinityConfig.petCooldownMs,
420
+ feedCooldown: nowMs - affinity.lastFeedAt < this.affinityConfig.feedCooldownMs
421
+ };
422
+ }
423
+ flush() {
424
+ try {
425
+ savePetPersist(this.persist, this.persistDir);
426
+ } catch {}
427
+ }
428
+ };
429
+ //#endregion
430
+ //#region src/routes.ts
431
+ /** Browser-facing base path of the pet API. */
432
+ const PET_API_PREFIX = "/api/pet";
433
+ /** Browser-facing base path of the pet asset routes. */
434
+ const PET_ASSET_PREFIX = "/pet/whale";
435
+ /** Relative (to package root) asset files exposed under the prefix. */
436
+ const ASSET_FILES = [{
437
+ name: "spritesheet.webp",
438
+ mime: "image/webp"
439
+ }, {
440
+ name: "pet.json",
441
+ mime: "application/json"
442
+ }];
443
+ /** Absolute package root, resolved from this module's own location (lib/). */
444
+ function petPackageRoot(importMetaUrl) {
445
+ return fileURLToPath(new URL("../", importMetaUrl));
446
+ }
447
+ /** Write one JSON response. */
448
+ function json(res, status, body) {
449
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
450
+ res.end(JSON.stringify(body));
451
+ }
452
+ /** Require the method or answer 405. */
453
+ function requireMethod(req, res, method) {
454
+ if (req.method === method) return true;
455
+ json(res, 405, {
456
+ ok: false,
457
+ error: "method-not-allowed"
458
+ });
459
+ return false;
460
+ }
461
+ /** Read a JSON request body (bounded). */
462
+ function readJsonBody(req) {
463
+ return new Promise((resolve, reject) => {
464
+ let size = 0;
465
+ const chunks = [];
466
+ req.on("data", (chunk) => {
467
+ size += chunk.length;
468
+ if (size > 64 * 1024) {
469
+ reject(/* @__PURE__ */ new Error("body-too-large"));
470
+ queueMicrotask(() => req.destroy());
471
+ return;
472
+ }
473
+ chunks.push(chunk);
474
+ });
475
+ req.on("end", () => {
476
+ if (chunks.length === 0) {
477
+ resolve({});
478
+ return;
479
+ }
480
+ try {
481
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
482
+ } catch {
483
+ reject(/* @__PURE__ */ new Error("invalid-json"));
484
+ }
485
+ });
486
+ req.on("error", reject);
487
+ });
488
+ }
489
+ /** Wrap one async service call as a GET JSON route. */
490
+ function getRoute(path, run) {
491
+ return {
492
+ kind: "exact",
493
+ path,
494
+ handler: (req, res) => {
495
+ if (!requireMethod(req, res, "GET")) return;
496
+ run().then((value) => json(res, 200, value), (error) => {
497
+ json(res, 500, {
498
+ ok: false,
499
+ error: error instanceof Error ? error.message : String(error)
500
+ });
501
+ });
502
+ }
503
+ };
504
+ }
505
+ /** Wrap one async service call as a POST JSON route (body passed through). */
506
+ function postRoute(path, run) {
507
+ return {
508
+ kind: "exact",
509
+ path,
510
+ handler: (req, res) => {
511
+ if (!requireMethod(req, res, "POST")) return Promise.resolve();
512
+ return readJsonBody(req).then((body) => {
513
+ return run(typeof body === "object" && body !== null ? body : {}).then((value) => json(res, 200, value), (error) => {
514
+ json(res, 400, {
515
+ ok: false,
516
+ error: error instanceof Error ? error.message : String(error)
517
+ });
518
+ });
519
+ }, (error) => {
520
+ json(res, 400, {
521
+ ok: false,
522
+ error: error instanceof Error ? error.message : String(error)
523
+ });
524
+ });
525
+ }
526
+ };
527
+ }
528
+ /** Build the full route family (API + assets) for one service + package root. */
529
+ function makePetRoutes(deps) {
530
+ const { service, packageRoot } = deps;
531
+ const apiRoutes = [
532
+ getRoute(`${PET_API_PREFIX}/state`, () => service.state()),
533
+ postRoute(`${PET_API_PREFIX}/interact`, (body) => {
534
+ const kind = body.kind;
535
+ if (kind !== "pet" && kind !== "feed") return Promise.reject(/* @__PURE__ */ new Error("invalid-kind"));
536
+ return service.interact(kind);
537
+ }),
538
+ postRoute(`${PET_API_PREFIX}/set-visible`, (body) => {
539
+ const visible = body.visible;
540
+ if (typeof visible !== "boolean") return Promise.reject(/* @__PURE__ */ new Error("invalid-visible"));
541
+ return service.setVisible(visible);
542
+ }),
543
+ postRoute(`${PET_API_PREFIX}/set-config`, (body) => service.setConfig({
544
+ ...typeof body.size === "number" ? { size: body.size } : {},
545
+ ...typeof body.right === "number" ? { right: body.right } : {},
546
+ ...typeof body.bottom === "number" ? { bottom: body.bottom } : {},
547
+ ...typeof body.visible === "boolean" ? { visible: body.visible } : {}
548
+ })),
549
+ postRoute(`${PET_API_PREFIX}/set-name`, (body) => {
550
+ const name = body.name;
551
+ if (typeof name !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-name"));
552
+ return service.setName(name);
553
+ })
554
+ ];
555
+ const assetRoutes = ASSET_FILES.map((file) => ({
556
+ kind: "exact",
557
+ path: `${PET_ASSET_PREFIX}/${file.name}`,
558
+ handler: (req, res) => {
559
+ if (req.method !== "GET" && req.method !== "HEAD") {
560
+ res.writeHead(405);
561
+ res.end();
562
+ return;
563
+ }
564
+ return readFile(join(packageRoot, "assets", "whale", file.name)).then((body) => {
565
+ res.writeHead(200, {
566
+ "content-type": file.mime,
567
+ "content-length": String(body.byteLength),
568
+ "cache-control": "no-cache"
569
+ });
570
+ if (req.method === "HEAD") {
571
+ res.end();
572
+ return;
573
+ }
574
+ res.end(body);
575
+ }, () => {
576
+ res.writeHead(404);
577
+ res.end();
578
+ });
579
+ }
580
+ }));
581
+ return [...apiRoutes, ...assetRoutes];
582
+ }
583
+ //#endregion
584
+ //#region src/index.ts
585
+ /** Stable cordis plugin name (matches cordis.patch.yml insert id). */
586
+ const name = "pet";
587
+ /** Services required before the pet can mount its surfaces. */
588
+ const inject = ["webServer"];
589
+ /** Settings section schema: the display fields and name the web settings surface edits. */
590
+ const PET_SETTINGS_SCHEMA = z.object({
591
+ visible: z.boolean().default(true),
592
+ size: z.number().step(1).min(32).max(512).default(160),
593
+ right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
594
+ bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
595
+ name: z.string().min(1).max(20).pattern(/\S/).default(DEFAULT_PET_NAME),
596
+ enabled: z.boolean().default(true)
597
+ });
598
+ /** Register the pet service and its API + asset routes on the context. */
599
+ function apply(ctx, config = {}) {
600
+ const service = new PetService(ctx, config);
601
+ let current = () => base;
602
+ const base = {
603
+ visible: service.display().visible,
604
+ size: service.display().size,
605
+ right: service.display().right,
606
+ bottom: service.display().bottom,
607
+ name: service.petName(),
608
+ enabled: config.enabled ?? true
609
+ };
610
+ const routes = makePetRoutes({
611
+ service,
612
+ packageRoot: petPackageRoot(import.meta.url)
613
+ });
614
+ let disposeRoutes;
615
+ const syncRoutes = () => {
616
+ const enabled = current().enabled ?? true;
617
+ if (disposeRoutes === void 0 && enabled) disposeRoutes = ctx.effect(() => {
618
+ const disposers = routes.map((route) => ctx.webServer.register(route));
619
+ return () => {
620
+ for (const dispose of disposers) dispose();
621
+ };
622
+ }, "pet: routes");
623
+ else if (disposeRoutes !== void 0 && !enabled) {
624
+ disposeRoutes();
625
+ disposeRoutes = void 0;
626
+ }
627
+ };
628
+ installSettingsSection(ctx, settingsNamespace("pet"), PET_SETTINGS_SCHEMA, base, {
629
+ setSource: (source) => {
630
+ current = source;
631
+ },
632
+ onChange: () => {
633
+ const section = current();
634
+ service.applySettingsSection(section);
635
+ service.setEnabled(section.enabled ?? true);
636
+ syncRoutes();
637
+ }
638
+ });
639
+ syncRoutes();
640
+ }
641
+ //#endregion
642
+ 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 };
@@ -0,0 +1,34 @@
1
+ import { l as defaultAffinityConfig, n as animationForPhase, o as AFFINITY_RANKS } from "./state-IyVnKymD.js";
2
+ //#region src/invariant.ts
3
+ /**
4
+ * Package invariants — cheap structural checks run at import time on the
5
+ * host side. Mirrors the pattern used by other dsh plugin packages.
6
+ * @module @linxin666/dsh-pet/invariant
7
+ */
8
+ /** Assert a condition; throws a descriptive Error when violated. */
9
+ function invariant(condition, message) {
10
+ if (!condition) throw new Error(`[dsh-pet] ${message}`);
11
+ }
12
+ /** Run every package invariant once; throws on the first violation. */
13
+ function runPetInvariants() {
14
+ invariant(true, "AFFINITY_MAX must be positive");
15
+ invariant(AFFINITY_RANKS.length > 0 && AFFINITY_RANKS[0].min === 0, "AFFINITY_RANKS must start at 0");
16
+ invariant(defaultAffinityConfig.turnReward > 0, "turnReward must be positive");
17
+ invariant(defaultAffinityConfig.feedCooldownMs > defaultAffinityConfig.petCooldownMs, "feed cooldown must exceed pet cooldown");
18
+ for (const phase of [
19
+ "idle",
20
+ "waiting",
21
+ "thinking",
22
+ "tool",
23
+ "done"
24
+ ]) invariant([
25
+ "idle",
26
+ "running",
27
+ "running-right",
28
+ "waiting",
29
+ "jumping"
30
+ ].includes(animationForPhase(phase)), `phase ${phase} maps outside the animation contract`);
31
+ }
32
+ runPetInvariants();
33
+ //#endregion
34
+ export { invariant, runPetInvariants };