diff --git a/GFramework.Game.Abstractions/README.md b/GFramework.Game.Abstractions/README.md index 62d5d1ea..dbdd00c6 100644 --- a/GFramework.Game.Abstractions/README.md +++ b/GFramework.Game.Abstractions/README.md @@ -27,6 +27,29 @@ - `IUiFactory`、`ISceneFactory`、`IUiRoot`、`ISceneRoot`、资源注册表等通常由引擎适配层或游戏项目自己实现。 - 常见做法也是这样组织:页面 / 场景 factory、root、registry 在项目层,运行时基类和契约来自 `GFramework.Game` 与本包。 +## Config Workflow Boundary + +If you only depend on `GFramework.Game.Abstractions`, keep the configuration boundary explicit. + +`Config/` in this package defines read-side contracts such as `IConfigLoader`, `IConfigRegistry`, `IConfigTable`, and +diagnostic models. It does not define the full adoption boundary of the AI-First configuration workflow by itself. + +The actual implementation and support boundary still lives in `GFramework.Game` and its companion documentation: + +- `YamlConfigLoader`, `GameConfigBootstrap`, and `GameConfigModule` are runtime features from `GFramework.Game` +- `GFramework.Game.SourceGenerators` targets the shared schema subset that stays aligned with the runtime contract +- schema designs outside that shared subset should be evaluated against `GFramework.Game` and + `docs/zh-CN/game/config-system.md`, not inferred from abstractions alone + +Typical examples that are outside the current adoption path include: + +- combinators such as `oneOf` and `anyOf` +- non-`false` forms of `additionalProperties` +- other schema designs that rely on open object shapes, union-like branching, or shape-merging behavior + +If your project needs those boundaries clarified, move from this package-level contract view to the runtime-facing +configuration documentation instead of assuming `Game.Abstractions` implies broader schema support. + ## 子系统地图 ### `Config/` @@ -205,6 +228,8 @@ public sealed class ContinueGameCommandHandler - 槽位存档仓库实现 - YAML 配置加载器 - Scene / UI 路由基类 +- AI-First configuration boundary details, including the supported shared schema subset and the unsupported combinator / + open-shape cases 也就是说,本包回答的是“项目各层如何约定”,`GFramework.Game` 回答的是“这些约定默认怎么跑起来”。 @@ -251,3 +276,6 @@ public sealed class ContinueGameCommandHandler - 你需要默认实现、基础设施拼装、运行时启动入口 - 两者一起用 - 最常见。公共层依赖 abstractions,应用层或引擎层依赖 runtime + +For configuration-specific adoption decisions, treat `GFramework.Game` and +[配置系统](../docs/zh-CN/game/config-system.md) as the authoritative next step. diff --git a/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md index 15a93c31..53562b2d 100644 --- a/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -19,3 +19,5 @@ GF_ConfigSchema_012 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_013 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_014 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_015 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_016 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics diff --git a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs index 6d92383c..eeedde14 100644 --- a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -232,6 +232,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } return TryValidateStringFormatMetadataRecursively(filePath, "", root, out diagnostic) && + TryValidateUnsupportedCombinatorKeywordsRecursively(filePath, "", root, out diagnostic) && + TryValidateUnsupportedOpenObjectKeywordsRecursively(filePath, "", root, out diagnostic) && TryValidateDependentRequiredMetadataRecursively(filePath, "", root, out diagnostic) && TryValidateDependentSchemasMetadataRecursively(filePath, "", root, out diagnostic) && TryValidateAllOfMetadataRecursively(filePath, "", root, out diagnostic) && @@ -844,6 +846,148 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator out diagnostic); } + /// + /// 递归拒绝当前共享子集尚未支持的组合关键字。 + /// 这里显式拦截 oneOf / anyOf,避免生成器静默接受会改变生成类型形状的 schema。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 失败时返回的诊断。 + /// 当前节点树是否未声明不支持的组合关键字。 + private static bool TryValidateUnsupportedCombinatorKeywordsRecursively( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + return TryTraverseSchemaRecursively( + filePath, + displayPath, + element, + static (currentFilePath, currentDisplayPath, currentElement, _) => + { + return TryValidateUnsupportedCombinatorKeywords( + currentFilePath, + currentDisplayPath, + currentElement, + out var currentDiagnostic) + ? (true, (Diagnostic?)null) + : (false, currentDiagnostic); + }, + out diagnostic); + } + + /// + /// 递归拒绝当前共享子集尚未支持的开放对象关键字形状。 + /// 当前对象字段集默认是闭合的,因此这里只接受显式重复该语义的 + /// additionalProperties: false。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 失败时返回的诊断。 + /// 当前节点树是否未声明不支持的开放对象关键字形状。 + private static bool TryValidateUnsupportedOpenObjectKeywordsRecursively( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + return TryTraverseSchemaRecursively( + filePath, + displayPath, + element, + static (currentFilePath, currentDisplayPath, currentElement, _) => + { + return TryValidateUnsupportedOpenObjectKeywords( + currentFilePath, + currentDisplayPath, + currentElement, + out var currentDiagnostic) + ? (true, (Diagnostic?)null) + : (false, currentDiagnostic); + }, + out diagnostic); + } + + /// + /// 验证当前节点是否声明了会改变生成类型形状的未支持组合关键字。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 失败时返回的诊断。 + /// 未声明不支持关键字时返回 + private static bool TryValidateUnsupportedCombinatorKeywords( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (TryGetUnsupportedCombinatorKeywordName(element) is not { } keywordName) + { + return true; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedCombinatorKeyword, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + keywordName, + "The current config schema subset does not support combinators that can change generated type shape."); + return false; + } + + /// + /// 验证当前节点是否声明了当前共享子集尚未支持的开放对象关键字形状。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 失败时返回的诊断。 + /// 未声明不支持关键字时返回 + private static bool TryValidateUnsupportedOpenObjectKeywords( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!element.TryGetProperty("additionalProperties", out var additionalPropertiesElement)) + { + return true; + } + + if (additionalPropertiesElement.ValueKind == JsonValueKind.False) + { + return true; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedOpenObjectKeyword, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "additionalProperties", + "The current config schema subset only accepts 'additionalProperties: false' so object fields remain closed and strongly typed."); + return false; + } + + /// + /// 返回当前节点声明的首个未支持组合关键字。 + /// + /// 当前 schema 节点。 + /// 命中的关键字名称;未声明时返回空。 + private static string? TryGetUnsupportedCombinatorKeywordName(JsonElement element) + { + return element.TryGetProperty("oneOf", out _) ? "oneOf" : + element.TryGetProperty("anyOf", out _) ? "anyOf" : + null; + } + /// /// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。 /// 该遍历覆盖对象属性、dependentSchemas / allOf / diff --git a/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs index adbbdd41..64c80336 100644 --- a/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs +++ b/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs @@ -162,4 +162,26 @@ public static class ConfigSchemaDiagnostics SourceGeneratorsConfigCategory, DiagnosticSeverity.Error, true); + + /// + /// schema 节点声明了当前共享子集尚未支持的组合关键字。 + /// + public static readonly DiagnosticDescriptor UnsupportedCombinatorKeyword = new( + "GF_ConfigSchema_015", + "Config schema uses an unsupported combinator keyword", + "Property '{1}' in schema file '{0}' uses unsupported combinator keyword '{2}': {3}", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); + + /// + /// schema 节点声明了当前共享子集尚未支持的开放对象关键字形状。 + /// + public static readonly DiagnosticDescriptor UnsupportedOpenObjectKeyword = new( + "GF_ConfigSchema_016", + "Config schema uses an unsupported open-object keyword", + "Property '{1}' in schema file '{0}' uses unsupported open-object keyword '{2}': {3}", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); } diff --git a/GFramework.Game.SourceGenerators/README.md b/GFramework.Game.SourceGenerators/README.md index 63478af8..1be51b44 100644 --- a/GFramework.Game.SourceGenerators/README.md +++ b/GFramework.Game.SourceGenerators/README.md @@ -14,6 +14,9 @@ - 对应的表包装类型 - 与 `GFramework.Game.Config` 运行时协作的访问辅助代码 +这里要先明确一条采用边界:`GFramework.Game.SourceGenerators` 服务的是当前与 `GFramework.Game` +Runtime 对齐的共享 schema 子集,而不是任意 `JSON Schema` 的全量实现。它的目标是让配置生成、运行时校验和工具链维持同一份可落地契约,而不是把所有 schema 组合能力都映射成生成类型。 + ## 包关系 - 运行时:`GFramework.Game` @@ -73,6 +76,15 @@ GameProject/ - 你希望在编译期拿到强类型配置访问入口 - 你希望运行时加载、schema 校验和编辑工具链共用同一份结构定义 +如果你的 schema 设计依赖下面这些场景,就不属于当前默认采用路径: + +- `oneOf` +- `anyOf` +- 非 `false` 的 `additionalProperties` +- 其他依赖开放对象形状、联合分支或属性合并的复杂组合约束 + +遇到这些情况时,建议先回到 [配置系统文档](../docs/zh-CN/game/config-system.md) 和原始 schema / YAML 设计本体,确认是否需要调整配置建模方式,而不是默认期待生成器直接支持完整 `JSON Schema` 语义。 + ## 对应文档 - 配置系统:[配置系统文档](../docs/zh-CN/game/config-system.md) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs index 1e4da8ff..017c6dff 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs @@ -270,6 +270,181 @@ public sealed class YamlConfigLoaderAllOfTests }); } + /// + /// 验证运行时会显式拒绝当前共享子集尚未支持的 oneOf。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Object_Schema_Declares_Unsupported_OneOf() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + ] + """, + """ + "oneOf": [ + { + "type": "object", + "required": ["bonus"], + "properties": { + "bonus": { "type": "integer" } + } + } + ] + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + var exception = Assert.ThrowsAsync(() => 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("reward")); + Assert.That(exception.Message, Does.Contain("unsupported combinator keyword 'oneOf'")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证运行时会显式拒绝当前共享子集尚未支持的 anyOf。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Object_Schema_Declares_Unsupported_AnyOf() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + ] + """, + """ + "anyOf": [ + { + "type": "object", + "required": ["bonus"], + "properties": { + "bonus": { "type": "integer" } + } + } + ] + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + var exception = Assert.ThrowsAsync(() => 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("reward")); + Assert.That(exception.Message, Does.Contain("unsupported combinator keyword 'anyOf'")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证运行时接受显式声明的 additionalProperties: false, + /// 因为这与当前闭合对象字段集语义保持一致。 + /// + [Test] + public async Task LoadAsync_Should_Accept_When_Object_Schema_Declares_AdditionalProperties_False() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + DefaultAllOfJson, + """ + "additionalProperties": false + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + await loader.LoadAsync(registry).ConfigureAwait(false); + + var table = registry.GetTable("monster"); + Assert.That(table.Count, Is.EqualTo(1)); + } + + /// + /// 验证运行时会拒绝会打开动态字段形状的 additionalProperties。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Object_Schema_Declares_Unsupported_AdditionalProperties() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + DefaultAllOfJson, + """ + "additionalProperties": true + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + var exception = Assert.ThrowsAsync(() => 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("reward")); + Assert.That(exception.Message, Does.Contain("unsupported 'additionalProperties' metadata")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证 allOf 条目只接受 object-typed schema。 /// @@ -566,10 +741,12 @@ public sealed class YamlConfigLoaderAllOfTests /// /// 奖励对象的 properties JSON 片段。 /// allOf 约束的 JSON 数组片段。 + /// 追加到奖励对象上的额外关键字 JSON 片段。 /// 完整的 schema JSON 文本。 private static string BuildMonsterSchema( string rewardPropertiesJson, - string allOfJson) + string allOfJson, + string additionalRewardKeywordsJson = "") { return $$""" { @@ -580,7 +757,7 @@ public sealed class YamlConfigLoaderAllOfTests "reward": { "type": "object", "properties": {{rewardPropertiesJson}}, - "allOf": {{allOfJson}} + "allOf": {{allOfJson}}{{(string.IsNullOrWhiteSpace(additionalRewardKeywordsJson) ? string.Empty : "," + Environment.NewLine + additionalRewardKeywordsJson.Trim())}} } } } diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 3aaf1d0d..37a9d622 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -321,6 +321,8 @@ internal static partial class YamlConfigSchemaValidator JsonElement element, bool isRoot = false) { + ValidateUnsupportedCombinatorKeywords(tableName, schemaPath, propertyPath, element); + ValidateUnsupportedOpenObjectKeywords(tableName, schemaPath, propertyPath, element); var typeName = ResolveNodeTypeName(tableName, schemaPath, propertyPath, element); var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element); ValidateObjectOnlyKeywords(tableName, schemaPath, propertyPath, element, typeName); @@ -336,6 +338,81 @@ internal static partial class YamlConfigSchemaValidator return parsedNode.WithNegatedSchemaNode(ParseNegatedSchemaNode(tableName, schemaPath, propertyPath, element)); } + /// + /// 显式拒绝当前共享子集中尚未支持、且会改变生成类型形状的组合关键字。 + /// 这样 Runtime / Generator / Tooling 会对同一份 schema 给出一致失败, + /// 而不是默默忽略 oneOf / anyOf 造成接受范围漂移。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 当前节点的逻辑属性路径。 + /// 当前 schema 节点。 + private static void ValidateUnsupportedCombinatorKeywords( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element) + { + if (TryGetUnsupportedCombinatorKeywordName(element) is not { } keywordName) + { + return; + } + + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' declares unsupported combinator keyword '{keywordName}'. " + + "The current config schema subset does not support combinators that can change generated type shape.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + /// + /// 显式拒绝当前共享子集中尚未支持的开放对象关键字形状。 + /// 当前配置系统默认采用闭合对象字段集;这里只接受显式重复该语义的 + /// additionalProperties: false,继续拒绝会引入动态字段形状的其它形式。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 当前节点的逻辑属性路径。 + /// 当前 schema 节点。 + private static void ValidateUnsupportedOpenObjectKeywords( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element) + { + if (!element.TryGetProperty("additionalProperties", out var additionalPropertiesElement)) + { + return; + } + + if (additionalPropertiesElement.ValueKind == JsonValueKind.False) + { + return; + } + + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported 'additionalProperties' metadata. " + + "The current config schema subset only accepts 'additionalProperties: false' so object fields remain closed and strongly typed.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + /// + /// 返回当前节点声明的首个未支持组合关键字。 + /// + /// 当前 schema 节点。 + /// 命中的关键字名称;未声明时返回空。 + private static string? TryGetUnsupportedCombinatorKeywordName(JsonElement element) + { + return element.TryGetProperty("oneOf", out _) ? "oneOf" : + element.TryGetProperty("anyOf", out _) ? "anyOf" : + null; + } + /// /// 解析 schema 节点声明的类型名称,并在缺失或类型错误时立刻给出定位清晰的诊断。 /// diff --git a/GFramework.Game/README.md b/GFramework.Game/README.md index 33aaa752..e6bd0736 100644 --- a/GFramework.Game/README.md +++ b/GFramework.Game/README.md @@ -292,6 +292,13 @@ var registry = bootstrap.Registry; - [内容配置系统](../docs/zh-CN/game/config-system.md) +接入前建议先记住当前采用边界: + +- 正式契约以 `YamlConfigLoader` 与 `GFramework.Game.SourceGenerators` 共享支持的 schema 子集为准 +- `additionalProperties` 当前只接受 `false`,用于保持对象字段集闭合 +- `oneOf` / `anyOf` 这类会改变生成类型形状的组合关键字当前不属于采用路径 +- VS Code 配置工具是内容维护辅助层;如果 schema 超出共享子集,应回退到 raw YAML 与 schema 本体设计 + ### 4. 接入 Scene / UI 路由 这里的最小前提不是“直接 new 一个 router”,而是先补齐运行时依赖: diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 9826693f..fb3bc15d 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -1795,6 +1795,173 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证生成器会显式拒绝当前共享子集尚未支持的 oneOf。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Object_Schema_Declares_Unsupported_OneOf() + { + const string source = DummySource; + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "oneOf": [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "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_015")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward")); + Assert.That(diagnostic.GetMessage(), Does.Contain("oneOf")); + Assert.That(diagnostic.GetMessage(), Does.Contain("does not support combinators that can change generated type shape")); + }); + } + + /// + /// 验证生成器会显式拒绝当前共享子集尚未支持的 anyOf。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Object_Schema_Declares_Unsupported_AnyOf() + { + const string source = DummySource; + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "anyOf": [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "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_015")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward")); + Assert.That(diagnostic.GetMessage(), Does.Contain("anyOf")); + Assert.That(diagnostic.GetMessage(), Does.Contain("does not support combinators that can change generated type shape")); + }); + } + + /// + /// 验证生成器接受显式声明的 additionalProperties: false。 + /// + [Test] + public void Run_Should_Accept_When_Object_Schema_Declares_AdditionalProperties_False() + { + const string source = DummySource; + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "additionalProperties": false, + "properties": { + "itemCount": { "type": "integer" } + } + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + Assert.That(result.Results.Single().Diagnostics, Is.Empty); + } + + /// + /// 验证生成器会拒绝会打开动态字段形状的 additionalProperties。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Object_Schema_Declares_Unsupported_AdditionalProperties() + { + const string source = DummySource; + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "additionalProperties": true, + "properties": { + "itemCount": { "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_016")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward")); + Assert.That(diagnostic.GetMessage(), Does.Contain("additionalProperties")); + Assert.That(diagnostic.GetMessage(), Does.Contain("only accepts 'additionalProperties: false'")); + }); + } + /// /// 验证 then 子 schema 内的非法 format 也会在生成阶段直接给出诊断。 /// diff --git a/README.md b/README.md index 82a0c6bc..5b2ea3ec 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - 想先跑一个最小例子:[快速开始](docs/zh-CN/getting-started/quick-start.md) - 想先确认该装哪些包:[安装配置](docs/zh-CN/getting-started/installation.md) - 想接入 AI-First 配置工作流:[配置系统](docs/zh-CN/game/config-system.md) / [VS Code 配置工具](docs/zh-CN/game/config-tool.md) + 当前正式支持边界以 `GFramework.Game` Runtime 与 `GFramework.Game.SourceGenerators` 共享 schema 子集为准;VS Code 工具是辅助层,不会单独扩出另一套配置契约 - 已经知道要用哪个模块:直接进入对应模块的说明页 ## 模块地图 @@ -74,6 +75,13 @@ - `GeWuYou.GFramework.*.SourceGenerators` 只在需要编译期生成代码时安装,版本应与运行时包保持一致。 +如果你采用 AI-First 配置工作流,建议在进入实现前先确认两条边界: + +- 当前共享子集接受闭合对象边界 `additionalProperties: false`(需显式设置为 `false`;省略或 `true` 视为不支持) +- `oneOf` / `anyOf` 这类会改变生成类型形状的组合关键字当前会被 Runtime / Generator / Tooling 直接拒绝 + +更复杂的 schema shape 需要先回到 schema 设计与 raw YAML 维护路径,而不是假定编辑器工具存在隐藏支持。 + ## 最小安装组合 ```bash diff --git a/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md b/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md index c45850ab..c63eb6a9 100644 --- a/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md +++ b/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md @@ -6,6 +6,13 @@ 当前阶段不再把 VS Code 工具能力当作阻塞项;工具链只要不拖累 C# 首发可用版本即可。 +## 并行 Lane 约束 + +- `C# Runtime + Source Generator + Consumer DX` 仍是当前主线恢复点 +- Tooling / Docs 作为非阻塞并行 lane 单独推进,但每一批仍要和 Runtime / Generator 的共享关键字边界保持一致 +- active tracking / trace 只保留恢复点、验证与 lane 指针;复杂编辑器细节、宿主手工验证和文档批次安排统一写在本文件 +- public docs 只写消费者接入、限制和迁移边界;治理噪音、批次编排和 recovery 元数据继续留在 `ai-plan/**` + ## 当前状态 - [x] 单表注册辅助:`Register{Entity}Table()` @@ -37,8 +44,8 @@ - [x] 继续扩展最有价值的 JSON Schema 子集 - 原则:只做 Runtime / Generator / Tooling 三端都能稳定解释的关键字 - - 已补齐:`enum`(当前覆盖标量、对象、数组节点,以及标量数组元素)、`const`、`not`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`uniqueItems`、`minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas`、`allOf`、object-focused `if` / `then` / `else` - - 当前产出:运行时拒绝相关约束违规值,VS Code 校验与表单 hint 对齐,生成代码 XML 文档同步暴露新关键字;对象 / 数组 `enum` 当前主要参与校验与文档输出,不额外扩展复杂表单控件;`allOf` 与 `if` / `then` / `else` 当前都收敛为 object-focused constraint block,不做属性合并 + - 已补齐:`enum`(当前覆盖标量、对象、数组节点,以及标量数组元素)、`const`、`not`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minLength`、`maxLength`、`minItems`、`maxItems`、`contains`、`minContains`、`maxContains`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`uniqueItems`、`minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas`、`allOf`、object-focused `if` / `then` / `else` + - 当前产出:运行时拒绝相关约束违规值,VS Code 校验与表单 hint 对齐,生成代码 XML 文档同步暴露新关键字;对象 / 数组 `enum` 当前主要参与校验与文档输出,不额外扩展复杂表单控件;`allOf` 与 `if` / `then` / `else` 当前都收敛为 object-focused constraint block,不做属性合并;`oneOf` / `anyOf` 当前已统一定义为不支持并在三端显式拒绝 - [x] 评估可选只读索引能力 - 目标:为高频查询字段提供比 `All()` 线性扫描更强的读取体验 @@ -60,10 +67,28 @@ - [ ] 继续扩插件的复杂表单能力 - 说明:这是可选项,不阻塞 C# 主线 +## Tooling / Docs 并行 Lane + +- [ ] Tooling:让 VS Code 表单支持更深层对象数组嵌套,减少 raw YAML 回退 + - 边界:不改变 Runtime / Generator 已定义的 schema 形状契约 + - 验证:优先补 JS 测试,其次再做真实 VS Code 宿主手工验证 + +- [ ] Tooling:为复杂结构提供比“顶层标量 / 标量数组”更强的批量编辑能力 + - 边界:只增强编辑体验,不反向要求 schema 扩展或新的生成类型形状 + - 验证:记录可观察的编辑路径和回退路径,而不是在 active 入口堆叠 UI 细节 + +- [ ] Tooling:在真实 VS Code 宿主中完成对象数组编辑与复杂 schema 的交互式手工验证 + - 边界:作为发布前增强项,不阻塞共享关键字主线 + - 验证:后续 batch 直接补记宿主验证结论与未覆盖场景 + +- [ ] Docs:在相关接入文档里补齐“工具能力是辅助层,不定义 Runtime 契约”的读者提示 + - 边界:只写 reader-facing 接入 guidance,不写批次、治理、风险台账 + - 验证:确认文档用语聚焦接入路径、能力边界和回退方案 + ## 暂缓 - [ ] 不追求完整 JSON Schema 全量支持 - - 原因:维护成本高,且容易造成 Runtime / Generator / Tooling 三端漂移 + - 原因:维护成本高,且容易造成 Runtime / Generator / Tooling 三端漂移;像 `oneOf` / `anyOf` 这类会改变生成类型形状的组合关键字当前已明确排除 - [ ] 不优先做运行时可写配置 - 原因:当前系统定位仍然是静态内容只读查询 @@ -75,7 +100,7 @@ 1. 用 `GeneratedConfigCatalog` 继续补齐启动与诊断辅助 2. 补一条比 `Architecture.OnInitialize()` 更正式的模块化接入建议 - 当前状态:第 1 项和第 2 项已完成,`allOf` 与 object-focused `if` / `then` / `else` 也已补齐;下一步转到下一批仍不改变生成形状的组合关键字评估,或继续推进 VS Code 复杂编辑体验 + 当前状态:第 1 项和第 2 项已完成,`allOf` 与 object-focused `if` / `then` / `else` 也已补齐;下一步默认转到下一批仍不改变生成形状的组合关键字评估。若另开并行 batch,再从本文件的 Tooling / Docs lane 接手 ## 完成标准 @@ -87,12 +112,16 @@ ## 下次恢复点 - 在当前稳定 `format` 子集(`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、object-focused `allOf` 与 object-focused `if` / `then` / `else` 之后,转到下一批仍不改变生成类型形状的关键字评估;仍然不要先回工具 UI +- `oneOf` / `anyOf` 已明确跳过;恢复时不要再把它们当作默认候选 - 恢复时优先检查: - `GFramework.Game/Config/YamlConfigSchemaValidator.cs` - `GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs` - `tools/gframework-config-tool/src/configValidation.js` - `tools/gframework-config-tool/src/extension.js` - `docs/zh-CN/game/config-system.md` +- 若恢复的是 Tooling / Docs 并行 lane: + - 先回看本文件的 `Tooling / Docs 并行 Lane` + - 只把结果摘要回填到 active tracking / trace,避免把编辑器批次细节重新塞回默认入口 ### 恢复块 @@ -108,5 +137,6 @@ - 结果:通过 - 下一步: 1. 检查 `YamlConfigSchemaValidator.cs`、`SchemaConfigGenerator.cs`、`configValidation.js` 中当前已支持的关键字列表 - 2. 评估 `oneOf` / `anyOf` 是否存在可接受的 object-focused 子集 - 3. 若结论否定,选择下一批共享解释关键字而不是先回工具 UI + 2. 跳过 `oneOf` / `anyOf`,选择下一批仍不改变生成类型形状的共享关键字 + 3. 优先找不需要属性合并、联合分支生成或额外 UI 形状解释的关键字,而不是先回工具 UI + 4. 若主线批次暂不动代码,可并行开启 Tooling / Docs lane,但不要让其反向改写主线恢复点定义 diff --git a/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md b/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md index aca43ecc..13939f27 100644 --- a/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md +++ b/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md @@ -11,22 +11,23 @@ - 当前阶段:`C# Runtime + Source Generator + Consumer DX` - 当前焦点: - 已完成 object-focused `if` / `then` / `else`,继续评估下一批仍不改变生成类型形状的共享关键字 + - 已明确将 `oneOf` / `anyOf` 归类为当前不支持的组合关键字,并在 Runtime / Generator / Tooling 三端显式拒绝,避免静默接受导致形状漂移 - 已完成 PR #262 的 CodeRabbit follow-up,补齐 latest review body 中 folded `Nitpick comments` 的 skill 解析并按建议收口 Tooling / Tests - 先以 Runtime / Generator / Tooling 三端一致语义为前提筛选下一项,而不是盲目扩全量 JSON Schema - - 继续把 VS Code 工具能力视为非阻塞项,不让复杂 UI 编辑器需求反过来拖慢 C# 主线 + - Tooling / Docs 后续改为非阻塞并行 lane;active 入口只保留主线恢复点,把批处理细节下沉到 backlog 文件 ### 已知风险 - 组合关键字扩展风险:下一批候选关键字可能像标准 `oneOf` / `anyOf` 一样更容易引入生成类型形状漂移 - - 缓解措施:延续 object-focused / focused matcher 约束,只接受三端都能稳定解释且不需要属性合并的子集 + - 缓解措施:`oneOf` / `anyOf` 已改为三端显式拒绝;后续仅继续评估不会引入联合形状、属性合并或分支生成漂移的关键字子集 - 工具链验证风险:VS Code 与 CI / 发布管道验证覆盖不足 - 缓解措施:继续为新增共享关键字补齐三端测试覆盖,优先保证 C# Runtime 与 Generator 回归通过,并记录 JS 测试与构建验证 - PR review 信号漂移风险:CodeRabbit 可能把建议折叠在 latest review body,而不是 issue comments - 缓解措施:`gframework-pr-review` 现已同时解析 latest review body,并输出 declared / parsed 数量以便快速识别解析缺口 - PR follow-up 残留风险:PR `#262` 最新 review thread 仍有少量 open comments,且 nitpick body 解析仍存在 declared / parsed 缺口 - 缓解措施:先以 latest unresolved thread 为准逐条本地核验;已确认并补齐运行时诊断路径与 `else without if` 回归测试,skill 现已补齐 `.py` nitpick 与 outside-diff comment 解析,剩余项只需等待本地修复推送后再复抓确认 -- 非阻塞项回退风险:将 VS Code 功能标为非阻塞但导致主线回退的风险 - - 缓解措施:C# 主线补齐新关键字时仍需在 `configValidation.js` 与 `extension.js` 中同步落地,只是不让复杂表单控件阻塞发布 +- 并行 lane 漂移风险:Tooling / Docs 作为并行项后,后续 batch 可能重新把治理说明写回 active 入口或 public docs + - 缓解措施:active tracking / trace 只保留恢复点、验证和 lane 指针;reader-facing 文档只写接入信息,治理说明继续留在 `ai-plan/**` ## 当前状态 @@ -35,8 +36,10 @@ - 已补齐一批共享 JSON Schema 子集,包括: - `enum`、`const`、`not`、`pattern` - `format` 稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid` - - `minItems`、`maxItems`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`uniqueItems` + - `minLength`、`maxLength`、`minItems`、`maxItems`、`contains`、`minContains`、`maxContains`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`uniqueItems` - `minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas`、`allOf`、object-focused `if` / `then` / `else` +- 已明确拒绝会改变生成类型形状的组合关键字: + - `oneOf`、`anyOf` 当前会在 Runtime / Generator / Tooling 三端直接报错,而不是静默忽略 - `if` / `then` / `else` 已按“不改变生成类型形状”的边界落地: - 只允许 object 节点上的 object-typed inline schema - `if` 必填,且必须至少伴随 `then` 或 `else` 之一 @@ -70,9 +73,7 @@ - 继续扩展“不会改变生成类型形状”的共享关键字支持 - 继续降低复杂 schema 与多配置域项目的接入成本 -- 让 VS Code 表单支持更深层对象数组嵌套,减少 raw YAML 回退 -- 为复杂结构提供比“顶层标量 / 标量数组”更强的批量编辑能力 -- 在真实 VS Code 宿主中完成对象数组编辑与复杂 schema 的交互式手工验证 +- Tooling / Docs 并行 lane 仍需推进复杂表单、交互式宿主验证和后续接入文档,但这些事项不再阻塞当前恢复点 ## 活跃文档 @@ -84,15 +85,12 @@ - `2026-04-17` 之前的详细实现记录与定向验证命令已归档到历史 tracking / trace - active 跟踪文件只保留当前恢复点、当前状态和下一步,不再重复堆积已完成阶段的完整历史 -- `2026-04-20` 当前恢复点验证: - - `python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --pr 262 --format json`:通过(`CodeRabbit outside-diff comments: 1 declared, 1 parsed`,`CodeRabbit nitpick comments: 2 declared, 2 parsed`) - - `bun run test`(`tools/gframework-config-tool`):通过(122 tests;包含条件分支坏形状回归) - - `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~SchemaConfigGeneratorTests"`:通过 - - `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~YamlConfigLoaderIfThenElseTests"`:通过(8 tests;新增 `else without if` 运行时回归) - - `dotnet build GFramework.sln -c Release`:通过(存在仓库既有 analyzer warning,无新增错误) +- 最近验证摘要:`2026-04-30` 已完成 Tooling / Docs reader-facing 收口与工具 parser 边界收紧,详细命令、批次背景与验证结果保留在 trace 的 `2026-04-30` 分阶段记录中 +- PR `#306` follow-up 摘要:已按 latest open review threads 补齐 Generator `anyOf` 对称回归、Tooling schema type 白名单、object-array 直系收集边界,以及 reader-facing docs 的显式 `additionalProperties: false` / adoption guidance 说明;细节和验证命令保留在 trace 的 `2026-04-30` 新增阶段记录中 +- PR review 跟进指针:当前分支的 latest review follow-up 与后续本地核验结论以 `ai-first-config-system-trace.md` 为准,active tracking 不再重复展开逐条命令历史 ## 下一步 -1. 提交并推送当前 PR `#262` follow-up 修复后,重新抓取一次 PR review,确认 outside-diff comment 与 open thread 是否都已收口 -2. 若 PR review 已收口,再回到 `GFramework.Game/Config/YamlConfigSchemaValidator.cs`、`GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs`、`tools/gframework-config-tool/src/configValidation.js` 盘点下一批候选关键字 -3. 优先判断 `oneOf` / `anyOf` 是否存在可接受的 object-focused 子集;若仍会引入生成类型形状漂移,就直接跳过 +1. 主线继续回到 `YamlConfigSchemaValidator.cs`、`SchemaConfigGenerator.cs` 与 `configValidation.js` 的共享关键字盘点,默认跳过 `oneOf` / `anyOf` +2. Tooling / Docs 若要并发推进,优先补 reader-facing 示例或采用路径,不再重复扩写能力边界说明 +3. 保持 active tracking / trace 精简,只记录当前恢复点、最近验证和下一步恢复指针 diff --git a/ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md b/ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md index e59b4c93..589cb4ff 100644 --- a/ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md +++ b/ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md @@ -106,6 +106,128 @@ ### 下一步 -1. 评估 `oneOf` / `anyOf` 是否值得继续沿用 object-focused 子集;若仍会造成生成形状漂移,就直接跳过 +1. 跳过 `oneOf` / `anyOf`,优先筛选下一个仍不改变生成类型形状、且不需要属性合并或联合分支生成的共享关键字 2. 若继续扩共享关键字,先在 Runtime / Generator / Tooling 三端同时定义一致边界,再进入实现 3. 继续把 active 入口保持精简,只记录当前恢复点、验证与下一步 + +## 2026-04-30 + +### 阶段:组合关键字边界收口(AI-FIRST-CONFIG-RP-003) + +- 已在 Runtime、Source Generator 与 VS Code Tooling 三端显式拒绝 `oneOf` / `anyOf` +- 本轮结论不是继续做 object-focused 子集,而是先收紧共享边界: + - `oneOf` / `anyOf` 更容易引入联合分支、属性合并或生成类型形状漂移 + - 当前配置系统主线仍优先保证 `C# Runtime + Source Generator + Consumer DX` 的稳定契约 + - 因此三端统一改为在 schema 解析 / 生成阶段直接失败,避免静默忽略同一份 schema +- active tracking 也已同步更新,不再把 `oneOf` / `anyOf` 作为下一批默认候选 + +### 验证 + +- 2026-04-30:`bun run test`(`tools/gframework-config-tool`) + - 目标:验证工具端会拒绝 `oneOf` +- 2026-04-30:`dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~SchemaConfigGeneratorTests"` + - 目标:验证生成器新增 `GF_ConfigSchema_015` +- 2026-04-30:`dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~YamlConfigLoaderAllOfTests"` + - 目标:验证运行时会拒绝对象节点上的 `oneOf` + +### 下一步 + +1. 若本轮定向验证通过,继续盘点下一批真正低风险、且不改变生成类型形状的共享关键字 +2. 不再重复评估 `oneOf` / `anyOf` 的 object-focused 子集,除非未来主线明确接受联合形状生成 +3. 若后续关键字需要新诊断编号或文档边界说明,继续保持 Runtime / Generator / Tooling 同步收口 + +### 阶段:Tooling lane 收口整理(AI-FIRST-CONFIG-RP-003) + +- 已把 Tooling / Docs 后续动作从 active 入口的主线叙述中剥离,改成 backlog 文件里的非阻塞并行 lane +- 当前 active tracking / trace 只继续承担三件事: + - 给 `boot` 提供当前恢复点 + - 记录最近一次验证或计划性验证占位 + - 指向真正承载并行批次细节的 backlog 文件 +- 本轮不新增代码范围、测试范围或文档范围,只整理 public `ai-plan/**` 的恢复入口表达,避免把治理噪音带回 reader-facing docs + +### 关键决定 + +- `C# Runtime + Source Generator + Consumer DX` 仍是默认恢复主线 +- Tooling / Docs 可以并发推进,但后续 batch 应直接以 `ai-first-config-system-csharp-experience-next.md` 为入口,而不是继续扩写 active tracking / trace +- public docs 后续只承接接入 guidance、能力边界和回退方式;批次编排、lane 风险和治理说明继续留在 `ai-plan/**` + +### 验证 + +- 2026-04-30:`wc -l ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md` + - 结果:通过 + - 备注:确认本轮仍把 active 入口控制在精简范围,并把 lane 细节下沉到 backlog 文件 + +### 下一步 + +1. 若继续做主线代码批次,直接回到共享关键字盘点,不让 Tooling / Docs 成为阻塞条件 +2. 若另开 Tooling / Docs batch,先读取 `ai-first-config-system-csharp-experience-next.md` 的并行 lane,再把结果摘要写回 active tracking / trace +3. 继续保持 active 入口精简,不在默认恢复文件中追加 UI 细节、治理台账或面向读者的文档草稿 + +### 阶段:Tooling / Docs reader-facing 边界补齐(AI-FIRST-CONFIG-RP-003) + +- 已在 `config-tool.md`、`config-system.md` 和 `tools/gframework-config-tool/README.md` 明确 reader-facing 能力边界 +- 本轮重点不是新增能力,而是把当前分支已经落地的结论写清楚: + - `contains` / `minContains` / `maxContains` + - `dependentRequired`、`dependentSchemas`、`allOf` + - object-focused `if` / `then` / `else` + - `additionalProperties: false` + - `oneOf` / `anyOf` rejection +- 同时补充了两个采用原则: + - VS Code 工具是辅助层,不定义 Runtime 契约 + - 复杂 shape 或超出共享子集的 schema,应回退到 raw YAML 与 schema 文件本体处理 + +### 验证 + +- 2026-04-30:`git diff --check -- docs/zh-CN/game/config-tool.md docs/zh-CN/game/config-system.md tools/gframework-config-tool/README.md ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md` + - 结果:通过 + +### 下一步 + +1. Tooling / Docs 后续若继续推进,优先补真实采用示例,而不是重复扩写边界清单 +2. 主线代码批次继续以 Runtime / Generator / Tooling 三端共享关键字收口为中心 + +### 阶段:Tooling parser 坏形状拒绝收紧(AI-FIRST-CONFIG-RP-003) + +- 已在 `tools/gframework-config-tool/src/configValidation.js` 收紧工具侧 schema parser 边界 +- 本轮不是扩 JSON Schema 能力,而是避免工具侧比 Runtime / Generator 更宽松: + - `additionalProperties` 现在只接受 `false` + - 数组 `items` 必须是 object-shaped 且显式带 `type` + - 数组 `contains` 若声明,也必须是 object-shaped 且显式带 `type` +- 这样 tuple-array `items: []`、缺失 `type` 的 `contains` 子 schema,以及其他会误导用户以为“工具支持但运行时不支持”的坏形状,会在工具解析阶段直接失败 + +### 验证 + +- 2026-04-30:`bun run test`(`tools/gframework-config-tool`) + - 结果:通过 + - 备注:新增 JS 回归覆盖 `additionalProperties`、tuple-array `items` 与缺失 `type` 的 `contains` + +### 下一步 + +1. 继续盘点 Runtime / Generator / Tooling 三端是否还有类似“工具宽松吞掉、主线不支持”的 schema 形状 +2. 若继续做 Tooling lane,优先补 reader-facing 示例或采用路径,而不是继续堆积边界清单 + +### 阶段:PR #306 open threads 收口(AI-FIRST-CONFIG-RP-003) + +- 已重新抓取 PR `#306` 的 latest open review threads,并按“本地仍成立 / 已被当前分支吸收”重新核验 +- 本轮收口重点不是继续扩能力,而是把 open threads 中仍成立的三类问题一次性补齐: + - Generator:补齐 `GF_ConfigSchema_015` 的 `anyOf` 对称负例,避免组合关键字只覆盖 `oneOf` + - Tooling:拒绝未知显式 `type`、收窄 object-array 只遍历当前 editor 直属 items、统一 `contains` hint 文案 + - Docs:把 `additionalProperties: false` 的“必须显式设置为 false”写清,并为工具补最小接入示例、迁移提示与更准确的 raw YAML 回退条件 +- 本轮同时更新了 JS / .NET 回归测试与 active tracking,避免只修 review comment 不保留恢复点 + +### 验证 + +- 2026-04-30:`bun run test`(`tools/gframework-config-tool`) + - 结果:通过(132 tests) + - 备注:新增未知 schema `type` 拒绝、嵌套 object-array 不串层,以及 `contains` hint 文案回归 +- 2026-04-30:`dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~SchemaConfigGeneratorTests"` + - 结果:通过(54 tests) + - 备注:补齐 `Run_Should_Report_Diagnostic_When_Object_Schema_Declares_Unsupported_AnyOf` +- 2026-04-30:`git diff --check` + - 结果:通过 + - 备注:本轮代码与文档改动未引入空白或冲突标记问题 + +### 下一步 + +1. 推送本轮修复后,重新抓取 PR `#306` review 状态,确认哪些 open threads 会被 GitHub 自动折叠或仍需人工回复 +2. 若还有残留 open threads,优先区分“远端未刷新 / 已过时评论 / 仍成立问题”,不要再把 review body 摘要和 latest open threads 混在一起处理 diff --git a/docs/zh-CN/abstractions/game-abstractions.md b/docs/zh-CN/abstractions/game-abstractions.md index 232f6db7..21d41673 100644 --- a/docs/zh-CN/abstractions/game-abstractions.md +++ b/docs/zh-CN/abstractions/game-abstractions.md @@ -25,6 +25,25 @@ description: GFramework.Game.Abstractions 的契约边界、包关系与源码 - 运行时实现:`GFramework.Game` - 底层基础契约:`GFramework.Core.Abstractions` +## 配置契约的采用边界 + +如果你只依赖 `GFramework.Game.Abstractions`,需要额外记住一件事:这里的 `Config/` 只定义“如何注册与访问配置表”的读取契约,不定义 +AI-First 配置工作流的完整实现边界。 + +与配置相关的实际采用路径仍然要回到 `GFramework.Game`: + +- `YamlConfigLoader`、`GameConfigBootstrap`、`GameConfigModule` 等实现都在 `GFramework.Game` +- `GFramework.Game.SourceGenerators` 生成的配置类型,服务的是与 Runtime 对齐的共享 schema 子集 +- 共享子集之外的复杂 schema 设计,不会因为你只依赖 abstractions 就自动获得额外支持 + +这意味着,如果你的 schema 依赖下面这些能力,就不能只停留在 abstractions 视角理解配置契约: + +- `oneOf`、`anyOf` 这类复杂组合关键字 +- 非 `false` 的 `additionalProperties` +- 其他会引入开放对象形状、联合分支或属性合并漂移的 schema 设计 + +这些边界由 `GFramework.Game` 与 [配置系统](../game/config-system.md) 负责说明和落地;`GFramework.Game.Abstractions` 本身不重新定义它们。 + ## 契约地图 | 契约族 | 作用 | @@ -105,6 +124,7 @@ public sealed class ContinueGameCommandHandler - 使用 `SettingsModel`、`SettingsSystem`、`SaveRepository` 等默认实现 - 使用 `YamlConfigLoader`、`GameConfigBootstrap`、`GameConfigModule` - 继承 `SceneRouterBase`、`UiRouterBase` 或默认转场处理器基类 +- 需要确认 AI-First 配置工作流当前支持的共享 schema 子集,以及 `oneOf` / `anyOf`、非 `false` `additionalProperties` 等不在采用路径内的边界 ## 阅读顺序 @@ -119,3 +139,5 @@ public sealed class ContinueGameCommandHandler 4. 需要统一入口时,回到: - [Game 模块总览](../game/index.md) - [入门指南](../getting-started/index.md) + +如果你的关注点是配置契约,请把 [配置系统](../game/config-system.md) 当作下一跳,而不是停留在 abstractions 页面对支持边界做推断。 diff --git a/docs/zh-CN/api-reference/index.md b/docs/zh-CN/api-reference/index.md index 7a496c5b..5573089e 100644 --- a/docs/zh-CN/api-reference/index.md +++ b/docs/zh-CN/api-reference/index.md @@ -13,6 +13,8 @@ description: GFramework 的 API 阅读入口,按模块映射 README、专题 2. 再进专题页确认安装、生命周期和推荐接线方式 3. 最后回到源码中的 XML 文档核对具体契约 +如果你在阅读 AI-First 配置工作流相关 API,先把 `GFramework.Game` Runtime 与 `GFramework.Game.SourceGenerators` 视为正式契约入口,再把 `VS Code` 配置工具视为辅助层。当前默认采用路径围绕共享 schema 子集展开,其中 `additionalProperties: false` 表示闭合对象边界(需显式设置为 `false`);`oneOf` / `anyOf` 在 Runtime / Generator / Tooling 层面会被直接拒绝。更复杂的 shape 应回到 raw YAML 与 schema 设计本体处理。 + ## 阅读顺序 ### 安装与选包入口 @@ -30,7 +32,7 @@ description: GFramework 的 API 阅读入口,按模块映射 README、专题 | --- | --- | --- | --- | | `Core` / `Core.Abstractions` | [Core 模块](../core/index.md) | [Core 抽象层说明](../abstractions/core-abstractions.md)、[快速开始](../getting-started/quick-start.md) | 架构入口、生命周期、命令 / 查询 / 事件 / 状态 / 资源 / 日志 / 配置 / 并发契约 | | `Cqrs` / `Cqrs.Abstractions` / `Cqrs.SourceGenerators` | [CQRS 运行时](../core/cqrs.md) | [CQRS Handler Registry 生成器](../source-generators/cqrs-handler-registry-generator.md)、[协程系统](../core/coroutine.md) | request / notification / handler / pipeline / generated registry / targeted fallback contract | -| `Game` / `Game.Abstractions` / `Game.SourceGenerators` | [Game 模块总览](../game/index.md) | [Game 抽象层说明](../abstractions/game-abstractions.md)、[配置系统](../game/config-system.md) | 配置、数据、设置、场景、UI、存储、序列化契约 | +| `Game` / `Game.Abstractions` / `Game.SourceGenerators` | [Game 模块总览](../game/index.md) | [Game 抽象层说明](../abstractions/game-abstractions.md)、[配置系统](../game/config-system.md) | 配置、数据、设置、场景、UI、存储、序列化契约;其中 AI-First 配置工作流的正式支持边界以 Runtime + Generator 共享 schema 子集为准 | | `Godot` / `Godot.SourceGenerators` | [Godot 模块总览](../godot/index.md) | [Godot 项目生成器](../source-generators/godot-project-generator.md)、[GetNode 生成器](../source-generators/get-node-generator.md)、[BindNodeSignal 生成器](../source-generators/bind-node-signal-generator.md) | 节点扩展、场景 / UI 适配、配置 / 存储 / 设置接线、Godot 生成器入口 | | `Ecs.Arch` / `Ecs.Arch.Abstractions` | [ECS 模块总览](../ecs/index.md) | [Arch ECS 集成](../ecs/arch.md)、[Ecs.Arch 抽象层说明](../abstractions/ecs-arch-abstractions.md) | ECS 模块契约、系统适配、配置对象和运行时装配边界 | @@ -60,6 +62,9 @@ description: GFramework 的 API 阅读入口,按模块映射 README、专题 - 最佳实践:[最佳实践](../best-practices/index.md) - 故障排查:[故障排查](../troubleshooting.md) +如果你阅读的是 AI-First 配置相关 API,请直接把 [配置系统](../game/config-system.md) 视为边界说明页: +像 `additionalProperties: false`、`oneOf` / `anyOf` rejection 这类采用约束不会由 VS Code 工具或 abstractions 页面单独改写。 + ## 共享支撑层怎么看 - `GFramework.Core.SourceGenerators.Abstractions` diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 1f1ca949..0d17cd11 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -17,10 +17,20 @@ description: 说明 GFramework.Game 配置系统的定位、目录约定、生 - JSON Schema 作为结构描述 - 一对象一文件的目录组织 - 运行时只读查询 -- Runtime / Generator / Tooling 共享支持 `enum`、`const`、`not`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas`、`allOf`、`if` / `then` / `else` +- Runtime / Generator / Tooling 共享支持 `enum`、`const`、`not`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas`、`allOf`、object-focused `if` / `then` / `else`,以及闭合对象边界 `additionalProperties: false` - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 +## 编辑器能力与 Runtime 契约 + +`GFramework Config Tool` 是这套配置系统的辅助层,不单独定义 Runtime 契约。 + +- 哪些 schema 能被正式采用,以 `GFramework.Game` Runtime 与 Source Generator 的共享支持边界为准 +- VS Code 插件负责把这些已落地的边界提前暴露成浏览、表单、校验和批量编辑体验 +- 工具层的可视化入口比 Runtime 契约更保守时,应该回到 raw YAML 和 schema 本体继续编辑,而不是把“当前没做成表单”误解为“运行时允许自由扩展” + +因此,判断某个关键字是否可用时,应该先看这里定义的共享契约,再把工具当作帮助你按这份契约工作的编辑器入口。 + 对应工具说明见:[VS Code 配置工具](./config-tool.md) ## 推荐目录结构 @@ -802,6 +812,14 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `dependentSchemas`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只接受“已声明 sibling 字段触发 object 子 schema”的形状,不改变生成类型形状,并按 focused constraint block 语义允许条件子 schema 未声明的额外同级字段继续存在 - `allOf`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只接受 object 节点上的 object-typed inline schema 数组,并按 focused constraint block 语义把每个条目叠加到当前对象上,不做属性合并,也不改变生成类型形状 - `if` / `then` / `else`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只接受 object 节点上的 object-typed inline schema,`if` 必填且必须至少配合 `then` 或 `else` 之一使用,分支只能约束父对象已声明的字段,不做属性合并,也不改变生成类型形状;条件匹配本身沿用 `dependentSchemas` / `allOf` 的 focused matcher 语义,允许对象保留未在条件块中声明的额外同级字段 +- `additionalProperties`:当前共享支持边界只接受 `additionalProperties: false`;它用于声明对象是闭合的,运行时、生成器和工具都会据此拒绝未声明字段。其他 `additionalProperties` 形态当前不属于共享支持子集,会在解析或生成阶段直接被拒绝 +- `oneOf` / `anyOf`:当前不属于共享支持子集;Runtime / Generator / Tooling 会在解析或生成阶段直接拒绝,避免静默接受会改变生成类型形状的组合关键字 + +如果你的 schema 需要超出这些边界的复杂 shape,推荐采用下面的回退顺序: + +1. 先在 raw YAML 与 schema 文件中直接编辑,而不是强行依赖表单入口 +2. 再核对该 shape 是否仍符合这里列出的共享支持子集 +3. 如果它依赖 `oneOf` / `anyOf`、非 `false` 的 `additionalProperties`、会向父对象注入新字段的 `allOf` / `dependentSchemas` / `if` 分支,或者更异构的深层数组结构,就应当把它视为当前版本之外的设计,而不是工具层遗漏的“隐藏能力” `allOf` 的最小可工作示例如下。关键点是:字段形状先在父对象 `properties` 中声明,再用 `allOf` 叠加 `required` 或更细的字段约束;`allOf` 条目不会把新字段并回父对象。 @@ -980,52 +998,10 @@ var hotReload = loader.EnableHotReload( ## VS Code 工具 -完整采用说明见:[VS Code 配置工具](./config-tool.md)。 +`GFramework Config Tool` 是这套配置系统的编辑器侧辅助入口,用来把 `config/`、`schemas/`、轻量校验、 +表单预览和批量维护收敛到一条 VS Code 工作流里。 -仓库中的 `tools/gframework-config-tool` 当前提供以下能力: +它不改变本页定义的运行时、生成器和 schema 语义边界,只负责把这些既有约束投射到编辑器采用路径中。 -- 浏览 `config/` 目录 -- 打开 raw YAML 文件 -- 打开匹配的 schema 文件 -- 根据 VS Code 当前界面语言在英文和简体中文之间切换主要工具界面文本 -- 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验 -- 对嵌套对象字段、对象数组、顶层标量字段和顶层标量数组提供轻量表单入口 -- 在表单中渲染已有 YAML 注释,并允许直接编辑字段级 YAML 注释 -- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 -- 对空配置文件提供基于 schema 的示例 YAML 初始化入口 -- 对同一配置域内的多份 YAML 文件执行批量字段更新 -- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-table(UI 中显示为 ref-table) / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired / dependentSchemas / allOf / if / then / else` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 - -当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。 - -对象数组编辑器当前支持: - -- 新增和删除对象项 -- 编辑对象项中的标量字段 -- 编辑对象项中的标量数组 -- 编辑对象项中的嵌套对象字段 - -如果对象数组项内部继续包含对象数组,当前仍建议回退到 raw YAML 完成。 - -当前批量编辑入口仍刻意限制在“同域文件统一改动顶层标量字段和顶层标量数组”,避免复杂结构批量写回时破坏人工维护的 YAML 排版。 - -## 适用范围 - -当前这套工具更适合已经定义好 schema、需要校验、轻量表单和批量改写能力的内容维护场景,尤其适合由开发者或技术策划主导的游戏项目配置工作流。 - -以下场景目前仍建议保留 raw YAML 编辑,或由项目补充专用工具: - -- 需要更完整的 JSON Schema 支持 -- 需要在 VS Code 中安全编辑更深层对象数组嵌套 -- 需要覆盖更复杂的数组结构和更深层 schema 关键字 - -## 工具形态建议 - -对当前仓库已经落地的工作流而言,`VS Code Extension` 形态已经可以覆盖 schema 校验、轻量表单、批量编辑和 raw YAML 回退这条采用路径。 - -如果你的团队出现以下需求,再评估独立 `Config Studio` 会更合适: - -- 配置维护主要由非开发角色承担,希望进一步降低 VS Code 的安装和使用门槛 -- 需要更重的表格视图、跨表可视化关系编辑、复杂审批流或离线发布流程 -- 插件形态已经明显受限于 VS Code Webview / Extension API,而不是 schema 与工作流本身 -- 已经沉淀出稳定的 schema 元数据约定,足以支撑单独工具的长期维护 +如果你要了解工作区约定、命令入口、表单与批量编辑边界、适用场景,以及何时应该回退到 raw YAML, +完整说明见:[VS Code 配置工具](./config-tool.md)。 diff --git a/docs/zh-CN/game/config-tool.md b/docs/zh-CN/game/config-tool.md index f1a4910f..9cdb7c34 100644 --- a/docs/zh-CN/game/config-tool.md +++ b/docs/zh-CN/game/config-tool.md @@ -68,6 +68,138 @@ GameProject/ 如果你更关心“当前 schema 和 YAML 是否仍一致”,优先使用全量校验;如果你只是定位单个字段或注释,优先使用 Explorer + 表单预览。 +### 当前能力范围 + +仓库中的 `tools/gframework-config-tool` 当前提供以下能力: + +- 浏览 `config/` 目录 +- 打开 raw YAML 文件 +- 打开匹配的 schema 文件 +- 根据 VS Code 当前界面语言在英文和简体中文之间切换主要工具界面文本 +- 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验 +- 对嵌套对象字段、对象数组、顶层标量字段和顶层标量数组提供轻量表单入口 +- 在表单中渲染已有 YAML 注释,并允许直接编辑字段级 YAML 注释 +- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 +- 对空配置文件提供基于 schema 的示例 YAML 初始化入口 +- 对同一配置域内的多份 YAML 文件执行批量字段更新 +- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-table(UI 中显示为 ref-table) / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired / dependentSchemas / allOf / if / then / else` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 +- 对 `additionalProperties: false` 提供闭合对象边界校验,并在遇到 `oneOf` / `anyOf` 或其他当前未收口的组合形状时明确提示该 schema 不属于当前工具支持子集 + +当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。 + +对象数组编辑器当前支持: + +- 新增和删除对象项 +- 编辑对象项中的标量字段 +- 编辑对象项中的标量数组 +- 编辑对象项中的嵌套对象字段 +- 编辑对象项内部继续嵌套的对象数组,只要这些内层对象数组项仍然由对象、标量字段、标量数组和嵌套对象组成 + +如果对象数组中混入了标量项,或者更深层结构超出当前 schema 子集,表单入口会明确提示该路径需要回退到 raw YAML。 + +当前批量编辑入口仍刻意限制在“同域文件统一改动顶层标量字段和顶层标量数组”,避免复杂结构批量写回时破坏人工维护的 YAML 排版。 + +### 工具边界与 Runtime 契约 + +这个扩展是编辑器侧的辅助层,不定义 `GFramework.Game` 的 Runtime 契约。 + +- Runtime / Source Generator 是否接受某份 schema,决定了它是否属于当前配置系统的正式支持范围 +- 工具里的表单、hint、校验和批量编辑,只是把这套已落地契约搬到 VS Code 中帮助你更快发现问题 +- 如果工具界面暂时没有把某个 shape 做成可视化编辑入口,不代表 Runtime 会自动接受更宽松的 schema;同样,如果 Runtime / Generator 已明确拒绝某类关键字,工具也不会把它包装成可继续编辑的“可用能力” + +日常采用时,建议把它理解为“优先用工具加速已支持子集的维护;遇到边界时立刻回到 schema + raw YAML 本体确认”。 + +### 最小接入示例与兼容 / 迁移说明 + +项目里至少需要准备三类内容: + +- `config//*.yaml`:实际配置文件 +- `schemas/.schema.json`:与该配置域对应的 schema +- VS Code 工作区里的 `GFramework Config Tool` 扩展,以及与 schema 保持一致的 `x-gframework-ref-table` 引用约定 + +最小目录可以从下面这个形态起步: + +```text +GameProject/ +├─ config/ +│ └─ monster/ +│ └─ slime.yaml +└─ schemas/ + └─ monster.schema.json +``` + +最小 schema 示例: + +```json +{ + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "rarity", "dropItems"], + "properties": { + "id": { + "type": "integer", + "title": "Monster Id", + "description": "Primary monster key.", + "default": 1 + }, + "name": { + "type": "string", + "title": "Display Name", + "minLength": 1 + }, + "rarity": { + "type": "string", + "enum": ["common", "elite", "boss"], + "default": "common" + }, + "spawnTime": { + "type": "string", + "format": "time" + }, + "dropItems": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "rewardTableId": { + "type": "string", + "x-gframework-ref-table": "reward-table" + } + } +} +``` + +对应的 YAML 初始文件可以保持很小: + +```yaml +id: 1 +name: Slime +rarity: common +spawnTime: 08:30:00Z +dropItems: + - potion +rewardTableId: starter-reward +``` + +推荐接入顺序: + +1. 在 VS Code 中打开包含 `config/` 和 `schemas/` 的工作区 +2. 如果目录不是默认值,先设置 `gframeworkConfig.configPath` 与 `gframeworkConfig.schemasPath` +3. 通过 Explorer 打开目标 YAML 或 schema,先跑一次全量校验 +4. 对空 YAML 使用“基于 schema 的示例 YAML 初始化”,或直接从 raw YAML 开始录入 +5. 需要统一改同域顶层标量字段时,再进入批量编辑 + +迁移自纯 raw YAML 工作流时,至少先检查下面几件事: + +- `additionalProperties` 是否显式设置为 `false`;省略或 `true` 不属于当前共享支持子集 +- schema 是否依赖 `oneOf` / `anyOf`;这些组合关键字会被 Runtime / Generator / Tooling 直接拒绝 +- 对象数组里是否混入标量项,或是否存在更深、更异构的数组结构 +- Runtime / Source Generator 是否已经接受这份 schema,而不是只有编辑器里“暂时看起来能写” + +当 schema 仍在共享支持子集内,但某段编辑路径已经超出轻量表单可视化边界时,优先回到 raw YAML;不要把“工具暂时没有表单入口”误判成“运行时契约已放宽”。 + ## 推荐工作流 ### 1. 浏览配置与 schema @@ -94,6 +226,9 @@ Explorer + 表单预览。 - 顶层标量数组 - 嵌套对象字段 - 对象数组 +- object-focused `if` / `then` / `else`、`dependentRequired`、`dependentSchemas`、`allOf` +- `contains` / `minContains` / `maxContains` +- `additionalProperties: false` 如果你进入更深层对象数组嵌套,当前更稳妥的做法通常是: @@ -101,6 +236,13 @@ Explorer + 表单预览。 2. 先看表单预览确认字段结构 3. 再回到 raw YAML 完成最终编辑 +以下 shape 目前也建议直接回退到 raw YAML,并同时检查 schema 是否仍在当前共享支持子集内: + +- 需要表达 `oneOf` / `anyOf` 这类会改变生成类型形状的组合关键字 +- 需要 `additionalProperties` 的其他形态,而不是当前明确支持的 `additionalProperties: false` +- 需要在 `allOf`、`dependentSchemas`、`if` / `then` / `else` 中引入父对象未声明的新字段 +- 需要比当前对象数组编辑器更深、更异构的数组结构 + ## 工作区设置 当前公开设置只有两个: @@ -125,12 +267,33 @@ Explorer + 表单预览。 - 校验聚焦仓库当前支持的 schema 子集 - 表单预览支持对象数组,但更深的嵌套对象数组仍可能需要回退到 raw YAML - 批量编辑当前聚焦顶层标量和顶层标量数组字段 +- 共享约束里只支持闭合对象边界 `additionalProperties: false`;`oneOf` / `anyOf` 等改变生成形状的组合关键字会被明确拒绝 因此,最稳妥的理解方式是: - 用它加速“浏览、定位、轻量校验、批量维护” - 不把它当成完整替代 YAML / schema 编辑的唯一入口 +## 适用范围 + +当前这套工具更适合已经定义好 schema、需要校验、轻量表单和批量改写能力的内容维护场景,尤其适合由开发者或技术策划主导的游戏项目配置工作流。 + +以下场景目前仍建议保留 raw YAML 编辑,或由项目补充专用工具: + +- 需要更完整的 JSON Schema 支持 +- 需要覆盖更复杂的数组结构和更深层 schema 关键字 + +## 工具形态建议 + +对当前仓库已经落地的工作流而言,`VS Code Extension` 形态已经可以覆盖 schema 校验、轻量表单、批量编辑和 raw YAML 回退这条采用路径。 + +如果你的团队出现以下需求,再评估独立 `Config Studio` 会更合适: + +- 配置维护主要由非开发角色承担,希望进一步降低 VS Code 的安装和使用门槛 +- 需要更重的表格视图、跨表可视化关系编辑、复杂审批流或离线发布流程 +- 插件形态已经明显受限于 VS Code Webview / Extension API,而不是 schema 与工作流本身 +- 已经沉淀出稳定的 schema 元数据约定,足以支撑单独工具的长期维护 + ## 继续阅读 - [游戏内容配置系统](./config-system.md) diff --git a/docs/zh-CN/game/data.md b/docs/zh-CN/game/data.md index a184e1a9..bbf9dc7a 100644 --- a/docs/zh-CN/game/data.md +++ b/docs/zh-CN/game/data.md @@ -18,6 +18,14 @@ description: 以当前 GFramework.Game 源码与 PersistenceTests 为准,说 如果先把这三类入口分开理解,后续接入时会清晰很多。 +## 与 AI-First 配置系统的边界 + +如果你是从 AI-First 配置工作流一路读到这里,需要先把“配置契约”和“运行时持久化”分开理解: + +- 配置系统的 schema 支持边界,仍以 `GFramework.Game` Runtime 和 `GFramework.Game.SourceGenerators` 当前共享的 schema 子集为准 +- `DataRepository`、`UnifiedSettingsDataRepository` 和 `SaveRepository` 负责的是数据怎么落盘、怎么回读、怎么组织槽位,而不是放宽配置契约 +- 如果配置设计依赖 `oneOf`、`anyOf`、非 `false` 的 `additionalProperties`(例如省略或 `true`),或其他更复杂的 schema shape,应直接回到 [配置系统](./config-system.md) 与 raw YAML / schema 本体继续处理,而不是期待 repository 层自动接管这些边界 + ## 什么时候用哪个仓库 ### `DataRepository` @@ -195,6 +203,7 @@ var saveConfiguration = new SaveConfiguration - `UnifiedSettingsDataRepository` 不是通用万能仓库,它专门服务“多 section 聚合单文件”的场景 - `SaveRepository` 不负责业务层的 autosave 策略、云同步或存档选择 UI - `LoadAsync(...)` 返回新实例的行为适合默认启动路径;如果项目需要“缺档即报错”,应在业务层显式调用 `ExistsAsync(...)` +- 如果 AI-First 配置系统里的 schema 已经超出 Runtime / Generator 共享子集,repository 也不会替你放宽这些约束;这时应优先回到 [配置系统](./config-system.md) 与 raw YAML / schema 设计本身 ## 继续阅读 diff --git a/docs/zh-CN/game/index.md b/docs/zh-CN/game/index.md index a66304a6..69eb146d 100644 --- a/docs/zh-CN/game/index.md +++ b/docs/zh-CN/game/index.md @@ -83,6 +83,13 @@ IStorage storage = new FileStorage("GameData", serializer); - `GFramework.Game.SourceGenerators` - `schemas/**/*.schema.json` + `config/**/*.yaml` +这条工作流的正式契约,以 `GFramework.Game` Runtime 和 `GFramework.Game.SourceGenerators` 当前共享支持的 schema +子集为准。`VS Code` 配置工具主要负责编辑期提示和表单辅助,不单独扩展运行时可接受的 schema 形状。 + +开始接入时,建议先把 schema 约束控制在共享子集内,并尽早确认像 `additionalProperties: false`(需显式设置为 `false`;省略或 `true` 视为非 `false`)这类已收口的对象边界,以及 +`oneOf` / `anyOf` 当前会被直接拒绝,而不是在工具里看起来“可以先写”。如果你的配置模型需要更深层的嵌套数组、联合分支或其他超出共享子集的复杂 +shape,优先回到 raw YAML 和 schema 设计本体处理,再决定是否拆分结构或调整约束方式。 + 完整约定见: - [配置系统](./config-system.md) @@ -120,7 +127,7 @@ IStorage storage = new FileStorage("GameData", serializer); - 运行时入口主要来自 `GFramework.Game` - 只依赖接口或拆分业务层时,补充 `GFramework.Game.Abstractions` - 需要静态内容配置类型和表包装生成时,再追加 `GFramework.Game.SourceGenerators` -- 需要编辑器侧内容维护工作流时,再看 [VS Code 配置工具](./config-tool.md) +- 需要编辑器侧内容维护工作流时,再看 [VS Code 配置工具](./config-tool.md),并把它视为共享契约之上的辅助层 ## 对应模块入口 diff --git a/docs/zh-CN/game/scene.md b/docs/zh-CN/game/scene.md index 438b58d3..c8db2d86 100644 --- a/docs/zh-CN/game/scene.md +++ b/docs/zh-CN/game/scene.md @@ -254,6 +254,21 @@ await sceneRouter.PopAsync(); - 项目提供 factory、root、资源映射和具体引擎装配 - 文档中的最小示例应优先说明职责边界,而不是继续堆叠大而全教程 +## 配置系统边界提示 + +如果你的场景路由接线同时依赖 AI-First 配置系统,本页只负责说明场景宿主、路由和生命周期接法,不负责定义配置 +schema 的正式支持边界。涉及 YAML 配置契约、组合关键字或编辑器辅助能力时,请回到 +[Game 配置系统](./config-system.md) 作为正式说明页。 + +默认采用路径之外的场景包括: + +- `oneOf` / `anyOf` +- 非 `false` 的 `additionalProperties` +- 依赖开放对象形状、形状合并或更复杂嵌套数组的 schema shape + +这类复杂 shape 不应从场景接线页推断支持范围。`VS Code` 工具只是辅助编辑与预览层;如果遇到这些情况,应直接回到 raw YAML +和 schema 本体设计处理。 + ## 推荐阅读 1. [Game 模块总览](./index.md) diff --git a/docs/zh-CN/game/serialization.md b/docs/zh-CN/game/serialization.md index 1d05fcb8..8397b004 100644 --- a/docs/zh-CN/game/serialization.md +++ b/docs/zh-CN/game/serialization.md @@ -148,11 +148,14 @@ var restored = serializer.Deserialize(json, data.GetType()); 如果你的目标是静态内容配置表,而不是运行时持久化对象,请改看 [配置系统](./config-system.md)。 +如果你在配置系统里进一步碰到更复杂的 schema shape,也要尽快回到配置系统主文档和 raw YAML / schema 本体继续设计。当前默认采用路径面向的是与 `GFramework.Game` Runtime 和 `GFramework.Game.SourceGenerators` 对齐的共享 schema 子集,不是任意 `JSON Schema` 的全量支持。 + ## 当前边界 - 当前公开默认实现只有 JSON,没有内建 MessagePack、Binary 或 ProtoBuf 实现 - `JsonSerializer` 负责序列化,不负责对象版本迁移;版本迁移属于 `SettingsModel` 或 `SaveRepository` - 序列化器共享后应视为只读配置对象,避免在运行期继续修改 settings / converters +- 如果配置设计依赖 `oneOf`、`anyOf`、非 `false` 的 `additionalProperties`(例如省略或 `true`),或其他需要开放对象形状与联合分支的复杂约束,请直接按配置系统主文档回到 raw YAML / schema 方案处理,而不是把这些场景归到序列化层 ## 继续阅读 diff --git a/docs/zh-CN/game/setting.md b/docs/zh-CN/game/setting.md index 67fab33e..be96f510 100644 --- a/docs/zh-CN/game/setting.md +++ b/docs/zh-CN/game/setting.md @@ -18,6 +18,14 @@ description: 以当前 SettingsModel、SettingsSystem 与相关测试为准, 而不是只靠若干 `Get() / Register(...)` 辅助方法就能自动完成一切的模型。 +## 与 AI-First 配置系统的边界 + +如果你关注的是“配置内容最后怎么变成运行时设置”,这里也需要先分清职责: + +- 配置 schema 的正式支持边界,仍以 `GFramework.Game` Runtime 和 `GFramework.Game.SourceGenerators` 当前共享的 schema 子集为准 +- `UnifiedSettingsDataRepository`、`SettingsModel` 和 `SettingsSystem` 负责设置数据的加载、迁移、保存与应用,不负责放宽 `oneOf`、`anyOf`、非 `false` 的 `additionalProperties`(例如省略或 `true`)等配置边界 +- 一旦配置设计开始依赖更复杂的 schema shape,应直接回到 [配置系统](./config-system.md) 与 raw YAML / schema 本体处理,再决定设置层怎么消费这些结果 + ## 当前公开入口 ### `ISettingsData` @@ -196,6 +204,7 @@ await settingsModel.SaveAllAsync(); - `SettingsModel` 负责数据生命周期,`SettingsSystem` 负责系统级调用入口;两者不要混成一个巨型服务 - applicator 决定“怎么把数据应用到宿主”,repository 决定“怎么保存数据”,两层职责不要互相侵入 - 设置迁移和存档迁移是两条不同管线;后者看 [数据与存档系统](./data.md) 里的 `SaveRepository` +- 如果某个配置 shape 已经超出 Runtime / Generator 共享支持子集,settings repository 和 `SettingsModel` 也不会替代配置系统去放宽它;应回到 [配置系统](./config-system.md) 与 raw YAML / schema 设计处理 ## 继续阅读 diff --git a/docs/zh-CN/game/storage.md b/docs/zh-CN/game/storage.md index 9d2997b6..901c9c33 100644 --- a/docs/zh-CN/game/storage.md +++ b/docs/zh-CN/game/storage.md @@ -165,6 +165,8 @@ var cacheStorage = new ScopedStorage(rootStorage, "runtime-cache"); - 业务层如果想保存设置,可继续阅读 [设置系统](./setting.md) - 业务层如果只是需要底层存储实现,才直接依赖 `IStorage` +如果你是在“配置系统最终把内容保存到哪里”这个角度读到这里,需要先把边界分开:`IStorage` 负责运行时持久化,不负责定义配置 schema 的支持范围。配置工作流里只要开始出现更复杂的 schema shape,仍应先回到 [配置系统](./config-system.md) 和 raw YAML / schema 本体继续设计,再决定运行时是否需要额外存储落盘策略。 + ## 当前边界 - `FileStorage` 已经会通过注入的 `ISerializer` 自动序列化对象;默认接法不需要先手工 `Serialize(...)` 再把字符串写回 @@ -172,6 +174,7 @@ var cacheStorage = new ScopedStorage(rootStorage, "runtime-cache"); - `ScopedStorage` 只做 key 前缀,不做权限、事务或迁移控制 - 锁粒度是“当前实例内的目标路径”,不是跨进程文件锁 - 原子写入只覆盖单文件替换,不等于多文件事务 +- 如果配置建模依赖 `oneOf`、`anyOf`、非 `false` 的 `additionalProperties`(例如省略或 `true`),或其他超出当前共享 schema 子集的复杂组合约束,这不是 `IStorage` 层能放宽的限制;应直接回到配置系统主文档与 raw YAML / schema 设计处理 ## 继续阅读 diff --git a/docs/zh-CN/game/ui.md b/docs/zh-CN/game/ui.md index 038635e5..3f303752 100644 --- a/docs/zh-CN/game/ui.md +++ b/docs/zh-CN/game/ui.md @@ -324,6 +324,21 @@ uiRouter.Hide(modalHandle, UiLayer.Modal); - 页面行为不仅有生命周期,还有输入、阻断、暂停契约 - router 是 UI 语义仲裁中心,项目输入层应主动接入它 +## 配置系统边界提示 + +如果你的 UI 宿主接线还会读取 AI-First 配置或 schema 驱动的页面数据,本页只说明 UI router、root、factory 与输入语义, +不负责定义配置系统的正式边界。凡是配置契约、组合关键字或工具辅助的支持范围,都应以 +[Game 配置系统](./config-system.md) 为准。 + +默认采用路径之外的典型场景包括: + +- `oneOf` / `anyOf` +- 非 `false` 的 `additionalProperties` +- 更复杂的 schema shape,例如依赖开放对象形状、形状合并或更深层异构数组 + +`VS Code` 工具只是辅助层,不是配置边界定义页。遇到这些复杂 shape 时,应直接回到 raw YAML 和 schema 本体设计, +而不是从 UI 接线页推断是否“已经被工具支持”。 + ## 推荐阅读 1. [Game 模块总览](./index.md) diff --git a/docs/zh-CN/getting-started/index.md b/docs/zh-CN/getting-started/index.md index addf0b4e..49c3ca59 100644 --- a/docs/zh-CN/getting-started/index.md +++ b/docs/zh-CN/getting-started/index.md @@ -70,12 +70,18 @@ description: 概览 GFramework 的模块组成、最小接入路径与继续阅 - Scene / UI / Routing 抽象与运行时 - 文件存储和序列化 +AI-First 配置工作流的正式契约以 `GFramework.Game` Runtime 与 `GFramework.Game.SourceGenerators` 的共享 schema 子集为准,`VS Code` 配置工具只负责辅助编辑与预览。默认采用路径当前以 `additionalProperties: false` 作为闭合对象边界,`oneOf` / `anyOf` 不在默认入口范围内;如果你的 schema shape 超出这组共享边界,优先回到 raw YAML 与 schema 设计本体继续建模。 + 对应文档: - [Game 模块总览](../game/index.md) - [配置系统](../game/config-system.md) - [安装配置](./installation.md) +如果你准备采用 AI-First 配置工作流,建议尽早确认当前采用边界:对象闭合只收口到 +`additionalProperties: false`,而 `oneOf` / `anyOf` 这类会改变生成类型形状的组合关键字当前不属于默认路径。 +超过这组共享子集的复杂 schema shape,应回到 raw YAML 与 schema 本体设计,而不是把差异理解成工具遗漏。 + ### Godot 项目接入 继续叠加: @@ -104,6 +110,8 @@ description: 概览 GFramework 的模块组成、最小接入路径与继续阅 - 为 CQRS handlers 生成注册表 - 生成 Godot 节点、场景和 UI 包装代码 +如果你要处理的是更复杂的 schema 设计,而不是编译期生成代码本身,先确认它是否仍在 `Game` Runtime 与生成器共享子集内;超出时应优先调整 raw YAML / schema 方案,而不是假定编辑器入口少了某个开关。 + 继续阅读: - [源码生成器总览](../source-generators/index.md) diff --git a/docs/zh-CN/getting-started/installation.md b/docs/zh-CN/getting-started/installation.md index 7d33eb7f..83e4bd03 100644 --- a/docs/zh-CN/getting-started/installation.md +++ b/docs/zh-CN/getting-started/installation.md @@ -46,6 +46,8 @@ GFramework 采用模块化设计,不同包提供不同的功能: - Arch ECS:直接安装 `GeWuYou.GFramework.Ecs.Arch`;如果只想共享宿主循环或接口边界,可改为 `GeWuYou.GFramework.Ecs.Arch.Abstractions` 如果你准备采用 AI-First 配置工作流,可以继续阅读 [游戏内容配置系统](../game/config-system.md) 与 [VS Code 配置工具](../game/config-tool.md)。 +接入时建议先按 Runtime + Source Generator 的共享 schema 子集设计配置模型,再把 `VS Code` 工具当作编辑辅助层来使用,而不是反过来以工具界面可编辑的 shape 作为正式契约。 +尤其需要尽早知道两个当前边界:对象闭合只收口到 `additionalProperties: false`,而 `oneOf` / `anyOf` 会被直接拒绝。若配置模型超出这组共享边界,优先回到 raw YAML 与 schema 本体调整结构,而不是把差异理解成工具遗漏能力。 ## 安装方式 diff --git a/docs/zh-CN/godot/storage.md b/docs/zh-CN/godot/storage.md index 0a1797ab..440d9ba7 100644 --- a/docs/zh-CN/godot/storage.md +++ b/docs/zh-CN/godot/storage.md @@ -116,6 +116,11 @@ architecture.RegisterUtility>(new SaveRepository/*.yaml` +- Place the matching schema at `schemas/.schema.json` +- Use `x-gframework-ref-table` only on fields that should link to another config domain or reference file +- Keep `additionalProperties` explicitly set to `false` when you need closed-object validation; omitting it or setting + it to `true` is outside the supported subset + +Use raw YAML directly when you need: + +- deeper or more heterogeneous array shapes +- supported object rules such as `allOf`, `dependentSchemas`, or object-focused `if` / `then` / `else` only when they + push the edit path beyond the lightweight form boundary +- `contains` / `minContains` / `maxContains` when the structure is easier to reason about directly in YAML +- schema designs outside the current shared subset, including `oneOf`, `anyOf`, or non-`false` `additionalProperties` + ## Documentation - Chinese adoption guide: [Game 配置工具](../../docs/zh-CN/game/config-tool.md) @@ -98,8 +135,11 @@ The extension currently validates the repository's current schema subset: - Multi-root workspaces use the first workspace folder - Validation only covers the repository's current schema subset -- Form preview supports object-array editing, but nested object arrays inside array items still fall back to raw YAML +- Form preview supports nested objects and object-array editing, but deeper nested object arrays inside array items still + fall back to raw YAML - Batch editing remains limited to top-level scalar fields and top-level scalar arrays +- Closed-object support is limited to `additionalProperties: false`, and unsupported combinators such as `oneOf` / + `anyOf` are rejected on purpose ## Local Testing diff --git a/tools/gframework-config-tool/package.nls.json b/tools/gframework-config-tool/package.nls.json index a7acaa0f..33c4355a 100644 --- a/tools/gframework-config-tool/package.nls.json +++ b/tools/gframework-config-tool/package.nls.json @@ -1,14 +1,14 @@ { "extension.displayName": "GFramework Config Tool", - "extension.description": "VS Code tooling for browsing, validating, and editing AI-First config files in GFramework projects.", + "extension.description": "VS Code tooling for browsing, validating, form-preview editing, and domain batch updates for AI-First config files in GFramework projects.", "view.gframeworkConfig.name": "GFramework Config", "command.refresh.title": "GFramework Config: Refresh", - "command.openRaw.title": "GFramework Config: Open Raw File", + "command.openRaw.title": "GFramework Config: Open Raw YAML", "command.openSchema.title": "GFramework Config: Open Schema", "command.openFormPreview.title": "GFramework Config: Open Form Preview", "command.batchEditDomain.title": "GFramework Config: Batch Edit Domain", "command.validateAll.title": "GFramework Config: Validate All", "configuration.title": "GFramework Config", - "configuration.configPath.description": "Relative path from the workspace root to the config directory.", - "configuration.schemasPath.description": "Relative path from the workspace root to the schema directory." + "configuration.configPath.description": "Relative path from the first workspace folder to the config directory.", + "configuration.schemasPath.description": "Relative path from the first workspace folder to the schema directory." } diff --git a/tools/gframework-config-tool/package.nls.zh-cn.json b/tools/gframework-config-tool/package.nls.zh-cn.json index bd067a59..b36368f6 100644 --- a/tools/gframework-config-tool/package.nls.zh-cn.json +++ b/tools/gframework-config-tool/package.nls.zh-cn.json @@ -1,14 +1,14 @@ { "extension.displayName": "GFramework 配置工具", - "extension.description": "为 GFramework 项目中的 AI-First 配置文件提供浏览、校验和编辑能力的 VS Code 扩展。", + "extension.description": "为 GFramework 项目中的 AI-First 配置文件提供浏览、校验、表单预览编辑和配置域批量更新能力的 VS Code 扩展。", "view.gframeworkConfig.name": "GFramework 配置", "command.refresh.title": "GFramework 配置:刷新", - "command.openRaw.title": "GFramework 配置:打开原始文件", + "command.openRaw.title": "GFramework 配置:打开原始 YAML", "command.openSchema.title": "GFramework 配置:打开 Schema", "command.openFormPreview.title": "GFramework 配置:打开表单预览", "command.batchEditDomain.title": "GFramework 配置:批量编辑配置域", "command.validateAll.title": "GFramework 配置:校验全部", "configuration.title": "GFramework 配置", - "configuration.configPath.description": "从工作区根目录到配置目录的相对路径。", - "configuration.schemasPath.description": "从工作区根目录到 Schema 目录的相对路径。" + "configuration.configPath.description": "从第一个工作区目录到配置目录的相对路径。", + "configuration.schemasPath.description": "从第一个工作区目录到 Schema 目录的相对路径。" } diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 6c7b193c..07bbc671 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -19,6 +19,7 @@ const DurationFormatPattern = const TimeFormatPattern = /^(?\d{2}):(?\d{2}):(?\d{2})(?\.\d+)?(?Z|[+-]\d{2}:\d{2})$/u; const SupportedStringFormats = new Set(["date", "date-time", "duration", "email", "time", "uri", "uuid"]); +const SupportedSchemaTypes = new Set(["object", "array", "string", "integer", "number", "boolean"]); /** * Compare two strings using the same UTF-16 code-unit ordering as C#'s @@ -1095,7 +1096,16 @@ function unquoteScalar(value) { */ function parseSchemaNode(rawNode, displayPath) { const value = rawNode && typeof rawNode === "object" ? rawNode : {}; - const type = typeof value.type === "string" ? value.type : "object"; + const unsupportedCombinatorKeyword = getUnsupportedCombinatorKeywordName(value); + if (unsupportedCombinatorKeyword) { + throw new Error( + `Schema property '${displayPath}' declares unsupported combinator keyword '${unsupportedCombinatorKeyword}'. ` + + "The current config schema subset does not support combinators that can change generated type shape."); + } + + validateUnsupportedOpenObjectKeyword(value, displayPath); + + const type = resolveSupportedSchemaType(value.type, displayPath); const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath); const stringFormat = normalizeSchemaStringFormat(value.format, type, displayPath); const negatedSchemaNode = parseNegatedSchemaNode(value.not, displayPath); @@ -1168,15 +1178,19 @@ function parseSchemaNode(rawNode, displayPath) { } if (type === "array") { - const itemNode = parseSchemaNode(value.items || {}, joinArrayTemplatePath(displayPath)); - const containsNode = value.contains && typeof value.contains === "object" - ? parseSchemaNode(value.contains, joinArrayTemplatePath(displayPath)) - : undefined; + const itemNode = parseRequiredArrayChildSchema(value.items, displayPath, "items"); + const containsNode = value.contains === undefined + ? undefined + : parseOptionalArrayChildSchema(value.contains, displayPath, "contains"); if (!containsNode && (typeof metadata.minContains === "number" || typeof metadata.maxContains === "number")) { throw new Error(`Schema property '${displayPath}' declares 'minContains' or 'maxContains' without 'contains'.`); } + if (itemNode.type === "array") { + throw new Error(`Schema property '${displayPath}' uses unsupported nested array items.`); + } + if (containsNode && containsNode.type === "array") { throw new Error(`Schema property '${displayPath}' uses unsupported nested array 'contains' schemas.`); } @@ -1253,6 +1267,116 @@ function parseSchemaNode(rawNode, displayPath) { }, value.const, displayPath), value.enum, displayPath); } +/** + * Reject open-object keyword forms that would drift away from the Runtime and + * Source Generator contracts. The current shared subset keeps object fields + * closed and only accepts an explicit `additionalProperties: false` reminder. + * + * @param {Record} schemaNode Raw schema object. + * @param {string} displayPath Logical property path. + */ +function validateUnsupportedOpenObjectKeyword(schemaNode, displayPath) { + if (!Object.prototype.hasOwnProperty.call(schemaNode, "additionalProperties")) { + return; + } + + if (schemaNode.additionalProperties === false) { + return; + } + + throw new Error( + `Schema property '${displayPath}' uses unsupported 'additionalProperties' metadata. ` + + "The current config schema subset only accepts 'additionalProperties: false' so object fields remain closed and strongly typed."); +} + +/** + * Parse one required array child schema while keeping tooling errors aligned + * with the Runtime and Source Generator contracts. + * + * @param {unknown} rawChild Raw child schema node. + * @param {string} displayPath Logical parent array path. + * @param {"items" | "contains"} keywordName Child schema keyword. + * @returns {SchemaNode} Parsed child schema node. + */ +function parseRequiredArrayChildSchema(rawChild, displayPath, keywordName) { + return parseArrayChildSchema(rawChild, displayPath, keywordName); +} + +/** + * Parse one optional array child schema when it is present. + * + * @param {unknown} rawChild Raw child schema node. + * @param {string} displayPath Logical parent array path. + * @param {"items" | "contains"} keywordName Child schema keyword. + * @returns {SchemaNode | undefined} Parsed child schema node. + */ +function parseOptionalArrayChildSchema(rawChild, displayPath, keywordName) { + return parseArrayChildSchema(rawChild, displayPath, keywordName); +} + +/** + * Parse one array child schema only when it is object-shaped and explicitly + * typed. This avoids silently treating tuple arrays or malformed child + * schemas as empty object nodes. + * + * @param {unknown} rawChild Raw child schema node. + * @param {string} displayPath Logical parent array path. + * @param {"items" | "contains"} keywordName Child schema keyword. + * @returns {SchemaNode} Parsed child schema node. + */ +function parseArrayChildSchema(rawChild, displayPath, keywordName) { + if (!rawChild || typeof rawChild !== "object" || Array.isArray(rawChild)) { + throw new Error( + `Schema property '${displayPath}' must declare '${keywordName}' as an object-valued schema with an explicit 'type'.`); + } + + if (typeof rawChild.type !== "string") { + throw new Error( + `Schema property '${displayPath}' must declare '${keywordName}' as an object-valued schema with an explicit 'type'.`); + } + + return parseSchemaNode(rawChild, joinArrayTemplatePath(displayPath)); +} + +/** + * Resolve one schema type while rejecting explicit strings that the shared + * subset does not support. + * + * @param {unknown} rawType Raw schema type value. + * @param {string} displayPath Logical property path. + * @returns {"object" | "array" | "string" | "integer" | "number" | "boolean"} Supported schema type. + */ +function resolveSupportedSchemaType(rawType, displayPath) { + if (typeof rawType !== "string") { + return "object"; + } + + if (!SupportedSchemaTypes.has(rawType)) { + throw new Error(`Schema property '${displayPath}' declares unsupported type '${rawType}'.`); + } + + return rawType; +} + +/** + * Return the first combinator keyword that the current shared schema subset + * intentionally rejects to keep Runtime / Generator / Tooling behavior aligned. + * + * @param {Record} schemaNode Raw schema object. + * @returns {string | undefined} Unsupported keyword name when present. + */ +function getUnsupportedCombinatorKeywordName(schemaNode) { + if (Object.prototype.hasOwnProperty.call(schemaNode, "oneOf")) { + return "oneOf"; + } + + if (Object.prototype.hasOwnProperty.call(schemaNode, "anyOf")) { + return "anyOf"; + } + + return undefined; +} + /** * Parse one optional `not` sub-schema and keep path formatting aligned with * the runtime/generator diagnostics. diff --git a/tools/gframework-config-tool/src/containsSummary.js b/tools/gframework-config-tool/src/containsSummary.js index a6fdbbe4..63a1771f 100644 --- a/tools/gframework-config-tool/src/containsSummary.js +++ b/tools/gframework-config-tool/src/containsSummary.js @@ -39,7 +39,7 @@ function describeContainsSchema(containsSchema, localizer) { /** * Build localized contains-related hint lines for array fields. * - * @param {{contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}, minContains?: number}} propertySchema Array property schema metadata. + * @param {{contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}, minContains?: number, maxContains?: number}} propertySchema Array property schema metadata. * @param {{t: (key: string, params?: Record) => string}} localizer Runtime localizer. * @returns {string[]} Localized contains hint lines. */ @@ -51,7 +51,7 @@ function buildContainsHintLines(propertySchema, localizer) { const effectiveMinContains = typeof propertySchema.minContains === "number" ? propertySchema.minContains : 1; - return [ + const lines = [ localizer.t("webview.hint.contains", { summary: describeContainsSchema(propertySchema.contains, localizer) }), @@ -59,6 +59,14 @@ function buildContainsHintLines(propertySchema, localizer) { value: effectiveMinContains }) ]; + + if (typeof propertySchema.maxContains === "number") { + lines.push(localizer.t("webview.hint.maxContains", { + value: propertySchema.maxContains + })); + } + + return lines; } module.exports = { diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 941d40bf..ce18e071 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -1,6 +1,43 @@ const fs = require("fs"); const path = require("path"); -const vscode = require("vscode"); +let vscode; +try { + vscode = require("vscode"); +} catch { + // Tests load pure helpers from this module without the VS Code host. + vscode = { + env: { + language: "en" + }, + EventEmitter: class EventEmitter { + constructor() { + this.event = () => undefined; + } + + fire() { + } + }, + TreeItem: class TreeItem { + constructor(label, collapsibleState) { + this.label = label; + this.collapsibleState = collapsibleState; + } + }, + TreeItemCollapsibleState: { + None: 0, + Collapsed: 1, + Expanded: 2 + }, + Uri: { + joinPath() { + return undefined; + } + }, + window: {}, + workspace: {}, + languages: {} + }; +} const { applyFormUpdates, createSampleConfigYaml, @@ -972,8 +1009,14 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml, options) { current = current[segment]; } } + function getDirectObjectArrayItems(editor) { + const itemsHost = editor.querySelector(":scope > [data-object-array-items]"); + return itemsHost + ? Array.from(itemsHost.querySelectorAll(":scope > [data-object-array-item]")) + : []; + } function renumberObjectArrayItems(editor) { - const items = editor.querySelectorAll("[data-object-array-item]"); + const items = getDirectObjectArrayItems(editor); items.forEach((item, index) => { const title = item.querySelector(".object-array-item-title"); if (title) { @@ -981,6 +1024,52 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml, options) { } }); } + function shouldIncludeNestedControl(control, ownerItem) { + return control.closest("[data-object-array-item]") === ownerItem; + } + function collectObjectArrayEditorItems(editor) { + const items = []; + for (const item of getDirectObjectArrayItems(editor)) { + items.push(collectObjectArrayItemValue(item)); + } + + return items; + } + function collectObjectArrayItemValue(item) { + const itemValue = {}; + + for (const control of item.querySelectorAll("[data-item-local-path]")) { + if (!shouldIncludeNestedControl(control, item)) { + continue; + } + + setNestedObjectValue(itemValue, control.dataset.itemLocalPath, control.value); + } + + for (const textarea of item.querySelectorAll("textarea[data-item-array-path]")) { + if (!shouldIncludeNestedControl(textarea, item)) { + continue; + } + + setNestedObjectValue( + itemValue, + textarea.dataset.itemArrayPath, + parseArrayEditorValue(textarea.value)); + } + + for (const nestedEditor of item.querySelectorAll("[data-item-object-array-path]")) { + if (!shouldIncludeNestedControl(nestedEditor, item)) { + continue; + } + + setNestedObjectValue( + itemValue, + nestedEditor.dataset.itemObjectArrayPath, + collectObjectArrayEditorItems(nestedEditor)); + } + + return itemValue; + } document.addEventListener("click", (event) => { const schemaButton = event.target.closest("[data-open-ref-schema]"); if (schemaButton) { @@ -1048,23 +1137,9 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml, options) { for (const textarea of document.querySelectorAll("textarea[data-comment-path]")) { comments[textarea.dataset.commentPath] = textarea.value; } - for (const editor of document.querySelectorAll("[data-object-array-editor]")) { + for (const editor of document.querySelectorAll("[data-object-array-editor][data-object-array-path]")) { const path = editor.dataset.objectArrayPath; - const items = []; - for (const item of editor.querySelectorAll("[data-object-array-items] > [data-object-array-item]")) { - const itemValue = {}; - for (const control of item.querySelectorAll("[data-item-local-path]")) { - setNestedObjectValue(itemValue, control.dataset.itemLocalPath, control.value); - } - for (const textarea of item.querySelectorAll("textarea[data-item-array-path]")) { - setNestedObjectValue( - itemValue, - textarea.dataset.itemArrayPath, - parseArrayEditorValue(textarea.value)); - } - items.push(itemValue); - } - objectArrays[path] = items; + objectArrays[path] = collectObjectArrayEditorItems(editor); } vscode.postMessage({ type: "save", scalars, arrays, objectArrays, comments }); }); @@ -1110,8 +1185,11 @@ function renderFormField(field) { title: localizer.t("webview.objectArray.item"), fields: field.templateFields }); + const pathAttribute = field.itemMode + ? `data-item-object-array-path="${escapeHtml(field.path)}"` + : `data-object-array-path="${escapeHtml(field.path)}"`; return ` -
+
${escapeHtml(field.label)} ${field.required ? `${escapeHtml(localizer.t("webview.badge.required"))}` : ""}
${escapeHtml(field.displayPath || field.path)}
${renderYamlCommentBlock(field)} @@ -1507,6 +1585,41 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa continue; } + if (propertySchema.type === "array" && + propertySchema.items && + propertySchema.items.type === "object") { + const templateFields = []; + collectObjectArrayItemFields( + propertySchema.items, + undefined, + "", + joinArrayTemplatePath(itemDisplayPath), + depth + 1, + templateFields, + unsupported, + commentLookup); + fields.push({ + kind: "objectArray", + path: itemLocalPath, + displayPath: itemDisplayPath, + label, + required: requiredSet.has(key), + depth, + schema: propertySchema, + itemMode: true, + comment: commentLookup[itemDisplayPath] || "", + items: buildObjectArrayItemModels( + propertySchema.items, + propertyValue, + itemDisplayPath, + depth + 1, + unsupported, + commentLookup), + templateFields + }); + continue; + } + if (["string", "integer", "number", "boolean"].includes(propertySchema.type)) { fields.push({ kind: "scalar", @@ -2096,5 +2209,8 @@ function parseArrayFieldPayload(arrays) { module.exports = { activate, - deactivate + deactivate, + __test: { + buildFormModel + } }; diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js index b2684e9f..605504cc 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -247,9 +247,9 @@ const zhCnMessages = { "webview.hint.format": "格式:{value}", "webview.hint.minItems": "最少元素数:{value}", "webview.hint.maxItems": "最多元素数:{value}", - "webview.hint.contains": "Contains 约束:{summary}", - "webview.hint.minContains": "最少 contains 匹配数:{value}", - "webview.hint.maxContains": "最多 contains 匹配数:{value}", + "webview.hint.contains": "contains 条件:{summary}", + "webview.hint.minContains": "最少匹配数:{value}", + "webview.hint.maxContains": "最多匹配数:{value}", "webview.hint.uniqueItems": "元素必须唯一", "webview.hint.required": "必填字段:{properties}", "webview.hint.itemMinimum": "元素最小值:{value}", @@ -265,7 +265,7 @@ const zhCnMessages = { "webview.hint.minProperties": "最少属性数:{value}", "webview.hint.maxProperties": "最多属性数:{value}", "webview.hint.dependentRequired": "当 {trigger} 出现时:还必须声明 {dependencies}", - "webview.hint.dependentSchemas": "当 {trigger} 出现时:还必须满足 {schema}", + "webview.hint.dependentSchemas": "当 {trigger} 出现时:还必须满足以下条件:{schema}", "webview.hint.allOf": "还必须满足:{schema}", "webview.hint.ifThen": "当满足 {condition} 时:还必须满足 {schema}", "webview.hint.ifElse": "否则(当 {condition} 不匹配时):还必须满足 {schema}", @@ -277,7 +277,7 @@ const zhCnMessages = { [ValidationMessageKeys.allOfViolation]: "对象“{displayPath}”必须满足全部 `allOf` schema,第 {index} 项未匹配。", [ValidationMessageKeys.constMismatch]: "属性“{displayPath}”必须匹配固定值 {value}。", [ValidationMessageKeys.dependentRequiredViolation]: "属性“{triggerProperty}”存在时,必须同时声明属性“{displayPath}”。", - [ValidationMessageKeys.dependentSchemasViolation]: "对象“{displayPath}”在属性“{triggerProperty}”存在时,必须满足对应的 dependent schema。", + [ValidationMessageKeys.dependentSchemasViolation]: "对象“{displayPath}”在属性“{triggerProperty}”存在时,必须满足对应的依赖 schema。", [ValidationMessageKeys.elseViolation]: "对象“{displayPath}”在内联 `if` 条件未命中时,必须满足对应的 `else` schema。", [ValidationMessageKeys.exclusiveMaximumViolation]: "属性“{displayPath}”必须小于 {value}。", [ValidationMessageKeys.exclusiveMinimumViolation]: "属性“{displayPath}”必须大于 {value}。", diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js index 51699094..a4c8ab5c 100644 --- a/tools/gframework-config-tool/src/localizationKeys.js +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -1,6 +1,7 @@ const ValidationMessageKeys = Object.freeze({ allOfViolation: "validation.allOfViolation", constMismatch: "validation.constMismatch", + dependentRequiredViolation: "validation.dependentRequiredViolation", dependentSchemasViolation: "validation.dependentSchemasViolation", elseViolation: "validation.elseViolation", enumMismatch: "validation.enumMismatch", diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index dcfe00ab..b31b6adc 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -1,5 +1,6 @@ const test = require("node:test"); const assert = require("node:assert/strict"); +const {__test: extensionTest} = require("../src/extension"); const { applyFormUpdates, applyScalarUpdates, @@ -178,6 +179,67 @@ test("parseSchemaContent should preserve empty-string const raw and display meta assert.equal(schema.properties.name.constDisplayValue, "\"\""); }); +test("parseSchemaContent should reject unsupported oneOf combinators", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "oneOf": [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + ] + } + } + } + `), + /unsupported combinator keyword 'oneOf'/u); +}); + +test("parseSchemaContent should reject unsupported additionalProperties forms", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "additionalProperties": true, + "properties": { + "itemCount": { "type": "integer" } + } + } + } + } + `), + /unsupported 'additionalProperties' metadata/u); +}); + +test("parseSchemaContent should reject unsupported explicit schema types", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "bogus" + } + } + } + `), + /declares unsupported type 'bogus'/u); +}); + test("parseSchemaContent should build object const comparable keys with ordinal property ordering", () => { const schema = parseSchemaContent(` { @@ -1516,6 +1578,45 @@ test("parseSchemaContent should reject nested-array contains schemas", () => { /unsupported nested array 'contains' schemas/u); }); +test("parseSchemaContent should reject array items without an explicit typed object schema", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRates": { + "type": "array", + "items": [ + { "type": "integer" } + ] + } + } + } + `), + /must declare 'items' as an object-valued schema with an explicit 'type'/u); +}); + +test("parseSchemaContent should reject contains without an explicit typed object schema", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRates": { + "type": "array", + "contains": { + "const": 5 + }, + "items": { + "type": "integer" + } + } + } + } + `), + /must declare 'contains' as an object-valued schema with an explicit 'type'/u); +}); + test("parseSchemaContent should reject minContains and maxContains without contains", () => { assert.throws( () => parseSchemaContent(` @@ -2498,6 +2599,166 @@ test("applyFormUpdates should rewrite object-array items from structured form pa assert.match(updated, /^ monsterId: goblin$/mu); }); +test("buildFormModel should expose nested object-array editors inside object-array items", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "phases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "wave": { "type": "integer" }, + "spawns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "monsterId": { "type": "string" }, + "tags": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +phases: + - + wave: 1 + spawns: + - + monsterId: slime + tags: + - starter +`); + + const formModel = extensionTest.buildFormModel(schema, yaml, {}); + const phasesField = formModel.fields.find((field) => field.path === "phases"); + + assert.ok(phasesField); + assert.equal(phasesField.kind, "objectArray"); + assert.equal(phasesField.items.length, 1); + assert.deepEqual(formModel.unsupported, []); + + const nestedSpawnField = phasesField.items[0].fields.find((field) => field.path === "spawns"); + assert.ok(nestedSpawnField); + assert.equal(nestedSpawnField.kind, "objectArray"); + assert.equal(nestedSpawnField.itemMode, true); + assert.equal(nestedSpawnField.items.length, 1); + + const spawnMonsterField = nestedSpawnField.items[0].fields.find((field) => field.path === "monsterId"); + assert.ok(spawnMonsterField); + assert.equal(spawnMonsterField.kind, "scalar"); + + const spawnTagsField = nestedSpawnField.items[0].fields.find((field) => field.path === "tags"); + assert.ok(spawnTagsField); + assert.equal(spawnTagsField.kind, "array"); +}); + +test("applyFormUpdates should rewrite nested object arrays from structured form payloads", () => { + const updated = applyFormUpdates( + [ + "phases:", + " -", + " wave: 1" + ].join("\n"), + { + objectArrays: { + phases: [ + { + wave: "1", + spawns: [ + { + monsterId: "slime", + tags: ["starter", "melee"], + reward: { + gold: "10" + } + }, + { + monsterId: "goblin", + conditions: [ + { + type: "night", + value: "true" + } + ] + } + ] + } + ] + } + }); + + assert.match(updated, /^phases:$/mu); + assert.match(updated, /^ -$/mu); + assert.match(updated, /^ wave: 1$/mu); + assert.match(updated, /^ spawns:$/mu); + assert.match(updated, /^ -$/mu); + assert.match(updated, /^ monsterId: slime$/mu); + assert.match(updated, /^ tags:$/mu); + assert.match(updated, /^ - starter$/mu); + assert.match(updated, /^ - melee$/mu); + assert.match(updated, /^ reward:$/mu); + assert.match(updated, /^ gold: 10$/mu); + assert.match(updated, /^ monsterId: goblin$/mu); + assert.match(updated, /^ conditions:$/mu); + assert.match(updated, /^ -$/mu); + assert.match(updated, /^ type: night$/mu); + assert.match(updated, /^ value: true$/mu); +}); + +test("applyFormUpdates should not mix nested object-array items into the parent array", () => { + const updated = applyFormUpdates( + [ + "phases:", + " -", + " wave: 1" + ].join("\n"), + { + objectArrays: { + phases: [ + { + wave: "1", + spawns: [ + { + monsterId: "slime" + }, + { + monsterId: "goblin" + } + ] + }, + { + wave: "2", + spawns: [ + { + monsterId: "bat" + } + ] + } + ] + } + }); + + assert.equal((updated.match(/^ -$/gmu) || []).length, 2); + assert.equal((updated.match(/^ -$/gmu) || []).length, 3); + assert.doesNotMatch(updated, /^ monsterId: slime$/mu); + assert.doesNotMatch(updated, /^ monsterId: goblin$/mu); + assert.match(updated, /^ -$/mu); + assert.match(updated, /^ monsterId: slime$/mu); + assert.match(updated, /^ monsterId: goblin$/mu); + assert.match(updated, /^ monsterId: bat$/mu); +}); + test("applyFormUpdates should clear object arrays when the form removes all items", () => { const updated = applyFormUpdates( [ diff --git a/tools/gframework-config-tool/test/containsSummary.test.js b/tools/gframework-config-tool/test/containsSummary.test.js index 8d29d7f0..b053b091 100644 --- a/tools/gframework-config-tool/test/containsSummary.test.js +++ b/tools/gframework-config-tool/test/containsSummary.test.js @@ -51,6 +51,7 @@ test("buildContainsHintLines should use explicit minContains when provided", () const lines = buildContainsHintLines( { minContains: 2, + maxContains: 3, contains: { type: "string", constValue: "\"potion\"", @@ -62,7 +63,8 @@ test("buildContainsHintLines should use explicit minContains when provided", () assert.deepEqual(lines, [ "Contains: string, Const: \"potion\", Ref table: item", - "Min contains: 2" + "Min contains: 2", + "Max contains: 3" ]); }); @@ -93,3 +95,24 @@ test("describeContainsSchema should format pattern-based contains schema in Chin assert.equal(summary, "string, 正则模式:^potion-, 引用表:item"); }); + +test("buildContainsHintLines should use updated Chinese contains hint wording", () => { + const localizer = createLocalizer("zh-cn"); + + const lines = buildContainsHintLines( + { + minContains: 1, + maxContains: 2, + contains: { + type: "string", + enumValues: ["potion", "elixir"] + } + }, + localizer); + + assert.deepEqual(lines, [ + "contains 条件:string, 允许值:potion, elixir", + "最少匹配数:1", + "最多匹配数:2" + ]); +}); diff --git a/tools/gframework-config-tool/test/localization.test.js b/tools/gframework-config-tool/test/localization.test.js index 52358254..9493f7af 100644 --- a/tools/gframework-config-tool/test/localization.test.js +++ b/tools/gframework-config-tool/test/localization.test.js @@ -68,6 +68,22 @@ test("createLocalizer should expose contains-count validation keys", () => { assert.equal( chineseLocalizer.t(ValidationMessageKeys.maxContainsViolation, {displayPath: "dropRates", value: 1}), "属性“dropRates”最多只能包含 1 个匹配 contains 条件的元素。"); + assert.equal( + chineseLocalizer.t("webview.hint.contains", {summary: "object, Required: itemCount"}), + "contains 条件:object, Required: itemCount"); +}); + +test("createLocalizer should resolve dependentRequired through the explicit validation key", () => { + const localizer = createLocalizer("en"); + + assert.equal(ValidationMessageKeys.dependentRequiredViolation, "validation.dependentRequiredViolation"); + assert.equal( + localizer.t(ValidationMessageKeys.dependentRequiredViolation, { + displayPath: "reward.itemCount", + triggerProperty: "reward.itemId" + }), + "Property 'reward.itemCount' is required when sibling property 'reward.itemId' is present."); + assert.equal(localizer.t("undefined"), "undefined"); }); test("createLocalizer should expose not validation keys", () => { @@ -132,7 +148,7 @@ test("createLocalizer should expose dependentSchemas validation keys", () => { trigger: "reward.itemId", schema: "object, 必填字段:itemCount" }), - "当 reward.itemId 出现时:还必须满足 object, 必填字段:itemCount"); + "当 reward.itemId 出现时:还必须满足以下条件:object, 必填字段:itemCount"); assert.equal( englishLocalizer.t(ValidationMessageKeys.dependentSchemasViolation, { displayPath: "reward", @@ -144,7 +160,7 @@ test("createLocalizer should expose dependentSchemas validation keys", () => { displayPath: "reward", triggerProperty: "reward.itemId" }), - "对象“reward”在属性“reward.itemId”存在时,必须满足对应的 dependent schema。"); + "对象“reward”在属性“reward.itemId”存在时,必须满足对应的依赖 schema。"); }); test("createLocalizer should expose allOf validation keys", () => {