@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
|
@@ -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.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
94
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
if (
|
|
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)
|
|
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
|
+
})
|
package/src/dsh-home.ts
ADDED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
|
69
|
+
return dshHome()
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
/** Numeric field guard: finite numbers only, else the fallback. */
|