@linxin666/dsh-pet 0.1.1

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 (79) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +106 -0
  3. package/assets/whale/pet.json +7 -0
  4. package/assets/whale/previews/failed.gif +0 -0
  5. package/assets/whale/previews/idle.gif +0 -0
  6. package/assets/whale/previews/jumping.gif +0 -0
  7. package/assets/whale/previews/review.gif +0 -0
  8. package/assets/whale/previews/running-left.gif +0 -0
  9. package/assets/whale/previews/running-right.gif +0 -0
  10. package/assets/whale/previews/running.gif +0 -0
  11. package/assets/whale/previews/waiting.gif +0 -0
  12. package/assets/whale/previews/waving.gif +0 -0
  13. package/assets/whale/spritesheet.webp +0 -0
  14. package/cordis.patch.yml +10 -0
  15. package/lib/client.js +1515 -0
  16. package/lib/index.js +642 -0
  17. package/lib/invariant.js +34 -0
  18. package/lib/types/affinity.d.ts +83 -0
  19. package/lib/types/affinity.d.ts.map +1 -0
  20. package/lib/types/client/PetDockEntry.d.ts +44 -0
  21. package/lib/types/client/PetDockEntry.d.ts.map +1 -0
  22. package/lib/types/client/PetSettingsCard.d.ts +67 -0
  23. package/lib/types/client/PetSettingsCard.d.ts.map +1 -0
  24. package/lib/types/client/PluginSettingsCard.d.ts +78 -0
  25. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  26. package/lib/types/client/WhalePet.d.ts +49 -0
  27. package/lib/types/client/WhalePet.d.ts.map +1 -0
  28. package/lib/types/client/index.d.ts +46 -0
  29. package/lib/types/client/index.d.ts.map +1 -0
  30. package/lib/types/client/locales.d.ts +101 -0
  31. package/lib/types/client/locales.d.ts.map +1 -0
  32. package/lib/types/client/pet-store.d.ts +49 -0
  33. package/lib/types/client/pet-store.d.ts.map +1 -0
  34. package/lib/types/client/settings-form.d.ts +119 -0
  35. package/lib/types/client/settings-form.d.ts.map +1 -0
  36. package/lib/types/client/slots-augment.d.ts +19 -0
  37. package/lib/types/client/slots-augment.d.ts.map +1 -0
  38. package/lib/types/client/spritesheet.d.ts +69 -0
  39. package/lib/types/client/spritesheet.d.ts.map +1 -0
  40. package/lib/types/index.d.ts +44 -0
  41. package/lib/types/index.d.ts.map +1 -0
  42. package/lib/types/invariant.d.ts +10 -0
  43. package/lib/types/invariant.d.ts.map +1 -0
  44. package/lib/types/persist.d.ts +45 -0
  45. package/lib/types/persist.d.ts.map +1 -0
  46. package/lib/types/routes.d.ts +22 -0
  47. package/lib/types/routes.d.ts.map +1 -0
  48. package/lib/types/service.d.ts +161 -0
  49. package/lib/types/service.d.ts.map +1 -0
  50. package/lib/types/state.d.ts +80 -0
  51. package/lib/types/state.d.ts.map +1 -0
  52. package/lib/types/treats.d.ts +59 -0
  53. package/lib/types/treats.d.ts.map +1 -0
  54. package/package.json +100 -0
  55. package/src/affinity.test.ts +74 -0
  56. package/src/affinity.ts +144 -0
  57. package/src/client/PetDockEntry.tsx +95 -0
  58. package/src/client/PetSettingsCard.tsx +186 -0
  59. package/src/client/PluginSettingsCard.tsx +207 -0
  60. package/src/client/WhalePet.tsx +342 -0
  61. package/src/client/css-modules.d.ts +6 -0
  62. package/src/client/index.ts +261 -0
  63. package/src/client/locales.ts +108 -0
  64. package/src/client/pet-store.ts +80 -0
  65. package/src/client/pet.module.css +185 -0
  66. package/src/client/settings-card.module.css +277 -0
  67. package/src/client/settings-form.ts +294 -0
  68. package/src/client/slots-augment.ts +27 -0
  69. package/src/client/spritesheet.ts +138 -0
  70. package/src/index.ts +148 -0
  71. package/src/invariant.ts +40 -0
  72. package/src/persist.test.ts +96 -0
  73. package/src/persist.ts +128 -0
  74. package/src/routes.ts +171 -0
  75. package/src/service.ts +389 -0
  76. package/src/state.test.ts +65 -0
  77. package/src/state.ts +156 -0
  78. package/src/treats.test.ts +86 -0
  79. package/src/treats.ts +101 -0
package/src/routes.ts ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Pet HTTP routes — the browser half talks to the host through plain
3
+ * same-origin JSON endpoints (`/api/pet/*`) and loads the whale-girl atlas
4
+ * from `/pet/whale/*`. The `/plugins/` endpoint only serves client bundles
5
+ * and RPC domains are platform-registered, so the pet serves its own API
6
+ * and media — the same pattern as dsh-remote-web-ui's `/api/pair` family.
7
+ * @module @linxin666/dsh-pet/routes
8
+ */
9
+
10
+ import type { IncomingMessage, ServerResponse } from 'node:http'
11
+ import { readFile } from 'node:fs/promises'
12
+ import { join } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
15
+ import type { PetService } from './service.ts'
16
+ import type { PetInteraction } from './affinity.ts'
17
+
18
+ /** Browser-facing base path of the pet API. */
19
+ export const PET_API_PREFIX = '/api/pet'
20
+
21
+ /** Browser-facing base path of the pet asset routes. */
22
+ export const PET_ASSET_PREFIX = '/pet/whale'
23
+
24
+ /** Relative (to package root) asset files exposed under the prefix. */
25
+ const ASSET_FILES = [
26
+ { name: 'spritesheet.webp', mime: 'image/webp' },
27
+ { name: 'pet.json', mime: 'application/json' },
28
+ ] as const
29
+
30
+ /** Absolute package root, resolved from this module's own location (lib/). */
31
+ export function petPackageRoot(importMetaUrl: string): string {
32
+ return fileURLToPath(new URL('../', importMetaUrl))
33
+ }
34
+
35
+ /** Write one JSON response. */
36
+ function json(res: ServerResponse, status: number, body: unknown): void {
37
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
38
+ res.end(JSON.stringify(body))
39
+ }
40
+
41
+ /** Require the method or answer 405. */
42
+ function requireMethod(req: IncomingMessage, res: ServerResponse, method: string): boolean {
43
+ if (req.method === method) return true
44
+ json(res, 405, { ok: false, error: 'method-not-allowed' })
45
+ return false
46
+ }
47
+
48
+ /** Read a JSON request body (bounded). */
49
+ function readJsonBody(req: IncomingMessage): Promise<unknown> {
50
+ return new Promise((resolve, reject) => {
51
+ let size = 0
52
+ const chunks: Buffer[] = []
53
+ req.on('data', (chunk: Buffer) => {
54
+ size += chunk.length
55
+ if (size > 64 * 1024) {
56
+ // Reject first so the error handler can write the 400 response,
57
+ // then close the connection once the response is flushed.
58
+ reject(new Error('body-too-large'))
59
+ queueMicrotask(() => req.destroy())
60
+ return
61
+ }
62
+ chunks.push(chunk)
63
+ })
64
+ req.on('end', () => {
65
+ if (chunks.length === 0) {
66
+ resolve({})
67
+ return
68
+ }
69
+ try {
70
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
71
+ } catch {
72
+ reject(new Error('invalid-json'))
73
+ }
74
+ })
75
+ req.on('error', reject)
76
+ })
77
+ }
78
+
79
+ /** Wrap one async service call as a GET JSON route. */
80
+ function getRoute(path: string, run: () => Promise<unknown>): WebRoute {
81
+ return {
82
+ kind: 'exact',
83
+ path,
84
+ handler: (req: IncomingMessage, res: ServerResponse): void => {
85
+ if (!requireMethod(req, res, 'GET')) return
86
+ run().then((value) => json(res, 200, value), (error) => {
87
+ json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
88
+ })
89
+ },
90
+ }
91
+ }
92
+
93
+ /** Wrap one async service call as a POST JSON route (body passed through). */
94
+ function postRoute(path: string, run: (body: Record<string, unknown>) => Promise<unknown>): WebRoute {
95
+ return {
96
+ kind: 'exact',
97
+ path,
98
+ handler: (req: IncomingMessage, res: ServerResponse): Promise<void> => {
99
+ if (!requireMethod(req, res, 'POST')) return Promise.resolve()
100
+ return readJsonBody(req).then((body) => {
101
+ const record = (typeof body === 'object' && body !== null) ? body as Record<string, unknown> : {}
102
+ return run(record).then(
103
+ (value) => json(res, 200, value),
104
+ (error) => {
105
+ json(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
106
+ },
107
+ )
108
+ }, (error) => {
109
+ json(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
110
+ })
111
+ },
112
+ }
113
+ }
114
+
115
+ /** Build the full route family (API + assets) for one service + package root. */
116
+ export function makePetRoutes(deps: { service: PetService; packageRoot: string }): WebRoute[] {
117
+ const { service, packageRoot } = deps
118
+ const apiRoutes: WebRoute[] = [
119
+ getRoute(`${PET_API_PREFIX}/state`, () => service.state()),
120
+ postRoute(`${PET_API_PREFIX}/interact`, (body) => {
121
+ const kind = body.kind as PetInteraction | undefined
122
+ if (kind !== 'pet' && kind !== 'feed') return Promise.reject(new Error('invalid-kind'))
123
+ return service.interact(kind)
124
+ }),
125
+ postRoute(`${PET_API_PREFIX}/set-visible`, (body) => {
126
+ const visible = body.visible
127
+ if (typeof visible !== 'boolean') return Promise.reject(new Error('invalid-visible'))
128
+ return service.setVisible(visible)
129
+ }),
130
+ postRoute(`${PET_API_PREFIX}/set-config`, (body) => service.setConfig({
131
+ ...(typeof body.size === 'number' ? { size: body.size } : {}),
132
+ ...(typeof body.right === 'number' ? { right: body.right } : {}),
133
+ ...(typeof body.bottom === 'number' ? { bottom: body.bottom } : {}),
134
+ ...(typeof body.visible === 'boolean' ? { visible: body.visible } : {}),
135
+ })),
136
+ postRoute(`${PET_API_PREFIX}/set-name`, (body) => {
137
+ const name = body.name
138
+ if (typeof name !== 'string') return Promise.reject(new Error('invalid-name'))
139
+ return service.setName(name)
140
+ }),
141
+ ]
142
+
143
+ const assetRoutes: WebRoute[] = ASSET_FILES.map((file): WebRoute => ({
144
+ kind: 'exact',
145
+ path: `${PET_ASSET_PREFIX}/${file.name}`,
146
+ handler: (req: IncomingMessage, res: ServerResponse): Promise<void> | void => {
147
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
148
+ res.writeHead(405)
149
+ res.end()
150
+ return
151
+ }
152
+ return readFile(join(packageRoot, 'assets', 'whale', file.name)).then((body) => {
153
+ res.writeHead(200, {
154
+ 'content-type': file.mime,
155
+ 'content-length': String(body.byteLength),
156
+ 'cache-control': 'no-cache',
157
+ })
158
+ if (req.method === 'HEAD') {
159
+ res.end()
160
+ return
161
+ }
162
+ res.end(body)
163
+ }, () => {
164
+ res.writeHead(404)
165
+ res.end()
166
+ })
167
+ },
168
+ }))
169
+
170
+ return [...apiRoutes, ...assetRoutes]
171
+ }
package/src/service.ts ADDED
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Pet host service — the `pet.*` RPC domain. Owns the state machine wiring
3
+ * (consumes `activity/status` session events and session lifecycle), the
4
+ * affinity ledger, and the persisted display config. The API gateway maps
5
+ * this service's methods onto `pet.state` / `pet.interact` /
6
+ * `pet.setVisible` / `pet.setConfig` for browser consumers.
7
+ * @module @linxin666/dsh-pet/service
8
+ */
9
+
10
+ import { Context, Service } from '@deepseek-ai/cordis'
11
+ import type { Session } from '@deepseek-ai/dsh-session'
12
+ import {
13
+ applyInteraction,
14
+ applyTurnReward,
15
+ defaultAffinityConfig,
16
+ rankOf,
17
+ type AffinityConfig,
18
+ type AffinityState,
19
+ type PetInteraction,
20
+ } from './affinity.ts'
21
+ import {
22
+ loadPetPersist,
23
+ petHomeDir,
24
+ savePetPersist,
25
+ DISPLAY_SIZE_MAX,
26
+ DISPLAY_SIZE_MIN,
27
+ DISPLAY_INSET_MAX,
28
+ PET_NAME_MAX_LENGTH,
29
+ type PetDisplayConfig,
30
+ type PetPersist,
31
+ } from './persist.ts'
32
+ import {
33
+ defaultTreatConfig,
34
+ settleTreatGrants,
35
+ consumeTreat,
36
+ type TreatConfig,
37
+ } from './treats.ts'
38
+ import {
39
+ defaultPetStateConfig,
40
+ PetStateMachine,
41
+ type PetStateConfig,
42
+ type PetStateSnapshot,
43
+ } from './state.ts'
44
+
45
+ /** Plugin configuration. */
46
+ export interface PetConfig {
47
+ /** Affinity tuning. */
48
+ affinity?: Partial<AffinityConfig>
49
+ /** State machine tuning. */
50
+ state?: Partial<PetStateConfig>
51
+ /** Treat economy tuning. */
52
+ treats?: Partial<TreatConfig>
53
+ /** Persistence directory override (defaults to $DSH_HOME). */
54
+ persistDir?: string
55
+ /** Master switch for the plugin (browser half + host routes). */
56
+ enabled?: boolean
57
+ }
58
+
59
+ /**
60
+ * The pet's settings-namespace section: the display fields and name the web
61
+ * settings surface edits. `right`/`bottom` are also updated by drag
62
+ * interactions, which keep the settings document in sync through the service.
63
+ */
64
+ export interface PetSettingsSection {
65
+ /** Master switch. */
66
+ visible: boolean
67
+ /** Scale of the rendered pet in px (sprite cell height). */
68
+ size: number
69
+ /** Horizontal inset from the viewport right edge, px. */
70
+ right: number
71
+ /** Vertical inset from the viewport bottom edge, px. */
72
+ bottom: number
73
+ /** User-customizable pet display name. */
74
+ name: string
75
+ /** Master switch for the plugin (browser half + host routes). */
76
+ enabled?: boolean
77
+ }
78
+
79
+ /** Settings namespace of the pet capability. Spelled here rather than imported: the browser half spells the same value. */
80
+ export const PET_SETTINGS_NAMESPACE = 'pet'
81
+
82
+ /** Snapshot returned by `pet.state`. */
83
+ export interface PetStateView {
84
+ animation: PetStateSnapshot['animation']
85
+ bubble?: string
86
+ phase: PetStateSnapshot['phase']
87
+ sessionActive: boolean
88
+ /** Affinity ledger snapshot. */
89
+ affinity: {
90
+ points: number
91
+ rank: string
92
+ rankEmoji: string
93
+ pets: number
94
+ feeds: number
95
+ turns: number
96
+ /** True while the pet interaction is inside its cooldown. */
97
+ petCooldown: boolean
98
+ /** True while the feed is inside its cooldown. */
99
+ feedCooldown: boolean
100
+ }
101
+ /** Display configuration. */
102
+ display: PetDisplayConfig
103
+ /** User-customizable pet display name. */
104
+ name: string
105
+ /** Treat (小鱼干) stock snapshot. */
106
+ treats: {
107
+ /** Stocked treats now. */
108
+ stocked: number
109
+ /** Stock cap. */
110
+ max: number
111
+ }
112
+ }
113
+
114
+ /** Result of `pet.interact`. */
115
+ export interface PetInteractResult {
116
+ /** Reaction copy bubble. */
117
+ reaction: string
118
+ /** Points gained (0 when inside the cooldown). */
119
+ delta: number
120
+ /** Full affinity snapshot (same shape as state view). */
121
+ affinity: PetStateView['affinity']
122
+ }
123
+
124
+ declare module '@deepseek-ai/cordis' {
125
+ interface Context {
126
+ pet: PetService
127
+ }
128
+ }
129
+
130
+ /** One session/event guard: only the latest activity snapshot matters. */
131
+ interface ActivityStatusEventLike {
132
+ phase?: string
133
+ line?: string
134
+ phrase?: string
135
+ }
136
+
137
+ /**
138
+ * Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
139
+ * written until a query or interaction arrives; event listeners update only
140
+ * in-memory state, and persistence happens on interaction/config changes
141
+ * plus every completed turn.
142
+ */
143
+ export class PetService extends Service {
144
+ static inject: string[] = []
145
+
146
+ private readonly machine: PetStateMachine
147
+ private readonly affinityConfig: AffinityConfig
148
+ private readonly treatConfig: TreatConfig
149
+ private readonly persistDir: string
150
+ private persist: PetPersist
151
+ private lastTurnRewardAt = 0
152
+ private enabled: boolean
153
+ private disposeActivity: (() => void) | undefined
154
+
155
+ constructor(ctx: Context, config: PetConfig = {}) {
156
+ super(ctx, 'pet')
157
+ this.persistDir = config.persistDir ?? petHomeDir()
158
+ this.affinityConfig = { ...defaultAffinityConfig, ...(config.affinity ?? {}) }
159
+ this.treatConfig = { ...defaultTreatConfig, ...(config.treats ?? {}) }
160
+ this.machine = new PetStateMachine({
161
+ ...defaultPetStateConfig,
162
+ ...(config.state ?? {}),
163
+ })
164
+ this.persist = loadPetPersist(this.persistDir)
165
+ this.enabled = config.enabled ?? true
166
+
167
+ this.syncActivity()
168
+ }
169
+
170
+ /** Whether the pet service consumes session activity while enabled. */
171
+ isEnabled(): boolean {
172
+ return this.enabled
173
+ }
174
+
175
+ /** RPC: current pet state snapshot. */
176
+ async state(): Promise<PetStateView> {
177
+ return this.view()
178
+ }
179
+
180
+ /** Current persisted display config (read-only view). */
181
+ display(): PetDisplayConfig {
182
+ return { ...this.persist.display }
183
+ }
184
+
185
+ /** Current persisted pet name (read-only view). */
186
+ petName(): string {
187
+ return this.persist.name
188
+ }
189
+
190
+ /** Start or stop the session-activity listeners that drive the pet. */
191
+ setEnabled(enabled: boolean): void {
192
+ this.enabled = enabled
193
+ this.syncActivity()
194
+ }
195
+
196
+ private syncActivity(): void {
197
+ if (this.disposeActivity !== undefined) {
198
+ this.disposeActivity()
199
+ this.disposeActivity = undefined
200
+ }
201
+ if (!this.enabled) return
202
+ this.disposeActivity = (() => {
203
+ const disposers = [
204
+ this.ctx.on('session/event', (_session: Session, event: { type: string; data?: unknown }) => {
205
+ if (event.type !== 'activity/status') return
206
+ const payload = (event.data ?? {}) as ActivityStatusEventLike
207
+ if (payload.phase === undefined) return
208
+ const phase = payload.phase as PetStateSnapshot['phase']
209
+ // Guard against unknown phases from newer activity trackers.
210
+ if (!['idle', 'waiting', 'thinking', 'tool', 'done'].includes(phase)) return
211
+ this.machine.onActivityStatus({
212
+ phase,
213
+ ...(typeof payload.line === 'string' ? { line: payload.line } : {}),
214
+ ...(typeof payload.phrase === 'string' ? { phrase: payload.phrase } : {}),
215
+ })
216
+ this.machine.onSessionActive()
217
+ if (phase === 'done') this.rewardTurn()
218
+ }),
219
+ this.ctx.on('session/disposed', () => {
220
+ this.machine.onSessionDisposed()
221
+ }),
222
+ ]
223
+ return () => { for (const dispose of disposers) dispose() }
224
+ })()
225
+ }
226
+
227
+ /** RPC: pet or feed the pet. */
228
+ async interact(kind: PetInteraction): Promise<PetInteractResult> {
229
+ const nowMs = Date.now()
230
+ // Feeding consumes a treat: settle the economy first (work + time
231
+ // output since the last settlement), then gate on the feed cooldown
232
+ // BEFORE spending stock — a feed inside the cooldown must not burn a
233
+ // treat for nothing.
234
+ if (kind === 'feed') this.settleTreats(nowMs)
235
+ const outcome = applyInteraction(this.persist.affinity, kind, nowMs, this.affinityConfig)
236
+ if (kind === 'feed' && !outcome.accepted) {
237
+ return { reaction: outcome.reaction, delta: 0, affinity: this.affinityView(this.persist.affinity) }
238
+ }
239
+ if (kind === 'feed') {
240
+ const consume = consumeTreat(this.persist.treats)
241
+ if (!consume.ok) {
242
+ const affinity = this.affinityView(this.persist.affinity)
243
+ return {
244
+ reaction: '没有小鱼干了,多陪鲸鱼娘工作一会儿吧~',
245
+ delta: 0,
246
+ affinity,
247
+ }
248
+ }
249
+ this.persist = { ...this.persist, treats: consume.ledger }
250
+ }
251
+ if (outcome.accepted) {
252
+ this.persist = { ...this.persist, affinity: outcome.affinity }
253
+ this.flush()
254
+ }
255
+ const affinity = this.affinityView(outcome.affinity)
256
+ return { reaction: outcome.reaction, delta: outcome.delta, affinity }
257
+ }
258
+
259
+ /** RPC: show or hide the pet. */
260
+ async setVisible(visible: boolean): Promise<{ ok: true; display: PetDisplayConfig }> {
261
+ this.persist = { ...this.persist, display: { ...this.persist.display, visible } }
262
+ this.flush()
263
+ this.syncSettingsFromPet()
264
+ return { ok: true, display: this.persist.display }
265
+ }
266
+
267
+ /** RPC: update display config (size / position). Values are clamped to whole pixels. */
268
+ async setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }> {
269
+ const next = { ...this.persist.display, ...patch }
270
+ next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, next.size)))
271
+ next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.right)))
272
+ next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.bottom)))
273
+ this.persist = { ...this.persist, display: next }
274
+ this.flush()
275
+ this.syncSettingsFromPet()
276
+ return { ok: true, display: this.persist.display }
277
+ }
278
+
279
+ /** RPC: rename the pet (trimmed, 1–20 chars). */
280
+ async setName(name: string): Promise<{ ok: true; name: string } | { ok: false; error: string }> {
281
+ const trimmed = name.trim()
282
+ if (trimmed === '') return { ok: false, error: 'name-empty' }
283
+ if (trimmed.length > PET_NAME_MAX_LENGTH) return { ok: false, error: 'name-too-long' }
284
+ this.persist = { ...this.persist, name: trimmed }
285
+ this.flush()
286
+ this.syncSettingsFromPet()
287
+ return { ok: true, name: trimmed }
288
+ }
289
+
290
+ /**
291
+ * Apply a committed settings section to the persisted display config. Called
292
+ * by the settings surface on every change; values are clamped exactly like
293
+ * the setConfig RPC so both write paths converge.
294
+ * @param section - the resolved settings section.
295
+ */
296
+ applySettingsSection(section: PetSettingsSection): void {
297
+ const next = { ...this.persist.display }
298
+ next.visible = section.visible && (section.enabled ?? true)
299
+ next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, section.size)))
300
+ next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)))
301
+ next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.bottom)))
302
+ this.persist = { ...this.persist, display: next, name: section.name.trim() }
303
+ this.flush()
304
+ }
305
+
306
+ /** Mirror the persisted display config into the settings document (best-effort). */
307
+ private syncSettingsFromPet(): void {
308
+ const settings = this.ctx.get('settings', false) as { update(ns: string, patch: object): Promise<void> } | undefined
309
+ if (settings === undefined) return
310
+ void settings.update(PET_SETTINGS_NAMESPACE, {
311
+ visible: this.persist.display.visible,
312
+ size: this.persist.display.size,
313
+ right: this.persist.display.right,
314
+ bottom: this.persist.display.bottom,
315
+ name: this.persist.name,
316
+ }).catch(() => {
317
+ // A settings write failure must not break the pet's own persistence.
318
+ })
319
+ }
320
+
321
+ /** Award the turn reward once per done phase (idempotent per transition). */
322
+ private rewardTurn(): void {
323
+ const nowMs = Date.now()
324
+ // A done phase can repeat while celebrating; only reward the first.
325
+ if (nowMs - this.lastTurnRewardAt < 5_000) return
326
+ this.lastTurnRewardAt = nowMs
327
+ this.persist = { ...this.persist, affinity: applyTurnReward(this.persist.affinity, this.affinityConfig) }
328
+ this.flush()
329
+ }
330
+
331
+ /**
332
+ * Settle the treat economy (work + time output since the last
333
+ * settlement); persists only when treats were actually granted.
334
+ */
335
+ private settleTreats(nowMs: number): void {
336
+ const settlement = settleTreatGrants(
337
+ this.persist.treats,
338
+ this.persist.affinity.turns,
339
+ nowMs,
340
+ this.treatConfig,
341
+ )
342
+ if (settlement.gained > 0) {
343
+ this.persist = { ...this.persist, treats: settlement.ledger }
344
+ this.flush()
345
+ }
346
+ }
347
+
348
+ private view(): PetStateView {
349
+ const snapshot = this.machine.render()
350
+ // Time-output treats accrue while the host is idle too; settle on read.
351
+ this.settleTreats(Date.now())
352
+ return {
353
+ animation: snapshot.animation,
354
+ ...(snapshot.bubble === undefined ? {} : { bubble: snapshot.bubble }),
355
+ phase: snapshot.phase,
356
+ sessionActive: snapshot.sessionActive,
357
+ affinity: this.affinityView(this.persist.affinity),
358
+ display: { ...this.persist.display },
359
+ name: this.persist.name,
360
+ treats: {
361
+ stocked: this.persist.treats.treats,
362
+ max: this.treatConfig.maxTreats,
363
+ },
364
+ }
365
+ }
366
+
367
+ private affinityView(affinity: AffinityState): PetStateView['affinity'] {
368
+ const nowMs = Date.now()
369
+ const rank = rankOf(affinity.points)
370
+ return {
371
+ points: affinity.points,
372
+ rank: rank.name,
373
+ rankEmoji: rank.emoji,
374
+ pets: affinity.pets,
375
+ feeds: affinity.feeds,
376
+ turns: affinity.turns,
377
+ petCooldown: nowMs - affinity.lastPetAt < this.affinityConfig.petCooldownMs,
378
+ feedCooldown: nowMs - affinity.lastFeedAt < this.affinityConfig.feedCooldownMs,
379
+ }
380
+ }
381
+
382
+ private flush(): void {
383
+ try {
384
+ savePetPersist(this.persist, this.persistDir)
385
+ } catch {
386
+ // Persistence is best-effort; the in-memory ledger keeps working.
387
+ }
388
+ }
389
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ animationForPhase,
4
+ defaultPetStateConfig,
5
+ PetStateMachine,
6
+ rowOf,
7
+ type PetAnimation,
8
+ } from './state.ts'
9
+
10
+ describe('animationForPhase', () => {
11
+ it('maps each activity phase onto the animation contract', () => {
12
+ expect(animationForPhase('thinking')).toBe('running')
13
+ expect(animationForPhase('tool')).toBe('running-right')
14
+ expect(animationForPhase('waiting')).toBe('waiting')
15
+ expect(animationForPhase('done')).toBe('jumping')
16
+ expect(animationForPhase('idle')).toBe('idle')
17
+ })
18
+ })
19
+
20
+ describe('PetStateMachine', () => {
21
+ it('celebrates for celebrateMs after done, then settles to idle', () => {
22
+ let now = 1_000_000
23
+ const machine = new PetStateMachine({ celebrateMs: 2400 }, () => now)
24
+ machine.onSessionActive()
25
+ machine.onActivityStatus({ phase: 'done', line: '完成' })
26
+ expect(machine.render().animation).toBe('jumping')
27
+ now += 2399
28
+ expect(machine.render().animation).toBe('jumping')
29
+ now += 2
30
+ expect(machine.render().animation).toBe('idle')
31
+ })
32
+
33
+ it('shows the phrase bubble when present, else the line', () => {
34
+ const machine = new PetStateMachine(defaultPetStateConfig, () => 1_000)
35
+ machine.onActivityStatus({ phase: 'thinking', phrase: '查资料中', line: 'tool: grep' })
36
+ expect(machine.render().bubble).toBe('查资料中')
37
+ machine.onActivityStatus({ phase: 'thinking', line: 'tool: grep' })
38
+ expect(machine.render().bubble).toBe('tool: grep')
39
+ machine.onActivityStatus({ phase: 'waiting' })
40
+ expect(machine.render().bubble).toBeUndefined()
41
+ })
42
+
43
+ it('resets on session dispose', () => {
44
+ const machine = new PetStateMachine(defaultPetStateConfig, () => 1_000)
45
+ machine.onSessionActive()
46
+ machine.onActivityStatus({ phase: 'done' })
47
+ machine.onSessionDisposed()
48
+ const s = machine.render()
49
+ expect(s.sessionActive).toBe(false)
50
+ expect(s.animation).toBe('idle')
51
+ expect(s.phase).toBe('idle')
52
+ })
53
+
54
+ it('keeps every animation on a known spritesheet row', () => {
55
+ const animations: readonly PetAnimation[] = [
56
+ 'idle', 'running-right', 'running-left', 'waving', 'jumping',
57
+ 'failed', 'waiting', 'running', 'review',
58
+ ]
59
+ for (const animation of animations) {
60
+ const row = rowOf(animation)
61
+ expect(row).toBeGreaterThanOrEqual(0)
62
+ expect(row).toBeLessThanOrEqual(8)
63
+ }
64
+ })
65
+ })