@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.
Files changed (90) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +73 -38
  3. package/README.zh.md +91 -55
  4. package/assets/whale/pet.json +12 -1
  5. package/cordis.patch.yml +4 -4
  6. package/lib/client.js +296 -347
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +975 -317
  9. package/lib/invariant.js +1 -1
  10. package/lib/{state-P-wsVJ6O.js → state-CFyJv0sQ.js} +15 -1
  11. package/lib/types/affinity.d.ts +15 -0
  12. package/lib/types/affinity.d.ts.map +1 -1
  13. package/lib/types/affinity.js +14 -0
  14. package/lib/types/client/PetDockEntry.d.ts +14 -12
  15. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  16. package/lib/types/client/PetDockEntry.js +13 -8
  17. package/lib/types/client/PetSettingsCard.d.ts +22 -9
  18. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/PetSettingsCard.js +42 -7
  20. package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
  21. package/lib/types/client/PetSprite.d.ts.map +1 -0
  22. package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
  23. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  24. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  25. package/lib/types/client/PluginSettingsCard.js +30 -8
  26. package/lib/types/client/index.d.ts +11 -8
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/index.js +32 -14
  29. package/lib/types/client/locales.d.ts +10 -10
  30. package/lib/types/client/locales.js +10 -10
  31. package/lib/types/client/pet-store.d.ts +5 -0
  32. package/lib/types/client/pet-store.d.ts.map +1 -1
  33. package/lib/types/client/pet-store.js +4 -0
  34. package/lib/types/client/settings-form.d.ts +76 -5
  35. package/lib/types/client/settings-form.d.ts.map +1 -1
  36. package/lib/types/client/settings-form.js +97 -20
  37. package/lib/types/client/spritesheet.d.ts +14 -48
  38. package/lib/types/client/spritesheet.d.ts.map +1 -1
  39. package/lib/types/client/spritesheet.js +13 -81
  40. package/lib/types/dsh-home.d.ts +17 -0
  41. package/lib/types/dsh-home.d.ts.map +1 -0
  42. package/lib/types/dsh-home.js +34 -0
  43. package/lib/types/event-projection.d.ts +36 -0
  44. package/lib/types/event-projection.d.ts.map +1 -0
  45. package/lib/types/event-projection.js +98 -0
  46. package/lib/types/index.d.ts +24 -11
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +53 -33
  49. package/lib/types/ledger.d.ts +79 -0
  50. package/lib/types/ledger.d.ts.map +1 -0
  51. package/lib/types/ledger.js +146 -0
  52. package/lib/types/persist.d.ts +15 -4
  53. package/lib/types/persist.d.ts.map +1 -1
  54. package/lib/types/persist.js +39 -8
  55. package/lib/types/registry.d.ts +158 -0
  56. package/lib/types/registry.d.ts.map +1 -0
  57. package/lib/types/registry.js +276 -0
  58. package/lib/types/routes.d.ts +10 -10
  59. package/lib/types/routes.d.ts.map +1 -1
  60. package/lib/types/routes.js +169 -51
  61. package/lib/types/service.d.ts +59 -57
  62. package/lib/types/service.d.ts.map +1 -1
  63. package/lib/types/service.js +111 -211
  64. package/package.json +2 -2
  65. package/src/affinity.ts +33 -0
  66. package/src/client/PetDockEntry.tsx +34 -26
  67. package/src/client/PetSettingsCard.tsx +67 -22
  68. package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
  69. package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
  70. package/src/client/PluginSettingsCard.tsx +90 -18
  71. package/src/client/index.ts +38 -16
  72. package/src/client/locales.ts +10 -10
  73. package/src/client/pet-store.ts +9 -0
  74. package/src/client/settings-card.module.css +9 -0
  75. package/src/client/settings-form.ts +153 -19
  76. package/src/client/spritesheet.ts +16 -92
  77. package/src/dsh-home.test.ts +35 -0
  78. package/src/dsh-home.ts +36 -0
  79. package/src/event-projection.ts +128 -0
  80. package/src/index.test.ts +23 -0
  81. package/src/index.ts +89 -45
  82. package/src/ledger.test.ts +69 -0
  83. package/src/ledger.ts +190 -0
  84. package/src/persist.test.ts +52 -3
  85. package/src/persist.ts +49 -11
  86. package/src/registry.test.ts +132 -0
  87. package/src/registry.ts +400 -0
  88. package/src/routes.ts +166 -53
  89. package/src/service.ts +153 -285
  90. 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 `./client` entry) renders the whale-girl companion and
4
- * drives it through the same-origin `/api/pet/*` JSON endpoints plus the
5
- * `/pet/whale/*` media route. Install via `dsh plugin --profile web add
6
- * link:<dsh-web-ui>/packages/dsh-pet`; the cordis.patch.yml inserts this plugin row.
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, petPackageRoot } from './routes.ts'
16
- import {
17
- DEFAULT_PET_NAME,
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
- /** Settings section schema: the display fields and name the web settings surface edits. */
86
- export const PET_SETTINGS_SCHEMA = z.object({
87
- visible: z.boolean().default(true),
88
- size: z.number().step(1).min(DISPLAY_SIZE_MIN).max(DISPLAY_SIZE_MAX).default(160),
89
- right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
90
- bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
91
- name: z.string().min(1).max(PET_NAME_MAX_LENGTH).pattern(/\S/).default(DEFAULT_PET_NAME),
92
- enabled: z.boolean().default(true),
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 service = new PetService(ctx, config)
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 the `pet`
100
- // namespace. The composition `base` starts as the persisted pet.json
101
- // values (clamped to schema bounds), so an empty user layer resolves to
102
- // exactly what the pet already shows — a fresh deployment never
103
- // overwrites a customized layout, and reset re-inherits it. Runtime drag
104
- // interactions mirror back into the settings document through the service
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
- name: service.petName(),
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 the atlas from the pet's own media route (RPC domains are
117
- // platform-registered, so the pet serves its own API — the same pattern as
118
- // dsh-remote-web-ui's /api/pair family). The routes are registered while
119
- // the plugin is enabled; toggling the setting off makes the pet API
120
- // disappear until it is re-enabled.
121
- const routes = makePetRoutes({ service, packageRoot: petPackageRoot(import.meta.url) })
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(ctx, settingsNamespace(PET_SETTINGS_NAMESPACE), PET_SETTINGS_SCHEMA, base, {
139
- setSource: (source) => { current = source },
140
- onChange: () => {
141
- const section = current()
142
- service.applySettingsSection(section)
143
- service.setEnabled(section.enabled ?? true)
144
- syncRoutes()
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
+ }