@linxin666/dsh-pet 0.2.3 → 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 +64 -1
- package/README.zh.md +64 -1
- package/assets/decorations/whale/decoration.json +20 -0
- package/assets/decorations/whale/whale-frames.png +0 -0
- package/contracts/pet-manifest-v2.schema.json +281 -0
- package/contracts/status-decoration-v1.schema.json +144 -0
- package/contracts/voice-pack-v1.schema.json +234 -0
- package/lib/client.js +144 -18
- package/lib/client.js.map +1 -1
- package/lib/index.js +979 -70
- package/lib/types/chatter.d.ts +61 -3
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +71 -12
- package/lib/types/client/PetSettingsCard.d.ts +4 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +3 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +103 -4
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/client/renderers/live2d.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d.js +13 -1
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- package/lib/types/contracts/status-decoration.d.ts +85 -0
- package/lib/types/contracts/status-decoration.d.ts.map +1 -0
- package/lib/types/contracts/status-decoration.js +21 -0
- package/lib/types/decoration.d.ts +39 -0
- package/lib/types/decoration.d.ts.map +1 -0
- package/lib/types/decoration.js +210 -0
- package/lib/types/event-projection.d.ts +8 -3
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +9 -4
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +2 -0
- package/lib/types/registry.d.ts +55 -2
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +198 -16
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +131 -3
- package/lib/types/service.d.ts +33 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +42 -3
- package/lib/types/voice-pack.d.ts +98 -0
- package/lib/types/voice-pack.d.ts.map +1 -0
- package/lib/types/voice-pack.js +384 -0
- package/package.json +11 -10
- package/src/chatter.test.ts +89 -2
- package/src/chatter.ts +120 -14
- package/src/client/PetSettingsCard.tsx +18 -0
- package/src/client/PetSprite.test.tsx +254 -12
- package/src/client/PetSprite.tsx +142 -25
- package/src/client/locales.ts +4 -0
- package/src/client/renderers/live2d.test.ts +23 -2
- package/src/client/renderers/live2d.ts +14 -1
- package/src/client/settings-form.ts +8 -6
- package/src/contracts/status-decoration.ts +78 -0
- package/src/decoration.test.ts +178 -0
- package/src/decoration.ts +220 -0
- package/src/event-projection.ts +10 -5
- package/src/index.ts +2 -0
- package/src/registry.test.ts +223 -0
- package/src/registry.ts +235 -15
- package/src/routes.ts +128 -3
- package/src/service.ts +59 -3
- package/src/voice-pack.test.ts +216 -0
- package/src/voice-pack.ts +413 -0
package/lib/index.js
CHANGED
|
@@ -145,6 +145,40 @@ const STATUS_POOLS = {
|
|
|
145
145
|
"蹲一个继续的指令"
|
|
146
146
|
]
|
|
147
147
|
};
|
|
148
|
+
/** Every status scene key, in declaration order (voice-pack key allow-list). */
|
|
149
|
+
const STATUS_SCENES = [
|
|
150
|
+
"prepare",
|
|
151
|
+
"waiting",
|
|
152
|
+
"thinking",
|
|
153
|
+
"review",
|
|
154
|
+
"toolResult",
|
|
155
|
+
"done",
|
|
156
|
+
"failed",
|
|
157
|
+
"toolFailed",
|
|
158
|
+
"maxTokens",
|
|
159
|
+
"interrupted",
|
|
160
|
+
"blocked"
|
|
161
|
+
];
|
|
162
|
+
/** Every tool-family key, in declaration order (voice-pack key allow-list). */
|
|
163
|
+
const TOOL_CATEGORIES = [
|
|
164
|
+
"read",
|
|
165
|
+
"write",
|
|
166
|
+
"edit",
|
|
167
|
+
"shell",
|
|
168
|
+
"grep",
|
|
169
|
+
"find",
|
|
170
|
+
"ls",
|
|
171
|
+
"webSearch",
|
|
172
|
+
"webFetch",
|
|
173
|
+
"mcp",
|
|
174
|
+
"memory",
|
|
175
|
+
"subagent",
|
|
176
|
+
"todo",
|
|
177
|
+
"browser",
|
|
178
|
+
"git",
|
|
179
|
+
"ask",
|
|
180
|
+
"generic"
|
|
181
|
+
];
|
|
148
182
|
/** Map a raw tool name onto its copy family (working-activity style regexes). */
|
|
149
183
|
function toolCategory(toolName) {
|
|
150
184
|
const name = toolName.toLowerCase();
|
|
@@ -420,12 +454,14 @@ function toolArgHint(toolName, argumentsJson) {
|
|
|
420
454
|
* stretch keeps changing its wording.
|
|
421
455
|
*/
|
|
422
456
|
var StatusVoice = class {
|
|
457
|
+
pools;
|
|
423
458
|
rotateMs;
|
|
424
459
|
counters = /* @__PURE__ */ new Map();
|
|
425
460
|
lastScene = "";
|
|
426
461
|
lastLine = "";
|
|
427
462
|
lastLineAt = Number.NEGATIVE_INFINITY;
|
|
428
|
-
constructor(rotateMs = STATUS_ROTATE_MS) {
|
|
463
|
+
constructor(pools = () => BUILTIN_VOICE_PACK, rotateMs = STATUS_ROTATE_MS) {
|
|
464
|
+
this.pools = pools;
|
|
429
465
|
this.rotateMs = rotateMs;
|
|
430
466
|
}
|
|
431
467
|
/** Draw the next line of one pool, advancing its round-robin cursor. */
|
|
@@ -442,18 +478,31 @@ var StatusVoice = class {
|
|
|
442
478
|
this.lastLineAt = nowMs;
|
|
443
479
|
return this.lastLine;
|
|
444
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* A scene's effective pool: the voice-pack override when it carries lines,
|
|
483
|
+
* else the built-in pool. Empty overrides fall back rather than blank the
|
|
484
|
+
* bubble — a scene line always renders.
|
|
485
|
+
*/
|
|
486
|
+
scenePool(scene) {
|
|
487
|
+
const override = this.pools().status?.[scene];
|
|
488
|
+
return override !== void 0 && override.length > 0 ? override : STATUS_POOLS[scene];
|
|
489
|
+
}
|
|
445
490
|
/** Status line for a phase scene. */
|
|
446
491
|
scene(scene, nowMs) {
|
|
447
|
-
return this.voice("scene:" + scene, "pool:" + scene,
|
|
492
|
+
return this.voice("scene:" + scene, "pool:" + scene, this.scenePool(scene), nowMs);
|
|
448
493
|
}
|
|
449
494
|
/** Status line for a tool call, with the real-argument hint when known. */
|
|
450
495
|
tool(toolName, displayName, hint, nowMs) {
|
|
451
496
|
const category = toolCategory(toolName);
|
|
452
|
-
|
|
497
|
+
const override = this.pools().tools?.[category];
|
|
498
|
+
const pool = override !== void 0 && override.length > 0 ? override : TOOL_POOLS[category];
|
|
499
|
+
return this.voice("tool:" + category, "tool:" + category, pool, nowMs).replaceAll("{tool}", displayName).replaceAll("{hint}", hint ?? displayName);
|
|
453
500
|
}
|
|
454
501
|
/** Status line while sibling tools still run (always reflects the count). */
|
|
455
502
|
toolRemaining(count, nowMs) {
|
|
456
|
-
|
|
503
|
+
const override = this.pools().toolRemaining;
|
|
504
|
+
const pool = override !== void 0 && override.length > 0 ? override : TOOL_REMAINING_POOL;
|
|
505
|
+
return this.voice("toolRemaining", "toolRemaining", pool, nowMs).replaceAll("{n}", String(count));
|
|
457
506
|
}
|
|
458
507
|
};
|
|
459
508
|
/** Murmur pacing: cooldown between whispers and output volume that earns one. */
|
|
@@ -786,26 +835,52 @@ const WHISPER_RULES = [
|
|
|
786
835
|
]
|
|
787
836
|
}
|
|
788
837
|
];
|
|
838
|
+
/** The built-in voice pack: the plugin's default copy, unchanged since v1. */
|
|
839
|
+
const BUILTIN_VOICE_PACK = {
|
|
840
|
+
status: STATUS_POOLS,
|
|
841
|
+
tools: TOOL_POOLS,
|
|
842
|
+
toolRemaining: TOOL_REMAINING_POOL,
|
|
843
|
+
whispers: {
|
|
844
|
+
generic: WHISPER_GENERIC_POOL,
|
|
845
|
+
rules: WHISPER_RULES
|
|
846
|
+
}
|
|
847
|
+
};
|
|
789
848
|
/**
|
|
790
849
|
* The murmur engine (碎碎念): watches the model's own output and lets the pet
|
|
791
850
|
* whisper its inner voice. Two ways to earn a whisper:
|
|
792
851
|
* - a keyword rule matches the fresh chunk text (themed whisper);
|
|
793
852
|
* - enough output volume flowed by without one (ambient whisper).
|
|
794
853
|
* A cooldown keeps whispers occasional; all picks are round-robin so tests
|
|
795
|
-
* reproduce exact lines.
|
|
854
|
+
* reproduce exact lines. The voice-pack provider (pet-center M4) swaps the
|
|
855
|
+
* pools at draw time, so a pet switch re-voices live engines in place.
|
|
796
856
|
*/
|
|
797
857
|
var WhisperEngine = class {
|
|
858
|
+
pools;
|
|
798
859
|
cooldownMs;
|
|
799
860
|
charBudget;
|
|
800
861
|
counters = /* @__PURE__ */ new Map();
|
|
801
862
|
genericCursor = 0;
|
|
802
863
|
lastWhisperAt = Number.NEGATIVE_INFINITY;
|
|
803
864
|
charsSinceWhisper = 0;
|
|
804
|
-
constructor(cooldownMs = WHISPER_COOLDOWN_MS, charBudget = 420) {
|
|
865
|
+
constructor(pools = () => BUILTIN_VOICE_PACK, cooldownMs = WHISPER_COOLDOWN_MS, charBudget = 420) {
|
|
866
|
+
this.pools = pools;
|
|
805
867
|
this.cooldownMs = cooldownMs;
|
|
806
868
|
this.charBudget = charBudget;
|
|
807
869
|
}
|
|
808
870
|
/**
|
|
871
|
+
* Effective keyword rules: an override replaces the built-in rules as a
|
|
872
|
+
* whole; an explicit empty array disables keyword-triggered whispers.
|
|
873
|
+
*/
|
|
874
|
+
rules() {
|
|
875
|
+
const override = this.pools().whispers?.rules;
|
|
876
|
+
return override === void 0 ? WHISPER_RULES : override;
|
|
877
|
+
}
|
|
878
|
+
/** Effective ambient pool (an explicit empty array mutes ambient whispers). */
|
|
879
|
+
generic() {
|
|
880
|
+
const override = this.pools().whispers?.generic;
|
|
881
|
+
return override === void 0 ? WHISPER_GENERIC_POOL : override;
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
809
884
|
* Feed one model-output chunk (reasoning or text). Returns the whisper to
|
|
810
885
|
* show, or undefined when the moment stays quiet.
|
|
811
886
|
*/
|
|
@@ -816,8 +891,9 @@ var WhisperEngine = class {
|
|
|
816
891
|
return;
|
|
817
892
|
}
|
|
818
893
|
const haystack = text.toLowerCase();
|
|
819
|
-
|
|
820
|
-
|
|
894
|
+
const rules = this.rules();
|
|
895
|
+
for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex += 1) {
|
|
896
|
+
const rule = rules[ruleIndex];
|
|
821
897
|
if (!rule.keywords.some((keyword) => haystack.includes(keyword))) continue;
|
|
822
898
|
const index = (this.counters.get(ruleIndex) ?? 0) % rule.pool.length;
|
|
823
899
|
this.counters.set(ruleIndex, index + 1);
|
|
@@ -825,7 +901,9 @@ var WhisperEngine = class {
|
|
|
825
901
|
}
|
|
826
902
|
this.charsSinceWhisper += text.length;
|
|
827
903
|
if (this.charsSinceWhisper < this.charBudget) return void 0;
|
|
828
|
-
const
|
|
904
|
+
const generic = this.generic();
|
|
905
|
+
if (generic.length === 0) return void 0;
|
|
906
|
+
const line = generic[this.genericCursor % generic.length];
|
|
829
907
|
this.genericCursor += 1;
|
|
830
908
|
return this.speak(line, nowMs);
|
|
831
909
|
}
|
|
@@ -837,14 +915,19 @@ var WhisperEngine = class {
|
|
|
837
915
|
};
|
|
838
916
|
//#endregion
|
|
839
917
|
//#region src/event-projection.ts
|
|
840
|
-
/**
|
|
841
|
-
|
|
918
|
+
/**
|
|
919
|
+
* Fresh projection runtime for a newly seen session. The optional voice-pack
|
|
920
|
+
* provider (pet-center M4, issue #677) hands both chatter engines their
|
|
921
|
+
* pools; engines resolve overrides at draw time, so swapping the provider's
|
|
922
|
+
* pack re-voices live runtimes without rebuilding them.
|
|
923
|
+
*/
|
|
924
|
+
function emptyProjectionRuntime(pools) {
|
|
842
925
|
return {
|
|
843
926
|
activeTools: /* @__PURE__ */ new Set(),
|
|
844
927
|
officialEventsSeen: false,
|
|
845
928
|
stepHadFailure: false,
|
|
846
|
-
voice: new StatusVoice(),
|
|
847
|
-
whispers: new WhisperEngine()
|
|
929
|
+
voice: new StatusVoice(pools),
|
|
930
|
+
whispers: new WhisperEngine(pools)
|
|
848
931
|
};
|
|
849
932
|
}
|
|
850
933
|
/** Keep tool names readable inside the compact status bubble. */
|
|
@@ -1373,6 +1456,307 @@ function savePetPersist(data, dir = petHomeDir()) {
|
|
|
1373
1456
|
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
|
|
1374
1457
|
renameSync(tmp, target);
|
|
1375
1458
|
}
|
|
1459
|
+
/** Hover-panel action buttons a pack can show or hide (canonical order). */
|
|
1460
|
+
const PANEL_ACTIONS = [
|
|
1461
|
+
"feed",
|
|
1462
|
+
"rename",
|
|
1463
|
+
"hide"
|
|
1464
|
+
];
|
|
1465
|
+
/** Panel label slots (unset slots keep the client's i18n dictionary copy). */
|
|
1466
|
+
const PANEL_LABEL_KEYS = [
|
|
1467
|
+
"feed",
|
|
1468
|
+
"rename",
|
|
1469
|
+
"hide",
|
|
1470
|
+
"confirm"
|
|
1471
|
+
];
|
|
1472
|
+
/** Panel stat slots ({rank}/{n}/{points} interpolate the live values). */
|
|
1473
|
+
const PANEL_STAT_KEYS = [
|
|
1474
|
+
"rank",
|
|
1475
|
+
"treats",
|
|
1476
|
+
"points"
|
|
1477
|
+
];
|
|
1478
|
+
/** Any '{token}' placeholder (no nesting, no newlines). */
|
|
1479
|
+
const PLACEHOLDER_PATTERN = /{[^{}]*}/g;
|
|
1480
|
+
/** Allowed placeholder tokens per pool kind (absent kind = none allowed). */
|
|
1481
|
+
const PLACEHOLDER_WHITELIST = {
|
|
1482
|
+
tools: ["{tool}", "{hint}"],
|
|
1483
|
+
toolRemaining: ["{n}"],
|
|
1484
|
+
stat: [
|
|
1485
|
+
"{rank}",
|
|
1486
|
+
"{n}",
|
|
1487
|
+
"{points}"
|
|
1488
|
+
]
|
|
1489
|
+
};
|
|
1490
|
+
function isRecord$2(value) {
|
|
1491
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1492
|
+
}
|
|
1493
|
+
/** Trim, length-cap and placeholder-check one copy line; undefined to drop. */
|
|
1494
|
+
function normalizeLine(raw, kind, onWarning) {
|
|
1495
|
+
const trimmed = raw.trim();
|
|
1496
|
+
if (trimmed === "") return void 0;
|
|
1497
|
+
let capped = trimmed.length > 160 ? trimmed.slice(0, 160) : trimmed;
|
|
1498
|
+
const dangling = capped.lastIndexOf("{");
|
|
1499
|
+
if (dangling !== -1 && capped.indexOf("}", dangling) === -1) {
|
|
1500
|
+
onWarning("line cut at an unterminated placeholder; tail dropped: " + capped.slice(0, 40) + "...");
|
|
1501
|
+
capped = capped.slice(0, dangling);
|
|
1502
|
+
}
|
|
1503
|
+
if (capped === "") return void 0;
|
|
1504
|
+
const allowed = PLACEHOLDER_WHITELIST[kind];
|
|
1505
|
+
const tokens = capped.match(PLACEHOLDER_PATTERN) ?? [];
|
|
1506
|
+
for (const token of tokens) {
|
|
1507
|
+
if (allowed?.includes(token) === true) continue;
|
|
1508
|
+
const preview = capped.length > 40 ? capped.slice(0, 40) + "..." : capped;
|
|
1509
|
+
onWarning("line dropped (unsupported placeholder " + token + "): " + preview);
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
return capped;
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* Normalize one pool slot. Accepts a single line or an array; non-string
|
|
1516
|
+
* entries warn and drop, empty lines drop silently, lines over the length
|
|
1517
|
+
* cap truncate, illegal placeholders drop the line, and pools over the line
|
|
1518
|
+
* cap keep their first lines. An explicit empty pool normalizes to [] (the
|
|
1519
|
+
* whisper channels read that as mute) while an absent slot normalizes to
|
|
1520
|
+
* undefined (the slot keeps the built-in pool).
|
|
1521
|
+
*/
|
|
1522
|
+
function normalizePool(raw, kind, onWarning = () => {}) {
|
|
1523
|
+
if (raw === void 0) return void 0;
|
|
1524
|
+
const entries = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : void 0;
|
|
1525
|
+
if (entries === void 0) {
|
|
1526
|
+
onWarning("pool must be a string or an array of strings");
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
if (entries.length > 64) onWarning("pool has more than 64 lines; extra lines are ignored");
|
|
1530
|
+
const pool = [];
|
|
1531
|
+
for (const entry of entries.slice(0, 64)) {
|
|
1532
|
+
if (typeof entry !== "string") {
|
|
1533
|
+
onWarning("non-string pool entry dropped");
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
const line = normalizeLine(entry, kind, onWarning);
|
|
1537
|
+
if (line !== void 0) pool.push(line);
|
|
1538
|
+
}
|
|
1539
|
+
return pool;
|
|
1540
|
+
}
|
|
1541
|
+
/** Normalize the ordered keyword-rule list ([] disables the keyword channel). */
|
|
1542
|
+
function normalizeWhisperRules(raw, onWarning = () => {}) {
|
|
1543
|
+
if (raw === void 0) return void 0;
|
|
1544
|
+
if (!Array.isArray(raw)) {
|
|
1545
|
+
onWarning("whispers.rules must be an array");
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (raw.length > 32) onWarning("whispers.rules has more than 32 rules; extra rules are ignored");
|
|
1549
|
+
const rules = [];
|
|
1550
|
+
for (const item of raw.slice(0, 32)) {
|
|
1551
|
+
if (!isRecord$2(item)) {
|
|
1552
|
+
onWarning("whisper rule must be an object");
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
for (const key of Object.keys(item)) if (key !== "keywords" && key !== "pool") onWarning("unknown whisper rule field " + key + " ignored");
|
|
1556
|
+
const keywordsRaw = item.keywords;
|
|
1557
|
+
const keywords = [];
|
|
1558
|
+
if (Array.isArray(keywordsRaw)) {
|
|
1559
|
+
for (const entry of keywordsRaw.slice(0, 16)) {
|
|
1560
|
+
if (typeof entry !== "string") {
|
|
1561
|
+
onWarning("non-string keyword dropped");
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
const trimmed = entry.trim().toLowerCase().slice(0, 40);
|
|
1565
|
+
if (trimmed !== "") keywords.push(trimmed);
|
|
1566
|
+
}
|
|
1567
|
+
if (keywordsRaw.length > 16) onWarning("rule has more than 16 keywords; extra keywords are ignored");
|
|
1568
|
+
}
|
|
1569
|
+
const pool = normalizePool(item.pool, "whisperRule", onWarning);
|
|
1570
|
+
if (keywords.length === 0 || pool === void 0 || pool.length === 0) {
|
|
1571
|
+
onWarning("whisper rule dropped (needs keywords and a non-empty pool)");
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1574
|
+
rules.push({
|
|
1575
|
+
keywords,
|
|
1576
|
+
pool
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
return rules;
|
|
1580
|
+
}
|
|
1581
|
+
/** Normalize the panel block (labels / stats / actions; warn-and-drop). */
|
|
1582
|
+
function normalizePanel(raw, onWarning = () => {}) {
|
|
1583
|
+
if (!isRecord$2(raw)) {
|
|
1584
|
+
onWarning("panel must be an object");
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
const panel = {};
|
|
1588
|
+
const labelsRaw = raw.labels;
|
|
1589
|
+
if (labelsRaw !== void 0) if (!isRecord$2(labelsRaw)) onWarning("panel.labels must be an object");
|
|
1590
|
+
else {
|
|
1591
|
+
const labels = {};
|
|
1592
|
+
for (const key of PANEL_LABEL_KEYS) {
|
|
1593
|
+
const value = labelsRaw[key];
|
|
1594
|
+
if (value === void 0) continue;
|
|
1595
|
+
if (typeof value !== "string") {
|
|
1596
|
+
onWarning("panel.labels." + key + " must be a string");
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
1599
|
+
const line = normalizeLine(value, "label", onWarning);
|
|
1600
|
+
if (line !== void 0) labels[key] = line.slice(0, 40);
|
|
1601
|
+
}
|
|
1602
|
+
if (Object.keys(labels).length > 0) panel.labels = labels;
|
|
1603
|
+
}
|
|
1604
|
+
const statsRaw = raw.stats;
|
|
1605
|
+
if (statsRaw !== void 0) if (!isRecord$2(statsRaw)) onWarning("panel.stats must be an object");
|
|
1606
|
+
else {
|
|
1607
|
+
const stats = {};
|
|
1608
|
+
for (const key of PANEL_STAT_KEYS) {
|
|
1609
|
+
const value = statsRaw[key];
|
|
1610
|
+
if (value === void 0) continue;
|
|
1611
|
+
if (typeof value !== "string") {
|
|
1612
|
+
onWarning("panel.stats." + key + " must be a string");
|
|
1613
|
+
continue;
|
|
1614
|
+
}
|
|
1615
|
+
const line = normalizeLine(value, "stat", onWarning);
|
|
1616
|
+
if (line !== void 0) stats[key] = line.slice(0, 80);
|
|
1617
|
+
}
|
|
1618
|
+
if (Object.keys(stats).length > 0) panel.stats = stats;
|
|
1619
|
+
}
|
|
1620
|
+
const actionsRaw = raw.actions;
|
|
1621
|
+
if (actionsRaw !== void 0) if (!Array.isArray(actionsRaw)) onWarning("panel.actions must be an array");
|
|
1622
|
+
else {
|
|
1623
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1624
|
+
for (const entry of actionsRaw) {
|
|
1625
|
+
if (typeof entry !== "string" || !PANEL_ACTIONS.includes(entry)) {
|
|
1626
|
+
onWarning("unknown panel action dropped: " + String(entry));
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
seen.add(entry);
|
|
1630
|
+
}
|
|
1631
|
+
panel.actions = PANEL_ACTIONS.filter((action) => seen.has(action));
|
|
1632
|
+
}
|
|
1633
|
+
if (panel.labels === void 0 && panel.stats === void 0 && panel.actions === void 0) return void 0;
|
|
1634
|
+
return panel;
|
|
1635
|
+
}
|
|
1636
|
+
/** Voice-pack top-level fields ('$schema' mirrors the schema twin; drift-locked in tests). */
|
|
1637
|
+
const VOICE_PACK_KEYS = /* @__PURE__ */ new Set([
|
|
1638
|
+
"$schema",
|
|
1639
|
+
"voicePackVersion",
|
|
1640
|
+
"status",
|
|
1641
|
+
"tools",
|
|
1642
|
+
"toolRemaining",
|
|
1643
|
+
"whispers",
|
|
1644
|
+
"panel"
|
|
1645
|
+
]);
|
|
1646
|
+
/** Allowed whisper-section fields (drift-locked in tests). */
|
|
1647
|
+
const WHISPER_KEYS = /* @__PURE__ */ new Set(["generic", "rules"]);
|
|
1648
|
+
/**
|
|
1649
|
+
* Normalize one raw voice.json document into a VoicePack, or undefined when
|
|
1650
|
+
* the file cannot serve as a pack at all (non-object root — structure is
|
|
1651
|
+
* fail-closed per file). Every slot issue is a warning, never a throw.
|
|
1652
|
+
*/
|
|
1653
|
+
function normalizeVoicePack(raw, onWarning = () => {}) {
|
|
1654
|
+
if (raw === void 0) return void 0;
|
|
1655
|
+
if (!isRecord$2(raw)) {
|
|
1656
|
+
onWarning("voice.json must be a JSON object; the file is ignored");
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
for (const key of Object.keys(raw)) if (!VOICE_PACK_KEYS.has(key)) onWarning("unknown top-level field " + key + " ignored");
|
|
1660
|
+
const version = raw.voicePackVersion;
|
|
1661
|
+
if (version !== void 0 && (typeof version !== "number" || version !== 1)) onWarning("voicePackVersion " + String(version) + " is not supported; reading as v1 best-effort");
|
|
1662
|
+
const overrides = {};
|
|
1663
|
+
const statusRaw = raw.status;
|
|
1664
|
+
if (statusRaw !== void 0) if (!isRecord$2(statusRaw)) onWarning("status must be an object");
|
|
1665
|
+
else for (const key of Object.keys(statusRaw)) {
|
|
1666
|
+
if (!STATUS_SCENES.includes(key)) {
|
|
1667
|
+
onWarning("unknown status scene " + key + " ignored");
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
const pool = normalizePool(statusRaw[key], "status", onWarning);
|
|
1671
|
+
if (pool !== void 0 && pool.length > 0) overrides.status = {
|
|
1672
|
+
...overrides.status,
|
|
1673
|
+
[key]: pool
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
const toolsRaw = raw.tools;
|
|
1677
|
+
if (toolsRaw !== void 0) if (!isRecord$2(toolsRaw)) onWarning("tools must be an object");
|
|
1678
|
+
else for (const key of Object.keys(toolsRaw)) {
|
|
1679
|
+
if (!TOOL_CATEGORIES.includes(key)) {
|
|
1680
|
+
onWarning("unknown tool family " + key + " ignored");
|
|
1681
|
+
continue;
|
|
1682
|
+
}
|
|
1683
|
+
const pool = normalizePool(toolsRaw[key], "tools", onWarning);
|
|
1684
|
+
if (pool !== void 0 && pool.length > 0) overrides.tools = {
|
|
1685
|
+
...overrides.tools,
|
|
1686
|
+
[key]: pool
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
const remainingRaw = raw.toolRemaining;
|
|
1690
|
+
if (remainingRaw !== void 0) {
|
|
1691
|
+
const pool = normalizePool(remainingRaw, "toolRemaining", onWarning);
|
|
1692
|
+
if (pool !== void 0 && pool.length > 0) overrides.toolRemaining = pool;
|
|
1693
|
+
}
|
|
1694
|
+
const whispersRaw = raw.whispers;
|
|
1695
|
+
if (whispersRaw !== void 0) if (!isRecord$2(whispersRaw)) onWarning("whispers must be an object");
|
|
1696
|
+
else {
|
|
1697
|
+
for (const key of Object.keys(whispersRaw)) if (!WHISPER_KEYS.has(key)) onWarning("unknown whispers field " + key + " ignored");
|
|
1698
|
+
const generic = normalizePool(whispersRaw.generic, "whisperGeneric", onWarning);
|
|
1699
|
+
const rules = normalizeWhisperRules(whispersRaw.rules, onWarning);
|
|
1700
|
+
if (generic !== void 0 || rules !== void 0) overrides.whispers = {
|
|
1701
|
+
...generic === void 0 ? {} : { generic },
|
|
1702
|
+
...rules === void 0 ? {} : { rules }
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
const panel = raw.panel === void 0 ? void 0 : normalizePanel(raw.panel, onWarning);
|
|
1706
|
+
if (overrides.status === void 0 && overrides.tools === void 0 && overrides.toolRemaining === void 0 && overrides.whispers === void 0 && panel === void 0) return;
|
|
1707
|
+
return {
|
|
1708
|
+
overrides,
|
|
1709
|
+
...panel === void 0 ? {} : { panel }
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Merge voice-pack layers into one pack; later layers win per slot. The
|
|
1714
|
+
* built-in pools are NOT a layer here — the chatter engines fall back to
|
|
1715
|
+
* them per key at draw time. Merge order for a selected pet:
|
|
1716
|
+
* mergeVoicePacks(registry.globalVoice, entry.voice).
|
|
1717
|
+
*/
|
|
1718
|
+
function mergeVoicePacks(...layers) {
|
|
1719
|
+
const overrides = {};
|
|
1720
|
+
const labels = {};
|
|
1721
|
+
const stats = {};
|
|
1722
|
+
let actions;
|
|
1723
|
+
let panelSeen = false;
|
|
1724
|
+
let any = false;
|
|
1725
|
+
for (const layer of layers) {
|
|
1726
|
+
if (layer === void 0) continue;
|
|
1727
|
+
any = true;
|
|
1728
|
+
if (layer.overrides.status !== void 0) overrides.status = {
|
|
1729
|
+
...overrides.status,
|
|
1730
|
+
...layer.overrides.status
|
|
1731
|
+
};
|
|
1732
|
+
if (layer.overrides.tools !== void 0) overrides.tools = {
|
|
1733
|
+
...overrides.tools,
|
|
1734
|
+
...layer.overrides.tools
|
|
1735
|
+
};
|
|
1736
|
+
if (layer.overrides.toolRemaining !== void 0) overrides.toolRemaining = layer.overrides.toolRemaining;
|
|
1737
|
+
if (layer.overrides.whispers !== void 0) overrides.whispers = {
|
|
1738
|
+
...overrides.whispers,
|
|
1739
|
+
...layer.overrides.whispers
|
|
1740
|
+
};
|
|
1741
|
+
if (layer.panel !== void 0) {
|
|
1742
|
+
panelSeen = true;
|
|
1743
|
+
if (layer.panel.labels !== void 0) Object.assign(labels, layer.panel.labels);
|
|
1744
|
+
if (layer.panel.stats !== void 0) Object.assign(stats, layer.panel.stats);
|
|
1745
|
+
if (layer.panel.actions !== void 0) actions = layer.panel.actions;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
if (!any) return void 0;
|
|
1749
|
+
const panel = {
|
|
1750
|
+
...Object.keys(labels).length > 0 ? { labels } : {},
|
|
1751
|
+
...Object.keys(stats).length > 0 ? { stats } : {},
|
|
1752
|
+
...actions === void 0 ? {} : { actions }
|
|
1753
|
+
};
|
|
1754
|
+
const panelEmpty = panel.labels === void 0 && panel.stats === void 0 && panel.actions === void 0;
|
|
1755
|
+
return {
|
|
1756
|
+
overrides,
|
|
1757
|
+
...panelSeen && !panelEmpty ? { panel } : {}
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1376
1760
|
/** Renderer kinds the pet center knows how to dispatch (M1 §2). */
|
|
1377
1761
|
const PET_RENDERER_KINDS = ["sprite2d", "live2d"];
|
|
1378
1762
|
/** The seven ActivityPhase semantics (pet-center owned; M1 §1). */
|
|
@@ -1385,8 +1769,8 @@ const PET_ACTIVITY_PHASES = [
|
|
|
1385
1769
|
"done",
|
|
1386
1770
|
"failed"
|
|
1387
1771
|
];
|
|
1388
|
-
const PET_ID_PATTERN$
|
|
1389
|
-
const PATH_SEGMENT_PATTERN$
|
|
1772
|
+
const PET_ID_PATTERN$2 = /^[a-z0-9][a-z0-9-]*$/;
|
|
1773
|
+
const PATH_SEGMENT_PATTERN$2 = /^[A-Za-z0-9._-]+$/;
|
|
1390
1774
|
const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
1391
1775
|
/**
|
|
1392
1776
|
* Field allow-lists mirroring contracts/pet-manifest-v2.schema.json. Exported
|
|
@@ -1428,7 +1812,7 @@ const KNOWN_LIVE2D = /* @__PURE__ */ new Set([
|
|
|
1428
1812
|
"hitAreas",
|
|
1429
1813
|
"lipSync"
|
|
1430
1814
|
]);
|
|
1431
|
-
var Diagnostics = class {
|
|
1815
|
+
var Diagnostics$1 = class {
|
|
1432
1816
|
list = [];
|
|
1433
1817
|
source;
|
|
1434
1818
|
constructor(source) {
|
|
@@ -1450,10 +1834,10 @@ var Diagnostics = class {
|
|
|
1450
1834
|
return this.list.some((d) => d.level === "error");
|
|
1451
1835
|
}
|
|
1452
1836
|
};
|
|
1453
|
-
function isRecord(value) {
|
|
1837
|
+
function isRecord$1(value) {
|
|
1454
1838
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1455
1839
|
}
|
|
1456
|
-
function unknownKeys(source, known) {
|
|
1840
|
+
function unknownKeys$1(source, known) {
|
|
1457
1841
|
return Object.keys(source).filter((key) => !known.has(key));
|
|
1458
1842
|
}
|
|
1459
1843
|
/**
|
|
@@ -1466,7 +1850,7 @@ function safeManifestPath(raw) {
|
|
|
1466
1850
|
if (isAbsolute(value) || value.includes("\\") || /^[a-z][a-z0-9+.-]*:/i.test(value)) return void 0;
|
|
1467
1851
|
const segments = value.split("/").filter((segment) => segment !== "");
|
|
1468
1852
|
if (segments.length === 0) return void 0;
|
|
1469
|
-
if (segments.some((segment) => segment === "." || segment === ".." || !PATH_SEGMENT_PATTERN$
|
|
1853
|
+
if (segments.some((segment) => segment === "." || segment === ".." || !PATH_SEGMENT_PATTERN$2.test(segment))) return void 0;
|
|
1470
1854
|
return segments.join("/");
|
|
1471
1855
|
}
|
|
1472
1856
|
function parseStringBlock(record, key, diag, required) {
|
|
@@ -1484,7 +1868,7 @@ function parseStringBlock(record, key, diag, required) {
|
|
|
1484
1868
|
/** Validate the phase-keyed string map shape shared by motions/expressions. */
|
|
1485
1869
|
function parsePhaseStringMap(raw, field, diag) {
|
|
1486
1870
|
if (raw === void 0) return void 0;
|
|
1487
|
-
if (!isRecord(raw)) {
|
|
1871
|
+
if (!isRecord$1(raw)) {
|
|
1488
1872
|
diag.error("field " + JSON.stringify(field) + " must be an object keyed by activity phase");
|
|
1489
1873
|
return;
|
|
1490
1874
|
}
|
|
@@ -1505,7 +1889,7 @@ function parsePhaseStringMap(raw, field, diag) {
|
|
|
1505
1889
|
/** Structural gate for sequences: content stays warn-and-drop (registry's job). */
|
|
1506
1890
|
function parseSequences(raw, diag) {
|
|
1507
1891
|
if (raw === void 0) return void 0;
|
|
1508
|
-
if (!isRecord(raw)) {
|
|
1892
|
+
if (!isRecord$1(raw)) {
|
|
1509
1893
|
diag.warn("sequences must be an object keyed by activity phase; ignoring");
|
|
1510
1894
|
return;
|
|
1511
1895
|
}
|
|
@@ -1524,16 +1908,16 @@ function parseSequences(raw, diag) {
|
|
|
1524
1908
|
return Object.keys(sequences).length === 0 ? void 0 : sequences;
|
|
1525
1909
|
}
|
|
1526
1910
|
function parseSprite2dBlock(raw, diag) {
|
|
1527
|
-
if (!isRecord(raw)) {
|
|
1911
|
+
if (!isRecord$1(raw)) {
|
|
1528
1912
|
diag.error("renderer sprite2d requires a \"sprite2d\" block object");
|
|
1529
1913
|
return;
|
|
1530
1914
|
}
|
|
1531
|
-
const extra = unknownKeys(raw, KNOWN_SPRITE2D);
|
|
1915
|
+
const extra = unknownKeys$1(raw, KNOWN_SPRITE2D);
|
|
1532
1916
|
if (extra.length > 0) diag.error("sprite2d: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
|
|
1533
1917
|
const spritesheetPath = safeManifestPath(raw.spritesheetPath);
|
|
1534
1918
|
if (spritesheetPath === void 0) diag.error("sprite2d.spritesheetPath must be a safe manifest-relative path");
|
|
1535
1919
|
const block = { spritesheetPath: spritesheetPath ?? "" };
|
|
1536
|
-
if (raw.cell !== void 0) if (!isRecord(raw.cell)) diag.error("sprite2d.cell must be an object { width?, height? }");
|
|
1920
|
+
if (raw.cell !== void 0) if (!isRecord$1(raw.cell)) diag.error("sprite2d.cell must be an object { width?, height? }");
|
|
1537
1921
|
else block.cell = raw.cell;
|
|
1538
1922
|
if (raw.columns !== void 0) if (typeof raw.columns !== "number" || !Number.isInteger(raw.columns) || raw.columns < 1) diag.error("sprite2d.columns must be a positive integer");
|
|
1539
1923
|
else block.columns = raw.columns;
|
|
@@ -1541,16 +1925,16 @@ function parseSprite2dBlock(raw, diag) {
|
|
|
1541
1925
|
else block.atlasRows = raw.atlasRows;
|
|
1542
1926
|
if (raw.frames !== void 0) if (!Array.isArray(raw.frames) || raw.frames.some((v) => typeof v !== "number" || !Number.isInteger(v) || v < 0)) diag.error("sprite2d.frames must be an array of non-negative integers");
|
|
1543
1927
|
else block.frames = raw.frames;
|
|
1544
|
-
if (raw.tracks !== void 0) if (!isRecord(raw.tracks)) diag.error("sprite2d.tracks must be an object keyed by animation");
|
|
1928
|
+
if (raw.tracks !== void 0) if (!isRecord$1(raw.tracks)) diag.error("sprite2d.tracks must be an object keyed by animation");
|
|
1545
1929
|
else block.tracks = raw.tracks;
|
|
1546
1930
|
return diag.hasErrors ? void 0 : block;
|
|
1547
1931
|
}
|
|
1548
1932
|
function parseLive2dBlock(raw, diag) {
|
|
1549
|
-
if (!isRecord(raw)) {
|
|
1933
|
+
if (!isRecord$1(raw)) {
|
|
1550
1934
|
diag.error("renderer live2d requires a \"live2d\" block object");
|
|
1551
1935
|
return;
|
|
1552
1936
|
}
|
|
1553
|
-
const extra = unknownKeys(raw, KNOWN_LIVE2D);
|
|
1937
|
+
const extra = unknownKeys$1(raw, KNOWN_LIVE2D);
|
|
1554
1938
|
if (extra.length > 0) diag.error("live2d: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
|
|
1555
1939
|
const model = safeManifestPath(raw.model);
|
|
1556
1940
|
if (model === void 0) diag.error("live2d.model must be a safe manifest-relative path to a .model3.json");
|
|
@@ -1564,7 +1948,7 @@ function parseLive2dBlock(raw, diag) {
|
|
|
1564
1948
|
};
|
|
1565
1949
|
if (raw.scale !== void 0) if (typeof raw.scale !== "number" || !Number.isFinite(raw.scale) || raw.scale <= 0 || raw.scale > 10) diag.error("live2d.scale must be a number in (0, 10]");
|
|
1566
1950
|
else block.scale = raw.scale;
|
|
1567
|
-
if (raw.translate !== void 0) if (!isRecord(raw.translate) || raw.translate.x !== void 0 && typeof raw.translate.x !== "number" || raw.translate.y !== void 0 && typeof raw.translate.y !== "number") diag.error("live2d.translate must be an object { x?: number, y?: number }");
|
|
1951
|
+
if (raw.translate !== void 0) if (!isRecord$1(raw.translate) || raw.translate.x !== void 0 && typeof raw.translate.x !== "number" || raw.translate.y !== void 0 && typeof raw.translate.y !== "number") diag.error("live2d.translate must be an object { x?: number, y?: number }");
|
|
1568
1952
|
else block.translate = raw.translate;
|
|
1569
1953
|
const expressions = parsePhaseStringMap(raw.expressions, "live2d.expressions", diag);
|
|
1570
1954
|
if (expressions !== void 0) block.expressions = expressions;
|
|
@@ -1577,16 +1961,16 @@ function parseLive2dBlock(raw, diag) {
|
|
|
1577
1961
|
/** v1 compat read: map the legacy flat manifest onto the v2 sprite2d shape. */
|
|
1578
1962
|
function compatV1(source, diag) {
|
|
1579
1963
|
const id = parseStringBlock(source, "id", diag, true);
|
|
1580
|
-
if (id !== void 0 && !PET_ID_PATTERN$
|
|
1964
|
+
if (id !== void 0 && !PET_ID_PATTERN$2.test(id)) diag.error("id " + JSON.stringify(id) + " is not a lowercase kebab id");
|
|
1581
1965
|
const displayName = typeof source.displayName === "string" && source.displayName.trim() !== "" ? source.displayName.trim() : id;
|
|
1582
1966
|
const spritesheetPath = safeManifestPath(source.spritesheetPath === void 0 ? "spritesheet.webp" : source.spritesheetPath);
|
|
1583
1967
|
if (spritesheetPath === void 0) diag.error("spritesheetPath " + JSON.stringify(String(source.spritesheetPath)) + " is not a safe relative path");
|
|
1584
1968
|
if (source.license === void 0) diag.warn("v1 compat read: no license field; run scripts/dsh-pet-migrate-v2 to migrate this pet");
|
|
1585
1969
|
const sprite2d = { spritesheetPath: spritesheetPath ?? "spritesheet.webp" };
|
|
1586
|
-
if (isRecord(source.cell)) sprite2d.cell = source.cell;
|
|
1970
|
+
if (isRecord$1(source.cell)) sprite2d.cell = source.cell;
|
|
1587
1971
|
if (typeof source.columns === "number") sprite2d.columns = source.columns;
|
|
1588
1972
|
if (Array.isArray(source.frames)) sprite2d.frames = source.frames;
|
|
1589
|
-
if (isRecord(source.tracks)) sprite2d.tracks = source.tracks;
|
|
1973
|
+
if (isRecord$1(source.tracks)) sprite2d.tracks = source.tracks;
|
|
1590
1974
|
if (source.spriteVersionNumber === 2) sprite2d.atlasRows = 11;
|
|
1591
1975
|
const manifest = {
|
|
1592
1976
|
petManifestVersion: 2,
|
|
@@ -1604,11 +1988,11 @@ function compatV1(source, diag) {
|
|
|
1604
1988
|
}
|
|
1605
1989
|
/** Strict v2 validation (fail-closed on structure). */
|
|
1606
1990
|
function parseV2(source, diag) {
|
|
1607
|
-
const extra = unknownKeys(source, KNOWN_TOP_LEVEL);
|
|
1991
|
+
const extra = unknownKeys$1(source, KNOWN_TOP_LEVEL);
|
|
1608
1992
|
if (extra.length > 0) diag.error("unknown top-level field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
|
|
1609
1993
|
if (source.petManifestVersion !== 2) diag.error("petManifestVersion must be 2 (got " + JSON.stringify(source.petManifestVersion) + ")");
|
|
1610
1994
|
const id = parseStringBlock(source, "id", diag, true);
|
|
1611
|
-
if (id !== void 0 && (!PET_ID_PATTERN$
|
|
1995
|
+
if (id !== void 0 && (!PET_ID_PATTERN$2.test(id) || id.length > 64)) diag.error("id " + JSON.stringify(id) + " must be a lowercase kebab id of at most 64 chars");
|
|
1612
1996
|
const displayName = parseStringBlock(source, "displayName", diag, true);
|
|
1613
1997
|
const license = parseStringBlock(source, "license", diag, true);
|
|
1614
1998
|
const rendererRaw = source.renderer === void 0 ? "sprite2d" : source.renderer;
|
|
@@ -1640,7 +2024,7 @@ function parseV2(source, diag) {
|
|
|
1640
2024
|
}
|
|
1641
2025
|
const sequences = parseSequences(source.sequences, diag);
|
|
1642
2026
|
if (sequences !== void 0) manifest.sequences = sequences;
|
|
1643
|
-
if (source.remarks !== void 0 && !isRecord(source.remarks)) diag.error("remarks must be an object of remark pools");
|
|
2027
|
+
if (source.remarks !== void 0 && !isRecord$1(source.remarks)) diag.error("remarks must be an object of remark pools");
|
|
1644
2028
|
else if (source.remarks !== void 0) manifest.remarks = source.remarks;
|
|
1645
2029
|
return diag.hasErrors ? void 0 : manifest;
|
|
1646
2030
|
}
|
|
@@ -1652,8 +2036,8 @@ function parseV2(source, diag) {
|
|
|
1652
2036
|
* @param sourceLabel - human-readable origin for diagnostics (dir or file).
|
|
1653
2037
|
*/
|
|
1654
2038
|
function parsePetManifest(raw, sourceLabel) {
|
|
1655
|
-
const diag = new Diagnostics(sourceLabel);
|
|
1656
|
-
if (!isRecord(raw)) {
|
|
2039
|
+
const diag = new Diagnostics$1(sourceLabel);
|
|
2040
|
+
if (!isRecord$1(raw)) {
|
|
1657
2041
|
diag.error("manifest is not an object");
|
|
1658
2042
|
return {
|
|
1659
2043
|
ok: false,
|
|
@@ -1686,6 +2070,191 @@ function parsePetManifest(raw, sourceLabel) {
|
|
|
1686
2070
|
diagnostics: diag.list
|
|
1687
2071
|
};
|
|
1688
2072
|
}
|
|
2073
|
+
const DECORATION_ENTRY_EXTENSIONS = [".webp", ".png"];
|
|
2074
|
+
/** Field allow-list (drift-locked to the schema twin in tests). */
|
|
2075
|
+
const KNOWN_DECORATION_TOP_LEVEL = /* @__PURE__ */ new Set([
|
|
2076
|
+
"$schema",
|
|
2077
|
+
"decorationManifestVersion",
|
|
2078
|
+
"id",
|
|
2079
|
+
"displayName",
|
|
2080
|
+
"license",
|
|
2081
|
+
"entry",
|
|
2082
|
+
"cell",
|
|
2083
|
+
"columns",
|
|
2084
|
+
"frameMs",
|
|
2085
|
+
"durations",
|
|
2086
|
+
"loop",
|
|
2087
|
+
"phases"
|
|
2088
|
+
]);
|
|
2089
|
+
const PET_ID_PATTERN$1 = /^[a-z0-9][a-z0-9-]*$/;
|
|
2090
|
+
const PATH_SEGMENT_PATTERN$1 = /^[A-Za-z0-9._-]+$/;
|
|
2091
|
+
var Diagnostics = class {
|
|
2092
|
+
list = [];
|
|
2093
|
+
source;
|
|
2094
|
+
constructor(source) {
|
|
2095
|
+
this.source = source;
|
|
2096
|
+
}
|
|
2097
|
+
error(message) {
|
|
2098
|
+
this.list.push({
|
|
2099
|
+
level: "error",
|
|
2100
|
+
message: this.source + ": " + message
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
warn(message) {
|
|
2104
|
+
this.list.push({
|
|
2105
|
+
level: "warning",
|
|
2106
|
+
message: this.source + ": " + message
|
|
2107
|
+
});
|
|
2108
|
+
}
|
|
2109
|
+
get hasErrors() {
|
|
2110
|
+
return this.list.some((d) => d.level === "error");
|
|
2111
|
+
}
|
|
2112
|
+
};
|
|
2113
|
+
function isRecord(value) {
|
|
2114
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2115
|
+
}
|
|
2116
|
+
function unknownKeys(source, known) {
|
|
2117
|
+
return Object.keys(source).filter((key) => !known.has(key));
|
|
2118
|
+
}
|
|
2119
|
+
/** Positive integer in [min, max], else undefined. */
|
|
2120
|
+
function finiteInt$1(value, min, max) {
|
|
2121
|
+
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max ? value : void 0;
|
|
2122
|
+
}
|
|
2123
|
+
/**
|
|
2124
|
+
* Validate a descriptor-relative entry path: no absolute paths, no
|
|
2125
|
+
* backslashes, no traversal, plain safe segments only, and an exact
|
|
2126
|
+
* lowercase PNG/WebP extension (the adopted entry discipline — SVG/CSS are
|
|
2127
|
+
* not accepted). The extension match is case-sensitive on purpose: the
|
|
2128
|
+
* asset route serves the declared path verbatim, so a case-mismatched
|
|
2129
|
+
* suffix (frames.PNG vs frames.png) would pass a lenient check but 403 on
|
|
2130
|
+
* case-sensitive filesystems.
|
|
2131
|
+
* Returns the normalized path or undefined.
|
|
2132
|
+
*/
|
|
2133
|
+
function safeDecorationEntry(raw) {
|
|
2134
|
+
if (typeof raw !== "string" || raw.trim() === "") return void 0;
|
|
2135
|
+
const value = raw.trim();
|
|
2136
|
+
if (value.length > 256) return void 0;
|
|
2137
|
+
if (isAbsolute(value) || value.includes("\\") || /^[a-z][a-z0-9+.-]*:/i.test(value)) return void 0;
|
|
2138
|
+
const segments = value.split("/").filter((segment) => segment !== "");
|
|
2139
|
+
if (segments.length === 0) return void 0;
|
|
2140
|
+
if (segments.some((segment) => segment === "." || segment === ".." || !PATH_SEGMENT_PATTERN$1.test(segment))) return void 0;
|
|
2141
|
+
const last = segments[segments.length - 1];
|
|
2142
|
+
const dot = last.lastIndexOf(".");
|
|
2143
|
+
if (dot <= 0 || !DECORATION_ENTRY_EXTENSIONS.includes(last.slice(dot))) return void 0;
|
|
2144
|
+
return segments.join("/");
|
|
2145
|
+
}
|
|
2146
|
+
/** Normalize one phase binding value; undefined (warning) on bad content. */
|
|
2147
|
+
function normalizeSegment(raw, columns, diag) {
|
|
2148
|
+
if (raw === "hide") return "hide";
|
|
2149
|
+
if (!isRecord(raw)) {
|
|
2150
|
+
diag.warn("phase binding must be \"hide\" or { from, to }; binding dropped");
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
const from = finiteInt$1(raw.from, 0, columns - 1);
|
|
2154
|
+
const to = finiteInt$1(raw.to, 0, columns - 1);
|
|
2155
|
+
if (from === void 0 || to === void 0 || from > to) {
|
|
2156
|
+
diag.warn("phase frame segment out of range; binding dropped");
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
return {
|
|
2160
|
+
from,
|
|
2161
|
+
to
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* Parse and validate one decoration.json document. Fail-closed over the
|
|
2166
|
+
* structure (types, key sets, paths, ranges); phase-binding content issues
|
|
2167
|
+
* drop that binding only (warn-and-drop, the registry never-throw rule).
|
|
2168
|
+
*/
|
|
2169
|
+
function parseDecorationManifest(raw, source = "decoration.json") {
|
|
2170
|
+
const diag = new Diagnostics(source);
|
|
2171
|
+
if (!isRecord(raw)) {
|
|
2172
|
+
diag.error("descriptor must be a JSON object");
|
|
2173
|
+
return {
|
|
2174
|
+
ok: false,
|
|
2175
|
+
diagnostics: diag.list
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
for (const key of unknownKeys(raw, KNOWN_DECORATION_TOP_LEVEL)) diag.error("unknown top-level field " + JSON.stringify(key));
|
|
2179
|
+
if (raw.decorationManifestVersion !== 1) diag.error("decorationManifestVersion must be 1");
|
|
2180
|
+
const id = typeof raw.id === "string" ? raw.id.trim() : "";
|
|
2181
|
+
if (!PET_ID_PATTERN$1.test(id)) diag.error("id must be a lowercase kebab id");
|
|
2182
|
+
if (id.length > 64) diag.error("id must be at most 64 characters");
|
|
2183
|
+
const license = typeof raw.license === "string" ? raw.license.trim() : "";
|
|
2184
|
+
if (license === "") diag.error("license is required (asset provenance)");
|
|
2185
|
+
if (license.length > 128) diag.error("license must be at most 128 characters");
|
|
2186
|
+
const entry = safeDecorationEntry(raw.entry);
|
|
2187
|
+
if (entry === void 0) diag.error("entry must be a safe relative PNG/WebP path");
|
|
2188
|
+
const rawCell = isRecord(raw.cell) ? raw.cell : {};
|
|
2189
|
+
for (const key of Object.keys(rawCell)) if (key !== "width" && key !== "height") diag.warn("unknown cell field " + JSON.stringify(key) + " ignored");
|
|
2190
|
+
const cellWidth = finiteInt$1(rawCell.width, 1, 256);
|
|
2191
|
+
const cellHeight = finiteInt$1(rawCell.height, 1, 256);
|
|
2192
|
+
if (cellWidth === void 0 || cellHeight === void 0) diag.error("cell width/height must be integers in [1, 256]");
|
|
2193
|
+
const columns = finiteInt$1(raw.columns, 1, 16);
|
|
2194
|
+
if (columns === void 0) diag.error("columns must be an integer in [1, 16]");
|
|
2195
|
+
if (diag.hasErrors || id === "" || entry === void 0 || columns === void 0) return {
|
|
2196
|
+
ok: false,
|
|
2197
|
+
diagnostics: diag.list
|
|
2198
|
+
};
|
|
2199
|
+
const displayName = typeof raw.displayName === "string" && raw.displayName.trim() !== "" ? raw.displayName.trim().slice(0, 64) : id;
|
|
2200
|
+
let loop;
|
|
2201
|
+
if (raw.loop === void 0 || typeof raw.loop === "boolean") loop = raw.loop ?? true;
|
|
2202
|
+
else {
|
|
2203
|
+
diag.warn("loop must be a boolean; defaulting to true");
|
|
2204
|
+
loop = true;
|
|
2205
|
+
}
|
|
2206
|
+
const rawDurations = raw.durations;
|
|
2207
|
+
let durations;
|
|
2208
|
+
if (Array.isArray(rawDurations)) {
|
|
2209
|
+
const usable = rawDurations.filter((v) => typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 2e3);
|
|
2210
|
+
if (usable.length !== columns) {
|
|
2211
|
+
diag.warn("durations length must equal columns; using the constant frameMs instead");
|
|
2212
|
+
durations = [];
|
|
2213
|
+
} else durations = usable;
|
|
2214
|
+
} else if (rawDurations !== void 0) {
|
|
2215
|
+
diag.warn("durations must be an array; using the constant frameMs instead");
|
|
2216
|
+
durations = [];
|
|
2217
|
+
} else durations = [];
|
|
2218
|
+
if (durations.length === 0) {
|
|
2219
|
+
const frameMs = finiteInt$1(raw.frameMs, 1, 2e3) ?? 120;
|
|
2220
|
+
durations = Array.from({ length: columns }, () => frameMs);
|
|
2221
|
+
}
|
|
2222
|
+
const phases = {};
|
|
2223
|
+
const rawPhases = raw.phases;
|
|
2224
|
+
if (isRecord(rawPhases)) for (const [key, value] of Object.entries(rawPhases)) {
|
|
2225
|
+
if (!PET_ACTIVITY_PHASES.includes(key)) {
|
|
2226
|
+
diag.warn("unknown phase " + JSON.stringify(key) + "; binding ignored");
|
|
2227
|
+
continue;
|
|
2228
|
+
}
|
|
2229
|
+
const segment = normalizeSegment(value, columns, diag);
|
|
2230
|
+
if (segment !== void 0) phases[key] = segment;
|
|
2231
|
+
}
|
|
2232
|
+
else if (rawPhases !== void 0) diag.warn("phases must be an object; all phases hide");
|
|
2233
|
+
if (!Object.values(phases).some((segment) => segment !== "hide")) diag.warn("no phase shows the ornament; the decoration stays hidden");
|
|
2234
|
+
return {
|
|
2235
|
+
ok: true,
|
|
2236
|
+
manifest: {
|
|
2237
|
+
decorationManifestVersion: 1,
|
|
2238
|
+
id,
|
|
2239
|
+
displayName,
|
|
2240
|
+
license,
|
|
2241
|
+
entry,
|
|
2242
|
+
cell: {
|
|
2243
|
+
width: cellWidth,
|
|
2244
|
+
height: cellHeight
|
|
2245
|
+
},
|
|
2246
|
+
columns,
|
|
2247
|
+
durations,
|
|
2248
|
+
loop,
|
|
2249
|
+
phases
|
|
2250
|
+
},
|
|
2251
|
+
diagnostics: diag.list
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
//#endregion
|
|
2255
|
+
//#region src/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";
|
|
1689
2258
|
//#endregion
|
|
1690
2259
|
//#region src/model3.ts
|
|
1691
2260
|
/**
|
|
@@ -2198,24 +2767,28 @@ function scanPetDir(dir, options) {
|
|
|
2198
2767
|
options.warnings?.push(diagnostic.message);
|
|
2199
2768
|
}
|
|
2200
2769
|
if (!verdict.ok) continue;
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
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);
|
|
2216
2785
|
}
|
|
2217
|
-
|
|
2218
|
-
|
|
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
|
+
});
|
|
2219
2792
|
}
|
|
2220
2793
|
return entries;
|
|
2221
2794
|
}
|
|
@@ -2229,6 +2802,135 @@ function readPetJson(file, warnings) {
|
|
|
2229
2802
|
}
|
|
2230
2803
|
}
|
|
2231
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
|
+
/**
|
|
2232
2934
|
* Load the pet registry: built-in 'assets/*' first, then the hatch-pet
|
|
2233
2935
|
* custom pets directory, then composed 'extra' manifests (each later source
|
|
2234
2936
|
* overrides an earlier one on id collision). The registry never throws on a
|
|
@@ -2262,13 +2964,20 @@ function loadPetRegistry(options) {
|
|
|
2262
2964
|
byId.set(entry.id, entry);
|
|
2263
2965
|
}
|
|
2264
2966
|
const dshPetsDir = options.dshPetsDir ?? join(dshHome(), "pets");
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
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
|
+
});
|
|
2272
2981
|
}
|
|
2273
2982
|
for (const manifest of options.extra ?? []) {
|
|
2274
2983
|
const raw = manifest.spritesheetPath;
|
|
@@ -2284,17 +2993,55 @@ function loadPetRegistry(options) {
|
|
|
2284
2993
|
if (byId.has(entry.id)) warnings.push("composed pet " + entry.id + " overrides an earlier registration");
|
|
2285
2994
|
byId.set(entry.id, entry);
|
|
2286
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
|
+
}
|
|
2287
3008
|
const entries = [...byId.values()];
|
|
3009
|
+
const decorations = [...decorationById.values()];
|
|
2288
3010
|
return {
|
|
2289
3011
|
entries,
|
|
2290
3012
|
warnings,
|
|
2291
3013
|
diagnostics,
|
|
2292
3014
|
byId: (id) => byId.get(id),
|
|
2293
|
-
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)
|
|
3019
|
+
};
|
|
3020
|
+
}
|
|
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
|
|
2294
3035
|
};
|
|
2295
3036
|
}
|
|
2296
|
-
/**
|
|
2297
|
-
|
|
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;
|
|
2298
3045
|
return {
|
|
2299
3046
|
id: entry.id,
|
|
2300
3047
|
displayName: entry.displayName,
|
|
@@ -2308,7 +3055,8 @@ function petEntryView(entry) {
|
|
|
2308
3055
|
tracks: entry.tracks,
|
|
2309
3056
|
...entry.sequences === void 0 ? {} : { sequences: entry.sequences },
|
|
2310
3057
|
atlasUrl: entry.atlasUrl,
|
|
2311
|
-
manifestUrl: entry.manifestUrl
|
|
3058
|
+
manifestUrl: entry.manifestUrl,
|
|
3059
|
+
...panel === void 0 ? {} : { panel }
|
|
2312
3060
|
};
|
|
2313
3061
|
}
|
|
2314
3062
|
/** Hard cap on simultaneously displayed session bubbles (most recent first). */
|
|
@@ -2327,10 +3075,18 @@ var PetService = class extends Service {
|
|
|
2327
3075
|
registry;
|
|
2328
3076
|
persistDir;
|
|
2329
3077
|
enabled;
|
|
3078
|
+
/** Status-decoration master switch (M5, #567); mirrored from settings. */
|
|
3079
|
+
decorationEnabled;
|
|
2330
3080
|
disposeActivity;
|
|
2331
3081
|
/** Session whose most recent meaningful event currently drives the global pet. */
|
|
2332
3082
|
displaySession;
|
|
2333
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
|
+
/**
|
|
2334
3090
|
* Per-session activity, most recent last (Map insertion order). Bounded by
|
|
2335
3091
|
* MAX_SESSION_BUBBLES so a burst of sessions cannot grow it without bound;
|
|
2336
3092
|
* disposed sessions are removed by the 'session/disposed' listener.
|
|
@@ -2362,8 +3118,26 @@ var PetService = class extends Service {
|
|
|
2362
3118
|
};
|
|
2363
3119
|
this.machine = new PetStateMachine(this.stateConfig);
|
|
2364
3120
|
this.enabled = config.enabled ?? true;
|
|
3121
|
+
this.decorationEnabled = config.decorationEnabled ?? true;
|
|
2365
3122
|
this.syncActivity();
|
|
2366
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
|
+
}
|
|
2367
3141
|
/** Whether the pet service consumes session activity while enabled. */
|
|
2368
3142
|
isEnabled() {
|
|
2369
3143
|
return this.enabled;
|
|
@@ -2378,7 +3152,7 @@ var PetService = class extends Service {
|
|
|
2378
3152
|
}
|
|
2379
3153
|
/** RPC: the registry entries the browser half renders and selects from. */
|
|
2380
3154
|
async pets() {
|
|
2381
|
-
return this.registry.entries.map(petEntryView);
|
|
3155
|
+
return this.registry.entries.map((entry) => petEntryView(entry, this.registry.globalVoice));
|
|
2382
3156
|
}
|
|
2383
3157
|
/** The loaded registry (the asset routes serve its entries). */
|
|
2384
3158
|
registrySnapshot() {
|
|
@@ -2388,6 +3162,15 @@ var PetService = class extends Service {
|
|
|
2388
3162
|
async diagnostics() {
|
|
2389
3163
|
return { diagnostics: this.registry.diagnostics };
|
|
2390
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
|
+
}
|
|
2391
3174
|
/** The selected pet's registry entry. */
|
|
2392
3175
|
activeEntry() {
|
|
2393
3176
|
return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry();
|
|
@@ -2471,7 +3254,7 @@ var PetService = class extends Service {
|
|
|
2471
3254
|
let activity = this.sessionActivity.get(session);
|
|
2472
3255
|
if (activity === void 0) {
|
|
2473
3256
|
activity = {
|
|
2474
|
-
runtime: emptyProjectionRuntime(),
|
|
3257
|
+
runtime: emptyProjectionRuntime(this.voicePools()),
|
|
2475
3258
|
machine: new PetStateMachine(this.stateConfig)
|
|
2476
3259
|
};
|
|
2477
3260
|
this.sessionActivity.set(session, activity);
|
|
@@ -2565,6 +3348,7 @@ var PetService = class extends Service {
|
|
|
2565
3348
|
* @param section - the resolved settings section.
|
|
2566
3349
|
*/
|
|
2567
3350
|
applySettingsSection(section) {
|
|
3351
|
+
this.decorationEnabled = section.decorationEnabled ?? true;
|
|
2568
3352
|
const selected = typeof section.petId === "string" ? this.registry.byId(section.petId) : void 0;
|
|
2569
3353
|
if (selected !== void 0) {
|
|
2570
3354
|
this.ledger.setPetId(selected.id);
|
|
@@ -2617,6 +3401,7 @@ var PetService = class extends Service {
|
|
|
2617
3401
|
}
|
|
2618
3402
|
const whisper = (this.displaySession === void 0 ? void 0 : this.sessionActivity.get(this.displaySession))?.whisper;
|
|
2619
3403
|
const freshWhisper = whisper !== void 0 && Date.now() - whisper.at < 8e3 ? whisper.text : void 0;
|
|
3404
|
+
const decoration = this.activeDecoration();
|
|
2620
3405
|
return {
|
|
2621
3406
|
animation: snapshot.animation,
|
|
2622
3407
|
...snapshot.bubble === void 0 ? {} : { bubble: snapshot.bubble },
|
|
@@ -2624,6 +3409,7 @@ var PetService = class extends Service {
|
|
|
2624
3409
|
sessionActive: snapshot.sessionActive,
|
|
2625
3410
|
sessions,
|
|
2626
3411
|
...freshWhisper === void 0 ? {} : { whisper: freshWhisper },
|
|
3412
|
+
...decoration === void 0 ? {} : { decoration },
|
|
2627
3413
|
affinity: this.ledger.affinityView(Date.now()),
|
|
2628
3414
|
display: { ...this.ledger.snapshot.display },
|
|
2629
3415
|
pet: {
|
|
@@ -2935,7 +3721,7 @@ function assetHandler(registry, caps) {
|
|
|
2935
3721
|
file = existsSync(preview) ? preview : void 0;
|
|
2936
3722
|
}
|
|
2937
3723
|
if (synthesized) {
|
|
2938
|
-
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");
|
|
2939
3725
|
res.writeHead(200, {
|
|
2940
3726
|
"content-type": "application/json; charset=utf-8",
|
|
2941
3727
|
"content-length": String(body.byteLength),
|
|
@@ -3083,6 +3869,121 @@ function runtimeHandler(roots) {
|
|
|
3083
3869
|
});
|
|
3084
3870
|
};
|
|
3085
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
|
+
}
|
|
3086
3987
|
/** Build the full route family (API + assets + runtime) for one service. */
|
|
3087
3988
|
function makePetRoutes(deps) {
|
|
3088
3989
|
const { service } = deps;
|
|
@@ -3130,10 +4031,16 @@ function makePetRoutes(deps) {
|
|
|
3130
4031
|
vendorDir: deps.vendorDir ?? join(petPackageRoot(import.meta.url), "lib")
|
|
3131
4032
|
})
|
|
3132
4033
|
};
|
|
4034
|
+
const decorationRoute = {
|
|
4035
|
+
kind: "prefix",
|
|
4036
|
+
path: DECORATION_ASSET_PREFIX,
|
|
4037
|
+
handler: decorationHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS)
|
|
4038
|
+
};
|
|
3133
4039
|
return [
|
|
3134
4040
|
...apiRoutes,
|
|
3135
4041
|
assetRoute,
|
|
3136
|
-
runtimeRoute
|
|
4042
|
+
runtimeRoute,
|
|
4043
|
+
decorationRoute
|
|
3137
4044
|
];
|
|
3138
4045
|
}
|
|
3139
4046
|
//#endregion
|
|
@@ -3198,7 +4105,8 @@ function makePetSettingsSchema(fallbackPetId) {
|
|
|
3198
4105
|
right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
|
|
3199
4106
|
bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
|
|
3200
4107
|
petId: z.string().default(fallbackPetId),
|
|
3201
|
-
enabled: z.boolean().default(true)
|
|
4108
|
+
enabled: z.boolean().default(true),
|
|
4109
|
+
decorationEnabled: z.boolean().default(true)
|
|
3202
4110
|
});
|
|
3203
4111
|
}
|
|
3204
4112
|
/** Register the pet service and its API + asset routes on the context. */
|
|
@@ -3219,7 +4127,8 @@ function applyImpl(ctx, config = {}) {
|
|
|
3219
4127
|
right: service.display().right,
|
|
3220
4128
|
bottom: service.display().bottom,
|
|
3221
4129
|
petId: service.selectedPetId(),
|
|
3222
|
-
enabled: config.enabled ?? true
|
|
4130
|
+
enabled: config.enabled ?? true,
|
|
4131
|
+
decorationEnabled: config.decorationEnabled ?? true
|
|
3223
4132
|
};
|
|
3224
4133
|
const routes = makePetRoutes({ service });
|
|
3225
4134
|
let disposeRoutes;
|