@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.
- package/LICENSE +29 -0
- package/README.md +106 -0
- package/assets/whale/pet.json +7 -0
- package/assets/whale/previews/failed.gif +0 -0
- package/assets/whale/previews/idle.gif +0 -0
- package/assets/whale/previews/jumping.gif +0 -0
- package/assets/whale/previews/review.gif +0 -0
- package/assets/whale/previews/running-left.gif +0 -0
- package/assets/whale/previews/running-right.gif +0 -0
- package/assets/whale/previews/running.gif +0 -0
- package/assets/whale/previews/waiting.gif +0 -0
- package/assets/whale/previews/waving.gif +0 -0
- package/assets/whale/spritesheet.webp +0 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +1515 -0
- package/lib/index.js +642 -0
- package/lib/invariant.js +34 -0
- package/lib/types/affinity.d.ts +83 -0
- package/lib/types/affinity.d.ts.map +1 -0
- package/lib/types/client/PetDockEntry.d.ts +44 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -0
- package/lib/types/client/PetSettingsCard.d.ts +67 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +78 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/WhalePet.d.ts +49 -0
- package/lib/types/client/WhalePet.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +46 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +101 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/pet-store.d.ts +49 -0
- package/lib/types/client/pet-store.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +119 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/client/slots-augment.d.ts +19 -0
- package/lib/types/client/slots-augment.d.ts.map +1 -0
- package/lib/types/client/spritesheet.d.ts +69 -0
- package/lib/types/client/spritesheet.d.ts.map +1 -0
- package/lib/types/index.d.ts +44 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/invariant.d.ts +10 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/persist.d.ts +45 -0
- package/lib/types/persist.d.ts.map +1 -0
- package/lib/types/routes.d.ts +22 -0
- package/lib/types/routes.d.ts.map +1 -0
- package/lib/types/service.d.ts +161 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/state.d.ts +80 -0
- package/lib/types/state.d.ts.map +1 -0
- package/lib/types/treats.d.ts +59 -0
- package/lib/types/treats.d.ts.map +1 -0
- package/package.json +100 -0
- package/src/affinity.test.ts +74 -0
- package/src/affinity.ts +144 -0
- package/src/client/PetDockEntry.tsx +95 -0
- package/src/client/PetSettingsCard.tsx +186 -0
- package/src/client/PluginSettingsCard.tsx +207 -0
- package/src/client/WhalePet.tsx +342 -0
- package/src/client/css-modules.d.ts +6 -0
- package/src/client/index.ts +261 -0
- package/src/client/locales.ts +108 -0
- package/src/client/pet-store.ts +80 -0
- package/src/client/pet.module.css +185 -0
- package/src/client/settings-card.module.css +277 -0
- package/src/client/settings-form.ts +294 -0
- package/src/client/slots-augment.ts +27 -0
- package/src/client/spritesheet.ts +138 -0
- package/src/index.ts +148 -0
- package/src/invariant.ts +40 -0
- package/src/persist.test.ts +96 -0
- package/src/persist.ts +128 -0
- package/src/routes.ts +171 -0
- package/src/service.ts +389 -0
- package/src/state.test.ts +65 -0
- package/src/state.ts +156 -0
- package/src/treats.test.ts +86 -0
- 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
|
+
})
|