@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.
- package/lib/client.js +29 -16
- package/lib/client.js.map +1 -1
- package/lib/index.js +355 -251
- package/lib/invariant.js +1 -1
- package/lib/{state-P-wsVJ6O.js → state-CFyJv0sQ.js} +15 -1
- package/lib/types/affinity.d.ts +15 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +14 -0
- package/lib/types/client/PluginSettingsCard.d.ts +26 -11
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +27 -7
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.js +3 -3
- package/lib/types/client/settings-form.d.ts +14 -5
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +38 -10
- package/lib/types/dsh-home.d.ts +17 -0
- package/lib/types/dsh-home.d.ts.map +1 -0
- package/lib/types/dsh-home.js +34 -0
- package/lib/types/event-projection.d.ts +36 -0
- package/lib/types/event-projection.d.ts.map +1 -0
- package/lib/types/event-projection.js +98 -0
- package/lib/types/ledger.d.ts +77 -0
- package/lib/types/ledger.d.ts.map +1 -0
- package/lib/types/ledger.js +139 -0
- package/lib/types/persist.d.ts +5 -1
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +7 -3
- package/lib/types/service.d.ts +15 -46
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +47 -204
- package/package.json +1 -1
- package/src/affinity.ts +33 -0
- package/src/client/PluginSettingsCard.tsx +83 -17
- package/src/client/index.ts +3 -3
- package/src/client/settings-card.module.css +6 -0
- package/src/client/settings-form.ts +41 -9
- package/src/dsh-home.test.ts +35 -0
- package/src/dsh-home.ts +36 -0
- package/src/event-projection.ts +128 -0
- package/src/ledger.test.ts +67 -0
- package/src/ledger.ts +183 -0
- package/src/persist.ts +7 -3
- package/src/service.ts +60 -274
package/src/service.ts
CHANGED
|
@@ -1,40 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pet host service — the `pet.*` RPC domain.
|
|
3
|
-
*
|
|
4
|
-
* affinity
|
|
5
|
-
*
|
|
6
|
-
* `pet.setVisible` / `pet.setConfig` for
|
|
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.
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
type
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
petHomeDir,
|
|
24
|
-
savePetPersist,
|
|
24
|
+
DISPLAY_INSET_MAX,
|
|
25
25
|
DISPLAY_SIZE_MAX,
|
|
26
26
|
DISPLAY_SIZE_MIN,
|
|
27
|
-
DISPLAY_INSET_MAX,
|
|
28
27
|
PET_NAME_MAX_LENGTH,
|
|
28
|
+
loadPetPersist,
|
|
29
|
+
petHomeDir,
|
|
30
|
+
savePetPersist,
|
|
29
31
|
type PetDisplayConfig,
|
|
30
32
|
type PetPersist,
|
|
31
33
|
} from './persist.ts'
|
|
32
|
-
import {
|
|
33
|
-
defaultTreatConfig,
|
|
34
|
-
settleTreatGrants,
|
|
35
|
-
consumeTreat,
|
|
36
|
-
type TreatConfig,
|
|
37
|
-
} from './treats.ts'
|
|
38
34
|
import {
|
|
39
35
|
defaultPetStateConfig,
|
|
40
36
|
PetStateMachine,
|
|
@@ -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
|
|
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 {
|
|
@@ -128,152 +106,33 @@ declare module '@deepseek-ai/cordis' {
|
|
|
128
106
|
}
|
|
129
107
|
}
|
|
130
108
|
|
|
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
109
|
/**
|
|
245
110
|
* Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
|
|
246
|
-
* written until
|
|
247
|
-
* in-memory state, and persistence happens on
|
|
248
|
-
*
|
|
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.
|
|
249
114
|
*/
|
|
250
115
|
export class PetService extends Service {
|
|
251
116
|
static inject: string[] = []
|
|
252
117
|
|
|
253
118
|
private readonly machine: PetStateMachine
|
|
254
|
-
private readonly
|
|
255
|
-
private readonly treatConfig: TreatConfig
|
|
119
|
+
private readonly ledger: PetLedger
|
|
256
120
|
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
121
|
private enabled: boolean
|
|
261
122
|
private disposeActivity: (() => void) | undefined
|
|
262
123
|
/** Session whose most recent meaningful event currently drives the global pet. */
|
|
263
124
|
private displaySession: Session | undefined
|
|
264
|
-
private readonly sessionActivity = new WeakMap<Session,
|
|
265
|
-
private lastLegacyTurnRewardAt = 0
|
|
125
|
+
private readonly sessionActivity = new WeakMap<Session, ProjectionRuntime>()
|
|
266
126
|
|
|
267
127
|
constructor(ctx: Context, config: PetConfig = {}) {
|
|
268
128
|
super(ctx, 'pet')
|
|
269
129
|
this.persistDir = config.persistDir ?? petHomeDir()
|
|
270
|
-
|
|
271
|
-
this.
|
|
130
|
+
const ledgerConfig: LedgerConfig = { affinity: config.affinity, treats: config.treats }
|
|
131
|
+
this.ledger = new PetLedger(loadPetPersist(this.persistDir), ledgerConfig)
|
|
272
132
|
this.machine = new PetStateMachine({
|
|
273
133
|
...defaultPetStateConfig,
|
|
274
134
|
...(config.state ?? {}),
|
|
275
135
|
})
|
|
276
|
-
this.persist = loadPetPersist(this.persistDir)
|
|
277
136
|
this.enabled = config.enabled ?? true
|
|
278
137
|
|
|
279
138
|
this.syncActivity()
|
|
@@ -291,12 +150,12 @@ export class PetService extends Service {
|
|
|
291
150
|
|
|
292
151
|
/** Current persisted display config (read-only view). */
|
|
293
152
|
display(): PetDisplayConfig {
|
|
294
|
-
return { ...this.
|
|
153
|
+
return { ...this.ledger.snapshot.display }
|
|
295
154
|
}
|
|
296
155
|
|
|
297
156
|
/** Current persisted pet name (read-only view). */
|
|
298
157
|
petName(): string {
|
|
299
|
-
return this.
|
|
158
|
+
return this.ledger.snapshot.name
|
|
300
159
|
}
|
|
301
160
|
|
|
302
161
|
/** Start or stop the session-activity listeners that drive the pet. */
|
|
@@ -354,14 +213,10 @@ export class PetService extends Service {
|
|
|
354
213
|
}
|
|
355
214
|
|
|
356
215
|
/** Return the projection state associated with one live session. */
|
|
357
|
-
private activityRuntime(session: Session):
|
|
216
|
+
private activityRuntime(session: Session): ProjectionRuntime {
|
|
358
217
|
let runtime = this.sessionActivity.get(session)
|
|
359
218
|
if (runtime === undefined) {
|
|
360
|
-
runtime =
|
|
361
|
-
activeTools: new Set(),
|
|
362
|
-
officialEventsSeen: false,
|
|
363
|
-
stepHadFailure: false,
|
|
364
|
-
}
|
|
219
|
+
runtime = emptyProjectionRuntime()
|
|
365
220
|
this.sessionActivity.set(session, runtime)
|
|
366
221
|
}
|
|
367
222
|
return runtime
|
|
@@ -377,53 +232,29 @@ export class PetService extends Service {
|
|
|
377
232
|
/** RPC: pet or feed the pet. */
|
|
378
233
|
async interact(kind: PetInteraction): Promise<PetInteractResult> {
|
|
379
234
|
const nowMs = Date.now()
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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 }
|
|
235
|
+
const result = this.ledger.interact(kind, nowMs)
|
|
236
|
+
if (this.ledger.takeDirty()) this.flush()
|
|
237
|
+
return result
|
|
407
238
|
}
|
|
408
239
|
|
|
409
240
|
/** RPC: show or hide the pet. */
|
|
410
241
|
async setVisible(visible: boolean): Promise<{ ok: true; display: PetDisplayConfig }> {
|
|
411
|
-
this.
|
|
242
|
+
this.ledger.setDisplay({ ...this.ledger.snapshot.display, visible })
|
|
412
243
|
this.flush()
|
|
413
244
|
this.syncSettingsFromPet()
|
|
414
|
-
return { ok: true, display: this.
|
|
245
|
+
return { ok: true, display: this.ledger.snapshot.display }
|
|
415
246
|
}
|
|
416
247
|
|
|
417
248
|
/** RPC: update display config (size / position). Values are clamped to whole pixels. */
|
|
418
249
|
async setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }> {
|
|
419
|
-
const next = { ...this.
|
|
250
|
+
const next = { ...this.ledger.snapshot.display, ...patch }
|
|
420
251
|
next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, next.size)))
|
|
421
252
|
next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.right)))
|
|
422
253
|
next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.bottom)))
|
|
423
|
-
this.
|
|
254
|
+
this.ledger.setDisplay(next)
|
|
424
255
|
this.flush()
|
|
425
256
|
this.syncSettingsFromPet()
|
|
426
|
-
return { ok: true, display: this.
|
|
257
|
+
return { ok: true, display: this.ledger.snapshot.display }
|
|
427
258
|
}
|
|
428
259
|
|
|
429
260
|
/** RPC: rename the pet (trimmed, 1–20 chars). */
|
|
@@ -431,7 +262,7 @@ export class PetService extends Service {
|
|
|
431
262
|
const trimmed = name.trim()
|
|
432
263
|
if (trimmed === '') return { ok: false, error: 'name-empty' }
|
|
433
264
|
if (trimmed.length > PET_NAME_MAX_LENGTH) return { ok: false, error: 'name-too-long' }
|
|
434
|
-
this.
|
|
265
|
+
this.ledger.setName(trimmed)
|
|
435
266
|
this.flush()
|
|
436
267
|
this.syncSettingsFromPet()
|
|
437
268
|
return { ok: true, name: trimmed }
|
|
@@ -444,12 +275,13 @@ export class PetService extends Service {
|
|
|
444
275
|
* @param section - the resolved settings section.
|
|
445
276
|
*/
|
|
446
277
|
applySettingsSection(section: PetSettingsSection): void {
|
|
447
|
-
const next = { ...this.
|
|
278
|
+
const next = { ...this.ledger.snapshot.display }
|
|
448
279
|
next.visible = section.visible && (section.enabled ?? true)
|
|
449
280
|
next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, section.size)))
|
|
450
281
|
next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)))
|
|
451
282
|
next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.bottom)))
|
|
452
|
-
this.
|
|
283
|
+
this.ledger.setDisplay(next)
|
|
284
|
+
this.ledger.setName(section.name.trim())
|
|
453
285
|
this.flush()
|
|
454
286
|
}
|
|
455
287
|
|
|
@@ -457,12 +289,13 @@ export class PetService extends Service {
|
|
|
457
289
|
private syncSettingsFromPet(): void {
|
|
458
290
|
const settings = this.ctx.get('settings', false) as { update(ns: string, patch: object): Promise<void> } | undefined
|
|
459
291
|
if (settings === undefined) return
|
|
292
|
+
const snapshot = this.ledger.snapshot
|
|
460
293
|
void settings.update(PET_SETTINGS_NAMESPACE, {
|
|
461
|
-
visible:
|
|
462
|
-
size:
|
|
463
|
-
right:
|
|
464
|
-
bottom:
|
|
465
|
-
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,
|
|
466
299
|
}).catch(() => {
|
|
467
300
|
// A settings write failure must not break the pet's own persistence.
|
|
468
301
|
})
|
|
@@ -470,83 +303,36 @@ export class PetService extends Service {
|
|
|
470
303
|
|
|
471
304
|
/** Award the turn reward once per completed turn (idempotent per session + turn). */
|
|
472
305
|
private rewardTurn(sessionId: string, turn: number): void {
|
|
473
|
-
|
|
474
|
-
if (turn <= last) return
|
|
475
|
-
this.rewardedTurns.set(sessionId, turn)
|
|
476
|
-
this.applyTurnReward()
|
|
306
|
+
if (this.ledger.rewardTurn(sessionId, turn, Date.now())) this.flush()
|
|
477
307
|
}
|
|
478
308
|
|
|
479
309
|
/** Preserve turn rewards for installations that only emit legacy activity. */
|
|
480
310
|
private rewardLegacyTurn(): void {
|
|
481
|
-
|
|
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
|
-
}
|
|
311
|
+
if (this.ledger.rewardLegacyTurn(Date.now())) this.flush()
|
|
511
312
|
}
|
|
512
313
|
|
|
513
314
|
private view(): PetStateView {
|
|
514
315
|
const snapshot = this.machine.render()
|
|
515
|
-
//
|
|
516
|
-
|
|
316
|
+
// Read-only: the ledger settles on economic events only, never on a read,
|
|
317
|
+
// so polling the state cannot trigger pet.json writes.
|
|
517
318
|
return {
|
|
518
319
|
animation: snapshot.animation,
|
|
519
320
|
...(snapshot.bubble === undefined ? {} : { bubble: snapshot.bubble }),
|
|
520
321
|
phase: snapshot.phase,
|
|
521
322
|
sessionActive: snapshot.sessionActive,
|
|
522
|
-
affinity: this.affinityView(
|
|
523
|
-
display: { ...this.
|
|
524
|
-
name: this.
|
|
323
|
+
affinity: this.ledger.affinityView(Date.now()),
|
|
324
|
+
display: { ...this.ledger.snapshot.display },
|
|
325
|
+
name: this.ledger.snapshot.name,
|
|
525
326
|
treats: {
|
|
526
|
-
stocked: this.
|
|
527
|
-
max: this.
|
|
327
|
+
stocked: this.ledger.snapshot.treats.treats,
|
|
328
|
+
max: this.ledger.treatMax,
|
|
528
329
|
},
|
|
529
330
|
}
|
|
530
331
|
}
|
|
531
332
|
|
|
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
333
|
private flush(): void {
|
|
548
334
|
try {
|
|
549
|
-
savePetPersist(this.
|
|
335
|
+
savePetPersist(this.ledger.snapshot, this.persistDir)
|
|
550
336
|
} catch {
|
|
551
337
|
// Persistence is best-effort; the in-memory ledger keeps working.
|
|
552
338
|
}
|