@lingfan-engine/editor 0.0.0-stage → 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 -0
- package/package.json +39 -3
- package/src/chapters/build.ts +180 -0
- package/src/chapters/index.ts +34 -0
- package/src/chapters/types.ts +71 -0
- package/src/contracts/diagnostics.ts +62 -0
- package/src/contracts/editing.ts +26 -0
- package/src/contracts/forms.ts +69 -0
- package/src/contracts/index.ts +22 -0
- package/src/contracts/texts.ts +204 -0
- package/src/diagnostics/analyze.ts +161 -0
- package/src/diagnostics/grouping.ts +164 -0
- package/src/diagnostics/index.ts +35 -0
- package/src/diagnostics/message.ts +60 -0
- package/src/diagnostics/refs.ts +123 -0
- package/src/diagnostics/symbols.ts +221 -0
- package/src/editing/columnGrouping.ts +331 -0
- package/src/editing/graphConnect.ts +88 -0
- package/src/editing/index.ts +72 -0
- package/src/editing/pointers.ts +291 -0
- package/src/editing/saveNormalization.ts +58 -0
- package/src/editing/session.ts +134 -0
- package/src/editing/storyOps.ts +268 -0
- package/src/editing/tsSourceGuard.ts +40 -0
- package/src/element/drag.ts +22 -0
- package/src/element/dragCreate.ts +60 -0
- package/src/element/index.ts +11 -0
- package/src/i18n/coverage.ts +191 -0
- package/src/i18n/extract.ts +38 -0
- package/src/i18n/index.ts +47 -0
- package/src/i18n/reconcile.ts +71 -0
- package/src/i18n/skeleton.ts +112 -0
- package/src/i18n/storyKeys.ts +27 -0
- package/src/i18n/surfaces.ts +54 -0
- package/src/i18n/table.ts +235 -0
- package/src/index.ts +342 -0
- package/src/layout/index.ts +14 -0
- package/src/layout/steps.ts +378 -0
- package/src/schema/catalog.ts +300 -0
- package/src/schema/elementForms.ts +307 -0
- package/src/schema/elementPalette.ts +100 -0
- package/src/schema/forms.ts +196 -0
- package/src/schema/index.ts +55 -0
- package/src/schema/opSchemas/commands.ts +374 -0
- package/src/schema/opSchemas/fragments.ts +24 -0
- package/src/schema/opSchemas/index.ts +16 -0
- package/src/schema/opSchemas/map.ts +134 -0
- package/src/schema/opSchemas/types.ts +42 -0
- package/src/schema/opSchemas/validate.ts +184 -0
- package/src/schema/surface.ts +101 -0
- package/src/schema/validation.ts +92 -0
- package/src/schema/walk.ts +167 -0
- package/src/script/expr/audit/index.ts +8 -0
- package/src/script/expr/audit/parser.ts +370 -0
- package/src/script/expr/audit/token.ts +23 -0
- package/src/script/expr/build.ts +203 -0
- package/src/script/expr/index.ts +17 -0
- package/src/script/expr/registry.ts +130 -0
- package/src/script/index.ts +168 -0
- package/src/script/vocabulary-gen.ts +202 -0
- package/src/script/vocabulary.g.ts +175 -0
- package/src/script/words/audio.ts +114 -0
- package/src/script/words/cells.ts +78 -0
- package/src/script/words/compile-time.ts +27 -0
- package/src/script/words/composite.ts +125 -0
- package/src/script/words/elements.ts +202 -0
- package/src/script/words/extension.ts +25 -0
- package/src/script/words/flow.ts +391 -0
- package/src/script/words/index.ts +23 -0
- package/src/script/words/interaction.ts +44 -0
- package/src/script/words/minigame.ts +66 -0
- package/src/script/words/narrative.ts +261 -0
- package/src/script/words/save.ts +54 -0
- package/src/script/words/vars.ts +155 -0
- package/src/script/words/video.ts +80 -0
- package/src/shared/guards.ts +19 -0
- package/src/shared/index.ts +6 -0
- package/src/shared/pointer.ts +23 -0
- package/README.md +0 -3
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展 op 面合并:**纯函数**产出扩展集,
|
|
3
|
+
* 不修改 `OP_SCHEMAS`/`OP_META` 常量本体——消费方(表单/校验/组件面板/遍历器)改读合并面。
|
|
4
|
+
* 扩展声明形状 = 引擎契约 `OpEditorSchema`(载体:label/group + zod 负载 schema);
|
|
5
|
+
* op 名冲突不可能(引擎注册期查重先抛),此处只做聚合。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { z } from "zod";
|
|
9
|
+
import type { OpExtension, WaitSpec } from "@lingfan-engine/core";
|
|
10
|
+
import type { EditorTexts, OpMeta } from "../contracts/index.ts";
|
|
11
|
+
import { resolveEditorTexts } from "../contracts/index.ts";
|
|
12
|
+
import { WAITING_OPS, collectWaitSpecs } from "@lingfan-engine/core";
|
|
13
|
+
import { OP_GROUP_ORDER, OP_META } from "./catalog.ts";
|
|
14
|
+
import { OP_SCHEMAS } from "./opSchemas/index.ts";
|
|
15
|
+
|
|
16
|
+
/** op 校验/呈现面:内建 = 常量本体;扩展注册后 = 合并集(消费方唯一读取口径) */
|
|
17
|
+
export interface OpSurface {
|
|
18
|
+
readonly schemas: Readonly<Record<string, z.ZodType>>;
|
|
19
|
+
readonly meta: readonly OpMeta[];
|
|
20
|
+
/** op → 等待规格合并面:内建表 + wait:true 的扩展 op(collectWaitSpecs 产物) */
|
|
21
|
+
readonly waiting?: ReadonlyMap<string, WaitSpec>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const BUILTIN_OP_SURFACE: OpSurface = {
|
|
25
|
+
schemas: OP_SCHEMAS,
|
|
26
|
+
meta: OP_META,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** 合并扩展负载 schema(`OpEditorSchema.schema` 以 unknown 载体声明,编辑器窄化为 zod) */
|
|
30
|
+
export function mergeOpSchemas(
|
|
31
|
+
base: Readonly<Record<string, z.ZodType>>,
|
|
32
|
+
extensions: readonly OpExtension[],
|
|
33
|
+
): Record<string, z.ZodType> {
|
|
34
|
+
const merged = { ...base };
|
|
35
|
+
for (const extension of extensions) {
|
|
36
|
+
for (const definition of extension.ops ?? []) {
|
|
37
|
+
if (definition.schema !== undefined) {
|
|
38
|
+
merged[definition.op] = definition.schema.schema as z.ZodType;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return merged;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 合并扩展 op 元数据(标签/归组;追加保序——面板分组渲染按 OP_GROUP_ORDER 归位)。
|
|
46
|
+
* 归组非法(不在 OP_GROUP_ORDER 八组内)= 扩展声明违约 → **装配期 fail-fast 抛错**(带定位),
|
|
47
|
+
* 与引擎注册期校验同精神——静默丢弃会让扩展 op 从面板消失(不诚实)。
|
|
48
|
+
* texts(可选)= 集中文案表注入(缺省 defaultEditorTexts;装配期错误无外部调用链,走缺省)。 */
|
|
49
|
+
export function mergeOpMeta(
|
|
50
|
+
base: readonly OpMeta[],
|
|
51
|
+
extensions: readonly OpExtension[],
|
|
52
|
+
texts: Partial<EditorTexts> = {},
|
|
53
|
+
): OpMeta[] {
|
|
54
|
+
const t = resolveEditorTexts(texts);
|
|
55
|
+
const additions: OpMeta[] = [];
|
|
56
|
+
for (const extension of extensions) {
|
|
57
|
+
for (const definition of extension.ops ?? []) {
|
|
58
|
+
const declared = definition.schema;
|
|
59
|
+
if (declared === undefined) continue;
|
|
60
|
+
if (!OP_GROUP_ORDER.includes(declared.group as never)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
t.invalidOpGroup(
|
|
63
|
+
extension.id,
|
|
64
|
+
definition.op,
|
|
65
|
+
String(declared.group),
|
|
66
|
+
OP_GROUP_ORDER.join(" / "),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
additions.push({
|
|
71
|
+
op: definition.op,
|
|
72
|
+
label: declared.label,
|
|
73
|
+
group: declared.group as OpMeta["group"],
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...base, ...additions];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 组合根入口:内建面 + 扩展 → 合并面(缺省扩展 = 内建面原样)。
|
|
81
|
+
* 等待声明表一并并入:内建表打底,wait:true 的扩展 op 覆盖同名(引擎注册期已查重,实为追加)——
|
|
82
|
+
* SSOT 仍是引擎的 `WAITING_OPS` + `collectWaitSpecs`,编辑器只透传不复制。 */
|
|
83
|
+
export function mergeOpSurface(
|
|
84
|
+
extensions: readonly OpExtension[],
|
|
85
|
+
base: OpSurface = BUILTIN_OP_SURFACE,
|
|
86
|
+
): OpSurface {
|
|
87
|
+
return {
|
|
88
|
+
schemas: mergeOpSchemas(base.schemas, extensions),
|
|
89
|
+
meta: mergeOpMeta(base.meta, extensions),
|
|
90
|
+
waiting: mergeOpWaiting(base.waiting, extensions),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 合并扩展等待声明:内建表 `WAITING_OPS` 打底(base 缺省时),collectWaitSpecs 的扩展条目并入。
|
|
95
|
+
* 消费方(steps.ts 边界判定)读合并面——未合并时回退内建口径。 */
|
|
96
|
+
function mergeOpWaiting(
|
|
97
|
+
base: ReadonlyMap<string, WaitSpec> | undefined,
|
|
98
|
+
extensions: readonly OpExtension[],
|
|
99
|
+
): ReadonlyMap<string, WaitSpec> {
|
|
100
|
+
return collectWaitSpecs(extensions, base ?? WAITING_OPS);
|
|
101
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 编辑期故事级校验:envelope(故事/列结构)+ 共享遍历器的逐命令校验
|
|
3
|
+
* (嵌套块体里的未知 op/坏负载同样拿到精确 JSON Pointer 诊断)。
|
|
4
|
+
* 结构校验面与 packages/core 的 `data/format/` 对齐(单列文件名不变量归组装器,此处不管)。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import type { Diagnostic } from "../contracts/index.ts";
|
|
9
|
+
import { escapePointerToken } from "../contracts/index.ts";
|
|
10
|
+
import type { OpExtension } from "@lingfan-engine/core";
|
|
11
|
+
import { validateElementNode } from "@lingfan-engine/core";
|
|
12
|
+
import { validateCommand } from "./opSchemas/index.ts";
|
|
13
|
+
import { mergeOpSurface } from "./surface.ts";
|
|
14
|
+
import { walkStoryCommands, walkStoryElements } from "./walk.ts";
|
|
15
|
+
|
|
16
|
+
const NonEmpty = z.string().min(1);
|
|
17
|
+
|
|
18
|
+
export const columnSchema = z.object({
|
|
19
|
+
id: NonEmpty,
|
|
20
|
+
kind: z.enum(["scene", "flow"]),
|
|
21
|
+
elements: z.array(z.unknown()).optional(),
|
|
22
|
+
entry: z.array(z.unknown()).optional(),
|
|
23
|
+
commands: z.array(z.unknown()).optional(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const storySchema = z.object({
|
|
27
|
+
formatVersion: z.literal(1),
|
|
28
|
+
id: NonEmpty,
|
|
29
|
+
entry: NonEmpty,
|
|
30
|
+
columns: z.array(columnSchema).min(1),
|
|
31
|
+
defines: z.record(z.string(), z.unknown()).optional(),
|
|
32
|
+
lang: z.string().optional(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function issuesToDiagnostics(
|
|
36
|
+
issues: z.core.$ZodIssue[],
|
|
37
|
+
basePointer: string,
|
|
38
|
+
): Diagnostic[] {
|
|
39
|
+
return issues.map((issue) => {
|
|
40
|
+
const segments = issue.path.map((seg) => escapePointerToken(String(seg)));
|
|
41
|
+
const pointer =
|
|
42
|
+
segments.length > 0
|
|
43
|
+
? `${basePointer}/${segments.join("/")}`
|
|
44
|
+
: basePointer;
|
|
45
|
+
return {
|
|
46
|
+
code: "invalid-structure",
|
|
47
|
+
severity: "error",
|
|
48
|
+
message: issue.message,
|
|
49
|
+
pointer,
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 编辑期整树 fail-closed 校验:envelope 结构 + 全部列(flow/scene 两容器)
|
|
56
|
+
* 及嵌套块体的逐命令校验。诊断一律带 JSON Pointer。
|
|
57
|
+
* `extensions`(可选):扩展 op 经合并面纳入校验(缺省 = 内建面,未注册 op 仍 unknown-op)。
|
|
58
|
+
*/
|
|
59
|
+
export function validateStory(
|
|
60
|
+
story: unknown,
|
|
61
|
+
extensions: readonly OpExtension[] = [],
|
|
62
|
+
): Diagnostic[] {
|
|
63
|
+
const out: Diagnostic[] = [];
|
|
64
|
+
const result = storySchema.safeParse(story);
|
|
65
|
+
if (!result.success) {
|
|
66
|
+
out.push(...issuesToDiagnostics(result.error.issues, ""));
|
|
67
|
+
}
|
|
68
|
+
const surface = mergeOpSurface(extensions);
|
|
69
|
+
walkStoryCommands(
|
|
70
|
+
story,
|
|
71
|
+
(cmd, pointer) => {
|
|
72
|
+
out.push(...validateCommand(cmd, pointer, surface.schemas));
|
|
73
|
+
},
|
|
74
|
+
surface,
|
|
75
|
+
);
|
|
76
|
+
// scene 列元素:复用引擎单一事实源(编辑期与运行期同口径)。
|
|
77
|
+
// 逐节点遍历 + `validateElementNode`(**单节点**版)——不能用带递归的 `validateElement`,
|
|
78
|
+
// 否则与遍历叠加会双报同一子元素问题。
|
|
79
|
+
walkStoryElements(story, (node, pointer) => {
|
|
80
|
+
const issues: string[] = [];
|
|
81
|
+
validateElementNode(node, pointer, issues);
|
|
82
|
+
for (const message of issues) {
|
|
83
|
+
out.push({
|
|
84
|
+
code: "invalid-element",
|
|
85
|
+
severity: "error",
|
|
86
|
+
message,
|
|
87
|
+
pointer,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 故事树命令遍历器(编辑器内部共享):列双容器(flow.commands / scene.elements+entry)
|
|
3
|
+
* + 沿表单描述符递归块体(if.then/elif[].then/else、while/for/foreach.body、
|
|
4
|
+
* switch.cases[].body/default、func.body)。校验与符号索引共用同一遍历,指针一致。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FieldDescriptor } from "../contracts/index.ts";
|
|
8
|
+
import { escapePointerToken } from "../contracts/index.ts";
|
|
9
|
+
import { isPlainObject } from "../shared/index.ts";
|
|
10
|
+
import { describeForm } from "./forms.ts";
|
|
11
|
+
import { BUILTIN_OP_SURFACE, type OpSurface } from "./surface.ts";
|
|
12
|
+
|
|
13
|
+
export type CommandVisitor = (
|
|
14
|
+
cmd: Record<string, unknown>,
|
|
15
|
+
pointer: string,
|
|
16
|
+
fields: readonly FieldDescriptor[] | undefined,
|
|
17
|
+
) => void;
|
|
18
|
+
|
|
19
|
+
function walkDescriptor(
|
|
20
|
+
desc: FieldDescriptor,
|
|
21
|
+
value: unknown,
|
|
22
|
+
pointer: string,
|
|
23
|
+
visit: CommandVisitor,
|
|
24
|
+
surface: OpSurface,
|
|
25
|
+
): void {
|
|
26
|
+
if (desc.kind === "body") {
|
|
27
|
+
if (Array.isArray(value)) walkCommands(value, pointer, visit, surface);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (desc.properties !== undefined && isPlainObject(value)) {
|
|
31
|
+
for (const child of desc.properties) {
|
|
32
|
+
walkDescriptor(
|
|
33
|
+
child,
|
|
34
|
+
value[child.key],
|
|
35
|
+
`${pointer}/${escapePointerToken(child.key)}`,
|
|
36
|
+
visit,
|
|
37
|
+
surface,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (desc.item !== undefined && Array.isArray(value)) {
|
|
43
|
+
value.forEach((element, index) => {
|
|
44
|
+
walkDescriptor(
|
|
45
|
+
desc.item!,
|
|
46
|
+
element,
|
|
47
|
+
`${pointer}/${index}`,
|
|
48
|
+
visit,
|
|
49
|
+
surface,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function walkCommands(
|
|
56
|
+
commands: unknown[],
|
|
57
|
+
pointer: string,
|
|
58
|
+
visit: CommandVisitor,
|
|
59
|
+
surface: OpSurface,
|
|
60
|
+
): void {
|
|
61
|
+
commands.forEach((cmd, index) => {
|
|
62
|
+
const commandPointer = `${pointer}/${index}`;
|
|
63
|
+
if (!isPlainObject(cmd)) {
|
|
64
|
+
visit(cmd as Record<string, unknown>, commandPointer, undefined);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const fields = describeForm(cmd.op as string, surface)?.fields;
|
|
68
|
+
visit(cmd, commandPointer, fields);
|
|
69
|
+
if (fields !== undefined) {
|
|
70
|
+
walkBodyFields(fields, cmd, commandPointer, visit, surface);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 块体递归本体(`fields` 已解析——`walkCommandBodies` 与 `walkCommands` 共用,避免重复解析) */
|
|
76
|
+
function walkBodyFields(
|
|
77
|
+
fields: readonly FieldDescriptor[],
|
|
78
|
+
cmd: Record<string, unknown>,
|
|
79
|
+
commandPointer: string,
|
|
80
|
+
visit: CommandVisitor,
|
|
81
|
+
surface: OpSurface,
|
|
82
|
+
): void {
|
|
83
|
+
for (const field of fields) {
|
|
84
|
+
walkDescriptor(
|
|
85
|
+
field,
|
|
86
|
+
cmd[field.key],
|
|
87
|
+
`${commandPointer}/${escapePointerToken(field.key)}`,
|
|
88
|
+
visit,
|
|
89
|
+
surface,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 沿**单条命令**的表单描述符递归其全部块体(if.then/elif[].then/else、while/for/foreach.body、
|
|
96
|
+
* switch.cases[].body/default、func.body),按有序体深度优先回调受控命令。
|
|
97
|
+
* 与 `walkStoryCommands` 共用同一份「块体字段」知识(表单描述符的单点),
|
|
98
|
+
* 故「某命令的体里有什么」在本模块内不存在第二套判定。
|
|
99
|
+
*/
|
|
100
|
+
export function walkCommandBodies(
|
|
101
|
+
cmd: Record<string, unknown>,
|
|
102
|
+
commandPointer: string,
|
|
103
|
+
visit: CommandVisitor,
|
|
104
|
+
surface: OpSurface = BUILTIN_OP_SURFACE,
|
|
105
|
+
): void {
|
|
106
|
+
const fields = describeForm(cmd.op as string, surface)?.fields;
|
|
107
|
+
if (fields === undefined) return;
|
|
108
|
+
walkBodyFields(fields, cmd, commandPointer, visit, surface);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 遍历整棵故事树的全部**命令**(含嵌套块体);visit 按命令指针回调。
|
|
113
|
+
* `surface`(可选)= op 合并面(缺省内建;扩展注册后由组合根传入)。
|
|
114
|
+
* 注意:scene 列的 `elements` **不在**命令遍历内——元素是声明式空间层,
|
|
115
|
+
* 其形状/属性校验走 `walkStoryElements` + `validateElement`。
|
|
116
|
+
* 混进命令遍历会被误报 `unknown-op`(元素只有 `type`,没有 `op`)。
|
|
117
|
+
*/
|
|
118
|
+
export function walkStoryCommands(
|
|
119
|
+
story: unknown,
|
|
120
|
+
visit: CommandVisitor,
|
|
121
|
+
surface: OpSurface = BUILTIN_OP_SURFACE,
|
|
122
|
+
): void {
|
|
123
|
+
if (!isPlainObject(story) || !Array.isArray(story.columns)) return;
|
|
124
|
+
story.columns.forEach((column, columnIndex) => {
|
|
125
|
+
if (!isPlainObject(column)) return;
|
|
126
|
+
const columnPointer = `/columns/${columnIndex}`;
|
|
127
|
+
const fields = column.kind === "flow" ? ["commands"] : ["entry"];
|
|
128
|
+
for (const field of fields) {
|
|
129
|
+
const list = column[field];
|
|
130
|
+
if (Array.isArray(list)) {
|
|
131
|
+
walkCommands(list, `${columnPointer}/${field}`, visit, surface);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type ElementVisitor = (
|
|
138
|
+
node: Record<string, unknown>,
|
|
139
|
+
pointer: string,
|
|
140
|
+
) => void;
|
|
141
|
+
|
|
142
|
+
function walkElementList(
|
|
143
|
+
list: readonly unknown[],
|
|
144
|
+
pointer: string,
|
|
145
|
+
visit: ElementVisitor,
|
|
146
|
+
): void {
|
|
147
|
+
list.forEach((node, index) => {
|
|
148
|
+
const nodePointer = `${pointer}/${index}`;
|
|
149
|
+
// 非对象也回调(由 validateElement 报「必须为对象」,保持诊断口径统一)
|
|
150
|
+
visit(node as Record<string, unknown>, nodePointer);
|
|
151
|
+
if (isPlainObject(node) && Array.isArray(node.children)) {
|
|
152
|
+
walkElementList(node.children, `${nodePointer}/children`, visit);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 遍历 scene 列的舞台元素(含 `children` 递归);visit 按元素指针回调(校验与索引共用) */
|
|
158
|
+
export function walkStoryElements(story: unknown, visit: ElementVisitor): void {
|
|
159
|
+
if (!isPlainObject(story) || !Array.isArray(story.columns)) return;
|
|
160
|
+
story.columns.forEach((column, columnIndex) => {
|
|
161
|
+
if (!isPlainObject(column) || column.kind !== "scene") return;
|
|
162
|
+
const list = column.elements;
|
|
163
|
+
if (Array.isArray(list)) {
|
|
164
|
+
walkElementList(list, `/columns/${columnIndex}/elements`, visit);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|