From 7c07395825acabedc8da2cbc1151257a181c292e Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:44:00 +0800 Subject: [PATCH] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 介绍面向静态游戏内容的 AI-First 配表方案 - 说明 YAML 配置源文件和 JSON Schema 结构描述支持 - 提供推荐目录结构和 Schema 配置示例 - 展示怪物配置和物品配置的 YAML 示例 - 提供完整的接入模板包括 csproj 配置和启动代码 - 介绍官方启动帮助器 GameConfigBootstrap 使用方法 - 说明 Godot 引擎文本配置桥接功能 - 提供运行时读取模板和生成查询辅助功能 - 介绍 Architecture 架构接入模板和热重载配置 - 详述运行时接入方式和注册辅助功能 - 说明运行时校验行为和跨表引用机制 - 提供开发期热重载功能配置指南 - 介绍生成器接入约定和 VS Code 工具支持 - 列出当前功能限制和独立工具评估结论 - 添加配置验证 JavaScript 实现代码 - 实现字符串格式验证和正则表达式校验 - 提供 schema 解析和 YAML 解析功能 - 实现配置字段编辑和注释提取功能 --- .../Config/YamlConfigLoaderTests.cs | 90 +++++++++++++++++++ .../Config/SchemaConfigGeneratorTests.cs | 89 ++++++++++++++++++ .../Config/SchemaConfigGenerator.cs | 86 ++++++++++++++++++ docs/zh-CN/game/config-system.md | 2 +- .../src/configValidation.js | 6 +- .../test/configValidation.test.js | 34 +++++++ 6 files changed, 303 insertions(+), 4 deletions(-) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 1ba55208..c22d2148 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -989,6 +989,96 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证 schema 在非字符串节点上声明 format 时,会在 schema 解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Format_Is_Used_On_Non_String_Property() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "hp"], + "properties": { + "id": { "type": "integer" }, + "hp": { + "type": "integer", + "format": "uuid" + } + } + } + """); + + 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("hp")); + Assert.That(exception.Message, Does.Contain("only 'string' scalar types support string formats")); + }); + } + + /// + /// 验证 schema 将 format 声明为非字符串值时,会在 schema 解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Format_Is_Not_A_String() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "format": 123 + }, + "hp": { "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("name")); + Assert.That(exception.Message, Does.Contain("must declare 'format' as a string")); + }); + } + /// /// 验证运行时 schema 校验与 JS 工具对反向引用模式保持一致。 /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index f34e7a49..73ce4d4e 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -181,6 +181,95 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证根节点在非字符串 schema 上声明 format 时也会在生成阶段直接给出诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Root_Node_Uses_Format() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "format": "uuid", + "required": ["id"], + "properties": { + "id": { "type": "integer" } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_009")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Only 'string' properties can declare 'format'.")); + }); + } + + /// + /// 验证数组 contains 子 schema 内的非法 format 也会在生成阶段直接给出诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Contains_Schema_Uses_Format_On_Non_String_Node() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "dropIds"], + "properties": { + "id": { "type": "integer" }, + "dropIds": { + "type": "array", + "items": { "type": "integer" }, + "contains": { + "type": "integer", + "format": "uuid" + } + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_009")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("dropIds[contains]")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Only 'string' properties can declare 'format'.")); + }); + } + /// /// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。 /// diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 14debdf2..cec8282e 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -126,6 +126,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator Path.GetFileName(file.Path))); } + if (!TryValidateStringFormatMetadataRecursively( + file.Path, + "", + root, + out var rootFormatDiagnostic)) + { + return SchemaParseResult.FromDiagnostic(rootFormatDiagnostic!); + } + var entityName = ToPascalCase(GetSchemaBaseName(file.Path)); var rootObject = ParseObjectSpec( file.Path, @@ -604,6 +613,83 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return false; } + /// + /// 递归验证 schema 树中的字符串 format 元数据。 + /// 该遍历专门补足根节点、contains 子 schema 等不会完全进入常规属性解析路径的片段, + /// 避免生成器对同一份 schema 比运行时和工具链更宽松。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 失败时返回的诊断。 + /// 当前节点树的 format 元数据是否有效。 + private static bool TryValidateStringFormatMetadataRecursively( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (element.ValueKind != JsonValueKind.Object) + { + return true; + } + + var schemaType = string.Empty; + if (element.TryGetProperty("type", out var typeElement) && + typeElement.ValueKind == JsonValueKind.String) + { + schemaType = typeElement.GetString() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(schemaType) && + !TryValidateStringFormatMetadata(filePath, displayPath, element, schemaType, out diagnostic)) + { + return false; + } + } + + if (string.Equals(schemaType, "object", StringComparison.Ordinal) && + element.TryGetProperty("properties", out var propertiesElement) && + propertiesElement.ValueKind == JsonValueKind.Object) + { + foreach (var property in propertiesElement.EnumerateObject()) + { + if (!TryValidateStringFormatMetadataRecursively( + filePath, + CombinePath(displayPath, property.Name), + property.Value, + out diagnostic)) + { + return false; + } + } + } + + if (!string.Equals(schemaType, "array", StringComparison.Ordinal)) + { + return true; + } + + if (element.TryGetProperty("items", out var itemsElement) && + itemsElement.ValueKind == JsonValueKind.Object && + !TryValidateStringFormatMetadataRecursively(filePath, $"{displayPath}[]", itemsElement, out diagnostic)) + { + return false; + } + + if (element.TryGetProperty("contains", out var containsElement) && + containsElement.ValueKind == JsonValueKind.Object && + !TryValidateStringFormatMetadataRecursively( + filePath, + $"{displayPath}[contains]", + containsElement, + out diagnostic)) + { + return false; + } + + return true; + } + /// /// 判断给定 format 名称是否属于当前共享支持子集。 /// diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 06492f01..6dc5ba21 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -874,7 +874,7 @@ var hotReload = loader.EnableHotReload( - 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 - 对空配置文件提供基于 schema 的示例 YAML 初始化入口 - 对同一配置域内的多份 YAML 文件执行批量字段更新 -- 在表单入口中显示 `title / description / default / const / enum / ref-table / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 +- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-table(UI 中显示为 ref-table) / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index def29e11..48c1c117 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -464,12 +464,12 @@ function normalizeSchemaPattern(value, displayPath) { * @returns {string | undefined} Normalized format name. */ function normalizeSchemaStringFormat(value, schemaType, displayPath) { - if (schemaType !== "string") { + if (value === undefined) { return undefined; } - if (value === undefined) { - return undefined; + if (schemaType !== "string") { + throw new Error(`Schema property '${displayPath}' can only declare 'format' on type 'string'.`); } if (typeof value !== "string") { diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index a291bd2d..b1d4f59b 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -1630,6 +1630,40 @@ test("parseSchemaContent should reject unsupported string format declarations", ); }); +test("parseSchemaContent should reject format declarations on non-string schema nodes", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "hp": { + "type": "integer", + "format": "uuid" + } + } + } + `), + /can only declare 'format' on type 'string'/u + ); +}); + +test("parseSchemaContent should reject non-string format metadata values", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "format": 123 + } + } + } + `), + /must declare 'format' as a string/u + ); +}); + test("parseSchemaContent should ignore mismatched constraint metadata on unsupported scalar types", () => { const schema = parseSchemaContent(` {