@linxin666/dsh-pet 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +73 -38
- package/README.zh.md +91 -55
- package/assets/whale/pet.json +12 -1
- package/cordis.patch.yml +4 -4
- package/lib/client.js +296 -347
- package/lib/client.js.map +1 -1
- package/lib/index.js +975 -317
- package/lib/invariant.js +1 -1
- package/lib/{state-P-wsVJ6O.js → state-CFyJv0sQ.js} +15 -1
- package/lib/types/affinity.d.ts +15 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +14 -0
- package/lib/types/client/PetDockEntry.d.ts +14 -12
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +13 -8
- package/lib/types/client/PetSettingsCard.d.ts +22 -9
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +42 -7
- package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
- package/lib/types/client/PetSprite.d.ts.map +1 -0
- package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
- package/lib/types/client/PluginSettingsCard.d.ts +26 -11
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +30 -8
- package/lib/types/client/index.d.ts +11 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +32 -14
- package/lib/types/client/locales.d.ts +10 -10
- package/lib/types/client/locales.js +10 -10
- package/lib/types/client/pet-store.d.ts +5 -0
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +4 -0
- package/lib/types/client/settings-form.d.ts +76 -5
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +97 -20
- package/lib/types/client/spritesheet.d.ts +14 -48
- package/lib/types/client/spritesheet.d.ts.map +1 -1
- package/lib/types/client/spritesheet.js +13 -81
- package/lib/types/dsh-home.d.ts +17 -0
- package/lib/types/dsh-home.d.ts.map +1 -0
- package/lib/types/dsh-home.js +34 -0
- package/lib/types/event-projection.d.ts +36 -0
- package/lib/types/event-projection.d.ts.map +1 -0
- package/lib/types/event-projection.js +98 -0
- package/lib/types/index.d.ts +24 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +53 -33
- package/lib/types/ledger.d.ts +79 -0
- package/lib/types/ledger.d.ts.map +1 -0
- package/lib/types/ledger.js +146 -0
- package/lib/types/persist.d.ts +15 -4
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +39 -8
- package/lib/types/registry.d.ts +158 -0
- package/lib/types/registry.d.ts.map +1 -0
- package/lib/types/registry.js +276 -0
- package/lib/types/routes.d.ts +10 -10
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +169 -51
- package/lib/types/service.d.ts +59 -57
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +111 -211
- package/package.json +2 -2
- package/src/affinity.ts +33 -0
- package/src/client/PetDockEntry.tsx +34 -26
- package/src/client/PetSettingsCard.tsx +67 -22
- package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
- package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
- package/src/client/PluginSettingsCard.tsx +90 -18
- package/src/client/index.ts +38 -16
- package/src/client/locales.ts +10 -10
- package/src/client/pet-store.ts +9 -0
- package/src/client/settings-card.module.css +9 -0
- package/src/client/settings-form.ts +153 -19
- package/src/client/spritesheet.ts +16 -92
- package/src/dsh-home.test.ts +35 -0
- package/src/dsh-home.ts +36 -0
- package/src/event-projection.ts +128 -0
- package/src/index.test.ts +23 -0
- package/src/index.ts +89 -45
- package/src/ledger.test.ts +69 -0
- package/src/ledger.ts +190 -0
- package/src/persist.test.ts +52 -3
- package/src/persist.ts +49 -11
- package/src/registry.test.ts +132 -0
- package/src/registry.ts +400 -0
- package/src/routes.ts +166 -53
- package/src/service.ts +153 -285
- package/lib/types/client/WhalePet.d.ts.map +0 -1
package/lib/index.js
CHANGED
|
@@ -1,12 +1,132 @@
|
|
|
1
|
-
import { a as AFFINITY_MAX, c as
|
|
1
|
+
import { a as AFFINITY_MAX, c as applyInteraction, d as emptyAffinity, f as rankOf, i as rowOf, l as applyTurnReward, n as animationForPhase, o as AFFINITY_RANKS, r as defaultPetStateConfig, s as affinityViewOf, t as PetStateMachine, u as defaultAffinityConfig } from "./state-CFyJv0sQ.js";
|
|
2
2
|
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
3
|
import z from "schemastery";
|
|
4
4
|
import { Service } from "@deepseek-ai/cordis";
|
|
5
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { join } from "node:path";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
|
-
import { readFile } from "node:fs/promises";
|
|
9
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
//#region src/event-projection.ts
|
|
11
|
+
/** Fresh projection runtime for a newly seen session. */
|
|
12
|
+
function emptyProjectionRuntime() {
|
|
13
|
+
return {
|
|
14
|
+
activeTools: /* @__PURE__ */ new Set(),
|
|
15
|
+
officialEventsSeen: false,
|
|
16
|
+
stepHadFailure: false
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Keep tool names readable inside the compact status bubble. */
|
|
20
|
+
function displayToolName(name) {
|
|
21
|
+
const compact = name.replace(/\s+/g, " ").trim() || "工具";
|
|
22
|
+
return compact.length <= 24 ? compact : `${compact.slice(0, 21)}...`;
|
|
23
|
+
}
|
|
24
|
+
/** Whether a legacy phase is part of the pet's supported vocabulary. */
|
|
25
|
+
function isActivityPhase(phase) {
|
|
26
|
+
return [
|
|
27
|
+
"idle",
|
|
28
|
+
"waiting",
|
|
29
|
+
"thinking",
|
|
30
|
+
"tool",
|
|
31
|
+
"review",
|
|
32
|
+
"done",
|
|
33
|
+
"failed"
|
|
34
|
+
].includes(phase);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Project the durable DSH session vocabulary into the pet's visual phases.
|
|
38
|
+
* Unknown and log-only events do not disturb the last meaningful activity.
|
|
39
|
+
*/
|
|
40
|
+
function projectOfficialEvent(event, runtime) {
|
|
41
|
+
switch (event.type) {
|
|
42
|
+
case "turn/start":
|
|
43
|
+
runtime.activeTools.clear();
|
|
44
|
+
runtime.stepHadFailure = false;
|
|
45
|
+
return { input: {
|
|
46
|
+
phase: "waiting",
|
|
47
|
+
line: "准备开始"
|
|
48
|
+
} };
|
|
49
|
+
case "step/start":
|
|
50
|
+
runtime.activeTools.clear();
|
|
51
|
+
runtime.stepHadFailure = false;
|
|
52
|
+
return { input: {
|
|
53
|
+
phase: "waiting",
|
|
54
|
+
line: "等待模型响应"
|
|
55
|
+
} };
|
|
56
|
+
case "assistant/chunk": {
|
|
57
|
+
const { chunk } = event.data;
|
|
58
|
+
if (chunk.type === "reasoning-delta" && chunk.text.length > 0) return { input: {
|
|
59
|
+
phase: "thinking",
|
|
60
|
+
line: "正在思考"
|
|
61
|
+
} };
|
|
62
|
+
if (chunk.type === "text-delta" && chunk.text.length > 0) return { input: {
|
|
63
|
+
phase: "review",
|
|
64
|
+
line: "整理回复中"
|
|
65
|
+
} };
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
case "assistant/message": return { input: {
|
|
69
|
+
phase: "review",
|
|
70
|
+
line: "整理回复中"
|
|
71
|
+
} };
|
|
72
|
+
case "tool/call":
|
|
73
|
+
runtime.activeTools.add(String(event.data.callId));
|
|
74
|
+
return { input: {
|
|
75
|
+
phase: "tool",
|
|
76
|
+
line: `正在使用 ${displayToolName(event.data.name)}`
|
|
77
|
+
} };
|
|
78
|
+
case "tool/result": {
|
|
79
|
+
const block = event.data.message.content[0];
|
|
80
|
+
runtime.activeTools.delete(String(event.data.message.source.callId));
|
|
81
|
+
runtime.stepHadFailure ||= event.data.error !== void 0 || block.isError === true;
|
|
82
|
+
if (runtime.activeTools.size > 0) return { input: {
|
|
83
|
+
phase: "tool",
|
|
84
|
+
line: `还有 ${runtime.activeTools.size} 个工具运行中`
|
|
85
|
+
} };
|
|
86
|
+
return runtime.stepHadFailure ? { input: {
|
|
87
|
+
phase: "failed",
|
|
88
|
+
line: "工具执行失败"
|
|
89
|
+
} } : { input: {
|
|
90
|
+
phase: "thinking",
|
|
91
|
+
line: "处理工具结果"
|
|
92
|
+
} };
|
|
93
|
+
}
|
|
94
|
+
case "turn/end":
|
|
95
|
+
runtime.activeTools.clear();
|
|
96
|
+
switch (event.data.reason.kind) {
|
|
97
|
+
case "completed": return {
|
|
98
|
+
input: {
|
|
99
|
+
phase: "done",
|
|
100
|
+
line: "完成啦"
|
|
101
|
+
},
|
|
102
|
+
completedTurn: event.data.turn
|
|
103
|
+
};
|
|
104
|
+
case "error": return { input: {
|
|
105
|
+
phase: "failed",
|
|
106
|
+
line: "执行失败"
|
|
107
|
+
} };
|
|
108
|
+
case "max-tokens": return { input: {
|
|
109
|
+
phase: "failed",
|
|
110
|
+
line: "达到输出上限"
|
|
111
|
+
} };
|
|
112
|
+
case "interrupted": return { input: {
|
|
113
|
+
phase: "failed",
|
|
114
|
+
line: "执行意外中断"
|
|
115
|
+
} };
|
|
116
|
+
case "blocked": return { input: {
|
|
117
|
+
phase: "waiting",
|
|
118
|
+
line: "等待继续"
|
|
119
|
+
} };
|
|
120
|
+
case "aborted": return { input: {
|
|
121
|
+
phase: "idle",
|
|
122
|
+
line: "已停止"
|
|
123
|
+
} };
|
|
124
|
+
default: return { input: { phase: "idle" } };
|
|
125
|
+
}
|
|
126
|
+
default: return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
10
130
|
//#region src/treats.ts
|
|
11
131
|
const defaultTreatConfig = {
|
|
12
132
|
turnsPerTreat: 3,
|
|
@@ -80,6 +200,205 @@ function consumeTreat(ledger) {
|
|
|
80
200
|
};
|
|
81
201
|
}
|
|
82
202
|
//#endregion
|
|
203
|
+
//#region src/ledger.ts
|
|
204
|
+
/**
|
|
205
|
+
* Pet affinity economy (ledger) — composes the pure affinity and treats
|
|
206
|
+
* modules with the cooldown/dedup bookkeeping and emits updated persistence
|
|
207
|
+
* snapshots, marking dirty so the owning facade decides when to flush. Read
|
|
208
|
+
* paths (view) no longer settle the economy; settlements happen on explicit
|
|
209
|
+
* economic events: completed-turn rewards (official or legacy) and feeds.
|
|
210
|
+
* @module @linxin666/dsh-pet/ledger
|
|
211
|
+
*/
|
|
212
|
+
/**
|
|
213
|
+
* Holds the current persistence snapshot and all economy bookkeeping. Every
|
|
214
|
+
* mutating call flags takeDirty so the facade persists exactly once per
|
|
215
|
+
* batch of changes; read methods (snapshot, affinityView) never write.
|
|
216
|
+
*/
|
|
217
|
+
var PetLedger = class {
|
|
218
|
+
affinityConfig;
|
|
219
|
+
treatConfig;
|
|
220
|
+
current;
|
|
221
|
+
/** Completed turns already rewarded, per session (turn numbers are per-session). */
|
|
222
|
+
rewardedTurns = /* @__PURE__ */ new Map();
|
|
223
|
+
lastLegacyTurnRewardAt = 0;
|
|
224
|
+
dirty = false;
|
|
225
|
+
constructor(persist, config = {}) {
|
|
226
|
+
this.affinityConfig = {
|
|
227
|
+
...defaultAffinityConfig,
|
|
228
|
+
...config.affinity ?? {}
|
|
229
|
+
};
|
|
230
|
+
this.treatConfig = {
|
|
231
|
+
...defaultTreatConfig,
|
|
232
|
+
...config.treats ?? {}
|
|
233
|
+
};
|
|
234
|
+
this.current = persist;
|
|
235
|
+
}
|
|
236
|
+
/** Affinity cooldown/rank tuning (read-only). */
|
|
237
|
+
get affinity() {
|
|
238
|
+
return this.affinityConfig;
|
|
239
|
+
}
|
|
240
|
+
/** The current persistence snapshot (trade a copy when mutating). */
|
|
241
|
+
get snapshot() {
|
|
242
|
+
return this.current;
|
|
243
|
+
}
|
|
244
|
+
/** Stock cap reported to clients. */
|
|
245
|
+
get treatMax() {
|
|
246
|
+
return this.treatConfig.maxTreats;
|
|
247
|
+
}
|
|
248
|
+
/** Consume the pending-write flag if any mutation occurred. */
|
|
249
|
+
takeDirty() {
|
|
250
|
+
const was = this.dirty;
|
|
251
|
+
this.dirty = false;
|
|
252
|
+
return was;
|
|
253
|
+
}
|
|
254
|
+
/** Replace the display block (clamping stays a caller concern). */
|
|
255
|
+
setDisplay(display) {
|
|
256
|
+
this.current = {
|
|
257
|
+
...this.current,
|
|
258
|
+
display
|
|
259
|
+
};
|
|
260
|
+
this.dirty = true;
|
|
261
|
+
}
|
|
262
|
+
/** Replace the selected pet id (validation stays a caller concern). */
|
|
263
|
+
setPetId(petId) {
|
|
264
|
+
if (this.current.petId === petId) return;
|
|
265
|
+
this.current = {
|
|
266
|
+
...this.current,
|
|
267
|
+
petId
|
|
268
|
+
};
|
|
269
|
+
this.dirty = true;
|
|
270
|
+
}
|
|
271
|
+
/** Replace one pet's display name (validation stays a caller concern). */
|
|
272
|
+
setPetName(petId, name) {
|
|
273
|
+
this.current = {
|
|
274
|
+
...this.current,
|
|
275
|
+
names: {
|
|
276
|
+
...this.current.names,
|
|
277
|
+
[petId]: name
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
this.dirty = true;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Settle the treat economy (work + time output since the last settlement).
|
|
284
|
+
* A zero-gain first settlement still starts the time clock (anchor write),
|
|
285
|
+
* which is how the 30-minute time output can ever accrue. Returns true when
|
|
286
|
+
* the in-memory ledger changed and should be persisted.
|
|
287
|
+
*/
|
|
288
|
+
settleTreats(nowMs) {
|
|
289
|
+
const settlement = settleTreatGrants(this.current.treats, this.current.affinity.turns, nowMs, this.treatConfig);
|
|
290
|
+
if (settlement.ledger === this.current.treats) return false;
|
|
291
|
+
this.current = {
|
|
292
|
+
...this.current,
|
|
293
|
+
treats: settlement.ledger
|
|
294
|
+
};
|
|
295
|
+
this.dirty = true;
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Award the completed-turn reward once per session+turn (idempotent) and
|
|
300
|
+
* run the treat settlement that work output feeds. Returns true when the
|
|
301
|
+
* snapshot changed.
|
|
302
|
+
*/
|
|
303
|
+
rewardTurn(sessionId, turn, nowMs) {
|
|
304
|
+
if (turn <= (this.rewardedTurns.get(sessionId) ?? 0)) return false;
|
|
305
|
+
this.rewardedTurns.set(sessionId, turn);
|
|
306
|
+
let changed = this.applyTurnReward();
|
|
307
|
+
if (this.settleTreats(nowMs)) changed = true;
|
|
308
|
+
return changed;
|
|
309
|
+
}
|
|
310
|
+
/** Preserve turn rewards for installations that only emit legacy activity. */
|
|
311
|
+
rewardLegacyTurn(nowMs) {
|
|
312
|
+
if (nowMs - this.lastLegacyTurnRewardAt < 5e3) return false;
|
|
313
|
+
this.lastLegacyTurnRewardAt = nowMs;
|
|
314
|
+
let changed = this.applyTurnReward();
|
|
315
|
+
if (this.settleTreats(nowMs)) changed = true;
|
|
316
|
+
return changed;
|
|
317
|
+
}
|
|
318
|
+
applyTurnReward() {
|
|
319
|
+
this.current = {
|
|
320
|
+
...this.current,
|
|
321
|
+
affinity: applyTurnReward(this.current.affinity, this.affinityConfig)
|
|
322
|
+
};
|
|
323
|
+
this.dirty = true;
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Pet or feed the pet. Feeding settles first, then gates on the feed
|
|
328
|
+
* cooldown before spending stock — a feed inside the cooldown must not burn
|
|
329
|
+
* a treat for nothing.
|
|
330
|
+
*/
|
|
331
|
+
interact(kind, nowMs) {
|
|
332
|
+
if (kind === "feed") this.settleTreats(nowMs);
|
|
333
|
+
const outcome = applyInteraction(this.current.affinity, kind, nowMs, this.affinityConfig);
|
|
334
|
+
if (kind === "feed" && !outcome.accepted) return {
|
|
335
|
+
reaction: outcome.reaction,
|
|
336
|
+
delta: 0,
|
|
337
|
+
affinity: this.affinityView(nowMs)
|
|
338
|
+
};
|
|
339
|
+
if (kind === "feed") {
|
|
340
|
+
const consume = consumeTreat(this.current.treats);
|
|
341
|
+
if (!consume.ok) return {
|
|
342
|
+
reaction: "没有小鱼干了,多陪我工作一会儿吧~",
|
|
343
|
+
delta: 0,
|
|
344
|
+
affinity: this.affinityView(nowMs)
|
|
345
|
+
};
|
|
346
|
+
this.current = {
|
|
347
|
+
...this.current,
|
|
348
|
+
treats: consume.ledger
|
|
349
|
+
};
|
|
350
|
+
this.dirty = true;
|
|
351
|
+
}
|
|
352
|
+
if (outcome.accepted) {
|
|
353
|
+
this.current = {
|
|
354
|
+
...this.current,
|
|
355
|
+
affinity: outcome.affinity
|
|
356
|
+
};
|
|
357
|
+
this.dirty = true;
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
reaction: outcome.reaction,
|
|
361
|
+
delta: outcome.delta,
|
|
362
|
+
affinity: this.affinityView(nowMs)
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/** Current affinity view for the RPC snapshot. */
|
|
366
|
+
affinityView(nowMs) {
|
|
367
|
+
return affinityViewOf(this.current.affinity, nowMs, this.affinityConfig);
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region src/dsh-home.ts
|
|
372
|
+
/**
|
|
373
|
+
* DSH_HOME resolution shared by the plugin family's Host halves: the
|
|
374
|
+
* environment override wins, the platform home fallback follows. Mirrors
|
|
375
|
+
* what dsh-pet and dsh-liangshen each used to implement locally.
|
|
376
|
+
*/
|
|
377
|
+
/** Expand a leading ~ (or ~user) in a path, platform-style. */
|
|
378
|
+
function expandHome(path, home = homedir()) {
|
|
379
|
+
if (path === "~") return home;
|
|
380
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
|
|
381
|
+
return path;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Resolve the DSH home directory.
|
|
385
|
+
* @param env - process environment to read DSH_HOME from.
|
|
386
|
+
* @param home - platform home directory fallback (test seam).
|
|
387
|
+
* @returns the absolute DSH home path.
|
|
388
|
+
*/
|
|
389
|
+
function resolveDshHome(env = process.env, home = homedir()) {
|
|
390
|
+
const raw = env.DSH_HOME;
|
|
391
|
+
if (raw !== void 0 && raw.trim() !== "") {
|
|
392
|
+
const expanded = expandHome(raw.trim(), home);
|
|
393
|
+
return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded);
|
|
394
|
+
}
|
|
395
|
+
return join(home, ".dsh");
|
|
396
|
+
}
|
|
397
|
+
/** Resolve the DSH home directory from the live environment. */
|
|
398
|
+
function dshHome() {
|
|
399
|
+
return resolveDshHome();
|
|
400
|
+
}
|
|
401
|
+
//#endregion
|
|
83
402
|
//#region src/persist.ts
|
|
84
403
|
/**
|
|
85
404
|
* Pet persistence — tiny JSON store for affinity + display config, written
|
|
@@ -94,24 +413,45 @@ const defaultDisplayConfig = {
|
|
|
94
413
|
bottom: 20
|
|
95
414
|
};
|
|
96
415
|
const DISPLAY_INSET_MAX = 1e4;
|
|
97
|
-
/**
|
|
416
|
+
/** Pet id the legacy single-pet installs resolve to on migration. */
|
|
417
|
+
const DEFAULT_PET_ID = "whale-girl";
|
|
418
|
+
/** Default pet name (used only when a manifest carries no displayName). */
|
|
98
419
|
const DEFAULT_PET_NAME = "鲸鱼娘";
|
|
420
|
+
/** Name constraints. */
|
|
421
|
+
const PET_NAME_MAX_LENGTH = 20;
|
|
99
422
|
function emptyPersist() {
|
|
100
423
|
return {
|
|
101
|
-
|
|
424
|
+
petId: DEFAULT_PET_ID,
|
|
425
|
+
names: {},
|
|
102
426
|
affinity: emptyAffinity(),
|
|
103
427
|
treats: emptyTreatLedger(),
|
|
104
428
|
display: { ...defaultDisplayConfig }
|
|
105
429
|
};
|
|
106
430
|
}
|
|
107
|
-
/**
|
|
431
|
+
/**
|
|
432
|
+
* Resolve the persistence directory ($DSH_HOME or ~/.dsh). Delegates to the
|
|
433
|
+
* shared {@link dshHome} resolution so the plugin family keeps one DSH_HOME
|
|
434
|
+
* definition (env override, ~ expansion, cwd-joined relative values).
|
|
435
|
+
*/
|
|
108
436
|
function petHomeDir() {
|
|
109
|
-
return
|
|
437
|
+
return dshHome();
|
|
110
438
|
}
|
|
111
439
|
/** Numeric field guard: finite numbers only, else the fallback. */
|
|
112
440
|
function finiteNum(value, fallback) {
|
|
113
441
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
114
442
|
}
|
|
443
|
+
/** Sanitize the per-pet names map (string keys, non-empty trimmed values). */
|
|
444
|
+
function loadPetNames(parsed) {
|
|
445
|
+
const names = {};
|
|
446
|
+
if (typeof parsed.names !== "object" || parsed.names === null) return names;
|
|
447
|
+
for (const [id, value] of Object.entries(parsed.names)) {
|
|
448
|
+
if (id === "" || typeof value !== "string") continue;
|
|
449
|
+
const name = value.trim();
|
|
450
|
+
if (name === "") continue;
|
|
451
|
+
names[id] = name.slice(0, 20);
|
|
452
|
+
}
|
|
453
|
+
return names;
|
|
454
|
+
}
|
|
115
455
|
/** Clamp one count/score into [0, max]. */
|
|
116
456
|
function clamp(value, max) {
|
|
117
457
|
return Math.min(max, Math.max(0, value));
|
|
@@ -144,8 +484,12 @@ function loadPetPersist(dir = petHomeDir()) {
|
|
|
144
484
|
right: Math.round(clamp(finiteNum(rawDisplay.right, base.display.right), DISPLAY_INSET_MAX)),
|
|
145
485
|
bottom: Math.round(clamp(finiteNum(rawDisplay.bottom, base.display.bottom), DISPLAY_INSET_MAX))
|
|
146
486
|
};
|
|
487
|
+
const petId = typeof parsed.petId === "string" && parsed.petId.trim() !== "" ? parsed.petId.trim() : base.petId;
|
|
488
|
+
const names = loadPetNames(parsed);
|
|
489
|
+
if (typeof parsed.name === "string" && parsed.name.trim() !== "" && names[petId] === void 0) names[petId] = parsed.name.trim().slice(0, 20);
|
|
147
490
|
return {
|
|
148
|
-
|
|
491
|
+
petId,
|
|
492
|
+
names,
|
|
149
493
|
affinity,
|
|
150
494
|
treats,
|
|
151
495
|
display
|
|
@@ -162,153 +506,391 @@ function savePetPersist(data, dir = petHomeDir()) {
|
|
|
162
506
|
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
|
|
163
507
|
renameSync(tmp, target);
|
|
164
508
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/registry.ts
|
|
511
|
+
/**
|
|
512
|
+
* Pet registry — the multi-pet contract. One pet is a directory holding a
|
|
513
|
+
* 'pet.json' manifest plus an atlas image; nothing else is required, and no
|
|
514
|
+
* host or client code changes when a pet is added. The registry scans three
|
|
515
|
+
* sources, later sources overriding earlier ones on an id collision:
|
|
516
|
+
*
|
|
517
|
+
* 1. the package's own 'assets' subdirectories (built-in pets);
|
|
518
|
+
* 2. '${CODEX_HOME:-~/.codex}/pets' subdirectories (hatch-pet custom pets);
|
|
519
|
+
* 3. 'PetConfig.pets' manifests composed by the embedding application
|
|
520
|
+
* (highest precedence).
|
|
521
|
+
*
|
|
522
|
+
* The manifest follows the Codex/hatch-pet contract (8 columns x 9 rows of
|
|
523
|
+
* 192x208 cells, the 9-state row order below). Legacy whale-girl manifests
|
|
524
|
+
* that only carry 'frames' keep working: geometry, per-row frame counts and
|
|
525
|
+
* per-track rhythm all fall back to the hatch-pet contract defaults, and the
|
|
526
|
+
* whale-girl manifest overrides its own durations.
|
|
527
|
+
* @module @linxin666/dsh-pet/registry
|
|
528
|
+
*/
|
|
529
|
+
/** Fixed row order of the 9-state animation contract. */
|
|
530
|
+
const PET_ROW_ORDER = [
|
|
531
|
+
"idle",
|
|
532
|
+
"running-right",
|
|
533
|
+
"running-left",
|
|
534
|
+
"waving",
|
|
535
|
+
"jumping",
|
|
536
|
+
"failed",
|
|
537
|
+
"waiting",
|
|
538
|
+
"running",
|
|
539
|
+
"review"
|
|
540
|
+
];
|
|
541
|
+
/** Atlas cell size in px (Codex/hatch-pet contract). */
|
|
542
|
+
const DEFAULT_PET_CELL = {
|
|
543
|
+
width: 192,
|
|
544
|
+
height: 208
|
|
545
|
+
};
|
|
546
|
+
/** Columns per row (max frames per track). */
|
|
547
|
+
const DEFAULT_PET_COLUMNS = 8;
|
|
548
|
+
/** Rows in the atlas (fixed by the animation contract). */
|
|
549
|
+
const DEFAULT_PET_ROW_COUNT = 9;
|
|
550
|
+
/**
|
|
551
|
+
* Per-row used-column counts from the hatch-pet contract table. Manifests
|
|
552
|
+
* that carry no 'frames' field (the Codex custom-pet shape) resolve here.
|
|
553
|
+
*/
|
|
554
|
+
const DEFAULT_FRAME_COUNTS = [
|
|
555
|
+
6,
|
|
556
|
+
8,
|
|
557
|
+
8,
|
|
558
|
+
4,
|
|
559
|
+
5,
|
|
560
|
+
8,
|
|
561
|
+
6,
|
|
562
|
+
6,
|
|
563
|
+
6
|
|
564
|
+
];
|
|
565
|
+
/** Absolute package root, resolved from a module URL (lib/ or src/). */
|
|
566
|
+
function petPackageRoot(importMetaUrl) {
|
|
567
|
+
return fileURLToPath(new URL("../", importMetaUrl));
|
|
169
568
|
}
|
|
170
|
-
/**
|
|
171
|
-
function
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
"done",
|
|
179
|
-
"failed"
|
|
180
|
-
].includes(phase);
|
|
569
|
+
/** Resolve the hatch-pet custom pets directory (CODEX_HOME or ~/.codex). */
|
|
570
|
+
function codexPetsDir(env = process.env, home = homedir()) {
|
|
571
|
+
const raw = env.CODEX_HOME !== void 0 && env.CODEX_HOME.trim() !== "" ? env.CODEX_HOME.trim() : join(home, ".codex");
|
|
572
|
+
return join(raw === "~" ? home : raw.startsWith("~/") || raw.startsWith("~\\") ? join(home, raw.slice(2)) : raw, "pets");
|
|
573
|
+
}
|
|
574
|
+
/** Finite non-negative integer guard, else the fallback. */
|
|
575
|
+
function finiteInt(value, fallback, max) {
|
|
576
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= max ? value : fallback;
|
|
181
577
|
}
|
|
578
|
+
/** Build the browser URL of one pet asset. */
|
|
579
|
+
function assetUrl(prefix, id, file) {
|
|
580
|
+
const path = file.split("/").filter((segment) => segment !== "").join("/");
|
|
581
|
+
return prefix + "/" + encodeURIComponent(id) + "/" + path;
|
|
582
|
+
}
|
|
583
|
+
/** Default per-track rhythm (hatch-pet contract table). */
|
|
584
|
+
const DEFAULT_TRACK_PATTERNS = {
|
|
585
|
+
idle: {
|
|
586
|
+
durations: [
|
|
587
|
+
280,
|
|
588
|
+
110,
|
|
589
|
+
110,
|
|
590
|
+
140,
|
|
591
|
+
140,
|
|
592
|
+
320
|
|
593
|
+
],
|
|
594
|
+
loop: true
|
|
595
|
+
},
|
|
596
|
+
"running-right": {
|
|
597
|
+
durations: [
|
|
598
|
+
120,
|
|
599
|
+
120,
|
|
600
|
+
120,
|
|
601
|
+
120,
|
|
602
|
+
120,
|
|
603
|
+
120,
|
|
604
|
+
120,
|
|
605
|
+
220
|
|
606
|
+
],
|
|
607
|
+
loop: true
|
|
608
|
+
},
|
|
609
|
+
"running-left": {
|
|
610
|
+
durations: [
|
|
611
|
+
120,
|
|
612
|
+
120,
|
|
613
|
+
120,
|
|
614
|
+
120,
|
|
615
|
+
120,
|
|
616
|
+
120,
|
|
617
|
+
120,
|
|
618
|
+
220
|
|
619
|
+
],
|
|
620
|
+
loop: true
|
|
621
|
+
},
|
|
622
|
+
waving: {
|
|
623
|
+
durations: [
|
|
624
|
+
140,
|
|
625
|
+
140,
|
|
626
|
+
140,
|
|
627
|
+
280
|
|
628
|
+
],
|
|
629
|
+
loop: true
|
|
630
|
+
},
|
|
631
|
+
jumping: {
|
|
632
|
+
durations: [
|
|
633
|
+
140,
|
|
634
|
+
140,
|
|
635
|
+
140,
|
|
636
|
+
140,
|
|
637
|
+
280
|
|
638
|
+
],
|
|
639
|
+
loop: false,
|
|
640
|
+
fallback: "idle"
|
|
641
|
+
},
|
|
642
|
+
failed: {
|
|
643
|
+
durations: [
|
|
644
|
+
140,
|
|
645
|
+
140,
|
|
646
|
+
140,
|
|
647
|
+
140,
|
|
648
|
+
140,
|
|
649
|
+
140,
|
|
650
|
+
140,
|
|
651
|
+
240
|
|
652
|
+
],
|
|
653
|
+
loop: false,
|
|
654
|
+
fallback: "idle"
|
|
655
|
+
},
|
|
656
|
+
waiting: {
|
|
657
|
+
durations: [
|
|
658
|
+
150,
|
|
659
|
+
150,
|
|
660
|
+
150,
|
|
661
|
+
150,
|
|
662
|
+
150,
|
|
663
|
+
260
|
|
664
|
+
],
|
|
665
|
+
loop: true
|
|
666
|
+
},
|
|
667
|
+
running: {
|
|
668
|
+
durations: [
|
|
669
|
+
120,
|
|
670
|
+
120,
|
|
671
|
+
120,
|
|
672
|
+
120,
|
|
673
|
+
120,
|
|
674
|
+
220
|
|
675
|
+
],
|
|
676
|
+
loop: true
|
|
677
|
+
},
|
|
678
|
+
review: {
|
|
679
|
+
durations: [
|
|
680
|
+
150,
|
|
681
|
+
150,
|
|
682
|
+
150,
|
|
683
|
+
150,
|
|
684
|
+
150,
|
|
685
|
+
280
|
|
686
|
+
],
|
|
687
|
+
loop: true
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
/** Stable id charset: keeps asset URLs plain and filesystem-safe. */
|
|
691
|
+
const PET_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
692
|
+
/** Safe path-segment charset for atlas files. */
|
|
693
|
+
const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
694
|
+
const PET_NAME_MAX_LENGTH$1 = 80;
|
|
182
695
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
696
|
+
* Normalize one parsed manifest into a renderable pet entry, or undefined
|
|
697
|
+
* (with a warning recorded) when the manifest violates the contract.
|
|
185
698
|
*/
|
|
186
|
-
function
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
699
|
+
function resolvePetManifest(raw, dir, options = {}) {
|
|
700
|
+
const { assetPrefix = "/pet", warnings = [] } = options;
|
|
701
|
+
const warn = (message) => {
|
|
702
|
+
warnings.push(message);
|
|
703
|
+
};
|
|
704
|
+
if (typeof raw !== "object" || raw === null) {
|
|
705
|
+
warn("manifest is not an object");
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const source = raw;
|
|
709
|
+
const id = typeof source.id === "string" ? source.id.trim() : "";
|
|
710
|
+
if (!PET_ID_PATTERN.test(id)) {
|
|
711
|
+
warn("manifest id " + JSON.stringify(String(source.id)) + " is not a lowercase kebab id");
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const displayName = typeof source.displayName === "string" && source.displayName.trim() !== "" ? source.displayName.trim().slice(0, PET_NAME_MAX_LENGTH$1) : id;
|
|
715
|
+
const description = typeof source.description === "string" ? source.description.trim() : "";
|
|
716
|
+
const spritesheet = typeof source.spritesheetPath === "string" && source.spritesheetPath.trim() !== "" ? source.spritesheetPath.trim() : "spritesheet.webp";
|
|
717
|
+
const spritesheetPath = spritesheet.split("/").filter((segment) => segment !== "");
|
|
718
|
+
if (spritesheetPath.length === 0 || isAbsolute(spritesheet) || spritesheet.includes("\\") || spritesheetPath.some((segment) => segment === ".." || !PATH_SEGMENT_PATTERN.test(segment))) {
|
|
719
|
+
warn("manifest spritesheetPath " + JSON.stringify(spritesheet) + " is not a safe relative path");
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const rawCell = typeof source.cell === "object" && source.cell !== null ? source.cell : {};
|
|
723
|
+
const cell = {
|
|
724
|
+
width: finiteInt(rawCell.width, DEFAULT_PET_CELL.width, 2048),
|
|
725
|
+
height: finiteInt(rawCell.height, DEFAULT_PET_CELL.height, 2048)
|
|
726
|
+
};
|
|
727
|
+
const columns = finiteInt(source.columns, 8, 32);
|
|
728
|
+
const rows = DEFAULT_FRAME_COUNTS.map((fallback, index) => {
|
|
729
|
+
return finiteInt(Array.isArray(source.frames) ? source.frames[index] : void 0, fallback, columns);
|
|
730
|
+
});
|
|
731
|
+
const trackOverrides = typeof source.tracks === "object" && source.tracks !== null ? source.tracks : {};
|
|
732
|
+
const tracks = {};
|
|
733
|
+
for (const [row, animation] of PET_ROW_ORDER.entries()) {
|
|
734
|
+
const pattern = DEFAULT_TRACK_PATTERNS[animation];
|
|
735
|
+
const override = trackOverrides[animation];
|
|
736
|
+
const durations = Array.isArray(override?.durations) && override.durations.length > 0 ? override.durations.filter((value) => typeof value === "number" && Number.isFinite(value) && value > 0) : pattern.durations;
|
|
737
|
+
if (durations.length === 0) {
|
|
738
|
+
warn("manifest " + id + ": track " + animation + " carries no usable durations");
|
|
212
739
|
return;
|
|
213
740
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
741
|
+
const frameCount = Math.max(1, Math.min(rows[row], columns));
|
|
742
|
+
const sized = durations.length >= frameCount ? durations.slice(0, frameCount) : Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]);
|
|
743
|
+
tracks[animation] = {
|
|
744
|
+
frames: Array.from({ length: frameCount }, (_, index) => index),
|
|
745
|
+
durations: sized,
|
|
746
|
+
loop: typeof override?.loop === "boolean" ? override.loop : pattern.loop,
|
|
747
|
+
...override?.fallback === void 0 ? pattern.fallback === void 0 ? {} : { fallback: pattern.fallback } : PET_ROW_ORDER.includes(override.fallback) ? { fallback: override.fallback } : pattern.fallback === void 0 ? {} : { fallback: pattern.fallback }
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
id,
|
|
752
|
+
displayName,
|
|
753
|
+
description,
|
|
754
|
+
cell,
|
|
755
|
+
columns,
|
|
756
|
+
rows,
|
|
757
|
+
tracks,
|
|
758
|
+
atlasUrl: assetUrl(assetPrefix, id, spritesheet),
|
|
759
|
+
manifestUrl: assetUrl(assetPrefix, id, "pet.json"),
|
|
760
|
+
dir,
|
|
761
|
+
spritesheetPath: spritesheetPath.join("/")
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
/** Scan one directory of pet folders; entries come back in name order. */
|
|
765
|
+
function scanPetDir(dir, options) {
|
|
766
|
+
if (!existsSync(dir)) return [];
|
|
767
|
+
let names = [];
|
|
768
|
+
try {
|
|
769
|
+
names = readdirSync(dir).filter((name) => !name.startsWith("."));
|
|
770
|
+
} catch {
|
|
771
|
+
return [];
|
|
772
|
+
}
|
|
773
|
+
names.sort();
|
|
774
|
+
const entries = [];
|
|
775
|
+
for (const name of names) {
|
|
776
|
+
const manifestFile = join(dir, name, "pet.json");
|
|
777
|
+
if (!existsSync(manifestFile)) continue;
|
|
778
|
+
const parsed = readPetJson(manifestFile, options.warnings);
|
|
779
|
+
if (parsed === void 0) continue;
|
|
780
|
+
const entry = resolvePetManifest(parsed, join(dir, name), options);
|
|
781
|
+
if (entry !== void 0) entries.push(entry);
|
|
782
|
+
}
|
|
783
|
+
return entries;
|
|
784
|
+
}
|
|
785
|
+
/** Read and parse one manifest file; undefined (warning recorded) on failure. */
|
|
786
|
+
function readPetJson(file, warnings) {
|
|
787
|
+
try {
|
|
788
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
789
|
+
} catch (error) {
|
|
790
|
+
warnings?.push("skipping " + file + ": " + (error instanceof Error ? error.message : String(error)));
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Load the pet registry: built-in 'assets/*' first, then the hatch-pet
|
|
796
|
+
* custom pets directory, then composed 'extra' manifests (each later source
|
|
797
|
+
* overrides an earlier one on id collision). The registry never throws on a
|
|
798
|
+
* bad manifest: it skips it and records a warning.
|
|
799
|
+
*/
|
|
800
|
+
function loadPetRegistry(options) {
|
|
801
|
+
const { packageRoot, assetPrefix = "/pet" } = options;
|
|
802
|
+
const warnings = [];
|
|
803
|
+
const byId = /* @__PURE__ */ new Map();
|
|
804
|
+
const builtinIds = /* @__PURE__ */ new Set();
|
|
805
|
+
for (const entry of scanPetDir(join(packageRoot, "assets"), {
|
|
806
|
+
assetPrefix,
|
|
807
|
+
warnings
|
|
808
|
+
})) {
|
|
809
|
+
if (byId.has(entry.id)) {
|
|
810
|
+
warnings.push("duplicate built-in pet id " + entry.id + "; the first one wins");
|
|
811
|
+
continue;
|
|
239
812
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
switch (event.data.reason.kind) {
|
|
243
|
-
case "completed": return {
|
|
244
|
-
input: {
|
|
245
|
-
phase: "done",
|
|
246
|
-
line: "完成啦"
|
|
247
|
-
},
|
|
248
|
-
completedTurn: event.data.turn
|
|
249
|
-
};
|
|
250
|
-
case "error": return { input: {
|
|
251
|
-
phase: "failed",
|
|
252
|
-
line: "执行失败"
|
|
253
|
-
} };
|
|
254
|
-
case "max-tokens": return { input: {
|
|
255
|
-
phase: "failed",
|
|
256
|
-
line: "达到输出上限"
|
|
257
|
-
} };
|
|
258
|
-
case "interrupted": return { input: {
|
|
259
|
-
phase: "failed",
|
|
260
|
-
line: "执行意外中断"
|
|
261
|
-
} };
|
|
262
|
-
case "blocked": return { input: {
|
|
263
|
-
phase: "waiting",
|
|
264
|
-
line: "等待继续"
|
|
265
|
-
} };
|
|
266
|
-
case "aborted": return { input: {
|
|
267
|
-
phase: "idle",
|
|
268
|
-
line: "已停止"
|
|
269
|
-
} };
|
|
270
|
-
default: return { input: { phase: "idle" } };
|
|
271
|
-
}
|
|
272
|
-
default: return;
|
|
813
|
+
byId.set(entry.id, entry);
|
|
814
|
+
builtinIds.add(entry.id);
|
|
273
815
|
}
|
|
816
|
+
const petsDir = options.petsDir ?? codexPetsDir();
|
|
817
|
+
if (petsDir !== "") for (const entry of scanPetDir(petsDir, {
|
|
818
|
+
assetPrefix,
|
|
819
|
+
warnings
|
|
820
|
+
})) {
|
|
821
|
+
if (byId.has(entry.id)) warnings.push("custom pet " + entry.id + " overrides the built-in one");
|
|
822
|
+
byId.set(entry.id, entry);
|
|
823
|
+
}
|
|
824
|
+
for (const manifest of options.extra ?? []) {
|
|
825
|
+
const entry = resolvePetManifest(manifest, manifest.spritesheetPath === void 0 || isAbsolute(manifest.spritesheetPath) ? join(packageRoot, "assets", "extra") : dirname(resolve(packageRoot, manifest.spritesheetPath)), {
|
|
826
|
+
assetPrefix,
|
|
827
|
+
warnings
|
|
828
|
+
});
|
|
829
|
+
if (entry === void 0) continue;
|
|
830
|
+
if (byId.has(entry.id)) warnings.push("composed pet " + entry.id + " overrides an earlier registration");
|
|
831
|
+
byId.set(entry.id, entry);
|
|
832
|
+
}
|
|
833
|
+
const entries = [...byId.values()];
|
|
834
|
+
return {
|
|
835
|
+
entries,
|
|
836
|
+
warnings,
|
|
837
|
+
byId: (id) => byId.get(id),
|
|
838
|
+
defaultEntry: () => entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0]
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
/** Strip host-only fields, leaving the client-visible definition. */
|
|
842
|
+
function petEntryView(entry) {
|
|
843
|
+
return {
|
|
844
|
+
id: entry.id,
|
|
845
|
+
displayName: entry.displayName,
|
|
846
|
+
description: entry.description,
|
|
847
|
+
cell: entry.cell,
|
|
848
|
+
columns: entry.columns,
|
|
849
|
+
rows: entry.rows,
|
|
850
|
+
tracks: entry.tracks,
|
|
851
|
+
atlasUrl: entry.atlasUrl,
|
|
852
|
+
manifestUrl: entry.manifestUrl
|
|
853
|
+
};
|
|
274
854
|
}
|
|
275
855
|
/**
|
|
276
856
|
* Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
|
|
277
|
-
* written until
|
|
278
|
-
* in-memory state, and persistence happens on
|
|
279
|
-
*
|
|
857
|
+
* written until an economic event or interaction arrives; event listeners
|
|
858
|
+
* update only in-memory state, and persistence happens on economic changes
|
|
859
|
+
* (turn rewards, feeds, config/name changes) — never on a read.
|
|
280
860
|
*/
|
|
281
861
|
var PetService = class extends Service {
|
|
282
862
|
static inject = [];
|
|
283
863
|
machine;
|
|
284
|
-
|
|
285
|
-
|
|
864
|
+
ledger;
|
|
865
|
+
registry;
|
|
286
866
|
persistDir;
|
|
287
|
-
persist;
|
|
288
|
-
/** Completed turns already rewarded, per session (turn numbers are per-session). */
|
|
289
|
-
rewardedTurns = /* @__PURE__ */ new Map();
|
|
290
867
|
enabled;
|
|
291
868
|
disposeActivity;
|
|
292
869
|
/** Session whose most recent meaningful event currently drives the global pet. */
|
|
293
870
|
displaySession;
|
|
294
871
|
sessionActivity = /* @__PURE__ */ new WeakMap();
|
|
295
|
-
lastLegacyTurnRewardAt = 0;
|
|
296
872
|
constructor(ctx, config = {}) {
|
|
297
873
|
super(ctx, "pet");
|
|
298
874
|
this.persistDir = config.persistDir ?? petHomeDir();
|
|
299
|
-
this.
|
|
300
|
-
|
|
301
|
-
...config.
|
|
875
|
+
this.registry = config.registry ?? loadPetRegistry({
|
|
876
|
+
packageRoot: petPackageRoot(import.meta.url),
|
|
877
|
+
...config.pets === void 0 ? {} : { extra: config.pets }
|
|
878
|
+
});
|
|
879
|
+
if (this.registry.entries.length === 0) throw new Error("[dsh-pet] no valid pet manifests found; nothing to render");
|
|
880
|
+
let persist = loadPetPersist(this.persistDir);
|
|
881
|
+
if (this.registry.byId(persist.petId) === void 0) persist = {
|
|
882
|
+
...persist,
|
|
883
|
+
petId: this.registry.defaultEntry().id
|
|
302
884
|
};
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
885
|
+
const ledgerConfig = {
|
|
886
|
+
affinity: config.affinity,
|
|
887
|
+
treats: config.treats
|
|
306
888
|
};
|
|
889
|
+
this.ledger = new PetLedger(persist, ledgerConfig);
|
|
307
890
|
this.machine = new PetStateMachine({
|
|
308
891
|
...defaultPetStateConfig,
|
|
309
892
|
...config.state ?? {}
|
|
310
893
|
});
|
|
311
|
-
this.persist = loadPetPersist(this.persistDir);
|
|
312
894
|
this.enabled = config.enabled ?? true;
|
|
313
895
|
this.syncActivity();
|
|
314
896
|
}
|
|
@@ -322,11 +904,44 @@ var PetService = class extends Service {
|
|
|
322
904
|
}
|
|
323
905
|
/** Current persisted display config (read-only view). */
|
|
324
906
|
display() {
|
|
325
|
-
return { ...this.
|
|
907
|
+
return { ...this.ledger.snapshot.display };
|
|
908
|
+
}
|
|
909
|
+
/** RPC: the registry entries the browser half renders and selects from. */
|
|
910
|
+
async pets() {
|
|
911
|
+
return this.registry.entries.map(petEntryView);
|
|
912
|
+
}
|
|
913
|
+
/** The loaded registry (the asset routes serve its entries). */
|
|
914
|
+
registrySnapshot() {
|
|
915
|
+
return this.registry;
|
|
916
|
+
}
|
|
917
|
+
/** The selected pet's registry entry. */
|
|
918
|
+
activeEntry() {
|
|
919
|
+
return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry();
|
|
326
920
|
}
|
|
327
|
-
/**
|
|
328
|
-
|
|
329
|
-
return this.
|
|
921
|
+
/** Currently selected pet id (persisted). */
|
|
922
|
+
selectedPetId() {
|
|
923
|
+
return this.ledger.snapshot.petId;
|
|
924
|
+
}
|
|
925
|
+
/** The display name of one pet (user rename or manifest displayName). */
|
|
926
|
+
petName(petId = this.selectedPetId()) {
|
|
927
|
+
const stored = this.ledger.snapshot.names[petId];
|
|
928
|
+
if (stored !== void 0 && stored.trim() !== "") return stored;
|
|
929
|
+
return this.registry.byId(petId)?.displayName ?? "鲸鱼娘";
|
|
930
|
+
}
|
|
931
|
+
/** RPC: switch the selected pet (persisted, settings document mirrored). */
|
|
932
|
+
async setPetId(petId) {
|
|
933
|
+
const entry = this.registry.byId(petId);
|
|
934
|
+
if (entry === void 0) return {
|
|
935
|
+
ok: false,
|
|
936
|
+
error: "unknown-pet"
|
|
937
|
+
};
|
|
938
|
+
this.ledger.setPetId(entry.id);
|
|
939
|
+
this.flush();
|
|
940
|
+
this.syncSettingsFromPet();
|
|
941
|
+
return {
|
|
942
|
+
ok: true,
|
|
943
|
+
petId: entry.id
|
|
944
|
+
};
|
|
330
945
|
}
|
|
331
946
|
/** Start or stop the session-activity listeners that drive the pet. */
|
|
332
947
|
setEnabled(enabled) {
|
|
@@ -372,11 +987,7 @@ var PetService = class extends Service {
|
|
|
372
987
|
activityRuntime(session) {
|
|
373
988
|
let runtime = this.sessionActivity.get(session);
|
|
374
989
|
if (runtime === void 0) {
|
|
375
|
-
runtime =
|
|
376
|
-
activeTools: /* @__PURE__ */ new Set(),
|
|
377
|
-
officialEventsSeen: false,
|
|
378
|
-
stepHadFailure: false
|
|
379
|
-
};
|
|
990
|
+
runtime = emptyProjectionRuntime();
|
|
380
991
|
this.sessionActivity.set(session, runtime);
|
|
381
992
|
}
|
|
382
993
|
return runtime;
|
|
@@ -390,76 +1001,41 @@ var PetService = class extends Service {
|
|
|
390
1001
|
/** RPC: pet or feed the pet. */
|
|
391
1002
|
async interact(kind) {
|
|
392
1003
|
const nowMs = Date.now();
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
reaction: outcome.reaction,
|
|
397
|
-
delta: 0,
|
|
398
|
-
affinity: this.affinityView(this.persist.affinity)
|
|
399
|
-
};
|
|
400
|
-
if (kind === "feed") {
|
|
401
|
-
const consume = consumeTreat(this.persist.treats);
|
|
402
|
-
if (!consume.ok) return {
|
|
403
|
-
reaction: "没有小鱼干了,多陪鲸鱼娘工作一会儿吧~",
|
|
404
|
-
delta: 0,
|
|
405
|
-
affinity: this.affinityView(this.persist.affinity)
|
|
406
|
-
};
|
|
407
|
-
this.persist = {
|
|
408
|
-
...this.persist,
|
|
409
|
-
treats: consume.ledger
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
if (outcome.accepted) {
|
|
413
|
-
this.persist = {
|
|
414
|
-
...this.persist,
|
|
415
|
-
affinity: outcome.affinity
|
|
416
|
-
};
|
|
417
|
-
this.flush();
|
|
418
|
-
}
|
|
419
|
-
const affinity = this.affinityView(outcome.affinity);
|
|
420
|
-
return {
|
|
421
|
-
reaction: outcome.reaction,
|
|
422
|
-
delta: outcome.delta,
|
|
423
|
-
affinity
|
|
424
|
-
};
|
|
1004
|
+
const result = this.ledger.interact(kind, nowMs);
|
|
1005
|
+
if (this.ledger.takeDirty()) this.flush();
|
|
1006
|
+
return result;
|
|
425
1007
|
}
|
|
426
1008
|
/** RPC: show or hide the pet. */
|
|
427
1009
|
async setVisible(visible) {
|
|
428
|
-
this.
|
|
429
|
-
...this.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
visible
|
|
433
|
-
}
|
|
434
|
-
};
|
|
1010
|
+
this.ledger.setDisplay({
|
|
1011
|
+
...this.ledger.snapshot.display,
|
|
1012
|
+
visible
|
|
1013
|
+
});
|
|
435
1014
|
this.flush();
|
|
436
1015
|
this.syncSettingsFromPet();
|
|
437
1016
|
return {
|
|
438
1017
|
ok: true,
|
|
439
|
-
display: this.
|
|
1018
|
+
display: this.ledger.snapshot.display
|
|
440
1019
|
};
|
|
441
1020
|
}
|
|
442
1021
|
/** RPC: update display config (size / position). Values are clamped to whole pixels. */
|
|
443
1022
|
async setConfig(patch) {
|
|
444
1023
|
const next = {
|
|
445
|
-
...this.
|
|
1024
|
+
...this.ledger.snapshot.display,
|
|
446
1025
|
...patch
|
|
447
1026
|
};
|
|
448
1027
|
next.size = Math.round(Math.min(512, Math.max(32, next.size)));
|
|
449
1028
|
next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.right)));
|
|
450
1029
|
next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.bottom)));
|
|
451
|
-
this.
|
|
452
|
-
...this.persist,
|
|
453
|
-
display: next
|
|
454
|
-
};
|
|
1030
|
+
this.ledger.setDisplay(next);
|
|
455
1031
|
this.flush();
|
|
456
1032
|
this.syncSettingsFromPet();
|
|
457
1033
|
return {
|
|
458
1034
|
ok: true,
|
|
459
|
-
display: this.
|
|
1035
|
+
display: this.ledger.snapshot.display
|
|
460
1036
|
};
|
|
461
1037
|
}
|
|
462
|
-
/** RPC: rename the pet (trimmed, 1–20 chars). */
|
|
1038
|
+
/** RPC: rename the selected pet (trimmed, 1–20 chars, per-pet storage). */
|
|
463
1039
|
async setName(name) {
|
|
464
1040
|
const trimmed = name.trim();
|
|
465
1041
|
if (trimmed === "") return {
|
|
@@ -470,139 +1046,111 @@ var PetService = class extends Service {
|
|
|
470
1046
|
ok: false,
|
|
471
1047
|
error: "name-too-long"
|
|
472
1048
|
};
|
|
473
|
-
this.
|
|
474
|
-
...this.persist,
|
|
475
|
-
name: trimmed
|
|
476
|
-
};
|
|
1049
|
+
this.ledger.setPetName(this.selectedPetId(), trimmed);
|
|
477
1050
|
this.flush();
|
|
478
|
-
this.syncSettingsFromPet();
|
|
479
1051
|
return {
|
|
480
1052
|
ok: true,
|
|
481
1053
|
name: trimmed
|
|
482
1054
|
};
|
|
483
1055
|
}
|
|
484
1056
|
/**
|
|
485
|
-
* Apply a committed settings section to the persisted
|
|
486
|
-
* by the settings surface on every change; values are
|
|
487
|
-
* the setConfig RPC so both write paths converge.
|
|
1057
|
+
* Apply a committed settings section to the persisted selection and display
|
|
1058
|
+
* config. Called by the settings surface on every change; values are
|
|
1059
|
+
* clamped exactly like the setConfig RPC so both write paths converge.
|
|
488
1060
|
* @param section - the resolved settings section.
|
|
489
1061
|
*/
|
|
490
1062
|
applySettingsSection(section) {
|
|
491
|
-
|
|
1063
|
+
if (typeof section.petId === "string" && this.registry.byId(section.petId) !== void 0) this.ledger.setPetId(section.petId);
|
|
1064
|
+
else if (section.petId !== void 0) this.syncSettingsFromPet();
|
|
1065
|
+
const next = { ...this.ledger.snapshot.display };
|
|
492
1066
|
next.visible = section.visible && (section.enabled ?? true);
|
|
493
1067
|
next.size = Math.round(Math.min(512, Math.max(32, section.size)));
|
|
494
1068
|
next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)));
|
|
495
1069
|
next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.bottom)));
|
|
496
|
-
this.
|
|
497
|
-
...this.persist,
|
|
498
|
-
display: next,
|
|
499
|
-
name: section.name.trim()
|
|
500
|
-
};
|
|
1070
|
+
this.ledger.setDisplay(next);
|
|
501
1071
|
this.flush();
|
|
502
1072
|
}
|
|
503
1073
|
/** Mirror the persisted display config into the settings document (best-effort). */
|
|
504
1074
|
syncSettingsFromPet() {
|
|
505
1075
|
const settings = this.ctx.get("settings", false);
|
|
506
1076
|
if (settings === void 0) return;
|
|
1077
|
+
const snapshot = this.ledger.snapshot;
|
|
507
1078
|
settings.update("pet", {
|
|
508
|
-
visible:
|
|
509
|
-
size:
|
|
510
|
-
right:
|
|
511
|
-
bottom:
|
|
512
|
-
|
|
1079
|
+
visible: snapshot.display.visible,
|
|
1080
|
+
size: snapshot.display.size,
|
|
1081
|
+
right: snapshot.display.right,
|
|
1082
|
+
bottom: snapshot.display.bottom,
|
|
1083
|
+
petId: snapshot.petId
|
|
513
1084
|
}).catch(() => {});
|
|
514
1085
|
}
|
|
515
1086
|
/** Award the turn reward once per completed turn (idempotent per session + turn). */
|
|
516
1087
|
rewardTurn(sessionId, turn) {
|
|
517
|
-
if (
|
|
518
|
-
this.rewardedTurns.set(sessionId, turn);
|
|
519
|
-
this.applyTurnReward();
|
|
1088
|
+
if (this.ledger.rewardTurn(sessionId, turn, Date.now())) this.flush();
|
|
520
1089
|
}
|
|
521
1090
|
/** Preserve turn rewards for installations that only emit legacy activity. */
|
|
522
1091
|
rewardLegacyTurn() {
|
|
523
|
-
|
|
524
|
-
if (nowMs - this.lastLegacyTurnRewardAt < 5e3) return;
|
|
525
|
-
this.lastLegacyTurnRewardAt = nowMs;
|
|
526
|
-
this.applyTurnReward();
|
|
527
|
-
}
|
|
528
|
-
/** Persist one accepted completed-turn reward. */
|
|
529
|
-
applyTurnReward() {
|
|
530
|
-
this.persist = {
|
|
531
|
-
...this.persist,
|
|
532
|
-
affinity: applyTurnReward(this.persist.affinity, this.affinityConfig)
|
|
533
|
-
};
|
|
534
|
-
this.flush();
|
|
535
|
-
}
|
|
536
|
-
/**
|
|
537
|
-
* Settle the treat economy (work + time output since the last settlement)
|
|
538
|
-
* and persist whenever the ledger changed. A zero-gain first settlement
|
|
539
|
-
* still starts the time clock (anchor write), which is what lets the
|
|
540
|
-
* 30-minute time output ever accrue.
|
|
541
|
-
*/
|
|
542
|
-
settleTreats(nowMs) {
|
|
543
|
-
const settlement = settleTreatGrants(this.persist.treats, this.persist.affinity.turns, nowMs, this.treatConfig);
|
|
544
|
-
if (settlement.ledger !== this.persist.treats) {
|
|
545
|
-
this.persist = {
|
|
546
|
-
...this.persist,
|
|
547
|
-
treats: settlement.ledger
|
|
548
|
-
};
|
|
549
|
-
this.flush();
|
|
550
|
-
}
|
|
1092
|
+
if (this.ledger.rewardLegacyTurn(Date.now())) this.flush();
|
|
551
1093
|
}
|
|
552
1094
|
view() {
|
|
553
1095
|
const snapshot = this.machine.render();
|
|
554
|
-
this.
|
|
1096
|
+
const entry = this.activeEntry();
|
|
555
1097
|
return {
|
|
556
1098
|
animation: snapshot.animation,
|
|
557
1099
|
...snapshot.bubble === void 0 ? {} : { bubble: snapshot.bubble },
|
|
558
1100
|
phase: snapshot.phase,
|
|
559
1101
|
sessionActive: snapshot.sessionActive,
|
|
560
|
-
affinity: this.affinityView(
|
|
561
|
-
display: { ...this.
|
|
562
|
-
|
|
1102
|
+
affinity: this.ledger.affinityView(Date.now()),
|
|
1103
|
+
display: { ...this.ledger.snapshot.display },
|
|
1104
|
+
pet: {
|
|
1105
|
+
id: entry.id,
|
|
1106
|
+
displayName: entry.displayName,
|
|
1107
|
+
description: entry.description
|
|
1108
|
+
},
|
|
1109
|
+
name: this.petName(),
|
|
563
1110
|
treats: {
|
|
564
|
-
stocked: this.
|
|
565
|
-
max: this.
|
|
1111
|
+
stocked: this.ledger.snapshot.treats.treats,
|
|
1112
|
+
max: this.ledger.treatMax
|
|
566
1113
|
}
|
|
567
1114
|
};
|
|
568
1115
|
}
|
|
569
|
-
affinityView(affinity) {
|
|
570
|
-
const nowMs = Date.now();
|
|
571
|
-
const rank = rankOf(affinity.points);
|
|
572
|
-
return {
|
|
573
|
-
points: affinity.points,
|
|
574
|
-
rank: rank.name,
|
|
575
|
-
rankEmoji: rank.emoji,
|
|
576
|
-
pets: affinity.pets,
|
|
577
|
-
feeds: affinity.feeds,
|
|
578
|
-
turns: affinity.turns,
|
|
579
|
-
petCooldown: nowMs - affinity.lastPetAt < this.affinityConfig.petCooldownMs,
|
|
580
|
-
feedCooldown: nowMs - affinity.lastFeedAt < this.affinityConfig.feedCooldownMs
|
|
581
|
-
};
|
|
582
|
-
}
|
|
583
1116
|
flush() {
|
|
584
1117
|
try {
|
|
585
|
-
savePetPersist(this.
|
|
1118
|
+
savePetPersist(this.ledger.snapshot, this.persistDir);
|
|
586
1119
|
} catch {}
|
|
587
1120
|
}
|
|
588
1121
|
};
|
|
589
1122
|
//#endregion
|
|
590
1123
|
//#region src/routes.ts
|
|
1124
|
+
/**
|
|
1125
|
+
* Pet HTTP routes — the browser half talks to the host through plain
|
|
1126
|
+
* same-origin JSON endpoints ('/api/pet/*') and loads pet assets from
|
|
1127
|
+
* '/pet/<id>/*'. The '/plugins/' endpoint only serves client bundles and RPC
|
|
1128
|
+
* domains are platform-registered, so the pet serves its own API and media —
|
|
1129
|
+
* the same pattern as dsh-remote-web-ui's '/api/pair' family. The asset route
|
|
1130
|
+
* is one prefix registration serving every registry entry (manifest, atlas,
|
|
1131
|
+
* optional previews), so adding a pet never touches route wiring.
|
|
1132
|
+
* @module @linxin666/dsh-pet/routes
|
|
1133
|
+
*/
|
|
591
1134
|
/** Browser-facing base path of the pet API. */
|
|
592
1135
|
const PET_API_PREFIX = "/api/pet";
|
|
593
|
-
/** Browser-facing base path of the pet asset routes. */
|
|
594
|
-
const PET_ASSET_PREFIX = "/pet
|
|
595
|
-
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
1136
|
+
/** Browser-facing base path of the pet asset routes ('/pet/<id>/...'). */
|
|
1137
|
+
const PET_ASSET_PREFIX = "/pet";
|
|
1138
|
+
const MANIFEST_FILE = "pet.json";
|
|
1139
|
+
const PREVIEW_DIR = "previews";
|
|
1140
|
+
const PREVIEW_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
1141
|
+
const MIME_BY_EXT = {
|
|
1142
|
+
".webp": "image/webp",
|
|
1143
|
+
".png": "image/png",
|
|
1144
|
+
".gif": "image/gif",
|
|
1145
|
+
".jpg": "image/jpeg",
|
|
1146
|
+
".jpeg": "image/jpeg",
|
|
1147
|
+
".json": "application/json"
|
|
1148
|
+
};
|
|
1149
|
+
/** Content type by file extension (safe fallback: octet-stream). */
|
|
1150
|
+
function mimeFor(file) {
|
|
1151
|
+
const dot = file.lastIndexOf(".");
|
|
1152
|
+
if (dot < 0) return "application/octet-stream";
|
|
1153
|
+
return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? "application/octet-stream";
|
|
606
1154
|
}
|
|
607
1155
|
/** Write one JSON response. */
|
|
608
1156
|
function json(res, status, body) {
|
|
@@ -685,60 +1233,157 @@ function postRoute(path, run) {
|
|
|
685
1233
|
}
|
|
686
1234
|
};
|
|
687
1235
|
}
|
|
688
|
-
/**
|
|
1236
|
+
/** Legacy URL aliases: each entry's directory basename (e.g. 'whale'). */
|
|
1237
|
+
function dirAliases(registry) {
|
|
1238
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
1239
|
+
for (const entry of registry.entries) {
|
|
1240
|
+
const alias = entry.dir.split(/[\\/]/).pop() ?? "";
|
|
1241
|
+
if (alias !== "" && !aliases.has(alias)) aliases.set(alias, entry);
|
|
1242
|
+
}
|
|
1243
|
+
return aliases;
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* The one asset handler behind the '/pet' prefix. Resolves the pet by id (or
|
|
1247
|
+
* legacy directory alias), then serves exactly the files a manifest declares:
|
|
1248
|
+
* pet.json, the declared spritesheet path, and optional 'previews/<name>'
|
|
1249
|
+
* media. Composed pets without a manifest file get a synthesized pet.json.
|
|
1250
|
+
*/
|
|
1251
|
+
function assetHandler(registry) {
|
|
1252
|
+
const aliases = dirAliases(registry);
|
|
1253
|
+
return (req, res) => {
|
|
1254
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1255
|
+
res.writeHead(405);
|
|
1256
|
+
res.end();
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
let pathname;
|
|
1260
|
+
try {
|
|
1261
|
+
pathname = new URL(req.url ?? "/", "http://pet.local").pathname;
|
|
1262
|
+
} catch {
|
|
1263
|
+
res.writeHead(400);
|
|
1264
|
+
res.end();
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
const segments = pathname.split("/").filter((segment) => segment !== "");
|
|
1268
|
+
if (segments[0] !== "pet" || segments[1] === void 0) {
|
|
1269
|
+
res.writeHead(404);
|
|
1270
|
+
res.end();
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
let id;
|
|
1274
|
+
try {
|
|
1275
|
+
id = decodeURIComponent(segments[1]);
|
|
1276
|
+
} catch {
|
|
1277
|
+
res.writeHead(400);
|
|
1278
|
+
res.end();
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
const entry = registry.byId(id) ?? aliases.get(id);
|
|
1282
|
+
if (entry === void 0) {
|
|
1283
|
+
res.writeHead(404);
|
|
1284
|
+
res.end();
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
const rest = [];
|
|
1288
|
+
for (const segment of segments.slice(2)) {
|
|
1289
|
+
let decoded;
|
|
1290
|
+
try {
|
|
1291
|
+
decoded = decodeURIComponent(segment);
|
|
1292
|
+
} catch {
|
|
1293
|
+
res.writeHead(400);
|
|
1294
|
+
res.end();
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
rest.push(decoded);
|
|
1298
|
+
}
|
|
1299
|
+
const rel = rest.join("/");
|
|
1300
|
+
let file;
|
|
1301
|
+
let synthesized = false;
|
|
1302
|
+
if (rest.length === 1 && rest[0] === MANIFEST_FILE) {
|
|
1303
|
+
const manifestFile = join(entry.dir, MANIFEST_FILE);
|
|
1304
|
+
file = existsSync(manifestFile) ? manifestFile : void 0;
|
|
1305
|
+
if (file === void 0) synthesized = true;
|
|
1306
|
+
} else if (rest.length > 0 && rel === entry.spritesheetPath) file = join(entry.dir, entry.spritesheetPath);
|
|
1307
|
+
else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1])) {
|
|
1308
|
+
const preview = join(entry.dir, PREVIEW_DIR, rest[1]);
|
|
1309
|
+
file = existsSync(preview) ? preview : void 0;
|
|
1310
|
+
}
|
|
1311
|
+
if (synthesized) {
|
|
1312
|
+
const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), "utf8");
|
|
1313
|
+
res.writeHead(200, {
|
|
1314
|
+
"content-type": "application/json; charset=utf-8",
|
|
1315
|
+
"content-length": String(body.byteLength),
|
|
1316
|
+
"cache-control": "no-cache"
|
|
1317
|
+
});
|
|
1318
|
+
if (req.method === "HEAD") {
|
|
1319
|
+
res.end();
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
res.end(body);
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
if (file === void 0) {
|
|
1326
|
+
res.writeHead(404);
|
|
1327
|
+
res.end();
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
const resolved = file;
|
|
1331
|
+
readFile(resolved).then((body) => {
|
|
1332
|
+
res.writeHead(200, {
|
|
1333
|
+
"content-type": mimeFor(resolved),
|
|
1334
|
+
"content-length": String(body.byteLength),
|
|
1335
|
+
"cache-control": "no-cache"
|
|
1336
|
+
});
|
|
1337
|
+
if (req.method === "HEAD") {
|
|
1338
|
+
res.end();
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
res.end(body);
|
|
1342
|
+
}, () => {
|
|
1343
|
+
res.writeHead(404);
|
|
1344
|
+
res.end();
|
|
1345
|
+
});
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
/** Build the full route family (API + assets) for one service. */
|
|
689
1349
|
function makePetRoutes(deps) {
|
|
690
|
-
const { service
|
|
1350
|
+
const { service } = deps;
|
|
691
1351
|
const apiRoutes = [
|
|
692
|
-
getRoute(
|
|
693
|
-
|
|
1352
|
+
getRoute("/api/pet/state", () => service.state()),
|
|
1353
|
+
getRoute("/api/pet/pets", () => service.pets()),
|
|
1354
|
+
postRoute("/api/pet/interact", (body) => {
|
|
694
1355
|
const kind = body.kind;
|
|
695
1356
|
if (kind !== "pet" && kind !== "feed") return Promise.reject(/* @__PURE__ */ new Error("invalid-kind"));
|
|
696
1357
|
return service.interact(kind);
|
|
697
1358
|
}),
|
|
698
|
-
postRoute(
|
|
1359
|
+
postRoute("/api/pet/set-visible", (body) => {
|
|
699
1360
|
const visible = body.visible;
|
|
700
1361
|
if (typeof visible !== "boolean") return Promise.reject(/* @__PURE__ */ new Error("invalid-visible"));
|
|
701
1362
|
return service.setVisible(visible);
|
|
702
1363
|
}),
|
|
703
|
-
postRoute(
|
|
1364
|
+
postRoute("/api/pet/set-config", (body) => service.setConfig({
|
|
704
1365
|
...typeof body.size === "number" ? { size: body.size } : {},
|
|
705
1366
|
...typeof body.right === "number" ? { right: body.right } : {},
|
|
706
1367
|
...typeof body.bottom === "number" ? { bottom: body.bottom } : {},
|
|
707
1368
|
...typeof body.visible === "boolean" ? { visible: body.visible } : {}
|
|
708
1369
|
})),
|
|
709
|
-
postRoute(
|
|
1370
|
+
postRoute("/api/pet/set-name", (body) => {
|
|
710
1371
|
const name = body.name;
|
|
711
1372
|
if (typeof name !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-name"));
|
|
712
1373
|
return service.setName(name);
|
|
1374
|
+
}),
|
|
1375
|
+
postRoute("/api/pet/set-pet", (body) => {
|
|
1376
|
+
const petId = body.petId;
|
|
1377
|
+
if (typeof petId !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-pet"));
|
|
1378
|
+
return service.setPetId(petId);
|
|
713
1379
|
})
|
|
714
1380
|
];
|
|
715
|
-
const
|
|
716
|
-
kind: "
|
|
717
|
-
path:
|
|
718
|
-
handler: (
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
res.end();
|
|
722
|
-
return;
|
|
723
|
-
}
|
|
724
|
-
return readFile(join(packageRoot, "assets", "whale", file.name)).then((body) => {
|
|
725
|
-
res.writeHead(200, {
|
|
726
|
-
"content-type": file.mime,
|
|
727
|
-
"content-length": String(body.byteLength),
|
|
728
|
-
"cache-control": "no-cache"
|
|
729
|
-
});
|
|
730
|
-
if (req.method === "HEAD") {
|
|
731
|
-
res.end();
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
res.end(body);
|
|
735
|
-
}, () => {
|
|
736
|
-
res.writeHead(404);
|
|
737
|
-
res.end();
|
|
738
|
-
});
|
|
739
|
-
}
|
|
740
|
-
}));
|
|
741
|
-
return [...apiRoutes, ...assetRoutes];
|
|
1381
|
+
const assetRoute = {
|
|
1382
|
+
kind: "prefix",
|
|
1383
|
+
path: PET_ASSET_PREFIX,
|
|
1384
|
+
handler: assetHandler(service.registrySnapshot())
|
|
1385
|
+
};
|
|
1386
|
+
return [...apiRoutes, assetRoute];
|
|
742
1387
|
}
|
|
743
1388
|
//#endregion
|
|
744
1389
|
//#region src/index.ts
|
|
@@ -746,31 +1391,44 @@ function makePetRoutes(deps) {
|
|
|
746
1391
|
const name = "pet";
|
|
747
1392
|
/** Services required before the pet can mount its surfaces. */
|
|
748
1393
|
const inject = ["webServer"];
|
|
749
|
-
/**
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
1394
|
+
/**
|
|
1395
|
+
* Settings section schema: pet selection and display fields the web settings
|
|
1396
|
+
* surface edits. petId is a plain string on purpose: the service clamps the
|
|
1397
|
+
* resolved value against the registry, so a stored selection that points at
|
|
1398
|
+
* a removed pet cannot invalidate the section (a strict union would refuse
|
|
1399
|
+
* the whole registration). The settings card renders the actual registry
|
|
1400
|
+
* choices itself from '/api/pet/pets'.
|
|
1401
|
+
*/
|
|
1402
|
+
function makePetSettingsSchema(fallbackPetId) {
|
|
1403
|
+
return z.object({
|
|
1404
|
+
visible: z.boolean().default(true),
|
|
1405
|
+
size: z.number().step(1).min(32).max(512).default(160),
|
|
1406
|
+
right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
|
|
1407
|
+
bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
|
|
1408
|
+
petId: z.string().default(fallbackPetId),
|
|
1409
|
+
enabled: z.boolean().default(true)
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
758
1412
|
/** Register the pet service and its API + asset routes on the context. */
|
|
759
1413
|
function apply(ctx, config = {}) {
|
|
760
|
-
const
|
|
1414
|
+
const registry = config.registry ?? loadPetRegistry({
|
|
1415
|
+
packageRoot: petPackageRoot(import.meta.url),
|
|
1416
|
+
...config.pets === void 0 ? {} : { extra: config.pets }
|
|
1417
|
+
});
|
|
1418
|
+
const service = new PetService(ctx, {
|
|
1419
|
+
...config,
|
|
1420
|
+
registry
|
|
1421
|
+
});
|
|
761
1422
|
let current = () => base;
|
|
762
1423
|
const base = {
|
|
763
1424
|
visible: service.display().visible,
|
|
764
1425
|
size: service.display().size,
|
|
765
1426
|
right: service.display().right,
|
|
766
1427
|
bottom: service.display().bottom,
|
|
767
|
-
|
|
1428
|
+
petId: service.selectedPetId(),
|
|
768
1429
|
enabled: config.enabled ?? true
|
|
769
1430
|
};
|
|
770
|
-
const routes = makePetRoutes({
|
|
771
|
-
service,
|
|
772
|
-
packageRoot: petPackageRoot(import.meta.url)
|
|
773
|
-
});
|
|
1431
|
+
const routes = makePetRoutes({ service });
|
|
774
1432
|
let disposeRoutes;
|
|
775
1433
|
const syncRoutes = () => {
|
|
776
1434
|
const enabled = current().enabled ?? true;
|
|
@@ -785,7 +1443,7 @@ function apply(ctx, config = {}) {
|
|
|
785
1443
|
disposeRoutes = void 0;
|
|
786
1444
|
}
|
|
787
1445
|
};
|
|
788
|
-
installSettingsSection(ctx, settingsNamespace("pet"),
|
|
1446
|
+
installSettingsSection(ctx, settingsNamespace("pet"), makePetSettingsSchema(service.selectedPetId()), base, {
|
|
789
1447
|
setSource: (source) => {
|
|
790
1448
|
current = source;
|
|
791
1449
|
},
|
|
@@ -799,4 +1457,4 @@ function apply(ctx, config = {}) {
|
|
|
799
1457
|
syncRoutes();
|
|
800
1458
|
}
|
|
801
1459
|
//#endregion
|
|
802
|
-
export { AFFINITY_MAX, AFFINITY_RANKS, PET_API_PREFIX, PET_ASSET_PREFIX,
|
|
1460
|
+
export { AFFINITY_MAX, AFFINITY_RANKS, DEFAULT_FRAME_COUNTS, DEFAULT_PET_CELL, DEFAULT_PET_COLUMNS, DEFAULT_PET_ID, DEFAULT_PET_NAME, DEFAULT_PET_ROW_COUNT, DEFAULT_TRACK_PATTERNS, PET_API_PREFIX, PET_ASSET_PREFIX, PET_NAME_MAX_LENGTH, PET_ROW_ORDER, PetService, PetStateMachine, animationForPhase, apply, applyInteraction, applyTurnReward, codexPetsDir, consumeTreat, defaultDisplayConfig, defaultTreatConfig, emptyAffinity, emptyPersist, emptyTreatLedger, inject, loadPetPersist, loadPetRegistry, makePetRoutes, makePetSettingsSchema, name, petEntryView, petHomeDir, petPackageRoot, rankOf, resolvePetManifest, rowOf, savePetPersist, settleTreatGrants };
|