@linxin666/dsh-pet 0.2.3 → 0.2.5
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 +65 -2
- package/README.zh.md +65 -2
- package/assets/decorations/whale/decoration.json +20 -0
- package/assets/decorations/whale/whale-frames.png +0 -0
- package/contracts/pet-manifest-v2.schema.json +281 -0
- package/contracts/status-decoration-v1.schema.json +144 -0
- package/contracts/voice-pack-v1.schema.json +234 -0
- package/lib/client.js +251 -43
- package/lib/client.js.map +1 -1
- package/lib/index.js +1139 -100
- package/lib/types/access.d.ts +16 -0
- package/lib/types/access.d.ts.map +1 -0
- package/lib/types/access.js +20 -0
- package/lib/types/chatter.d.ts +61 -3
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +71 -12
- package/lib/types/client/PetSettingsCard.d.ts +4 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +11 -3
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +111 -4
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d/Live2dVisualMount.js +1 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts +13 -1
- package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d.js +133 -25
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- package/lib/types/contracts/status-decoration.d.ts +85 -0
- package/lib/types/contracts/status-decoration.d.ts.map +1 -0
- package/lib/types/contracts/status-decoration.js +21 -0
- package/lib/types/decoration.d.ts +39 -0
- package/lib/types/decoration.d.ts.map +1 -0
- package/lib/types/decoration.js +210 -0
- package/lib/types/event-projection.d.ts +8 -3
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +9 -4
- package/lib/types/image-dimensions.d.ts +26 -0
- package/lib/types/image-dimensions.d.ts.map +1 -0
- package/lib/types/image-dimensions.js +77 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +3 -1
- package/lib/types/registry.d.ts +55 -2
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +240 -16
- package/lib/types/routes.d.ts +6 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +163 -32
- package/lib/types/service.d.ts +33 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +42 -3
- package/lib/types/voice-pack.d.ts +98 -0
- package/lib/types/voice-pack.d.ts.map +1 -0
- package/lib/types/voice-pack.js +384 -0
- package/package.json +11 -10
- package/src/access.ts +40 -0
- package/src/chatter.test.ts +89 -2
- package/src/chatter.ts +120 -14
- package/src/client/PetSettingsCard.tsx +26 -2
- package/src/client/PetSprite.test.tsx +325 -12
- package/src/client/PetSprite.tsx +150 -25
- package/src/client/locales.ts +4 -0
- package/src/client/renderers/live2d/Live2dVisualMount.test.tsx +72 -0
- package/src/client/renderers/live2d/Live2dVisualMount.tsx +1 -0
- package/src/client/renderers/live2d/runtime.ts +8 -2
- package/src/client/renderers/live2d.test.ts +233 -9
- package/src/client/renderers/live2d.ts +143 -30
- package/src/client/settings-form.ts +8 -6
- package/src/contracts/status-decoration.ts +78 -0
- package/src/decoration.test.ts +178 -0
- package/src/decoration.ts +220 -0
- package/src/event-projection.ts +10 -5
- package/src/image-dimensions.test.ts +85 -0
- package/src/image-dimensions.ts +76 -0
- package/src/index.ts +3 -1
- package/src/registry.test.ts +276 -0
- package/src/registry.ts +274 -15
- package/src/routes.ts +162 -33
- package/src/service.ts +59 -3
- package/src/voice-pack.test.ts +216 -0
- package/src/voice-pack.ts +413 -0
package/lib/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { _ as RemarkPicker, a as AFFINITY_MAX, c as applyInteraction, d as empty
|
|
|
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 { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -145,6 +145,40 @@ const STATUS_POOLS = {
|
|
|
145
145
|
"蹲一个继续的指令"
|
|
146
146
|
]
|
|
147
147
|
};
|
|
148
|
+
/** Every status scene key, in declaration order (voice-pack key allow-list). */
|
|
149
|
+
const STATUS_SCENES = [
|
|
150
|
+
"prepare",
|
|
151
|
+
"waiting",
|
|
152
|
+
"thinking",
|
|
153
|
+
"review",
|
|
154
|
+
"toolResult",
|
|
155
|
+
"done",
|
|
156
|
+
"failed",
|
|
157
|
+
"toolFailed",
|
|
158
|
+
"maxTokens",
|
|
159
|
+
"interrupted",
|
|
160
|
+
"blocked"
|
|
161
|
+
];
|
|
162
|
+
/** Every tool-family key, in declaration order (voice-pack key allow-list). */
|
|
163
|
+
const TOOL_CATEGORIES = [
|
|
164
|
+
"read",
|
|
165
|
+
"write",
|
|
166
|
+
"edit",
|
|
167
|
+
"shell",
|
|
168
|
+
"grep",
|
|
169
|
+
"find",
|
|
170
|
+
"ls",
|
|
171
|
+
"webSearch",
|
|
172
|
+
"webFetch",
|
|
173
|
+
"mcp",
|
|
174
|
+
"memory",
|
|
175
|
+
"subagent",
|
|
176
|
+
"todo",
|
|
177
|
+
"browser",
|
|
178
|
+
"git",
|
|
179
|
+
"ask",
|
|
180
|
+
"generic"
|
|
181
|
+
];
|
|
148
182
|
/** Map a raw tool name onto its copy family (working-activity style regexes). */
|
|
149
183
|
function toolCategory(toolName) {
|
|
150
184
|
const name = toolName.toLowerCase();
|
|
@@ -420,12 +454,14 @@ function toolArgHint(toolName, argumentsJson) {
|
|
|
420
454
|
* stretch keeps changing its wording.
|
|
421
455
|
*/
|
|
422
456
|
var StatusVoice = class {
|
|
457
|
+
pools;
|
|
423
458
|
rotateMs;
|
|
424
459
|
counters = /* @__PURE__ */ new Map();
|
|
425
460
|
lastScene = "";
|
|
426
461
|
lastLine = "";
|
|
427
462
|
lastLineAt = Number.NEGATIVE_INFINITY;
|
|
428
|
-
constructor(rotateMs = STATUS_ROTATE_MS) {
|
|
463
|
+
constructor(pools = () => BUILTIN_VOICE_PACK, rotateMs = STATUS_ROTATE_MS) {
|
|
464
|
+
this.pools = pools;
|
|
429
465
|
this.rotateMs = rotateMs;
|
|
430
466
|
}
|
|
431
467
|
/** Draw the next line of one pool, advancing its round-robin cursor. */
|
|
@@ -442,18 +478,31 @@ var StatusVoice = class {
|
|
|
442
478
|
this.lastLineAt = nowMs;
|
|
443
479
|
return this.lastLine;
|
|
444
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* A scene's effective pool: the voice-pack override when it carries lines,
|
|
483
|
+
* else the built-in pool. Empty overrides fall back rather than blank the
|
|
484
|
+
* bubble — a scene line always renders.
|
|
485
|
+
*/
|
|
486
|
+
scenePool(scene) {
|
|
487
|
+
const override = this.pools().status?.[scene];
|
|
488
|
+
return override !== void 0 && override.length > 0 ? override : STATUS_POOLS[scene];
|
|
489
|
+
}
|
|
445
490
|
/** Status line for a phase scene. */
|
|
446
491
|
scene(scene, nowMs) {
|
|
447
|
-
return this.voice("scene:" + scene, "pool:" + scene,
|
|
492
|
+
return this.voice("scene:" + scene, "pool:" + scene, this.scenePool(scene), nowMs);
|
|
448
493
|
}
|
|
449
494
|
/** Status line for a tool call, with the real-argument hint when known. */
|
|
450
495
|
tool(toolName, displayName, hint, nowMs) {
|
|
451
496
|
const category = toolCategory(toolName);
|
|
452
|
-
|
|
497
|
+
const override = this.pools().tools?.[category];
|
|
498
|
+
const pool = override !== void 0 && override.length > 0 ? override : TOOL_POOLS[category];
|
|
499
|
+
return this.voice("tool:" + category, "tool:" + category, pool, nowMs).replaceAll("{tool}", displayName).replaceAll("{hint}", hint ?? displayName);
|
|
453
500
|
}
|
|
454
501
|
/** Status line while sibling tools still run (always reflects the count). */
|
|
455
502
|
toolRemaining(count, nowMs) {
|
|
456
|
-
|
|
503
|
+
const override = this.pools().toolRemaining;
|
|
504
|
+
const pool = override !== void 0 && override.length > 0 ? override : TOOL_REMAINING_POOL;
|
|
505
|
+
return this.voice("toolRemaining", "toolRemaining", pool, nowMs).replaceAll("{n}", String(count));
|
|
457
506
|
}
|
|
458
507
|
};
|
|
459
508
|
/** Murmur pacing: cooldown between whispers and output volume that earns one. */
|
|
@@ -786,26 +835,52 @@ const WHISPER_RULES = [
|
|
|
786
835
|
]
|
|
787
836
|
}
|
|
788
837
|
];
|
|
838
|
+
/** The built-in voice pack: the plugin's default copy, unchanged since v1. */
|
|
839
|
+
const BUILTIN_VOICE_PACK = {
|
|
840
|
+
status: STATUS_POOLS,
|
|
841
|
+
tools: TOOL_POOLS,
|
|
842
|
+
toolRemaining: TOOL_REMAINING_POOL,
|
|
843
|
+
whispers: {
|
|
844
|
+
generic: WHISPER_GENERIC_POOL,
|
|
845
|
+
rules: WHISPER_RULES
|
|
846
|
+
}
|
|
847
|
+
};
|
|
789
848
|
/**
|
|
790
849
|
* The murmur engine (碎碎念): watches the model's own output and lets the pet
|
|
791
850
|
* whisper its inner voice. Two ways to earn a whisper:
|
|
792
851
|
* - a keyword rule matches the fresh chunk text (themed whisper);
|
|
793
852
|
* - enough output volume flowed by without one (ambient whisper).
|
|
794
853
|
* A cooldown keeps whispers occasional; all picks are round-robin so tests
|
|
795
|
-
* reproduce exact lines.
|
|
854
|
+
* reproduce exact lines. The voice-pack provider (pet-center M4) swaps the
|
|
855
|
+
* pools at draw time, so a pet switch re-voices live engines in place.
|
|
796
856
|
*/
|
|
797
857
|
var WhisperEngine = class {
|
|
858
|
+
pools;
|
|
798
859
|
cooldownMs;
|
|
799
860
|
charBudget;
|
|
800
861
|
counters = /* @__PURE__ */ new Map();
|
|
801
862
|
genericCursor = 0;
|
|
802
863
|
lastWhisperAt = Number.NEGATIVE_INFINITY;
|
|
803
864
|
charsSinceWhisper = 0;
|
|
804
|
-
constructor(cooldownMs = WHISPER_COOLDOWN_MS, charBudget = 420) {
|
|
865
|
+
constructor(pools = () => BUILTIN_VOICE_PACK, cooldownMs = WHISPER_COOLDOWN_MS, charBudget = 420) {
|
|
866
|
+
this.pools = pools;
|
|
805
867
|
this.cooldownMs = cooldownMs;
|
|
806
868
|
this.charBudget = charBudget;
|
|
807
869
|
}
|
|
808
870
|
/**
|
|
871
|
+
* Effective keyword rules: an override replaces the built-in rules as a
|
|
872
|
+
* whole; an explicit empty array disables keyword-triggered whispers.
|
|
873
|
+
*/
|
|
874
|
+
rules() {
|
|
875
|
+
const override = this.pools().whispers?.rules;
|
|
876
|
+
return override === void 0 ? WHISPER_RULES : override;
|
|
877
|
+
}
|
|
878
|
+
/** Effective ambient pool (an explicit empty array mutes ambient whispers). */
|
|
879
|
+
generic() {
|
|
880
|
+
const override = this.pools().whispers?.generic;
|
|
881
|
+
return override === void 0 ? WHISPER_GENERIC_POOL : override;
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
809
884
|
* Feed one model-output chunk (reasoning or text). Returns the whisper to
|
|
810
885
|
* show, or undefined when the moment stays quiet.
|
|
811
886
|
*/
|
|
@@ -816,8 +891,9 @@ var WhisperEngine = class {
|
|
|
816
891
|
return;
|
|
817
892
|
}
|
|
818
893
|
const haystack = text.toLowerCase();
|
|
819
|
-
|
|
820
|
-
|
|
894
|
+
const rules = this.rules();
|
|
895
|
+
for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex += 1) {
|
|
896
|
+
const rule = rules[ruleIndex];
|
|
821
897
|
if (!rule.keywords.some((keyword) => haystack.includes(keyword))) continue;
|
|
822
898
|
const index = (this.counters.get(ruleIndex) ?? 0) % rule.pool.length;
|
|
823
899
|
this.counters.set(ruleIndex, index + 1);
|
|
@@ -825,7 +901,9 @@ var WhisperEngine = class {
|
|
|
825
901
|
}
|
|
826
902
|
this.charsSinceWhisper += text.length;
|
|
827
903
|
if (this.charsSinceWhisper < this.charBudget) return void 0;
|
|
828
|
-
const
|
|
904
|
+
const generic = this.generic();
|
|
905
|
+
if (generic.length === 0) return void 0;
|
|
906
|
+
const line = generic[this.genericCursor % generic.length];
|
|
829
907
|
this.genericCursor += 1;
|
|
830
908
|
return this.speak(line, nowMs);
|
|
831
909
|
}
|
|
@@ -837,14 +915,19 @@ var WhisperEngine = class {
|
|
|
837
915
|
};
|
|
838
916
|
//#endregion
|
|
839
917
|
//#region src/event-projection.ts
|
|
840
|
-
/**
|
|
841
|
-
|
|
918
|
+
/**
|
|
919
|
+
* Fresh projection runtime for a newly seen session. The optional voice-pack
|
|
920
|
+
* provider (pet-center M4, issue #677) hands both chatter engines their
|
|
921
|
+
* pools; engines resolve overrides at draw time, so swapping the provider's
|
|
922
|
+
* pack re-voices live runtimes without rebuilding them.
|
|
923
|
+
*/
|
|
924
|
+
function emptyProjectionRuntime(pools) {
|
|
842
925
|
return {
|
|
843
926
|
activeTools: /* @__PURE__ */ new Set(),
|
|
844
927
|
officialEventsSeen: false,
|
|
845
928
|
stepHadFailure: false,
|
|
846
|
-
voice: new StatusVoice(),
|
|
847
|
-
whispers: new WhisperEngine()
|
|
929
|
+
voice: new StatusVoice(pools),
|
|
930
|
+
whispers: new WhisperEngine(pools)
|
|
848
931
|
};
|
|
849
932
|
}
|
|
850
933
|
/** Keep tool names readable inside the compact status bubble. */
|
|
@@ -1373,6 +1456,307 @@ function savePetPersist(data, dir = petHomeDir()) {
|
|
|
1373
1456
|
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
|
|
1374
1457
|
renameSync(tmp, target);
|
|
1375
1458
|
}
|
|
1459
|
+
/** Hover-panel action buttons a pack can show or hide (canonical order). */
|
|
1460
|
+
const PANEL_ACTIONS = [
|
|
1461
|
+
"feed",
|
|
1462
|
+
"rename",
|
|
1463
|
+
"hide"
|
|
1464
|
+
];
|
|
1465
|
+
/** Panel label slots (unset slots keep the client's i18n dictionary copy). */
|
|
1466
|
+
const PANEL_LABEL_KEYS = [
|
|
1467
|
+
"feed",
|
|
1468
|
+
"rename",
|
|
1469
|
+
"hide",
|
|
1470
|
+
"confirm"
|
|
1471
|
+
];
|
|
1472
|
+
/** Panel stat slots ({rank}/{n}/{points} interpolate the live values). */
|
|
1473
|
+
const PANEL_STAT_KEYS = [
|
|
1474
|
+
"rank",
|
|
1475
|
+
"treats",
|
|
1476
|
+
"points"
|
|
1477
|
+
];
|
|
1478
|
+
/** Any '{token}' placeholder (no nesting, no newlines). */
|
|
1479
|
+
const PLACEHOLDER_PATTERN = /{[^{}]*}/g;
|
|
1480
|
+
/** Allowed placeholder tokens per pool kind (absent kind = none allowed). */
|
|
1481
|
+
const PLACEHOLDER_WHITELIST = {
|
|
1482
|
+
tools: ["{tool}", "{hint}"],
|
|
1483
|
+
toolRemaining: ["{n}"],
|
|
1484
|
+
stat: [
|
|
1485
|
+
"{rank}",
|
|
1486
|
+
"{n}",
|
|
1487
|
+
"{points}"
|
|
1488
|
+
]
|
|
1489
|
+
};
|
|
1490
|
+
function isRecord$2(value) {
|
|
1491
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1492
|
+
}
|
|
1493
|
+
/** Trim, length-cap and placeholder-check one copy line; undefined to drop. */
|
|
1494
|
+
function normalizeLine(raw, kind, onWarning) {
|
|
1495
|
+
const trimmed = raw.trim();
|
|
1496
|
+
if (trimmed === "") return void 0;
|
|
1497
|
+
let capped = trimmed.length > 160 ? trimmed.slice(0, 160) : trimmed;
|
|
1498
|
+
const dangling = capped.lastIndexOf("{");
|
|
1499
|
+
if (dangling !== -1 && capped.indexOf("}", dangling) === -1) {
|
|
1500
|
+
onWarning("line cut at an unterminated placeholder; tail dropped: " + capped.slice(0, 40) + "...");
|
|
1501
|
+
capped = capped.slice(0, dangling);
|
|
1502
|
+
}
|
|
1503
|
+
if (capped === "") return void 0;
|
|
1504
|
+
const allowed = PLACEHOLDER_WHITELIST[kind];
|
|
1505
|
+
const tokens = capped.match(PLACEHOLDER_PATTERN) ?? [];
|
|
1506
|
+
for (const token of tokens) {
|
|
1507
|
+
if (allowed?.includes(token) === true) continue;
|
|
1508
|
+
const preview = capped.length > 40 ? capped.slice(0, 40) + "..." : capped;
|
|
1509
|
+
onWarning("line dropped (unsupported placeholder " + token + "): " + preview);
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
return capped;
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* Normalize one pool slot. Accepts a single line or an array; non-string
|
|
1516
|
+
* entries warn and drop, empty lines drop silently, lines over the length
|
|
1517
|
+
* cap truncate, illegal placeholders drop the line, and pools over the line
|
|
1518
|
+
* cap keep their first lines. An explicit empty pool normalizes to [] (the
|
|
1519
|
+
* whisper channels read that as mute) while an absent slot normalizes to
|
|
1520
|
+
* undefined (the slot keeps the built-in pool).
|
|
1521
|
+
*/
|
|
1522
|
+
function normalizePool(raw, kind, onWarning = () => {}) {
|
|
1523
|
+
if (raw === void 0) return void 0;
|
|
1524
|
+
const entries = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : void 0;
|
|
1525
|
+
if (entries === void 0) {
|
|
1526
|
+
onWarning("pool must be a string or an array of strings");
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
if (entries.length > 64) onWarning("pool has more than 64 lines; extra lines are ignored");
|
|
1530
|
+
const pool = [];
|
|
1531
|
+
for (const entry of entries.slice(0, 64)) {
|
|
1532
|
+
if (typeof entry !== "string") {
|
|
1533
|
+
onWarning("non-string pool entry dropped");
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
const line = normalizeLine(entry, kind, onWarning);
|
|
1537
|
+
if (line !== void 0) pool.push(line);
|
|
1538
|
+
}
|
|
1539
|
+
return pool;
|
|
1540
|
+
}
|
|
1541
|
+
/** Normalize the ordered keyword-rule list ([] disables the keyword channel). */
|
|
1542
|
+
function normalizeWhisperRules(raw, onWarning = () => {}) {
|
|
1543
|
+
if (raw === void 0) return void 0;
|
|
1544
|
+
if (!Array.isArray(raw)) {
|
|
1545
|
+
onWarning("whispers.rules must be an array");
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (raw.length > 32) onWarning("whispers.rules has more than 32 rules; extra rules are ignored");
|
|
1549
|
+
const rules = [];
|
|
1550
|
+
for (const item of raw.slice(0, 32)) {
|
|
1551
|
+
if (!isRecord$2(item)) {
|
|
1552
|
+
onWarning("whisper rule must be an object");
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
for (const key of Object.keys(item)) if (key !== "keywords" && key !== "pool") onWarning("unknown whisper rule field " + key + " ignored");
|
|
1556
|
+
const keywordsRaw = item.keywords;
|
|
1557
|
+
const keywords = [];
|
|
1558
|
+
if (Array.isArray(keywordsRaw)) {
|
|
1559
|
+
for (const entry of keywordsRaw.slice(0, 16)) {
|
|
1560
|
+
if (typeof entry !== "string") {
|
|
1561
|
+
onWarning("non-string keyword dropped");
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
const trimmed = entry.trim().toLowerCase().slice(0, 40);
|
|
1565
|
+
if (trimmed !== "") keywords.push(trimmed);
|
|
1566
|
+
}
|
|
1567
|
+
if (keywordsRaw.length > 16) onWarning("rule has more than 16 keywords; extra keywords are ignored");
|
|
1568
|
+
}
|
|
1569
|
+
const pool = normalizePool(item.pool, "whisperRule", onWarning);
|
|
1570
|
+
if (keywords.length === 0 || pool === void 0 || pool.length === 0) {
|
|
1571
|
+
onWarning("whisper rule dropped (needs keywords and a non-empty pool)");
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1574
|
+
rules.push({
|
|
1575
|
+
keywords,
|
|
1576
|
+
pool
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
return rules;
|
|
1580
|
+
}
|
|
1581
|
+
/** Normalize the panel block (labels / stats / actions; warn-and-drop). */
|
|
1582
|
+
function normalizePanel(raw, onWarning = () => {}) {
|
|
1583
|
+
if (!isRecord$2(raw)) {
|
|
1584
|
+
onWarning("panel must be an object");
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
const panel = {};
|
|
1588
|
+
const labelsRaw = raw.labels;
|
|
1589
|
+
if (labelsRaw !== void 0) if (!isRecord$2(labelsRaw)) onWarning("panel.labels must be an object");
|
|
1590
|
+
else {
|
|
1591
|
+
const labels = {};
|
|
1592
|
+
for (const key of PANEL_LABEL_KEYS) {
|
|
1593
|
+
const value = labelsRaw[key];
|
|
1594
|
+
if (value === void 0) continue;
|
|
1595
|
+
if (typeof value !== "string") {
|
|
1596
|
+
onWarning("panel.labels." + key + " must be a string");
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
1599
|
+
const line = normalizeLine(value, "label", onWarning);
|
|
1600
|
+
if (line !== void 0) labels[key] = line.slice(0, 40);
|
|
1601
|
+
}
|
|
1602
|
+
if (Object.keys(labels).length > 0) panel.labels = labels;
|
|
1603
|
+
}
|
|
1604
|
+
const statsRaw = raw.stats;
|
|
1605
|
+
if (statsRaw !== void 0) if (!isRecord$2(statsRaw)) onWarning("panel.stats must be an object");
|
|
1606
|
+
else {
|
|
1607
|
+
const stats = {};
|
|
1608
|
+
for (const key of PANEL_STAT_KEYS) {
|
|
1609
|
+
const value = statsRaw[key];
|
|
1610
|
+
if (value === void 0) continue;
|
|
1611
|
+
if (typeof value !== "string") {
|
|
1612
|
+
onWarning("panel.stats." + key + " must be a string");
|
|
1613
|
+
continue;
|
|
1614
|
+
}
|
|
1615
|
+
const line = normalizeLine(value, "stat", onWarning);
|
|
1616
|
+
if (line !== void 0) stats[key] = line.slice(0, 80);
|
|
1617
|
+
}
|
|
1618
|
+
if (Object.keys(stats).length > 0) panel.stats = stats;
|
|
1619
|
+
}
|
|
1620
|
+
const actionsRaw = raw.actions;
|
|
1621
|
+
if (actionsRaw !== void 0) if (!Array.isArray(actionsRaw)) onWarning("panel.actions must be an array");
|
|
1622
|
+
else {
|
|
1623
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1624
|
+
for (const entry of actionsRaw) {
|
|
1625
|
+
if (typeof entry !== "string" || !PANEL_ACTIONS.includes(entry)) {
|
|
1626
|
+
onWarning("unknown panel action dropped: " + String(entry));
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
seen.add(entry);
|
|
1630
|
+
}
|
|
1631
|
+
panel.actions = PANEL_ACTIONS.filter((action) => seen.has(action));
|
|
1632
|
+
}
|
|
1633
|
+
if (panel.labels === void 0 && panel.stats === void 0 && panel.actions === void 0) return void 0;
|
|
1634
|
+
return panel;
|
|
1635
|
+
}
|
|
1636
|
+
/** Voice-pack top-level fields ('$schema' mirrors the schema twin; drift-locked in tests). */
|
|
1637
|
+
const VOICE_PACK_KEYS = /* @__PURE__ */ new Set([
|
|
1638
|
+
"$schema",
|
|
1639
|
+
"voicePackVersion",
|
|
1640
|
+
"status",
|
|
1641
|
+
"tools",
|
|
1642
|
+
"toolRemaining",
|
|
1643
|
+
"whispers",
|
|
1644
|
+
"panel"
|
|
1645
|
+
]);
|
|
1646
|
+
/** Allowed whisper-section fields (drift-locked in tests). */
|
|
1647
|
+
const WHISPER_KEYS = /* @__PURE__ */ new Set(["generic", "rules"]);
|
|
1648
|
+
/**
|
|
1649
|
+
* Normalize one raw voice.json document into a VoicePack, or undefined when
|
|
1650
|
+
* the file cannot serve as a pack at all (non-object root — structure is
|
|
1651
|
+
* fail-closed per file). Every slot issue is a warning, never a throw.
|
|
1652
|
+
*/
|
|
1653
|
+
function normalizeVoicePack(raw, onWarning = () => {}) {
|
|
1654
|
+
if (raw === void 0) return void 0;
|
|
1655
|
+
if (!isRecord$2(raw)) {
|
|
1656
|
+
onWarning("voice.json must be a JSON object; the file is ignored");
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
for (const key of Object.keys(raw)) if (!VOICE_PACK_KEYS.has(key)) onWarning("unknown top-level field " + key + " ignored");
|
|
1660
|
+
const version = raw.voicePackVersion;
|
|
1661
|
+
if (version !== void 0 && (typeof version !== "number" || version !== 1)) onWarning("voicePackVersion " + String(version) + " is not supported; reading as v1 best-effort");
|
|
1662
|
+
const overrides = {};
|
|
1663
|
+
const statusRaw = raw.status;
|
|
1664
|
+
if (statusRaw !== void 0) if (!isRecord$2(statusRaw)) onWarning("status must be an object");
|
|
1665
|
+
else for (const key of Object.keys(statusRaw)) {
|
|
1666
|
+
if (!STATUS_SCENES.includes(key)) {
|
|
1667
|
+
onWarning("unknown status scene " + key + " ignored");
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
const pool = normalizePool(statusRaw[key], "status", onWarning);
|
|
1671
|
+
if (pool !== void 0 && pool.length > 0) overrides.status = {
|
|
1672
|
+
...overrides.status,
|
|
1673
|
+
[key]: pool
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
const toolsRaw = raw.tools;
|
|
1677
|
+
if (toolsRaw !== void 0) if (!isRecord$2(toolsRaw)) onWarning("tools must be an object");
|
|
1678
|
+
else for (const key of Object.keys(toolsRaw)) {
|
|
1679
|
+
if (!TOOL_CATEGORIES.includes(key)) {
|
|
1680
|
+
onWarning("unknown tool family " + key + " ignored");
|
|
1681
|
+
continue;
|
|
1682
|
+
}
|
|
1683
|
+
const pool = normalizePool(toolsRaw[key], "tools", onWarning);
|
|
1684
|
+
if (pool !== void 0 && pool.length > 0) overrides.tools = {
|
|
1685
|
+
...overrides.tools,
|
|
1686
|
+
[key]: pool
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
const remainingRaw = raw.toolRemaining;
|
|
1690
|
+
if (remainingRaw !== void 0) {
|
|
1691
|
+
const pool = normalizePool(remainingRaw, "toolRemaining", onWarning);
|
|
1692
|
+
if (pool !== void 0 && pool.length > 0) overrides.toolRemaining = pool;
|
|
1693
|
+
}
|
|
1694
|
+
const whispersRaw = raw.whispers;
|
|
1695
|
+
if (whispersRaw !== void 0) if (!isRecord$2(whispersRaw)) onWarning("whispers must be an object");
|
|
1696
|
+
else {
|
|
1697
|
+
for (const key of Object.keys(whispersRaw)) if (!WHISPER_KEYS.has(key)) onWarning("unknown whispers field " + key + " ignored");
|
|
1698
|
+
const generic = normalizePool(whispersRaw.generic, "whisperGeneric", onWarning);
|
|
1699
|
+
const rules = normalizeWhisperRules(whispersRaw.rules, onWarning);
|
|
1700
|
+
if (generic !== void 0 || rules !== void 0) overrides.whispers = {
|
|
1701
|
+
...generic === void 0 ? {} : { generic },
|
|
1702
|
+
...rules === void 0 ? {} : { rules }
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
const panel = raw.panel === void 0 ? void 0 : normalizePanel(raw.panel, onWarning);
|
|
1706
|
+
if (overrides.status === void 0 && overrides.tools === void 0 && overrides.toolRemaining === void 0 && overrides.whispers === void 0 && panel === void 0) return;
|
|
1707
|
+
return {
|
|
1708
|
+
overrides,
|
|
1709
|
+
...panel === void 0 ? {} : { panel }
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Merge voice-pack layers into one pack; later layers win per slot. The
|
|
1714
|
+
* built-in pools are NOT a layer here — the chatter engines fall back to
|
|
1715
|
+
* them per key at draw time. Merge order for a selected pet:
|
|
1716
|
+
* mergeVoicePacks(registry.globalVoice, entry.voice).
|
|
1717
|
+
*/
|
|
1718
|
+
function mergeVoicePacks(...layers) {
|
|
1719
|
+
const overrides = {};
|
|
1720
|
+
const labels = {};
|
|
1721
|
+
const stats = {};
|
|
1722
|
+
let actions;
|
|
1723
|
+
let panelSeen = false;
|
|
1724
|
+
let any = false;
|
|
1725
|
+
for (const layer of layers) {
|
|
1726
|
+
if (layer === void 0) continue;
|
|
1727
|
+
any = true;
|
|
1728
|
+
if (layer.overrides.status !== void 0) overrides.status = {
|
|
1729
|
+
...overrides.status,
|
|
1730
|
+
...layer.overrides.status
|
|
1731
|
+
};
|
|
1732
|
+
if (layer.overrides.tools !== void 0) overrides.tools = {
|
|
1733
|
+
...overrides.tools,
|
|
1734
|
+
...layer.overrides.tools
|
|
1735
|
+
};
|
|
1736
|
+
if (layer.overrides.toolRemaining !== void 0) overrides.toolRemaining = layer.overrides.toolRemaining;
|
|
1737
|
+
if (layer.overrides.whispers !== void 0) overrides.whispers = {
|
|
1738
|
+
...overrides.whispers,
|
|
1739
|
+
...layer.overrides.whispers
|
|
1740
|
+
};
|
|
1741
|
+
if (layer.panel !== void 0) {
|
|
1742
|
+
panelSeen = true;
|
|
1743
|
+
if (layer.panel.labels !== void 0) Object.assign(labels, layer.panel.labels);
|
|
1744
|
+
if (layer.panel.stats !== void 0) Object.assign(stats, layer.panel.stats);
|
|
1745
|
+
if (layer.panel.actions !== void 0) actions = layer.panel.actions;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
if (!any) return void 0;
|
|
1749
|
+
const panel = {
|
|
1750
|
+
...Object.keys(labels).length > 0 ? { labels } : {},
|
|
1751
|
+
...Object.keys(stats).length > 0 ? { stats } : {},
|
|
1752
|
+
...actions === void 0 ? {} : { actions }
|
|
1753
|
+
};
|
|
1754
|
+
const panelEmpty = panel.labels === void 0 && panel.stats === void 0 && panel.actions === void 0;
|
|
1755
|
+
return {
|
|
1756
|
+
overrides,
|
|
1757
|
+
...panelSeen && !panelEmpty ? { panel } : {}
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1376
1760
|
/** Renderer kinds the pet center knows how to dispatch (M1 §2). */
|
|
1377
1761
|
const PET_RENDERER_KINDS = ["sprite2d", "live2d"];
|
|
1378
1762
|
/** The seven ActivityPhase semantics (pet-center owned; M1 §1). */
|
|
@@ -1385,8 +1769,8 @@ const PET_ACTIVITY_PHASES = [
|
|
|
1385
1769
|
"done",
|
|
1386
1770
|
"failed"
|
|
1387
1771
|
];
|
|
1388
|
-
const PET_ID_PATTERN$
|
|
1389
|
-
const PATH_SEGMENT_PATTERN$
|
|
1772
|
+
const PET_ID_PATTERN$2 = /^[a-z0-9][a-z0-9-]*$/;
|
|
1773
|
+
const PATH_SEGMENT_PATTERN$2 = /^[A-Za-z0-9._-]+$/;
|
|
1390
1774
|
const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
1391
1775
|
/**
|
|
1392
1776
|
* Field allow-lists mirroring contracts/pet-manifest-v2.schema.json. Exported
|
|
@@ -1428,7 +1812,7 @@ const KNOWN_LIVE2D = /* @__PURE__ */ new Set([
|
|
|
1428
1812
|
"hitAreas",
|
|
1429
1813
|
"lipSync"
|
|
1430
1814
|
]);
|
|
1431
|
-
var Diagnostics = class {
|
|
1815
|
+
var Diagnostics$1 = class {
|
|
1432
1816
|
list = [];
|
|
1433
1817
|
source;
|
|
1434
1818
|
constructor(source) {
|
|
@@ -1450,10 +1834,10 @@ var Diagnostics = class {
|
|
|
1450
1834
|
return this.list.some((d) => d.level === "error");
|
|
1451
1835
|
}
|
|
1452
1836
|
};
|
|
1453
|
-
function isRecord(value) {
|
|
1837
|
+
function isRecord$1(value) {
|
|
1454
1838
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1455
1839
|
}
|
|
1456
|
-
function unknownKeys(source, known) {
|
|
1840
|
+
function unknownKeys$1(source, known) {
|
|
1457
1841
|
return Object.keys(source).filter((key) => !known.has(key));
|
|
1458
1842
|
}
|
|
1459
1843
|
/**
|
|
@@ -1466,7 +1850,7 @@ function safeManifestPath(raw) {
|
|
|
1466
1850
|
if (isAbsolute(value) || value.includes("\\") || /^[a-z][a-z0-9+.-]*:/i.test(value)) return void 0;
|
|
1467
1851
|
const segments = value.split("/").filter((segment) => segment !== "");
|
|
1468
1852
|
if (segments.length === 0) return void 0;
|
|
1469
|
-
if (segments.some((segment) => segment === "." || segment === ".." || !PATH_SEGMENT_PATTERN$
|
|
1853
|
+
if (segments.some((segment) => segment === "." || segment === ".." || !PATH_SEGMENT_PATTERN$2.test(segment))) return void 0;
|
|
1470
1854
|
return segments.join("/");
|
|
1471
1855
|
}
|
|
1472
1856
|
function parseStringBlock(record, key, diag, required) {
|
|
@@ -1484,7 +1868,7 @@ function parseStringBlock(record, key, diag, required) {
|
|
|
1484
1868
|
/** Validate the phase-keyed string map shape shared by motions/expressions. */
|
|
1485
1869
|
function parsePhaseStringMap(raw, field, diag) {
|
|
1486
1870
|
if (raw === void 0) return void 0;
|
|
1487
|
-
if (!isRecord(raw)) {
|
|
1871
|
+
if (!isRecord$1(raw)) {
|
|
1488
1872
|
diag.error("field " + JSON.stringify(field) + " must be an object keyed by activity phase");
|
|
1489
1873
|
return;
|
|
1490
1874
|
}
|
|
@@ -1505,7 +1889,7 @@ function parsePhaseStringMap(raw, field, diag) {
|
|
|
1505
1889
|
/** Structural gate for sequences: content stays warn-and-drop (registry's job). */
|
|
1506
1890
|
function parseSequences(raw, diag) {
|
|
1507
1891
|
if (raw === void 0) return void 0;
|
|
1508
|
-
if (!isRecord(raw)) {
|
|
1892
|
+
if (!isRecord$1(raw)) {
|
|
1509
1893
|
diag.warn("sequences must be an object keyed by activity phase; ignoring");
|
|
1510
1894
|
return;
|
|
1511
1895
|
}
|
|
@@ -1524,16 +1908,16 @@ function parseSequences(raw, diag) {
|
|
|
1524
1908
|
return Object.keys(sequences).length === 0 ? void 0 : sequences;
|
|
1525
1909
|
}
|
|
1526
1910
|
function parseSprite2dBlock(raw, diag) {
|
|
1527
|
-
if (!isRecord(raw)) {
|
|
1911
|
+
if (!isRecord$1(raw)) {
|
|
1528
1912
|
diag.error("renderer sprite2d requires a \"sprite2d\" block object");
|
|
1529
1913
|
return;
|
|
1530
1914
|
}
|
|
1531
|
-
const extra = unknownKeys(raw, KNOWN_SPRITE2D);
|
|
1915
|
+
const extra = unknownKeys$1(raw, KNOWN_SPRITE2D);
|
|
1532
1916
|
if (extra.length > 0) diag.error("sprite2d: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
|
|
1533
1917
|
const spritesheetPath = safeManifestPath(raw.spritesheetPath);
|
|
1534
1918
|
if (spritesheetPath === void 0) diag.error("sprite2d.spritesheetPath must be a safe manifest-relative path");
|
|
1535
1919
|
const block = { spritesheetPath: spritesheetPath ?? "" };
|
|
1536
|
-
if (raw.cell !== void 0) if (!isRecord(raw.cell)) diag.error("sprite2d.cell must be an object { width?, height? }");
|
|
1920
|
+
if (raw.cell !== void 0) if (!isRecord$1(raw.cell)) diag.error("sprite2d.cell must be an object { width?, height? }");
|
|
1537
1921
|
else block.cell = raw.cell;
|
|
1538
1922
|
if (raw.columns !== void 0) if (typeof raw.columns !== "number" || !Number.isInteger(raw.columns) || raw.columns < 1) diag.error("sprite2d.columns must be a positive integer");
|
|
1539
1923
|
else block.columns = raw.columns;
|
|
@@ -1541,16 +1925,16 @@ function parseSprite2dBlock(raw, diag) {
|
|
|
1541
1925
|
else block.atlasRows = raw.atlasRows;
|
|
1542
1926
|
if (raw.frames !== void 0) if (!Array.isArray(raw.frames) || raw.frames.some((v) => typeof v !== "number" || !Number.isInteger(v) || v < 0)) diag.error("sprite2d.frames must be an array of non-negative integers");
|
|
1543
1927
|
else block.frames = raw.frames;
|
|
1544
|
-
if (raw.tracks !== void 0) if (!isRecord(raw.tracks)) diag.error("sprite2d.tracks must be an object keyed by animation");
|
|
1928
|
+
if (raw.tracks !== void 0) if (!isRecord$1(raw.tracks)) diag.error("sprite2d.tracks must be an object keyed by animation");
|
|
1545
1929
|
else block.tracks = raw.tracks;
|
|
1546
1930
|
return diag.hasErrors ? void 0 : block;
|
|
1547
1931
|
}
|
|
1548
1932
|
function parseLive2dBlock(raw, diag) {
|
|
1549
|
-
if (!isRecord(raw)) {
|
|
1933
|
+
if (!isRecord$1(raw)) {
|
|
1550
1934
|
diag.error("renderer live2d requires a \"live2d\" block object");
|
|
1551
1935
|
return;
|
|
1552
1936
|
}
|
|
1553
|
-
const extra = unknownKeys(raw, KNOWN_LIVE2D);
|
|
1937
|
+
const extra = unknownKeys$1(raw, KNOWN_LIVE2D);
|
|
1554
1938
|
if (extra.length > 0) diag.error("live2d: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
|
|
1555
1939
|
const model = safeManifestPath(raw.model);
|
|
1556
1940
|
if (model === void 0) diag.error("live2d.model must be a safe manifest-relative path to a .model3.json");
|
|
@@ -1564,7 +1948,7 @@ function parseLive2dBlock(raw, diag) {
|
|
|
1564
1948
|
};
|
|
1565
1949
|
if (raw.scale !== void 0) if (typeof raw.scale !== "number" || !Number.isFinite(raw.scale) || raw.scale <= 0 || raw.scale > 10) diag.error("live2d.scale must be a number in (0, 10]");
|
|
1566
1950
|
else block.scale = raw.scale;
|
|
1567
|
-
if (raw.translate !== void 0) if (!isRecord(raw.translate) || raw.translate.x !== void 0 && typeof raw.translate.x !== "number" || raw.translate.y !== void 0 && typeof raw.translate.y !== "number") diag.error("live2d.translate must be an object { x?: number, y?: number }");
|
|
1951
|
+
if (raw.translate !== void 0) if (!isRecord$1(raw.translate) || raw.translate.x !== void 0 && typeof raw.translate.x !== "number" || raw.translate.y !== void 0 && typeof raw.translate.y !== "number") diag.error("live2d.translate must be an object { x?: number, y?: number }");
|
|
1568
1952
|
else block.translate = raw.translate;
|
|
1569
1953
|
const expressions = parsePhaseStringMap(raw.expressions, "live2d.expressions", diag);
|
|
1570
1954
|
if (expressions !== void 0) block.expressions = expressions;
|
|
@@ -1577,16 +1961,16 @@ function parseLive2dBlock(raw, diag) {
|
|
|
1577
1961
|
/** v1 compat read: map the legacy flat manifest onto the v2 sprite2d shape. */
|
|
1578
1962
|
function compatV1(source, diag) {
|
|
1579
1963
|
const id = parseStringBlock(source, "id", diag, true);
|
|
1580
|
-
if (id !== void 0 && !PET_ID_PATTERN$
|
|
1964
|
+
if (id !== void 0 && !PET_ID_PATTERN$2.test(id)) diag.error("id " + JSON.stringify(id) + " is not a lowercase kebab id");
|
|
1581
1965
|
const displayName = typeof source.displayName === "string" && source.displayName.trim() !== "" ? source.displayName.trim() : id;
|
|
1582
1966
|
const spritesheetPath = safeManifestPath(source.spritesheetPath === void 0 ? "spritesheet.webp" : source.spritesheetPath);
|
|
1583
1967
|
if (spritesheetPath === void 0) diag.error("spritesheetPath " + JSON.stringify(String(source.spritesheetPath)) + " is not a safe relative path");
|
|
1584
1968
|
if (source.license === void 0) diag.warn("v1 compat read: no license field; run scripts/dsh-pet-migrate-v2 to migrate this pet");
|
|
1585
1969
|
const sprite2d = { spritesheetPath: spritesheetPath ?? "spritesheet.webp" };
|
|
1586
|
-
if (isRecord(source.cell)) sprite2d.cell = source.cell;
|
|
1970
|
+
if (isRecord$1(source.cell)) sprite2d.cell = source.cell;
|
|
1587
1971
|
if (typeof source.columns === "number") sprite2d.columns = source.columns;
|
|
1588
1972
|
if (Array.isArray(source.frames)) sprite2d.frames = source.frames;
|
|
1589
|
-
if (isRecord(source.tracks)) sprite2d.tracks = source.tracks;
|
|
1973
|
+
if (isRecord$1(source.tracks)) sprite2d.tracks = source.tracks;
|
|
1590
1974
|
if (source.spriteVersionNumber === 2) sprite2d.atlasRows = 11;
|
|
1591
1975
|
const manifest = {
|
|
1592
1976
|
petManifestVersion: 2,
|
|
@@ -1604,11 +1988,11 @@ function compatV1(source, diag) {
|
|
|
1604
1988
|
}
|
|
1605
1989
|
/** Strict v2 validation (fail-closed on structure). */
|
|
1606
1990
|
function parseV2(source, diag) {
|
|
1607
|
-
const extra = unknownKeys(source, KNOWN_TOP_LEVEL);
|
|
1991
|
+
const extra = unknownKeys$1(source, KNOWN_TOP_LEVEL);
|
|
1608
1992
|
if (extra.length > 0) diag.error("unknown top-level field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
|
|
1609
1993
|
if (source.petManifestVersion !== 2) diag.error("petManifestVersion must be 2 (got " + JSON.stringify(source.petManifestVersion) + ")");
|
|
1610
1994
|
const id = parseStringBlock(source, "id", diag, true);
|
|
1611
|
-
if (id !== void 0 && (!PET_ID_PATTERN$
|
|
1995
|
+
if (id !== void 0 && (!PET_ID_PATTERN$2.test(id) || id.length > 64)) diag.error("id " + JSON.stringify(id) + " must be a lowercase kebab id of at most 64 chars");
|
|
1612
1996
|
const displayName = parseStringBlock(source, "displayName", diag, true);
|
|
1613
1997
|
const license = parseStringBlock(source, "license", diag, true);
|
|
1614
1998
|
const rendererRaw = source.renderer === void 0 ? "sprite2d" : source.renderer;
|
|
@@ -1640,7 +2024,7 @@ function parseV2(source, diag) {
|
|
|
1640
2024
|
}
|
|
1641
2025
|
const sequences = parseSequences(source.sequences, diag);
|
|
1642
2026
|
if (sequences !== void 0) manifest.sequences = sequences;
|
|
1643
|
-
if (source.remarks !== void 0 && !isRecord(source.remarks)) diag.error("remarks must be an object of remark pools");
|
|
2027
|
+
if (source.remarks !== void 0 && !isRecord$1(source.remarks)) diag.error("remarks must be an object of remark pools");
|
|
1644
2028
|
else if (source.remarks !== void 0) manifest.remarks = source.remarks;
|
|
1645
2029
|
return diag.hasErrors ? void 0 : manifest;
|
|
1646
2030
|
}
|
|
@@ -1652,8 +2036,8 @@ function parseV2(source, diag) {
|
|
|
1652
2036
|
* @param sourceLabel - human-readable origin for diagnostics (dir or file).
|
|
1653
2037
|
*/
|
|
1654
2038
|
function parsePetManifest(raw, sourceLabel) {
|
|
1655
|
-
const diag = new Diagnostics(sourceLabel);
|
|
1656
|
-
if (!isRecord(raw)) {
|
|
2039
|
+
const diag = new Diagnostics$1(sourceLabel);
|
|
2040
|
+
if (!isRecord$1(raw)) {
|
|
1657
2041
|
diag.error("manifest is not an object");
|
|
1658
2042
|
return {
|
|
1659
2043
|
ok: false,
|
|
@@ -1686,6 +2070,266 @@ function parsePetManifest(raw, sourceLabel) {
|
|
|
1686
2070
|
diagnostics: diag.list
|
|
1687
2071
|
};
|
|
1688
2072
|
}
|
|
2073
|
+
const DECORATION_ENTRY_EXTENSIONS = [".webp", ".png"];
|
|
2074
|
+
/** Field allow-list (drift-locked to the schema twin in tests). */
|
|
2075
|
+
const KNOWN_DECORATION_TOP_LEVEL = /* @__PURE__ */ new Set([
|
|
2076
|
+
"$schema",
|
|
2077
|
+
"decorationManifestVersion",
|
|
2078
|
+
"id",
|
|
2079
|
+
"displayName",
|
|
2080
|
+
"license",
|
|
2081
|
+
"entry",
|
|
2082
|
+
"cell",
|
|
2083
|
+
"columns",
|
|
2084
|
+
"frameMs",
|
|
2085
|
+
"durations",
|
|
2086
|
+
"loop",
|
|
2087
|
+
"phases"
|
|
2088
|
+
]);
|
|
2089
|
+
const PET_ID_PATTERN$1 = /^[a-z0-9][a-z0-9-]*$/;
|
|
2090
|
+
const PATH_SEGMENT_PATTERN$1 = /^[A-Za-z0-9._-]+$/;
|
|
2091
|
+
var Diagnostics = class {
|
|
2092
|
+
list = [];
|
|
2093
|
+
source;
|
|
2094
|
+
constructor(source) {
|
|
2095
|
+
this.source = source;
|
|
2096
|
+
}
|
|
2097
|
+
error(message) {
|
|
2098
|
+
this.list.push({
|
|
2099
|
+
level: "error",
|
|
2100
|
+
message: this.source + ": " + message
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
warn(message) {
|
|
2104
|
+
this.list.push({
|
|
2105
|
+
level: "warning",
|
|
2106
|
+
message: this.source + ": " + message
|
|
2107
|
+
});
|
|
2108
|
+
}
|
|
2109
|
+
get hasErrors() {
|
|
2110
|
+
return this.list.some((d) => d.level === "error");
|
|
2111
|
+
}
|
|
2112
|
+
};
|
|
2113
|
+
function isRecord(value) {
|
|
2114
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2115
|
+
}
|
|
2116
|
+
function unknownKeys(source, known) {
|
|
2117
|
+
return Object.keys(source).filter((key) => !known.has(key));
|
|
2118
|
+
}
|
|
2119
|
+
/** Positive integer in [min, max], else undefined. */
|
|
2120
|
+
function finiteInt$1(value, min, max) {
|
|
2121
|
+
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max ? value : void 0;
|
|
2122
|
+
}
|
|
2123
|
+
/**
|
|
2124
|
+
* Validate a descriptor-relative entry path: no absolute paths, no
|
|
2125
|
+
* backslashes, no traversal, plain safe segments only, and an exact
|
|
2126
|
+
* lowercase PNG/WebP extension (the adopted entry discipline — SVG/CSS are
|
|
2127
|
+
* not accepted). The extension match is case-sensitive on purpose: the
|
|
2128
|
+
* asset route serves the declared path verbatim, so a case-mismatched
|
|
2129
|
+
* suffix (frames.PNG vs frames.png) would pass a lenient check but 403 on
|
|
2130
|
+
* case-sensitive filesystems.
|
|
2131
|
+
* Returns the normalized path or undefined.
|
|
2132
|
+
*/
|
|
2133
|
+
function safeDecorationEntry(raw) {
|
|
2134
|
+
if (typeof raw !== "string" || raw.trim() === "") return void 0;
|
|
2135
|
+
const value = raw.trim();
|
|
2136
|
+
if (value.length > 256) return void 0;
|
|
2137
|
+
if (isAbsolute(value) || value.includes("\\") || /^[a-z][a-z0-9+.-]*:/i.test(value)) return void 0;
|
|
2138
|
+
const segments = value.split("/").filter((segment) => segment !== "");
|
|
2139
|
+
if (segments.length === 0) return void 0;
|
|
2140
|
+
if (segments.some((segment) => segment === "." || segment === ".." || !PATH_SEGMENT_PATTERN$1.test(segment))) return void 0;
|
|
2141
|
+
const last = segments[segments.length - 1];
|
|
2142
|
+
const dot = last.lastIndexOf(".");
|
|
2143
|
+
if (dot <= 0 || !DECORATION_ENTRY_EXTENSIONS.includes(last.slice(dot))) return void 0;
|
|
2144
|
+
return segments.join("/");
|
|
2145
|
+
}
|
|
2146
|
+
/** Normalize one phase binding value; undefined (warning) on bad content. */
|
|
2147
|
+
function normalizeSegment(raw, columns, diag) {
|
|
2148
|
+
if (raw === "hide") return "hide";
|
|
2149
|
+
if (!isRecord(raw)) {
|
|
2150
|
+
diag.warn("phase binding must be \"hide\" or { from, to }; binding dropped");
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
const from = finiteInt$1(raw.from, 0, columns - 1);
|
|
2154
|
+
const to = finiteInt$1(raw.to, 0, columns - 1);
|
|
2155
|
+
if (from === void 0 || to === void 0 || from > to) {
|
|
2156
|
+
diag.warn("phase frame segment out of range; binding dropped");
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
return {
|
|
2160
|
+
from,
|
|
2161
|
+
to
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* Parse and validate one decoration.json document. Fail-closed over the
|
|
2166
|
+
* structure (types, key sets, paths, ranges); phase-binding content issues
|
|
2167
|
+
* drop that binding only (warn-and-drop, the registry never-throw rule).
|
|
2168
|
+
*/
|
|
2169
|
+
function parseDecorationManifest(raw, source = "decoration.json") {
|
|
2170
|
+
const diag = new Diagnostics(source);
|
|
2171
|
+
if (!isRecord(raw)) {
|
|
2172
|
+
diag.error("descriptor must be a JSON object");
|
|
2173
|
+
return {
|
|
2174
|
+
ok: false,
|
|
2175
|
+
diagnostics: diag.list
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
for (const key of unknownKeys(raw, KNOWN_DECORATION_TOP_LEVEL)) diag.error("unknown top-level field " + JSON.stringify(key));
|
|
2179
|
+
if (raw.decorationManifestVersion !== 1) diag.error("decorationManifestVersion must be 1");
|
|
2180
|
+
const id = typeof raw.id === "string" ? raw.id.trim() : "";
|
|
2181
|
+
if (!PET_ID_PATTERN$1.test(id)) diag.error("id must be a lowercase kebab id");
|
|
2182
|
+
if (id.length > 64) diag.error("id must be at most 64 characters");
|
|
2183
|
+
const license = typeof raw.license === "string" ? raw.license.trim() : "";
|
|
2184
|
+
if (license === "") diag.error("license is required (asset provenance)");
|
|
2185
|
+
if (license.length > 128) diag.error("license must be at most 128 characters");
|
|
2186
|
+
const entry = safeDecorationEntry(raw.entry);
|
|
2187
|
+
if (entry === void 0) diag.error("entry must be a safe relative PNG/WebP path");
|
|
2188
|
+
const rawCell = isRecord(raw.cell) ? raw.cell : {};
|
|
2189
|
+
for (const key of Object.keys(rawCell)) if (key !== "width" && key !== "height") diag.warn("unknown cell field " + JSON.stringify(key) + " ignored");
|
|
2190
|
+
const cellWidth = finiteInt$1(rawCell.width, 1, 256);
|
|
2191
|
+
const cellHeight = finiteInt$1(rawCell.height, 1, 256);
|
|
2192
|
+
if (cellWidth === void 0 || cellHeight === void 0) diag.error("cell width/height must be integers in [1, 256]");
|
|
2193
|
+
const columns = finiteInt$1(raw.columns, 1, 16);
|
|
2194
|
+
if (columns === void 0) diag.error("columns must be an integer in [1, 16]");
|
|
2195
|
+
if (diag.hasErrors || id === "" || entry === void 0 || columns === void 0) return {
|
|
2196
|
+
ok: false,
|
|
2197
|
+
diagnostics: diag.list
|
|
2198
|
+
};
|
|
2199
|
+
const displayName = typeof raw.displayName === "string" && raw.displayName.trim() !== "" ? raw.displayName.trim().slice(0, 64) : id;
|
|
2200
|
+
let loop;
|
|
2201
|
+
if (raw.loop === void 0 || typeof raw.loop === "boolean") loop = raw.loop ?? true;
|
|
2202
|
+
else {
|
|
2203
|
+
diag.warn("loop must be a boolean; defaulting to true");
|
|
2204
|
+
loop = true;
|
|
2205
|
+
}
|
|
2206
|
+
const rawDurations = raw.durations;
|
|
2207
|
+
let durations;
|
|
2208
|
+
if (Array.isArray(rawDurations)) {
|
|
2209
|
+
const usable = rawDurations.filter((v) => typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 2e3);
|
|
2210
|
+
if (usable.length !== columns) {
|
|
2211
|
+
diag.warn("durations length must equal columns; using the constant frameMs instead");
|
|
2212
|
+
durations = [];
|
|
2213
|
+
} else durations = usable;
|
|
2214
|
+
} else if (rawDurations !== void 0) {
|
|
2215
|
+
diag.warn("durations must be an array; using the constant frameMs instead");
|
|
2216
|
+
durations = [];
|
|
2217
|
+
} else durations = [];
|
|
2218
|
+
if (durations.length === 0) {
|
|
2219
|
+
const frameMs = finiteInt$1(raw.frameMs, 1, 2e3) ?? 120;
|
|
2220
|
+
durations = Array.from({ length: columns }, () => frameMs);
|
|
2221
|
+
}
|
|
2222
|
+
const phases = {};
|
|
2223
|
+
const rawPhases = raw.phases;
|
|
2224
|
+
if (isRecord(rawPhases)) for (const [key, value] of Object.entries(rawPhases)) {
|
|
2225
|
+
if (!PET_ACTIVITY_PHASES.includes(key)) {
|
|
2226
|
+
diag.warn("unknown phase " + JSON.stringify(key) + "; binding ignored");
|
|
2227
|
+
continue;
|
|
2228
|
+
}
|
|
2229
|
+
const segment = normalizeSegment(value, columns, diag);
|
|
2230
|
+
if (segment !== void 0) phases[key] = segment;
|
|
2231
|
+
}
|
|
2232
|
+
else if (rawPhases !== void 0) diag.warn("phases must be an object; all phases hide");
|
|
2233
|
+
if (!Object.values(phases).some((segment) => segment !== "hide")) diag.warn("no phase shows the ornament; the decoration stays hidden");
|
|
2234
|
+
return {
|
|
2235
|
+
ok: true,
|
|
2236
|
+
manifest: {
|
|
2237
|
+
decorationManifestVersion: 1,
|
|
2238
|
+
id,
|
|
2239
|
+
displayName,
|
|
2240
|
+
license,
|
|
2241
|
+
entry,
|
|
2242
|
+
cell: {
|
|
2243
|
+
width: cellWidth,
|
|
2244
|
+
height: cellHeight
|
|
2245
|
+
},
|
|
2246
|
+
columns,
|
|
2247
|
+
durations,
|
|
2248
|
+
loop,
|
|
2249
|
+
phases
|
|
2250
|
+
},
|
|
2251
|
+
diagnostics: diag.list
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
//#endregion
|
|
2255
|
+
//#region src/image-dimensions.ts
|
|
2256
|
+
/**
|
|
2257
|
+
* Minimal PNG/WebP dimension reader — header-only, no decoding, no
|
|
2258
|
+
* dependencies. Used by the decoration registry to verify a strip's actual
|
|
2259
|
+
* pixel geometry matches its descriptor (single-row sprite strip; the client
|
|
2260
|
+
* renders by frame-column offsets, so a mismatched strip silently shows the
|
|
2261
|
+
* wrong frames). Parsing is best-effort: an unrecognized or truncated header
|
|
2262
|
+
* returns undefined (the caller decides whether to warn).
|
|
2263
|
+
*
|
|
2264
|
+
* PNG: signature (8) + IHDR chunk — length (4) + 'IHDR' (4) + width (4) +
|
|
2265
|
+
* height (4), both big-endian uint32 at fixed offsets 16/20.
|
|
2266
|
+
* WebP: RIFF header (12) + chunk — 'VP8X' extended (width-1/height-1 as
|
|
2267
|
+
* little-endian uint24 at 24/27), 'VP8L' lossless (packed 14-bit dims at
|
|
2268
|
+
* 21), or 'VP8 ' lossy (frame header, low 14 bits of the uint16 at 26/28).
|
|
2269
|
+
* @module @linxin666/dsh-pet/image-dimensions
|
|
2270
|
+
*/
|
|
2271
|
+
const PNG_SIGNATURE = Buffer.from([
|
|
2272
|
+
137,
|
|
2273
|
+
80,
|
|
2274
|
+
78,
|
|
2275
|
+
71,
|
|
2276
|
+
13,
|
|
2277
|
+
10,
|
|
2278
|
+
26,
|
|
2279
|
+
10
|
|
2280
|
+
]);
|
|
2281
|
+
/** Read the pixel size of a PNG buffer, or undefined when unrecognized. */
|
|
2282
|
+
function pngDimensions(buf) {
|
|
2283
|
+
if (buf.length < 24) return void 0;
|
|
2284
|
+
if (!buf.subarray(0, 8).equals(PNG_SIGNATURE)) return void 0;
|
|
2285
|
+
if (buf.toString("ascii", 12, 16) !== "IHDR") return void 0;
|
|
2286
|
+
return {
|
|
2287
|
+
width: buf.readUInt32BE(16),
|
|
2288
|
+
height: buf.readUInt32BE(20)
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2291
|
+
/** Read the pixel size of a WebP buffer, or undefined when unrecognized. */
|
|
2292
|
+
function webpDimensions(buf) {
|
|
2293
|
+
if (buf.length < 21) return void 0;
|
|
2294
|
+
if (buf.toString("ascii", 0, 4) !== "RIFF") return void 0;
|
|
2295
|
+
if (buf.toString("ascii", 8, 12) !== "WEBP") return void 0;
|
|
2296
|
+
const fourcc = buf.toString("ascii", 12, 16);
|
|
2297
|
+
if (fourcc === "VP8X") {
|
|
2298
|
+
if (buf.length < 30) return void 0;
|
|
2299
|
+
return {
|
|
2300
|
+
width: 1 + buf.readUIntLE(24, 3),
|
|
2301
|
+
height: 1 + buf.readUIntLE(27, 3)
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
if (fourcc === "VP8L") {
|
|
2305
|
+
if (buf.length < 25) return void 0;
|
|
2306
|
+
const bits = buf.readUInt32LE(21);
|
|
2307
|
+
return {
|
|
2308
|
+
width: 1 + (bits & 16383),
|
|
2309
|
+
height: 1 + (bits >>> 14 & 16383)
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2312
|
+
if (fourcc === "VP8 ") {
|
|
2313
|
+
if (buf.length < 30) return void 0;
|
|
2314
|
+
return {
|
|
2315
|
+
width: buf.readUInt16LE(26) & 16383,
|
|
2316
|
+
height: buf.readUInt16LE(28) & 16383
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
/**
|
|
2321
|
+
* Read image pixel dimensions from a PNG or WebP buffer. Returns undefined
|
|
2322
|
+
* for formats this reader does not recognize (never throws). Callers treat
|
|
2323
|
+
* undefined as "cannot verify", not as an error.
|
|
2324
|
+
*/
|
|
2325
|
+
function imageDimensions(buf) {
|
|
2326
|
+
if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF") return webpDimensions(buf);
|
|
2327
|
+
return pngDimensions(buf);
|
|
2328
|
+
}
|
|
2329
|
+
//#endregion
|
|
2330
|
+
//#region src/contracts/status-decoration.ts
|
|
2331
|
+
/** Contract version decorations declare against (independent of manifests). */
|
|
2332
|
+
const PET_DECORATION_API_VERSION = "x-org.linxin666.pet-center/status-decoration-v1";
|
|
1689
2333
|
//#endregion
|
|
1690
2334
|
//#region src/model3.ts
|
|
1691
2335
|
/**
|
|
@@ -2198,24 +2842,28 @@ function scanPetDir(dir, options) {
|
|
|
2198
2842
|
options.warnings?.push(diagnostic.message);
|
|
2199
2843
|
}
|
|
2200
2844
|
if (!verdict.ok) continue;
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2845
|
+
let entry;
|
|
2846
|
+
if (verdict.manifest.renderer === "live2d") entry = resolveLive2dEntry(verdict.manifest, entryDir, options);
|
|
2847
|
+
else {
|
|
2848
|
+
const legacy = flattenV2Sprite2d(verdict.manifest);
|
|
2849
|
+
if (legacy === void 0) {
|
|
2850
|
+
const note = "pet " + verdict.manifest.id + ": sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver";
|
|
2851
|
+
options.diagnostics?.push({
|
|
2852
|
+
level: "error",
|
|
2853
|
+
source: entryDir,
|
|
2854
|
+
message: note
|
|
2855
|
+
});
|
|
2856
|
+
options.warnings?.push(note);
|
|
2857
|
+
continue;
|
|
2858
|
+
}
|
|
2859
|
+
entry = resolvePetManifest(legacy, entryDir, options);
|
|
2216
2860
|
}
|
|
2217
|
-
|
|
2218
|
-
|
|
2861
|
+
if (entry === void 0) continue;
|
|
2862
|
+
const voice = loadVoicePackFile(join(entryDir, "voice.json"), options);
|
|
2863
|
+
entries.push({
|
|
2864
|
+
...entry,
|
|
2865
|
+
...voice === void 0 ? {} : { voice }
|
|
2866
|
+
});
|
|
2219
2867
|
}
|
|
2220
2868
|
return entries;
|
|
2221
2869
|
}
|
|
@@ -2229,6 +2877,167 @@ function readPetJson(file, warnings) {
|
|
|
2229
2877
|
}
|
|
2230
2878
|
}
|
|
2231
2879
|
/**
|
|
2880
|
+
* Stat one scanned JSON descriptor with a regular-file + size guard, so a
|
|
2881
|
+
* pathological user file is skipped with a warning instead of stalling or
|
|
2882
|
+
* OOM-ing the host at startup. Returns the Stats, or undefined when the
|
|
2883
|
+
* caller must skip the file (a warning was recorded).
|
|
2884
|
+
*/
|
|
2885
|
+
function guardedScannedJsonStat(file, options, what) {
|
|
2886
|
+
let st;
|
|
2887
|
+
try {
|
|
2888
|
+
st = statSync(file);
|
|
2889
|
+
} catch {
|
|
2890
|
+
return;
|
|
2891
|
+
}
|
|
2892
|
+
const warn = (message) => {
|
|
2893
|
+
options.warnings?.push(file + ": " + message);
|
|
2894
|
+
options.diagnostics?.push({
|
|
2895
|
+
level: "warning",
|
|
2896
|
+
source: file,
|
|
2897
|
+
message: file + ": " + message
|
|
2898
|
+
});
|
|
2899
|
+
};
|
|
2900
|
+
if (!st.isFile()) {
|
|
2901
|
+
warn(what + " is not a regular file; ignored");
|
|
2902
|
+
return;
|
|
2903
|
+
}
|
|
2904
|
+
if (st.size > 65536) {
|
|
2905
|
+
warn(what + " exceeds the 65536-byte scan ceiling; ignored");
|
|
2906
|
+
return;
|
|
2907
|
+
}
|
|
2908
|
+
return st;
|
|
2909
|
+
}
|
|
2910
|
+
/**
|
|
2911
|
+
* Load and normalize one optional voice.json (pet-center M4). A missing
|
|
2912
|
+
* file is silent; a broken file warns and drops. The pack is pure content,
|
|
2913
|
+
* so every issue stays a warning — a bad voice.json never rejects a pet.
|
|
2914
|
+
*/
|
|
2915
|
+
function loadVoicePackFile(file, options) {
|
|
2916
|
+
if (!existsSync(file)) return void 0;
|
|
2917
|
+
if (guardedScannedJsonStat(file, options, "voice pack") === void 0) return void 0;
|
|
2918
|
+
const warn = (message) => {
|
|
2919
|
+
options.warnings?.push(file + ": " + message);
|
|
2920
|
+
options.diagnostics?.push({
|
|
2921
|
+
level: "warning",
|
|
2922
|
+
source: file,
|
|
2923
|
+
message: file + ": " + message
|
|
2924
|
+
});
|
|
2925
|
+
};
|
|
2926
|
+
let raw;
|
|
2927
|
+
try {
|
|
2928
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
2929
|
+
} catch (error) {
|
|
2930
|
+
warn("voice pack is not valid JSON; ignored: " + (error instanceof Error ? error.message : String(error)));
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
return normalizeVoicePack(raw, warn);
|
|
2934
|
+
}
|
|
2935
|
+
/** Decoration asset URL prefix (served by the decoration route, M5). */
|
|
2936
|
+
const DECORATION_ASSET_PREFIX = "/api/pet/decoration";
|
|
2937
|
+
/** Read the pixel dimensions of a decoration strip (PNG/WebP), if decodable. */
|
|
2938
|
+
function readImageDimensions(file) {
|
|
2939
|
+
let header;
|
|
2940
|
+
try {
|
|
2941
|
+
const fd = openSync(file, "r");
|
|
2942
|
+
try {
|
|
2943
|
+
header = Buffer.alloc(64);
|
|
2944
|
+
const read = readSync(fd, header, 0, header.length, 0);
|
|
2945
|
+
if (read < 0) return void 0;
|
|
2946
|
+
header = header.subarray(0, read);
|
|
2947
|
+
} finally {
|
|
2948
|
+
closeSync(fd);
|
|
2949
|
+
}
|
|
2950
|
+
} catch {
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2953
|
+
return imageDimensions(header);
|
|
2954
|
+
}
|
|
2955
|
+
/**
|
|
2956
|
+
* Scan one directory of decoration folders ('decoration.json' + strip).
|
|
2957
|
+
* Later scans override earlier ones on id collision; a bad descriptor warns
|
|
2958
|
+
* and skips — the never-throw philosophy holds for decorations too (M5).
|
|
2959
|
+
*/
|
|
2960
|
+
function scanDecorationDir(dir, options) {
|
|
2961
|
+
if (!existsSync(dir)) return [];
|
|
2962
|
+
let names = [];
|
|
2963
|
+
try {
|
|
2964
|
+
names = readdirSync(dir).filter((name) => !name.startsWith("."));
|
|
2965
|
+
} catch {
|
|
2966
|
+
return [];
|
|
2967
|
+
}
|
|
2968
|
+
names.sort();
|
|
2969
|
+
const entries = [];
|
|
2970
|
+
for (const name of names) {
|
|
2971
|
+
const entryDir = join(dir, name);
|
|
2972
|
+
const manifestFile = join(entryDir, "decoration.json");
|
|
2973
|
+
if (!existsSync(manifestFile)) continue;
|
|
2974
|
+
if (guardedScannedJsonStat(manifestFile, options, "decoration descriptor") === void 0) continue;
|
|
2975
|
+
let raw;
|
|
2976
|
+
try {
|
|
2977
|
+
raw = JSON.parse(readFileSync(manifestFile, "utf8"));
|
|
2978
|
+
} catch (error) {
|
|
2979
|
+
const message = "skipping " + manifestFile + ": " + (error instanceof Error ? error.message : String(error));
|
|
2980
|
+
options.warnings?.push(message);
|
|
2981
|
+
options.diagnostics?.push({
|
|
2982
|
+
level: "error",
|
|
2983
|
+
source: entryDir,
|
|
2984
|
+
message
|
|
2985
|
+
});
|
|
2986
|
+
continue;
|
|
2987
|
+
}
|
|
2988
|
+
const verdict = parseDecorationManifest(raw, manifestFile);
|
|
2989
|
+
for (const diagnostic of verdict.diagnostics) {
|
|
2990
|
+
options.diagnostics?.push({
|
|
2991
|
+
level: diagnostic.level,
|
|
2992
|
+
source: entryDir,
|
|
2993
|
+
message: diagnostic.message
|
|
2994
|
+
});
|
|
2995
|
+
options.warnings?.push(diagnostic.message);
|
|
2996
|
+
}
|
|
2997
|
+
if (!verdict.ok) continue;
|
|
2998
|
+
const manifest = verdict.manifest;
|
|
2999
|
+
if (!existsSync(join(entryDir, manifest.entry))) {
|
|
3000
|
+
const message = "decoration " + manifest.id + ": strip file missing: " + manifest.entry;
|
|
3001
|
+
options.warnings?.push(message);
|
|
3002
|
+
options.diagnostics?.push({
|
|
3003
|
+
level: "warning",
|
|
3004
|
+
source: entryDir,
|
|
3005
|
+
message
|
|
3006
|
+
});
|
|
3007
|
+
} else {
|
|
3008
|
+
const actual = readImageDimensions(join(entryDir, manifest.entry));
|
|
3009
|
+
if (actual !== void 0) {
|
|
3010
|
+
const expectedWidth = manifest.cell.width * manifest.columns;
|
|
3011
|
+
if (actual.width !== expectedWidth || actual.height !== manifest.cell.height) {
|
|
3012
|
+
const message = "decoration " + manifest.id + ": strip " + actual.width + "x" + actual.height + " does not match cell " + manifest.cell.width + "x" + manifest.cell.height + " x " + manifest.columns + " columns (expected " + expectedWidth + "x" + manifest.cell.height + "); frames will render wrong";
|
|
3013
|
+
options.warnings?.push(message);
|
|
3014
|
+
options.diagnostics?.push({
|
|
3015
|
+
level: "warning",
|
|
3016
|
+
source: entryDir,
|
|
3017
|
+
message
|
|
3018
|
+
});
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
entries.push({
|
|
3023
|
+
apiVersion: PET_DECORATION_API_VERSION,
|
|
3024
|
+
id: manifest.id,
|
|
3025
|
+
dir: entryDir,
|
|
3026
|
+
entryPath: manifest.entry,
|
|
3027
|
+
servable: ["decoration.json", manifest.entry],
|
|
3028
|
+
license: manifest.license,
|
|
3029
|
+
assetBase: "/api/pet/decoration/" + encodeURIComponent(manifest.id),
|
|
3030
|
+
entryUrl: "/api/pet/decoration/" + encodeURIComponent(manifest.id) + "/" + manifest.entry,
|
|
3031
|
+
cell: manifest.cell,
|
|
3032
|
+
columns: manifest.columns,
|
|
3033
|
+
durations: manifest.durations,
|
|
3034
|
+
loop: manifest.loop,
|
|
3035
|
+
phases: manifest.phases
|
|
3036
|
+
});
|
|
3037
|
+
}
|
|
3038
|
+
return entries;
|
|
3039
|
+
}
|
|
3040
|
+
/**
|
|
2232
3041
|
* Load the pet registry: built-in 'assets/*' first, then the hatch-pet
|
|
2233
3042
|
* custom pets directory, then composed 'extra' manifests (each later source
|
|
2234
3043
|
* overrides an earlier one on id collision). The registry never throws on a
|
|
@@ -2262,13 +3071,20 @@ function loadPetRegistry(options) {
|
|
|
2262
3071
|
byId.set(entry.id, entry);
|
|
2263
3072
|
}
|
|
2264
3073
|
const dshPetsDir = options.dshPetsDir ?? join(dshHome(), "pets");
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
3074
|
+
let globalVoice;
|
|
3075
|
+
if (dshPetsDir !== "") {
|
|
3076
|
+
for (const entry of scanPetDir(dshPetsDir, {
|
|
3077
|
+
assetPrefix,
|
|
3078
|
+
warnings,
|
|
3079
|
+
diagnostics
|
|
3080
|
+
})) {
|
|
3081
|
+
if (byId.has(entry.id)) warnings.push("user pet " + entry.id + " overrides an earlier registration");
|
|
3082
|
+
byId.set(entry.id, entry);
|
|
3083
|
+
}
|
|
3084
|
+
globalVoice = loadVoicePackFile(join(dshPetsDir, ".voice.json"), {
|
|
3085
|
+
warnings,
|
|
3086
|
+
diagnostics
|
|
3087
|
+
});
|
|
2272
3088
|
}
|
|
2273
3089
|
for (const manifest of options.extra ?? []) {
|
|
2274
3090
|
const raw = manifest.spritesheetPath;
|
|
@@ -2284,17 +3100,55 @@ function loadPetRegistry(options) {
|
|
|
2284
3100
|
if (byId.has(entry.id)) warnings.push("composed pet " + entry.id + " overrides an earlier registration");
|
|
2285
3101
|
byId.set(entry.id, entry);
|
|
2286
3102
|
}
|
|
3103
|
+
const decorationById = /* @__PURE__ */ new Map();
|
|
3104
|
+
for (const entry of scanDecorationDir(join(packageRoot, "assets", "decorations"), {
|
|
3105
|
+
warnings,
|
|
3106
|
+
diagnostics
|
|
3107
|
+
})) decorationById.set(entry.id, entry);
|
|
3108
|
+
if (dshPetsDir !== "") for (const entry of scanDecorationDir(join(dshPetsDir, "decorations"), {
|
|
3109
|
+
warnings,
|
|
3110
|
+
diagnostics
|
|
3111
|
+
})) {
|
|
3112
|
+
if (decorationById.has(entry.id)) warnings.push("user decoration " + entry.id + " overrides the built-in one");
|
|
3113
|
+
decorationById.set(entry.id, entry);
|
|
3114
|
+
}
|
|
2287
3115
|
const entries = [...byId.values()];
|
|
3116
|
+
const decorations = [...decorationById.values()];
|
|
2288
3117
|
return {
|
|
2289
3118
|
entries,
|
|
2290
3119
|
warnings,
|
|
2291
3120
|
diagnostics,
|
|
2292
3121
|
byId: (id) => byId.get(id),
|
|
2293
|
-
defaultEntry: () => entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0]
|
|
3122
|
+
defaultEntry: () => entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0],
|
|
3123
|
+
...globalVoice === void 0 ? {} : { globalVoice },
|
|
3124
|
+
decorations,
|
|
3125
|
+
decorationById: (id) => decorationById.get(id)
|
|
2294
3126
|
};
|
|
2295
3127
|
}
|
|
2296
|
-
/**
|
|
2297
|
-
|
|
3128
|
+
/** The built-in default decoration id (M5): the first reference ornament. */
|
|
3129
|
+
const DEFAULT_DECORATION_ID = "whale";
|
|
3130
|
+
/** Strip host-only fields, leaving the browser-visible decoration view. */
|
|
3131
|
+
function decorationView(entry) {
|
|
3132
|
+
return {
|
|
3133
|
+
apiVersion: PET_DECORATION_API_VERSION,
|
|
3134
|
+
id: entry.id,
|
|
3135
|
+
assetBase: entry.assetBase,
|
|
3136
|
+
entryUrl: entry.entryUrl,
|
|
3137
|
+
cell: entry.cell,
|
|
3138
|
+
columns: entry.columns,
|
|
3139
|
+
durations: entry.durations,
|
|
3140
|
+
loop: entry.loop,
|
|
3141
|
+
phases: entry.phases
|
|
3142
|
+
};
|
|
3143
|
+
}
|
|
3144
|
+
/**
|
|
3145
|
+
* Strip host-only fields, leaving the client-visible definition. When the
|
|
3146
|
+
* registry carries a global voice pack, its panel chrome layers under the
|
|
3147
|
+
* entry's own pack (per-slot merge, pet > global), mirroring the voice-pool
|
|
3148
|
+
* layering (pet-center M4, issue #677).
|
|
3149
|
+
*/
|
|
3150
|
+
function petEntryView(entry, globalVoice) {
|
|
3151
|
+
const panel = globalVoice === void 0 ? entry.voice?.panel : mergeVoicePacks(globalVoice, entry.voice)?.panel;
|
|
2298
3152
|
return {
|
|
2299
3153
|
id: entry.id,
|
|
2300
3154
|
displayName: entry.displayName,
|
|
@@ -2308,7 +3162,8 @@ function petEntryView(entry) {
|
|
|
2308
3162
|
tracks: entry.tracks,
|
|
2309
3163
|
...entry.sequences === void 0 ? {} : { sequences: entry.sequences },
|
|
2310
3164
|
atlasUrl: entry.atlasUrl,
|
|
2311
|
-
manifestUrl: entry.manifestUrl
|
|
3165
|
+
manifestUrl: entry.manifestUrl,
|
|
3166
|
+
...panel === void 0 ? {} : { panel }
|
|
2312
3167
|
};
|
|
2313
3168
|
}
|
|
2314
3169
|
/** Hard cap on simultaneously displayed session bubbles (most recent first). */
|
|
@@ -2327,10 +3182,18 @@ var PetService = class extends Service {
|
|
|
2327
3182
|
registry;
|
|
2328
3183
|
persistDir;
|
|
2329
3184
|
enabled;
|
|
3185
|
+
/** Status-decoration master switch (M5, #567); mirrored from settings. */
|
|
3186
|
+
decorationEnabled;
|
|
2330
3187
|
disposeActivity;
|
|
2331
3188
|
/** Session whose most recent meaningful event currently drives the global pet. */
|
|
2332
3189
|
displaySession;
|
|
2333
3190
|
/**
|
|
3191
|
+
* Effective voice-pack overrides for the currently selected pet (M4,
|
|
3192
|
+
* #677). Cached per pet id; the registry is an immutable snapshot, so the
|
|
3193
|
+
* global pack and each entry's pack cannot change behind the cache.
|
|
3194
|
+
*/
|
|
3195
|
+
voiceCache;
|
|
3196
|
+
/**
|
|
2334
3197
|
* Per-session activity, most recent last (Map insertion order). Bounded by
|
|
2335
3198
|
* MAX_SESSION_BUBBLES so a burst of sessions cannot grow it without bound;
|
|
2336
3199
|
* disposed sessions are removed by the 'session/disposed' listener.
|
|
@@ -2362,8 +3225,26 @@ var PetService = class extends Service {
|
|
|
2362
3225
|
};
|
|
2363
3226
|
this.machine = new PetStateMachine(this.stateConfig);
|
|
2364
3227
|
this.enabled = config.enabled ?? true;
|
|
3228
|
+
this.decorationEnabled = config.decorationEnabled ?? true;
|
|
2365
3229
|
this.syncActivity();
|
|
2366
3230
|
}
|
|
3231
|
+
/**
|
|
3232
|
+
* The draw-time voice-pool provider handed to every projection runtime.
|
|
3233
|
+
* It re-resolves when the selected pet changes, so live engines re-voice
|
|
3234
|
+
* on the next draw without being rebuilt (M4, #677).
|
|
3235
|
+
*/
|
|
3236
|
+
voicePools() {
|
|
3237
|
+
return () => {
|
|
3238
|
+
const entry = this.activeEntry();
|
|
3239
|
+
if (this.voiceCache !== void 0 && this.voiceCache.petId === entry.id) return this.voiceCache.overrides;
|
|
3240
|
+
const overrides = mergeVoicePacks(this.registry.globalVoice, entry.voice)?.overrides ?? {};
|
|
3241
|
+
this.voiceCache = {
|
|
3242
|
+
petId: entry.id,
|
|
3243
|
+
overrides
|
|
3244
|
+
};
|
|
3245
|
+
return overrides;
|
|
3246
|
+
};
|
|
3247
|
+
}
|
|
2367
3248
|
/** Whether the pet service consumes session activity while enabled. */
|
|
2368
3249
|
isEnabled() {
|
|
2369
3250
|
return this.enabled;
|
|
@@ -2378,7 +3259,7 @@ var PetService = class extends Service {
|
|
|
2378
3259
|
}
|
|
2379
3260
|
/** RPC: the registry entries the browser half renders and selects from. */
|
|
2380
3261
|
async pets() {
|
|
2381
|
-
return this.registry.entries.map(petEntryView);
|
|
3262
|
+
return this.registry.entries.map((entry) => petEntryView(entry, this.registry.globalVoice));
|
|
2382
3263
|
}
|
|
2383
3264
|
/** The loaded registry (the asset routes serve its entries). */
|
|
2384
3265
|
registrySnapshot() {
|
|
@@ -2388,6 +3269,15 @@ var PetService = class extends Service {
|
|
|
2388
3269
|
async diagnostics() {
|
|
2389
3270
|
return { diagnostics: this.registry.diagnostics };
|
|
2390
3271
|
}
|
|
3272
|
+
/**
|
|
3273
|
+
* The active status decoration view (M5, #567): the default 'whale' entry
|
|
3274
|
+
* (user directories override built-ins by id), gated by the master switch.
|
|
3275
|
+
*/
|
|
3276
|
+
activeDecoration() {
|
|
3277
|
+
if (!this.decorationEnabled) return void 0;
|
|
3278
|
+
const entry = this.registry.decorationById?.(DEFAULT_DECORATION_ID);
|
|
3279
|
+
return entry === void 0 ? void 0 : decorationView(entry);
|
|
3280
|
+
}
|
|
2391
3281
|
/** The selected pet's registry entry. */
|
|
2392
3282
|
activeEntry() {
|
|
2393
3283
|
return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry();
|
|
@@ -2471,7 +3361,7 @@ var PetService = class extends Service {
|
|
|
2471
3361
|
let activity = this.sessionActivity.get(session);
|
|
2472
3362
|
if (activity === void 0) {
|
|
2473
3363
|
activity = {
|
|
2474
|
-
runtime: emptyProjectionRuntime(),
|
|
3364
|
+
runtime: emptyProjectionRuntime(this.voicePools()),
|
|
2475
3365
|
machine: new PetStateMachine(this.stateConfig)
|
|
2476
3366
|
};
|
|
2477
3367
|
this.sessionActivity.set(session, activity);
|
|
@@ -2565,6 +3455,7 @@ var PetService = class extends Service {
|
|
|
2565
3455
|
* @param section - the resolved settings section.
|
|
2566
3456
|
*/
|
|
2567
3457
|
applySettingsSection(section) {
|
|
3458
|
+
this.decorationEnabled = section.decorationEnabled ?? true;
|
|
2568
3459
|
const selected = typeof section.petId === "string" ? this.registry.byId(section.petId) : void 0;
|
|
2569
3460
|
if (selected !== void 0) {
|
|
2570
3461
|
this.ledger.setPetId(selected.id);
|
|
@@ -2617,6 +3508,7 @@ var PetService = class extends Service {
|
|
|
2617
3508
|
}
|
|
2618
3509
|
const whisper = (this.displaySession === void 0 ? void 0 : this.sessionActivity.get(this.displaySession))?.whisper;
|
|
2619
3510
|
const freshWhisper = whisper !== void 0 && Date.now() - whisper.at < 8e3 ? whisper.text : void 0;
|
|
3511
|
+
const decoration = this.activeDecoration();
|
|
2620
3512
|
return {
|
|
2621
3513
|
animation: snapshot.animation,
|
|
2622
3514
|
...snapshot.bubble === void 0 ? {} : { bubble: snapshot.bubble },
|
|
@@ -2624,6 +3516,7 @@ var PetService = class extends Service {
|
|
|
2624
3516
|
sessionActive: snapshot.sessionActive,
|
|
2625
3517
|
sessions,
|
|
2626
3518
|
...freshWhisper === void 0 ? {} : { whisper: freshWhisper },
|
|
3519
|
+
...decoration === void 0 ? {} : { decoration },
|
|
2627
3520
|
affinity: this.ledger.affinityView(Date.now()),
|
|
2628
3521
|
display: { ...this.ledger.snapshot.display },
|
|
2629
3522
|
pet: {
|
|
@@ -2690,6 +3583,23 @@ function isLoopbackRequest(request) {
|
|
|
2690
3583
|
}
|
|
2691
3584
|
}
|
|
2692
3585
|
//#endregion
|
|
3586
|
+
//#region src/access.ts
|
|
3587
|
+
/**
|
|
3588
|
+
* Whether this request may enter any /api/pet or /pet asset route.
|
|
3589
|
+
* @param ctx - host context; may expose remoteWebUiPairing.
|
|
3590
|
+
* @param request - the incoming HTTP request.
|
|
3591
|
+
* @returns true for loopback, or a live paired-device cookie.
|
|
3592
|
+
*/
|
|
3593
|
+
function isPetAllowed(ctx, request) {
|
|
3594
|
+
if (isLoopbackRequest(request)) return true;
|
|
3595
|
+
const bag = ctx;
|
|
3596
|
+
const fromGet = typeof bag.get === "function" ? bag.get("remoteWebUiPairing", false) : void 0;
|
|
3597
|
+
return (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)?.isPairedDevice(request) === true;
|
|
3598
|
+
}
|
|
3599
|
+
function isPairingAccess(value) {
|
|
3600
|
+
return value !== void 0 && value !== null && typeof value.isPairedDevice === "function";
|
|
3601
|
+
}
|
|
3602
|
+
//#endregion
|
|
2693
3603
|
//#region src/routes.ts
|
|
2694
3604
|
/**
|
|
2695
3605
|
* Pet HTTP routes — the browser half talks to the host through plain
|
|
@@ -2698,7 +3608,10 @@ function isLoopbackRequest(request) {
|
|
|
2698
3608
|
* domains are platform-registered, so the pet serves its own API and media —
|
|
2699
3609
|
* the same pattern as dsh-remote-web-ui's '/api/pair' family. The asset route
|
|
2700
3610
|
* is one prefix registration serving every registry entry (manifest, atlas,
|
|
2701
|
-
* optional previews), so adding a pet never touches route wiring.
|
|
3611
|
+
* optional previews), so adding a pet never touches route wiring. Both the
|
|
3612
|
+
* JSON API, the asset prefix, and the Live2D runtime prefix are loopback-only
|
|
3613
|
+
* by default; a live paired-device cookie is an extra allow path when
|
|
3614
|
+
* remote-web-ui is loaded.
|
|
2702
3615
|
* @module @linxin666/dsh-pet/routes
|
|
2703
3616
|
*/
|
|
2704
3617
|
/** Browser-facing base path of the pet API. */
|
|
@@ -2804,9 +3717,9 @@ function readJsonBody(req) {
|
|
|
2804
3717
|
req.on("error", reject);
|
|
2805
3718
|
});
|
|
2806
3719
|
}
|
|
2807
|
-
/** Shared route fence:
|
|
2808
|
-
function guard(req, res) {
|
|
2809
|
-
if (
|
|
3720
|
+
/** Shared route fence: loopback always passes; a live paired-device cookie is an extra allow path. */
|
|
3721
|
+
function guard(ctx, req, res) {
|
|
3722
|
+
if (isPetAllowed(ctx, req)) return true;
|
|
2810
3723
|
json(res, 403, {
|
|
2811
3724
|
ok: false,
|
|
2812
3725
|
error: "forbidden: loopback-only"
|
|
@@ -2814,12 +3727,12 @@ function guard(req, res) {
|
|
|
2814
3727
|
return false;
|
|
2815
3728
|
}
|
|
2816
3729
|
/** Wrap one async service call as a GET JSON route. */
|
|
2817
|
-
function getRoute(path, run) {
|
|
3730
|
+
function getRoute(ctx, path, run) {
|
|
2818
3731
|
return {
|
|
2819
3732
|
kind: "exact",
|
|
2820
3733
|
path,
|
|
2821
3734
|
handler: (req, res) => {
|
|
2822
|
-
if (!guard(req, res)) return;
|
|
3735
|
+
if (!guard(ctx, req, res)) return;
|
|
2823
3736
|
if (!requireMethod(req, res, "GET")) return;
|
|
2824
3737
|
run().then((value) => json(res, 200, value), (error) => {
|
|
2825
3738
|
json(res, 500, {
|
|
@@ -2831,12 +3744,12 @@ function getRoute(path, run) {
|
|
|
2831
3744
|
};
|
|
2832
3745
|
}
|
|
2833
3746
|
/** Wrap one async service call as a POST JSON route (body passed through). */
|
|
2834
|
-
function postRoute(path, run) {
|
|
3747
|
+
function postRoute(ctx, path, run) {
|
|
2835
3748
|
return {
|
|
2836
3749
|
kind: "exact",
|
|
2837
3750
|
path,
|
|
2838
3751
|
handler: (req, res) => {
|
|
2839
|
-
if (!guard(req, res)) return Promise.resolve();
|
|
3752
|
+
if (!guard(ctx, req, res)) return Promise.resolve();
|
|
2840
3753
|
if (!requireMethod(req, res, "POST")) return Promise.resolve();
|
|
2841
3754
|
return readJsonBody(req).then((body) => {
|
|
2842
3755
|
return run(typeof body === "object" && body !== null ? body : {}).then((value) => json(res, 200, value), (error) => {
|
|
@@ -2873,10 +3786,10 @@ function dirAliases(registry) {
|
|
|
2873
3786
|
* match; containedRealpath stays as the second layer. Composed pets without
|
|
2874
3787
|
* a manifest file get a synthesized pet.json.
|
|
2875
3788
|
*/
|
|
2876
|
-
function assetHandler(registry, caps) {
|
|
3789
|
+
function assetHandler(ctx, registry, caps) {
|
|
2877
3790
|
const aliases = dirAliases(registry);
|
|
2878
|
-
return (req, res) => {
|
|
2879
|
-
if (!guard(req, res)) return;
|
|
3791
|
+
return ((req, res) => {
|
|
3792
|
+
if (!guard(ctx, req, res)) return;
|
|
2880
3793
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2881
3794
|
res.writeHead(405);
|
|
2882
3795
|
res.end();
|
|
@@ -2935,7 +3848,7 @@ function assetHandler(registry, caps) {
|
|
|
2935
3848
|
file = existsSync(preview) ? preview : void 0;
|
|
2936
3849
|
}
|
|
2937
3850
|
if (synthesized) {
|
|
2938
|
-
const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), "utf8");
|
|
3851
|
+
const body = Buffer.from(JSON.stringify(petEntryView(entry, registry.globalVoice), null, 2), "utf8");
|
|
2939
3852
|
res.writeHead(200, {
|
|
2940
3853
|
"content-type": "application/json; charset=utf-8",
|
|
2941
3854
|
"content-length": String(body.byteLength),
|
|
@@ -2971,7 +3884,7 @@ function assetHandler(registry, caps) {
|
|
|
2971
3884
|
res.end();
|
|
2972
3885
|
return;
|
|
2973
3886
|
}
|
|
2974
|
-
readFile(resolved).then((body) => {
|
|
3887
|
+
return readFile(resolved).then((body) => {
|
|
2975
3888
|
res.writeHead(200, {
|
|
2976
3889
|
"content-type": mimeFor(resolved),
|
|
2977
3890
|
"content-length": String(body.byteLength),
|
|
@@ -2986,7 +3899,7 @@ function assetHandler(registry, caps) {
|
|
|
2986
3899
|
res.writeHead(404);
|
|
2987
3900
|
res.end();
|
|
2988
3901
|
});
|
|
2989
|
-
};
|
|
3902
|
+
});
|
|
2990
3903
|
}
|
|
2991
3904
|
/** Browser-facing base path of the plugin runtime files (pet-center M3). */
|
|
2992
3905
|
const PET_RUNTIME_PREFIX = "/api/pet/runtime";
|
|
@@ -3008,9 +3921,9 @@ const RUNTIME_FILES = {
|
|
|
3008
3921
|
* guidance (the Cubism Core is user-supplied, so its absence is a normal
|
|
3009
3922
|
* state, not an error).
|
|
3010
3923
|
*/
|
|
3011
|
-
function runtimeHandler(roots) {
|
|
3012
|
-
return (req, res) => {
|
|
3013
|
-
if (!guard(req, res)) return;
|
|
3924
|
+
function runtimeHandler(ctx, roots) {
|
|
3925
|
+
return ((req, res) => {
|
|
3926
|
+
if (!guard(ctx, req, res)) return;
|
|
3014
3927
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
3015
3928
|
res.writeHead(405);
|
|
3016
3929
|
res.end();
|
|
@@ -3066,7 +3979,7 @@ function runtimeHandler(roots) {
|
|
|
3066
3979
|
res.end();
|
|
3067
3980
|
return;
|
|
3068
3981
|
}
|
|
3069
|
-
readFile(resolved).then((body) => {
|
|
3982
|
+
return readFile(resolved).then((body) => {
|
|
3070
3983
|
res.writeHead(200, {
|
|
3071
3984
|
"content-type": name.endsWith(".map") ? "application/json" : "application/javascript; charset=utf-8",
|
|
3072
3985
|
"content-length": String(body.byteLength),
|
|
@@ -3081,37 +3994,152 @@ function runtimeHandler(roots) {
|
|
|
3081
3994
|
res.writeHead(404);
|
|
3082
3995
|
res.end();
|
|
3083
3996
|
});
|
|
3997
|
+
});
|
|
3998
|
+
}
|
|
3999
|
+
/**
|
|
4000
|
+
* The decoration asset handler behind '/api/pet/decoration/<id>/<file>'
|
|
4001
|
+
* (pet-center M5, #567). Serves exactly the files a decoration descriptor
|
|
4002
|
+
* declares — decoration.json and the PNG/WebP strip — by exact allow-list
|
|
4003
|
+
* match, with realpath containment and the same size ceilings as pet
|
|
4004
|
+
* assets. Crafted '..' or '.' segments never match the normalized closure.
|
|
4005
|
+
*/
|
|
4006
|
+
function decorationHandler(ctx, registry, caps) {
|
|
4007
|
+
return (req, res) => {
|
|
4008
|
+
if (!guard(ctx, req, res)) return;
|
|
4009
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
4010
|
+
res.writeHead(405);
|
|
4011
|
+
res.end();
|
|
4012
|
+
return;
|
|
4013
|
+
}
|
|
4014
|
+
let pathname;
|
|
4015
|
+
try {
|
|
4016
|
+
pathname = new URL(req.url ?? "/", "http://pet.local").pathname;
|
|
4017
|
+
} catch {
|
|
4018
|
+
res.writeHead(400);
|
|
4019
|
+
res.end();
|
|
4020
|
+
return;
|
|
4021
|
+
}
|
|
4022
|
+
const segments = pathname.split("/").filter((segment) => segment !== "");
|
|
4023
|
+
const prefixSegments = DECORATION_ASSET_PREFIX.split("/").filter((segment) => segment !== "");
|
|
4024
|
+
if (segments.length < prefixSegments.length + 2) {
|
|
4025
|
+
res.writeHead(404);
|
|
4026
|
+
res.end();
|
|
4027
|
+
return;
|
|
4028
|
+
}
|
|
4029
|
+
for (let i = 0; i < prefixSegments.length; i += 1) if (segments[i] !== prefixSegments[i]) {
|
|
4030
|
+
res.writeHead(404);
|
|
4031
|
+
res.end();
|
|
4032
|
+
return;
|
|
4033
|
+
}
|
|
4034
|
+
let id;
|
|
4035
|
+
try {
|
|
4036
|
+
id = decodeURIComponent(segments[prefixSegments.length]);
|
|
4037
|
+
} catch {
|
|
4038
|
+
res.writeHead(400);
|
|
4039
|
+
res.end();
|
|
4040
|
+
return;
|
|
4041
|
+
}
|
|
4042
|
+
const entry = registry.decorationById?.(id);
|
|
4043
|
+
if (entry === void 0) {
|
|
4044
|
+
res.writeHead(404);
|
|
4045
|
+
res.end();
|
|
4046
|
+
return;
|
|
4047
|
+
}
|
|
4048
|
+
const rest = [];
|
|
4049
|
+
for (const segment of segments.slice(prefixSegments.length + 1)) {
|
|
4050
|
+
let decoded;
|
|
4051
|
+
try {
|
|
4052
|
+
decoded = decodeURIComponent(segment);
|
|
4053
|
+
} catch {
|
|
4054
|
+
res.writeHead(400);
|
|
4055
|
+
res.end();
|
|
4056
|
+
return;
|
|
4057
|
+
}
|
|
4058
|
+
rest.push(decoded);
|
|
4059
|
+
}
|
|
4060
|
+
const rel = rest.join("/");
|
|
4061
|
+
if (!entry.servable.includes(rel)) {
|
|
4062
|
+
res.writeHead(404);
|
|
4063
|
+
res.end();
|
|
4064
|
+
return;
|
|
4065
|
+
}
|
|
4066
|
+
const file = join(entry.dir, rel);
|
|
4067
|
+
const resolved = containedRealpath(entry.dir, file);
|
|
4068
|
+
if (resolved === void 0) {
|
|
4069
|
+
res.writeHead(403);
|
|
4070
|
+
res.end();
|
|
4071
|
+
return;
|
|
4072
|
+
}
|
|
4073
|
+
const cap = rel === "decoration.json" ? caps.manifest : caps.image;
|
|
4074
|
+
let stat;
|
|
4075
|
+
try {
|
|
4076
|
+
stat = statSync(resolved);
|
|
4077
|
+
if (stat.size > cap) {
|
|
4078
|
+
res.writeHead(413);
|
|
4079
|
+
res.end();
|
|
4080
|
+
return;
|
|
4081
|
+
}
|
|
4082
|
+
} catch {
|
|
4083
|
+
res.writeHead(404);
|
|
4084
|
+
res.end();
|
|
4085
|
+
return;
|
|
4086
|
+
}
|
|
4087
|
+
const etag = "\"" + stat.size.toString(16) + "-" + Math.round(stat.mtimeMs).toString(16) + "\"";
|
|
4088
|
+
if (req.headers["if-none-match"] === etag) {
|
|
4089
|
+
res.writeHead(304, {
|
|
4090
|
+
etag,
|
|
4091
|
+
"cache-control": "no-cache"
|
|
4092
|
+
});
|
|
4093
|
+
res.end();
|
|
4094
|
+
return;
|
|
4095
|
+
}
|
|
4096
|
+
readFile(resolved).then((body) => {
|
|
4097
|
+
res.writeHead(200, {
|
|
4098
|
+
"content-type": mimeFor(resolved),
|
|
4099
|
+
"content-length": String(body.byteLength),
|
|
4100
|
+
"cache-control": "no-cache",
|
|
4101
|
+
etag
|
|
4102
|
+
});
|
|
4103
|
+
if (req.method === "HEAD") {
|
|
4104
|
+
res.end();
|
|
4105
|
+
return;
|
|
4106
|
+
}
|
|
4107
|
+
res.end(body);
|
|
4108
|
+
}, () => {
|
|
4109
|
+
res.writeHead(404);
|
|
4110
|
+
res.end();
|
|
4111
|
+
});
|
|
3084
4112
|
};
|
|
3085
4113
|
}
|
|
3086
4114
|
/** Build the full route family (API + assets + runtime) for one service. */
|
|
3087
4115
|
function makePetRoutes(deps) {
|
|
3088
|
-
const { service } = deps;
|
|
4116
|
+
const { service, ctx } = deps;
|
|
3089
4117
|
const apiRoutes = [
|
|
3090
|
-
getRoute("/api/pet/state", () => service.state()),
|
|
3091
|
-
getRoute("/api/pet/pets", () => service.pets()),
|
|
3092
|
-
getRoute("/api/pet/diagnostics", () => service.diagnostics()),
|
|
3093
|
-
postRoute("/api/pet/interact", (body) => {
|
|
4118
|
+
getRoute(ctx, "/api/pet/state", () => service.state()),
|
|
4119
|
+
getRoute(ctx, "/api/pet/pets", () => service.pets()),
|
|
4120
|
+
getRoute(ctx, "/api/pet/diagnostics", () => service.diagnostics()),
|
|
4121
|
+
postRoute(ctx, "/api/pet/interact", (body) => {
|
|
3094
4122
|
const kind = body.kind;
|
|
3095
4123
|
if (kind !== "pet" && kind !== "feed") return Promise.reject(/* @__PURE__ */ new Error("invalid-kind"));
|
|
3096
4124
|
return service.interact(kind);
|
|
3097
4125
|
}),
|
|
3098
|
-
postRoute("/api/pet/set-visible", (body) => {
|
|
4126
|
+
postRoute(ctx, "/api/pet/set-visible", (body) => {
|
|
3099
4127
|
const visible = body.visible;
|
|
3100
4128
|
if (typeof visible !== "boolean") return Promise.reject(/* @__PURE__ */ new Error("invalid-visible"));
|
|
3101
4129
|
return service.setVisible(visible);
|
|
3102
4130
|
}),
|
|
3103
|
-
postRoute("/api/pet/set-config", (body) => service.setConfig({
|
|
4131
|
+
postRoute(ctx, "/api/pet/set-config", (body) => service.setConfig({
|
|
3104
4132
|
...typeof body.size === "number" ? { size: body.size } : {},
|
|
3105
4133
|
...typeof body.right === "number" ? { right: body.right } : {},
|
|
3106
4134
|
...typeof body.bottom === "number" ? { bottom: body.bottom } : {},
|
|
3107
4135
|
...typeof body.visible === "boolean" ? { visible: body.visible } : {}
|
|
3108
4136
|
})),
|
|
3109
|
-
postRoute("/api/pet/set-name", (body) => {
|
|
4137
|
+
postRoute(ctx, "/api/pet/set-name", (body) => {
|
|
3110
4138
|
const name = body.name;
|
|
3111
4139
|
if (typeof name !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-name"));
|
|
3112
4140
|
return service.setName(name);
|
|
3113
4141
|
}),
|
|
3114
|
-
postRoute("/api/pet/set-pet", (body) => {
|
|
4142
|
+
postRoute(ctx, "/api/pet/set-pet", (body) => {
|
|
3115
4143
|
const petId = body.petId;
|
|
3116
4144
|
if (typeof petId !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-pet"));
|
|
3117
4145
|
return service.setPetId(petId);
|
|
@@ -3120,20 +4148,26 @@ function makePetRoutes(deps) {
|
|
|
3120
4148
|
const assetRoute = {
|
|
3121
4149
|
kind: "prefix",
|
|
3122
4150
|
path: PET_ASSET_PREFIX,
|
|
3123
|
-
handler: assetHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS)
|
|
4151
|
+
handler: assetHandler(ctx, service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS)
|
|
3124
4152
|
};
|
|
3125
4153
|
const runtimeRoute = {
|
|
3126
4154
|
kind: "prefix",
|
|
3127
4155
|
path: PET_RUNTIME_PREFIX,
|
|
3128
|
-
handler: runtimeHandler({
|
|
4156
|
+
handler: runtimeHandler(ctx, {
|
|
3129
4157
|
runtimeDir: deps.runtimeDir ?? join(dshHome(), "pets", ".runtime"),
|
|
3130
4158
|
vendorDir: deps.vendorDir ?? join(petPackageRoot(import.meta.url), "lib")
|
|
3131
4159
|
})
|
|
3132
4160
|
};
|
|
4161
|
+
const decorationRoute = {
|
|
4162
|
+
kind: "prefix",
|
|
4163
|
+
path: DECORATION_ASSET_PREFIX,
|
|
4164
|
+
handler: decorationHandler(ctx, service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS)
|
|
4165
|
+
};
|
|
3133
4166
|
return [
|
|
3134
4167
|
...apiRoutes,
|
|
3135
4168
|
assetRoute,
|
|
3136
|
-
runtimeRoute
|
|
4169
|
+
runtimeRoute,
|
|
4170
|
+
decorationRoute
|
|
3137
4171
|
];
|
|
3138
4172
|
}
|
|
3139
4173
|
//#endregion
|
|
@@ -3198,7 +4232,8 @@ function makePetSettingsSchema(fallbackPetId) {
|
|
|
3198
4232
|
right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
|
|
3199
4233
|
bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
|
|
3200
4234
|
petId: z.string().default(fallbackPetId),
|
|
3201
|
-
enabled: z.boolean().default(true)
|
|
4235
|
+
enabled: z.boolean().default(true),
|
|
4236
|
+
decorationEnabled: z.boolean().default(true)
|
|
3202
4237
|
});
|
|
3203
4238
|
}
|
|
3204
4239
|
/** Register the pet service and its API + asset routes on the context. */
|
|
@@ -3219,9 +4254,13 @@ function applyImpl(ctx, config = {}) {
|
|
|
3219
4254
|
right: service.display().right,
|
|
3220
4255
|
bottom: service.display().bottom,
|
|
3221
4256
|
petId: service.selectedPetId(),
|
|
3222
|
-
enabled: config.enabled ?? true
|
|
4257
|
+
enabled: config.enabled ?? true,
|
|
4258
|
+
decorationEnabled: config.decorationEnabled ?? true
|
|
3223
4259
|
};
|
|
3224
|
-
const routes = makePetRoutes({
|
|
4260
|
+
const routes = makePetRoutes({
|
|
4261
|
+
service,
|
|
4262
|
+
ctx
|
|
4263
|
+
});
|
|
3225
4264
|
let disposeRoutes;
|
|
3226
4265
|
const syncRoutes = () => {
|
|
3227
4266
|
const enabled = current().enabled ?? true;
|