@linxin666/dsh-pet 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +73 -38
- package/README.zh.md +91 -55
- package/assets/whale/pet.json +12 -1
- package/cordis.patch.yml +4 -4
- package/lib/client.js +296 -347
- package/lib/client.js.map +1 -1
- package/lib/index.js +975 -317
- 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/PetDockEntry.d.ts +14 -12
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +13 -8
- package/lib/types/client/PetSettingsCard.d.ts +22 -9
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +42 -7
- package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
- package/lib/types/client/PetSprite.d.ts.map +1 -0
- package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
- 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 +30 -8
- package/lib/types/client/index.d.ts +11 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +32 -14
- package/lib/types/client/locales.d.ts +10 -10
- package/lib/types/client/locales.js +10 -10
- package/lib/types/client/pet-store.d.ts +5 -0
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +4 -0
- package/lib/types/client/settings-form.d.ts +76 -5
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +97 -20
- package/lib/types/client/spritesheet.d.ts +14 -48
- package/lib/types/client/spritesheet.d.ts.map +1 -1
- package/lib/types/client/spritesheet.js +13 -81
- 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/index.d.ts +24 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +53 -33
- package/lib/types/ledger.d.ts +79 -0
- package/lib/types/ledger.d.ts.map +1 -0
- package/lib/types/ledger.js +146 -0
- package/lib/types/persist.d.ts +15 -4
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +39 -8
- package/lib/types/registry.d.ts +158 -0
- package/lib/types/registry.d.ts.map +1 -0
- package/lib/types/registry.js +276 -0
- package/lib/types/routes.d.ts +10 -10
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +169 -51
- package/lib/types/service.d.ts +59 -57
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +111 -211
- package/package.json +2 -2
- package/src/affinity.ts +33 -0
- package/src/client/PetDockEntry.tsx +34 -26
- package/src/client/PetSettingsCard.tsx +67 -22
- package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
- package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
- package/src/client/PluginSettingsCard.tsx +90 -18
- package/src/client/index.ts +38 -16
- package/src/client/locales.ts +10 -10
- package/src/client/pet-store.ts +9 -0
- package/src/client/settings-card.module.css +9 -0
- package/src/client/settings-form.ts +153 -19
- package/src/client/spritesheet.ts +16 -92
- package/src/dsh-home.test.ts +35 -0
- package/src/dsh-home.ts +36 -0
- package/src/event-projection.ts +128 -0
- package/src/index.test.ts +23 -0
- package/src/index.ts +89 -45
- package/src/ledger.test.ts +69 -0
- package/src/ledger.ts +190 -0
- package/src/persist.test.ts +52 -3
- package/src/persist.ts +49 -11
- package/src/registry.test.ts +132 -0
- package/src/registry.ts +400 -0
- package/src/routes.ts +166 -53
- package/src/service.ts +153 -285
- package/lib/types/client/WhalePet.d.ts.map +0 -1
|
@@ -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,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { makePetSettingsSchema } from './index.ts'
|
|
3
|
+
|
|
4
|
+
describe('makePetSettingsSchema', () => {
|
|
5
|
+
it('resolves the selected pet and display defaults from an empty section', () => {
|
|
6
|
+
const schema = makePetSettingsSchema('whale-girl')
|
|
7
|
+
expect(schema({})).toMatchObject({
|
|
8
|
+
petId: 'whale-girl',
|
|
9
|
+
visible: true,
|
|
10
|
+
size: 160,
|
|
11
|
+
right: 24,
|
|
12
|
+
bottom: 20,
|
|
13
|
+
enabled: true,
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('accepts any petId string so a removed pet cannot brick the namespace', () => {
|
|
18
|
+
// The service clamps the value against the registry; the schema must
|
|
19
|
+
// never reject a stale stored selection outright.
|
|
20
|
+
const schema = makePetSettingsSchema('whale-girl')
|
|
21
|
+
expect(schema({ petId: 'dragon' }).petId).toBe('dragon')
|
|
22
|
+
})
|
|
23
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-pet host half — mounts the pet service and its HTTP routes. The
|
|
3
|
-
* browser half (the
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* browser half (the './client' entry) renders the selected pet and drives it
|
|
4
|
+
* through the same-origin '/api/pet/*' JSON endpoints plus the '/pet/<id>/*'
|
|
5
|
+
* media route. The host builds the multi-pet registry once at startup from
|
|
6
|
+
* the package assets, the hatch-pet custom pets directory, and composed
|
|
7
|
+
* config entries; adding a pet means dropping a manifest + atlas into one of
|
|
8
|
+
* those sources, never touching host or client code. Install via
|
|
9
|
+
* 'dsh plugin --profile web add link:<dsh-web-ui>/packages/dsh-pet'; the
|
|
10
|
+
* cordis.patch.yml inserts this plugin row.
|
|
7
11
|
* @module @linxin666/dsh-pet
|
|
8
12
|
*/
|
|
9
13
|
|
|
@@ -12,19 +16,15 @@ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-sett
|
|
|
12
16
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
13
17
|
import z from 'schemastery'
|
|
14
18
|
import { PetService, PET_SETTINGS_NAMESPACE, type PetConfig, type PetSettingsSection } from './service.ts'
|
|
15
|
-
import { makePetRoutes
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
DISPLAY_INSET_MAX,
|
|
19
|
-
DISPLAY_SIZE_MAX,
|
|
20
|
-
DISPLAY_SIZE_MIN,
|
|
21
|
-
PET_NAME_MAX_LENGTH,
|
|
22
|
-
} from './persist.ts'
|
|
19
|
+
import { makePetRoutes } from './routes.ts'
|
|
20
|
+
import { loadPetRegistry, petPackageRoot } from './registry.ts'
|
|
21
|
+
import { DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN } from './persist.ts'
|
|
23
22
|
|
|
24
23
|
export { PetService } from './service.ts'
|
|
25
24
|
export type {
|
|
26
25
|
PetConfig,
|
|
27
26
|
PetInteractResult,
|
|
27
|
+
PetSettingsSection,
|
|
28
28
|
PetStateView,
|
|
29
29
|
} from './service.ts'
|
|
30
30
|
export {
|
|
@@ -61,6 +61,9 @@ export {
|
|
|
61
61
|
} from './treats.ts'
|
|
62
62
|
export type { TreatConfig, TreatLedger, TreatSettlement } from './treats.ts'
|
|
63
63
|
export {
|
|
64
|
+
DEFAULT_PET_ID,
|
|
65
|
+
DEFAULT_PET_NAME,
|
|
66
|
+
PET_NAME_MAX_LENGTH,
|
|
64
67
|
defaultDisplayConfig,
|
|
65
68
|
emptyPersist,
|
|
66
69
|
loadPetPersist,
|
|
@@ -68,10 +71,31 @@ export {
|
|
|
68
71
|
savePetPersist,
|
|
69
72
|
} from './persist.ts'
|
|
70
73
|
export type { PetDisplayConfig, PetPersist } from './persist.ts'
|
|
74
|
+
export {
|
|
75
|
+
DEFAULT_FRAME_COUNTS,
|
|
76
|
+
DEFAULT_PET_CELL,
|
|
77
|
+
DEFAULT_PET_COLUMNS,
|
|
78
|
+
DEFAULT_PET_ROW_COUNT,
|
|
79
|
+
DEFAULT_TRACK_PATTERNS,
|
|
80
|
+
PET_ROW_ORDER,
|
|
81
|
+
codexPetsDir,
|
|
82
|
+
loadPetRegistry,
|
|
83
|
+
petEntryView,
|
|
84
|
+
petPackageRoot,
|
|
85
|
+
resolvePetManifest,
|
|
86
|
+
} from './registry.ts'
|
|
87
|
+
export type {
|
|
88
|
+
PetDefinition,
|
|
89
|
+
PetEntry,
|
|
90
|
+
PetManifest,
|
|
91
|
+
PetRegistry,
|
|
92
|
+
PetRegistryOptions,
|
|
93
|
+
PetTrackDef,
|
|
94
|
+
PetTrackOverride,
|
|
95
|
+
} from './registry.ts'
|
|
71
96
|
|
|
72
97
|
export {
|
|
73
98
|
makePetRoutes,
|
|
74
|
-
petPackageRoot,
|
|
75
99
|
PET_API_PREFIX,
|
|
76
100
|
PET_ASSET_PREFIX,
|
|
77
101
|
} from './routes.ts'
|
|
@@ -82,43 +106,57 @@ export const name = 'pet'
|
|
|
82
106
|
/** Services required before the pet can mount its surfaces. */
|
|
83
107
|
export const inject = ['webServer']
|
|
84
108
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Settings section schema: pet selection and display fields the web settings
|
|
111
|
+
* surface edits. petId is a plain string on purpose: the service clamps the
|
|
112
|
+
* resolved value against the registry, so a stored selection that points at
|
|
113
|
+
* a removed pet cannot invalidate the section (a strict union would refuse
|
|
114
|
+
* the whole registration). The settings card renders the actual registry
|
|
115
|
+
* choices itself from '/api/pet/pets'.
|
|
116
|
+
*/
|
|
117
|
+
export function makePetSettingsSchema(fallbackPetId: string) {
|
|
118
|
+
return z.object({
|
|
119
|
+
visible: z.boolean().default(true),
|
|
120
|
+
size: z.number().step(1).min(DISPLAY_SIZE_MIN).max(DISPLAY_SIZE_MAX).default(160),
|
|
121
|
+
right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
|
|
122
|
+
bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
|
|
123
|
+
petId: z.string().default(fallbackPetId),
|
|
124
|
+
enabled: z.boolean().default(true),
|
|
125
|
+
})
|
|
126
|
+
}
|
|
94
127
|
|
|
95
128
|
/** Register the pet service and its API + asset routes on the context. */
|
|
96
129
|
export function apply(ctx: Context, config: PetConfig = {}): void {
|
|
97
|
-
const
|
|
130
|
+
const registry = config.registry
|
|
131
|
+
?? loadPetRegistry({
|
|
132
|
+
packageRoot: petPackageRoot(import.meta.url),
|
|
133
|
+
...(config.pets === undefined ? {} : { extra: config.pets }),
|
|
134
|
+
})
|
|
135
|
+
const service = new PetService(ctx, { ...config, registry })
|
|
98
136
|
|
|
99
|
-
// The settings surface edits the display config through
|
|
100
|
-
// namespace. The composition
|
|
101
|
-
// values (clamped to schema bounds), so an empty user layer
|
|
102
|
-
// exactly what the pet already shows — a fresh deployment
|
|
103
|
-
// overwrites a customized layout, and reset re-inherits it. Runtime
|
|
104
|
-
// interactions mirror back into the settings document through the
|
|
105
|
-
// (see syncSettingsFromPet), keeping both views consistent.
|
|
137
|
+
// The settings surface edits the pet selection + display config through
|
|
138
|
+
// the 'pet' namespace. The composition 'base' starts as the persisted
|
|
139
|
+
// pet.json values (clamped to schema bounds), so an empty user layer
|
|
140
|
+
// resolves to exactly what the pet already shows — a fresh deployment
|
|
141
|
+
// never overwrites a customized layout, and reset re-inherits it. Runtime
|
|
142
|
+
// drag interactions mirror back into the settings document through the
|
|
143
|
+
// service (see syncSettingsFromPet), keeping both views consistent.
|
|
106
144
|
let current: () => PetSettingsSection = () => base
|
|
107
145
|
const base: PetSettingsSection = {
|
|
108
146
|
visible: service.display().visible,
|
|
109
147
|
size: service.display().size,
|
|
110
148
|
right: service.display().right,
|
|
111
149
|
bottom: service.display().bottom,
|
|
112
|
-
|
|
150
|
+
petId: service.selectedPetId(),
|
|
113
151
|
enabled: config.enabled ?? true,
|
|
114
152
|
}
|
|
115
153
|
// The browser half talks to the pet through same-origin JSON endpoints and
|
|
116
|
-
// loads
|
|
117
|
-
// platform-registered, so the pet serves its own API — the same
|
|
118
|
-
// dsh-remote-web-ui's /api/pair family). The routes are
|
|
119
|
-
// the plugin is enabled; toggling the setting off makes
|
|
120
|
-
// disappear until it is re-enabled.
|
|
121
|
-
const routes = makePetRoutes({ service
|
|
154
|
+
// loads each pet's atlas from the registry's own media route (RPC domains
|
|
155
|
+
// are platform-registered, so the pet serves its own API — the same
|
|
156
|
+
// pattern as dsh-remote-web-ui's /api/pair family). The routes are
|
|
157
|
+
// registered while the plugin is enabled; toggling the setting off makes
|
|
158
|
+
// the pet API disappear until it is re-enabled.
|
|
159
|
+
const routes = makePetRoutes({ service })
|
|
122
160
|
let disposeRoutes: (() => void) | undefined
|
|
123
161
|
const syncRoutes = (): void => {
|
|
124
162
|
const enabled = current().enabled ?? true
|
|
@@ -135,14 +173,20 @@ export function apply(ctx: Context, config: PetConfig = {}): void {
|
|
|
135
173
|
disposeRoutes = undefined
|
|
136
174
|
}
|
|
137
175
|
}
|
|
138
|
-
installSettingsSection(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
176
|
+
installSettingsSection(
|
|
177
|
+
ctx,
|
|
178
|
+
settingsNamespace(PET_SETTINGS_NAMESPACE),
|
|
179
|
+
makePetSettingsSchema(service.selectedPetId()),
|
|
180
|
+
base,
|
|
181
|
+
{
|
|
182
|
+
setSource: (source) => { current = source },
|
|
183
|
+
onChange: () => {
|
|
184
|
+
const section = current()
|
|
185
|
+
service.applySettingsSection(section)
|
|
186
|
+
service.setEnabled(section.enabled ?? true)
|
|
187
|
+
syncRoutes()
|
|
188
|
+
},
|
|
145
189
|
},
|
|
146
|
-
|
|
190
|
+
)
|
|
147
191
|
syncRoutes()
|
|
148
192
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
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/pet/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.setPetId('otter')
|
|
63
|
+
ledger.setPetName('otter', '泡泡')
|
|
64
|
+
expect(ledger.snapshot.display.visible).toBe(false)
|
|
65
|
+
expect(ledger.snapshot.petId).toBe('otter')
|
|
66
|
+
expect(ledger.snapshot.names).toEqual({ otter: '泡泡' })
|
|
67
|
+
expect(ledger.takeDirty()).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
})
|
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
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 selected pet id (validation stays a caller concern). */
|
|
92
|
+
setPetId(petId: string): void {
|
|
93
|
+
if (this.current.petId === petId) return
|
|
94
|
+
this.current = { ...this.current, petId }
|
|
95
|
+
this.dirty = true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Replace one pet's display name (validation stays a caller concern). */
|
|
99
|
+
setPetName(petId: string, name: string): void {
|
|
100
|
+
this.current = { ...this.current, names: { ...this.current.names, [petId]: name } }
|
|
101
|
+
this.dirty = true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Settle the treat economy (work + time output since the last settlement).
|
|
106
|
+
* A zero-gain first settlement still starts the time clock (anchor write),
|
|
107
|
+
* which is how the 30-minute time output can ever accrue. Returns true when
|
|
108
|
+
* the in-memory ledger changed and should be persisted.
|
|
109
|
+
*/
|
|
110
|
+
settleTreats(nowMs: number): boolean {
|
|
111
|
+
const settlement = settleTreatGrants(
|
|
112
|
+
this.current.treats,
|
|
113
|
+
this.current.affinity.turns,
|
|
114
|
+
nowMs,
|
|
115
|
+
this.treatConfig,
|
|
116
|
+
)
|
|
117
|
+
if (settlement.ledger === this.current.treats) return false
|
|
118
|
+
this.current = { ...this.current, treats: settlement.ledger }
|
|
119
|
+
this.dirty = true
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Award the completed-turn reward once per session+turn (idempotent) and
|
|
125
|
+
* run the treat settlement that work output feeds. Returns true when the
|
|
126
|
+
* snapshot changed.
|
|
127
|
+
*/
|
|
128
|
+
rewardTurn(sessionId: string, turn: number, nowMs: number): boolean {
|
|
129
|
+
const last = this.rewardedTurns.get(sessionId) ?? 0
|
|
130
|
+
if (turn <= last) return false
|
|
131
|
+
this.rewardedTurns.set(sessionId, turn)
|
|
132
|
+
let changed = this.applyTurnReward()
|
|
133
|
+
if (this.settleTreats(nowMs)) changed = true
|
|
134
|
+
return changed
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Preserve turn rewards for installations that only emit legacy activity. */
|
|
138
|
+
rewardLegacyTurn(nowMs: number): boolean {
|
|
139
|
+
// A legacy done snapshot may repeat during the celebration window.
|
|
140
|
+
if (nowMs - this.lastLegacyTurnRewardAt < 5_000) return false
|
|
141
|
+
this.lastLegacyTurnRewardAt = nowMs
|
|
142
|
+
let changed = this.applyTurnReward()
|
|
143
|
+
if (this.settleTreats(nowMs)) changed = true
|
|
144
|
+
return changed
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private applyTurnReward(): boolean {
|
|
148
|
+
this.current = {
|
|
149
|
+
...this.current,
|
|
150
|
+
affinity: applyTurnReward(this.current.affinity, this.affinityConfig),
|
|
151
|
+
}
|
|
152
|
+
this.dirty = true
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Pet or feed the pet. Feeding settles first, then gates on the feed
|
|
158
|
+
* cooldown before spending stock — a feed inside the cooldown must not burn
|
|
159
|
+
* a treat for nothing.
|
|
160
|
+
*/
|
|
161
|
+
interact(kind: PetInteraction, nowMs: number): LedgerInteractionResult {
|
|
162
|
+
if (kind === 'feed') this.settleTreats(nowMs)
|
|
163
|
+
const outcome = applyInteraction(this.current.affinity, kind, nowMs, this.affinityConfig)
|
|
164
|
+
if (kind === 'feed' && !outcome.accepted) {
|
|
165
|
+
return { reaction: outcome.reaction, delta: 0, affinity: this.affinityView(nowMs) }
|
|
166
|
+
}
|
|
167
|
+
if (kind === 'feed') {
|
|
168
|
+
const consume = consumeTreat(this.current.treats)
|
|
169
|
+
if (!consume.ok) {
|
|
170
|
+
return {
|
|
171
|
+
reaction: '没有小鱼干了,多陪我工作一会儿吧~',
|
|
172
|
+
delta: 0,
|
|
173
|
+
affinity: this.affinityView(nowMs),
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
this.current = { ...this.current, treats: consume.ledger }
|
|
177
|
+
this.dirty = true
|
|
178
|
+
}
|
|
179
|
+
if (outcome.accepted) {
|
|
180
|
+
this.current = { ...this.current, affinity: outcome.affinity }
|
|
181
|
+
this.dirty = true
|
|
182
|
+
}
|
|
183
|
+
return { reaction: outcome.reaction, delta: outcome.delta, affinity: this.affinityView(nowMs) }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Current affinity view for the RPC snapshot. */
|
|
187
|
+
affinityView(nowMs: number): PetAffinityView {
|
|
188
|
+
return affinityViewOf(this.current.affinity, nowMs, this.affinityConfig)
|
|
189
|
+
}
|
|
190
|
+
}
|