@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
@@ -1,10 +1,11 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
1
2
  /**
2
3
  * Staged form model behind the plugin settings card. A card stages what the
3
4
  * user types and writes it only when they save — the settings write is a
4
5
  * durable, revision-fenced document mutation, so staging keeps what is on
5
- * screen exactly what a save would store. Mirrors the official
6
- * ui-plugin-config card-store pattern in a self-contained slice: this
7
- * package must not depend on a sibling UI package.
6
+ * screen exactly what a save would store. Family-shared slice inlined into
7
+ * each plugin's client bundle; mirrors the official ui-plugin-config
8
+ * card-store pattern.
8
9
  */
9
10
 
10
11
  import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
@@ -90,8 +91,17 @@ interface PlannedWrite {
90
91
  run: (() => Promise<boolean>) | undefined
91
92
  }
92
93
 
93
- /** A whole-number field. An empty draft clears the field; any other draft that is not a finite number blocks the save. */
94
- export function numberField(field: string): FieldSpec {
94
+ /** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
95
+ export interface NumberConstraints {
96
+ /** The accepted value must be a whole number. */
97
+ integer?: boolean
98
+ /** The accepted value must be at least this. */
99
+ min?: number
100
+ }
101
+
102
+ /** A whole- or decimal-number field. An empty draft clears the field; any other draft that is not a finite number within the constraints blocks the save. */
103
+ export function numberField(field: string, constraints: NumberConstraints = {}): FieldSpec {
104
+ const { integer = false, min } = constraints
95
105
  return {
96
106
  field,
97
107
  format: value => typeof value === 'number' ? String(value) : '',
@@ -99,7 +109,10 @@ export function numberField(field: string): FieldSpec {
99
109
  const trimmed = text.trim()
100
110
  if (trimmed === '') return { kind: 'clear' }
101
111
  const parsed = Number(trimmed)
102
- return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined
112
+ if (!Number.isFinite(parsed)) return undefined
113
+ if (integer && !Number.isInteger(parsed)) return undefined
114
+ if (min !== undefined && parsed < min) return undefined
115
+ return { kind: 'set', value: parsed }
103
116
  },
104
117
  }
105
118
  }
@@ -122,13 +135,27 @@ export function booleanField(field: string): FieldSpec {
122
135
  field,
123
136
  format: value => typeof value === 'boolean' ? String(value) : '',
124
137
  parse: (text) => {
125
- if (text === 'true') return { kind: 'set', value: true }
126
- if (text === 'false') return { kind: 'set', value: false }
138
+ const trimmed = text.trim()
139
+ if (trimmed === '') return { kind: 'clear' }
140
+ if (trimmed === 'true') return { kind: 'set', value: true }
141
+ if (trimmed === 'false') return { kind: 'set', value: false }
127
142
  return undefined
128
143
  },
129
144
  }
130
145
  }
131
146
 
147
+ /** An enumerated string field; only the listed choices are accepted. An empty draft clears the field. */
148
+ export function choiceField(field: string, choices: readonly string[]): FieldSpec {
149
+ return {
150
+ field,
151
+ format: value => typeof value === 'string' && choices.includes(value) ? value : '',
152
+ parse: (text) => {
153
+ if (text === '') return { kind: 'clear' }
154
+ return choices.includes(text) ? { kind: 'set', value: text } : undefined
155
+ },
156
+ }
157
+ }
158
+
132
159
  /**
133
160
  * Stages one card's edits over one settings namespace and writes them on save.
134
161
  *
@@ -215,6 +242,9 @@ export class CardForm<T> {
215
242
  const plan = this.plan()
216
243
  const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
217
244
  if (plan.length === 0 || this.saving || writes.length !== plan.length) return
245
+ // Snapshot the fields this save writes, so edits staged while it is in
246
+ // flight survive: only the staged keys this save actually wrote are cleared.
247
+ const fields = new Set(plan.map(item => item.field))
218
248
  this.saving = true
219
249
  this.failed = false
220
250
  this.publish()
@@ -222,7 +252,9 @@ export class CardForm<T> {
222
252
  for (const write of writes) {
223
253
  landed = await write() && landed
224
254
  }
225
- if (landed) this.staged.clear()
255
+ if (landed) {
256
+ for (const field of fields) this.staged.delete(field)
257
+ }
226
258
  this.saving = false
227
259
  this.failed = !landed
228
260
  this.publish()
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { join } from 'node:path'
3
+ import { expandHome, resolveDshHome } from './dsh-home.ts'
4
+ import { petHomeDir } from './persist.ts'
5
+
6
+ describe('resolveDshHome', () => {
7
+ it('falls back to ~/.dsh when DSH_HOME is unset', () => {
8
+ const home = join('/home', 'tester')
9
+ expect(resolveDshHome({}, home)).toBe(join(home, '.dsh'))
10
+ })
11
+
12
+ it('prefers the DSH_HOME env override', () => {
13
+ const home = join('/home', 'tester')
14
+ expect(resolveDshHome({ DSH_HOME: '/custom/dsh' }, home)).toBe('/custom/dsh')
15
+ })
16
+
17
+ it('expands a leading ~ (and ~/) against the platform home', () => {
18
+ const home = join('/home', 'tester')
19
+ expect(resolveDshHome({ DSH_HOME: '~/data' }, home)).toBe(join(home, 'data'))
20
+ expect(expandHome('~', home)).toBe(home)
21
+ })
22
+
23
+ it('joins a relative DSH_HOME onto the working directory', () => {
24
+ const home = join('/home', 'tester')
25
+ const rel = resolveDshHome({ DSH_HOME: 'rel/dsh' }, home)
26
+ expect(rel).not.toBe('rel/dsh')
27
+ expect(rel).toBe(join(process.cwd(), 'rel/dsh'))
28
+ })
29
+ })
30
+
31
+ describe('petHomeDir delegates to the shared DSH_HOME resolution', () => {
32
+ it('returns the same value as dshHome under the same env', () => {
33
+ expect(petHomeDir).toBeTypeOf('function')
34
+ })
35
+ })
@@ -0,0 +1,36 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/dsh-home.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * DSH_HOME resolution shared by the plugin family's Host halves: the
4
+ * environment override wins, the platform home fallback follows. Mirrors
5
+ * what dsh-pet and dsh-liangshen each used to implement locally.
6
+ */
7
+
8
+ import { homedir } from 'node:os'
9
+ import { isAbsolute, join } from 'node:path'
10
+
11
+ /** Expand a leading ~ (or ~user) in a path, platform-style. */
12
+ export function expandHome(path: string, home: string = homedir()): string {
13
+ if (path === '~') return home
14
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(home, path.slice(2))
15
+ return path
16
+ }
17
+
18
+ /**
19
+ * Resolve the DSH home directory.
20
+ * @param env - process environment to read DSH_HOME from.
21
+ * @param home - platform home directory fallback (test seam).
22
+ * @returns the absolute DSH home path.
23
+ */
24
+ export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
25
+ const raw = env.DSH_HOME
26
+ if (raw !== undefined && raw.trim() !== '') {
27
+ const expanded = expandHome(raw.trim(), home)
28
+ return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded)
29
+ }
30
+ return join(home, '.dsh')
31
+ }
32
+
33
+ /** Resolve the DSH home directory from the live environment. */
34
+ export function dshHome(): string {
35
+ return resolveDshHome()
36
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Official session event projection — pure. Maps the durable DSH session
3
+ * vocabulary onto the pet's visual phases and carries an optional completed-
4
+ * turn reward for the ledger. Holds no state of its own; callers keep a
5
+ * {@link ProjectionRuntime} per session and feed events in arrival order.
6
+ * @module @linxin666/dsh-pet/event-projection
7
+ */
8
+
9
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
10
+ import type { PetStateInput } from './state.ts'
11
+
12
+ /** Runtime shape of the optional legacy activity event. */
13
+ export interface ActivityStatusEventLike {
14
+ phase?: string
15
+ line?: string
16
+ phrase?: string
17
+ }
18
+
19
+ /** Per-session facts needed to project the official event stream. */
20
+ export interface ProjectionRuntime {
21
+ activeTools: Set<string>
22
+ officialEventsSeen: boolean
23
+ stepHadFailure: boolean
24
+ }
25
+
26
+ /** One official event projection, optionally carrying a completed turn reward. */
27
+ export interface PetActivityTransition {
28
+ input: PetStateInput
29
+ completedTurn?: number
30
+ }
31
+
32
+ /** Fresh projection runtime for a newly seen session. */
33
+ export function emptyProjectionRuntime(): ProjectionRuntime {
34
+ return { activeTools: new Set(), officialEventsSeen: false, stepHadFailure: false }
35
+ }
36
+
37
+ /** Keep tool names readable inside the compact status bubble. */
38
+ function displayToolName(name: string): string {
39
+ const compact = name.replace(/\s+/g, ' ').trim() || '工具'
40
+ return compact.length <= 24 ? compact : `${compact.slice(0, 21)}...`
41
+ }
42
+
43
+ /** Whether a legacy phase is part of the pet's supported vocabulary. */
44
+ export function isActivityPhase(phase: string): phase is PetStateInput['phase'] {
45
+ return ['idle', 'waiting', 'thinking', 'tool', 'review', 'done', 'failed'].includes(phase)
46
+ }
47
+
48
+ /**
49
+ * Project the durable DSH session vocabulary into the pet's visual phases.
50
+ * Unknown and log-only events do not disturb the last meaningful activity.
51
+ */
52
+ export function projectOfficialEvent(
53
+ event: SessionEvent,
54
+ runtime: ProjectionRuntime,
55
+ ): PetActivityTransition | undefined {
56
+ switch (event.type) {
57
+ case 'turn/start':
58
+ runtime.activeTools.clear()
59
+ runtime.stepHadFailure = false
60
+ return { input: { phase: 'waiting', line: '准备开始' } }
61
+ case 'step/start':
62
+ runtime.activeTools.clear()
63
+ runtime.stepHadFailure = false
64
+ return { input: { phase: 'waiting', line: '等待模型响应' } }
65
+ case 'assistant/chunk': {
66
+ const { chunk } = event.data
67
+ if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
68
+ return { input: { phase: 'thinking', line: '正在思考' } }
69
+ }
70
+ if (chunk.type === 'text-delta' && chunk.text.length > 0) {
71
+ return { input: { phase: 'review', line: '整理回复中' } }
72
+ }
73
+ return undefined
74
+ }
75
+ case 'assistant/message':
76
+ return { input: { phase: 'review', line: '整理回复中' } }
77
+ case 'tool/call':
78
+ runtime.activeTools.add(String(event.data.callId))
79
+ return {
80
+ input: {
81
+ phase: 'tool',
82
+ line: `正在使用 ${displayToolName(event.data.name)}`,
83
+ },
84
+ }
85
+ case 'tool/result': {
86
+ const block = event.data.message.content[0]
87
+ runtime.activeTools.delete(String(event.data.message.source.callId))
88
+ runtime.stepHadFailure ||= event.data.error !== undefined || block.isError === true
89
+ if (runtime.activeTools.size > 0) {
90
+ return {
91
+ input: {
92
+ phase: 'tool',
93
+ line: `还有 ${runtime.activeTools.size} 个工具运行中`,
94
+ },
95
+ }
96
+ }
97
+ return runtime.stepHadFailure
98
+ ? { input: { phase: 'failed', line: '工具执行失败' } }
99
+ : { input: { phase: 'thinking', line: '处理工具结果' } }
100
+ }
101
+ case 'turn/end': {
102
+ runtime.activeTools.clear()
103
+ switch (event.data.reason.kind) {
104
+ case 'completed':
105
+ return {
106
+ input: { phase: 'done', line: '完成啦' },
107
+ completedTurn: event.data.turn,
108
+ }
109
+ case 'error':
110
+ return { input: { phase: 'failed', line: '执行失败' } }
111
+ case 'max-tokens':
112
+ return { input: { phase: 'failed', line: '达到输出上限' } }
113
+ case 'interrupted':
114
+ return { input: { phase: 'failed', line: '执行意外中断' } }
115
+ case 'blocked':
116
+ return { input: { phase: 'waiting', line: '等待继续' } }
117
+ case 'aborted':
118
+ return { input: { phase: 'idle', line: '已停止' } }
119
+ default:
120
+ // TurnEndReasonMap is merge-extensible; a newer ending must not
121
+ // leave the pet showing stale in-progress work.
122
+ return { input: { phase: 'idle' } }
123
+ }
124
+ }
125
+ default:
126
+ return undefined
127
+ }
128
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { defaultTreatConfig } from './treats.ts'
3
+ import { defaultAffinityConfig } from './affinity.ts'
4
+ import { emptyPersist } from './persist.ts'
5
+ import { PetLedger } from './ledger.ts'
6
+
7
+ describe('PetLedger', () => {
8
+ it('settles the economy on completed turns (work treat per 3 turns)', () => {
9
+ const ledger = new PetLedger(emptyPersist())
10
+ const n = 1_000_000
11
+ ledger.rewardTurn('s1', 1, n)
12
+ ledger.rewardTurn('s1', 2, n + 1)
13
+ ledger.rewardTurn('s1', 3, n + 2)
14
+ expect(ledger.snapshot.affinity.turns).toBe(3)
15
+ expect(ledger.snapshot.treats.treats).toBe(1)
16
+ expect(ledger.takeDirty()).toBe(true)
17
+ })
18
+
19
+ it('rewards each completed turn once per session (idempotent)', () => {
20
+ const ledger = new PetLedger(emptyPersist())
21
+ const n = 1_000_000
22
+ expect(ledger.rewardTurn('s1', 3, n)).toBe(true)
23
+ // A duplicate delivery of the same turn must not double count.
24
+ expect(ledger.rewardTurn('s1', 3, n + 1)).toBe(false)
25
+ expect(ledger.snapshot.affinity.turns).toBe(1)
26
+ })
27
+
28
+ it('a read of the view does not mark dirty (no settle on read)', () => {
29
+ const ledger = new PetLedger(emptyPersist())
30
+ ledger.affinityView(1_000_000)
31
+ expect(ledger.takeDirty()).toBe(false)
32
+ })
33
+
34
+ it('feed consumes a treat and applies the feed reward', () => {
35
+ const ledger = new PetLedger(emptyPersist())
36
+ const n = 1_000_000
37
+ ledger.rewardTurn('s1', 1, n)
38
+ ledger.rewardTurn('s1', 2, n + 1)
39
+ ledger.rewardTurn('s1', 3, n + 2)
40
+ expect(ledger.snapshot.treats.treats).toBe(1)
41
+ const res = ledger.interact('feed', n + 10)
42
+ expect(res.delta).toBe(defaultAffinityConfig.feedReward)
43
+ expect(ledger.snapshot.treats.treats).toBe(0)
44
+ expect(ledger.snapshot.affinity.feeds).toBe(1)
45
+ })
46
+
47
+ it('refuses a feed on an empty stock without burning anything', () => {
48
+ const ledger = new PetLedger(emptyPersist())
49
+ const res = ledger.interact('feed', 1_000_000)
50
+ expect(res.delta).toBe(0)
51
+ expect(res.reaction).toContain('没有小鱼干')
52
+ expect(ledger.snapshot.affinity.feeds).toBe(0)
53
+ // The empty-stock feed still marks dirty because the first settlement
54
+ // starts the time clock, mirroring the service-level behavior.
55
+ expect(ledger.takeDirty()).toBe(true)
56
+ })
57
+
58
+ it('exposes the treat stock cap and display/name setters', () => {
59
+ const ledger = new PetLedger(emptyPersist())
60
+ expect(ledger.treatMax).toBe(defaultTreatConfig.maxTreats)
61
+ ledger.setDisplay({ ...ledger.snapshot.display, visible: false })
62
+ ledger.setName('泡泡')
63
+ expect(ledger.snapshot.display.visible).toBe(false)
64
+ expect(ledger.snapshot.name).toBe('泡泡')
65
+ expect(ledger.takeDirty()).toBe(true)
66
+ })
67
+ })
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. */