@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/src/service.ts CHANGED
@@ -1,40 +1,45 @@
1
1
  /**
2
- * Pet host service — the `pet.*` RPC domain. Owns the state machine wiring
3
- * (projects official session events and accepts legacy `activity/status`), the
4
- * affinity ledger, and the persisted display config. The API gateway maps
5
- * this service's methods onto `pet.state` / `pet.interact` /
6
- * `pet.setVisible` / `pet.setConfig` for browser consumers.
2
+ * Pet host service — the `pet.*` RPC domain. A composition facade: it wires
3
+ * the pure event projection (`event-projection`) onto the state machine,
4
+ * delegates the affinity economy to the ledger (`ledger`), and routes
5
+ * persistence through `persist`. The API gateway maps these methods onto
6
+ * `pet.state` / `pet.pets` / `pet.interact` / `pet.setVisible` /
7
+ * `pet.setConfig` / `pet.setName` / `pet.setPet` for browser consumers.
7
8
  * @module @linxin666/dsh-pet/service
8
9
  */
9
10
 
10
11
  import { Context, Service } from '@deepseek-ai/cordis'
11
12
  import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
13
+ import type { AffinityConfig, PetAffinityView, PetInteraction } from './affinity.ts'
14
+ import type { TreatConfig } from './treats.ts'
12
15
  import {
13
- applyInteraction,
14
- applyTurnReward,
15
- defaultAffinityConfig,
16
- rankOf,
17
- type AffinityConfig,
18
- type AffinityState,
19
- type PetInteraction,
20
- } from './affinity.ts'
16
+ emptyProjectionRuntime,
17
+ isActivityPhase,
18
+ projectOfficialEvent,
19
+ type ActivityStatusEventLike,
20
+ type ProjectionRuntime,
21
+ } from './event-projection.ts'
22
+ import { PetLedger, type LedgerConfig, type LedgerInteractionResult } from './ledger.ts'
21
23
  import {
22
- loadPetPersist,
23
- petHomeDir,
24
- savePetPersist,
24
+ DEFAULT_PET_NAME,
25
+ DISPLAY_INSET_MAX,
25
26
  DISPLAY_SIZE_MAX,
26
27
  DISPLAY_SIZE_MIN,
27
- DISPLAY_INSET_MAX,
28
28
  PET_NAME_MAX_LENGTH,
29
+ loadPetPersist,
30
+ petHomeDir,
31
+ savePetPersist,
29
32
  type PetDisplayConfig,
30
33
  type PetPersist,
31
34
  } from './persist.ts'
32
35
  import {
33
- defaultTreatConfig,
34
- settleTreatGrants,
35
- consumeTreat,
36
- type TreatConfig,
37
- } from './treats.ts'
36
+ loadPetRegistry,
37
+ petEntryView,
38
+ petPackageRoot,
39
+ type PetDefinition,
40
+ type PetManifest,
41
+ type PetRegistry,
42
+ } from './registry.ts'
38
43
  import {
39
44
  defaultPetStateConfig,
40
45
  PetStateMachine,
@@ -55,14 +60,22 @@ export interface PetConfig {
55
60
  persistDir?: string
56
61
  /** Master switch for the plugin (browser half + host routes). */
57
62
  enabled?: boolean
63
+ /** Prebuilt registry (tests); defaults to scanning the package + user dirs. */
64
+ registry?: PetRegistry
65
+ /** Extra manifest entries composed by the embedding application. */
66
+ pets?: readonly PetManifest[]
58
67
  }
59
68
 
60
69
  /**
61
- * The pet's settings-namespace section: the display fields and name the web
62
- * settings surface edits. `right`/`bottom` are also updated by drag
70
+ * The pet's settings-namespace section: the pet selection and display fields
71
+ * the web settings surface edits. `right`/`bottom` are also updated by drag
63
72
  * interactions, which keep the settings document in sync through the service.
73
+ * Naming is per pet and lives outside the settings document (the hover-panel
74
+ * rename targets the selected pet).
64
75
  */
65
76
  export interface PetSettingsSection {
77
+ /** Selected pet id (a registry entry; the service clamps stale values). */
78
+ petId?: string
66
79
  /** Master switch. */
67
80
  visible: boolean
68
81
  /** Scale of the rendered pet in px (sprite cell height). */
@@ -71,8 +84,6 @@ export interface PetSettingsSection {
71
84
  right: number
72
85
  /** Vertical inset from the viewport bottom edge, px. */
73
86
  bottom: number
74
- /** User-customizable pet display name. */
75
- name: string
76
87
  /** Master switch for the plugin (browser half + host routes). */
77
88
  enabled?: boolean
78
89
  }
@@ -87,21 +98,19 @@ export interface PetStateView {
87
98
  phase: PetStateSnapshot['phase']
88
99
  sessionActive: boolean
89
100
  /** Affinity ledger snapshot. */
90
- affinity: {
91
- points: number
92
- rank: string
93
- rankEmoji: string
94
- pets: number
95
- feeds: number
96
- turns: number
97
- /** True while the pet interaction is inside its cooldown. */
98
- petCooldown: boolean
99
- /** True while the feed is inside its cooldown. */
100
- feedCooldown: boolean
101
- }
101
+ affinity: PetAffinityView
102
102
  /** Display configuration. */
103
103
  display: PetDisplayConfig
104
- /** User-customizable pet display name. */
104
+ /** The selected pet's registry identity. */
105
+ pet: {
106
+ /** Registry id. */
107
+ id: string
108
+ /** Manifest display name (unrenamed default). */
109
+ displayName: string
110
+ /** Manifest description. */
111
+ description: string
112
+ }
113
+ /** The selected pet's display name (user rename or manifest default). */
105
114
  name: string
106
115
  /** Treat (小鱼干) stock snapshot. */
107
116
  treats: {
@@ -113,14 +122,7 @@ export interface PetStateView {
113
122
  }
114
123
 
115
124
  /** Result of `pet.interact`. */
116
- export interface PetInteractResult {
117
- /** Reaction copy bubble. */
118
- reaction: string
119
- /** Points gained (0 when inside the cooldown). */
120
- delta: number
121
- /** Full affinity snapshot (same shape as state view). */
122
- affinity: PetStateView['affinity']
123
- }
125
+ export type PetInteractResult = LedgerInteractionResult
124
126
 
125
127
  declare module '@deepseek-ai/cordis' {
126
128
  interface Context {
@@ -128,152 +130,48 @@ declare module '@deepseek-ai/cordis' {
128
130
  }
129
131
  }
130
132
 
131
- /** Runtime shape of the optional legacy activity event. */
132
- interface ActivityStatusEventLike {
133
- phase?: string
134
- line?: string
135
- phrase?: string
136
- }
137
-
138
- /** Per-session facts needed to project the official event stream. */
139
- interface SessionActivityRuntime {
140
- activeTools: Set<string>
141
- officialEventsSeen: boolean
142
- stepHadFailure: boolean
143
- }
144
-
145
- /** One official event projection, optionally carrying a completed turn reward. */
146
- interface PetActivityTransition {
147
- input: PetStateInput
148
- completedTurn?: number
149
- }
150
-
151
- /** Keep tool names readable inside the compact status bubble. */
152
- function displayToolName(name: string): string {
153
- const compact = name.replace(/\s+/g, ' ').trim() || '工具'
154
- return compact.length <= 24 ? compact : `${compact.slice(0, 21)}...`
155
- }
156
-
157
- /** Whether a legacy phase is part of the pet's supported vocabulary. */
158
- function isActivityPhase(phase: string): phase is PetStateInput['phase'] {
159
- return ['idle', 'waiting', 'thinking', 'tool', 'review', 'done', 'failed'].includes(phase)
160
- }
161
-
162
- /**
163
- * Project the durable DSH session vocabulary into the pet's visual phases.
164
- * Unknown and log-only events do not disturb the last meaningful activity.
165
- */
166
- function projectOfficialEvent(
167
- event: SessionEvent,
168
- runtime: SessionActivityRuntime,
169
- ): PetActivityTransition | undefined {
170
- switch (event.type) {
171
- case 'turn/start':
172
- runtime.activeTools.clear()
173
- runtime.stepHadFailure = false
174
- return { input: { phase: 'waiting', line: '准备开始' } }
175
- case 'step/start':
176
- runtime.activeTools.clear()
177
- runtime.stepHadFailure = false
178
- return { input: { phase: 'waiting', line: '等待模型响应' } }
179
- case 'assistant/chunk': {
180
- const { chunk } = event.data
181
- if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
182
- return { input: { phase: 'thinking', line: '正在思考' } }
183
- }
184
- if (chunk.type === 'text-delta' && chunk.text.length > 0) {
185
- return { input: { phase: 'review', line: '整理回复中' } }
186
- }
187
- return undefined
188
- }
189
- case 'assistant/message':
190
- return { input: { phase: 'review', line: '整理回复中' } }
191
- case 'tool/call':
192
- runtime.activeTools.add(String(event.data.callId))
193
- return {
194
- input: {
195
- phase: 'tool',
196
- line: `正在使用 ${displayToolName(event.data.name)}`,
197
- },
198
- }
199
- case 'tool/result': {
200
- const block = event.data.message.content[0]
201
- runtime.activeTools.delete(String(event.data.message.source.callId))
202
- runtime.stepHadFailure ||= event.data.error !== undefined || block.isError === true
203
- if (runtime.activeTools.size > 0) {
204
- return {
205
- input: {
206
- phase: 'tool',
207
- line: `还有 ${runtime.activeTools.size} 个工具运行中`,
208
- },
209
- }
210
- }
211
- return runtime.stepHadFailure
212
- ? { input: { phase: 'failed', line: '工具执行失败' } }
213
- : { input: { phase: 'thinking', line: '处理工具结果' } }
214
- }
215
- case 'turn/end': {
216
- runtime.activeTools.clear()
217
- switch (event.data.reason.kind) {
218
- case 'completed':
219
- return {
220
- input: { phase: 'done', line: '完成啦' },
221
- completedTurn: event.data.turn,
222
- }
223
- case 'error':
224
- return { input: { phase: 'failed', line: '执行失败' } }
225
- case 'max-tokens':
226
- return { input: { phase: 'failed', line: '达到输出上限' } }
227
- case 'interrupted':
228
- return { input: { phase: 'failed', line: '执行意外中断' } }
229
- case 'blocked':
230
- return { input: { phase: 'waiting', line: '等待继续' } }
231
- case 'aborted':
232
- return { input: { phase: 'idle', line: '已停止' } }
233
- default:
234
- // TurnEndReasonMap is merge-extensible; a newer ending must not
235
- // leave the pet showing stale in-progress work.
236
- return { input: { phase: 'idle' } }
237
- }
238
- }
239
- default:
240
- return undefined
241
- }
242
- }
243
-
244
133
  /**
245
134
  * Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
246
- * written until a query or interaction arrives; event listeners update only
247
- * in-memory state, and persistence happens on interaction/config changes
248
- * plus every completed turn.
135
+ * written until an economic event or interaction arrives; event listeners
136
+ * update only in-memory state, and persistence happens on economic changes
137
+ * (turn rewards, feeds, config/name changes) — never on a read.
249
138
  */
250
139
  export class PetService extends Service {
251
140
  static inject: string[] = []
252
141
 
253
142
  private readonly machine: PetStateMachine
254
- private readonly affinityConfig: AffinityConfig
255
- private readonly treatConfig: TreatConfig
143
+ private readonly ledger: PetLedger
144
+ private readonly registry: PetRegistry
256
145
  private readonly persistDir: string
257
- private persist: PetPersist
258
- /** Completed turns already rewarded, per session (turn numbers are per-session). */
259
- private rewardedTurns = new Map<string, number>()
260
146
  private enabled: boolean
261
147
  private disposeActivity: (() => void) | undefined
262
148
  /** Session whose most recent meaningful event currently drives the global pet. */
263
149
  private displaySession: Session | undefined
264
- private readonly sessionActivity = new WeakMap<Session, SessionActivityRuntime>()
265
- private lastLegacyTurnRewardAt = 0
150
+ private readonly sessionActivity = new WeakMap<Session, ProjectionRuntime>()
266
151
 
267
152
  constructor(ctx: Context, config: PetConfig = {}) {
268
153
  super(ctx, 'pet')
269
154
  this.persistDir = config.persistDir ?? petHomeDir()
270
- this.affinityConfig = { ...defaultAffinityConfig, ...(config.affinity ?? {}) }
271
- this.treatConfig = { ...defaultTreatConfig, ...(config.treats ?? {}) }
155
+ this.registry = config.registry
156
+ ?? loadPetRegistry({
157
+ packageRoot: petPackageRoot(import.meta.url),
158
+ ...(config.pets === undefined ? {} : { extra: config.pets }),
159
+ })
160
+ if (this.registry.entries.length === 0) {
161
+ throw new Error('[dsh-pet] no valid pet manifests found; nothing to render')
162
+ }
163
+ let persist = loadPetPersist(this.persistDir)
164
+ if (this.registry.byId(persist.petId) === undefined) {
165
+ // The selected pet no longer exists (removed or a fresh install with a
166
+ // copied pet.json): fall back to the registry default.
167
+ persist = { ...persist, petId: this.registry.defaultEntry().id }
168
+ }
169
+ const ledgerConfig: LedgerConfig = { affinity: config.affinity, treats: config.treats }
170
+ this.ledger = new PetLedger(persist, ledgerConfig)
272
171
  this.machine = new PetStateMachine({
273
172
  ...defaultPetStateConfig,
274
173
  ...(config.state ?? {}),
275
174
  })
276
- this.persist = loadPetPersist(this.persistDir)
277
175
  this.enabled = config.enabled ?? true
278
176
 
279
177
  this.syncActivity()
@@ -291,12 +189,44 @@ export class PetService extends Service {
291
189
 
292
190
  /** Current persisted display config (read-only view). */
293
191
  display(): PetDisplayConfig {
294
- return { ...this.persist.display }
192
+ return { ...this.ledger.snapshot.display }
295
193
  }
296
194
 
297
- /** Current persisted pet name (read-only view). */
298
- petName(): string {
299
- return this.persist.name
195
+ /** RPC: the registry entries the browser half renders and selects from. */
196
+ async pets(): Promise<PetDefinition[]> {
197
+ return this.registry.entries.map(petEntryView)
198
+ }
199
+
200
+ /** The loaded registry (the asset routes serve its entries). */
201
+ registrySnapshot(): PetRegistry {
202
+ return this.registry
203
+ }
204
+
205
+ /** The selected pet's registry entry. */
206
+ activeEntry(): NonNullable<PetRegistry['entries'][number]> {
207
+ return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry()
208
+ }
209
+
210
+ /** Currently selected pet id (persisted). */
211
+ selectedPetId(): string {
212
+ return this.ledger.snapshot.petId
213
+ }
214
+
215
+ /** The display name of one pet (user rename or manifest displayName). */
216
+ petName(petId: string = this.selectedPetId()): string {
217
+ const stored = this.ledger.snapshot.names[petId]
218
+ if (stored !== undefined && stored.trim() !== '') return stored
219
+ return this.registry.byId(petId)?.displayName ?? DEFAULT_PET_NAME
220
+ }
221
+
222
+ /** RPC: switch the selected pet (persisted, settings document mirrored). */
223
+ async setPetId(petId: string): Promise<{ ok: true; petId: string } | { ok: false; error: string }> {
224
+ const entry = this.registry.byId(petId)
225
+ if (entry === undefined) return { ok: false, error: 'unknown-pet' }
226
+ this.ledger.setPetId(entry.id)
227
+ this.flush()
228
+ this.syncSettingsFromPet()
229
+ return { ok: true, petId: entry.id }
300
230
  }
301
231
 
302
232
  /** Start or stop the session-activity listeners that drive the pet. */
@@ -354,14 +284,10 @@ export class PetService extends Service {
354
284
  }
355
285
 
356
286
  /** Return the projection state associated with one live session. */
357
- private activityRuntime(session: Session): SessionActivityRuntime {
287
+ private activityRuntime(session: Session): ProjectionRuntime {
358
288
  let runtime = this.sessionActivity.get(session)
359
289
  if (runtime === undefined) {
360
- runtime = {
361
- activeTools: new Set(),
362
- officialEventsSeen: false,
363
- stepHadFailure: false,
364
- }
290
+ runtime = emptyProjectionRuntime()
365
291
  this.sessionActivity.set(session, runtime)
366
292
  }
367
293
  return runtime
@@ -377,79 +303,61 @@ export class PetService extends Service {
377
303
  /** RPC: pet or feed the pet. */
378
304
  async interact(kind: PetInteraction): Promise<PetInteractResult> {
379
305
  const nowMs = Date.now()
380
- // Feeding consumes a treat: settle the economy first (work + time
381
- // output since the last settlement), then gate on the feed cooldown
382
- // BEFORE spending stock — a feed inside the cooldown must not burn a
383
- // treat for nothing.
384
- if (kind === 'feed') this.settleTreats(nowMs)
385
- const outcome = applyInteraction(this.persist.affinity, kind, nowMs, this.affinityConfig)
386
- if (kind === 'feed' && !outcome.accepted) {
387
- return { reaction: outcome.reaction, delta: 0, affinity: this.affinityView(this.persist.affinity) }
388
- }
389
- if (kind === 'feed') {
390
- const consume = consumeTreat(this.persist.treats)
391
- if (!consume.ok) {
392
- const affinity = this.affinityView(this.persist.affinity)
393
- return {
394
- reaction: '没有小鱼干了,多陪鲸鱼娘工作一会儿吧~',
395
- delta: 0,
396
- affinity,
397
- }
398
- }
399
- this.persist = { ...this.persist, treats: consume.ledger }
400
- }
401
- if (outcome.accepted) {
402
- this.persist = { ...this.persist, affinity: outcome.affinity }
403
- this.flush()
404
- }
405
- const affinity = this.affinityView(outcome.affinity)
406
- return { reaction: outcome.reaction, delta: outcome.delta, affinity }
306
+ const result = this.ledger.interact(kind, nowMs)
307
+ if (this.ledger.takeDirty()) this.flush()
308
+ return result
407
309
  }
408
310
 
409
311
  /** RPC: show or hide the pet. */
410
312
  async setVisible(visible: boolean): Promise<{ ok: true; display: PetDisplayConfig }> {
411
- this.persist = { ...this.persist, display: { ...this.persist.display, visible } }
313
+ this.ledger.setDisplay({ ...this.ledger.snapshot.display, visible })
412
314
  this.flush()
413
315
  this.syncSettingsFromPet()
414
- return { ok: true, display: this.persist.display }
316
+ return { ok: true, display: this.ledger.snapshot.display }
415
317
  }
416
318
 
417
319
  /** RPC: update display config (size / position). Values are clamped to whole pixels. */
418
320
  async setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }> {
419
- const next = { ...this.persist.display, ...patch }
321
+ const next = { ...this.ledger.snapshot.display, ...patch }
420
322
  next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, next.size)))
421
323
  next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.right)))
422
324
  next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.bottom)))
423
- this.persist = { ...this.persist, display: next }
325
+ this.ledger.setDisplay(next)
424
326
  this.flush()
425
327
  this.syncSettingsFromPet()
426
- return { ok: true, display: this.persist.display }
328
+ return { ok: true, display: this.ledger.snapshot.display }
427
329
  }
428
330
 
429
- /** RPC: rename the pet (trimmed, 1–20 chars). */
331
+ /** RPC: rename the selected pet (trimmed, 1–20 chars, per-pet storage). */
430
332
  async setName(name: string): Promise<{ ok: true; name: string } | { ok: false; error: string }> {
431
333
  const trimmed = name.trim()
432
334
  if (trimmed === '') return { ok: false, error: 'name-empty' }
433
335
  if (trimmed.length > PET_NAME_MAX_LENGTH) return { ok: false, error: 'name-too-long' }
434
- this.persist = { ...this.persist, name: trimmed }
336
+ this.ledger.setPetName(this.selectedPetId(), trimmed)
435
337
  this.flush()
436
- this.syncSettingsFromPet()
437
338
  return { ok: true, name: trimmed }
438
339
  }
439
340
 
440
341
  /**
441
- * Apply a committed settings section to the persisted display config. Called
442
- * by the settings surface on every change; values are clamped exactly like
443
- * the setConfig RPC so both write paths converge.
342
+ * Apply a committed settings section to the persisted selection and display
343
+ * config. Called by the settings surface on every change; values are
344
+ * clamped exactly like the setConfig RPC so both write paths converge.
444
345
  * @param section - the resolved settings section.
445
346
  */
446
347
  applySettingsSection(section: PetSettingsSection): void {
447
- const next = { ...this.persist.display }
348
+ if (typeof section.petId === 'string' && this.registry.byId(section.petId) !== undefined) {
349
+ this.ledger.setPetId(section.petId)
350
+ } else if (section.petId !== undefined) {
351
+ // The stored selection names a pet the registry no longer has: keep the
352
+ // current selection and repair the settings document.
353
+ this.syncSettingsFromPet()
354
+ }
355
+ const next = { ...this.ledger.snapshot.display }
448
356
  next.visible = section.visible && (section.enabled ?? true)
449
357
  next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, section.size)))
450
358
  next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)))
451
359
  next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.bottom)))
452
- this.persist = { ...this.persist, display: next, name: section.name.trim() }
360
+ this.ledger.setDisplay(next)
453
361
  this.flush()
454
362
  }
455
363
 
@@ -457,12 +365,13 @@ export class PetService extends Service {
457
365
  private syncSettingsFromPet(): void {
458
366
  const settings = this.ctx.get('settings', false) as { update(ns: string, patch: object): Promise<void> } | undefined
459
367
  if (settings === undefined) return
368
+ const snapshot = this.ledger.snapshot
460
369
  void settings.update(PET_SETTINGS_NAMESPACE, {
461
- visible: this.persist.display.visible,
462
- size: this.persist.display.size,
463
- right: this.persist.display.right,
464
- bottom: this.persist.display.bottom,
465
- name: this.persist.name,
370
+ visible: snapshot.display.visible,
371
+ size: snapshot.display.size,
372
+ right: snapshot.display.right,
373
+ bottom: snapshot.display.bottom,
374
+ petId: snapshot.petId,
466
375
  }).catch(() => {
467
376
  // A settings write failure must not break the pet's own persistence.
468
377
  })
@@ -470,83 +379,42 @@ export class PetService extends Service {
470
379
 
471
380
  /** Award the turn reward once per completed turn (idempotent per session + turn). */
472
381
  private rewardTurn(sessionId: string, turn: number): void {
473
- const last = this.rewardedTurns.get(sessionId) ?? 0
474
- if (turn <= last) return
475
- this.rewardedTurns.set(sessionId, turn)
476
- this.applyTurnReward()
382
+ if (this.ledger.rewardTurn(sessionId, turn, Date.now())) this.flush()
477
383
  }
478
384
 
479
385
  /** Preserve turn rewards for installations that only emit legacy activity. */
480
386
  private rewardLegacyTurn(): void {
481
- const nowMs = Date.now()
482
- // A legacy `done` snapshot may repeat during the celebration window.
483
- if (nowMs - this.lastLegacyTurnRewardAt < 5_000) return
484
- this.lastLegacyTurnRewardAt = nowMs
485
- this.applyTurnReward()
486
- }
487
-
488
- /** Persist one accepted completed-turn reward. */
489
- private applyTurnReward(): void {
490
- this.persist = { ...this.persist, affinity: applyTurnReward(this.persist.affinity, this.affinityConfig) }
491
- this.flush()
492
- }
493
-
494
- /**
495
- * Settle the treat economy (work + time output since the last settlement)
496
- * and persist whenever the ledger changed. A zero-gain first settlement
497
- * still starts the time clock (anchor write), which is what lets the
498
- * 30-minute time output ever accrue.
499
- */
500
- private settleTreats(nowMs: number): void {
501
- const settlement = settleTreatGrants(
502
- this.persist.treats,
503
- this.persist.affinity.turns,
504
- nowMs,
505
- this.treatConfig,
506
- )
507
- if (settlement.ledger !== this.persist.treats) {
508
- this.persist = { ...this.persist, treats: settlement.ledger }
509
- this.flush()
510
- }
387
+ if (this.ledger.rewardLegacyTurn(Date.now())) this.flush()
511
388
  }
512
389
 
513
390
  private view(): PetStateView {
514
391
  const snapshot = this.machine.render()
515
- // Time-output treats accrue while the host is idle too; settle on read.
516
- this.settleTreats(Date.now())
392
+ const entry = this.activeEntry()
393
+ // Read-only: the ledger settles on economic events only, never on a read,
394
+ // so polling the state cannot trigger pet.json writes.
517
395
  return {
518
396
  animation: snapshot.animation,
519
397
  ...(snapshot.bubble === undefined ? {} : { bubble: snapshot.bubble }),
520
398
  phase: snapshot.phase,
521
399
  sessionActive: snapshot.sessionActive,
522
- affinity: this.affinityView(this.persist.affinity),
523
- display: { ...this.persist.display },
524
- name: this.persist.name,
400
+ affinity: this.ledger.affinityView(Date.now()),
401
+ display: { ...this.ledger.snapshot.display },
402
+ pet: {
403
+ id: entry.id,
404
+ displayName: entry.displayName,
405
+ description: entry.description,
406
+ },
407
+ name: this.petName(),
525
408
  treats: {
526
- stocked: this.persist.treats.treats,
527
- max: this.treatConfig.maxTreats,
409
+ stocked: this.ledger.snapshot.treats.treats,
410
+ max: this.ledger.treatMax,
528
411
  },
529
412
  }
530
413
  }
531
414
 
532
- private affinityView(affinity: AffinityState): PetStateView['affinity'] {
533
- const nowMs = Date.now()
534
- const rank = rankOf(affinity.points)
535
- return {
536
- points: affinity.points,
537
- rank: rank.name,
538
- rankEmoji: rank.emoji,
539
- pets: affinity.pets,
540
- feeds: affinity.feeds,
541
- turns: affinity.turns,
542
- petCooldown: nowMs - affinity.lastPetAt < this.affinityConfig.petCooldownMs,
543
- feedCooldown: nowMs - affinity.lastFeedAt < this.affinityConfig.feedCooldownMs,
544
- }
545
- }
546
-
547
415
  private flush(): void {
548
416
  try {
549
- savePetPersist(this.persist, this.persistDir)
417
+ savePetPersist(this.ledger.snapshot, this.persistDir)
550
418
  } catch {
551
419
  // Persistence is best-effort; the in-memory ledger keeps working.
552
420
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"WhalePet.d.ts","sourceRoot":"","sources":["../../../src/client/WhalePet.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAqC,WAAW,EAAE,MAAM,OAAO,CAAA;AAE3E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAGjD,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAGjC,iFAAiF;AACjF,eAAO,MAAM,mBAAmB,gCAAgC,CAAA;AAEhE,mFAAmF;AACnF,eAAO,MAAM,gBAAgB,wBAAwB,CAAA;AAErD,wEAAwE;AACxE,MAAM,WAAW,aAAa;IAC5B,gDAAgD;IAChD,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAA;IAC7B,qDAAqD;IACrD,OAAO,EAAE,gBAAgB,CAAA;IACzB,sCAAsC;IACtC,QAAQ,EAAE,WAAW,GAAG,IAAI,CAAA;IAC5B,kCAAkC;IAClC,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,0CAA0C;IAC1C,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,0CAA0C;IAC1C,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,+BAA+B;IAC/B,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IAClD,8CAA8C;IAC9C,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,2DAA2D;IAC3D,cAAc,EAAE,MAAM,IAAI,CAAA;IAC1B,+CAA+C;IAC/C,CAAC,EAAE,WAAW,CAAC,OAAO,EAAE,CAAC,CAAA;CAC1B;AAOD;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,aAAa,GAAG,WAAW,CAyT1D"}