@linxin666/dsh-pet 0.1.14 → 0.1.15

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 (44) hide show
  1. package/lib/client.js +29 -16
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +355 -251
  4. package/lib/invariant.js +1 -1
  5. package/lib/{state-P-wsVJ6O.js → state-CFyJv0sQ.js} +15 -1
  6. package/lib/types/affinity.d.ts +15 -0
  7. package/lib/types/affinity.d.ts.map +1 -1
  8. package/lib/types/affinity.js +14 -0
  9. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  10. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/PluginSettingsCard.js +27 -7
  12. package/lib/types/client/index.d.ts +1 -1
  13. package/lib/types/client/index.js +3 -3
  14. package/lib/types/client/settings-form.d.ts +14 -5
  15. package/lib/types/client/settings-form.d.ts.map +1 -1
  16. package/lib/types/client/settings-form.js +38 -10
  17. package/lib/types/dsh-home.d.ts +17 -0
  18. package/lib/types/dsh-home.d.ts.map +1 -0
  19. package/lib/types/dsh-home.js +34 -0
  20. package/lib/types/event-projection.d.ts +36 -0
  21. package/lib/types/event-projection.d.ts.map +1 -0
  22. package/lib/types/event-projection.js +98 -0
  23. package/lib/types/ledger.d.ts +77 -0
  24. package/lib/types/ledger.d.ts.map +1 -0
  25. package/lib/types/ledger.js +139 -0
  26. package/lib/types/persist.d.ts +5 -1
  27. package/lib/types/persist.d.ts.map +1 -1
  28. package/lib/types/persist.js +7 -3
  29. package/lib/types/service.d.ts +15 -46
  30. package/lib/types/service.d.ts.map +1 -1
  31. package/lib/types/service.js +47 -204
  32. package/package.json +1 -1
  33. package/src/affinity.ts +33 -0
  34. package/src/client/PluginSettingsCard.tsx +83 -17
  35. package/src/client/index.ts +3 -3
  36. package/src/client/settings-card.module.css +6 -0
  37. package/src/client/settings-form.ts +41 -9
  38. package/src/dsh-home.test.ts +35 -0
  39. package/src/dsh-home.ts +36 -0
  40. package/src/event-projection.ts +128 -0
  41. package/src/ledger.test.ts +67 -0
  42. package/src/ledger.ts +183 -0
  43. package/src/persist.ts +7 -3
  44. package/src/service.ts +60 -274
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
5
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
- import { join } from "node:path";
6
+ import { isAbsolute, join } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { readFile } from "node:fs/promises";
9
9
  import { fileURLToPath } from "node:url";
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,193 @@ 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 pet display name (validation stays a caller concern). */
263
+ setName(name) {
264
+ this.current = {
265
+ ...this.current,
266
+ name
267
+ };
268
+ this.dirty = true;
269
+ }
270
+ /**
271
+ * Settle the treat economy (work + time output since the last settlement).
272
+ * A zero-gain first settlement still starts the time clock (anchor write),
273
+ * which is how the 30-minute time output can ever accrue. Returns true when
274
+ * the in-memory ledger changed and should be persisted.
275
+ */
276
+ settleTreats(nowMs) {
277
+ const settlement = settleTreatGrants(this.current.treats, this.current.affinity.turns, nowMs, this.treatConfig);
278
+ if (settlement.ledger === this.current.treats) return false;
279
+ this.current = {
280
+ ...this.current,
281
+ treats: settlement.ledger
282
+ };
283
+ this.dirty = true;
284
+ return true;
285
+ }
286
+ /**
287
+ * Award the completed-turn reward once per session+turn (idempotent) and
288
+ * run the treat settlement that work output feeds. Returns true when the
289
+ * snapshot changed.
290
+ */
291
+ rewardTurn(sessionId, turn, nowMs) {
292
+ if (turn <= (this.rewardedTurns.get(sessionId) ?? 0)) return false;
293
+ this.rewardedTurns.set(sessionId, turn);
294
+ let changed = this.applyTurnReward();
295
+ if (this.settleTreats(nowMs)) changed = true;
296
+ return changed;
297
+ }
298
+ /** Preserve turn rewards for installations that only emit legacy activity. */
299
+ rewardLegacyTurn(nowMs) {
300
+ if (nowMs - this.lastLegacyTurnRewardAt < 5e3) return false;
301
+ this.lastLegacyTurnRewardAt = nowMs;
302
+ let changed = this.applyTurnReward();
303
+ if (this.settleTreats(nowMs)) changed = true;
304
+ return changed;
305
+ }
306
+ applyTurnReward() {
307
+ this.current = {
308
+ ...this.current,
309
+ affinity: applyTurnReward(this.current.affinity, this.affinityConfig)
310
+ };
311
+ this.dirty = true;
312
+ return true;
313
+ }
314
+ /**
315
+ * Pet or feed the pet. Feeding settles first, then gates on the feed
316
+ * cooldown before spending stock — a feed inside the cooldown must not burn
317
+ * a treat for nothing.
318
+ */
319
+ interact(kind, nowMs) {
320
+ if (kind === "feed") this.settleTreats(nowMs);
321
+ const outcome = applyInteraction(this.current.affinity, kind, nowMs, this.affinityConfig);
322
+ if (kind === "feed" && !outcome.accepted) return {
323
+ reaction: outcome.reaction,
324
+ delta: 0,
325
+ affinity: this.affinityView(nowMs)
326
+ };
327
+ if (kind === "feed") {
328
+ const consume = consumeTreat(this.current.treats);
329
+ if (!consume.ok) return {
330
+ reaction: "没有小鱼干了,多陪鲸鱼娘工作一会儿吧~",
331
+ delta: 0,
332
+ affinity: this.affinityView(nowMs)
333
+ };
334
+ this.current = {
335
+ ...this.current,
336
+ treats: consume.ledger
337
+ };
338
+ this.dirty = true;
339
+ }
340
+ if (outcome.accepted) {
341
+ this.current = {
342
+ ...this.current,
343
+ affinity: outcome.affinity
344
+ };
345
+ this.dirty = true;
346
+ }
347
+ return {
348
+ reaction: outcome.reaction,
349
+ delta: outcome.delta,
350
+ affinity: this.affinityView(nowMs)
351
+ };
352
+ }
353
+ /** Current affinity view for the RPC snapshot. */
354
+ affinityView(nowMs) {
355
+ return affinityViewOf(this.current.affinity, nowMs, this.affinityConfig);
356
+ }
357
+ };
358
+ //#endregion
359
+ //#region src/dsh-home.ts
360
+ /**
361
+ * DSH_HOME resolution shared by the plugin family's Host halves: the
362
+ * environment override wins, the platform home fallback follows. Mirrors
363
+ * what dsh-pet and dsh-liangshen each used to implement locally.
364
+ */
365
+ /** Expand a leading ~ (or ~user) in a path, platform-style. */
366
+ function expandHome(path, home = homedir()) {
367
+ if (path === "~") return home;
368
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
369
+ return path;
370
+ }
371
+ /**
372
+ * Resolve the DSH home directory.
373
+ * @param env - process environment to read DSH_HOME from.
374
+ * @param home - platform home directory fallback (test seam).
375
+ * @returns the absolute DSH home path.
376
+ */
377
+ function resolveDshHome(env = process.env, home = homedir()) {
378
+ const raw = env.DSH_HOME;
379
+ if (raw !== void 0 && raw.trim() !== "") {
380
+ const expanded = expandHome(raw.trim(), home);
381
+ return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded);
382
+ }
383
+ return join(home, ".dsh");
384
+ }
385
+ /** Resolve the DSH home directory from the live environment. */
386
+ function dshHome() {
387
+ return resolveDshHome();
388
+ }
389
+ //#endregion
83
390
  //#region src/persist.ts
84
391
  /**
85
392
  * Pet persistence — tiny JSON store for affinity + display config, written
@@ -104,9 +411,13 @@ function emptyPersist() {
104
411
  display: { ...defaultDisplayConfig }
105
412
  };
106
413
  }
107
- /** Resolve the persistence directory ($DSH_HOME or ~/.dsh). */
414
+ /**
415
+ * Resolve the persistence directory ($DSH_HOME or ~/.dsh). Delegates to the
416
+ * shared {@link dshHome} resolution so the plugin family keeps one DSH_HOME
417
+ * definition (env override, ~ expansion, cwd-joined relative values).
418
+ */
108
419
  function petHomeDir() {
109
- return process.env.DSH_HOME ?? join(homedir(), ".dsh");
420
+ return dshHome();
110
421
  }
111
422
  /** Numeric field guard: finite numbers only, else the fallback. */
112
423
  function finiteNum(value, fallback) {
@@ -162,153 +473,34 @@ function savePetPersist(data, dir = petHomeDir()) {
162
473
  writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
163
474
  renameSync(tmp, target);
164
475
  }
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)}...`;
169
- }
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);
181
- }
182
- /**
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.
185
- */
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
- } };
212
- return;
213
- }
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
- } };
239
- }
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;
273
- }
274
- }
275
476
  /**
276
477
  * 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.
478
+ * written until an economic event or interaction arrives; event listeners
479
+ * update only in-memory state, and persistence happens on economic changes
480
+ * (turn rewards, feeds, config/name changes) — never on a read.
280
481
  */
281
482
  var PetService = class extends Service {
282
483
  static inject = [];
283
484
  machine;
284
- affinityConfig;
285
- treatConfig;
485
+ ledger;
286
486
  persistDir;
287
- persist;
288
- /** Completed turns already rewarded, per session (turn numbers are per-session). */
289
- rewardedTurns = /* @__PURE__ */ new Map();
290
487
  enabled;
291
488
  disposeActivity;
292
489
  /** Session whose most recent meaningful event currently drives the global pet. */
293
490
  displaySession;
294
491
  sessionActivity = /* @__PURE__ */ new WeakMap();
295
- lastLegacyTurnRewardAt = 0;
296
492
  constructor(ctx, config = {}) {
297
493
  super(ctx, "pet");
298
494
  this.persistDir = config.persistDir ?? petHomeDir();
299
- this.affinityConfig = {
300
- ...defaultAffinityConfig,
301
- ...config.affinity ?? {}
302
- };
303
- this.treatConfig = {
304
- ...defaultTreatConfig,
305
- ...config.treats ?? {}
495
+ const ledgerConfig = {
496
+ affinity: config.affinity,
497
+ treats: config.treats
306
498
  };
499
+ this.ledger = new PetLedger(loadPetPersist(this.persistDir), ledgerConfig);
307
500
  this.machine = new PetStateMachine({
308
501
  ...defaultPetStateConfig,
309
502
  ...config.state ?? {}
310
503
  });
311
- this.persist = loadPetPersist(this.persistDir);
312
504
  this.enabled = config.enabled ?? true;
313
505
  this.syncActivity();
314
506
  }
@@ -322,11 +514,11 @@ var PetService = class extends Service {
322
514
  }
323
515
  /** Current persisted display config (read-only view). */
324
516
  display() {
325
- return { ...this.persist.display };
517
+ return { ...this.ledger.snapshot.display };
326
518
  }
327
519
  /** Current persisted pet name (read-only view). */
328
520
  petName() {
329
- return this.persist.name;
521
+ return this.ledger.snapshot.name;
330
522
  }
331
523
  /** Start or stop the session-activity listeners that drive the pet. */
332
524
  setEnabled(enabled) {
@@ -372,11 +564,7 @@ var PetService = class extends Service {
372
564
  activityRuntime(session) {
373
565
  let runtime = this.sessionActivity.get(session);
374
566
  if (runtime === void 0) {
375
- runtime = {
376
- activeTools: /* @__PURE__ */ new Set(),
377
- officialEventsSeen: false,
378
- stepHadFailure: false
379
- };
567
+ runtime = emptyProjectionRuntime();
380
568
  this.sessionActivity.set(session, runtime);
381
569
  }
382
570
  return runtime;
@@ -390,73 +578,38 @@ var PetService = class extends Service {
390
578
  /** RPC: pet or feed the pet. */
391
579
  async interact(kind) {
392
580
  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
- };
581
+ const result = this.ledger.interact(kind, nowMs);
582
+ if (this.ledger.takeDirty()) this.flush();
583
+ return result;
425
584
  }
426
585
  /** RPC: show or hide the pet. */
427
586
  async setVisible(visible) {
428
- this.persist = {
429
- ...this.persist,
430
- display: {
431
- ...this.persist.display,
432
- visible
433
- }
434
- };
587
+ this.ledger.setDisplay({
588
+ ...this.ledger.snapshot.display,
589
+ visible
590
+ });
435
591
  this.flush();
436
592
  this.syncSettingsFromPet();
437
593
  return {
438
594
  ok: true,
439
- display: this.persist.display
595
+ display: this.ledger.snapshot.display
440
596
  };
441
597
  }
442
598
  /** RPC: update display config (size / position). Values are clamped to whole pixels. */
443
599
  async setConfig(patch) {
444
600
  const next = {
445
- ...this.persist.display,
601
+ ...this.ledger.snapshot.display,
446
602
  ...patch
447
603
  };
448
604
  next.size = Math.round(Math.min(512, Math.max(32, next.size)));
449
605
  next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.right)));
450
606
  next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.bottom)));
451
- this.persist = {
452
- ...this.persist,
453
- display: next
454
- };
607
+ this.ledger.setDisplay(next);
455
608
  this.flush();
456
609
  this.syncSettingsFromPet();
457
610
  return {
458
611
  ok: true,
459
- display: this.persist.display
612
+ display: this.ledger.snapshot.display
460
613
  };
461
614
  }
462
615
  /** RPC: rename the pet (trimmed, 1–20 chars). */
@@ -470,10 +623,7 @@ var PetService = class extends Service {
470
623
  ok: false,
471
624
  error: "name-too-long"
472
625
  };
473
- this.persist = {
474
- ...this.persist,
475
- name: trimmed
476
- };
626
+ this.ledger.setName(trimmed);
477
627
  this.flush();
478
628
  this.syncSettingsFromPet();
479
629
  return {
@@ -488,101 +638,55 @@ var PetService = class extends Service {
488
638
  * @param section - the resolved settings section.
489
639
  */
490
640
  applySettingsSection(section) {
491
- const next = { ...this.persist.display };
641
+ const next = { ...this.ledger.snapshot.display };
492
642
  next.visible = section.visible && (section.enabled ?? true);
493
643
  next.size = Math.round(Math.min(512, Math.max(32, section.size)));
494
644
  next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)));
495
645
  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
- };
646
+ this.ledger.setDisplay(next);
647
+ this.ledger.setName(section.name.trim());
501
648
  this.flush();
502
649
  }
503
650
  /** Mirror the persisted display config into the settings document (best-effort). */
504
651
  syncSettingsFromPet() {
505
652
  const settings = this.ctx.get("settings", false);
506
653
  if (settings === void 0) return;
654
+ const snapshot = this.ledger.snapshot;
507
655
  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
656
+ visible: snapshot.display.visible,
657
+ size: snapshot.display.size,
658
+ right: snapshot.display.right,
659
+ bottom: snapshot.display.bottom,
660
+ name: snapshot.name
513
661
  }).catch(() => {});
514
662
  }
515
663
  /** Award the turn reward once per completed turn (idempotent per session + turn). */
516
664
  rewardTurn(sessionId, turn) {
517
- if (turn <= (this.rewardedTurns.get(sessionId) ?? 0)) return;
518
- this.rewardedTurns.set(sessionId, turn);
519
- this.applyTurnReward();
665
+ if (this.ledger.rewardTurn(sessionId, turn, Date.now())) this.flush();
520
666
  }
521
667
  /** Preserve turn rewards for installations that only emit legacy activity. */
522
668
  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
- }
669
+ if (this.ledger.rewardLegacyTurn(Date.now())) this.flush();
551
670
  }
552
671
  view() {
553
672
  const snapshot = this.machine.render();
554
- this.settleTreats(Date.now());
555
673
  return {
556
674
  animation: snapshot.animation,
557
675
  ...snapshot.bubble === void 0 ? {} : { bubble: snapshot.bubble },
558
676
  phase: snapshot.phase,
559
677
  sessionActive: snapshot.sessionActive,
560
- affinity: this.affinityView(this.persist.affinity),
561
- display: { ...this.persist.display },
562
- name: this.persist.name,
678
+ affinity: this.ledger.affinityView(Date.now()),
679
+ display: { ...this.ledger.snapshot.display },
680
+ name: this.ledger.snapshot.name,
563
681
  treats: {
564
- stocked: this.persist.treats.treats,
565
- max: this.treatConfig.maxTreats
682
+ stocked: this.ledger.snapshot.treats.treats,
683
+ max: this.ledger.treatMax
566
684
  }
567
685
  };
568
686
  }
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
687
  flush() {
584
688
  try {
585
- savePetPersist(this.persist, this.persistDir);
689
+ savePetPersist(this.ledger.snapshot, this.persistDir);
586
690
  } catch {}
587
691
  }
588
692
  };