@linxin666/dsh-pet 0.1.1 → 0.1.3
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.md +4 -1
- package/lib/client.js.map +1 -0
- package/lib/state-7ChK99mH.js +217 -0
- package/lib/state-CKh9f_dF.js +196 -0
- package/lib/state-DMgOYVFT.js +198 -0
- package/lib/state-IyVnKymD.js +198 -0
- package/lib/types/affinity.js +84 -0
- package/lib/types/client/DockPetDockEntry.d.ts +23 -0
- package/lib/types/client/DockPetDockEntry.d.ts.map +1 -0
- package/lib/types/client/DockPetDockEntry.js +10 -0
- package/lib/types/client/PetDockEntry.js +34 -0
- package/lib/types/client/PetSettingsCard.js +55 -0
- package/lib/types/client/PluginSettingsCard.js +39 -0
- package/lib/types/client/WhalePet.js +239 -0
- package/lib/types/client/index.js +206 -0
- package/lib/types/client/locales.js +92 -0
- package/lib/types/client/pet-store.js +33 -0
- package/lib/types/client/settings-form.js +211 -0
- package/lib/types/client/slots-augment.js +1 -0
- package/lib/types/client/spritesheet.js +120 -0
- package/lib/types/index.js +84 -0
- package/lib/types/invariant.js +27 -0
- package/lib/types/persist.js +96 -0
- package/lib/types/routes.js +158 -0
- package/lib/types/service.js +255 -0
- package/lib/types/state.js +106 -0
- package/lib/types/treats.js +63 -0
- package/package.json +11 -9
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
/** Browser-facing base path of the pet API. */
|
|
13
|
+
export const PET_API_PREFIX = '/api/pet';
|
|
14
|
+
/** Browser-facing base path of the pet asset routes. */
|
|
15
|
+
export const PET_ASSET_PREFIX = '/pet/whale';
|
|
16
|
+
/** Relative (to package root) asset files exposed under the prefix. */
|
|
17
|
+
const ASSET_FILES = [
|
|
18
|
+
{ name: 'spritesheet.webp', mime: 'image/webp' },
|
|
19
|
+
{ name: 'pet.json', mime: 'application/json' },
|
|
20
|
+
];
|
|
21
|
+
/** Absolute package root, resolved from this module's own location (lib/). */
|
|
22
|
+
export function petPackageRoot(importMetaUrl) {
|
|
23
|
+
return fileURLToPath(new URL('../', importMetaUrl));
|
|
24
|
+
}
|
|
25
|
+
/** Write one JSON response. */
|
|
26
|
+
function json(res, status, body) {
|
|
27
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
28
|
+
res.end(JSON.stringify(body));
|
|
29
|
+
}
|
|
30
|
+
/** Require the method or answer 405. */
|
|
31
|
+
function requireMethod(req, res, method) {
|
|
32
|
+
if (req.method === method)
|
|
33
|
+
return true;
|
|
34
|
+
json(res, 405, { ok: false, error: 'method-not-allowed' });
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
/** Read a JSON request body (bounded). */
|
|
38
|
+
function readJsonBody(req) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
let size = 0;
|
|
41
|
+
const chunks = [];
|
|
42
|
+
req.on('data', (chunk) => {
|
|
43
|
+
size += chunk.length;
|
|
44
|
+
if (size > 64 * 1024) {
|
|
45
|
+
// Reject first so the error handler can write the 400 response,
|
|
46
|
+
// then close the connection once the response is flushed.
|
|
47
|
+
reject(new Error('body-too-large'));
|
|
48
|
+
queueMicrotask(() => req.destroy());
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
chunks.push(chunk);
|
|
52
|
+
});
|
|
53
|
+
req.on('end', () => {
|
|
54
|
+
if (chunks.length === 0) {
|
|
55
|
+
resolve({});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
reject(new Error('invalid-json'));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
req.on('error', reject);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** Wrap one async service call as a GET JSON route. */
|
|
69
|
+
function getRoute(path, run) {
|
|
70
|
+
return {
|
|
71
|
+
kind: 'exact',
|
|
72
|
+
path,
|
|
73
|
+
handler: (req, res) => {
|
|
74
|
+
if (!requireMethod(req, res, 'GET'))
|
|
75
|
+
return;
|
|
76
|
+
run().then((value) => json(res, 200, value), (error) => {
|
|
77
|
+
json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Wrap one async service call as a POST JSON route (body passed through). */
|
|
83
|
+
function postRoute(path, run) {
|
|
84
|
+
return {
|
|
85
|
+
kind: 'exact',
|
|
86
|
+
path,
|
|
87
|
+
handler: (req, res) => {
|
|
88
|
+
if (!requireMethod(req, res, 'POST'))
|
|
89
|
+
return Promise.resolve();
|
|
90
|
+
return readJsonBody(req).then((body) => {
|
|
91
|
+
const record = (typeof body === 'object' && body !== null) ? body : {};
|
|
92
|
+
return run(record).then((value) => json(res, 200, value), (error) => {
|
|
93
|
+
json(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
94
|
+
});
|
|
95
|
+
}, (error) => {
|
|
96
|
+
json(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Build the full route family (API + assets) for one service + package root. */
|
|
102
|
+
export function makePetRoutes(deps) {
|
|
103
|
+
const { service, packageRoot } = deps;
|
|
104
|
+
const apiRoutes = [
|
|
105
|
+
getRoute(`${PET_API_PREFIX}/state`, () => service.state()),
|
|
106
|
+
postRoute(`${PET_API_PREFIX}/interact`, (body) => {
|
|
107
|
+
const kind = body.kind;
|
|
108
|
+
if (kind !== 'pet' && kind !== 'feed')
|
|
109
|
+
return Promise.reject(new Error('invalid-kind'));
|
|
110
|
+
return service.interact(kind);
|
|
111
|
+
}),
|
|
112
|
+
postRoute(`${PET_API_PREFIX}/set-visible`, (body) => {
|
|
113
|
+
const visible = body.visible;
|
|
114
|
+
if (typeof visible !== 'boolean')
|
|
115
|
+
return Promise.reject(new Error('invalid-visible'));
|
|
116
|
+
return service.setVisible(visible);
|
|
117
|
+
}),
|
|
118
|
+
postRoute(`${PET_API_PREFIX}/set-config`, (body) => service.setConfig({
|
|
119
|
+
...(typeof body.size === 'number' ? { size: body.size } : {}),
|
|
120
|
+
...(typeof body.right === 'number' ? { right: body.right } : {}),
|
|
121
|
+
...(typeof body.bottom === 'number' ? { bottom: body.bottom } : {}),
|
|
122
|
+
...(typeof body.visible === 'boolean' ? { visible: body.visible } : {}),
|
|
123
|
+
})),
|
|
124
|
+
postRoute(`${PET_API_PREFIX}/set-name`, (body) => {
|
|
125
|
+
const name = body.name;
|
|
126
|
+
if (typeof name !== 'string')
|
|
127
|
+
return Promise.reject(new Error('invalid-name'));
|
|
128
|
+
return service.setName(name);
|
|
129
|
+
}),
|
|
130
|
+
];
|
|
131
|
+
const assetRoutes = ASSET_FILES.map((file) => ({
|
|
132
|
+
kind: 'exact',
|
|
133
|
+
path: `${PET_ASSET_PREFIX}/${file.name}`,
|
|
134
|
+
handler: (req, res) => {
|
|
135
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
136
|
+
res.writeHead(405);
|
|
137
|
+
res.end();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
return readFile(join(packageRoot, 'assets', 'whale', file.name)).then((body) => {
|
|
141
|
+
res.writeHead(200, {
|
|
142
|
+
'content-type': file.mime,
|
|
143
|
+
'content-length': String(body.byteLength),
|
|
144
|
+
'cache-control': 'no-cache',
|
|
145
|
+
});
|
|
146
|
+
if (req.method === 'HEAD') {
|
|
147
|
+
res.end();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
res.end(body);
|
|
151
|
+
}, () => {
|
|
152
|
+
res.writeHead(404);
|
|
153
|
+
res.end();
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
}));
|
|
157
|
+
return [...apiRoutes, ...assetRoutes];
|
|
158
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
10
|
+
import { applyInteraction, applyTurnReward, defaultAffinityConfig, rankOf, } from "./affinity.js";
|
|
11
|
+
import { loadPetPersist, petHomeDir, savePetPersist, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN, DISPLAY_INSET_MAX, PET_NAME_MAX_LENGTH, } from "./persist.js";
|
|
12
|
+
import { defaultTreatConfig, settleTreatGrants, consumeTreat, } from "./treats.js";
|
|
13
|
+
import { defaultPetStateConfig, PetStateMachine, } from "./state.js";
|
|
14
|
+
/** Settings namespace of the pet capability. Spelled here rather than imported: the browser half spells the same value. */
|
|
15
|
+
export const PET_SETTINGS_NAMESPACE = 'pet';
|
|
16
|
+
/**
|
|
17
|
+
* Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
|
|
18
|
+
* written until a query or interaction arrives; event listeners update only
|
|
19
|
+
* in-memory state, and persistence happens on interaction/config changes
|
|
20
|
+
* plus every completed turn.
|
|
21
|
+
*/
|
|
22
|
+
export class PetService extends Service {
|
|
23
|
+
static inject = [];
|
|
24
|
+
machine;
|
|
25
|
+
affinityConfig;
|
|
26
|
+
treatConfig;
|
|
27
|
+
persistDir;
|
|
28
|
+
persist;
|
|
29
|
+
lastTurnRewardAt = 0;
|
|
30
|
+
enabled;
|
|
31
|
+
disposeActivity;
|
|
32
|
+
constructor(ctx, config = {}) {
|
|
33
|
+
super(ctx, 'pet');
|
|
34
|
+
this.persistDir = config.persistDir ?? petHomeDir();
|
|
35
|
+
this.affinityConfig = { ...defaultAffinityConfig, ...(config.affinity ?? {}) };
|
|
36
|
+
this.treatConfig = { ...defaultTreatConfig, ...(config.treats ?? {}) };
|
|
37
|
+
this.machine = new PetStateMachine({
|
|
38
|
+
...defaultPetStateConfig,
|
|
39
|
+
...(config.state ?? {}),
|
|
40
|
+
});
|
|
41
|
+
this.persist = loadPetPersist(this.persistDir);
|
|
42
|
+
this.enabled = config.enabled ?? true;
|
|
43
|
+
this.syncActivity();
|
|
44
|
+
}
|
|
45
|
+
/** Whether the pet service consumes session activity while enabled. */
|
|
46
|
+
isEnabled() {
|
|
47
|
+
return this.enabled;
|
|
48
|
+
}
|
|
49
|
+
/** RPC: current pet state snapshot. */
|
|
50
|
+
async state() {
|
|
51
|
+
return this.view();
|
|
52
|
+
}
|
|
53
|
+
/** Current persisted display config (read-only view). */
|
|
54
|
+
display() {
|
|
55
|
+
return { ...this.persist.display };
|
|
56
|
+
}
|
|
57
|
+
/** Current persisted pet name (read-only view). */
|
|
58
|
+
petName() {
|
|
59
|
+
return this.persist.name;
|
|
60
|
+
}
|
|
61
|
+
/** Start or stop the session-activity listeners that drive the pet. */
|
|
62
|
+
setEnabled(enabled) {
|
|
63
|
+
this.enabled = enabled;
|
|
64
|
+
this.syncActivity();
|
|
65
|
+
}
|
|
66
|
+
syncActivity() {
|
|
67
|
+
if (this.disposeActivity !== undefined) {
|
|
68
|
+
this.disposeActivity();
|
|
69
|
+
this.disposeActivity = undefined;
|
|
70
|
+
}
|
|
71
|
+
if (!this.enabled)
|
|
72
|
+
return;
|
|
73
|
+
this.disposeActivity = (() => {
|
|
74
|
+
const disposers = [
|
|
75
|
+
this.ctx.on('session/event', (_session, event) => {
|
|
76
|
+
if (event.type !== 'activity/status')
|
|
77
|
+
return;
|
|
78
|
+
const payload = (event.data ?? {});
|
|
79
|
+
if (payload.phase === undefined)
|
|
80
|
+
return;
|
|
81
|
+
const phase = payload.phase;
|
|
82
|
+
// Guard against unknown phases from newer activity trackers.
|
|
83
|
+
if (!['idle', 'waiting', 'thinking', 'tool', 'done'].includes(phase))
|
|
84
|
+
return;
|
|
85
|
+
this.machine.onActivityStatus({
|
|
86
|
+
phase,
|
|
87
|
+
...(typeof payload.line === 'string' ? { line: payload.line } : {}),
|
|
88
|
+
...(typeof payload.phrase === 'string' ? { phrase: payload.phrase } : {}),
|
|
89
|
+
});
|
|
90
|
+
this.machine.onSessionActive();
|
|
91
|
+
if (phase === 'done')
|
|
92
|
+
this.rewardTurn();
|
|
93
|
+
}),
|
|
94
|
+
this.ctx.on('session/disposed', () => {
|
|
95
|
+
this.machine.onSessionDisposed();
|
|
96
|
+
}),
|
|
97
|
+
];
|
|
98
|
+
return () => { for (const dispose of disposers)
|
|
99
|
+
dispose(); };
|
|
100
|
+
})();
|
|
101
|
+
}
|
|
102
|
+
/** RPC: pet or feed the pet. */
|
|
103
|
+
async interact(kind) {
|
|
104
|
+
const nowMs = Date.now();
|
|
105
|
+
// Feeding consumes a treat: settle the economy first (work + time
|
|
106
|
+
// output since the last settlement), then gate on the feed cooldown
|
|
107
|
+
// BEFORE spending stock — a feed inside the cooldown must not burn a
|
|
108
|
+
// treat for nothing.
|
|
109
|
+
if (kind === 'feed')
|
|
110
|
+
this.settleTreats(nowMs);
|
|
111
|
+
const outcome = applyInteraction(this.persist.affinity, kind, nowMs, this.affinityConfig);
|
|
112
|
+
if (kind === 'feed' && !outcome.accepted) {
|
|
113
|
+
return { reaction: outcome.reaction, delta: 0, affinity: this.affinityView(this.persist.affinity) };
|
|
114
|
+
}
|
|
115
|
+
if (kind === 'feed') {
|
|
116
|
+
const consume = consumeTreat(this.persist.treats);
|
|
117
|
+
if (!consume.ok) {
|
|
118
|
+
const affinity = this.affinityView(this.persist.affinity);
|
|
119
|
+
return {
|
|
120
|
+
reaction: '没有小鱼干了,多陪鲸鱼娘工作一会儿吧~',
|
|
121
|
+
delta: 0,
|
|
122
|
+
affinity,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
this.persist = { ...this.persist, treats: consume.ledger };
|
|
126
|
+
}
|
|
127
|
+
if (outcome.accepted) {
|
|
128
|
+
this.persist = { ...this.persist, affinity: outcome.affinity };
|
|
129
|
+
this.flush();
|
|
130
|
+
}
|
|
131
|
+
const affinity = this.affinityView(outcome.affinity);
|
|
132
|
+
return { reaction: outcome.reaction, delta: outcome.delta, affinity };
|
|
133
|
+
}
|
|
134
|
+
/** RPC: show or hide the pet. */
|
|
135
|
+
async setVisible(visible) {
|
|
136
|
+
this.persist = { ...this.persist, display: { ...this.persist.display, visible } };
|
|
137
|
+
this.flush();
|
|
138
|
+
this.syncSettingsFromPet();
|
|
139
|
+
return { ok: true, display: this.persist.display };
|
|
140
|
+
}
|
|
141
|
+
/** RPC: update display config (size / position). Values are clamped to whole pixels. */
|
|
142
|
+
async setConfig(patch) {
|
|
143
|
+
const next = { ...this.persist.display, ...patch };
|
|
144
|
+
next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, next.size)));
|
|
145
|
+
next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.right)));
|
|
146
|
+
next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.bottom)));
|
|
147
|
+
this.persist = { ...this.persist, display: next };
|
|
148
|
+
this.flush();
|
|
149
|
+
this.syncSettingsFromPet();
|
|
150
|
+
return { ok: true, display: this.persist.display };
|
|
151
|
+
}
|
|
152
|
+
/** RPC: rename the pet (trimmed, 1–20 chars). */
|
|
153
|
+
async setName(name) {
|
|
154
|
+
const trimmed = name.trim();
|
|
155
|
+
if (trimmed === '')
|
|
156
|
+
return { ok: false, error: 'name-empty' };
|
|
157
|
+
if (trimmed.length > PET_NAME_MAX_LENGTH)
|
|
158
|
+
return { ok: false, error: 'name-too-long' };
|
|
159
|
+
this.persist = { ...this.persist, name: trimmed };
|
|
160
|
+
this.flush();
|
|
161
|
+
this.syncSettingsFromPet();
|
|
162
|
+
return { ok: true, name: trimmed };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Apply a committed settings section to the persisted display config. Called
|
|
166
|
+
* by the settings surface on every change; values are clamped exactly like
|
|
167
|
+
* the setConfig RPC so both write paths converge.
|
|
168
|
+
* @param section - the resolved settings section.
|
|
169
|
+
*/
|
|
170
|
+
applySettingsSection(section) {
|
|
171
|
+
const next = { ...this.persist.display };
|
|
172
|
+
next.visible = section.visible && (section.enabled ?? true);
|
|
173
|
+
next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, section.size)));
|
|
174
|
+
next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)));
|
|
175
|
+
next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.bottom)));
|
|
176
|
+
this.persist = { ...this.persist, display: next, name: section.name.trim() };
|
|
177
|
+
this.flush();
|
|
178
|
+
}
|
|
179
|
+
/** Mirror the persisted display config into the settings document (best-effort). */
|
|
180
|
+
syncSettingsFromPet() {
|
|
181
|
+
const settings = this.ctx.get('settings', false);
|
|
182
|
+
if (settings === undefined)
|
|
183
|
+
return;
|
|
184
|
+
void settings.update(PET_SETTINGS_NAMESPACE, {
|
|
185
|
+
visible: this.persist.display.visible,
|
|
186
|
+
size: this.persist.display.size,
|
|
187
|
+
right: this.persist.display.right,
|
|
188
|
+
bottom: this.persist.display.bottom,
|
|
189
|
+
name: this.persist.name,
|
|
190
|
+
}).catch(() => {
|
|
191
|
+
// A settings write failure must not break the pet's own persistence.
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/** Award the turn reward once per done phase (idempotent per transition). */
|
|
195
|
+
rewardTurn() {
|
|
196
|
+
const nowMs = Date.now();
|
|
197
|
+
// A done phase can repeat while celebrating; only reward the first.
|
|
198
|
+
if (nowMs - this.lastTurnRewardAt < 5_000)
|
|
199
|
+
return;
|
|
200
|
+
this.lastTurnRewardAt = nowMs;
|
|
201
|
+
this.persist = { ...this.persist, affinity: applyTurnReward(this.persist.affinity, this.affinityConfig) };
|
|
202
|
+
this.flush();
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Settle the treat economy (work + time output since the last
|
|
206
|
+
* settlement); persists only when treats were actually granted.
|
|
207
|
+
*/
|
|
208
|
+
settleTreats(nowMs) {
|
|
209
|
+
const settlement = settleTreatGrants(this.persist.treats, this.persist.affinity.turns, nowMs, this.treatConfig);
|
|
210
|
+
if (settlement.gained > 0) {
|
|
211
|
+
this.persist = { ...this.persist, treats: settlement.ledger };
|
|
212
|
+
this.flush();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
view() {
|
|
216
|
+
const snapshot = this.machine.render();
|
|
217
|
+
// Time-output treats accrue while the host is idle too; settle on read.
|
|
218
|
+
this.settleTreats(Date.now());
|
|
219
|
+
return {
|
|
220
|
+
animation: snapshot.animation,
|
|
221
|
+
...(snapshot.bubble === undefined ? {} : { bubble: snapshot.bubble }),
|
|
222
|
+
phase: snapshot.phase,
|
|
223
|
+
sessionActive: snapshot.sessionActive,
|
|
224
|
+
affinity: this.affinityView(this.persist.affinity),
|
|
225
|
+
display: { ...this.persist.display },
|
|
226
|
+
name: this.persist.name,
|
|
227
|
+
treats: {
|
|
228
|
+
stocked: this.persist.treats.treats,
|
|
229
|
+
max: this.treatConfig.maxTreats,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
affinityView(affinity) {
|
|
234
|
+
const nowMs = Date.now();
|
|
235
|
+
const rank = rankOf(affinity.points);
|
|
236
|
+
return {
|
|
237
|
+
points: affinity.points,
|
|
238
|
+
rank: rank.name,
|
|
239
|
+
rankEmoji: rank.emoji,
|
|
240
|
+
pets: affinity.pets,
|
|
241
|
+
feeds: affinity.feeds,
|
|
242
|
+
turns: affinity.turns,
|
|
243
|
+
petCooldown: nowMs - affinity.lastPetAt < this.affinityConfig.petCooldownMs,
|
|
244
|
+
feedCooldown: nowMs - affinity.lastFeedAt < this.affinityConfig.feedCooldownMs,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
flush() {
|
|
248
|
+
try {
|
|
249
|
+
savePetPersist(this.persist, this.persistDir);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Persistence is best-effort; the in-memory ledger keeps working.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pet state machine — pure, clock-injected. Maps the DSH `activity/status`
|
|
3
|
+
* phase vocabulary (session events) onto the 9-state Codex pet
|
|
4
|
+
* animation contract, plus the session lifecycle transitions the web UI
|
|
5
|
+
* exposes (turn end celebration, no-session idle).
|
|
6
|
+
*
|
|
7
|
+
* The machine is deliberately dumb: it holds the last input phase, the
|
|
8
|
+
* animation decision, and a one-shot "celebration" window after `done` so the
|
|
9
|
+
* pet visibly jumps before settling back to idle. Everything here is a pure
|
|
10
|
+
* function of (input, nowMs); persistence and RPC live in the service.
|
|
11
|
+
* @module @linxin666/dsh-pet/state
|
|
12
|
+
*/
|
|
13
|
+
export const defaultPetStateConfig = { celebrateMs: 2400 };
|
|
14
|
+
/**
|
|
15
|
+
* Map one activity phase onto the animation contract.
|
|
16
|
+
* - thinking / tool → `running` (focused work), with `running-right` as the
|
|
17
|
+
* side-alternating variant the client may use for tool activity.
|
|
18
|
+
* - waiting → `waiting` (expectant pose, needs user input).
|
|
19
|
+
* - done → `jumping` (celebration), then back to `idle` after the window.
|
|
20
|
+
* - idle → `idle` (calm breathing loop).
|
|
21
|
+
* `failed` has no DSH phase source yet; the machine keeps the mapping table
|
|
22
|
+
* so a future error event can light it up.
|
|
23
|
+
*/
|
|
24
|
+
export function animationForPhase(phase) {
|
|
25
|
+
switch (phase) {
|
|
26
|
+
case 'thinking': return 'running';
|
|
27
|
+
case 'tool': return 'running-right';
|
|
28
|
+
case 'waiting': return 'waiting';
|
|
29
|
+
case 'done': return 'jumping';
|
|
30
|
+
case 'idle': return 'idle';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** The spritesheet row index for one animation track. */
|
|
34
|
+
export function rowOf(animation) {
|
|
35
|
+
const rows = {
|
|
36
|
+
'idle': 0,
|
|
37
|
+
'running-right': 1,
|
|
38
|
+
'running-left': 2,
|
|
39
|
+
'waving': 3,
|
|
40
|
+
'jumping': 4,
|
|
41
|
+
'failed': 5,
|
|
42
|
+
'waiting': 6,
|
|
43
|
+
'running': 7,
|
|
44
|
+
'review': 8,
|
|
45
|
+
};
|
|
46
|
+
return rows[animation];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* PetStateMachine — one instance per host process. Holds only the latest
|
|
50
|
+
* input snapshot and the celebration timing; no storage, no side effects.
|
|
51
|
+
*/
|
|
52
|
+
export class PetStateMachine {
|
|
53
|
+
config;
|
|
54
|
+
now;
|
|
55
|
+
phase = 'idle';
|
|
56
|
+
line;
|
|
57
|
+
phrase;
|
|
58
|
+
sessionActive = false;
|
|
59
|
+
doneAt;
|
|
60
|
+
constructor(config = defaultPetStateConfig, now = Date.now) {
|
|
61
|
+
this.config = config;
|
|
62
|
+
this.now = now;
|
|
63
|
+
}
|
|
64
|
+
/** Consume one `activity/status` session event. */
|
|
65
|
+
onActivityStatus(input) {
|
|
66
|
+
this.phase = input.phase;
|
|
67
|
+
this.line = input.line;
|
|
68
|
+
this.phrase = input.phrase;
|
|
69
|
+
if (input.phase === 'done')
|
|
70
|
+
this.doneAt = this.now();
|
|
71
|
+
}
|
|
72
|
+
/** A session became the active one (or a fresh session started). */
|
|
73
|
+
onSessionActive() {
|
|
74
|
+
this.sessionActive = true;
|
|
75
|
+
}
|
|
76
|
+
/** The active session was disposed (or none left). */
|
|
77
|
+
onSessionDisposed() {
|
|
78
|
+
this.sessionActive = false;
|
|
79
|
+
this.phase = 'idle';
|
|
80
|
+
this.line = undefined;
|
|
81
|
+
this.phrase = undefined;
|
|
82
|
+
this.doneAt = undefined;
|
|
83
|
+
}
|
|
84
|
+
/** Render the current animation decision. */
|
|
85
|
+
render() {
|
|
86
|
+
const nowMs = this.now();
|
|
87
|
+
let animation = animationForPhase(this.phase);
|
|
88
|
+
// Celebration window: after `done`, jump for celebrateMs then settle idle.
|
|
89
|
+
if (this.phase === 'done' && this.doneAt !== undefined) {
|
|
90
|
+
if (nowMs - this.doneAt < this.config.celebrateMs) {
|
|
91
|
+
animation = 'jumping';
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
animation = 'idle';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const bubble = this.phrase ?? this.line;
|
|
98
|
+
return {
|
|
99
|
+
animation,
|
|
100
|
+
...(bubble === undefined ? {} : { bubble }),
|
|
101
|
+
animationStartedAt: nowMs,
|
|
102
|
+
phase: this.phase,
|
|
103
|
+
sessionActive: this.sessionActive,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Treat (小鱼干) economy — pure, clock-injected. The pet's food comes from
|
|
3
|
+
* two sources, both tied to companionship:
|
|
4
|
+
* - work output: every N completed turns grant one treat;
|
|
5
|
+
* - time output: every T minutes of wall-clock time grant one treat.
|
|
6
|
+
* Feeding consumes one treat. Settlement is lazy: it runs whenever the host
|
|
7
|
+
* serves a state snapshot or an interaction, so there is no timer and no
|
|
8
|
+
* drift — elapsed periods are computed from the persisted last-grant marks.
|
|
9
|
+
* @module @linxin666/dsh-pet/treats
|
|
10
|
+
*/
|
|
11
|
+
export const defaultTreatConfig = {
|
|
12
|
+
turnsPerTreat: 3,
|
|
13
|
+
timeTreatMs: 30 * 60_000,
|
|
14
|
+
maxTreats: 20,
|
|
15
|
+
};
|
|
16
|
+
export function emptyTreatLedger() {
|
|
17
|
+
return { treats: 0, lastTreatGrantAt: 0, turnsAtLastTreatGrant: 0 };
|
|
18
|
+
}
|
|
19
|
+
function cap(treats, max) {
|
|
20
|
+
return Math.min(max, Math.max(0, treats));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Settle treat grants from both sources against one ledger snapshot.
|
|
24
|
+
* Work output counts whole periods since the last work settlement
|
|
25
|
+
* (turnsDelta / turnsPerTreat) and advances only the work anchor;
|
|
26
|
+
* time output counts whole periods since the time anchor
|
|
27
|
+
* (`lastTreatGrantAt`) and advances only the time anchor. The two sources
|
|
28
|
+
* are independent so a continuously working user still earns time treats.
|
|
29
|
+
* 0 time history never backfills — the clock starts at the first settlement.
|
|
30
|
+
* Both sources are clamped by the stock cap.
|
|
31
|
+
*/
|
|
32
|
+
export function settleTreatGrants(ledger, turns, nowMs, config = defaultTreatConfig) {
|
|
33
|
+
const turnDelta = Math.max(0, turns - ledger.turnsAtLastTreatGrant);
|
|
34
|
+
const workGrants = Math.floor(turnDelta / config.turnsPerTreat);
|
|
35
|
+
// The time clock starts at the first settlement (no backfill of pre-first
|
|
36
|
+
// idle history); thereafter only time grants move it forward.
|
|
37
|
+
const timeAnchor = ledger.lastTreatGrantAt === 0 ? nowMs : ledger.lastTreatGrantAt;
|
|
38
|
+
const timeGrants = Math.floor(Math.max(0, nowMs - timeAnchor) / config.timeTreatMs);
|
|
39
|
+
const gained = workGrants + timeGrants;
|
|
40
|
+
if (gained <= 0)
|
|
41
|
+
return { ledger, gained: 0 };
|
|
42
|
+
return {
|
|
43
|
+
ledger: {
|
|
44
|
+
treats: cap(ledger.treats + gained, config.maxTreats),
|
|
45
|
+
lastTreatGrantAt: timeGrants > 0
|
|
46
|
+
? timeAnchor + timeGrants * config.timeTreatMs
|
|
47
|
+
: timeAnchor,
|
|
48
|
+
turnsAtLastTreatGrant: workGrants > 0
|
|
49
|
+
? turns - (turnDelta % config.turnsPerTreat)
|
|
50
|
+
: ledger.turnsAtLastTreatGrant,
|
|
51
|
+
},
|
|
52
|
+
gained,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Consume one treat for a feed. Returns the outcome; a feed with no stocked
|
|
57
|
+
* treats is refused.
|
|
58
|
+
*/
|
|
59
|
+
export function consumeTreat(ledger) {
|
|
60
|
+
if (ledger.treats <= 0)
|
|
61
|
+
return { ok: false };
|
|
62
|
+
return { ok: true, ledger: { ...ledger, treats: ledger.treats - 1 } };
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-pet",
|
|
3
3
|
"description": "鲸鱼娘宠物插件 for the dsh web GUI: a soft healing whale-girl companion that reacts to model activity (idle/waiting/thinking/tool/done), with petting/feeding interactions and an affinity score",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -25,14 +25,13 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
|
-
"lib
|
|
29
|
-
"lib
|
|
30
|
-
"lib
|
|
31
|
-
"lib
|
|
32
|
-
"lib/types/**/*.d.ts.map",
|
|
28
|
+
"lib/**/*.js",
|
|
29
|
+
"lib/**/*.js.map",
|
|
30
|
+
"lib/**/*.d.ts",
|
|
31
|
+
"lib/**/*.d.ts.map",
|
|
33
32
|
"src",
|
|
34
|
-
"
|
|
35
|
-
"
|
|
33
|
+
"cordis.patch.yml",
|
|
34
|
+
"assets"
|
|
36
35
|
],
|
|
37
36
|
"license": "BSD-3-Clause",
|
|
38
37
|
"dsh": {
|
|
@@ -44,7 +43,6 @@
|
|
|
44
43
|
"@deepseek-ai/dsh-client-runtime",
|
|
45
44
|
"@deepseek-ai/dsh-client-connection",
|
|
46
45
|
"@deepseek-ai/dsh-client-ui-settings",
|
|
47
|
-
"@deepseek-ai/dsh-client-ui-slots",
|
|
48
46
|
"@deepseek-ai/dsh-client-ui-conversation"
|
|
49
47
|
],
|
|
50
48
|
"platform": "web"
|
|
@@ -92,6 +90,10 @@
|
|
|
92
90
|
"vite-tsconfig-paths": "^6.1.1",
|
|
93
91
|
"vitest": "^4.1.8"
|
|
94
92
|
},
|
|
93
|
+
"repository": {
|
|
94
|
+
"type": "git",
|
|
95
|
+
"url": "https://github.com/zhu1090093659/dsh-web-ui.git"
|
|
96
|
+
},
|
|
95
97
|
"scripts": {
|
|
96
98
|
"build": "tsc -b && tsdown",
|
|
97
99
|
"test": "vitest run",
|