@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/lib/index.js
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import { a as AFFINITY_MAX, c as applyTurnReward, d as rankOf, i as rowOf, l as defaultAffinityConfig, n as animationForPhase, o as AFFINITY_RANKS, r as defaultPetStateConfig, s as applyInteraction, t as PetStateMachine, u as emptyAffinity } from "./state-IyVnKymD.js";
|
|
2
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
|
+
import z from "schemastery";
|
|
4
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
5
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
//#region src/treats.ts
|
|
11
|
+
const defaultTreatConfig = {
|
|
12
|
+
turnsPerTreat: 3,
|
|
13
|
+
timeTreatMs: 30 * 6e4,
|
|
14
|
+
maxTreats: 20
|
|
15
|
+
};
|
|
16
|
+
function emptyTreatLedger() {
|
|
17
|
+
return {
|
|
18
|
+
treats: 0,
|
|
19
|
+
lastTreatGrantAt: 0,
|
|
20
|
+
turnsAtLastTreatGrant: 0
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function cap(treats, max) {
|
|
24
|
+
return Math.min(max, Math.max(0, treats));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Settle treat grants from both sources against one ledger snapshot.
|
|
28
|
+
* Work output counts whole periods since the last work settlement
|
|
29
|
+
* (turnsDelta / turnsPerTreat) and advances only the work anchor;
|
|
30
|
+
* time output counts whole periods since the time anchor
|
|
31
|
+
* (`lastTreatGrantAt`) and advances only the time anchor. The two sources
|
|
32
|
+
* are independent so a continuously working user still earns time treats.
|
|
33
|
+
* 0 time history never backfills — the clock starts at the first settlement.
|
|
34
|
+
* Both sources are clamped by the stock cap.
|
|
35
|
+
*/
|
|
36
|
+
function settleTreatGrants(ledger, turns, nowMs, config = defaultTreatConfig) {
|
|
37
|
+
const turnDelta = Math.max(0, turns - ledger.turnsAtLastTreatGrant);
|
|
38
|
+
const workGrants = Math.floor(turnDelta / config.turnsPerTreat);
|
|
39
|
+
const timeAnchor = ledger.lastTreatGrantAt === 0 ? nowMs : ledger.lastTreatGrantAt;
|
|
40
|
+
const timeGrants = Math.floor(Math.max(0, nowMs - timeAnchor) / config.timeTreatMs);
|
|
41
|
+
const gained = workGrants + timeGrants;
|
|
42
|
+
if (gained <= 0) return {
|
|
43
|
+
ledger,
|
|
44
|
+
gained: 0
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
ledger: {
|
|
48
|
+
treats: cap(ledger.treats + gained, config.maxTreats),
|
|
49
|
+
lastTreatGrantAt: timeGrants > 0 ? timeAnchor + timeGrants * config.timeTreatMs : timeAnchor,
|
|
50
|
+
turnsAtLastTreatGrant: workGrants > 0 ? turns - turnDelta % config.turnsPerTreat : 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
|
+
function consumeTreat(ledger) {
|
|
60
|
+
if (ledger.treats <= 0) return { ok: false };
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
ledger: {
|
|
64
|
+
...ledger,
|
|
65
|
+
treats: ledger.treats - 1
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/persist.ts
|
|
71
|
+
/**
|
|
72
|
+
* Pet persistence — tiny JSON store for affinity + display config, written
|
|
73
|
+
* under $DSH_HOME (defaults to ~/.dsh) as `pet.json`. Deliberately minimal:
|
|
74
|
+
* one file, atomic rename write, tolerant read (corrupt file → defaults).
|
|
75
|
+
* @module @linxin666/dsh-pet/persist
|
|
76
|
+
*/
|
|
77
|
+
const defaultDisplayConfig = {
|
|
78
|
+
visible: true,
|
|
79
|
+
size: 160,
|
|
80
|
+
right: 24,
|
|
81
|
+
bottom: 20
|
|
82
|
+
};
|
|
83
|
+
const DISPLAY_INSET_MAX = 1e4;
|
|
84
|
+
/** Default pet name (used until the user renames the pet). */
|
|
85
|
+
const DEFAULT_PET_NAME = "鲸鱼娘";
|
|
86
|
+
function emptyPersist() {
|
|
87
|
+
return {
|
|
88
|
+
name: DEFAULT_PET_NAME,
|
|
89
|
+
affinity: emptyAffinity(),
|
|
90
|
+
treats: emptyTreatLedger(),
|
|
91
|
+
display: { ...defaultDisplayConfig }
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Resolve the persistence directory ($DSH_HOME or ~/.dsh). */
|
|
95
|
+
function petHomeDir() {
|
|
96
|
+
return process.env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
97
|
+
}
|
|
98
|
+
/** Numeric field guard: finite numbers only, else the fallback. */
|
|
99
|
+
function finiteNum(value, fallback) {
|
|
100
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
101
|
+
}
|
|
102
|
+
/** Clamp one count/score into [0, max]. */
|
|
103
|
+
function clamp(value, max) {
|
|
104
|
+
return Math.min(max, Math.max(0, value));
|
|
105
|
+
}
|
|
106
|
+
/** Load persisted state; missing or corrupt files fall back to defaults. */
|
|
107
|
+
function loadPetPersist(dir = petHomeDir()) {
|
|
108
|
+
try {
|
|
109
|
+
const raw = readFileSync(join(dir, "pet.json"), "utf8");
|
|
110
|
+
const parsed = JSON.parse(raw);
|
|
111
|
+
const base = emptyPersist();
|
|
112
|
+
const rawAffinity = parsed.affinity ?? {};
|
|
113
|
+
const affinity = {
|
|
114
|
+
points: clamp(finiteNum(rawAffinity.points, 0), 100),
|
|
115
|
+
lastPetAt: clamp(finiteNum(rawAffinity.lastPetAt, 0), Number.MAX_SAFE_INTEGER),
|
|
116
|
+
lastFeedAt: clamp(finiteNum(rawAffinity.lastFeedAt, 0), Number.MAX_SAFE_INTEGER),
|
|
117
|
+
pets: clamp(finiteNum(rawAffinity.pets, 0), Number.MAX_SAFE_INTEGER),
|
|
118
|
+
feeds: clamp(finiteNum(rawAffinity.feeds, 0), Number.MAX_SAFE_INTEGER),
|
|
119
|
+
turns: clamp(finiteNum(rawAffinity.turns, 0), Number.MAX_SAFE_INTEGER)
|
|
120
|
+
};
|
|
121
|
+
const rawTreats = parsed.treats ?? {};
|
|
122
|
+
const treats = {
|
|
123
|
+
treats: clamp(finiteNum(rawTreats.treats, 0), defaultTreatConfig.maxTreats),
|
|
124
|
+
lastTreatGrantAt: clamp(finiteNum(rawTreats.lastTreatGrantAt, 0), Number.MAX_SAFE_INTEGER),
|
|
125
|
+
turnsAtLastTreatGrant: clamp(finiteNum(rawTreats.turnsAtLastTreatGrant, 0), Number.MAX_SAFE_INTEGER)
|
|
126
|
+
};
|
|
127
|
+
const rawDisplay = parsed.display ?? {};
|
|
128
|
+
const display = {
|
|
129
|
+
visible: typeof rawDisplay.visible === "boolean" ? rawDisplay.visible : base.display.visible,
|
|
130
|
+
size: Math.round(Math.min(512, Math.max(32, finiteNum(rawDisplay.size, base.display.size)))),
|
|
131
|
+
right: Math.round(clamp(finiteNum(rawDisplay.right, base.display.right), DISPLAY_INSET_MAX)),
|
|
132
|
+
bottom: Math.round(clamp(finiteNum(rawDisplay.bottom, base.display.bottom), DISPLAY_INSET_MAX))
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
name: typeof parsed.name === "string" && parsed.name.trim() !== "" ? parsed.name : base.name,
|
|
136
|
+
affinity,
|
|
137
|
+
treats,
|
|
138
|
+
display
|
|
139
|
+
};
|
|
140
|
+
} catch {
|
|
141
|
+
return emptyPersist();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Atomically persist state (write temp + rename). */
|
|
145
|
+
function savePetPersist(data, dir = petHomeDir()) {
|
|
146
|
+
mkdirSync(dir, { recursive: true });
|
|
147
|
+
const target = join(dir, "pet.json");
|
|
148
|
+
const tmp = `${target}.tmp`;
|
|
149
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
|
|
150
|
+
renameSync(tmp, target);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
|
|
154
|
+
* written until a query or interaction arrives; event listeners update only
|
|
155
|
+
* in-memory state, and persistence happens on interaction/config changes
|
|
156
|
+
* plus every completed turn.
|
|
157
|
+
*/
|
|
158
|
+
var PetService = class extends Service {
|
|
159
|
+
static inject = [];
|
|
160
|
+
machine;
|
|
161
|
+
affinityConfig;
|
|
162
|
+
treatConfig;
|
|
163
|
+
persistDir;
|
|
164
|
+
persist;
|
|
165
|
+
lastTurnRewardAt = 0;
|
|
166
|
+
enabled;
|
|
167
|
+
disposeActivity;
|
|
168
|
+
constructor(ctx, config = {}) {
|
|
169
|
+
super(ctx, "pet");
|
|
170
|
+
this.persistDir = config.persistDir ?? petHomeDir();
|
|
171
|
+
this.affinityConfig = {
|
|
172
|
+
...defaultAffinityConfig,
|
|
173
|
+
...config.affinity ?? {}
|
|
174
|
+
};
|
|
175
|
+
this.treatConfig = {
|
|
176
|
+
...defaultTreatConfig,
|
|
177
|
+
...config.treats ?? {}
|
|
178
|
+
};
|
|
179
|
+
this.machine = new PetStateMachine({
|
|
180
|
+
...defaultPetStateConfig,
|
|
181
|
+
...config.state ?? {}
|
|
182
|
+
});
|
|
183
|
+
this.persist = loadPetPersist(this.persistDir);
|
|
184
|
+
this.enabled = config.enabled ?? true;
|
|
185
|
+
this.syncActivity();
|
|
186
|
+
}
|
|
187
|
+
/** Whether the pet service consumes session activity while enabled. */
|
|
188
|
+
isEnabled() {
|
|
189
|
+
return this.enabled;
|
|
190
|
+
}
|
|
191
|
+
/** RPC: current pet state snapshot. */
|
|
192
|
+
async state() {
|
|
193
|
+
return this.view();
|
|
194
|
+
}
|
|
195
|
+
/** Current persisted display config (read-only view). */
|
|
196
|
+
display() {
|
|
197
|
+
return { ...this.persist.display };
|
|
198
|
+
}
|
|
199
|
+
/** Current persisted pet name (read-only view). */
|
|
200
|
+
petName() {
|
|
201
|
+
return this.persist.name;
|
|
202
|
+
}
|
|
203
|
+
/** Start or stop the session-activity listeners that drive the pet. */
|
|
204
|
+
setEnabled(enabled) {
|
|
205
|
+
this.enabled = enabled;
|
|
206
|
+
this.syncActivity();
|
|
207
|
+
}
|
|
208
|
+
syncActivity() {
|
|
209
|
+
if (this.disposeActivity !== void 0) {
|
|
210
|
+
this.disposeActivity();
|
|
211
|
+
this.disposeActivity = void 0;
|
|
212
|
+
}
|
|
213
|
+
if (!this.enabled) return;
|
|
214
|
+
this.disposeActivity = (() => {
|
|
215
|
+
const disposers = [this.ctx.on("session/event", (_session, event) => {
|
|
216
|
+
if (event.type !== "activity/status") return;
|
|
217
|
+
const payload = event.data ?? {};
|
|
218
|
+
if (payload.phase === void 0) return;
|
|
219
|
+
const phase = payload.phase;
|
|
220
|
+
if (![
|
|
221
|
+
"idle",
|
|
222
|
+
"waiting",
|
|
223
|
+
"thinking",
|
|
224
|
+
"tool",
|
|
225
|
+
"done"
|
|
226
|
+
].includes(phase)) return;
|
|
227
|
+
this.machine.onActivityStatus({
|
|
228
|
+
phase,
|
|
229
|
+
...typeof payload.line === "string" ? { line: payload.line } : {},
|
|
230
|
+
...typeof payload.phrase === "string" ? { phrase: payload.phrase } : {}
|
|
231
|
+
});
|
|
232
|
+
this.machine.onSessionActive();
|
|
233
|
+
if (phase === "done") this.rewardTurn();
|
|
234
|
+
}), this.ctx.on("session/disposed", () => {
|
|
235
|
+
this.machine.onSessionDisposed();
|
|
236
|
+
})];
|
|
237
|
+
return () => {
|
|
238
|
+
for (const dispose of disposers) dispose();
|
|
239
|
+
};
|
|
240
|
+
})();
|
|
241
|
+
}
|
|
242
|
+
/** RPC: pet or feed the pet. */
|
|
243
|
+
async interact(kind) {
|
|
244
|
+
const nowMs = Date.now();
|
|
245
|
+
if (kind === "feed") this.settleTreats(nowMs);
|
|
246
|
+
const outcome = applyInteraction(this.persist.affinity, kind, nowMs, this.affinityConfig);
|
|
247
|
+
if (kind === "feed" && !outcome.accepted) return {
|
|
248
|
+
reaction: outcome.reaction,
|
|
249
|
+
delta: 0,
|
|
250
|
+
affinity: this.affinityView(this.persist.affinity)
|
|
251
|
+
};
|
|
252
|
+
if (kind === "feed") {
|
|
253
|
+
const consume = consumeTreat(this.persist.treats);
|
|
254
|
+
if (!consume.ok) return {
|
|
255
|
+
reaction: "没有小鱼干了,多陪鲸鱼娘工作一会儿吧~",
|
|
256
|
+
delta: 0,
|
|
257
|
+
affinity: this.affinityView(this.persist.affinity)
|
|
258
|
+
};
|
|
259
|
+
this.persist = {
|
|
260
|
+
...this.persist,
|
|
261
|
+
treats: consume.ledger
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (outcome.accepted) {
|
|
265
|
+
this.persist = {
|
|
266
|
+
...this.persist,
|
|
267
|
+
affinity: outcome.affinity
|
|
268
|
+
};
|
|
269
|
+
this.flush();
|
|
270
|
+
}
|
|
271
|
+
const affinity = this.affinityView(outcome.affinity);
|
|
272
|
+
return {
|
|
273
|
+
reaction: outcome.reaction,
|
|
274
|
+
delta: outcome.delta,
|
|
275
|
+
affinity
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** RPC: show or hide the pet. */
|
|
279
|
+
async setVisible(visible) {
|
|
280
|
+
this.persist = {
|
|
281
|
+
...this.persist,
|
|
282
|
+
display: {
|
|
283
|
+
...this.persist.display,
|
|
284
|
+
visible
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
this.flush();
|
|
288
|
+
this.syncSettingsFromPet();
|
|
289
|
+
return {
|
|
290
|
+
ok: true,
|
|
291
|
+
display: this.persist.display
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/** RPC: update display config (size / position). Values are clamped to whole pixels. */
|
|
295
|
+
async setConfig(patch) {
|
|
296
|
+
const next = {
|
|
297
|
+
...this.persist.display,
|
|
298
|
+
...patch
|
|
299
|
+
};
|
|
300
|
+
next.size = Math.round(Math.min(512, Math.max(32, next.size)));
|
|
301
|
+
next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.right)));
|
|
302
|
+
next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.bottom)));
|
|
303
|
+
this.persist = {
|
|
304
|
+
...this.persist,
|
|
305
|
+
display: next
|
|
306
|
+
};
|
|
307
|
+
this.flush();
|
|
308
|
+
this.syncSettingsFromPet();
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
display: this.persist.display
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/** RPC: rename the pet (trimmed, 1–20 chars). */
|
|
315
|
+
async setName(name) {
|
|
316
|
+
const trimmed = name.trim();
|
|
317
|
+
if (trimmed === "") return {
|
|
318
|
+
ok: false,
|
|
319
|
+
error: "name-empty"
|
|
320
|
+
};
|
|
321
|
+
if (trimmed.length > 20) return {
|
|
322
|
+
ok: false,
|
|
323
|
+
error: "name-too-long"
|
|
324
|
+
};
|
|
325
|
+
this.persist = {
|
|
326
|
+
...this.persist,
|
|
327
|
+
name: trimmed
|
|
328
|
+
};
|
|
329
|
+
this.flush();
|
|
330
|
+
this.syncSettingsFromPet();
|
|
331
|
+
return {
|
|
332
|
+
ok: true,
|
|
333
|
+
name: trimmed
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Apply a committed settings section to the persisted display config. Called
|
|
338
|
+
* by the settings surface on every change; values are clamped exactly like
|
|
339
|
+
* the setConfig RPC so both write paths converge.
|
|
340
|
+
* @param section - the resolved settings section.
|
|
341
|
+
*/
|
|
342
|
+
applySettingsSection(section) {
|
|
343
|
+
const next = { ...this.persist.display };
|
|
344
|
+
next.visible = section.visible && (section.enabled ?? true);
|
|
345
|
+
next.size = Math.round(Math.min(512, Math.max(32, section.size)));
|
|
346
|
+
next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)));
|
|
347
|
+
next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.bottom)));
|
|
348
|
+
this.persist = {
|
|
349
|
+
...this.persist,
|
|
350
|
+
display: next,
|
|
351
|
+
name: section.name.trim()
|
|
352
|
+
};
|
|
353
|
+
this.flush();
|
|
354
|
+
}
|
|
355
|
+
/** Mirror the persisted display config into the settings document (best-effort). */
|
|
356
|
+
syncSettingsFromPet() {
|
|
357
|
+
const settings = this.ctx.get("settings", false);
|
|
358
|
+
if (settings === void 0) return;
|
|
359
|
+
settings.update("pet", {
|
|
360
|
+
visible: this.persist.display.visible,
|
|
361
|
+
size: this.persist.display.size,
|
|
362
|
+
right: this.persist.display.right,
|
|
363
|
+
bottom: this.persist.display.bottom,
|
|
364
|
+
name: this.persist.name
|
|
365
|
+
}).catch(() => {});
|
|
366
|
+
}
|
|
367
|
+
/** Award the turn reward once per done phase (idempotent per transition). */
|
|
368
|
+
rewardTurn() {
|
|
369
|
+
const nowMs = Date.now();
|
|
370
|
+
if (nowMs - this.lastTurnRewardAt < 5e3) return;
|
|
371
|
+
this.lastTurnRewardAt = nowMs;
|
|
372
|
+
this.persist = {
|
|
373
|
+
...this.persist,
|
|
374
|
+
affinity: applyTurnReward(this.persist.affinity, this.affinityConfig)
|
|
375
|
+
};
|
|
376
|
+
this.flush();
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Settle the treat economy (work + time output since the last
|
|
380
|
+
* settlement); persists only when treats were actually granted.
|
|
381
|
+
*/
|
|
382
|
+
settleTreats(nowMs) {
|
|
383
|
+
const settlement = settleTreatGrants(this.persist.treats, this.persist.affinity.turns, nowMs, this.treatConfig);
|
|
384
|
+
if (settlement.gained > 0) {
|
|
385
|
+
this.persist = {
|
|
386
|
+
...this.persist,
|
|
387
|
+
treats: settlement.ledger
|
|
388
|
+
};
|
|
389
|
+
this.flush();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
view() {
|
|
393
|
+
const snapshot = this.machine.render();
|
|
394
|
+
this.settleTreats(Date.now());
|
|
395
|
+
return {
|
|
396
|
+
animation: snapshot.animation,
|
|
397
|
+
...snapshot.bubble === void 0 ? {} : { bubble: snapshot.bubble },
|
|
398
|
+
phase: snapshot.phase,
|
|
399
|
+
sessionActive: snapshot.sessionActive,
|
|
400
|
+
affinity: this.affinityView(this.persist.affinity),
|
|
401
|
+
display: { ...this.persist.display },
|
|
402
|
+
name: this.persist.name,
|
|
403
|
+
treats: {
|
|
404
|
+
stocked: this.persist.treats.treats,
|
|
405
|
+
max: this.treatConfig.maxTreats
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
affinityView(affinity) {
|
|
410
|
+
const nowMs = Date.now();
|
|
411
|
+
const rank = rankOf(affinity.points);
|
|
412
|
+
return {
|
|
413
|
+
points: affinity.points,
|
|
414
|
+
rank: rank.name,
|
|
415
|
+
rankEmoji: rank.emoji,
|
|
416
|
+
pets: affinity.pets,
|
|
417
|
+
feeds: affinity.feeds,
|
|
418
|
+
turns: affinity.turns,
|
|
419
|
+
petCooldown: nowMs - affinity.lastPetAt < this.affinityConfig.petCooldownMs,
|
|
420
|
+
feedCooldown: nowMs - affinity.lastFeedAt < this.affinityConfig.feedCooldownMs
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
flush() {
|
|
424
|
+
try {
|
|
425
|
+
savePetPersist(this.persist, this.persistDir);
|
|
426
|
+
} catch {}
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
//#endregion
|
|
430
|
+
//#region src/routes.ts
|
|
431
|
+
/** Browser-facing base path of the pet API. */
|
|
432
|
+
const PET_API_PREFIX = "/api/pet";
|
|
433
|
+
/** Browser-facing base path of the pet asset routes. */
|
|
434
|
+
const PET_ASSET_PREFIX = "/pet/whale";
|
|
435
|
+
/** Relative (to package root) asset files exposed under the prefix. */
|
|
436
|
+
const ASSET_FILES = [{
|
|
437
|
+
name: "spritesheet.webp",
|
|
438
|
+
mime: "image/webp"
|
|
439
|
+
}, {
|
|
440
|
+
name: "pet.json",
|
|
441
|
+
mime: "application/json"
|
|
442
|
+
}];
|
|
443
|
+
/** Absolute package root, resolved from this module's own location (lib/). */
|
|
444
|
+
function petPackageRoot(importMetaUrl) {
|
|
445
|
+
return fileURLToPath(new URL("../", importMetaUrl));
|
|
446
|
+
}
|
|
447
|
+
/** Write one JSON response. */
|
|
448
|
+
function json(res, status, body) {
|
|
449
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
450
|
+
res.end(JSON.stringify(body));
|
|
451
|
+
}
|
|
452
|
+
/** Require the method or answer 405. */
|
|
453
|
+
function requireMethod(req, res, method) {
|
|
454
|
+
if (req.method === method) return true;
|
|
455
|
+
json(res, 405, {
|
|
456
|
+
ok: false,
|
|
457
|
+
error: "method-not-allowed"
|
|
458
|
+
});
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
/** Read a JSON request body (bounded). */
|
|
462
|
+
function readJsonBody(req) {
|
|
463
|
+
return new Promise((resolve, reject) => {
|
|
464
|
+
let size = 0;
|
|
465
|
+
const chunks = [];
|
|
466
|
+
req.on("data", (chunk) => {
|
|
467
|
+
size += chunk.length;
|
|
468
|
+
if (size > 64 * 1024) {
|
|
469
|
+
reject(/* @__PURE__ */ new Error("body-too-large"));
|
|
470
|
+
queueMicrotask(() => req.destroy());
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
chunks.push(chunk);
|
|
474
|
+
});
|
|
475
|
+
req.on("end", () => {
|
|
476
|
+
if (chunks.length === 0) {
|
|
477
|
+
resolve({});
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
482
|
+
} catch {
|
|
483
|
+
reject(/* @__PURE__ */ new Error("invalid-json"));
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
req.on("error", reject);
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
/** Wrap one async service call as a GET JSON route. */
|
|
490
|
+
function getRoute(path, run) {
|
|
491
|
+
return {
|
|
492
|
+
kind: "exact",
|
|
493
|
+
path,
|
|
494
|
+
handler: (req, res) => {
|
|
495
|
+
if (!requireMethod(req, res, "GET")) return;
|
|
496
|
+
run().then((value) => json(res, 200, value), (error) => {
|
|
497
|
+
json(res, 500, {
|
|
498
|
+
ok: false,
|
|
499
|
+
error: error instanceof Error ? error.message : String(error)
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/** Wrap one async service call as a POST JSON route (body passed through). */
|
|
506
|
+
function postRoute(path, run) {
|
|
507
|
+
return {
|
|
508
|
+
kind: "exact",
|
|
509
|
+
path,
|
|
510
|
+
handler: (req, res) => {
|
|
511
|
+
if (!requireMethod(req, res, "POST")) return Promise.resolve();
|
|
512
|
+
return readJsonBody(req).then((body) => {
|
|
513
|
+
return run(typeof body === "object" && body !== null ? body : {}).then((value) => json(res, 200, value), (error) => {
|
|
514
|
+
json(res, 400, {
|
|
515
|
+
ok: false,
|
|
516
|
+
error: error instanceof Error ? error.message : String(error)
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
}, (error) => {
|
|
520
|
+
json(res, 400, {
|
|
521
|
+
ok: false,
|
|
522
|
+
error: error instanceof Error ? error.message : String(error)
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
/** Build the full route family (API + assets) for one service + package root. */
|
|
529
|
+
function makePetRoutes(deps) {
|
|
530
|
+
const { service, packageRoot } = deps;
|
|
531
|
+
const apiRoutes = [
|
|
532
|
+
getRoute(`${PET_API_PREFIX}/state`, () => service.state()),
|
|
533
|
+
postRoute(`${PET_API_PREFIX}/interact`, (body) => {
|
|
534
|
+
const kind = body.kind;
|
|
535
|
+
if (kind !== "pet" && kind !== "feed") return Promise.reject(/* @__PURE__ */ new Error("invalid-kind"));
|
|
536
|
+
return service.interact(kind);
|
|
537
|
+
}),
|
|
538
|
+
postRoute(`${PET_API_PREFIX}/set-visible`, (body) => {
|
|
539
|
+
const visible = body.visible;
|
|
540
|
+
if (typeof visible !== "boolean") return Promise.reject(/* @__PURE__ */ new Error("invalid-visible"));
|
|
541
|
+
return service.setVisible(visible);
|
|
542
|
+
}),
|
|
543
|
+
postRoute(`${PET_API_PREFIX}/set-config`, (body) => service.setConfig({
|
|
544
|
+
...typeof body.size === "number" ? { size: body.size } : {},
|
|
545
|
+
...typeof body.right === "number" ? { right: body.right } : {},
|
|
546
|
+
...typeof body.bottom === "number" ? { bottom: body.bottom } : {},
|
|
547
|
+
...typeof body.visible === "boolean" ? { visible: body.visible } : {}
|
|
548
|
+
})),
|
|
549
|
+
postRoute(`${PET_API_PREFIX}/set-name`, (body) => {
|
|
550
|
+
const name = body.name;
|
|
551
|
+
if (typeof name !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-name"));
|
|
552
|
+
return service.setName(name);
|
|
553
|
+
})
|
|
554
|
+
];
|
|
555
|
+
const assetRoutes = ASSET_FILES.map((file) => ({
|
|
556
|
+
kind: "exact",
|
|
557
|
+
path: `${PET_ASSET_PREFIX}/${file.name}`,
|
|
558
|
+
handler: (req, res) => {
|
|
559
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
560
|
+
res.writeHead(405);
|
|
561
|
+
res.end();
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
return readFile(join(packageRoot, "assets", "whale", file.name)).then((body) => {
|
|
565
|
+
res.writeHead(200, {
|
|
566
|
+
"content-type": file.mime,
|
|
567
|
+
"content-length": String(body.byteLength),
|
|
568
|
+
"cache-control": "no-cache"
|
|
569
|
+
});
|
|
570
|
+
if (req.method === "HEAD") {
|
|
571
|
+
res.end();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
res.end(body);
|
|
575
|
+
}, () => {
|
|
576
|
+
res.writeHead(404);
|
|
577
|
+
res.end();
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}));
|
|
581
|
+
return [...apiRoutes, ...assetRoutes];
|
|
582
|
+
}
|
|
583
|
+
//#endregion
|
|
584
|
+
//#region src/index.ts
|
|
585
|
+
/** Stable cordis plugin name (matches cordis.patch.yml insert id). */
|
|
586
|
+
const name = "pet";
|
|
587
|
+
/** Services required before the pet can mount its surfaces. */
|
|
588
|
+
const inject = ["webServer"];
|
|
589
|
+
/** Settings section schema: the display fields and name the web settings surface edits. */
|
|
590
|
+
const PET_SETTINGS_SCHEMA = z.object({
|
|
591
|
+
visible: z.boolean().default(true),
|
|
592
|
+
size: z.number().step(1).min(32).max(512).default(160),
|
|
593
|
+
right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
|
|
594
|
+
bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
|
|
595
|
+
name: z.string().min(1).max(20).pattern(/\S/).default(DEFAULT_PET_NAME),
|
|
596
|
+
enabled: z.boolean().default(true)
|
|
597
|
+
});
|
|
598
|
+
/** Register the pet service and its API + asset routes on the context. */
|
|
599
|
+
function apply(ctx, config = {}) {
|
|
600
|
+
const service = new PetService(ctx, config);
|
|
601
|
+
let current = () => base;
|
|
602
|
+
const base = {
|
|
603
|
+
visible: service.display().visible,
|
|
604
|
+
size: service.display().size,
|
|
605
|
+
right: service.display().right,
|
|
606
|
+
bottom: service.display().bottom,
|
|
607
|
+
name: service.petName(),
|
|
608
|
+
enabled: config.enabled ?? true
|
|
609
|
+
};
|
|
610
|
+
const routes = makePetRoutes({
|
|
611
|
+
service,
|
|
612
|
+
packageRoot: petPackageRoot(import.meta.url)
|
|
613
|
+
});
|
|
614
|
+
let disposeRoutes;
|
|
615
|
+
const syncRoutes = () => {
|
|
616
|
+
const enabled = current().enabled ?? true;
|
|
617
|
+
if (disposeRoutes === void 0 && enabled) disposeRoutes = ctx.effect(() => {
|
|
618
|
+
const disposers = routes.map((route) => ctx.webServer.register(route));
|
|
619
|
+
return () => {
|
|
620
|
+
for (const dispose of disposers) dispose();
|
|
621
|
+
};
|
|
622
|
+
}, "pet: routes");
|
|
623
|
+
else if (disposeRoutes !== void 0 && !enabled) {
|
|
624
|
+
disposeRoutes();
|
|
625
|
+
disposeRoutes = void 0;
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
installSettingsSection(ctx, settingsNamespace("pet"), PET_SETTINGS_SCHEMA, base, {
|
|
629
|
+
setSource: (source) => {
|
|
630
|
+
current = source;
|
|
631
|
+
},
|
|
632
|
+
onChange: () => {
|
|
633
|
+
const section = current();
|
|
634
|
+
service.applySettingsSection(section);
|
|
635
|
+
service.setEnabled(section.enabled ?? true);
|
|
636
|
+
syncRoutes();
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
syncRoutes();
|
|
640
|
+
}
|
|
641
|
+
//#endregion
|
|
642
|
+
export { AFFINITY_MAX, AFFINITY_RANKS, PET_API_PREFIX, PET_ASSET_PREFIX, PET_SETTINGS_SCHEMA, PetService, PetStateMachine, animationForPhase, apply, applyInteraction, applyTurnReward, consumeTreat, defaultDisplayConfig, defaultTreatConfig, emptyAffinity, emptyPersist, emptyTreatLedger, inject, loadPetPersist, makePetRoutes, name, petHomeDir, petPackageRoot, rankOf, rowOf, savePetPersist, settleTreatGrants };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { l as defaultAffinityConfig, n as animationForPhase, o as AFFINITY_RANKS } from "./state-IyVnKymD.js";
|
|
2
|
+
//#region src/invariant.ts
|
|
3
|
+
/**
|
|
4
|
+
* Package invariants — cheap structural checks run at import time on the
|
|
5
|
+
* host side. Mirrors the pattern used by other dsh plugin packages.
|
|
6
|
+
* @module @linxin666/dsh-pet/invariant
|
|
7
|
+
*/
|
|
8
|
+
/** Assert a condition; throws a descriptive Error when violated. */
|
|
9
|
+
function invariant(condition, message) {
|
|
10
|
+
if (!condition) throw new Error(`[dsh-pet] ${message}`);
|
|
11
|
+
}
|
|
12
|
+
/** Run every package invariant once; throws on the first violation. */
|
|
13
|
+
function runPetInvariants() {
|
|
14
|
+
invariant(true, "AFFINITY_MAX must be positive");
|
|
15
|
+
invariant(AFFINITY_RANKS.length > 0 && AFFINITY_RANKS[0].min === 0, "AFFINITY_RANKS must start at 0");
|
|
16
|
+
invariant(defaultAffinityConfig.turnReward > 0, "turnReward must be positive");
|
|
17
|
+
invariant(defaultAffinityConfig.feedCooldownMs > defaultAffinityConfig.petCooldownMs, "feed cooldown must exceed pet cooldown");
|
|
18
|
+
for (const phase of [
|
|
19
|
+
"idle",
|
|
20
|
+
"waiting",
|
|
21
|
+
"thinking",
|
|
22
|
+
"tool",
|
|
23
|
+
"done"
|
|
24
|
+
]) invariant([
|
|
25
|
+
"idle",
|
|
26
|
+
"running",
|
|
27
|
+
"running-right",
|
|
28
|
+
"waiting",
|
|
29
|
+
"jumping"
|
|
30
|
+
].includes(animationForPhase(phase)), `phase ${phase} maps outside the animation contract`);
|
|
31
|
+
}
|
|
32
|
+
runPetInvariants();
|
|
33
|
+
//#endregion
|
|
34
|
+
export { invariant, runPetInvariants };
|