@lingfan-engine/editor 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -201
- package/package.json +2 -2
- package/src/contracts/diagnostics.ts +62 -62
- package/src/contracts/index.ts +22 -22
- package/src/diagnostics/analyze.ts +161 -161
- package/src/i18n/reconcile.ts +71 -71
- package/src/i18n/skeleton.ts +112 -112
- package/src/i18n/table.ts +235 -235
- package/src/schema/opSchemas/commands.ts +374 -374
- package/src/schema/opSchemas/map.ts +134 -134
- package/src/schema/opSchemas/validate.ts +184 -184
- package/src/script/expr/audit/parser.ts +370 -370
- package/src/script/expr/build.ts +203 -203
- package/src/script/expr/registry.ts +130 -130
|
@@ -1,134 +1,134 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import {
|
|
3
|
-
saySchema,
|
|
4
|
-
menuSchema,
|
|
5
|
-
inputSchema,
|
|
6
|
-
notifySchema,
|
|
7
|
-
nvlSchema,
|
|
8
|
-
characterSchema,
|
|
9
|
-
uncharacterSchema,
|
|
10
|
-
waitSchema,
|
|
11
|
-
pauseSchema,
|
|
12
|
-
randomSchema,
|
|
13
|
-
jumpSchema,
|
|
14
|
-
navigateSchema,
|
|
15
|
-
funcSchema,
|
|
16
|
-
callSchema,
|
|
17
|
-
returnSchema,
|
|
18
|
-
ifSchema,
|
|
19
|
-
whileSchema,
|
|
20
|
-
forSchema,
|
|
21
|
-
foreachSchema,
|
|
22
|
-
switchSchema,
|
|
23
|
-
emptySchema,
|
|
24
|
-
assertSchema,
|
|
25
|
-
guardSchema,
|
|
26
|
-
assignSchema,
|
|
27
|
-
undefSchema,
|
|
28
|
-
arraySchema,
|
|
29
|
-
arrayPushSchema,
|
|
30
|
-
arrayPopSchema,
|
|
31
|
-
dictSchema,
|
|
32
|
-
dictSetSchema,
|
|
33
|
-
saveSchema,
|
|
34
|
-
loadSchema,
|
|
35
|
-
autoSaveSchema,
|
|
36
|
-
saveDeleteSchema,
|
|
37
|
-
bgmSchema,
|
|
38
|
-
stopSchema,
|
|
39
|
-
seSchema,
|
|
40
|
-
voiceSchema,
|
|
41
|
-
videoSchema,
|
|
42
|
-
cutsceneSchema,
|
|
43
|
-
seekVideoSchema,
|
|
44
|
-
videoSkipableSchema,
|
|
45
|
-
minigameSchema,
|
|
46
|
-
interactionSchema,
|
|
47
|
-
showSchema,
|
|
48
|
-
hideSchema,
|
|
49
|
-
backgroundSchema,
|
|
50
|
-
bgSwitchSchema,
|
|
51
|
-
zindexSchema,
|
|
52
|
-
styleSchema,
|
|
53
|
-
windowSchema,
|
|
54
|
-
animateSchema,
|
|
55
|
-
animateBlockSchema,
|
|
56
|
-
transitionSchema,
|
|
57
|
-
shakeSchema,
|
|
58
|
-
textTypewriterSchema,
|
|
59
|
-
} from "./commands.ts";
|
|
60
|
-
|
|
61
|
-
/** op → 负载 schema(不含 op 键本身);字面量键形态 = `ScriptCommand` 判别联合的派生源。
|
|
62
|
-
* 新增 op = 加条目 = 表单 / 校验 / 类型三面自动出现(单一事实源,零重复定义) */
|
|
63
|
-
export const OP_SCHEMA_MAP = {
|
|
64
|
-
say: saySchema,
|
|
65
|
-
menu: menuSchema,
|
|
66
|
-
input: inputSchema,
|
|
67
|
-
notify: notifySchema,
|
|
68
|
-
nvl: nvlSchema,
|
|
69
|
-
character: characterSchema,
|
|
70
|
-
uncharacter: uncharacterSchema,
|
|
71
|
-
wait: waitSchema,
|
|
72
|
-
pause: pauseSchema,
|
|
73
|
-
random: randomSchema,
|
|
74
|
-
jump: jumpSchema,
|
|
75
|
-
navigate: navigateSchema,
|
|
76
|
-
func: funcSchema,
|
|
77
|
-
call: callSchema,
|
|
78
|
-
return: returnSchema,
|
|
79
|
-
if: ifSchema,
|
|
80
|
-
while: whileSchema,
|
|
81
|
-
for: forSchema,
|
|
82
|
-
foreach: foreachSchema,
|
|
83
|
-
switch: switchSchema,
|
|
84
|
-
break: emptySchema,
|
|
85
|
-
continue: emptySchema,
|
|
86
|
-
assert: assertSchema,
|
|
87
|
-
guard: guardSchema,
|
|
88
|
-
set: assignSchema,
|
|
89
|
-
define: assignSchema,
|
|
90
|
-
let: assignSchema,
|
|
91
|
-
local: assignSchema,
|
|
92
|
-
undef: undefSchema,
|
|
93
|
-
array: arraySchema,
|
|
94
|
-
array_push: arrayPushSchema,
|
|
95
|
-
array_pop: arrayPopSchema,
|
|
96
|
-
dict: dictSchema,
|
|
97
|
-
dict_set: dictSetSchema,
|
|
98
|
-
save: saveSchema,
|
|
99
|
-
load: loadSchema,
|
|
100
|
-
auto_save: autoSaveSchema,
|
|
101
|
-
save_delete: saveDeleteSchema,
|
|
102
|
-
bgm: bgmSchema,
|
|
103
|
-
stop_bgm: stopSchema,
|
|
104
|
-
se: seSchema,
|
|
105
|
-
ambient: bgmSchema,
|
|
106
|
-
stop_ambient: stopSchema,
|
|
107
|
-
voice: voiceSchema,
|
|
108
|
-
stop_voice: stopSchema,
|
|
109
|
-
video: videoSchema,
|
|
110
|
-
cutscene: cutsceneSchema,
|
|
111
|
-
seek_video: seekVideoSchema,
|
|
112
|
-
pause_video: emptySchema,
|
|
113
|
-
resume_video: emptySchema,
|
|
114
|
-
stop_video: emptySchema,
|
|
115
|
-
video_skipable: videoSkipableSchema,
|
|
116
|
-
minigame: minigameSchema,
|
|
117
|
-
interaction: interactionSchema,
|
|
118
|
-
// 元素系统
|
|
119
|
-
show: showSchema,
|
|
120
|
-
hide: hideSchema,
|
|
121
|
-
background: backgroundSchema,
|
|
122
|
-
bg_switch: bgSwitchSchema,
|
|
123
|
-
zindex: zindexSchema,
|
|
124
|
-
style: styleSchema,
|
|
125
|
-
window: windowSchema,
|
|
126
|
-
animate: animateSchema,
|
|
127
|
-
animate_block: animateBlockSchema,
|
|
128
|
-
transition: transitionSchema,
|
|
129
|
-
shake: shakeSchema,
|
|
130
|
-
text_typewriter: textTypewriterSchema,
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
/** 运行期查找面(与 `OP_SCHEMA_MAP` 同一对象,键集恒等);新增 op 自动可用 */
|
|
134
|
-
export const OP_SCHEMAS: Readonly<Record<string, z.ZodType>> = OP_SCHEMA_MAP;
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
saySchema,
|
|
4
|
+
menuSchema,
|
|
5
|
+
inputSchema,
|
|
6
|
+
notifySchema,
|
|
7
|
+
nvlSchema,
|
|
8
|
+
characterSchema,
|
|
9
|
+
uncharacterSchema,
|
|
10
|
+
waitSchema,
|
|
11
|
+
pauseSchema,
|
|
12
|
+
randomSchema,
|
|
13
|
+
jumpSchema,
|
|
14
|
+
navigateSchema,
|
|
15
|
+
funcSchema,
|
|
16
|
+
callSchema,
|
|
17
|
+
returnSchema,
|
|
18
|
+
ifSchema,
|
|
19
|
+
whileSchema,
|
|
20
|
+
forSchema,
|
|
21
|
+
foreachSchema,
|
|
22
|
+
switchSchema,
|
|
23
|
+
emptySchema,
|
|
24
|
+
assertSchema,
|
|
25
|
+
guardSchema,
|
|
26
|
+
assignSchema,
|
|
27
|
+
undefSchema,
|
|
28
|
+
arraySchema,
|
|
29
|
+
arrayPushSchema,
|
|
30
|
+
arrayPopSchema,
|
|
31
|
+
dictSchema,
|
|
32
|
+
dictSetSchema,
|
|
33
|
+
saveSchema,
|
|
34
|
+
loadSchema,
|
|
35
|
+
autoSaveSchema,
|
|
36
|
+
saveDeleteSchema,
|
|
37
|
+
bgmSchema,
|
|
38
|
+
stopSchema,
|
|
39
|
+
seSchema,
|
|
40
|
+
voiceSchema,
|
|
41
|
+
videoSchema,
|
|
42
|
+
cutsceneSchema,
|
|
43
|
+
seekVideoSchema,
|
|
44
|
+
videoSkipableSchema,
|
|
45
|
+
minigameSchema,
|
|
46
|
+
interactionSchema,
|
|
47
|
+
showSchema,
|
|
48
|
+
hideSchema,
|
|
49
|
+
backgroundSchema,
|
|
50
|
+
bgSwitchSchema,
|
|
51
|
+
zindexSchema,
|
|
52
|
+
styleSchema,
|
|
53
|
+
windowSchema,
|
|
54
|
+
animateSchema,
|
|
55
|
+
animateBlockSchema,
|
|
56
|
+
transitionSchema,
|
|
57
|
+
shakeSchema,
|
|
58
|
+
textTypewriterSchema,
|
|
59
|
+
} from "./commands.ts";
|
|
60
|
+
|
|
61
|
+
/** op → 负载 schema(不含 op 键本身);字面量键形态 = `ScriptCommand` 判别联合的派生源。
|
|
62
|
+
* 新增 op = 加条目 = 表单 / 校验 / 类型三面自动出现(单一事实源,零重复定义) */
|
|
63
|
+
export const OP_SCHEMA_MAP = {
|
|
64
|
+
say: saySchema,
|
|
65
|
+
menu: menuSchema,
|
|
66
|
+
input: inputSchema,
|
|
67
|
+
notify: notifySchema,
|
|
68
|
+
nvl: nvlSchema,
|
|
69
|
+
character: characterSchema,
|
|
70
|
+
uncharacter: uncharacterSchema,
|
|
71
|
+
wait: waitSchema,
|
|
72
|
+
pause: pauseSchema,
|
|
73
|
+
random: randomSchema,
|
|
74
|
+
jump: jumpSchema,
|
|
75
|
+
navigate: navigateSchema,
|
|
76
|
+
func: funcSchema,
|
|
77
|
+
call: callSchema,
|
|
78
|
+
return: returnSchema,
|
|
79
|
+
if: ifSchema,
|
|
80
|
+
while: whileSchema,
|
|
81
|
+
for: forSchema,
|
|
82
|
+
foreach: foreachSchema,
|
|
83
|
+
switch: switchSchema,
|
|
84
|
+
break: emptySchema,
|
|
85
|
+
continue: emptySchema,
|
|
86
|
+
assert: assertSchema,
|
|
87
|
+
guard: guardSchema,
|
|
88
|
+
set: assignSchema,
|
|
89
|
+
define: assignSchema,
|
|
90
|
+
let: assignSchema,
|
|
91
|
+
local: assignSchema,
|
|
92
|
+
undef: undefSchema,
|
|
93
|
+
array: arraySchema,
|
|
94
|
+
array_push: arrayPushSchema,
|
|
95
|
+
array_pop: arrayPopSchema,
|
|
96
|
+
dict: dictSchema,
|
|
97
|
+
dict_set: dictSetSchema,
|
|
98
|
+
save: saveSchema,
|
|
99
|
+
load: loadSchema,
|
|
100
|
+
auto_save: autoSaveSchema,
|
|
101
|
+
save_delete: saveDeleteSchema,
|
|
102
|
+
bgm: bgmSchema,
|
|
103
|
+
stop_bgm: stopSchema,
|
|
104
|
+
se: seSchema,
|
|
105
|
+
ambient: bgmSchema,
|
|
106
|
+
stop_ambient: stopSchema,
|
|
107
|
+
voice: voiceSchema,
|
|
108
|
+
stop_voice: stopSchema,
|
|
109
|
+
video: videoSchema,
|
|
110
|
+
cutscene: cutsceneSchema,
|
|
111
|
+
seek_video: seekVideoSchema,
|
|
112
|
+
pause_video: emptySchema,
|
|
113
|
+
resume_video: emptySchema,
|
|
114
|
+
stop_video: emptySchema,
|
|
115
|
+
video_skipable: videoSkipableSchema,
|
|
116
|
+
minigame: minigameSchema,
|
|
117
|
+
interaction: interactionSchema,
|
|
118
|
+
// 元素系统
|
|
119
|
+
show: showSchema,
|
|
120
|
+
hide: hideSchema,
|
|
121
|
+
background: backgroundSchema,
|
|
122
|
+
bg_switch: bgSwitchSchema,
|
|
123
|
+
zindex: zindexSchema,
|
|
124
|
+
style: styleSchema,
|
|
125
|
+
window: windowSchema,
|
|
126
|
+
animate: animateSchema,
|
|
127
|
+
animate_block: animateBlockSchema,
|
|
128
|
+
transition: transitionSchema,
|
|
129
|
+
shake: shakeSchema,
|
|
130
|
+
text_typewriter: textTypewriterSchema,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** 运行期查找面(与 `OP_SCHEMA_MAP` 同一对象,键集恒等);新增 op 自动可用 */
|
|
134
|
+
export const OP_SCHEMAS: Readonly<Record<string, z.ZodType>> = OP_SCHEMA_MAP;
|
|
@@ -1,184 +1,184 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 编辑期 fail-closed 校验执行:把 zod issue 翻译为中文人话诊断(未知 op /
|
|
3
|
-
* 未知字段 / 缺必填 / 类型错),供 `validateStory` 与属性面板实时校验共用。
|
|
4
|
-
*/
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
import type { Diagnostic, EditorTexts } from "../../contracts/index.ts";
|
|
7
|
-
import { escapePointerToken, resolveEditorTexts } from "../../contracts/index.ts";
|
|
8
|
-
import { OP_SCHEMAS } from "./map.ts";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* **「resource 为空」时该用哪个停止命令**。
|
|
12
|
-
*
|
|
13
|
-
* 为什么需要:既有工程里有 `bgm ""` 表「停止 BGM」的写法,而播放器对空路径
|
|
14
|
-
* **无特判**(直接以空串去加载)⇒ **静默失败**——作者以为停了,其实没有。
|
|
15
|
-
* 显式报错比「静默通过」更正确,但直接抛 zod 英文原话
|
|
16
|
-
* (`Too small: expected string to have >=1 characters`)作者既看不懂、
|
|
17
|
-
* 也不知道该改什么。
|
|
18
|
-
*
|
|
19
|
-
* 处置 = **保持严格校验**(不引入「空路径=停止」这种隐式约定)
|
|
20
|
-
* + **把正确写法直接告诉作者**(语义明确)。
|
|
21
|
-
*/
|
|
22
|
-
const STOP_OP_BY_RESOURCE_OWNER: Readonly<Record<string, string>> = {
|
|
23
|
-
bgm: "stop_bgm",
|
|
24
|
-
ambient: "stop_ambient",
|
|
25
|
-
voice: "stop_voice",
|
|
26
|
-
video: "stop_video",
|
|
27
|
-
cutscene: "stop_video",
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* zod 错误 → **中文人话**(编辑期诊断给作者看,不该直接透传英文)。
|
|
32
|
-
*
|
|
33
|
-
* **兜底保留原文**:没覆盖的 code 返回 `issue.message`——
|
|
34
|
-
* 宁可英文也不静默丢信息(不吞信息)。
|
|
35
|
-
*
|
|
36
|
-
* 文案经 EditorTexts 表(本模块是唯一 zod→中文翻译点;表方法由本函数调用)。
|
|
37
|
-
*/
|
|
38
|
-
function localizeZodIssue(
|
|
39
|
-
op: string,
|
|
40
|
-
issue: z.core.$ZodIssue,
|
|
41
|
-
texts: EditorTexts,
|
|
42
|
-
): string {
|
|
43
|
-
const field = issue.path.map(String).join(".");
|
|
44
|
-
const at = field === "" ? "" : `${field} `;
|
|
45
|
-
switch (issue.code) {
|
|
46
|
-
case "too_small": {
|
|
47
|
-
const min = (issue as { minimum?: number }).minimum;
|
|
48
|
-
if (field === "resource" || field.endsWith(".resource")) {
|
|
49
|
-
const stopOp = STOP_OP_BY_RESOURCE_OWNER[op];
|
|
50
|
-
return stopOp === undefined
|
|
51
|
-
? texts.resourcePathEmpty()
|
|
52
|
-
: texts.resourcePathEmptyStop(stopOp);
|
|
53
|
-
}
|
|
54
|
-
return typeof min === "number" && min > 0
|
|
55
|
-
? texts.valueTooSmall(at, min)
|
|
56
|
-
: texts.valueTooSmallBare(at);
|
|
57
|
-
}
|
|
58
|
-
case "too_big": {
|
|
59
|
-
const max = (issue as { maximum?: number }).maximum;
|
|
60
|
-
return typeof max === "number"
|
|
61
|
-
? texts.valueTooBig(at, max)
|
|
62
|
-
: texts.valueTooBigBare(at);
|
|
63
|
-
}
|
|
64
|
-
case "invalid_type": {
|
|
65
|
-
const expected = (issue as { expected?: unknown }).expected;
|
|
66
|
-
return texts.invalidType(at, typeof expected === "string" ? expected : "");
|
|
67
|
-
}
|
|
68
|
-
case "invalid_format": {
|
|
69
|
-
const format = (issue as { format?: unknown }).format;
|
|
70
|
-
return texts.invalidFormat(at, typeof format === "string" ? format : "");
|
|
71
|
-
}
|
|
72
|
-
default:
|
|
73
|
-
return issue.message;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function issueToDiagnostic(
|
|
78
|
-
issue: z.core.$ZodIssue,
|
|
79
|
-
basePointer: string,
|
|
80
|
-
op: string,
|
|
81
|
-
texts: EditorTexts,
|
|
82
|
-
): Diagnostic {
|
|
83
|
-
const segments = issue.path.map((seg) => escapePointerToken(String(seg)));
|
|
84
|
-
const pointer =
|
|
85
|
-
segments.length > 0 ? `${basePointer}/${segments.join("/")}` : basePointer;
|
|
86
|
-
if (issue.code === "unrecognized_keys") {
|
|
87
|
-
const keys =
|
|
88
|
-
"keys" in issue && Array.isArray(issue.keys)
|
|
89
|
-
? issue.keys.map(String).join(", ")
|
|
90
|
-
: issue.message;
|
|
91
|
-
return {
|
|
92
|
-
code: "unknown-field",
|
|
93
|
-
severity: "error",
|
|
94
|
-
message: texts.unknownPayloadFields(op, keys),
|
|
95
|
-
pointer,
|
|
96
|
-
op,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
const received = (issue as { received?: unknown }).received;
|
|
100
|
-
if (
|
|
101
|
-
issue.code === "invalid_type" &&
|
|
102
|
-
(received === undefined || received === "undefined")
|
|
103
|
-
) {
|
|
104
|
-
return {
|
|
105
|
-
code: "missing-required",
|
|
106
|
-
severity: "error",
|
|
107
|
-
message: texts.missingRequired(
|
|
108
|
-
op,
|
|
109
|
-
issue.path.length > 0 ? issue.path.join(".") : "",
|
|
110
|
-
),
|
|
111
|
-
pointer,
|
|
112
|
-
op,
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
return {
|
|
116
|
-
code: "invalid-value",
|
|
117
|
-
severity: "error",
|
|
118
|
-
message: texts.invalidValue(
|
|
119
|
-
op,
|
|
120
|
-
issue.path.length > 0 ? issue.path.join(".") : "",
|
|
121
|
-
localizeZodIssue(op, issue, texts),
|
|
122
|
-
),
|
|
123
|
-
pointer,
|
|
124
|
-
op,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* 编辑期单命令 fail-closed 校验(结构层):未知 op / 未知字段 / 缺必填 / 类型错。
|
|
130
|
-
* pointer = 命令对象自身在 Story 树上的 JSON Pointer。
|
|
131
|
-
* `schemas`(可选)= op 负载 schema 面(缺省内建 OP_SCHEMAS;扩展注册后传合并集)。
|
|
132
|
-
* `texts`(可选)= 集中文案表注入(缺省 defaultEditorTexts)。
|
|
133
|
-
* 编辑期 fail-closed 校验的测试见 validation.test。
|
|
134
|
-
*/
|
|
135
|
-
export function validateCommand(
|
|
136
|
-
cmd: unknown,
|
|
137
|
-
pointer = "",
|
|
138
|
-
schemas: Readonly<Record<string, z.ZodType>> = OP_SCHEMAS,
|
|
139
|
-
texts: Partial<EditorTexts> = {},
|
|
140
|
-
): Diagnostic[] {
|
|
141
|
-
const t = resolveEditorTexts(texts);
|
|
142
|
-
if (typeof cmd !== "object" || cmd === null || Array.isArray(cmd)) {
|
|
143
|
-
return [
|
|
144
|
-
{
|
|
145
|
-
code: "invalid-structure",
|
|
146
|
-
severity: "error",
|
|
147
|
-
message: t.commandMustBeObject(),
|
|
148
|
-
pointer,
|
|
149
|
-
},
|
|
150
|
-
];
|
|
151
|
-
}
|
|
152
|
-
const op = (cmd as Record<string, unknown>).op;
|
|
153
|
-
if (typeof op !== "string" || op === "") {
|
|
154
|
-
return [
|
|
155
|
-
{
|
|
156
|
-
code: "invalid-structure",
|
|
157
|
-
severity: "error",
|
|
158
|
-
message: t.commandMustHaveOp(),
|
|
159
|
-
pointer,
|
|
160
|
-
},
|
|
161
|
-
];
|
|
162
|
-
}
|
|
163
|
-
const schema = schemas[op];
|
|
164
|
-
if (schema === undefined) {
|
|
165
|
-
return [
|
|
166
|
-
{
|
|
167
|
-
code: "unknown-op",
|
|
168
|
-
severity: "error",
|
|
169
|
-
message: t.unknownCommand(op),
|
|
170
|
-
pointer,
|
|
171
|
-
op,
|
|
172
|
-
},
|
|
173
|
-
];
|
|
174
|
-
}
|
|
175
|
-
const payload: Record<string, unknown> = {
|
|
176
|
-
...(cmd as Record<string, unknown>),
|
|
177
|
-
};
|
|
178
|
-
delete payload.op;
|
|
179
|
-
const result = schema.safeParse(payload);
|
|
180
|
-
if (result.success) return [];
|
|
181
|
-
return result.error.issues.map((issue) =>
|
|
182
|
-
issueToDiagnostic(issue, pointer, op, t),
|
|
183
|
-
);
|
|
184
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 编辑期 fail-closed 校验执行:把 zod issue 翻译为中文人话诊断(未知 op /
|
|
3
|
+
* 未知字段 / 缺必填 / 类型错),供 `validateStory` 与属性面板实时校验共用。
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type { Diagnostic, EditorTexts } from "../../contracts/index.ts";
|
|
7
|
+
import { escapePointerToken, resolveEditorTexts } from "../../contracts/index.ts";
|
|
8
|
+
import { OP_SCHEMAS } from "./map.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* **「resource 为空」时该用哪个停止命令**。
|
|
12
|
+
*
|
|
13
|
+
* 为什么需要:既有工程里有 `bgm ""` 表「停止 BGM」的写法,而播放器对空路径
|
|
14
|
+
* **无特判**(直接以空串去加载)⇒ **静默失败**——作者以为停了,其实没有。
|
|
15
|
+
* 显式报错比「静默通过」更正确,但直接抛 zod 英文原话
|
|
16
|
+
* (`Too small: expected string to have >=1 characters`)作者既看不懂、
|
|
17
|
+
* 也不知道该改什么。
|
|
18
|
+
*
|
|
19
|
+
* 处置 = **保持严格校验**(不引入「空路径=停止」这种隐式约定)
|
|
20
|
+
* + **把正确写法直接告诉作者**(语义明确)。
|
|
21
|
+
*/
|
|
22
|
+
const STOP_OP_BY_RESOURCE_OWNER: Readonly<Record<string, string>> = {
|
|
23
|
+
bgm: "stop_bgm",
|
|
24
|
+
ambient: "stop_ambient",
|
|
25
|
+
voice: "stop_voice",
|
|
26
|
+
video: "stop_video",
|
|
27
|
+
cutscene: "stop_video",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* zod 错误 → **中文人话**(编辑期诊断给作者看,不该直接透传英文)。
|
|
32
|
+
*
|
|
33
|
+
* **兜底保留原文**:没覆盖的 code 返回 `issue.message`——
|
|
34
|
+
* 宁可英文也不静默丢信息(不吞信息)。
|
|
35
|
+
*
|
|
36
|
+
* 文案经 EditorTexts 表(本模块是唯一 zod→中文翻译点;表方法由本函数调用)。
|
|
37
|
+
*/
|
|
38
|
+
function localizeZodIssue(
|
|
39
|
+
op: string,
|
|
40
|
+
issue: z.core.$ZodIssue,
|
|
41
|
+
texts: EditorTexts,
|
|
42
|
+
): string {
|
|
43
|
+
const field = issue.path.map(String).join(".");
|
|
44
|
+
const at = field === "" ? "" : `${field} `;
|
|
45
|
+
switch (issue.code) {
|
|
46
|
+
case "too_small": {
|
|
47
|
+
const min = (issue as { minimum?: number }).minimum;
|
|
48
|
+
if (field === "resource" || field.endsWith(".resource")) {
|
|
49
|
+
const stopOp = STOP_OP_BY_RESOURCE_OWNER[op];
|
|
50
|
+
return stopOp === undefined
|
|
51
|
+
? texts.resourcePathEmpty()
|
|
52
|
+
: texts.resourcePathEmptyStop(stopOp);
|
|
53
|
+
}
|
|
54
|
+
return typeof min === "number" && min > 0
|
|
55
|
+
? texts.valueTooSmall(at, min)
|
|
56
|
+
: texts.valueTooSmallBare(at);
|
|
57
|
+
}
|
|
58
|
+
case "too_big": {
|
|
59
|
+
const max = (issue as { maximum?: number }).maximum;
|
|
60
|
+
return typeof max === "number"
|
|
61
|
+
? texts.valueTooBig(at, max)
|
|
62
|
+
: texts.valueTooBigBare(at);
|
|
63
|
+
}
|
|
64
|
+
case "invalid_type": {
|
|
65
|
+
const expected = (issue as { expected?: unknown }).expected;
|
|
66
|
+
return texts.invalidType(at, typeof expected === "string" ? expected : "");
|
|
67
|
+
}
|
|
68
|
+
case "invalid_format": {
|
|
69
|
+
const format = (issue as { format?: unknown }).format;
|
|
70
|
+
return texts.invalidFormat(at, typeof format === "string" ? format : "");
|
|
71
|
+
}
|
|
72
|
+
default:
|
|
73
|
+
return issue.message;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function issueToDiagnostic(
|
|
78
|
+
issue: z.core.$ZodIssue,
|
|
79
|
+
basePointer: string,
|
|
80
|
+
op: string,
|
|
81
|
+
texts: EditorTexts,
|
|
82
|
+
): Diagnostic {
|
|
83
|
+
const segments = issue.path.map((seg) => escapePointerToken(String(seg)));
|
|
84
|
+
const pointer =
|
|
85
|
+
segments.length > 0 ? `${basePointer}/${segments.join("/")}` : basePointer;
|
|
86
|
+
if (issue.code === "unrecognized_keys") {
|
|
87
|
+
const keys =
|
|
88
|
+
"keys" in issue && Array.isArray(issue.keys)
|
|
89
|
+
? issue.keys.map(String).join(", ")
|
|
90
|
+
: issue.message;
|
|
91
|
+
return {
|
|
92
|
+
code: "unknown-field",
|
|
93
|
+
severity: "error",
|
|
94
|
+
message: texts.unknownPayloadFields(op, keys),
|
|
95
|
+
pointer,
|
|
96
|
+
op,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const received = (issue as { received?: unknown }).received;
|
|
100
|
+
if (
|
|
101
|
+
issue.code === "invalid_type" &&
|
|
102
|
+
(received === undefined || received === "undefined")
|
|
103
|
+
) {
|
|
104
|
+
return {
|
|
105
|
+
code: "missing-required",
|
|
106
|
+
severity: "error",
|
|
107
|
+
message: texts.missingRequired(
|
|
108
|
+
op,
|
|
109
|
+
issue.path.length > 0 ? issue.path.join(".") : "",
|
|
110
|
+
),
|
|
111
|
+
pointer,
|
|
112
|
+
op,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
code: "invalid-value",
|
|
117
|
+
severity: "error",
|
|
118
|
+
message: texts.invalidValue(
|
|
119
|
+
op,
|
|
120
|
+
issue.path.length > 0 ? issue.path.join(".") : "",
|
|
121
|
+
localizeZodIssue(op, issue, texts),
|
|
122
|
+
),
|
|
123
|
+
pointer,
|
|
124
|
+
op,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 编辑期单命令 fail-closed 校验(结构层):未知 op / 未知字段 / 缺必填 / 类型错。
|
|
130
|
+
* pointer = 命令对象自身在 Story 树上的 JSON Pointer。
|
|
131
|
+
* `schemas`(可选)= op 负载 schema 面(缺省内建 OP_SCHEMAS;扩展注册后传合并集)。
|
|
132
|
+
* `texts`(可选)= 集中文案表注入(缺省 defaultEditorTexts)。
|
|
133
|
+
* 编辑期 fail-closed 校验的测试见 validation.test。
|
|
134
|
+
*/
|
|
135
|
+
export function validateCommand(
|
|
136
|
+
cmd: unknown,
|
|
137
|
+
pointer = "",
|
|
138
|
+
schemas: Readonly<Record<string, z.ZodType>> = OP_SCHEMAS,
|
|
139
|
+
texts: Partial<EditorTexts> = {},
|
|
140
|
+
): Diagnostic[] {
|
|
141
|
+
const t = resolveEditorTexts(texts);
|
|
142
|
+
if (typeof cmd !== "object" || cmd === null || Array.isArray(cmd)) {
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
code: "invalid-structure",
|
|
146
|
+
severity: "error",
|
|
147
|
+
message: t.commandMustBeObject(),
|
|
148
|
+
pointer,
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
const op = (cmd as Record<string, unknown>).op;
|
|
153
|
+
if (typeof op !== "string" || op === "") {
|
|
154
|
+
return [
|
|
155
|
+
{
|
|
156
|
+
code: "invalid-structure",
|
|
157
|
+
severity: "error",
|
|
158
|
+
message: t.commandMustHaveOp(),
|
|
159
|
+
pointer,
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
const schema = schemas[op];
|
|
164
|
+
if (schema === undefined) {
|
|
165
|
+
return [
|
|
166
|
+
{
|
|
167
|
+
code: "unknown-op",
|
|
168
|
+
severity: "error",
|
|
169
|
+
message: t.unknownCommand(op),
|
|
170
|
+
pointer,
|
|
171
|
+
op,
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
const payload: Record<string, unknown> = {
|
|
176
|
+
...(cmd as Record<string, unknown>),
|
|
177
|
+
};
|
|
178
|
+
delete payload.op;
|
|
179
|
+
const result = schema.safeParse(payload);
|
|
180
|
+
if (result.success) return [];
|
|
181
|
+
return result.error.issues.map((issue) =>
|
|
182
|
+
issueToDiagnostic(issue, pointer, op, t),
|
|
183
|
+
);
|
|
184
|
+
}
|