@linxin666/dsh-pet 0.2.2 → 0.2.4
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 +129 -3
- package/README.zh.md +129 -3
- package/assets/decorations/whale/decoration.json +20 -0
- package/assets/decorations/whale/whale-frames.png +0 -0
- package/assets/whale/pet.json +169 -20
- package/assets/whale-refined/pet.json +17 -2
- 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 +581 -38
- package/lib/client.js.map +1 -1
- package/lib/index.js +1684 -59
- package/lib/live2d-vendor.js +995 -0
- package/lib/live2d-vendor.js.map +1 -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/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +2 -1
- package/lib/types/client/PetSettingsCard.d.ts +14 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +24 -1
- package/lib/types/client/PetSprite.d.ts +7 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +120 -12
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +5 -0
- package/lib/types/client/locales.d.ts +14 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +14 -0
- package/lib/types/client/phase-stream.d.ts +22 -0
- package/lib/types/client/phase-stream.d.ts.map +1 -0
- package/lib/types/client/phase-stream.js +28 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
- package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/runtime.js +63 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
- package/lib/types/client/renderers/live2d.d.ts +52 -0
- package/lib/types/client/renderers/live2d.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d.js +192 -0
- package/lib/types/client/renderers/registry.d.ts +32 -0
- package/lib/types/client/renderers/registry.d.ts.map +1 -0
- package/lib/types/client/renderers/registry.js +49 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- package/lib/types/contracts/renderer.d.ts +50 -0
- package/lib/types/contracts/renderer.d.ts.map +1 -0
- package/lib/types/contracts/renderer.js +15 -0
- 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/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +2 -0
- package/lib/types/manifest-v2.d.ts +129 -0
- package/lib/types/manifest-v2.d.ts.map +1 -0
- package/lib/types/manifest-v2.js +387 -0
- package/lib/types/model3.d.ts +26 -0
- package/lib/types/model3.d.ts.map +1 -0
- package/lib/types/model3.js +96 -0
- package/lib/types/registry.d.ts +112 -5
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +395 -38
- package/lib/types/routes.d.ts +39 -2
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +312 -13
- package/lib/types/service.d.ts +38 -1
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +46 -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 +13 -10
- package/src/chatter.test.ts +89 -2
- package/src/chatter.ts +120 -14
- package/src/client/PetDockEntry.tsx +19 -11
- package/src/client/PetSettingsCard.tsx +57 -0
- package/src/client/PetSprite.test.tsx +265 -12
- package/src/client/PetSprite.tsx +165 -33
- package/src/client/index.ts +6 -0
- package/src/client/locales.ts +14 -0
- package/src/client/phase-stream.test.ts +76 -0
- package/src/client/phase-stream.ts +39 -0
- package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
- package/src/client/renderers/PetRendererSwitch.tsx +48 -0
- package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
- package/src/client/renderers/live2d/runtime.test.ts +61 -0
- package/src/client/renderers/live2d/runtime.ts +119 -0
- package/src/client/renderers/live2d/vendor-entry.ts +14 -0
- package/src/client/renderers/live2d.test.ts +263 -0
- package/src/client/renderers/live2d.ts +231 -0
- package/src/client/renderers/registry.ts +58 -0
- package/src/client/settings-form.ts +8 -6
- package/src/client/settings-section.module.css +23 -0
- package/src/contracts/renderer.ts +54 -0
- 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/index.ts +2 -0
- package/src/manifest-v2.test.ts +251 -0
- package/src/manifest-v2.ts +414 -0
- package/src/model3.test.ts +79 -0
- package/src/model3.ts +91 -0
- package/src/registry.test.ts +438 -2
- package/src/registry.ts +470 -38
- package/src/routes.ts +328 -14
- package/src/service.ts +65 -3
- package/src/voice-pack.test.ts +216 -0
- package/src/voice-pack.ts +413 -0
package/lib/index.js
CHANGED
|
@@ -2,8 +2,8 @@ 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, renameSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
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";
|
|
9
9
|
import { readFile } from "node:fs/promises";
|
|
@@ -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,19 +1456,895 @@ 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
|
+
}
|
|
1760
|
+
/** Renderer kinds the pet center knows how to dispatch (M1 §2). */
|
|
1761
|
+
const PET_RENDERER_KINDS = ["sprite2d", "live2d"];
|
|
1762
|
+
/** The seven ActivityPhase semantics (pet-center owned; M1 §1). */
|
|
1763
|
+
const PET_ACTIVITY_PHASES = [
|
|
1764
|
+
"idle",
|
|
1765
|
+
"waiting",
|
|
1766
|
+
"thinking",
|
|
1767
|
+
"tool",
|
|
1768
|
+
"review",
|
|
1769
|
+
"done",
|
|
1770
|
+
"failed"
|
|
1771
|
+
];
|
|
1772
|
+
const PET_ID_PATTERN$2 = /^[a-z0-9][a-z0-9-]*$/;
|
|
1773
|
+
const PATH_SEGMENT_PATTERN$2 = /^[A-Za-z0-9._-]+$/;
|
|
1774
|
+
const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
1775
|
+
/**
|
|
1776
|
+
* Field allow-lists mirroring contracts/pet-manifest-v2.schema.json. Exported
|
|
1777
|
+
* so the drift test can lock the schema file and this validator together;
|
|
1778
|
+
* the CLI reuses parsePetManifest instead of these.
|
|
1779
|
+
*/
|
|
1780
|
+
const KNOWN_TOP_LEVEL = /* @__PURE__ */ new Set([
|
|
1781
|
+
"$schema",
|
|
1782
|
+
"petManifestVersion",
|
|
1783
|
+
"id",
|
|
1784
|
+
"displayName",
|
|
1785
|
+
"description",
|
|
1786
|
+
"version",
|
|
1787
|
+
"author",
|
|
1788
|
+
"license",
|
|
1789
|
+
"homepage",
|
|
1790
|
+
"renderer",
|
|
1791
|
+
"sprite2d",
|
|
1792
|
+
"live2d",
|
|
1793
|
+
"sequences",
|
|
1794
|
+
"remarks"
|
|
1795
|
+
]);
|
|
1796
|
+
/** sprite2d block field allow-list (drift-locked to the schema file). */
|
|
1797
|
+
const KNOWN_SPRITE2D = /* @__PURE__ */ new Set([
|
|
1798
|
+
"spritesheetPath",
|
|
1799
|
+
"cell",
|
|
1800
|
+
"columns",
|
|
1801
|
+
"atlasRows",
|
|
1802
|
+
"frames",
|
|
1803
|
+
"tracks"
|
|
1804
|
+
]);
|
|
1805
|
+
/** live2d block field allow-list (drift-locked to the schema file). */
|
|
1806
|
+
const KNOWN_LIVE2D = /* @__PURE__ */ new Set([
|
|
1807
|
+
"model",
|
|
1808
|
+
"scale",
|
|
1809
|
+
"translate",
|
|
1810
|
+
"motions",
|
|
1811
|
+
"expressions",
|
|
1812
|
+
"hitAreas",
|
|
1813
|
+
"lipSync"
|
|
1814
|
+
]);
|
|
1815
|
+
var Diagnostics$1 = class {
|
|
1816
|
+
list = [];
|
|
1817
|
+
source;
|
|
1818
|
+
constructor(source) {
|
|
1819
|
+
this.source = source;
|
|
1820
|
+
}
|
|
1821
|
+
error(message) {
|
|
1822
|
+
this.list.push({
|
|
1823
|
+
level: "error",
|
|
1824
|
+
message: this.source + ": " + message
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
warn(message) {
|
|
1828
|
+
this.list.push({
|
|
1829
|
+
level: "warning",
|
|
1830
|
+
message: this.source + ": " + message
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
get hasErrors() {
|
|
1834
|
+
return this.list.some((d) => d.level === "error");
|
|
1835
|
+
}
|
|
1836
|
+
};
|
|
1837
|
+
function isRecord$1(value) {
|
|
1838
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1839
|
+
}
|
|
1840
|
+
function unknownKeys$1(source, known) {
|
|
1841
|
+
return Object.keys(source).filter((key) => !known.has(key));
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Validate a manifest-relative asset path: no absolute paths, no backslashes,
|
|
1845
|
+
* no traversal, plain safe segments only. Returns the normalized path.
|
|
1846
|
+
*/
|
|
1847
|
+
function safeManifestPath(raw) {
|
|
1848
|
+
if (typeof raw !== "string" || raw.trim() === "") return void 0;
|
|
1849
|
+
const value = raw.trim();
|
|
1850
|
+
if (isAbsolute(value) || value.includes("\\") || /^[a-z][a-z0-9+.-]*:/i.test(value)) return void 0;
|
|
1851
|
+
const segments = value.split("/").filter((segment) => segment !== "");
|
|
1852
|
+
if (segments.length === 0) return void 0;
|
|
1853
|
+
if (segments.some((segment) => segment === "." || segment === ".." || !PATH_SEGMENT_PATTERN$2.test(segment))) return void 0;
|
|
1854
|
+
return segments.join("/");
|
|
1855
|
+
}
|
|
1856
|
+
function parseStringBlock(record, key, diag, required) {
|
|
1857
|
+
const value = record[key];
|
|
1858
|
+
if (value === void 0) {
|
|
1859
|
+
if (required) diag.error("missing required field " + JSON.stringify(key));
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
1863
|
+
diag.error("field " + JSON.stringify(key) + " must be a non-empty string");
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
return value.trim();
|
|
1867
|
+
}
|
|
1868
|
+
/** Validate the phase-keyed string map shape shared by motions/expressions. */
|
|
1869
|
+
function parsePhaseStringMap(raw, field, diag) {
|
|
1870
|
+
if (raw === void 0) return void 0;
|
|
1871
|
+
if (!isRecord$1(raw)) {
|
|
1872
|
+
diag.error("field " + JSON.stringify(field) + " must be an object keyed by activity phase");
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
const result = {};
|
|
1876
|
+
for (const [phase, value] of Object.entries(raw)) {
|
|
1877
|
+
if (!PET_ACTIVITY_PHASES.includes(phase)) {
|
|
1878
|
+
diag.error(field + ": unknown activity phase " + JSON.stringify(phase));
|
|
1879
|
+
continue;
|
|
1880
|
+
}
|
|
1881
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
1882
|
+
diag.error(field + "." + phase + " must be a non-empty string");
|
|
1883
|
+
continue;
|
|
1884
|
+
}
|
|
1885
|
+
result[phase] = value.trim();
|
|
1886
|
+
}
|
|
1887
|
+
return result;
|
|
1888
|
+
}
|
|
1889
|
+
/** Structural gate for sequences: content stays warn-and-drop (registry's job). */
|
|
1890
|
+
function parseSequences(raw, diag) {
|
|
1891
|
+
if (raw === void 0) return void 0;
|
|
1892
|
+
if (!isRecord$1(raw)) {
|
|
1893
|
+
diag.warn("sequences must be an object keyed by activity phase; ignoring");
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
const sequences = {};
|
|
1897
|
+
for (const [phase, value] of Object.entries(raw)) {
|
|
1898
|
+
if (!PET_ACTIVITY_PHASES.includes(phase)) {
|
|
1899
|
+
diag.warn("sequences: unknown activity phase " + JSON.stringify(phase) + "; entry dropped");
|
|
1900
|
+
continue;
|
|
1901
|
+
}
|
|
1902
|
+
if (!Array.isArray(value) || value.length < 5 || value.some((item) => typeof item !== "string")) {
|
|
1903
|
+
diag.warn("sequences." + phase + " must be an array of at least 5 animation names; entry dropped");
|
|
1904
|
+
continue;
|
|
1905
|
+
}
|
|
1906
|
+
sequences[phase] = value;
|
|
1907
|
+
}
|
|
1908
|
+
return Object.keys(sequences).length === 0 ? void 0 : sequences;
|
|
1909
|
+
}
|
|
1910
|
+
function parseSprite2dBlock(raw, diag) {
|
|
1911
|
+
if (!isRecord$1(raw)) {
|
|
1912
|
+
diag.error("renderer sprite2d requires a \"sprite2d\" block object");
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
const extra = unknownKeys$1(raw, KNOWN_SPRITE2D);
|
|
1916
|
+
if (extra.length > 0) diag.error("sprite2d: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
|
|
1917
|
+
const spritesheetPath = safeManifestPath(raw.spritesheetPath);
|
|
1918
|
+
if (spritesheetPath === void 0) diag.error("sprite2d.spritesheetPath must be a safe manifest-relative path");
|
|
1919
|
+
const block = { spritesheetPath: spritesheetPath ?? "" };
|
|
1920
|
+
if (raw.cell !== void 0) if (!isRecord$1(raw.cell)) diag.error("sprite2d.cell must be an object { width?, height? }");
|
|
1921
|
+
else block.cell = raw.cell;
|
|
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");
|
|
1923
|
+
else block.columns = raw.columns;
|
|
1924
|
+
if (raw.atlasRows !== void 0) if (typeof raw.atlasRows !== "number" || !Number.isInteger(raw.atlasRows) || raw.atlasRows < 1) diag.error("sprite2d.atlasRows must be a positive integer");
|
|
1925
|
+
else block.atlasRows = raw.atlasRows;
|
|
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");
|
|
1927
|
+
else block.frames = raw.frames;
|
|
1928
|
+
if (raw.tracks !== void 0) if (!isRecord$1(raw.tracks)) diag.error("sprite2d.tracks must be an object keyed by animation");
|
|
1929
|
+
else block.tracks = raw.tracks;
|
|
1930
|
+
return diag.hasErrors ? void 0 : block;
|
|
1931
|
+
}
|
|
1932
|
+
function parseLive2dBlock(raw, diag) {
|
|
1933
|
+
if (!isRecord$1(raw)) {
|
|
1934
|
+
diag.error("renderer live2d requires a \"live2d\" block object");
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
const extra = unknownKeys$1(raw, KNOWN_LIVE2D);
|
|
1938
|
+
if (extra.length > 0) diag.error("live2d: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
|
|
1939
|
+
const model = safeManifestPath(raw.model);
|
|
1940
|
+
if (model === void 0) diag.error("live2d.model must be a safe manifest-relative path to a .model3.json");
|
|
1941
|
+
else if (!model.endsWith(".model3.json")) diag.error("live2d.model must point at a .model3.json file");
|
|
1942
|
+
const motions = parsePhaseStringMap(raw.motions, "live2d.motions", diag);
|
|
1943
|
+
if (raw.motions === void 0) diag.error("live2d.motions is required (at least an \"idle\" group)");
|
|
1944
|
+
else if (motions !== void 0 && motions.idle === void 0) diag.error("live2d.motions.idle is required (unmapped phases fall back to it)");
|
|
1945
|
+
const block = {
|
|
1946
|
+
model: model ?? "",
|
|
1947
|
+
motions: motions ?? { idle: "" }
|
|
1948
|
+
};
|
|
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]");
|
|
1950
|
+
else block.scale = raw.scale;
|
|
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 }");
|
|
1952
|
+
else block.translate = raw.translate;
|
|
1953
|
+
const expressions = parsePhaseStringMap(raw.expressions, "live2d.expressions", diag);
|
|
1954
|
+
if (expressions !== void 0) block.expressions = expressions;
|
|
1955
|
+
if (raw.hitAreas !== void 0) if (!Array.isArray(raw.hitAreas) || raw.hitAreas.some((v) => typeof v !== "string" || v.trim() === "")) diag.error("live2d.hitAreas must be an array of non-empty strings");
|
|
1956
|
+
else block.hitAreas = raw.hitAreas;
|
|
1957
|
+
if (raw.lipSync !== void 0) if (typeof raw.lipSync !== "boolean") diag.error("live2d.lipSync must be a boolean");
|
|
1958
|
+
else block.lipSync = raw.lipSync;
|
|
1959
|
+
return diag.hasErrors ? void 0 : block;
|
|
1960
|
+
}
|
|
1961
|
+
/** v1 compat read: map the legacy flat manifest onto the v2 sprite2d shape. */
|
|
1962
|
+
function compatV1(source, diag) {
|
|
1963
|
+
const id = parseStringBlock(source, "id", diag, true);
|
|
1964
|
+
if (id !== void 0 && !PET_ID_PATTERN$2.test(id)) diag.error("id " + JSON.stringify(id) + " is not a lowercase kebab id");
|
|
1965
|
+
const displayName = typeof source.displayName === "string" && source.displayName.trim() !== "" ? source.displayName.trim() : id;
|
|
1966
|
+
const spritesheetPath = safeManifestPath(source.spritesheetPath === void 0 ? "spritesheet.webp" : source.spritesheetPath);
|
|
1967
|
+
if (spritesheetPath === void 0) diag.error("spritesheetPath " + JSON.stringify(String(source.spritesheetPath)) + " is not a safe relative path");
|
|
1968
|
+
if (source.license === void 0) diag.warn("v1 compat read: no license field; run scripts/dsh-pet-migrate-v2 to migrate this pet");
|
|
1969
|
+
const sprite2d = { spritesheetPath: spritesheetPath ?? "spritesheet.webp" };
|
|
1970
|
+
if (isRecord$1(source.cell)) sprite2d.cell = source.cell;
|
|
1971
|
+
if (typeof source.columns === "number") sprite2d.columns = source.columns;
|
|
1972
|
+
if (Array.isArray(source.frames)) sprite2d.frames = source.frames;
|
|
1973
|
+
if (isRecord$1(source.tracks)) sprite2d.tracks = source.tracks;
|
|
1974
|
+
if (source.spriteVersionNumber === 2) sprite2d.atlasRows = 11;
|
|
1975
|
+
const manifest = {
|
|
1976
|
+
petManifestVersion: 2,
|
|
1977
|
+
id: id ?? "",
|
|
1978
|
+
displayName: displayName ?? "",
|
|
1979
|
+
renderer: "sprite2d",
|
|
1980
|
+
sprite2d
|
|
1981
|
+
};
|
|
1982
|
+
if (typeof source.description === "string" && source.description.trim() !== "") manifest.description = source.description.trim();
|
|
1983
|
+
if (typeof source.license === "string" && source.license.trim() !== "") manifest.license = source.license.trim();
|
|
1984
|
+
const sequences = parseSequences(source.sequences, diag);
|
|
1985
|
+
if (sequences !== void 0) manifest.sequences = sequences;
|
|
1986
|
+
if (source.remarks !== void 0) manifest.remarks = source.remarks;
|
|
1987
|
+
return diag.hasErrors ? void 0 : manifest;
|
|
1988
|
+
}
|
|
1989
|
+
/** Strict v2 validation (fail-closed on structure). */
|
|
1990
|
+
function parseV2(source, diag) {
|
|
1991
|
+
const extra = unknownKeys$1(source, KNOWN_TOP_LEVEL);
|
|
1992
|
+
if (extra.length > 0) diag.error("unknown top-level field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
|
|
1993
|
+
if (source.petManifestVersion !== 2) diag.error("petManifestVersion must be 2 (got " + JSON.stringify(source.petManifestVersion) + ")");
|
|
1994
|
+
const id = parseStringBlock(source, "id", diag, true);
|
|
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");
|
|
1996
|
+
const displayName = parseStringBlock(source, "displayName", diag, true);
|
|
1997
|
+
const license = parseStringBlock(source, "license", diag, true);
|
|
1998
|
+
const rendererRaw = source.renderer === void 0 ? "sprite2d" : source.renderer;
|
|
1999
|
+
if (!PET_RENDERER_KINDS.includes(rendererRaw)) diag.error("unknown renderer " + JSON.stringify(rendererRaw) + "; expected one of " + PET_RENDERER_KINDS.join(", "));
|
|
2000
|
+
const renderer = rendererRaw;
|
|
2001
|
+
const manifest = {
|
|
2002
|
+
petManifestVersion: 2,
|
|
2003
|
+
id: id ?? "",
|
|
2004
|
+
displayName: displayName ?? "",
|
|
2005
|
+
renderer
|
|
2006
|
+
};
|
|
2007
|
+
if (license !== void 0) manifest.license = license;
|
|
2008
|
+
if (source.description !== void 0) if (typeof source.description !== "string" || source.description.length > 500) diag.error("description must be a string of at most 500 chars");
|
|
2009
|
+
else manifest.description = source.description;
|
|
2010
|
+
if (source.version !== void 0) if (typeof source.version !== "string" || !SEMVER_PATTERN.test(source.version)) diag.error("version must be a semver string (x.y.z)");
|
|
2011
|
+
else manifest.version = source.version;
|
|
2012
|
+
if (source.author !== void 0) if (typeof source.author !== "string" || source.author.length > 128) diag.error("author must be a string of at most 128 chars");
|
|
2013
|
+
else manifest.author = source.author;
|
|
2014
|
+
if (source.homepage !== void 0) if (typeof source.homepage !== "string") diag.error("homepage must be a string URL");
|
|
2015
|
+
else manifest.homepage = source.homepage;
|
|
2016
|
+
if (renderer === "sprite2d") {
|
|
2017
|
+
const block = parseSprite2dBlock(source.sprite2d, diag);
|
|
2018
|
+
if (block !== void 0) manifest.sprite2d = block;
|
|
2019
|
+
if (source.live2d !== void 0) diag.error("renderer sprite2d must not declare a live2d block");
|
|
2020
|
+
} else if (renderer === "live2d") {
|
|
2021
|
+
const block = parseLive2dBlock(source.live2d, diag);
|
|
2022
|
+
if (block !== void 0) manifest.live2d = block;
|
|
2023
|
+
if (source.sprite2d !== void 0) diag.error("renderer live2d must not declare a sprite2d block");
|
|
2024
|
+
}
|
|
2025
|
+
const sequences = parseSequences(source.sequences, diag);
|
|
2026
|
+
if (sequences !== void 0) manifest.sequences = sequences;
|
|
2027
|
+
if (source.remarks !== void 0 && !isRecord$1(source.remarks)) diag.error("remarks must be an object of remark pools");
|
|
2028
|
+
else if (source.remarks !== void 0) manifest.remarks = source.remarks;
|
|
2029
|
+
return diag.hasErrors ? void 0 : manifest;
|
|
2030
|
+
}
|
|
2031
|
+
/**
|
|
2032
|
+
* Parse one pet manifest: v1 (no petManifestVersion) is compat-read as a
|
|
2033
|
+
* sprite2d pet with a migration hint; v2 is validated fail-closed. The parse
|
|
2034
|
+
* never throws — every failure comes back as structured diagnostics.
|
|
2035
|
+
* @param raw - the parsed pet.json value.
|
|
2036
|
+
* @param sourceLabel - human-readable origin for diagnostics (dir or file).
|
|
2037
|
+
*/
|
|
2038
|
+
function parsePetManifest(raw, sourceLabel) {
|
|
2039
|
+
const diag = new Diagnostics$1(sourceLabel);
|
|
2040
|
+
if (!isRecord$1(raw)) {
|
|
2041
|
+
diag.error("manifest is not an object");
|
|
2042
|
+
return {
|
|
2043
|
+
ok: false,
|
|
2044
|
+
diagnostics: diag.list
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
if (raw.petManifestVersion === void 0) {
|
|
2048
|
+
const manifest = compatV1(raw, diag);
|
|
2049
|
+
if (manifest === void 0) return {
|
|
2050
|
+
ok: false,
|
|
2051
|
+
diagnostics: diag.list
|
|
2052
|
+
};
|
|
2053
|
+
diag.warn("v1 compat read: manifest treated as renderer \"sprite2d\"; run scripts/dsh-pet-migrate-v2 to migrate");
|
|
2054
|
+
return {
|
|
2055
|
+
ok: true,
|
|
2056
|
+
manifest,
|
|
2057
|
+
migrated: "v1-compat",
|
|
2058
|
+
diagnostics: diag.list
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
const manifest = parseV2(raw, diag);
|
|
2062
|
+
if (manifest === void 0) return {
|
|
2063
|
+
ok: false,
|
|
2064
|
+
diagnostics: diag.list
|
|
2065
|
+
};
|
|
2066
|
+
return {
|
|
2067
|
+
ok: true,
|
|
2068
|
+
manifest,
|
|
2069
|
+
migrated: void 0,
|
|
2070
|
+
diagnostics: diag.list
|
|
2071
|
+
};
|
|
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/contracts/status-decoration.ts
|
|
2256
|
+
/** Contract version decorations declare against (independent of manifests). */
|
|
2257
|
+
const PET_DECORATION_API_VERSION = "x-org.linxin666.pet-center/status-decoration-v1";
|
|
2258
|
+
//#endregion
|
|
2259
|
+
//#region src/model3.ts
|
|
2260
|
+
/**
|
|
2261
|
+
* Live2D .model3.json reference closure — the set of files a model declares
|
|
2262
|
+
* (pet-center M2, issue #623). The host asset route only ever serves a pet's
|
|
2263
|
+
* declared manifest, its declared primary assets, and this closure; the CLI
|
|
2264
|
+
* validator reuses the same extractor so an install-time check proves the
|
|
2265
|
+
* serving set is complete.
|
|
2266
|
+
*
|
|
2267
|
+
* Cubism file family: Moc (.moc3), Textures (images), Physics (.physics3.json),
|
|
2268
|
+
* Pose (.pose3.json), DisplayInfo (.cdi3.json), Expressions[].File
|
|
2269
|
+
* (.exp3.json), Motions.<group>[].File (.motion3.json), UserData
|
|
2270
|
+
* (.userdata3.json). Every reference must be a safe manifest-relative path
|
|
2271
|
+
* (safeManifestPath); unsafe entries make the model unloadable.
|
|
2272
|
+
*
|
|
2273
|
+
* Erasable-syntax-only: scripts/ import this under node strip-only mode.
|
|
2274
|
+
* @module @linxin666/dsh-pet/model3
|
|
2275
|
+
*/
|
|
2276
|
+
/** Collect the safe relative paths one model3.json references. */
|
|
2277
|
+
function collectModel3References(model3) {
|
|
2278
|
+
const errors = [];
|
|
2279
|
+
if (typeof model3 !== "object" || model3 === null) return {
|
|
2280
|
+
references: [],
|
|
2281
|
+
errors: ["model3.json is not an object"]
|
|
2282
|
+
};
|
|
2283
|
+
const fileReferences = model3.FileReferences;
|
|
2284
|
+
if (typeof fileReferences !== "object" || fileReferences === null) return {
|
|
2285
|
+
references: [],
|
|
2286
|
+
errors: ["model3.json has no FileReferences"]
|
|
2287
|
+
};
|
|
2288
|
+
const refs = fileReferences;
|
|
2289
|
+
const collected = /* @__PURE__ */ new Set();
|
|
2290
|
+
const push = (raw, field) => {
|
|
2291
|
+
const safe = safeManifestPath(raw);
|
|
2292
|
+
if (safe === void 0) {
|
|
2293
|
+
errors.push(field + " is not a safe relative path: " + JSON.stringify(String(raw)));
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
collected.add(safe);
|
|
2297
|
+
};
|
|
2298
|
+
if (refs.Moc !== void 0) push(refs.Moc, "FileReferences.Moc");
|
|
2299
|
+
if (Array.isArray(refs.Textures)) refs.Textures.forEach((texture, index) => push(texture, "FileReferences.Textures[" + index + "]"));
|
|
2300
|
+
for (const scalar of [
|
|
2301
|
+
"Physics",
|
|
2302
|
+
"Pose",
|
|
2303
|
+
"DisplayInfo",
|
|
2304
|
+
"UserData"
|
|
2305
|
+
]) if (refs[scalar] !== void 0) push(refs[scalar], "FileReferences." + scalar);
|
|
2306
|
+
if (Array.isArray(refs.Expressions)) refs.Expressions.forEach((expression, index) => {
|
|
2307
|
+
const file = typeof expression === "object" && expression !== null ? expression.File : void 0;
|
|
2308
|
+
if (file !== void 0) push(file, "FileReferences.Expressions[" + index + "].File");
|
|
2309
|
+
});
|
|
2310
|
+
if (typeof refs.Motions === "object" && refs.Motions !== null) for (const [group, motions] of Object.entries(refs.Motions)) {
|
|
2311
|
+
if (!Array.isArray(motions)) {
|
|
2312
|
+
errors.push("FileReferences.Motions." + group + " is not an array");
|
|
2313
|
+
continue;
|
|
2314
|
+
}
|
|
2315
|
+
motions.forEach((motion, index) => {
|
|
2316
|
+
const file = typeof motion === "object" && motion !== null ? motion.File : void 0;
|
|
2317
|
+
if (file !== void 0) push(file, "FileReferences.Motions." + group + "[" + index + "].File");
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
return {
|
|
2321
|
+
references: [...collected].sort(),
|
|
2322
|
+
errors
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
1376
2325
|
//#endregion
|
|
1377
2326
|
//#region src/registry.ts
|
|
1378
2327
|
/**
|
|
1379
2328
|
* Pet registry — the multi-pet contract. One pet is a directory holding a
|
|
1380
2329
|
* 'pet.json' manifest plus an atlas image; nothing else is required, and no
|
|
1381
|
-
* host or client code changes when a pet is added. The registry scans
|
|
2330
|
+
* host or client code changes when a pet is added. The registry scans four
|
|
1382
2331
|
* sources, later sources overriding earlier ones on an id collision:
|
|
1383
2332
|
*
|
|
1384
2333
|
* 1. the package's own 'assets' subdirectories (built-in pets);
|
|
1385
|
-
* 2. '${CODEX_HOME:-~/.codex}/pets' subdirectories (hatch-pet custom pets
|
|
1386
|
-
*
|
|
2334
|
+
* 2. '${CODEX_HOME:-~/.codex}/pets' subdirectories (hatch-pet custom pets,
|
|
2335
|
+
* legacy source kept readable);
|
|
2336
|
+
* 3. '$DSH_HOME/pets' subdirectories (the pet-center user directory);
|
|
2337
|
+
* 4. 'PetConfig.pets' manifests composed by the embedding application
|
|
1387
2338
|
* (highest precedence).
|
|
1388
2339
|
*
|
|
2340
|
+
* Manifests are parsed through manifest-v2 (pet-center M2, issue #623): v1
|
|
2341
|
+
* manifests are compat-read as sprite2d, v2 manifests validate fail-closed,
|
|
2342
|
+
* and structured diagnostics ride alongside the legacy warnings. Live2d
|
|
2343
|
+
* entries (pet-center M3) list like any other pet: the entry carries the
|
|
2344
|
+
* validated live2d block plus the model's reference closure (the servable
|
|
2345
|
+
* set the asset route allows), and a model3.json that is unreadable or
|
|
2346
|
+
* declares unsafe references rejects the entry with an error diagnostic.
|
|
2347
|
+
*
|
|
1389
2348
|
* The manifest follows the Codex/hatch-pet contract (8 columns x 9 rows of
|
|
1390
2349
|
* 192x208 cells, the 9-state row order below). Legacy whale-girl manifests
|
|
1391
2350
|
* that only carry 'frames' keep working: geometry, per-row frame counts and
|
|
@@ -1596,6 +2555,33 @@ function normalizeSequences(raw, id, warn) {
|
|
|
1596
2555
|
return Object.keys(sequences).length === 0 ? void 0 : sequences;
|
|
1597
2556
|
}
|
|
1598
2557
|
/**
|
|
2558
|
+
* Build the fully resolved animation tracks from the contract defaults plus
|
|
2559
|
+
* optional per-track overrides. Shared by the sprite2d resolver and the
|
|
2560
|
+
* live2d entry builder (which fills the sprite fields with contract
|
|
2561
|
+
* defaults so the flat PetDefinition shape holds for every renderer).
|
|
2562
|
+
*/
|
|
2563
|
+
function buildTracks(rows, columns, trackOverrides, warn) {
|
|
2564
|
+
const tracks = {};
|
|
2565
|
+
for (const [row, animation] of PET_ROW_ORDER.entries()) {
|
|
2566
|
+
const pattern = DEFAULT_TRACK_PATTERNS[animation];
|
|
2567
|
+
const override = trackOverrides[animation];
|
|
2568
|
+
const durations = Array.isArray(override?.durations) && override.durations.length > 0 ? override.durations.filter((value) => typeof value === "number" && Number.isFinite(value) && value > 0) : pattern.durations;
|
|
2569
|
+
if (durations.length === 0) {
|
|
2570
|
+
warn("track " + animation + " carries no usable durations");
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
const frameCount = Math.max(1, Math.min(rows[row], columns));
|
|
2574
|
+
const sized = durations.length >= frameCount ? durations.slice(0, frameCount) : Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]);
|
|
2575
|
+
tracks[animation] = {
|
|
2576
|
+
frames: Array.from({ length: frameCount }, (_, index) => index),
|
|
2577
|
+
durations: sized,
|
|
2578
|
+
loop: typeof override?.loop === "boolean" ? override.loop : pattern.loop,
|
|
2579
|
+
...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 }
|
|
2580
|
+
};
|
|
2581
|
+
}
|
|
2582
|
+
return tracks;
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
1599
2585
|
* Normalize one parsed manifest into a renderable pet entry, or undefined
|
|
1600
2586
|
* (with a warning recorded) when the manifest violates the contract.
|
|
1601
2587
|
*/
|
|
@@ -1634,29 +2620,14 @@ function resolvePetManifest(raw, dir, options = {}) {
|
|
|
1634
2620
|
});
|
|
1635
2621
|
const remarks = normalizePetRemarks(source.remarks, (message) => warn("manifest " + id + ": " + message));
|
|
1636
2622
|
const sequences = normalizeSequences(source.sequences, id, warn);
|
|
1637
|
-
const
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
const pattern = DEFAULT_TRACK_PATTERNS[animation];
|
|
1641
|
-
const override = trackOverrides[animation];
|
|
1642
|
-
const durations = Array.isArray(override?.durations) && override.durations.length > 0 ? override.durations.filter((value) => typeof value === "number" && Number.isFinite(value) && value > 0) : pattern.durations;
|
|
1643
|
-
if (durations.length === 0) {
|
|
1644
|
-
warn("manifest " + id + ": track " + animation + " carries no usable durations");
|
|
1645
|
-
return;
|
|
1646
|
-
}
|
|
1647
|
-
const frameCount = Math.max(1, Math.min(rows[row], columns));
|
|
1648
|
-
const sized = durations.length >= frameCount ? durations.slice(0, frameCount) : Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]);
|
|
1649
|
-
tracks[animation] = {
|
|
1650
|
-
frames: Array.from({ length: frameCount }, (_, index) => index),
|
|
1651
|
-
durations: sized,
|
|
1652
|
-
loop: typeof override?.loop === "boolean" ? override.loop : pattern.loop,
|
|
1653
|
-
...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 }
|
|
1654
|
-
};
|
|
1655
|
-
}
|
|
2623
|
+
const tracks = buildTracks(rows, columns, typeof source.tracks === "object" && source.tracks !== null ? source.tracks : {}, (message) => warn("manifest " + id + ": " + message));
|
|
2624
|
+
if (tracks === void 0) return void 0;
|
|
2625
|
+
const sheet = spritesheetPath.join("/");
|
|
1656
2626
|
return {
|
|
1657
2627
|
id,
|
|
1658
2628
|
displayName,
|
|
1659
2629
|
description,
|
|
2630
|
+
renderer: "sprite2d",
|
|
1660
2631
|
cell,
|
|
1661
2632
|
columns,
|
|
1662
2633
|
rows,
|
|
@@ -1666,7 +2637,106 @@ function resolvePetManifest(raw, dir, options = {}) {
|
|
|
1666
2637
|
atlasUrl: assetUrl(assetPrefix, id, spritesheet),
|
|
1667
2638
|
manifestUrl: assetUrl(assetPrefix, id, "pet.json"),
|
|
1668
2639
|
dir,
|
|
1669
|
-
spritesheetPath:
|
|
2640
|
+
spritesheetPath: sheet,
|
|
2641
|
+
servable: [sheet],
|
|
2642
|
+
...remarks === void 0 ? {} : { remarks }
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
/**
|
|
2646
|
+
* Adapt a validated v2 manifest's sprite2d block onto the legacy flat shape
|
|
2647
|
+
* the established resolver consumes (pet-center M2 P2). The legacy resolver
|
|
2648
|
+
* only expresses 9-row (default) and 11-row (spriteVersionNumber 2) atlases,
|
|
2649
|
+
* so other atlasRows values are rejected here with a diagnostic.
|
|
2650
|
+
*/
|
|
2651
|
+
function flattenV2Sprite2d(manifest) {
|
|
2652
|
+
const block = manifest.sprite2d;
|
|
2653
|
+
if (block === void 0) return void 0;
|
|
2654
|
+
const legacy = {
|
|
2655
|
+
id: manifest.id,
|
|
2656
|
+
displayName: manifest.displayName,
|
|
2657
|
+
spritesheetPath: block.spritesheetPath
|
|
2658
|
+
};
|
|
2659
|
+
if (manifest.description !== void 0) legacy.description = manifest.description;
|
|
2660
|
+
if (block.cell !== void 0) legacy.cell = block.cell;
|
|
2661
|
+
if (block.columns !== void 0) legacy.columns = block.columns;
|
|
2662
|
+
if (block.frames !== void 0) legacy.frames = block.frames;
|
|
2663
|
+
if (block.tracks !== void 0) legacy.tracks = block.tracks;
|
|
2664
|
+
if (block.atlasRows !== void 0) {
|
|
2665
|
+
if (block.atlasRows === 11) legacy.spriteVersionNumber = 2;
|
|
2666
|
+
else if (block.atlasRows !== 9) return void 0;
|
|
2667
|
+
}
|
|
2668
|
+
if (manifest.sequences !== void 0) legacy.sequences = manifest.sequences;
|
|
2669
|
+
if (manifest.remarks !== void 0) legacy.remarks = manifest.remarks;
|
|
2670
|
+
return legacy;
|
|
2671
|
+
}
|
|
2672
|
+
/**
|
|
2673
|
+
* Resolve a validated live2d manifest into a renderable entry (pet-center
|
|
2674
|
+
* M3). The model3.json is read at scan time: its reference closure becomes
|
|
2675
|
+
* the entry's servable set (the asset route's allow-list), and a model that
|
|
2676
|
+
* is unreadable or declares unsafe references rejects the entry fail-closed
|
|
2677
|
+
* with an error diagnostic. Closure files missing on disk warn but keep the
|
|
2678
|
+
* entry listed — the client renderer's diagnostic card reports the broken
|
|
2679
|
+
* render, matching the registry's never-throw philosophy (install-time
|
|
2680
|
+
* strictness belongs to the CLI validator). The sprite fields carry contract
|
|
2681
|
+
* defaults: the chrome sizes live2d pets off 'display.size', not the atlas.
|
|
2682
|
+
*/
|
|
2683
|
+
function resolveLive2dEntry(manifest, dir, options) {
|
|
2684
|
+
const assetPrefix = options.assetPrefix ?? "/pet";
|
|
2685
|
+
const record = (level, message) => {
|
|
2686
|
+
options.diagnostics?.push({
|
|
2687
|
+
level,
|
|
2688
|
+
source: dir,
|
|
2689
|
+
message
|
|
2690
|
+
});
|
|
2691
|
+
options.warnings?.push(message);
|
|
2692
|
+
};
|
|
2693
|
+
const block = manifest.live2d;
|
|
2694
|
+
if (block === void 0) {
|
|
2695
|
+
record("error", "pet " + manifest.id + ": renderer live2d requires a live2d block");
|
|
2696
|
+
return;
|
|
2697
|
+
}
|
|
2698
|
+
let model3;
|
|
2699
|
+
try {
|
|
2700
|
+
model3 = JSON.parse(readFileSync(join(dir, block.model), "utf8"));
|
|
2701
|
+
} catch (error) {
|
|
2702
|
+
record("error", "pet " + manifest.id + ": live2d model " + block.model + " is not readable: " + (error instanceof Error ? error.message : String(error)));
|
|
2703
|
+
return;
|
|
2704
|
+
}
|
|
2705
|
+
const { references, errors } = collectModel3References(model3);
|
|
2706
|
+
if (errors.length > 0) {
|
|
2707
|
+
for (const message of errors) record("error", "pet " + manifest.id + ": live2d model " + block.model + ": " + message);
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
for (const reference of references) if (!existsSync(join(dir, reference))) record("warning", "pet " + manifest.id + ": live2d closure file missing: " + reference);
|
|
2711
|
+
const tracks = buildTracks(DEFAULT_FRAME_COUNTS, 8, {}, (message) => record("warning", "pet " + manifest.id + ": " + message));
|
|
2712
|
+
if (tracks === void 0) return void 0;
|
|
2713
|
+
const remarks = normalizePetRemarks(manifest.remarks, (message) => record("warning", "pet " + manifest.id + ": " + message));
|
|
2714
|
+
const modelUrl = assetUrl(assetPrefix, manifest.id, block.model);
|
|
2715
|
+
const live2d = {
|
|
2716
|
+
modelUrl,
|
|
2717
|
+
modelPath: block.model,
|
|
2718
|
+
...block.scale === void 0 ? {} : { scale: block.scale },
|
|
2719
|
+
...block.translate === void 0 ? {} : { translate: block.translate },
|
|
2720
|
+
motions: block.motions,
|
|
2721
|
+
...block.expressions === void 0 ? {} : { expressions: block.expressions },
|
|
2722
|
+
...block.hitAreas === void 0 ? {} : { hitAreas: block.hitAreas }
|
|
2723
|
+
};
|
|
2724
|
+
return {
|
|
2725
|
+
id: manifest.id,
|
|
2726
|
+
displayName: manifest.displayName,
|
|
2727
|
+
description: manifest.description ?? "",
|
|
2728
|
+
renderer: "live2d",
|
|
2729
|
+
live2d,
|
|
2730
|
+
cell: { ...DEFAULT_PET_CELL },
|
|
2731
|
+
columns: 8,
|
|
2732
|
+
rows: [...DEFAULT_FRAME_COUNTS],
|
|
2733
|
+
atlasRows: 9,
|
|
2734
|
+
tracks,
|
|
2735
|
+
atlasUrl: modelUrl,
|
|
2736
|
+
manifestUrl: assetUrl(assetPrefix, manifest.id, "pet.json"),
|
|
2737
|
+
dir,
|
|
2738
|
+
spritesheetPath: block.model,
|
|
2739
|
+
servable: [block.model, ...references],
|
|
1670
2740
|
...remarks === void 0 ? {} : { remarks }
|
|
1671
2741
|
};
|
|
1672
2742
|
}
|
|
@@ -1686,8 +2756,39 @@ function scanPetDir(dir, options) {
|
|
|
1686
2756
|
if (!existsSync(manifestFile)) continue;
|
|
1687
2757
|
const parsed = readPetJson(manifestFile, options.warnings);
|
|
1688
2758
|
if (parsed === void 0) continue;
|
|
1689
|
-
const
|
|
1690
|
-
|
|
2759
|
+
const entryDir = join(dir, name);
|
|
2760
|
+
const verdict = parsePetManifest(parsed, entryDir);
|
|
2761
|
+
for (const diagnostic of verdict.diagnostics) {
|
|
2762
|
+
options.diagnostics?.push({
|
|
2763
|
+
level: diagnostic.level,
|
|
2764
|
+
source: entryDir,
|
|
2765
|
+
message: diagnostic.message
|
|
2766
|
+
});
|
|
2767
|
+
options.warnings?.push(diagnostic.message);
|
|
2768
|
+
}
|
|
2769
|
+
if (!verdict.ok) continue;
|
|
2770
|
+
let entry;
|
|
2771
|
+
if (verdict.manifest.renderer === "live2d") entry = resolveLive2dEntry(verdict.manifest, entryDir, options);
|
|
2772
|
+
else {
|
|
2773
|
+
const legacy = flattenV2Sprite2d(verdict.manifest);
|
|
2774
|
+
if (legacy === void 0) {
|
|
2775
|
+
const note = "pet " + verdict.manifest.id + ": sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver";
|
|
2776
|
+
options.diagnostics?.push({
|
|
2777
|
+
level: "error",
|
|
2778
|
+
source: entryDir,
|
|
2779
|
+
message: note
|
|
2780
|
+
});
|
|
2781
|
+
options.warnings?.push(note);
|
|
2782
|
+
continue;
|
|
2783
|
+
}
|
|
2784
|
+
entry = resolvePetManifest(legacy, entryDir, options);
|
|
2785
|
+
}
|
|
2786
|
+
if (entry === void 0) continue;
|
|
2787
|
+
const voice = loadVoicePackFile(join(entryDir, "voice.json"), options);
|
|
2788
|
+
entries.push({
|
|
2789
|
+
...entry,
|
|
2790
|
+
...voice === void 0 ? {} : { voice }
|
|
2791
|
+
});
|
|
1691
2792
|
}
|
|
1692
2793
|
return entries;
|
|
1693
2794
|
}
|
|
@@ -1701,6 +2802,135 @@ function readPetJson(file, warnings) {
|
|
|
1701
2802
|
}
|
|
1702
2803
|
}
|
|
1703
2804
|
/**
|
|
2805
|
+
* Stat one scanned JSON descriptor with a regular-file + size guard, so a
|
|
2806
|
+
* pathological user file is skipped with a warning instead of stalling or
|
|
2807
|
+
* OOM-ing the host at startup. Returns the Stats, or undefined when the
|
|
2808
|
+
* caller must skip the file (a warning was recorded).
|
|
2809
|
+
*/
|
|
2810
|
+
function guardedScannedJsonStat(file, options, what) {
|
|
2811
|
+
let st;
|
|
2812
|
+
try {
|
|
2813
|
+
st = statSync(file);
|
|
2814
|
+
} catch {
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
const warn = (message) => {
|
|
2818
|
+
options.warnings?.push(file + ": " + message);
|
|
2819
|
+
options.diagnostics?.push({
|
|
2820
|
+
level: "warning",
|
|
2821
|
+
source: file,
|
|
2822
|
+
message: file + ": " + message
|
|
2823
|
+
});
|
|
2824
|
+
};
|
|
2825
|
+
if (!st.isFile()) {
|
|
2826
|
+
warn(what + " is not a regular file; ignored");
|
|
2827
|
+
return;
|
|
2828
|
+
}
|
|
2829
|
+
if (st.size > 65536) {
|
|
2830
|
+
warn(what + " exceeds the 65536-byte scan ceiling; ignored");
|
|
2831
|
+
return;
|
|
2832
|
+
}
|
|
2833
|
+
return st;
|
|
2834
|
+
}
|
|
2835
|
+
/**
|
|
2836
|
+
* Load and normalize one optional voice.json (pet-center M4). A missing
|
|
2837
|
+
* file is silent; a broken file warns and drops. The pack is pure content,
|
|
2838
|
+
* so every issue stays a warning — a bad voice.json never rejects a pet.
|
|
2839
|
+
*/
|
|
2840
|
+
function loadVoicePackFile(file, options) {
|
|
2841
|
+
if (!existsSync(file)) return void 0;
|
|
2842
|
+
if (guardedScannedJsonStat(file, options, "voice pack") === void 0) return void 0;
|
|
2843
|
+
const warn = (message) => {
|
|
2844
|
+
options.warnings?.push(file + ": " + message);
|
|
2845
|
+
options.diagnostics?.push({
|
|
2846
|
+
level: "warning",
|
|
2847
|
+
source: file,
|
|
2848
|
+
message: file + ": " + message
|
|
2849
|
+
});
|
|
2850
|
+
};
|
|
2851
|
+
let raw;
|
|
2852
|
+
try {
|
|
2853
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
2854
|
+
} catch (error) {
|
|
2855
|
+
warn("voice pack is not valid JSON; ignored: " + (error instanceof Error ? error.message : String(error)));
|
|
2856
|
+
return;
|
|
2857
|
+
}
|
|
2858
|
+
return normalizeVoicePack(raw, warn);
|
|
2859
|
+
}
|
|
2860
|
+
/** Decoration asset URL prefix (served by the decoration route, M5). */
|
|
2861
|
+
const DECORATION_ASSET_PREFIX = "/api/pet/decoration";
|
|
2862
|
+
/**
|
|
2863
|
+
* Scan one directory of decoration folders ('decoration.json' + strip).
|
|
2864
|
+
* Later scans override earlier ones on id collision; a bad descriptor warns
|
|
2865
|
+
* and skips — the never-throw philosophy holds for decorations too (M5).
|
|
2866
|
+
*/
|
|
2867
|
+
function scanDecorationDir(dir, options) {
|
|
2868
|
+
if (!existsSync(dir)) return [];
|
|
2869
|
+
let names = [];
|
|
2870
|
+
try {
|
|
2871
|
+
names = readdirSync(dir).filter((name) => !name.startsWith("."));
|
|
2872
|
+
} catch {
|
|
2873
|
+
return [];
|
|
2874
|
+
}
|
|
2875
|
+
names.sort();
|
|
2876
|
+
const entries = [];
|
|
2877
|
+
for (const name of names) {
|
|
2878
|
+
const entryDir = join(dir, name);
|
|
2879
|
+
const manifestFile = join(entryDir, "decoration.json");
|
|
2880
|
+
if (!existsSync(manifestFile)) continue;
|
|
2881
|
+
if (guardedScannedJsonStat(manifestFile, options, "decoration descriptor") === void 0) continue;
|
|
2882
|
+
let raw;
|
|
2883
|
+
try {
|
|
2884
|
+
raw = JSON.parse(readFileSync(manifestFile, "utf8"));
|
|
2885
|
+
} catch (error) {
|
|
2886
|
+
const message = "skipping " + manifestFile + ": " + (error instanceof Error ? error.message : String(error));
|
|
2887
|
+
options.warnings?.push(message);
|
|
2888
|
+
options.diagnostics?.push({
|
|
2889
|
+
level: "error",
|
|
2890
|
+
source: entryDir,
|
|
2891
|
+
message
|
|
2892
|
+
});
|
|
2893
|
+
continue;
|
|
2894
|
+
}
|
|
2895
|
+
const verdict = parseDecorationManifest(raw, manifestFile);
|
|
2896
|
+
for (const diagnostic of verdict.diagnostics) {
|
|
2897
|
+
options.diagnostics?.push({
|
|
2898
|
+
level: diagnostic.level,
|
|
2899
|
+
source: entryDir,
|
|
2900
|
+
message: diagnostic.message
|
|
2901
|
+
});
|
|
2902
|
+
options.warnings?.push(diagnostic.message);
|
|
2903
|
+
}
|
|
2904
|
+
if (!verdict.ok) continue;
|
|
2905
|
+
const manifest = verdict.manifest;
|
|
2906
|
+
if (!existsSync(join(entryDir, manifest.entry))) {
|
|
2907
|
+
const message = "decoration " + manifest.id + ": strip file missing: " + manifest.entry;
|
|
2908
|
+
options.warnings?.push(message);
|
|
2909
|
+
options.diagnostics?.push({
|
|
2910
|
+
level: "warning",
|
|
2911
|
+
source: entryDir,
|
|
2912
|
+
message
|
|
2913
|
+
});
|
|
2914
|
+
}
|
|
2915
|
+
entries.push({
|
|
2916
|
+
apiVersion: PET_DECORATION_API_VERSION,
|
|
2917
|
+
id: manifest.id,
|
|
2918
|
+
dir: entryDir,
|
|
2919
|
+
entryPath: manifest.entry,
|
|
2920
|
+
servable: ["decoration.json", manifest.entry],
|
|
2921
|
+
license: manifest.license,
|
|
2922
|
+
assetBase: "/api/pet/decoration/" + encodeURIComponent(manifest.id),
|
|
2923
|
+
entryUrl: "/api/pet/decoration/" + encodeURIComponent(manifest.id) + "/" + manifest.entry,
|
|
2924
|
+
cell: manifest.cell,
|
|
2925
|
+
columns: manifest.columns,
|
|
2926
|
+
durations: manifest.durations,
|
|
2927
|
+
loop: manifest.loop,
|
|
2928
|
+
phases: manifest.phases
|
|
2929
|
+
});
|
|
2930
|
+
}
|
|
2931
|
+
return entries;
|
|
2932
|
+
}
|
|
2933
|
+
/**
|
|
1704
2934
|
* Load the pet registry: built-in 'assets/*' first, then the hatch-pet
|
|
1705
2935
|
* custom pets directory, then composed 'extra' manifests (each later source
|
|
1706
2936
|
* overrides an earlier one on id collision). The registry never throws on a
|
|
@@ -1709,11 +2939,13 @@ function readPetJson(file, warnings) {
|
|
|
1709
2939
|
function loadPetRegistry(options) {
|
|
1710
2940
|
const { packageRoot, assetPrefix = "/pet" } = options;
|
|
1711
2941
|
const warnings = [];
|
|
2942
|
+
const diagnostics = [];
|
|
1712
2943
|
const byId = /* @__PURE__ */ new Map();
|
|
1713
2944
|
const builtinIds = /* @__PURE__ */ new Set();
|
|
1714
2945
|
for (const entry of scanPetDir(join(packageRoot, "assets"), {
|
|
1715
2946
|
assetPrefix,
|
|
1716
|
-
warnings
|
|
2947
|
+
warnings,
|
|
2948
|
+
diagnostics
|
|
1717
2949
|
})) {
|
|
1718
2950
|
if (byId.has(entry.id)) {
|
|
1719
2951
|
warnings.push("duplicate built-in pet id " + entry.id + "; the first one wins");
|
|
@@ -1725,11 +2957,28 @@ function loadPetRegistry(options) {
|
|
|
1725
2957
|
const petsDir = options.petsDir ?? codexPetsDir();
|
|
1726
2958
|
if (petsDir !== "") for (const entry of scanPetDir(petsDir, {
|
|
1727
2959
|
assetPrefix,
|
|
1728
|
-
warnings
|
|
2960
|
+
warnings,
|
|
2961
|
+
diagnostics
|
|
1729
2962
|
})) {
|
|
1730
2963
|
if (byId.has(entry.id)) warnings.push("custom pet " + entry.id + " overrides the built-in one");
|
|
1731
2964
|
byId.set(entry.id, entry);
|
|
1732
2965
|
}
|
|
2966
|
+
const dshPetsDir = options.dshPetsDir ?? join(dshHome(), "pets");
|
|
2967
|
+
let globalVoice;
|
|
2968
|
+
if (dshPetsDir !== "") {
|
|
2969
|
+
for (const entry of scanPetDir(dshPetsDir, {
|
|
2970
|
+
assetPrefix,
|
|
2971
|
+
warnings,
|
|
2972
|
+
diagnostics
|
|
2973
|
+
})) {
|
|
2974
|
+
if (byId.has(entry.id)) warnings.push("user pet " + entry.id + " overrides an earlier registration");
|
|
2975
|
+
byId.set(entry.id, entry);
|
|
2976
|
+
}
|
|
2977
|
+
globalVoice = loadVoicePackFile(join(dshPetsDir, ".voice.json"), {
|
|
2978
|
+
warnings,
|
|
2979
|
+
diagnostics
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
1733
2982
|
for (const manifest of options.extra ?? []) {
|
|
1734
2983
|
const raw = manifest.spritesheetPath;
|
|
1735
2984
|
const dir = raw === void 0 || isAbsolute(raw) ? join(packageRoot, "assets", "extra") : dirname(resolve(packageRoot, raw));
|
|
@@ -1744,20 +2993,61 @@ function loadPetRegistry(options) {
|
|
|
1744
2993
|
if (byId.has(entry.id)) warnings.push("composed pet " + entry.id + " overrides an earlier registration");
|
|
1745
2994
|
byId.set(entry.id, entry);
|
|
1746
2995
|
}
|
|
2996
|
+
const decorationById = /* @__PURE__ */ new Map();
|
|
2997
|
+
for (const entry of scanDecorationDir(join(packageRoot, "assets", "decorations"), {
|
|
2998
|
+
warnings,
|
|
2999
|
+
diagnostics
|
|
3000
|
+
})) decorationById.set(entry.id, entry);
|
|
3001
|
+
if (dshPetsDir !== "") for (const entry of scanDecorationDir(join(dshPetsDir, "decorations"), {
|
|
3002
|
+
warnings,
|
|
3003
|
+
diagnostics
|
|
3004
|
+
})) {
|
|
3005
|
+
if (decorationById.has(entry.id)) warnings.push("user decoration " + entry.id + " overrides the built-in one");
|
|
3006
|
+
decorationById.set(entry.id, entry);
|
|
3007
|
+
}
|
|
1747
3008
|
const entries = [...byId.values()];
|
|
3009
|
+
const decorations = [...decorationById.values()];
|
|
1748
3010
|
return {
|
|
1749
3011
|
entries,
|
|
1750
3012
|
warnings,
|
|
3013
|
+
diagnostics,
|
|
1751
3014
|
byId: (id) => byId.get(id),
|
|
1752
|
-
defaultEntry: () => entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0]
|
|
3015
|
+
defaultEntry: () => entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0],
|
|
3016
|
+
...globalVoice === void 0 ? {} : { globalVoice },
|
|
3017
|
+
decorations,
|
|
3018
|
+
decorationById: (id) => decorationById.get(id)
|
|
1753
3019
|
};
|
|
1754
3020
|
}
|
|
1755
|
-
/**
|
|
1756
|
-
|
|
3021
|
+
/** The built-in default decoration id (M5): the first reference ornament. */
|
|
3022
|
+
const DEFAULT_DECORATION_ID = "whale";
|
|
3023
|
+
/** Strip host-only fields, leaving the browser-visible decoration view. */
|
|
3024
|
+
function decorationView(entry) {
|
|
3025
|
+
return {
|
|
3026
|
+
apiVersion: PET_DECORATION_API_VERSION,
|
|
3027
|
+
id: entry.id,
|
|
3028
|
+
assetBase: entry.assetBase,
|
|
3029
|
+
entryUrl: entry.entryUrl,
|
|
3030
|
+
cell: entry.cell,
|
|
3031
|
+
columns: entry.columns,
|
|
3032
|
+
durations: entry.durations,
|
|
3033
|
+
loop: entry.loop,
|
|
3034
|
+
phases: entry.phases
|
|
3035
|
+
};
|
|
3036
|
+
}
|
|
3037
|
+
/**
|
|
3038
|
+
* Strip host-only fields, leaving the client-visible definition. When the
|
|
3039
|
+
* registry carries a global voice pack, its panel chrome layers under the
|
|
3040
|
+
* entry's own pack (per-slot merge, pet > global), mirroring the voice-pool
|
|
3041
|
+
* layering (pet-center M4, issue #677).
|
|
3042
|
+
*/
|
|
3043
|
+
function petEntryView(entry, globalVoice) {
|
|
3044
|
+
const panel = globalVoice === void 0 ? entry.voice?.panel : mergeVoicePacks(globalVoice, entry.voice)?.panel;
|
|
1757
3045
|
return {
|
|
1758
3046
|
id: entry.id,
|
|
1759
3047
|
displayName: entry.displayName,
|
|
1760
3048
|
description: entry.description,
|
|
3049
|
+
renderer: entry.renderer,
|
|
3050
|
+
...entry.live2d === void 0 ? {} : { live2d: entry.live2d },
|
|
1761
3051
|
cell: entry.cell,
|
|
1762
3052
|
columns: entry.columns,
|
|
1763
3053
|
rows: entry.rows,
|
|
@@ -1765,7 +3055,8 @@ function petEntryView(entry) {
|
|
|
1765
3055
|
tracks: entry.tracks,
|
|
1766
3056
|
...entry.sequences === void 0 ? {} : { sequences: entry.sequences },
|
|
1767
3057
|
atlasUrl: entry.atlasUrl,
|
|
1768
|
-
manifestUrl: entry.manifestUrl
|
|
3058
|
+
manifestUrl: entry.manifestUrl,
|
|
3059
|
+
...panel === void 0 ? {} : { panel }
|
|
1769
3060
|
};
|
|
1770
3061
|
}
|
|
1771
3062
|
/** Hard cap on simultaneously displayed session bubbles (most recent first). */
|
|
@@ -1784,10 +3075,18 @@ var PetService = class extends Service {
|
|
|
1784
3075
|
registry;
|
|
1785
3076
|
persistDir;
|
|
1786
3077
|
enabled;
|
|
3078
|
+
/** Status-decoration master switch (M5, #567); mirrored from settings. */
|
|
3079
|
+
decorationEnabled;
|
|
1787
3080
|
disposeActivity;
|
|
1788
3081
|
/** Session whose most recent meaningful event currently drives the global pet. */
|
|
1789
3082
|
displaySession;
|
|
1790
3083
|
/**
|
|
3084
|
+
* Effective voice-pack overrides for the currently selected pet (M4,
|
|
3085
|
+
* #677). Cached per pet id; the registry is an immutable snapshot, so the
|
|
3086
|
+
* global pack and each entry's pack cannot change behind the cache.
|
|
3087
|
+
*/
|
|
3088
|
+
voiceCache;
|
|
3089
|
+
/**
|
|
1791
3090
|
* Per-session activity, most recent last (Map insertion order). Bounded by
|
|
1792
3091
|
* MAX_SESSION_BUBBLES so a burst of sessions cannot grow it without bound;
|
|
1793
3092
|
* disposed sessions are removed by the 'session/disposed' listener.
|
|
@@ -1819,8 +3118,26 @@ var PetService = class extends Service {
|
|
|
1819
3118
|
};
|
|
1820
3119
|
this.machine = new PetStateMachine(this.stateConfig);
|
|
1821
3120
|
this.enabled = config.enabled ?? true;
|
|
3121
|
+
this.decorationEnabled = config.decorationEnabled ?? true;
|
|
1822
3122
|
this.syncActivity();
|
|
1823
3123
|
}
|
|
3124
|
+
/**
|
|
3125
|
+
* The draw-time voice-pool provider handed to every projection runtime.
|
|
3126
|
+
* It re-resolves when the selected pet changes, so live engines re-voice
|
|
3127
|
+
* on the next draw without being rebuilt (M4, #677).
|
|
3128
|
+
*/
|
|
3129
|
+
voicePools() {
|
|
3130
|
+
return () => {
|
|
3131
|
+
const entry = this.activeEntry();
|
|
3132
|
+
if (this.voiceCache !== void 0 && this.voiceCache.petId === entry.id) return this.voiceCache.overrides;
|
|
3133
|
+
const overrides = mergeVoicePacks(this.registry.globalVoice, entry.voice)?.overrides ?? {};
|
|
3134
|
+
this.voiceCache = {
|
|
3135
|
+
petId: entry.id,
|
|
3136
|
+
overrides
|
|
3137
|
+
};
|
|
3138
|
+
return overrides;
|
|
3139
|
+
};
|
|
3140
|
+
}
|
|
1824
3141
|
/** Whether the pet service consumes session activity while enabled. */
|
|
1825
3142
|
isEnabled() {
|
|
1826
3143
|
return this.enabled;
|
|
@@ -1835,12 +3152,25 @@ var PetService = class extends Service {
|
|
|
1835
3152
|
}
|
|
1836
3153
|
/** RPC: the registry entries the browser half renders and selects from. */
|
|
1837
3154
|
async pets() {
|
|
1838
|
-
return this.registry.entries.map(petEntryView);
|
|
3155
|
+
return this.registry.entries.map((entry) => petEntryView(entry, this.registry.globalVoice));
|
|
1839
3156
|
}
|
|
1840
3157
|
/** The loaded registry (the asset routes serve its entries). */
|
|
1841
3158
|
registrySnapshot() {
|
|
1842
3159
|
return this.registry;
|
|
1843
3160
|
}
|
|
3161
|
+
/** RPC: structured registry diagnostics (pet-center M2, issue #623). */
|
|
3162
|
+
async diagnostics() {
|
|
3163
|
+
return { diagnostics: this.registry.diagnostics };
|
|
3164
|
+
}
|
|
3165
|
+
/**
|
|
3166
|
+
* The active status decoration view (M5, #567): the default 'whale' entry
|
|
3167
|
+
* (user directories override built-ins by id), gated by the master switch.
|
|
3168
|
+
*/
|
|
3169
|
+
activeDecoration() {
|
|
3170
|
+
if (!this.decorationEnabled) return void 0;
|
|
3171
|
+
const entry = this.registry.decorationById?.(DEFAULT_DECORATION_ID);
|
|
3172
|
+
return entry === void 0 ? void 0 : decorationView(entry);
|
|
3173
|
+
}
|
|
1844
3174
|
/** The selected pet's registry entry. */
|
|
1845
3175
|
activeEntry() {
|
|
1846
3176
|
return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry();
|
|
@@ -1924,7 +3254,7 @@ var PetService = class extends Service {
|
|
|
1924
3254
|
let activity = this.sessionActivity.get(session);
|
|
1925
3255
|
if (activity === void 0) {
|
|
1926
3256
|
activity = {
|
|
1927
|
-
runtime: emptyProjectionRuntime(),
|
|
3257
|
+
runtime: emptyProjectionRuntime(this.voicePools()),
|
|
1928
3258
|
machine: new PetStateMachine(this.stateConfig)
|
|
1929
3259
|
};
|
|
1930
3260
|
this.sessionActivity.set(session, activity);
|
|
@@ -2018,6 +3348,7 @@ var PetService = class extends Service {
|
|
|
2018
3348
|
* @param section - the resolved settings section.
|
|
2019
3349
|
*/
|
|
2020
3350
|
applySettingsSection(section) {
|
|
3351
|
+
this.decorationEnabled = section.decorationEnabled ?? true;
|
|
2021
3352
|
const selected = typeof section.petId === "string" ? this.registry.byId(section.petId) : void 0;
|
|
2022
3353
|
if (selected !== void 0) {
|
|
2023
3354
|
this.ledger.setPetId(selected.id);
|
|
@@ -2070,6 +3401,7 @@ var PetService = class extends Service {
|
|
|
2070
3401
|
}
|
|
2071
3402
|
const whisper = (this.displaySession === void 0 ? void 0 : this.sessionActivity.get(this.displaySession))?.whisper;
|
|
2072
3403
|
const freshWhisper = whisper !== void 0 && Date.now() - whisper.at < 8e3 ? whisper.text : void 0;
|
|
3404
|
+
const decoration = this.activeDecoration();
|
|
2073
3405
|
return {
|
|
2074
3406
|
animation: snapshot.animation,
|
|
2075
3407
|
...snapshot.bubble === void 0 ? {} : { bubble: snapshot.bubble },
|
|
@@ -2077,6 +3409,7 @@ var PetService = class extends Service {
|
|
|
2077
3409
|
sessionActive: snapshot.sessionActive,
|
|
2078
3410
|
sessions,
|
|
2079
3411
|
...freshWhisper === void 0 ? {} : { whisper: freshWhisper },
|
|
3412
|
+
...decoration === void 0 ? {} : { decoration },
|
|
2080
3413
|
affinity: this.ledger.affinityView(Date.now()),
|
|
2081
3414
|
display: { ...this.ledger.snapshot.display },
|
|
2082
3415
|
pet: {
|
|
@@ -2161,6 +3494,46 @@ const PET_ASSET_PREFIX = "/pet";
|
|
|
2161
3494
|
const MANIFEST_FILE = "pet.json";
|
|
2162
3495
|
const PREVIEW_DIR = "previews";
|
|
2163
3496
|
const PREVIEW_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
3497
|
+
/**
|
|
3498
|
+
* Per-class size ceilings for served pet assets, in bytes (pet-center M2 P3,
|
|
3499
|
+
* issue #623). Constants are tested directly; makePetRoutes accepts an
|
|
3500
|
+
* override so tests can exercise the 413 path with tiny caps.
|
|
3501
|
+
*/
|
|
3502
|
+
const PET_ASSET_CAPS = {
|
|
3503
|
+
/** pet.json manifest. */
|
|
3504
|
+
manifest: 64 * 1024,
|
|
3505
|
+
/** Atlas, preview and Live2D texture imagery. */
|
|
3506
|
+
image: 20 * 1024 * 1024,
|
|
3507
|
+
/** Live2D model closure files (.moc3, motion/physics/expression JSON; M3). */
|
|
3508
|
+
model: 32 * 1024 * 1024
|
|
3509
|
+
};
|
|
3510
|
+
/** Imagery extensions classify into the image cap; everything else served from a closure is model-class. */
|
|
3511
|
+
const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
3512
|
+
".webp",
|
|
3513
|
+
".png",
|
|
3514
|
+
".gif",
|
|
3515
|
+
".jpg",
|
|
3516
|
+
".jpeg"
|
|
3517
|
+
]);
|
|
3518
|
+
/** Lowercased file extension ('' when none). */
|
|
3519
|
+
function extensionOf(file) {
|
|
3520
|
+
const dot = file.lastIndexOf(".");
|
|
3521
|
+
return dot < 0 ? "" : file.slice(dot).toLowerCase();
|
|
3522
|
+
}
|
|
3523
|
+
/**
|
|
3524
|
+
* realpath containment: resolve both sides and require the candidate to stay
|
|
3525
|
+
* inside the base directory. A pet directory (or an atlas/preview inside it)
|
|
3526
|
+
* that is a symlink escaping its root is rejected, never followed.
|
|
3527
|
+
*/
|
|
3528
|
+
function containedRealpath(base, candidate) {
|
|
3529
|
+
try {
|
|
3530
|
+
const realBase = realpathSync(base);
|
|
3531
|
+
const realCandidate = realpathSync(candidate);
|
|
3532
|
+
return realCandidate === realBase || realCandidate.startsWith(realBase + sep) ? realCandidate : void 0;
|
|
3533
|
+
} catch {
|
|
3534
|
+
return;
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
2164
3537
|
const MIME_BY_EXT = {
|
|
2165
3538
|
".webp": "image/webp",
|
|
2166
3539
|
".png": "image/png",
|
|
@@ -2279,10 +3652,14 @@ function dirAliases(registry) {
|
|
|
2279
3652
|
/**
|
|
2280
3653
|
* The one asset handler behind the '/pet' prefix. Resolves the pet by id (or
|
|
2281
3654
|
* legacy directory alias), then serves exactly the files a manifest declares:
|
|
2282
|
-
* pet.json, the
|
|
2283
|
-
*
|
|
3655
|
+
* pet.json, the entry's servable set (the sprite2d atlas, or the live2d
|
|
3656
|
+
* model3.json plus its reference closure — pet-center M3), and optional
|
|
3657
|
+
* 'previews/<name>' media. The servable match is an exact string comparison
|
|
3658
|
+
* against scan-time normalized paths, so crafted '..' or '.' segments never
|
|
3659
|
+
* match; containedRealpath stays as the second layer. Composed pets without
|
|
3660
|
+
* a manifest file get a synthesized pet.json.
|
|
2284
3661
|
*/
|
|
2285
|
-
function assetHandler(registry) {
|
|
3662
|
+
function assetHandler(registry, caps) {
|
|
2286
3663
|
const aliases = dirAliases(registry);
|
|
2287
3664
|
return (req, res) => {
|
|
2288
3665
|
if (!guard(req, res)) return;
|
|
@@ -2338,13 +3715,13 @@ function assetHandler(registry) {
|
|
|
2338
3715
|
const manifestFile = join(entry.dir, MANIFEST_FILE);
|
|
2339
3716
|
file = existsSync(manifestFile) ? manifestFile : void 0;
|
|
2340
3717
|
if (file === void 0) synthesized = true;
|
|
2341
|
-
} else if (rest.length > 0 &&
|
|
3718
|
+
} else if (rest.length > 0 && entry.servable.includes(rel)) file = join(entry.dir, rel);
|
|
2342
3719
|
else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1])) {
|
|
2343
3720
|
const preview = join(entry.dir, PREVIEW_DIR, rest[1]);
|
|
2344
3721
|
file = existsSync(preview) ? preview : void 0;
|
|
2345
3722
|
}
|
|
2346
3723
|
if (synthesized) {
|
|
2347
|
-
const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), "utf8");
|
|
3724
|
+
const body = Buffer.from(JSON.stringify(petEntryView(entry, registry.globalVoice), null, 2), "utf8");
|
|
2348
3725
|
res.writeHead(200, {
|
|
2349
3726
|
"content-type": "application/json; charset=utf-8",
|
|
2350
3727
|
"content-length": String(body.byteLength),
|
|
@@ -2362,7 +3739,24 @@ function assetHandler(registry) {
|
|
|
2362
3739
|
res.end();
|
|
2363
3740
|
return;
|
|
2364
3741
|
}
|
|
2365
|
-
const resolved = file;
|
|
3742
|
+
const resolved = containedRealpath(entry.dir, file);
|
|
3743
|
+
if (resolved === void 0) {
|
|
3744
|
+
res.writeHead(403);
|
|
3745
|
+
res.end();
|
|
3746
|
+
return;
|
|
3747
|
+
}
|
|
3748
|
+
const cap = rest.length === 1 && rest[0] === MANIFEST_FILE ? caps.manifest : IMAGE_EXTENSIONS.has(extensionOf(rel)) ? caps.image : caps.model;
|
|
3749
|
+
try {
|
|
3750
|
+
if (statSync(resolved).size > cap) {
|
|
3751
|
+
res.writeHead(413);
|
|
3752
|
+
res.end();
|
|
3753
|
+
return;
|
|
3754
|
+
}
|
|
3755
|
+
} catch {
|
|
3756
|
+
res.writeHead(404);
|
|
3757
|
+
res.end();
|
|
3758
|
+
return;
|
|
3759
|
+
}
|
|
2366
3760
|
readFile(resolved).then((body) => {
|
|
2367
3761
|
res.writeHead(200, {
|
|
2368
3762
|
"content-type": mimeFor(resolved),
|
|
@@ -2380,12 +3774,223 @@ function assetHandler(registry) {
|
|
|
2380
3774
|
});
|
|
2381
3775
|
};
|
|
2382
3776
|
}
|
|
2383
|
-
/**
|
|
3777
|
+
/** Browser-facing base path of the plugin runtime files (pet-center M3). */
|
|
3778
|
+
const PET_RUNTIME_PREFIX = "/api/pet/runtime";
|
|
3779
|
+
/**
|
|
3780
|
+
* The runtime files the route may serve, by exact name (no slashes, no
|
|
3781
|
+
* user-controlled path segments, so traversal is structurally impossible):
|
|
3782
|
+
* the user-supplied Cubism Core from the pet runtime directory (the plugin
|
|
3783
|
+
* never bundles or downloads it — issue #623 M1 §0) and the plugin-shipped
|
|
3784
|
+
* MIT vendor bundle from the package lib directory.
|
|
3785
|
+
*/
|
|
3786
|
+
const RUNTIME_FILES = {
|
|
3787
|
+
"live2dcubismcore.min.js": { root: "runtimeDir" },
|
|
3788
|
+
"live2d-vendor.js": { root: "vendorDir" },
|
|
3789
|
+
"live2d-vendor.js.map": { root: "vendorDir" }
|
|
3790
|
+
};
|
|
3791
|
+
/**
|
|
3792
|
+
* The runtime handler behind '/api/pet/runtime/<name>'. A missing file
|
|
3793
|
+
* answers 404 with a JSON marker the client renderer turns into install
|
|
3794
|
+
* guidance (the Cubism Core is user-supplied, so its absence is a normal
|
|
3795
|
+
* state, not an error).
|
|
3796
|
+
*/
|
|
3797
|
+
function runtimeHandler(roots) {
|
|
3798
|
+
return (req, res) => {
|
|
3799
|
+
if (!guard(req, res)) return;
|
|
3800
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
3801
|
+
res.writeHead(405);
|
|
3802
|
+
res.end();
|
|
3803
|
+
return;
|
|
3804
|
+
}
|
|
3805
|
+
let pathname;
|
|
3806
|
+
try {
|
|
3807
|
+
pathname = new URL(req.url ?? "/", "http://pet.local").pathname;
|
|
3808
|
+
} catch {
|
|
3809
|
+
res.writeHead(400);
|
|
3810
|
+
res.end();
|
|
3811
|
+
return;
|
|
3812
|
+
}
|
|
3813
|
+
const rest = pathname.slice(16).replace(/^\/+/, "");
|
|
3814
|
+
let name;
|
|
3815
|
+
try {
|
|
3816
|
+
name = decodeURIComponent(rest);
|
|
3817
|
+
} catch {
|
|
3818
|
+
res.writeHead(400);
|
|
3819
|
+
res.end();
|
|
3820
|
+
return;
|
|
3821
|
+
}
|
|
3822
|
+
const spec = RUNTIME_FILES[name];
|
|
3823
|
+
if (spec === void 0) {
|
|
3824
|
+
res.writeHead(404);
|
|
3825
|
+
res.end();
|
|
3826
|
+
return;
|
|
3827
|
+
}
|
|
3828
|
+
const base = spec.root === "runtimeDir" ? roots.runtimeDir : roots.vendorDir;
|
|
3829
|
+
const file = join(base, name);
|
|
3830
|
+
if (!existsSync(file)) {
|
|
3831
|
+
json(res, 404, {
|
|
3832
|
+
ok: false,
|
|
3833
|
+
error: "runtime-file-missing",
|
|
3834
|
+
file: name
|
|
3835
|
+
});
|
|
3836
|
+
return;
|
|
3837
|
+
}
|
|
3838
|
+
const resolved = containedRealpath(base, file);
|
|
3839
|
+
if (resolved === void 0) {
|
|
3840
|
+
res.writeHead(403);
|
|
3841
|
+
res.end();
|
|
3842
|
+
return;
|
|
3843
|
+
}
|
|
3844
|
+
try {
|
|
3845
|
+
if (statSync(resolved).size > 16777216) {
|
|
3846
|
+
res.writeHead(413);
|
|
3847
|
+
res.end();
|
|
3848
|
+
return;
|
|
3849
|
+
}
|
|
3850
|
+
} catch {
|
|
3851
|
+
res.writeHead(404);
|
|
3852
|
+
res.end();
|
|
3853
|
+
return;
|
|
3854
|
+
}
|
|
3855
|
+
readFile(resolved).then((body) => {
|
|
3856
|
+
res.writeHead(200, {
|
|
3857
|
+
"content-type": name.endsWith(".map") ? "application/json" : "application/javascript; charset=utf-8",
|
|
3858
|
+
"content-length": String(body.byteLength),
|
|
3859
|
+
"cache-control": "no-cache"
|
|
3860
|
+
});
|
|
3861
|
+
if (req.method === "HEAD") {
|
|
3862
|
+
res.end();
|
|
3863
|
+
return;
|
|
3864
|
+
}
|
|
3865
|
+
res.end(body);
|
|
3866
|
+
}, () => {
|
|
3867
|
+
res.writeHead(404);
|
|
3868
|
+
res.end();
|
|
3869
|
+
});
|
|
3870
|
+
};
|
|
3871
|
+
}
|
|
3872
|
+
/**
|
|
3873
|
+
* The decoration asset handler behind '/api/pet/decoration/<id>/<file>'
|
|
3874
|
+
* (pet-center M5, #567). Serves exactly the files a decoration descriptor
|
|
3875
|
+
* declares — decoration.json and the PNG/WebP strip — by exact allow-list
|
|
3876
|
+
* match, with realpath containment and the same size ceilings as pet
|
|
3877
|
+
* assets. Crafted '..' or '.' segments never match the normalized closure.
|
|
3878
|
+
*/
|
|
3879
|
+
function decorationHandler(registry, caps) {
|
|
3880
|
+
return (req, res) => {
|
|
3881
|
+
if (!guard(req, res)) return;
|
|
3882
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
3883
|
+
res.writeHead(405);
|
|
3884
|
+
res.end();
|
|
3885
|
+
return;
|
|
3886
|
+
}
|
|
3887
|
+
let pathname;
|
|
3888
|
+
try {
|
|
3889
|
+
pathname = new URL(req.url ?? "/", "http://pet.local").pathname;
|
|
3890
|
+
} catch {
|
|
3891
|
+
res.writeHead(400);
|
|
3892
|
+
res.end();
|
|
3893
|
+
return;
|
|
3894
|
+
}
|
|
3895
|
+
const segments = pathname.split("/").filter((segment) => segment !== "");
|
|
3896
|
+
const prefixSegments = DECORATION_ASSET_PREFIX.split("/").filter((segment) => segment !== "");
|
|
3897
|
+
if (segments.length < prefixSegments.length + 2) {
|
|
3898
|
+
res.writeHead(404);
|
|
3899
|
+
res.end();
|
|
3900
|
+
return;
|
|
3901
|
+
}
|
|
3902
|
+
for (let i = 0; i < prefixSegments.length; i += 1) if (segments[i] !== prefixSegments[i]) {
|
|
3903
|
+
res.writeHead(404);
|
|
3904
|
+
res.end();
|
|
3905
|
+
return;
|
|
3906
|
+
}
|
|
3907
|
+
let id;
|
|
3908
|
+
try {
|
|
3909
|
+
id = decodeURIComponent(segments[prefixSegments.length]);
|
|
3910
|
+
} catch {
|
|
3911
|
+
res.writeHead(400);
|
|
3912
|
+
res.end();
|
|
3913
|
+
return;
|
|
3914
|
+
}
|
|
3915
|
+
const entry = registry.decorationById?.(id);
|
|
3916
|
+
if (entry === void 0) {
|
|
3917
|
+
res.writeHead(404);
|
|
3918
|
+
res.end();
|
|
3919
|
+
return;
|
|
3920
|
+
}
|
|
3921
|
+
const rest = [];
|
|
3922
|
+
for (const segment of segments.slice(prefixSegments.length + 1)) {
|
|
3923
|
+
let decoded;
|
|
3924
|
+
try {
|
|
3925
|
+
decoded = decodeURIComponent(segment);
|
|
3926
|
+
} catch {
|
|
3927
|
+
res.writeHead(400);
|
|
3928
|
+
res.end();
|
|
3929
|
+
return;
|
|
3930
|
+
}
|
|
3931
|
+
rest.push(decoded);
|
|
3932
|
+
}
|
|
3933
|
+
const rel = rest.join("/");
|
|
3934
|
+
if (!entry.servable.includes(rel)) {
|
|
3935
|
+
res.writeHead(404);
|
|
3936
|
+
res.end();
|
|
3937
|
+
return;
|
|
3938
|
+
}
|
|
3939
|
+
const file = join(entry.dir, rel);
|
|
3940
|
+
const resolved = containedRealpath(entry.dir, file);
|
|
3941
|
+
if (resolved === void 0) {
|
|
3942
|
+
res.writeHead(403);
|
|
3943
|
+
res.end();
|
|
3944
|
+
return;
|
|
3945
|
+
}
|
|
3946
|
+
const cap = rel === "decoration.json" ? caps.manifest : caps.image;
|
|
3947
|
+
let stat;
|
|
3948
|
+
try {
|
|
3949
|
+
stat = statSync(resolved);
|
|
3950
|
+
if (stat.size > cap) {
|
|
3951
|
+
res.writeHead(413);
|
|
3952
|
+
res.end();
|
|
3953
|
+
return;
|
|
3954
|
+
}
|
|
3955
|
+
} catch {
|
|
3956
|
+
res.writeHead(404);
|
|
3957
|
+
res.end();
|
|
3958
|
+
return;
|
|
3959
|
+
}
|
|
3960
|
+
const etag = "\"" + stat.size.toString(16) + "-" + Math.round(stat.mtimeMs).toString(16) + "\"";
|
|
3961
|
+
if (req.headers["if-none-match"] === etag) {
|
|
3962
|
+
res.writeHead(304, {
|
|
3963
|
+
etag,
|
|
3964
|
+
"cache-control": "no-cache"
|
|
3965
|
+
});
|
|
3966
|
+
res.end();
|
|
3967
|
+
return;
|
|
3968
|
+
}
|
|
3969
|
+
readFile(resolved).then((body) => {
|
|
3970
|
+
res.writeHead(200, {
|
|
3971
|
+
"content-type": mimeFor(resolved),
|
|
3972
|
+
"content-length": String(body.byteLength),
|
|
3973
|
+
"cache-control": "no-cache",
|
|
3974
|
+
etag
|
|
3975
|
+
});
|
|
3976
|
+
if (req.method === "HEAD") {
|
|
3977
|
+
res.end();
|
|
3978
|
+
return;
|
|
3979
|
+
}
|
|
3980
|
+
res.end(body);
|
|
3981
|
+
}, () => {
|
|
3982
|
+
res.writeHead(404);
|
|
3983
|
+
res.end();
|
|
3984
|
+
});
|
|
3985
|
+
};
|
|
3986
|
+
}
|
|
3987
|
+
/** Build the full route family (API + assets + runtime) for one service. */
|
|
2384
3988
|
function makePetRoutes(deps) {
|
|
2385
3989
|
const { service } = deps;
|
|
2386
3990
|
const apiRoutes = [
|
|
2387
3991
|
getRoute("/api/pet/state", () => service.state()),
|
|
2388
3992
|
getRoute("/api/pet/pets", () => service.pets()),
|
|
3993
|
+
getRoute("/api/pet/diagnostics", () => service.diagnostics()),
|
|
2389
3994
|
postRoute("/api/pet/interact", (body) => {
|
|
2390
3995
|
const kind = body.kind;
|
|
2391
3996
|
if (kind !== "pet" && kind !== "feed") return Promise.reject(/* @__PURE__ */ new Error("invalid-kind"));
|
|
@@ -2416,9 +4021,27 @@ function makePetRoutes(deps) {
|
|
|
2416
4021
|
const assetRoute = {
|
|
2417
4022
|
kind: "prefix",
|
|
2418
4023
|
path: PET_ASSET_PREFIX,
|
|
2419
|
-
handler: assetHandler(service.registrySnapshot())
|
|
4024
|
+
handler: assetHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS)
|
|
4025
|
+
};
|
|
4026
|
+
const runtimeRoute = {
|
|
4027
|
+
kind: "prefix",
|
|
4028
|
+
path: PET_RUNTIME_PREFIX,
|
|
4029
|
+
handler: runtimeHandler({
|
|
4030
|
+
runtimeDir: deps.runtimeDir ?? join(dshHome(), "pets", ".runtime"),
|
|
4031
|
+
vendorDir: deps.vendorDir ?? join(petPackageRoot(import.meta.url), "lib")
|
|
4032
|
+
})
|
|
2420
4033
|
};
|
|
2421
|
-
|
|
4034
|
+
const decorationRoute = {
|
|
4035
|
+
kind: "prefix",
|
|
4036
|
+
path: DECORATION_ASSET_PREFIX,
|
|
4037
|
+
handler: decorationHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS)
|
|
4038
|
+
};
|
|
4039
|
+
return [
|
|
4040
|
+
...apiRoutes,
|
|
4041
|
+
assetRoute,
|
|
4042
|
+
runtimeRoute,
|
|
4043
|
+
decorationRoute
|
|
4044
|
+
];
|
|
2422
4045
|
}
|
|
2423
4046
|
//#endregion
|
|
2424
4047
|
//#region src/mount-once.ts
|
|
@@ -2482,7 +4105,8 @@ function makePetSettingsSchema(fallbackPetId) {
|
|
|
2482
4105
|
right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
|
|
2483
4106
|
bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
|
|
2484
4107
|
petId: z.string().default(fallbackPetId),
|
|
2485
|
-
enabled: z.boolean().default(true)
|
|
4108
|
+
enabled: z.boolean().default(true),
|
|
4109
|
+
decorationEnabled: z.boolean().default(true)
|
|
2486
4110
|
});
|
|
2487
4111
|
}
|
|
2488
4112
|
/** Register the pet service and its API + asset routes on the context. */
|
|
@@ -2503,7 +4127,8 @@ function applyImpl(ctx, config = {}) {
|
|
|
2503
4127
|
right: service.display().right,
|
|
2504
4128
|
bottom: service.display().bottom,
|
|
2505
4129
|
petId: service.selectedPetId(),
|
|
2506
|
-
enabled: config.enabled ?? true
|
|
4130
|
+
enabled: config.enabled ?? true,
|
|
4131
|
+
decorationEnabled: config.decorationEnabled ?? true
|
|
2507
4132
|
};
|
|
2508
4133
|
const routes = makePetRoutes({ service });
|
|
2509
4134
|
let disposeRoutes;
|