From 039ef9817ac85fab94135031f36c12182908be3a Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Fri, 10 Apr 2026 18:52:03 +0800
Subject: [PATCH] =?UTF-8?q?feat(extension):=20=E6=B7=BB=E5=8A=A0GFramework?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=B7=A5=E5=85=B7=E6=89=A9=E5=B1=95=E5=8A=9F?=
=?UTF-8?q?=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现配置文件浏览器树视图,支持工作区配置目录导航
- 集成轻量级验证系统,支持YAML配置文件语法检查
- 添加模式感知表单预览功能,支持结构化配置编辑
- 实现批量编辑功能,支持跨多个配置文件统一修改字段值
- 集成国际化支持,提供中英文本地化界面
- 添加实时配置文件保存验证,在文件保存时自动校验
- 实现引用导航功能,支持跳转到关联配置表和文件
- 添加工作区变更响应,支持动态刷新配置树视图
---
.../Config/YamlConfigLoaderTests.cs | 350 +++++++++++++++++-
.../Config/YamlConfigSchemaValidator.cs | 42 ++-
.../src/containsSummary.js | 41 ++
tools/gframework-config-tool/src/extension.js | 32 +-
.../test/containsSummary.test.js | 27 ++
5 files changed, 454 insertions(+), 38 deletions(-)
create mode 100644 tools/gframework-config-tool/src/containsSummary.js
create mode 100644 tools/gframework-config-tool/test/containsSummary.test.js
diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
index e4a8a1df..af964a0b 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -1211,6 +1211,170 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证匹配数量刚好等于 minContains / maxContains 时会被视为合法边界。
+ ///
+ [Test]
+ public async Task LoadAsync_Should_Accept_Array_When_Contains_Match_Count_Equals_Min_And_Max_Bounds()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ dropRates:
+ - 5
+ - 7
+ - 5
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "dropRates"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "dropRates": {
+ "type": "array",
+ "minContains": 2,
+ "maxContains": 2,
+ "contains": {
+ "type": "integer",
+ "const": 5
+ },
+ "items": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ await loader.LoadAsync(registry);
+
+ var table = registry.GetTable("monster");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(table.Count, Is.EqualTo(1));
+ Assert.That(table.Get(1).DropRates, Is.EqualTo(new[] { 5, 7, 5 }));
+ });
+ }
+
+ ///
+ /// 验证数组字段将 contains 声明为非对象 schema 时,会在 schema 解析阶段被拒绝。
+ ///
+ [Test]
+ public void LoadAsync_Should_Throw_When_Contains_Is_Not_Object_Schema()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ dropRates:
+ - 5
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "dropRates"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "dropRates": {
+ "type": "array",
+ "contains": 5,
+ "items": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception, Is.Not.Null);
+ Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported));
+ Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRates"));
+ Assert.That(exception.Message, Does.Contain("'contains' as an object-valued schema"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
+ ///
+ /// 验证数组字段将 contains 声明为嵌套数组 schema 时,会在 schema 解析阶段被拒绝。
+ ///
+ [Test]
+ public void LoadAsync_Should_Throw_When_Contains_Uses_Nested_Array_Schema()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ dropRates:
+ - 5
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "dropRates"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "dropRates": {
+ "type": "array",
+ "contains": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ },
+ "items": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception, Is.Not.Null);
+ Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported));
+ Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRates"));
+ Assert.That(exception.Message, Does.Contain("unsupported nested array 'contains' schemas"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
///
/// 验证数组在未声明 contains 时不能单独使用 minContains。
///
@@ -2345,6 +2509,190 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证仅声明在 contains 子 schema 里的跨表引用也会参与整批加载校验。
+ ///
+ [Test]
+ public void LoadAsync_Should_Throw_When_Contains_Matched_Reference_Target_Is_Missing()
+ {
+ CreateConfigFile(
+ "item/potion.yaml",
+ """
+ id: potion
+ name: Potion
+ """);
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ dropItemIds:
+ - potion
+ - missing_item
+ """);
+ CreateSchemaFile(
+ "schemas/item.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": {
+ "id": { "type": "string" },
+ "name": { "type": "string" }
+ }
+ }
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "dropItemIds"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "dropItemIds": {
+ "type": "array",
+ "minContains": 1,
+ "contains": {
+ "type": "string",
+ "x-gframework-ref-table": "item"
+ },
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable("item", "item", "schemas/item.schema.json",
+ static config => config.Id)
+ .RegisterTable("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception, Is.Not.Null);
+ Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ReferencedKeyNotFound));
+ Assert.That(exception.Diagnostic.TableName, Is.EqualTo("monster"));
+ Assert.That(exception.Diagnostic.ReferencedTableName, Is.EqualTo("item"));
+ Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropItemIds[1]"));
+ Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("missing_item"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
+ ///
+ /// 验证依赖关系仅来自 contains 子 schema 时,热重载仍会追踪该依赖并在目标表破坏引用后回滚。
+ ///
+ [Test]
+ public async Task EnableHotReload_Should_Keep_Previous_State_When_Contains_Reference_Dependency_Breaks()
+ {
+ CreateConfigFile(
+ "item/potion.yaml",
+ """
+ id: potion
+ name: Potion
+ """);
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ dropItemIds:
+ - potion
+ """);
+ CreateSchemaFile(
+ "schemas/item.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": {
+ "id": { "type": "string" },
+ "name": { "type": "string" }
+ }
+ }
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "dropItemIds"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "dropItemIds": {
+ "type": "array",
+ "minContains": 1,
+ "contains": {
+ "type": "string",
+ "x-gframework-ref-table": "item"
+ },
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable("item", "item", "schemas/item.schema.json",
+ static config => config.Id)
+ .RegisterTable("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+ await loader.LoadAsync(registry);
+
+ var reloadFailureTaskSource =
+ new TaskCompletionSource<(string TableName, Exception Exception)>(TaskCreationOptions
+ .RunContinuationsAsynchronously);
+ var hotReload = loader.EnableHotReload(
+ registry,
+ onTableReloadFailed: (tableName, exception) =>
+ reloadFailureTaskSource.TrySetResult((tableName, exception)),
+ debounceDelay: TimeSpan.FromMilliseconds(150));
+
+ try
+ {
+ CreateConfigFile(
+ "item/potion.yaml",
+ """
+ id: elixir
+ name: Elixir
+ """);
+
+ var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5));
+ var diagnosticException = failure.Exception as ConfigLoadException;
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(failure.TableName, Is.EqualTo("item"));
+ Assert.That(diagnosticException, Is.Not.Null);
+ Assert.That(diagnosticException!.Diagnostic.FailureKind,
+ Is.EqualTo(ConfigLoadFailureKind.ReferencedKeyNotFound));
+ Assert.That(diagnosticException.Diagnostic.TableName, Is.EqualTo("monster"));
+ Assert.That(diagnosticException.Diagnostic.ReferencedTableName, Is.EqualTo("item"));
+ Assert.That(diagnosticException.Diagnostic.DisplayPath, Is.EqualTo("dropItemIds[0]"));
+ Assert.That(diagnosticException.Diagnostic.RawValue, Is.EqualTo("potion"));
+ Assert.That(registry.GetTable("item").ContainsKey("potion"), Is.True);
+ Assert.That(registry.GetTable("monster").Get(1).DropItemIds,
+ Is.EqualTo(new[] { "potion" }));
+ });
+ }
+ finally
+ {
+ hotReload.UnRegister();
+ }
+ }
+
///
/// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。
///
@@ -2779,7 +3127,7 @@ public class YamlConfigLoaderTests
///
/// 获取或设置掉落率列表。
///
- public IReadOnlyList DropRates { get; set; } = Array.Empty();
+ public List DropRates { get; set; } = new();
}
///
diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
index 8b60101f..a2b76c6d 100644
--- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs
+++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
@@ -687,7 +687,7 @@ internal static class YamlConfigSchemaValidator
}
ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
- ValidateArrayContainsConstraints(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
+ ValidateArrayContainsConstraints(tableName, yamlPath, displayPath, sequenceNode, schemaNode, references);
ValidateConstantValue(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
}
@@ -2153,12 +2153,14 @@ internal static class YamlConfigSchemaValidator
/// 字段路径。
/// 实际数组节点。
/// 数组 schema 节点。
+ /// 匹配成功的 contains 子树所声明的跨表引用收集器。
private static void ValidateArrayContainsConstraints(
string tableName,
string yamlPath,
string displayPath,
YamlSequenceNode sequenceNode,
- YamlConfigSchemaNode schemaNode)
+ YamlConfigSchemaNode schemaNode,
+ ICollection? references)
{
var containsConstraints = schemaNode.ArrayConstraints?.ContainsConstraints;
if (containsConstraints is null)
@@ -2171,7 +2173,8 @@ internal static class YamlConfigSchemaValidator
yamlPath,
displayPath,
sequenceNode,
- containsConstraints.ContainsNode);
+ containsConstraints.ContainsNode,
+ references);
var rawValue = matchingCount.ToString(CultureInfo.InvariantCulture);
var requiredMinContains = containsConstraints.MinContains ?? 1;
if (matchingCount < requiredMinContains)
@@ -2211,13 +2214,15 @@ internal static class YamlConfigSchemaValidator
/// 数组字段路径。
/// 实际数组节点。
/// contains 子 schema。
+ /// 匹配成功元素的可选跨表引用收集器。
/// 匹配 contains 子 schema 的元素数量。
private static int CountMatchingContainsItems(
string tableName,
string yamlPath,
string displayPath,
YamlSequenceNode sequenceNode,
- YamlConfigSchemaNode containsNode)
+ YamlConfigSchemaNode containsNode,
+ ICollection? references)
{
var matchingCount = 0;
for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++)
@@ -2227,7 +2232,8 @@ internal static class YamlConfigSchemaValidator
yamlPath,
$"{displayPath}[{itemIndex}]",
sequenceNode.Children[itemIndex],
- containsNode))
+ containsNode,
+ references))
{
matchingCount++;
}
@@ -2245,17 +2251,33 @@ internal static class YamlConfigSchemaValidator
/// 当前数组元素路径。
/// 实际 YAML 元素。
/// contains 子 schema。
+ /// 当前元素匹配成功后要写回的可选跨表引用收集器。
/// 当前元素是否匹配 contains 子 schema。
private static bool IsArrayItemMatchingContains(
string tableName,
string yamlPath,
string displayPath,
YamlNode itemNode,
- YamlConfigSchemaNode containsNode)
+ YamlConfigSchemaNode containsNode,
+ ICollection? references)
{
+ // contains 的“试匹配”不能把失败元素的引用泄漏给外层,但匹配成功的元素仍需要参与
+ // 跨表引用收集,否则仅声明在 contains 子 schema 里的 ref-table 会被运行时遗漏。
+ List? matchedReferences = references is null ? null : new();
+
try
{
- ValidateNode(tableName, yamlPath, displayPath, itemNode, containsNode, references: null);
+ ValidateNode(tableName, yamlPath, displayPath, itemNode, containsNode, matchedReferences);
+
+ if (references is not null &&
+ matchedReferences is not null)
+ {
+ foreach (var referenceUsage in matchedReferences)
+ {
+ references.Add(referenceUsage);
+ }
+ }
+
return true;
}
catch (ConfigLoadException exception) when (exception.Diagnostic.FailureKind != ConfigLoadFailureKind.UnexpectedFailure)
@@ -2627,6 +2649,12 @@ internal static class YamlConfigSchemaValidator
{
CollectReferencedTableNames(node.ItemNode, referencedTableNames);
}
+
+ var containsNode = node.ArrayConstraints?.ContainsConstraints?.ContainsNode;
+ if (containsNode is not null)
+ {
+ CollectReferencedTableNames(containsNode, referencedTableNames);
+ }
}
///
diff --git a/tools/gframework-config-tool/src/containsSummary.js b/tools/gframework-config-tool/src/containsSummary.js
new file mode 100644
index 00000000..7b28c193
--- /dev/null
+++ b/tools/gframework-config-tool/src/containsSummary.js
@@ -0,0 +1,41 @@
+/**
+ * Build a compact contains-schema summary for array field hints.
+ * The summary reuses existing localized hint strings so Chinese UI surfaces
+ * do not fall back to mixed English tokens such as const/enum/pattern/ref.
+ *
+ * @param {{type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}} containsSchema Parsed contains schema metadata.
+ * @param {{t: (key: string, params?: Record) => string}} localizer Runtime localizer.
+ * @returns {string} Human-facing summary.
+ */
+function describeContainsSchema(containsSchema, localizer) {
+ const parts = [];
+ if (containsSchema.type) {
+ parts.push(containsSchema.type);
+ }
+
+ if (containsSchema.constValue !== undefined) {
+ parts.push(localizer.t("webview.hint.const", {
+ value: containsSchema.constDisplayValue ?? containsSchema.constValue
+ }));
+ } else if (Array.isArray(containsSchema.enumValues) && containsSchema.enumValues.length > 0) {
+ parts.push(localizer.t("webview.hint.allowed", {
+ values: containsSchema.enumValues.join(", ")
+ }));
+ } else if (containsSchema.pattern) {
+ parts.push(localizer.t("webview.hint.pattern", {
+ value: containsSchema.pattern
+ }));
+ }
+
+ if (containsSchema.refTable) {
+ parts.push(localizer.t("webview.hint.refTable", {
+ refTable: containsSchema.refTable
+ }));
+ }
+
+ return parts.join(", ") || localizer.t("webview.objectArray.item");
+}
+
+module.exports = {
+ describeContainsSchema
+};
diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js
index 75c758ef..f07b6c3f 100644
--- a/tools/gframework-config-tool/src/extension.js
+++ b/tools/gframework-config-tool/src/extension.js
@@ -18,6 +18,7 @@ const {
joinArrayTemplatePath,
joinPropertyPath
} = require("./configPath");
+const {describeContainsSchema} = require("./containsSummary");
const {createLocalizer} = require("./localization");
const localizer = createLocalizer(vscode.env.language);
@@ -1658,7 +1659,7 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true
if (isArrayField && propertySchema.contains) {
hints.push(escapeHtml(localizer.t("webview.hint.contains", {
- summary: describeContainsSchema(propertySchema.contains)
+ summary: describeContainsSchema(propertySchema.contains, localizer)
})));
}
@@ -1723,35 +1724,6 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true
return `${hints.join(" · ")}`;
}
-/**
- * Build a compact contains-schema summary for array field hints.
- * The hint intentionally stays short so the form preview can expose the rule
- * without inlining a second full schema tree beside the field controls.
- *
- * @param {{type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}} containsSchema Parsed contains schema metadata.
- * @returns {string} Human-facing summary.
- */
-function describeContainsSchema(containsSchema) {
- const parts = [];
- if (containsSchema.type) {
- parts.push(containsSchema.type);
- }
-
- if (containsSchema.constValue !== undefined) {
- parts.push(`const = ${containsSchema.constDisplayValue ?? containsSchema.constValue}`);
- } else if (Array.isArray(containsSchema.enumValues) && containsSchema.enumValues.length > 0) {
- parts.push(`enum = ${containsSchema.enumValues.join(", ")}`);
- } else if (containsSchema.pattern) {
- parts.push(`pattern = ${containsSchema.pattern}`);
- }
-
- if (containsSchema.refTable) {
- parts.push(`ref = ${containsSchema.refTable}`);
- }
-
- return parts.join(", ") || "item";
-}
-
/**
* Prompt for one batch-edit field value.
*
diff --git a/tools/gframework-config-tool/test/containsSummary.test.js b/tools/gframework-config-tool/test/containsSummary.test.js
new file mode 100644
index 00000000..4ad19c24
--- /dev/null
+++ b/tools/gframework-config-tool/test/containsSummary.test.js
@@ -0,0 +1,27 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const {describeContainsSchema} = require("../src/containsSummary");
+const {createLocalizer} = require("../src/localization");
+
+test("describeContainsSchema should reuse localized Chinese hint strings", () => {
+ const localizer = createLocalizer("zh-cn");
+
+ const summary = describeContainsSchema(
+ {
+ type: "string",
+ constValue: "\"potion\"",
+ constDisplayValue: "\"potion\"",
+ refTable: "item"
+ },
+ localizer);
+
+ assert.equal(summary, "string, 固定值:\"potion\", 引用表:item");
+});
+
+test("describeContainsSchema should fall back to localized item label", () => {
+ const localizer = createLocalizer("en");
+
+ const summary = describeContainsSchema({}, localizer);
+
+ assert.equal(summary, "Item");
+});