@linxin666/dsh-pet 0.1.13 → 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 (57) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +11 -7
  3. package/README.zh.md +11 -7
  4. package/lib/client.js +38 -17
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +393 -150
  7. package/lib/invariant.js +1 -1
  8. package/lib/{state-C9EyycwI.js → state-CFyJv0sQ.js} +22 -7
  9. package/lib/types/affinity.d.ts +15 -0
  10. package/lib/types/affinity.d.ts.map +1 -1
  11. package/lib/types/affinity.js +14 -0
  12. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  13. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PluginSettingsCard.js +27 -7
  15. package/lib/types/client/WhalePet.d.ts.map +1 -1
  16. package/lib/types/client/WhalePet.js +2 -1
  17. package/lib/types/client/index.d.ts +1 -1
  18. package/lib/types/client/index.js +3 -3
  19. package/lib/types/client/settings-form.d.ts +14 -5
  20. package/lib/types/client/settings-form.d.ts.map +1 -1
  21. package/lib/types/client/settings-form.js +38 -10
  22. package/lib/types/dsh-home.d.ts +17 -0
  23. package/lib/types/dsh-home.d.ts.map +1 -0
  24. package/lib/types/dsh-home.js +34 -0
  25. package/lib/types/event-projection.d.ts +36 -0
  26. package/lib/types/event-projection.d.ts.map +1 -0
  27. package/lib/types/event-projection.js +98 -0
  28. package/lib/types/ledger.d.ts +77 -0
  29. package/lib/types/ledger.d.ts.map +1 -0
  30. package/lib/types/ledger.js +139 -0
  31. package/lib/types/persist.d.ts +5 -1
  32. package/lib/types/persist.d.ts.map +1 -1
  33. package/lib/types/persist.js +7 -3
  34. package/lib/types/service.d.ts +24 -44
  35. package/lib/types/service.d.ts.map +1 -1
  36. package/lib/types/service.js +98 -131
  37. package/lib/types/state.d.ts +10 -12
  38. package/lib/types/state.d.ts.map +1 -1
  39. package/lib/types/state.js +14 -10
  40. package/package.json +1 -1
  41. package/src/affinity.ts +33 -0
  42. package/src/client/PluginSettingsCard.tsx +83 -17
  43. package/src/client/WhalePet.test.tsx +25 -1
  44. package/src/client/WhalePet.tsx +6 -0
  45. package/src/client/index.ts +3 -3
  46. package/src/client/pet.module.css +9 -0
  47. package/src/client/settings-card.module.css +6 -0
  48. package/src/client/settings-form.ts +41 -9
  49. package/src/dsh-home.test.ts +35 -0
  50. package/src/dsh-home.ts +36 -0
  51. package/src/event-projection.ts +128 -0
  52. package/src/ledger.test.ts +67 -0
  53. package/src/ledger.ts +183 -0
  54. package/src/persist.ts +7 -3
  55. package/src/service.ts +110 -171
  56. package/src/state.test.ts +4 -1
  57. package/src/state.ts +17 -13
package/src/ledger.ts ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Pet affinity economy (ledger) — composes the pure affinity and treats
3
+ * modules with the cooldown/dedup bookkeeping and emits updated persistence
4
+ * snapshots, marking dirty so the owning facade decides when to flush. Read
5
+ * paths (view) no longer settle the economy; settlements happen on explicit
6
+ * economic events: completed-turn rewards (official or legacy) and feeds.
7
+ * @module @linxin666/dsh-pet/ledger
8
+ */
9
+
10
+ import {
11
+ applyInteraction,
12
+ affinityViewOf,
13
+ applyTurnReward,
14
+ defaultAffinityConfig,
15
+ type AffinityConfig,
16
+ type PetAffinityView,
17
+ type PetInteraction,
18
+ } from './affinity.ts'
19
+ import {
20
+ consumeTreat,
21
+ defaultTreatConfig,
22
+ settleTreatGrants,
23
+ type TreatConfig,
24
+ } from './treats.ts'
25
+ import type { PetDisplayConfig, PetPersist } from './persist.ts'
26
+
27
+ /** Tuning overrides for the affinity economy. */
28
+ export interface LedgerConfig {
29
+ affinity?: Partial<AffinityConfig>
30
+ treats?: Partial<TreatConfig>
31
+ }
32
+
33
+ /** Result of one ledger interaction (the shape the pet RPC returns). */
34
+ export interface LedgerInteractionResult {
35
+ /** Reaction copy bubble. */
36
+ reaction: string
37
+ /** Points gained (0 when inside the cooldown). */
38
+ delta: number
39
+ /** Full affinity snapshot (same shape as the state view). */
40
+ affinity: PetAffinityView
41
+ }
42
+
43
+ /**
44
+ * Holds the current persistence snapshot and all economy bookkeeping. Every
45
+ * mutating call flags takeDirty so the facade persists exactly once per
46
+ * batch of changes; read methods (snapshot, affinityView) never write.
47
+ */
48
+ export class PetLedger {
49
+ private readonly affinityConfig: AffinityConfig
50
+ private readonly treatConfig: TreatConfig
51
+ private current: PetPersist
52
+ /** Completed turns already rewarded, per session (turn numbers are per-session). */
53
+ private rewardedTurns = new Map<string, number>()
54
+ private lastLegacyTurnRewardAt = 0
55
+ private dirty = false
56
+
57
+ constructor(persist: PetPersist, config: LedgerConfig = {}) {
58
+ this.affinityConfig = { ...defaultAffinityConfig, ...(config.affinity ?? {}) }
59
+ this.treatConfig = { ...defaultTreatConfig, ...(config.treats ?? {}) }
60
+ this.current = persist
61
+ }
62
+
63
+ /** Affinity cooldown/rank tuning (read-only). */
64
+ get affinity(): AffinityConfig {
65
+ return this.affinityConfig
66
+ }
67
+
68
+ /** The current persistence snapshot (trade a copy when mutating). */
69
+ get snapshot(): PetPersist {
70
+ return this.current
71
+ }
72
+
73
+ /** Stock cap reported to clients. */
74
+ get treatMax(): number {
75
+ return this.treatConfig.maxTreats
76
+ }
77
+
78
+ /** Consume the pending-write flag if any mutation occurred. */
79
+ takeDirty(): boolean {
80
+ const was = this.dirty
81
+ this.dirty = false
82
+ return was
83
+ }
84
+
85
+ /** Replace the display block (clamping stays a caller concern). */
86
+ setDisplay(display: PetDisplayConfig): void {
87
+ this.current = { ...this.current, display }
88
+ this.dirty = true
89
+ }
90
+
91
+ /** Replace the pet display name (validation stays a caller concern). */
92
+ setName(name: string): void {
93
+ this.current = { ...this.current, name }
94
+ this.dirty = true
95
+ }
96
+
97
+ /**
98
+ * Settle the treat economy (work + time output since the last settlement).
99
+ * A zero-gain first settlement still starts the time clock (anchor write),
100
+ * which is how the 30-minute time output can ever accrue. Returns true when
101
+ * the in-memory ledger changed and should be persisted.
102
+ */
103
+ settleTreats(nowMs: number): boolean {
104
+ const settlement = settleTreatGrants(
105
+ this.current.treats,
106
+ this.current.affinity.turns,
107
+ nowMs,
108
+ this.treatConfig,
109
+ )
110
+ if (settlement.ledger === this.current.treats) return false
111
+ this.current = { ...this.current, treats: settlement.ledger }
112
+ this.dirty = true
113
+ return true
114
+ }
115
+
116
+ /**
117
+ * Award the completed-turn reward once per session+turn (idempotent) and
118
+ * run the treat settlement that work output feeds. Returns true when the
119
+ * snapshot changed.
120
+ */
121
+ rewardTurn(sessionId: string, turn: number, nowMs: number): boolean {
122
+ const last = this.rewardedTurns.get(sessionId) ?? 0
123
+ if (turn <= last) return false
124
+ this.rewardedTurns.set(sessionId, turn)
125
+ let changed = this.applyTurnReward()
126
+ if (this.settleTreats(nowMs)) changed = true
127
+ return changed
128
+ }
129
+
130
+ /** Preserve turn rewards for installations that only emit legacy activity. */
131
+ rewardLegacyTurn(nowMs: number): boolean {
132
+ // A legacy done snapshot may repeat during the celebration window.
133
+ if (nowMs - this.lastLegacyTurnRewardAt < 5_000) return false
134
+ this.lastLegacyTurnRewardAt = nowMs
135
+ let changed = this.applyTurnReward()
136
+ if (this.settleTreats(nowMs)) changed = true
137
+ return changed
138
+ }
139
+
140
+ private applyTurnReward(): boolean {
141
+ this.current = {
142
+ ...this.current,
143
+ affinity: applyTurnReward(this.current.affinity, this.affinityConfig),
144
+ }
145
+ this.dirty = true
146
+ return true
147
+ }
148
+
149
+ /**
150
+ * Pet or feed the pet. Feeding settles first, then gates on the feed
151
+ * cooldown before spending stock — a feed inside the cooldown must not burn
152
+ * a treat for nothing.
153
+ */
154
+ interact(kind: PetInteraction, nowMs: number): LedgerInteractionResult {
155
+ if (kind === 'feed') this.settleTreats(nowMs)
156
+ const outcome = applyInteraction(this.current.affinity, kind, nowMs, this.affinityConfig)
157
+ if (kind === 'feed' && !outcome.accepted) {
158
+ return { reaction: outcome.reaction, delta: 0, affinity: this.affinityView(nowMs) }
159
+ }
160
+ if (kind === 'feed') {
161
+ const consume = consumeTreat(this.current.treats)
162
+ if (!consume.ok) {
163
+ return {
164
+ reaction: '没有小鱼干了,多陪鲸鱼娘工作一会儿吧~',
165
+ delta: 0,
166
+ affinity: this.affinityView(nowMs),
167
+ }
168
+ }
169
+ this.current = { ...this.current, treats: consume.ledger }
170
+ this.dirty = true
171
+ }
172
+ if (outcome.accepted) {
173
+ this.current = { ...this.current, affinity: outcome.affinity }
174
+ this.dirty = true
175
+ }
176
+ return { reaction: outcome.reaction, delta: outcome.delta, affinity: this.affinityView(nowMs) }
177
+ }
178
+
179
+ /** Current affinity view for the RPC snapshot. */
180
+ affinityView(nowMs: number): PetAffinityView {
181
+ return affinityViewOf(this.current.affinity, nowMs, this.affinityConfig)
182
+ }
183
+ }
package/src/persist.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
9
9
  import { join } from 'node:path'
10
- import { homedir } from 'node:os'
10
+ import { dshHome } from './dsh-home.ts'
11
11
  import { AFFINITY_MAX, emptyAffinity, type AffinityState } from './affinity.ts'
12
12
  import { defaultTreatConfig, emptyTreatLedger, type TreatLedger } from './treats.ts'
13
13
 
@@ -60,9 +60,13 @@ export function emptyPersist(): PetPersist {
60
60
  }
61
61
  }
62
62
 
63
- /** Resolve the persistence directory ($DSH_HOME or ~/.dsh). */
63
+ /**
64
+ * Resolve the persistence directory ($DSH_HOME or ~/.dsh). Delegates to the
65
+ * shared {@link dshHome} resolution so the plugin family keeps one DSH_HOME
66
+ * definition (env override, ~ expansion, cwd-joined relative values).
67
+ */
64
68
  export function petHomeDir(): string {
65
- return process.env.DSH_HOME ?? join(homedir(), '.dsh')
69
+ return dshHome()
66
70
  }
67
71
 
68
72
  /** Numeric field guard: finite numbers only, else the fallback. */
package/src/service.ts CHANGED
@@ -1,45 +1,41 @@
1
1
  /**
2
- * Pet host service — the `pet.*` RPC domain. Owns the state machine wiring
3
- * (maps core rc.6 session events turn/step/tool boundaries — and the
4
- * session lifecycle onto the pet phases), the affinity ledger, and the
5
- * persisted display config. The API gateway maps this service's methods onto
6
- * `pet.state` / `pet.interact` / `pet.setVisible` / `pet.setConfig`
7
- * 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.interact` / `pet.setVisible` / `pet.setConfig` for
7
+ * browser consumers.
8
8
  * @module @linxin666/dsh-pet/service
9
9
  */
10
10
 
11
11
  import { Context, Service } from '@deepseek-ai/cordis'
12
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'
13
15
  import {
14
- applyInteraction,
15
- applyTurnReward,
16
- defaultAffinityConfig,
17
- rankOf,
18
- type AffinityConfig,
19
- type AffinityState,
20
- type PetInteraction,
21
- } 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'
22
23
  import {
23
- loadPetPersist,
24
- petHomeDir,
25
- savePetPersist,
24
+ DISPLAY_INSET_MAX,
26
25
  DISPLAY_SIZE_MAX,
27
26
  DISPLAY_SIZE_MIN,
28
- DISPLAY_INSET_MAX,
29
27
  PET_NAME_MAX_LENGTH,
28
+ loadPetPersist,
29
+ petHomeDir,
30
+ savePetPersist,
30
31
  type PetDisplayConfig,
31
32
  type PetPersist,
32
33
  } from './persist.ts'
33
- import {
34
- defaultTreatConfig,
35
- settleTreatGrants,
36
- consumeTreat,
37
- type TreatConfig,
38
- } from './treats.ts'
39
34
  import {
40
35
  defaultPetStateConfig,
41
36
  PetStateMachine,
42
37
  type PetStateConfig,
38
+ type PetStateInput,
43
39
  type PetStateSnapshot,
44
40
  } from './state.ts'
45
41
 
@@ -87,18 +83,7 @@ export interface PetStateView {
87
83
  phase: PetStateSnapshot['phase']
88
84
  sessionActive: boolean
89
85
  /** 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
- }
86
+ affinity: PetAffinityView
102
87
  /** Display configuration. */
103
88
  display: PetDisplayConfig
104
89
  /** User-customizable pet display name. */
@@ -113,14 +98,7 @@ export interface PetStateView {
113
98
  }
114
99
 
115
100
  /** 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
- }
101
+ export type PetInteractResult = LedgerInteractionResult
124
102
 
125
103
  declare module '@deepseek-ai/cordis' {
126
104
  interface Context {
@@ -130,33 +108,31 @@ declare module '@deepseek-ai/cordis' {
130
108
 
131
109
  /**
132
110
  * Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
133
- * written until a query or interaction arrives; event listeners update only
134
- * in-memory state, and persistence happens on interaction/config changes
135
- * plus every completed turn.
111
+ * written until an economic event or interaction arrives; event listeners
112
+ * update only in-memory state, and persistence happens on economic changes
113
+ * (turn rewards, feeds, config/name changes) — never on a read.
136
114
  */
137
115
  export class PetService extends Service {
138
116
  static inject: string[] = []
139
117
 
140
118
  private readonly machine: PetStateMachine
141
- private readonly affinityConfig: AffinityConfig
142
- private readonly treatConfig: TreatConfig
119
+ private readonly ledger: PetLedger
143
120
  private readonly persistDir: string
144
- private persist: PetPersist
145
- /** Completed turns already rewarded, per session (turn numbers are per-session). */
146
- private rewardedTurns = new Map<string, number>()
147
121
  private enabled: boolean
148
122
  private disposeActivity: (() => void) | undefined
123
+ /** Session whose most recent meaningful event currently drives the global pet. */
124
+ private displaySession: Session | undefined
125
+ private readonly sessionActivity = new WeakMap<Session, ProjectionRuntime>()
149
126
 
150
127
  constructor(ctx: Context, config: PetConfig = {}) {
151
128
  super(ctx, 'pet')
152
129
  this.persistDir = config.persistDir ?? petHomeDir()
153
- this.affinityConfig = { ...defaultAffinityConfig, ...(config.affinity ?? {}) }
154
- this.treatConfig = { ...defaultTreatConfig, ...(config.treats ?? {}) }
130
+ const ledgerConfig: LedgerConfig = { affinity: config.affinity, treats: config.treats }
131
+ this.ledger = new PetLedger(loadPetPersist(this.persistDir), ledgerConfig)
155
132
  this.machine = new PetStateMachine({
156
133
  ...defaultPetStateConfig,
157
134
  ...(config.state ?? {}),
158
135
  })
159
- this.persist = loadPetPersist(this.persistDir)
160
136
  this.enabled = config.enabled ?? true
161
137
 
162
138
  this.syncActivity()
@@ -174,12 +150,12 @@ export class PetService extends Service {
174
150
 
175
151
  /** Current persisted display config (read-only view). */
176
152
  display(): PetDisplayConfig {
177
- return { ...this.persist.display }
153
+ return { ...this.ledger.snapshot.display }
178
154
  }
179
155
 
180
156
  /** Current persisted pet name (read-only view). */
181
157
  petName(): string {
182
- return this.persist.name
158
+ return this.ledger.snapshot.name
183
159
  }
184
160
 
185
161
  /** Start or stop the session-activity listeners that drive the pet. */
@@ -197,37 +173,38 @@ export class PetService extends Service {
197
173
  this.disposeActivity = (() => {
198
174
  const disposers = [
199
175
  this.ctx.on('session/event', (session: Session, event: SessionEvent) => {
200
- // rc.6 publishes no 'activity/status' event (the working-activity
201
- // tracker is gone), so the pet derives its phases from the core
202
- // session vocabulary instead.
203
- switch (event.type) {
204
- case 'turn/start':
205
- this.machine.onSessionActive()
206
- break
207
- case 'step/start':
208
- this.machine.onSessionActive()
209
- this.machine.onActivityStatus({ phase: 'thinking' })
210
- break
211
- case 'tool/call':
212
- this.machine.onSessionActive()
213
- this.machine.onActivityStatus({ phase: 'tool', line: 'tool: ' + event.data.name })
214
- break
215
- case 'turn/end':
216
- this.machine.onSessionActive()
217
- if (event.data.reason.kind === 'completed') {
218
- this.machine.onActivityStatus({ phase: 'done' })
219
- this.rewardTurn(String(session.id), event.data.turn)
220
- } else {
221
- // Aborted / failed turns clear the working pose instead of
222
- // freezing the pet on its last phase.
223
- this.machine.onActivityStatus({ phase: 'idle' })
224
- }
225
- break
226
- default:
227
- break
176
+ const runtime = this.activityRuntime(session)
177
+ // `activity/status` is an optional compatibility input. It is not
178
+ // declared as a durable event type by this package because current
179
+ // Harness installations publish the official session vocabulary.
180
+ if ((event.type as string) === 'activity/status') {
181
+ const payload = ((event as unknown as { data?: unknown }).data ?? {}) as ActivityStatusEventLike
182
+ if (typeof payload.phase !== 'string' || !isActivityPhase(payload.phase)) return
183
+ this.applyActivity(session, {
184
+ phase: payload.phase,
185
+ ...(typeof payload.line === 'string' ? { line: payload.line } : {}),
186
+ ...(typeof payload.phrase === 'string' ? { phrase: payload.phrase } : {}),
187
+ })
188
+ // On a legacy-only stream the compatibility event owns turn
189
+ // rewards. Once any official activity is observed, turn/end owns
190
+ // them and a derived legacy `done` cannot double-count.
191
+ if (payload.phase === 'done' && !runtime.officialEventsSeen) {
192
+ this.rewardLegacyTurn()
193
+ }
194
+ return
195
+ }
196
+
197
+ const transition = projectOfficialEvent(event, runtime)
198
+ if (transition === undefined) return
199
+ runtime.officialEventsSeen = true
200
+ this.applyActivity(session, transition.input)
201
+ if (transition.completedTurn !== undefined) {
202
+ this.rewardTurn(String(session.id), transition.completedTurn)
228
203
  }
229
204
  }),
230
- this.ctx.on('session/disposed', () => {
205
+ this.ctx.on('session/disposed', (session: Session) => {
206
+ if (session !== this.displaySession) return
207
+ this.displaySession = undefined
231
208
  this.machine.onSessionDisposed()
232
209
  }),
233
210
  ]
@@ -235,56 +212,49 @@ export class PetService extends Service {
235
212
  })()
236
213
  }
237
214
 
215
+ /** Return the projection state associated with one live session. */
216
+ private activityRuntime(session: Session): ProjectionRuntime {
217
+ let runtime = this.sessionActivity.get(session)
218
+ if (runtime === undefined) {
219
+ runtime = emptyProjectionRuntime()
220
+ this.sessionActivity.set(session, runtime)
221
+ }
222
+ return runtime
223
+ }
224
+
225
+ /** Commit one activity as the host-global pet's most recent display state. */
226
+ private applyActivity(session: Session, input: PetStateInput): void {
227
+ this.displaySession = session
228
+ this.machine.onActivityStatus(input)
229
+ this.machine.onSessionActive()
230
+ }
231
+
238
232
  /** RPC: pet or feed the pet. */
239
233
  async interact(kind: PetInteraction): Promise<PetInteractResult> {
240
234
  const nowMs = Date.now()
241
- // Feeding consumes a treat: settle the economy first (work + time
242
- // output since the last settlement), then gate on the feed cooldown
243
- // BEFORE spending stock — a feed inside the cooldown must not burn a
244
- // treat for nothing.
245
- if (kind === 'feed') this.settleTreats(nowMs)
246
- const outcome = applyInteraction(this.persist.affinity, kind, nowMs, this.affinityConfig)
247
- if (kind === 'feed' && !outcome.accepted) {
248
- return { reaction: outcome.reaction, delta: 0, affinity: this.affinityView(this.persist.affinity) }
249
- }
250
- if (kind === 'feed') {
251
- const consume = consumeTreat(this.persist.treats)
252
- if (!consume.ok) {
253
- const affinity = this.affinityView(this.persist.affinity)
254
- return {
255
- reaction: '没有小鱼干了,多陪鲸鱼娘工作一会儿吧~',
256
- delta: 0,
257
- affinity,
258
- }
259
- }
260
- this.persist = { ...this.persist, treats: consume.ledger }
261
- }
262
- if (outcome.accepted) {
263
- this.persist = { ...this.persist, affinity: outcome.affinity }
264
- this.flush()
265
- }
266
- const affinity = this.affinityView(outcome.affinity)
267
- return { reaction: outcome.reaction, delta: outcome.delta, affinity }
235
+ const result = this.ledger.interact(kind, nowMs)
236
+ if (this.ledger.takeDirty()) this.flush()
237
+ return result
268
238
  }
269
239
 
270
240
  /** RPC: show or hide the pet. */
271
241
  async setVisible(visible: boolean): Promise<{ ok: true; display: PetDisplayConfig }> {
272
- this.persist = { ...this.persist, display: { ...this.persist.display, visible } }
242
+ this.ledger.setDisplay({ ...this.ledger.snapshot.display, visible })
273
243
  this.flush()
274
244
  this.syncSettingsFromPet()
275
- return { ok: true, display: this.persist.display }
245
+ return { ok: true, display: this.ledger.snapshot.display }
276
246
  }
277
247
 
278
248
  /** RPC: update display config (size / position). Values are clamped to whole pixels. */
279
249
  async setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }> {
280
- const next = { ...this.persist.display, ...patch }
250
+ const next = { ...this.ledger.snapshot.display, ...patch }
281
251
  next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, next.size)))
282
252
  next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.right)))
283
253
  next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.bottom)))
284
- this.persist = { ...this.persist, display: next }
254
+ this.ledger.setDisplay(next)
285
255
  this.flush()
286
256
  this.syncSettingsFromPet()
287
- return { ok: true, display: this.persist.display }
257
+ return { ok: true, display: this.ledger.snapshot.display }
288
258
  }
289
259
 
290
260
  /** RPC: rename the pet (trimmed, 1–20 chars). */
@@ -292,7 +262,7 @@ export class PetService extends Service {
292
262
  const trimmed = name.trim()
293
263
  if (trimmed === '') return { ok: false, error: 'name-empty' }
294
264
  if (trimmed.length > PET_NAME_MAX_LENGTH) return { ok: false, error: 'name-too-long' }
295
- this.persist = { ...this.persist, name: trimmed }
265
+ this.ledger.setName(trimmed)
296
266
  this.flush()
297
267
  this.syncSettingsFromPet()
298
268
  return { ok: true, name: trimmed }
@@ -305,12 +275,13 @@ export class PetService extends Service {
305
275
  * @param section - the resolved settings section.
306
276
  */
307
277
  applySettingsSection(section: PetSettingsSection): void {
308
- const next = { ...this.persist.display }
278
+ const next = { ...this.ledger.snapshot.display }
309
279
  next.visible = section.visible && (section.enabled ?? true)
310
280
  next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, section.size)))
311
281
  next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)))
312
282
  next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.bottom)))
313
- this.persist = { ...this.persist, display: next, name: section.name.trim() }
283
+ this.ledger.setDisplay(next)
284
+ this.ledger.setName(section.name.trim())
314
285
  this.flush()
315
286
  }
316
287
 
@@ -318,12 +289,13 @@ export class PetService extends Service {
318
289
  private syncSettingsFromPet(): void {
319
290
  const settings = this.ctx.get('settings', false) as { update(ns: string, patch: object): Promise<void> } | undefined
320
291
  if (settings === undefined) return
292
+ const snapshot = this.ledger.snapshot
321
293
  void settings.update(PET_SETTINGS_NAMESPACE, {
322
- visible: this.persist.display.visible,
323
- size: this.persist.display.size,
324
- right: this.persist.display.right,
325
- bottom: this.persist.display.bottom,
326
- name: this.persist.name,
294
+ visible: snapshot.display.visible,
295
+ size: snapshot.display.size,
296
+ right: snapshot.display.right,
297
+ bottom: snapshot.display.bottom,
298
+ name: snapshot.name,
327
299
  }).catch(() => {
328
300
  // A settings write failure must not break the pet's own persistence.
329
301
  })
@@ -331,69 +303,36 @@ export class PetService extends Service {
331
303
 
332
304
  /** Award the turn reward once per completed turn (idempotent per session + turn). */
333
305
  private rewardTurn(sessionId: string, turn: number): void {
334
- const last = this.rewardedTurns.get(sessionId) ?? 0
335
- if (turn <= last) return
336
- this.rewardedTurns.set(sessionId, turn)
337
- this.persist = { ...this.persist, affinity: applyTurnReward(this.persist.affinity, this.affinityConfig) }
338
- this.flush()
306
+ if (this.ledger.rewardTurn(sessionId, turn, Date.now())) this.flush()
339
307
  }
340
308
 
341
- /**
342
- * Settle the treat economy (work + time output since the last settlement)
343
- * and persist whenever the ledger changed. A zero-gain first settlement
344
- * still starts the time clock (anchor write), which is what lets the
345
- * 30-minute time output ever accrue.
346
- */
347
- private settleTreats(nowMs: number): void {
348
- const settlement = settleTreatGrants(
349
- this.persist.treats,
350
- this.persist.affinity.turns,
351
- nowMs,
352
- this.treatConfig,
353
- )
354
- if (settlement.ledger !== this.persist.treats) {
355
- this.persist = { ...this.persist, treats: settlement.ledger }
356
- this.flush()
357
- }
309
+ /** Preserve turn rewards for installations that only emit legacy activity. */
310
+ private rewardLegacyTurn(): void {
311
+ if (this.ledger.rewardLegacyTurn(Date.now())) this.flush()
358
312
  }
359
313
 
360
314
  private view(): PetStateView {
361
315
  const snapshot = this.machine.render()
362
- // Time-output treats accrue while the host is idle too; settle on read.
363
- this.settleTreats(Date.now())
316
+ // Read-only: the ledger settles on economic events only, never on a read,
317
+ // so polling the state cannot trigger pet.json writes.
364
318
  return {
365
319
  animation: snapshot.animation,
366
320
  ...(snapshot.bubble === undefined ? {} : { bubble: snapshot.bubble }),
367
321
  phase: snapshot.phase,
368
322
  sessionActive: snapshot.sessionActive,
369
- affinity: this.affinityView(this.persist.affinity),
370
- display: { ...this.persist.display },
371
- name: this.persist.name,
323
+ affinity: this.ledger.affinityView(Date.now()),
324
+ display: { ...this.ledger.snapshot.display },
325
+ name: this.ledger.snapshot.name,
372
326
  treats: {
373
- stocked: this.persist.treats.treats,
374
- max: this.treatConfig.maxTreats,
327
+ stocked: this.ledger.snapshot.treats.treats,
328
+ max: this.ledger.treatMax,
375
329
  },
376
330
  }
377
331
  }
378
332
 
379
- private affinityView(affinity: AffinityState): PetStateView['affinity'] {
380
- const nowMs = Date.now()
381
- const rank = rankOf(affinity.points)
382
- return {
383
- points: affinity.points,
384
- rank: rank.name,
385
- rankEmoji: rank.emoji,
386
- pets: affinity.pets,
387
- feeds: affinity.feeds,
388
- turns: affinity.turns,
389
- petCooldown: nowMs - affinity.lastPetAt < this.affinityConfig.petCooldownMs,
390
- feedCooldown: nowMs - affinity.lastFeedAt < this.affinityConfig.feedCooldownMs,
391
- }
392
- }
393
-
394
333
  private flush(): void {
395
334
  try {
396
- savePetPersist(this.persist, this.persistDir)
335
+ savePetPersist(this.ledger.snapshot, this.persistDir)
397
336
  } catch {
398
337
  // Persistence is best-effort; the in-memory ledger keeps working.
399
338
  }
package/src/state.test.ts CHANGED
@@ -11,8 +11,10 @@ describe('animationForPhase', () => {
11
11
  it('maps each activity phase onto the animation contract', () => {
12
12
  expect(animationForPhase('thinking')).toBe('running')
13
13
  expect(animationForPhase('tool')).toBe('running-right')
14
+ expect(animationForPhase('review')).toBe('review')
14
15
  expect(animationForPhase('waiting')).toBe('waiting')
15
16
  expect(animationForPhase('done')).toBe('jumping')
17
+ expect(animationForPhase('failed')).toBe('failed')
16
18
  expect(animationForPhase('idle')).toBe('idle')
17
19
  })
18
20
  })
@@ -27,7 +29,8 @@ describe('PetStateMachine', () => {
27
29
  now += 2399
28
30
  expect(machine.render().animation).toBe('jumping')
29
31
  now += 2
30
- expect(machine.render().animation).toBe('idle')
32
+ expect(machine.render()).toMatchObject({ animation: 'idle' })
33
+ expect(machine.render().bubble).toBeUndefined()
31
34
  })
32
35
 
33
36
  it('shows the phrase bubble when present, else the line', () => {