From c693337ebf377f698d019aa843872ed09bb8becd Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:06:20 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E5=9F=BA?= =?UTF-8?q?=E4=BA=8EJSON=20schema=E7=9A=84=E9=85=8D=E7=BD=AE=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了SchemaConfigGenerator源代码生成器 - 支持根据JSON schema文件自动生成配置类型 - 生成强类型的配置表包装类 - 支持嵌套对象和对象数组的类型生成 - 生成配置表的查询和索引功能 - 添加了跨表引用的元数据支持 - 生成运行时注册和访问辅助代码 - 支持默认值、枚举和约束的文档生成 --- .../Config/YamlConfigLoaderTests.cs | 102 +++++++ .../Config/YamlConfigSchemaValidator.cs | 268 +++++++++++++++++- .../SchemaConfigGeneratorSnapshotTests.cs | 2 + .../SchemaConfigGenerator/MonsterConfig.g.txt | 4 +- .../Config/SchemaConfigGenerator.cs | 18 +- 5 files changed, 386 insertions(+), 8 deletions(-) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 059d61aa..488c3427 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -510,6 +510,54 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证数值不满足 multipleOf 时会在运行时被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Number_Violates_MultipleOf() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 12 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { + "type": "integer", + "multipleOf": 5 + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("hp")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("12")); + Assert.That(exception.Message, Does.Contain("multiple of 5")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。 /// @@ -762,6 +810,60 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证数组声明 uniqueItems 后,重复元素会在运行时被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Array_Violates_UniqueItems() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 5 + - 10 + - 5 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "integer" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRates[2]")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("5")); + Assert.That(exception.Message, Does.Contain("unique array items")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证启用 schema 校验后,未知字段不会再被静默忽略。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 381f321c..c1f85a3e 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -7,6 +7,8 @@ namespace GFramework.Game.Config; /// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。 /// 该校验器与当前配置生成器、VS Code 工具支持的 schema 子集保持一致, /// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。 +/// 当前共享子集额外支持 multipleOfuniqueItems, +/// 让数值步进和数组去重规则在运行时与生成器 / 工具侧保持一致。 /// internal static class YamlConfigSchemaValidator { @@ -603,6 +605,8 @@ internal static class YamlConfigSchemaValidator schemaNode.ItemNode, references); } + + ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode); } /// @@ -776,6 +780,7 @@ internal static class YamlConfigSchemaValidator TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "exclusiveMinimum"); var exclusiveMaximum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "exclusiveMaximum"); + var multipleOf = TryParseMultipleOfConstraint(tableName, schemaPath, propertyPath, element, nodeType); var minLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minLength"); var maxLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maxLength"); var pattern = TryParsePatternConstraint(tableName, schemaPath, propertyPath, element, nodeType); @@ -813,6 +818,7 @@ internal static class YamlConfigSchemaValidator !maximum.HasValue && !exclusiveMinimum.HasValue && !exclusiveMaximum.HasValue && + !multipleOf.HasValue && !minLength.HasValue && !maxLength.HasValue && pattern is null) @@ -825,6 +831,7 @@ internal static class YamlConfigSchemaValidator maximum, exclusiveMinimum, exclusiveMaximum, + multipleOf, minLength, maxLength, pattern, @@ -851,6 +858,7 @@ internal static class YamlConfigSchemaValidator { var minItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "minItems"); var maxItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "maxItems"); + var uniqueItems = TryParseUniqueItemsConstraint(tableName, schemaPath, propertyPath, element); if (minItems.HasValue && maxItems.HasValue && minItems.Value > maxItems.Value) { @@ -862,9 +870,9 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(propertyPath)); } - return !minItems.HasValue && !maxItems.HasValue + return !minItems.HasValue && !maxItems.HasValue && !uniqueItems ? null - : new YamlConfigArrayConstraints(minItems, maxItems); + : new YamlConfigArrayConstraints(minItems, maxItems, uniqueItems); } /// @@ -917,6 +925,41 @@ internal static class YamlConfigSchemaValidator return constraintValue; } + /// + /// 读取 multipleOf 约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 字段路径。 + /// Schema 节点。 + /// 字段类型。 + /// 步进约束;未声明时返回空。 + private static double? TryParseMultipleOfConstraint( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + YamlConfigSchemaPropertyType nodeType) + { + var multipleOf = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "multipleOf"); + if (!multipleOf.HasValue) + { + return null; + } + + if (multipleOf.Value <= 0d) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'multipleOf' as a positive finite number.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return multipleOf; + } + /// /// 读取字符串长度约束。 /// @@ -1062,6 +1105,39 @@ internal static class YamlConfigSchemaValidator return constraintValue; } + /// + /// 读取数组去重约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 字段路径。 + /// Schema 节点。 + /// 是否启用 uniqueItems + private static bool TryParseUniqueItemsConstraint( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element) + { + if (!element.TryGetProperty("uniqueItems", out var constraintElement)) + { + return false; + } + + if (constraintElement.ValueKind != JsonValueKind.True && + constraintElement.ValueKind != JsonValueKind.False) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'uniqueItems' as a boolean.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return constraintElement.GetBoolean(); + } + /// /// 校验数值上下界组合不会形成空区间。 /// 这里把闭区间与开区间统一折算为最强边界,避免 schema 进入“无任何合法值”的状态。 @@ -1238,6 +1314,20 @@ internal static class YamlConfigSchemaValidator $"Exclusive maximum allowed value: {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}."); } + if (constraints.MultipleOf.HasValue && + !IsMultipleOf(numericValue, constraints.MultipleOf.Value)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be a multiple of {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Required numeric step: {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}."); + } + return; case YamlConfigSchemaPropertyType.String: @@ -1345,6 +1435,159 @@ internal static class YamlConfigSchemaValidator } } + /// + /// 校验数组是否满足去重约束。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 字段路径。 + /// 实际数组节点。 + /// 数组 schema 节点。 + private static void ValidateArrayUniqueItemsConstraint( + string tableName, + string yamlPath, + string displayPath, + YamlSequenceNode sequenceNode, + YamlConfigSchemaNode schemaNode) + { + var constraints = schemaNode.ArrayConstraints; + if (constraints is null || + !constraints.UniqueItems || + schemaNode.ItemNode is null) + { + return; + } + + // The canonical item key uses schema-aware normalization so object key order, + // scalar quoting, and numeric formatting do not accidentally bypass uniqueItems. + Dictionary seenItems = new(StringComparer.Ordinal); + for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) + { + var itemNode = sequenceNode.Children[itemIndex]; + var comparableValue = BuildComparableNodeValue(itemNode, schemaNode.ItemNode); + if (seenItems.TryGetValue(comparableValue, out var existingIndex)) + { + var itemPath = $"{displayPath}[{itemIndex}]"; + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' requires unique array items, but item '{itemPath}' duplicates '{displayPath}[{existingIndex}]'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: itemPath, + rawValue: DescribeYamlNodeForDiagnostics(itemNode, schemaNode.ItemNode), + detail: "The schema declares uniqueItems = true."); + } + + seenItems.Add(comparableValue, itemIndex); + } + } + + /// + /// 将一个已通过结构校验的 YAML 节点归一化为可比较字符串。 + /// 该键仅用于 uniqueItems,因此要忽略对象字段顺序和字符串引号形式。 + /// + /// YAML 节点。 + /// 对应 schema 节点。 + /// 可稳定比较的归一化键。 + private static string BuildComparableNodeValue(YamlNode node, YamlConfigSchemaNode schemaNode) + { + switch (schemaNode.NodeType) + { + case YamlConfigSchemaPropertyType.Object: + if (node is not YamlMappingNode mappingNode) + { + throw new InvalidOperationException("Validated object nodes must be YAML mappings."); + } + + var objectEntries = new List>(mappingNode.Children.Count); + foreach (var entry in mappingNode.Children) + { + if (entry.Key is not YamlScalarNode keyNode || + keyNode.Value is null || + schemaNode.Properties is null || + !schemaNode.Properties.TryGetValue(keyNode.Value, out var propertySchema)) + { + throw new InvalidOperationException("Validated object nodes must use declared scalar property names."); + } + + objectEntries.Add( + new KeyValuePair( + keyNode.Value, + BuildComparableNodeValue(entry.Value, propertySchema))); + } + + objectEntries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); + return string.Join( + "|", + objectEntries.Select(static entry => + $"{entry.Key.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Key}={entry.Value.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Value}")); + + case YamlConfigSchemaPropertyType.Array: + if (node is not YamlSequenceNode sequenceNode || + schemaNode.ItemNode is null) + { + throw new InvalidOperationException("Validated array nodes must be YAML sequences with item schema."); + } + + return "[" + + string.Join( + ",", + sequenceNode.Children.Select( + item => BuildComparableNodeValue(item, schemaNode.ItemNode))) + + "]"; + + case YamlConfigSchemaPropertyType.Integer: + case YamlConfigSchemaPropertyType.Number: + case YamlConfigSchemaPropertyType.Boolean: + case YamlConfigSchemaPropertyType.String: + if (node is not YamlScalarNode scalarNode || + scalarNode.Value is null) + { + throw new InvalidOperationException("Validated scalar nodes must be YAML scalars."); + } + + var normalizedScalar = NormalizeScalarValue(schemaNode.NodeType, scalarNode.Value); + return $"{schemaNode.NodeType}:{normalizedScalar.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedScalar}"; + + default: + throw new InvalidOperationException($"Unsupported schema node type '{schemaNode.NodeType}'."); + } + } + + /// + /// 为唯一性诊断提取一个可读的节点摘要。 + /// + /// YAML 节点。 + /// 对应 schema 节点。 + /// 诊断摘要。 + private static string DescribeYamlNodeForDiagnostics(YamlNode node, YamlConfigSchemaNode schemaNode) + { + return schemaNode.NodeType switch + { + YamlConfigSchemaPropertyType.Object => "{...}", + YamlConfigSchemaPropertyType.Array => "[...]", + _ when node is YamlScalarNode scalarNode => scalarNode.Value ?? string.Empty, + _ => node.GetType().Name + }; + } + + /// + /// 判断数值是否满足 multipleOf。 + /// 双精度浮点比较会保留一个与商值量级相关的微小容差, + /// 以避免运行时与 JS 工具侧在 0.1 / 0.01 这类十进制步进上出现伪失败。 + /// + /// 当前值。 + /// 步进约束。 + /// 是否满足整倍数关系。 + private static bool IsMultipleOf(double value, double divisor) + { + var quotient = value / divisor; + var nearestInteger = Math.Round(quotient); + var tolerance = 1e-9 * Math.Max(1d, Math.Abs(quotient)); + return Math.Abs(quotient - nearestInteger) <= tolerance; + } + /// /// 解析跨表引用目标表名称。 /// @@ -1758,7 +2001,7 @@ internal sealed class YamlConfigSchemaNode } /// -/// 表示一个标量节点上声明的数值范围或字符串长度约束。 +/// 表示一个标量节点上声明的数值范围、步进或字符串长度约束。 /// 该模型让运行时、热重载和跨文件诊断都能复用同一份最小约束信息。 /// internal sealed class YamlConfigScalarConstraints @@ -1770,6 +2013,7 @@ internal sealed class YamlConfigScalarConstraints /// 最大值约束。 /// 开区间最小值约束。 /// 开区间最大值约束。 + /// 数值步进约束。 /// 最小长度约束。 /// 最大长度约束。 /// 正则模式约束。 @@ -1779,6 +2023,7 @@ internal sealed class YamlConfigScalarConstraints double? maximum, double? exclusiveMinimum, double? exclusiveMaximum, + double? multipleOf, int? minLength, int? maxLength, string? pattern, @@ -1788,6 +2033,7 @@ internal sealed class YamlConfigScalarConstraints Maximum = maximum; ExclusiveMinimum = exclusiveMinimum; ExclusiveMaximum = exclusiveMaximum; + MultipleOf = multipleOf; MinLength = minLength; MaxLength = maxLength; Pattern = pattern; @@ -1814,6 +2060,11 @@ internal sealed class YamlConfigScalarConstraints /// public double? ExclusiveMaximum { get; } + /// + /// 获取数值步进约束。 + /// + public double? MultipleOf { get; } + /// /// 获取最小长度约束。 /// @@ -1836,7 +2087,7 @@ internal sealed class YamlConfigScalarConstraints } /// -/// 表示一个数组节点上声明的元素数量约束。 +/// 表示一个数组节点上声明的元素数量或去重约束。 /// 该模型与标量约束拆分保存,避免数组节点继续共享不适用的标量字段。 /// internal sealed class YamlConfigArrayConstraints @@ -1846,10 +2097,12 @@ internal sealed class YamlConfigArrayConstraints /// /// 最小元素数量约束。 /// 最大元素数量约束。 - public YamlConfigArrayConstraints(int? minItems, int? maxItems) + /// 是否要求数组元素唯一。 + public YamlConfigArrayConstraints(int? minItems, int? maxItems, bool uniqueItems) { MinItems = minItems; MaxItems = maxItems; + UniqueItems = uniqueItems; } /// @@ -1861,6 +2114,11 @@ internal sealed class YamlConfigArrayConstraints /// 获取最大元素数量约束。 /// public int? MaxItems { get; } + + /// + /// 获取是否要求数组元素唯一。 + /// + public bool UniqueItems { get; } } /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs index 290985ee..dc2a36b5 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs @@ -92,6 +92,7 @@ public class SchemaConfigGeneratorSnapshotTests "maximum": 999, "exclusiveMinimum": 0, "exclusiveMaximum": 1000, + "multipleOf": 5, "default": 10 }, "dropItems": { @@ -99,6 +100,7 @@ public class SchemaConfigGeneratorSnapshotTests "type": "array", "minItems": 1, "maxItems": 3, + "uniqueItems": true, "items": { "type": "string", "minLength": 3, diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt index 2306fa00..d5b5f909 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt @@ -34,7 +34,7 @@ public sealed partial class MonsterConfig /// /// /// Schema property path: 'hp'. - /// Constraints: minimum = 1, exclusiveMinimum = 0, maximum = 999, exclusiveMaximum = 1000. + /// Constraints: minimum = 1, exclusiveMinimum = 0, maximum = 999, exclusiveMaximum = 1000, multipleOf = 5. /// Generated default initializer: = 10; /// public int? Hp { get; set; } = 10; @@ -45,7 +45,7 @@ public sealed partial class MonsterConfig /// /// Schema property path: 'dropItems'. /// Allowed values: potion, slime_gel. - /// Constraints: minItems = 1, maxItems = 3. + /// Constraints: minItems = 1, maxItems = 3, uniqueItems = true. /// References config table: 'item'. /// Item constraints: minLength = 3, maxLength = 12. /// Generated default initializer: = new string[] { "potion" }; diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index ca249109..5890cc1f 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -6,6 +6,8 @@ namespace GFramework.SourceGenerators.Config; /// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。 /// 当前实现聚焦 AI-First 配置系统共享的最小 schema 子集, /// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / ref-table 元数据。 +/// 当前共享子集也会把 multipleOfuniqueItems 写入生成代码文档, +/// 让消费者能直接在强类型 API 上看到运行时生效的约束。 /// [Generator] public sealed class SchemaConfigGenerator : IIncrementalGenerator @@ -2430,7 +2432,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } /// - /// 将 shared schema 子集中的范围、长度、模式与数组数量约束整理成 XML 文档可读字符串。 + /// 将 shared schema 子集中的范围、步进、长度、模式与数组数量 / 去重约束整理成 XML 文档可读字符串。 /// /// Schema 节点。 /// 标量类型。 @@ -2463,6 +2465,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator parts.Add($"exclusiveMaximum = {exclusiveMaximum.ToString(CultureInfo.InvariantCulture)}"); } + if ((schemaType == "integer" || schemaType == "number") && + TryGetFiniteNumber(element, "multipleOf", out var multipleOf) && + multipleOf > 0d) + { + parts.Add($"multipleOf = {multipleOf.ToString(CultureInfo.InvariantCulture)}"); + } + if (schemaType == "string" && TryGetNonNegativeInt32(element, "minLength", out var minLength)) { @@ -2494,6 +2503,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator parts.Add($"maxItems = {maxItems.ToString(CultureInfo.InvariantCulture)}"); } + if (schemaType == "array" && + element.TryGetProperty("uniqueItems", out var uniqueItemsElement) && + uniqueItemsElement.ValueKind == JsonValueKind.True) + { + parts.Add("uniqueItems = true"); + } + return parts.Count > 0 ? string.Join(", ", parts) : null; } From 51de7f11026093dfddcc70a658c956a3a355e3bb Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:06:43 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=AA=8C=E8=AF=81=E5=92=8CYAML=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了配置模式解析器,支持递归对象/数组/标量树结构 - 添加了可编辑字段收集功能,支持批量编辑标量和数组类型 - 实现了YAML解析器,支持嵌套对象、标量数组和对象数组 - 添加了YAML注释提取功能,将注释映射到逻辑字段路径 - 实现了基于模式的示例YAML配置生成功能 - 添加了扩展端验证诊断功能,支持中英文错误消息 - 实现了表单更新应用功能,支持标量、数组和对象数组更新 - 添加了批处理数组值解析和模式枚举值标准化功能 - 实现了YAML标量格式化和引号移除功能 - 添加了完整的模式节点验证,支持数值约束、长度限制和模式匹配 - 实现了多语言验证消息本地化功能 - 添加了YAML标记化和块解析功能 - 实现了唯一性检查和比较键构建功能 --- .../src/configValidation.js | 131 ++++++++++++++++++ tools/gframework-config-tool/src/extension.js | 14 +- .../src/localization.js | 10 ++ .../src/localizationKeys.js | 2 + .../test/configValidation.test.js | 67 +++++++++ 5 files changed, 223 insertions(+), 1 deletion(-) diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index e80a559e..25a49b19 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -370,6 +370,16 @@ function normalizeSchemaNumber(value) { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +/** + * Normalize one strictly positive finite schema number. + * + * @param {unknown} value Raw schema value. + * @returns {number | undefined} Normalized positive number. + */ +function normalizeSchemaPositiveNumber(value) { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + /** * Normalize one non-negative integer schema value for length constraints. * @@ -380,6 +390,16 @@ function normalizeSchemaNonNegativeInteger(value) { return Number.isInteger(value) && value >= 0 ? value : undefined; } +/** + * Normalize one boolean schema flag. + * + * @param {unknown} value Raw schema value. + * @returns {boolean | undefined} Normalized boolean. + */ +function normalizeSchemaBoolean(value) { + return typeof value === "boolean" ? value : undefined; +} + /** * Normalize one schema pattern string when the regular expression can be * compiled by the local tooling runtime. @@ -454,6 +474,25 @@ function matchesSchemaPattern(scalarValue, pattern, displayPath) { } } +/** + * Test whether one numeric scalar satisfies a multipleOf constraint. + * + * @param {string} scalarValue YAML scalar value. + * @param {number | undefined} multipleOf Schema multipleOf value. + * @returns {boolean} True when compatible or the constraint is absent. + */ +function matchesSchemaMultipleOf(scalarValue, multipleOf) { + if (typeof multipleOf !== "number") { + return true; + } + + const numericValue = Number(scalarValue); + const quotient = numericValue / multipleOf; + const nearestInteger = Math.round(quotient); + const tolerance = 1e-9 * Math.max(1, Math.abs(quotient)); + return Math.abs(quotient - nearestInteger) <= tolerance; +} + /** * Format a scalar value for YAML output. * @@ -505,11 +544,13 @@ function parseSchemaNode(rawNode, displayPath) { exclusiveMinimum: normalizeSchemaNumber(value.exclusiveMinimum), maximum: normalizeSchemaNumber(value.maximum), exclusiveMaximum: normalizeSchemaNumber(value.exclusiveMaximum), + multipleOf: normalizeSchemaPositiveNumber(value.multipleOf), minLength: normalizeSchemaNonNegativeInteger(value.minLength), maxLength: normalizeSchemaNonNegativeInteger(value.maxLength), pattern: normalizeSchemaPattern(value.pattern, displayPath), minItems: normalizeSchemaNonNegativeInteger(value.minItems), maxItems: normalizeSchemaNonNegativeInteger(value.maxItems), + uniqueItems: normalizeSchemaBoolean(value.uniqueItems), refTable: typeof value["x-gframework-ref-table"] === "string" ? value["x-gframework-ref-table"] : undefined @@ -545,6 +586,7 @@ function parseSchemaNode(rawNode, displayPath) { defaultValue: metadata.defaultValue, minItems: metadata.minItems, maxItems: metadata.maxItems, + uniqueItems: metadata.uniqueItems === true, refTable: metadata.refTable, items: itemNode }; @@ -568,6 +610,9 @@ function parseSchemaNode(rawNode, displayPath) { exclusiveMaximum: type === "integer" || type === "number" ? metadata.exclusiveMaximum : undefined, + multipleOf: type === "integer" || type === "number" + ? metadata.multipleOf + : undefined, minLength: type === "string" ? metadata.minLength : undefined, @@ -638,6 +683,26 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) diagnostics, localizer); } + + if (schemaNode.uniqueItems === true) { + const seenItems = new Map(); + for (let index = 0; index < yamlNode.items.length; index += 1) { + const comparableValue = buildComparableNodeValue(schemaNode.items, yamlNode.items[index]); + if (seenItems.has(comparableValue)) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.uniqueItemsViolation, localizer, { + displayPath: joinArrayIndexPath(displayPath, index), + duplicatePath: joinArrayIndexPath(displayPath, seenItems.get(comparableValue)) + }) + }); + break; + } + + seenItems.set(comparableValue, index); + } + } + return; } @@ -729,6 +794,17 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) }); } + if (supportsNumericConstraints && + !matchesSchemaMultipleOf(scalarValue, schemaNode.multipleOf)) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.multipleOfViolation, localizer, { + displayPath, + value: String(schemaNode.multipleOf) + }) + }); + } + if (supportsLengthConstraints && typeof schemaNode.minLength === "number" && scalarValue.length < schemaNode.minLength) { @@ -824,6 +900,51 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca } } +/** + * Build one schema-aware comparable key for uniqueItems checks. + * + * @param {SchemaNode} schemaNode Schema node. + * @param {YamlNode | undefined} yamlNode YAML node. + * @returns {string} Comparable key. + */ +function buildComparableNodeValue(schemaNode, yamlNode) { + if (!yamlNode) { + return "missing"; + } + + if (schemaNode.type === "object") { + if (yamlNode.kind !== "object") { + return yamlNode.kind; + } + + return Object.keys(schemaNode.properties) + .filter((key) => yamlNode.map.has(key)) + .sort((left, right) => left.localeCompare(right)) + .map((key) => `${key.length}:${key}=${buildComparableNodeValue(schemaNode.properties[key], yamlNode.map.get(key))}`) + .join("|"); + } + + if (schemaNode.type === "array") { + if (yamlNode.kind !== "array") { + return yamlNode.kind; + } + + return `[${yamlNode.items.map((item) => buildComparableNodeValue(schemaNode.items, item)).join(",")}]`; + } + + if (yamlNode.kind !== "scalar") { + return yamlNode.kind; + } + + const scalarValue = unquoteScalar(yamlNode.value); + const normalizedScalar = schemaNode.type === "integer" || schemaNode.type === "number" + ? String(Number(scalarValue)) + : schemaNode.type === "boolean" + ? String(/^true$/iu.test(scalarValue)) + : scalarValue; + return `${schemaNode.type}:${normalizedScalar}`; +} + /** * Format one validation message in either English or Simplified Chinese. * @@ -859,12 +980,16 @@ function localizeValidationMessage(key, localizer, params) { return `属性“${params.displayPath}”长度必须不超过 ${params.value} 个字符。`; case ValidationMessageKeys.minimumViolation: return `属性“${params.displayPath}”必须大于或等于 ${params.value}。`; + case ValidationMessageKeys.multipleOfViolation: + return `属性“${params.displayPath}”必须是 ${params.value} 的整数倍。`; case ValidationMessageKeys.minItemsViolation: return `属性“${params.displayPath}”至少需要包含 ${params.value} 个元素。`; case ValidationMessageKeys.minLengthViolation: return `属性“${params.displayPath}”长度必须至少为 ${params.value} 个字符。`; case ValidationMessageKeys.patternViolation: return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`; + case ValidationMessageKeys.uniqueItemsViolation: + return `属性“${params.displayPath}”与更早的数组元素 ${params.duplicatePath} 重复;该数组要求元素唯一。`; case ValidationMessageKeys.expectedObject: return params.subject; case ValidationMessageKeys.missingRequired: @@ -897,12 +1022,16 @@ function localizeValidationMessage(key, localizer, params) { return `Property '${params.displayPath}' must be at most ${params.value} characters long.`; case ValidationMessageKeys.minimumViolation: return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`; + case ValidationMessageKeys.multipleOfViolation: + return `Property '${params.displayPath}' must be a multiple of ${params.value}.`; case ValidationMessageKeys.minItemsViolation: return `Property '${params.displayPath}' must contain at least ${params.value} items.`; case ValidationMessageKeys.minLengthViolation: return `Property '${params.displayPath}' must be at least ${params.value} characters long.`; case ValidationMessageKeys.patternViolation: return `Property '${params.displayPath}' must match pattern '${params.value}'.`; + case ValidationMessageKeys.uniqueItemsViolation: + return `Property '${params.displayPath}' duplicates earlier array item '${params.duplicatePath}', but uniqueItems is required.`; case ValidationMessageKeys.expectedObject: return params.subject; case ValidationMessageKeys.missingRequired: @@ -1558,6 +1687,7 @@ module.exports = { * defaultValue?: string, * minItems?: number, * maxItems?: number, + * uniqueItems?: boolean, * refTable?: string, * items: SchemaNode * } | { @@ -1570,6 +1700,7 @@ module.exports = { * exclusiveMinimum?: number, * maximum?: number, * exclusiveMaximum?: number, + * multipleOf?: number, * minLength?: number, * maxLength?: number, * pattern?: string, diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 870f01e7..c78c8aa4 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -1574,7 +1574,7 @@ function getScalarArrayValue(yamlNode) { /** * Render human-facing metadata hints for one schema field. * - * @param {{description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata. + * @param {{description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, uniqueItems?: boolean, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata. * @param {boolean} isArrayField Whether the field is an array. * @returns {string} HTML fragment. */ @@ -1614,6 +1614,10 @@ function renderFieldHint(propertySchema, isArrayField) { hints.push(escapeHtml(localizer.t("webview.hint.exclusiveMaximum", {value: propertySchema.exclusiveMaximum}))); } + if (!isArrayField && typeof propertySchema.multipleOf === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.multipleOf", {value: propertySchema.multipleOf}))); + } + if (!isArrayField && typeof propertySchema.minLength === "number") { hints.push(escapeHtml(localizer.t("webview.hint.minLength", {value: propertySchema.minLength}))); } @@ -1634,6 +1638,10 @@ function renderFieldHint(propertySchema, isArrayField) { hints.push(escapeHtml(localizer.t("webview.hint.maxItems", {value: propertySchema.maxItems}))); } + if (isArrayField && propertySchema.uniqueItems === true) { + hints.push(escapeHtml(localizer.t("webview.hint.uniqueItems"))); + } + if (isArrayField && propertySchema.items && typeof propertySchema.items.minimum === "number") { hints.push(escapeHtml(localizer.t("webview.hint.itemMinimum", {value: propertySchema.items.minimum}))); } @@ -1650,6 +1658,10 @@ function renderFieldHint(propertySchema, isArrayField) { hints.push(escapeHtml(localizer.t("webview.hint.itemExclusiveMaximum", {value: propertySchema.items.exclusiveMaximum}))); } + if (isArrayField && propertySchema.items && typeof propertySchema.items.multipleOf === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.itemMultipleOf", {value: propertySchema.items.multipleOf}))); + } + if (isArrayField && propertySchema.items && typeof propertySchema.items.minLength === "number") { hints.push(escapeHtml(localizer.t("webview.hint.itemMinLength", {value: propertySchema.items.minLength}))); } diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js index b73c3848..f58bddf2 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -109,15 +109,18 @@ const enMessages = { "webview.hint.exclusiveMinimum": "Exclusive minimum: {value}", "webview.hint.maximum": "Maximum: {value}", "webview.hint.exclusiveMaximum": "Exclusive maximum: {value}", + "webview.hint.multipleOf": "Multiple of: {value}", "webview.hint.minLength": "Min length: {value}", "webview.hint.maxLength": "Max length: {value}", "webview.hint.pattern": "Pattern: {value}", "webview.hint.minItems": "Min items: {value}", "webview.hint.maxItems": "Max items: {value}", + "webview.hint.uniqueItems": "Items must be unique", "webview.hint.itemMinimum": "Item minimum: {value}", "webview.hint.itemExclusiveMinimum": "Item exclusive minimum: {value}", "webview.hint.itemMaximum": "Item maximum: {value}", "webview.hint.itemExclusiveMaximum": "Item exclusive maximum: {value}", + "webview.hint.itemMultipleOf": "Item multiple of: {value}", "webview.hint.itemMinLength": "Item min length: {value}", "webview.hint.itemMaxLength": "Item max length: {value}", "webview.hint.itemPattern": "Item pattern: {value}", @@ -132,9 +135,11 @@ const enMessages = { [ValidationMessageKeys.maxItemsViolation]: "Property '{displayPath}' must contain at most {value} items.", [ValidationMessageKeys.maxLengthViolation]: "Property '{displayPath}' must be at most {value} characters long.", [ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.", + [ValidationMessageKeys.multipleOfViolation]: "Property '{displayPath}' must be a multiple of {value}.", [ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.", [ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.", [ValidationMessageKeys.patternViolation]: "Property '{displayPath}' must match pattern '{value}'.", + [ValidationMessageKeys.uniqueItemsViolation]: "Property '{displayPath}' duplicates earlier array item '{duplicatePath}', but uniqueItems is required.", [ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.", [ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.", [ValidationMessageKeys.expectedObject]: "{subject} is expected to be an object.", @@ -208,15 +213,18 @@ const zhCnMessages = { "webview.hint.exclusiveMinimum": "开区间最小值:{value}", "webview.hint.maximum": "最大值:{value}", "webview.hint.exclusiveMaximum": "开区间最大值:{value}", + "webview.hint.multipleOf": "倍数约束:{value}", "webview.hint.minLength": "最小长度:{value}", "webview.hint.maxLength": "最大长度:{value}", "webview.hint.pattern": "正则模式:{value}", "webview.hint.minItems": "最少元素数:{value}", "webview.hint.maxItems": "最多元素数:{value}", + "webview.hint.uniqueItems": "元素必须唯一", "webview.hint.itemMinimum": "元素最小值:{value}", "webview.hint.itemExclusiveMinimum": "元素开区间最小值:{value}", "webview.hint.itemMaximum": "元素最大值:{value}", "webview.hint.itemExclusiveMaximum": "元素开区间最大值:{value}", + "webview.hint.itemMultipleOf": "元素倍数约束:{value}", "webview.hint.itemMinLength": "元素最小长度:{value}", "webview.hint.itemMaxLength": "元素最大长度:{value}", "webview.hint.itemPattern": "元素正则模式:{value}", @@ -231,9 +239,11 @@ const zhCnMessages = { [ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。", [ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。", [ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。", + [ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。", [ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。", [ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。", [ValidationMessageKeys.patternViolation]: "属性“{displayPath}”必须匹配正则模式“{value}”。", + [ValidationMessageKeys.uniqueItemsViolation]: "属性“{displayPath}”与更早的数组元素“{duplicatePath}”重复;该数组要求元素唯一。", [ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。", [ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。", [ValidationMessageKeys.expectedObject]: "{subject}", diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js index 2eb991ff..3e315ff9 100644 --- a/tools/gframework-config-tool/src/localizationKeys.js +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -10,10 +10,12 @@ const ValidationMessageKeys = Object.freeze({ maxItemsViolation: "validation.maxItemsViolation", maxLengthViolation: "validation.maxLengthViolation", minimumViolation: "validation.minimumViolation", + multipleOfViolation: "validation.multipleOfViolation", minItemsViolation: "validation.minItemsViolation", minLengthViolation: "validation.minLengthViolation", missingRequired: "validation.missingRequired", patternViolation: "validation.patternViolation", + uniqueItemsViolation: "validation.uniqueItemsViolation", unknownProperty: "validation.unknownProperty" }); diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 0c3ebaab..19020fdf 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -308,6 +308,47 @@ tags: assert.match(diagnostics[1].message, /at most 3 items|最多只能包含 3 个元素/u); }); +test("validateParsedConfig should report multipleOf and uniqueItems violations", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "hp": { + "type": "integer", + "multipleOf": 5 + }, + "phases": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "wave": { "type": "integer" }, + "monsterId": { "type": "string" } + } + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +hp: 12 +phases: + - + wave: 1 + monsterId: slime + - + monsterId: slime + wave: 1 +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 2); + assert.match(diagnostics[0].message, /multiple of 5|5 的整数倍/u); + assert.match(diagnostics[1].message, /phases\[1\]|uniqueItems|元素唯一/u); +}); + test("parseSchemaContent should capture scalar range and length metadata", () => { const schema = parseSchemaContent(` { @@ -378,6 +419,32 @@ test("parseSchemaContent should capture exclusive bounds, pattern, and array ite assert.equal(schema.properties.tags.items.pattern, "^[a-z]+$"); }); +test("parseSchemaContent should capture multipleOf and uniqueItems metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "hp": { + "type": "integer", + "multipleOf": 5 + }, + "dropRates": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "number", + "multipleOf": 0.5 + } + } + } + } + `); + + assert.equal(schema.properties.hp.multipleOf, 5); + assert.equal(schema.properties.dropRates.uniqueItems, true); + assert.equal(schema.properties.dropRates.items.multipleOf, 0.5); +}); + test("parseSchemaContent should reject invalid pattern declarations instead of dropping them", () => { assert.throws( () => parseSchemaContent(` From f5317eda01d2c1106017b620e675c8b25ac8bf87 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:07:29 +0800 Subject: [PATCH 3/8] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 介绍面向静态游戏内容的 AI-First 配表方案 - 详细说明 YAML 配置源文件和 JSON Schema 结构描述功能 - 提供推荐目录结构和 Schema 示例配置指南 - 说明 VS Code 插件提供的配置浏览和编辑功能 - 提供运行时接入模板和强类型查询辅助使用方法 - 说明跨表引用和运行时校验行为规范 - 介绍开发期热重载功能和性能优化建议 - 说明当前限制和独立 Config Studio 评估结论 --- docs/zh-CN/game/config-system.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 5561dccd..7b4389d6 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -12,7 +12,7 @@ - JSON Schema 作为结构描述 - 一对象一文件的目录组织 - 运行时只读查询 -- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems` +- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems`、`uniqueItems` - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 @@ -53,7 +53,8 @@ GameProject/ }, "hp": { "type": "integer", - "default": 10 + "default": 10, + "multipleOf": 5 }, "rarity": { "type": "string", @@ -62,6 +63,7 @@ GameProject/ "dropItems": { "type": "array", "description": "掉落物品表主键。", + "uniqueItems": true, "items": { "type": "string", "enum": ["potion", "slime_gel", "bomb"] @@ -650,9 +652,11 @@ var loader = new YamlConfigLoader("config-root") - 对象数组元素结构不匹配 - 数值字段违反 `minimum` / `maximum` - 数值字段违反 `exclusiveMinimum` / `exclusiveMaximum` +- 数值字段违反 `multipleOf` - 字符串字段违反 `minLength` / `maxLength` - 字符串字段违反 `pattern` - 数组字段违反 `minItems` / `maxItems` +- 数组字段违反 `uniqueItems` - 标量 `enum` 不匹配 - 标量数组元素 `enum` 不匹配 - 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行 @@ -702,9 +706,11 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `enum`:供运行时校验、VS Code 校验和表单枚举选择复用 - `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 +- `multipleOf`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前按运行时与 JS 共用的浮点容差策略判断十进制步进 - `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS 默认分组语义解释,非法模式会在 schema 解析阶段直接报错 - `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用 +- `uniqueItems`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序 这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 @@ -801,7 +807,7 @@ var hotReload = loader.EnableHotReload( - 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 - 对空配置文件提供基于 schema 的示例 YAML 初始化入口 - 对同一配置域内的多份 YAML 文件执行批量字段更新 -- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据 +- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table / multipleOf / uniqueItems` 元数据 当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。 From 16686a0d9746f39dbd4187b2e50890957fe7c8b6 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:23:06 +0800 Subject: [PATCH 4/8] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=92=8C=E9=AA=8C=E8=AF=81=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增游戏内容配置系统完整文档,涵盖 YAML 配置、JSON Schema 结构、目录组织等 - 添加 Schema 示例和 YAML 示例,说明怪物、物品等静态数据配置方式 - 提供推荐接入模板,包括目录结构、csproj 配置和启动代码模板 - 实现官方启动帮助器 GameConfigBootstrap 与 GameConfigModule 集成 - 添加运行时读取模板,提供强类型配置访问入口 - 实现生成查询辅助功能,支持 FindBy* 和 TryFindFirstBy* 查询接口 - 提供 Architecture 推荐接入模板,支持模块化配置管理 - 添加热重载模板,支持开发期配置文件自动刷新 - 实现运行时接入方案,提供只读表形式的配置访问 - 添加运行时校验行为说明,支持跨表引用和数据完整性检查 - 实现开发期热重载功能,支持配置变更自动重载 - 添加生成器接入约定,自动生成配置类型和表包装代码 - 提供 VS Code 工具支持,包括配置浏览、表单编辑和批量更新功能 - 实现配置验证工具,支持 JSON Schema 子集解析和 YAML 校验功能 --- .../Config/YamlConfigLoaderTests.cs | 151 +++ .../Config/YamlConfigSchemaValidator.cs | 886 ++++++++++++------ docs/zh-CN/game/config-system.md | 4 +- .../src/configValidation.js | 72 +- .../test/configValidation.test.js | 129 +++ 5 files changed, 934 insertions(+), 308 deletions(-) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 488c3427..29d57119 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -558,6 +558,47 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证科学计数法数值会按 number 类型被运行时接受。 + /// + [Test] + public async Task LoadAsync_Should_Accept_Scientific_Notation_Number() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + dropRate: 1.5e10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "dropRate"], + "properties": { + "id": { "type": "integer" }, + "dropRate": { "type": "number" } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(table.Get(1).DropRate, Is.EqualTo(1.5e10)); + }); + } + /// /// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。 /// @@ -864,6 +905,68 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证 uniqueItems 的归一化键不会把带分隔符的不同对象值误判为重复项。 + /// + [Test] + public async Task LoadAsync_Should_Accept_Distinct_Object_Items_When_Comparable_Values_Contain_Separators() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + entries: + - + a: "x|1:b=string:yz" + - + a: x + b: yz + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "entries"], + "properties": { + "id": { "type": "integer" }, + "entries": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "a": { "type": "string" }, + "b": { "type": "string" } + } + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable( + "monster", + "monster", + "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(table.Get(1).Entries.Count, Is.EqualTo(2)); + Assert.That(table.Get(1).Entries[0].A, Is.EqualTo("x|1:b=string:yz")); + Assert.That(table.Get(1).Entries[1].A, Is.EqualTo("x")); + Assert.That(table.Get(1).Entries[1].B, Is.EqualTo("yz")); + }); + } + /// /// 验证启用 schema 校验后,未知字段不会再被静默忽略。 /// @@ -1801,6 +1904,22 @@ public class YamlConfigLoaderTests public int Hp { get; set; } } + /// + /// 用于浮点数 schema 校验测试的最小怪物配置类型。 + /// + private sealed class MonsterNumberConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置浮点掉落率。 + /// + public double DropRate { get; set; } + } + /// /// 用于数组 schema 校验测试的最小怪物配置类型。 /// @@ -1880,6 +1999,22 @@ public class YamlConfigLoaderTests public IReadOnlyList Phases { get; set; } = Array.Empty(); } + /// + /// 用于 uniqueItems 比较键碰撞回归测试的最小配置类型。 + /// + private sealed class MonsterComparableEntryArrayConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置待比较对象数组。 + /// + public List Entries { get; set; } = new(); + } + /// /// 表示对象数组中的阶段元素。 /// @@ -1896,6 +2031,22 @@ public class YamlConfigLoaderTests public string MonsterId { get; set; } = string.Empty; } + /// + /// 表示用于比较键碰撞回归测试的对象数组元素。 + /// + private sealed class ComparableEntryConfigStub + { + /// + /// 获取或设置字段 A。 + /// + public string A { get; set; } = string.Empty; + + /// + /// 获取或设置字段 B。 + /// + public string B { get; set; } = string.Empty; + } + /// /// 用于深层跨表引用测试的怪物配置类型。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index c1f85a3e..d8c09f9f 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -110,7 +110,7 @@ internal static class YamlConfigSchemaValidator string yamlPath, string yamlText) { - ValidateAndCollectReferences(tableName, schema, yamlPath, yamlText); + ValidateCore(tableName, schema, yamlPath, yamlText, references: null); } /// @@ -129,6 +129,29 @@ internal static class YamlConfigSchemaValidator YamlConfigSchema schema, string yamlPath, string yamlText) + { + var references = new List(); + ValidateCore(tableName, schema, yamlPath, yamlText, references); + return references; + } + + /// + /// 执行共享的 YAML 结构校验流程,并按需收集跨表引用。 + /// 这样 可以复用同一条校验链路,同时避免为“不关心引用结果”的调用方分配临时列表。 + /// + /// 所属配置表名称。 + /// 已解析的 schema 模型。 + /// YAML 文件路径,仅用于诊断信息。 + /// YAML 文本内容。 + /// 可选的跨表引用收集器;为 时只做结构校验。 + /// 当参数为空时抛出。 + /// 当 YAML 内容与 schema 不匹配时抛出。 + private static void ValidateCore( + string tableName, + YamlConfigSchema schema, + string yamlPath, + string yamlText, + ICollection? references) { if (string.IsNullOrWhiteSpace(tableName)) { @@ -166,9 +189,7 @@ internal static class YamlConfigSchemaValidator schemaPath: schema.SchemaPath); } - var references = new List(); ValidateNode(tableName, yamlPath, string.Empty, yamlStream.Documents[0].RootNode, schema.RootNode, references); - return references; } /// @@ -296,16 +317,7 @@ internal static class YamlConfigSchemaValidator property.Value); } - return new YamlConfigSchemaNode( - YamlConfigSchemaPropertyType.Object, - properties, - requiredProperties, - itemNode: null, - referenceTableName: null, - allowedValues: null, - constraints: null, - arrayConstraints: null, - schemaPath); + return YamlConfigSchemaNode.CreateObject(properties, requiredProperties, schemaPath); } /// @@ -365,15 +377,9 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(propertyPath)); } - return new YamlConfigSchemaNode( - YamlConfigSchemaPropertyType.Array, - properties: null, - requiredProperties: null, + return YamlConfigSchemaNode.CreateArray( itemNode, - referenceTableName: null, - allowedValues: null, - constraints: null, - arrayConstraints: ParseArrayConstraints(tableName, schemaPath, propertyPath, element), + ParseArrayConstraints(tableName, schemaPath, propertyPath, element), schemaPath); } @@ -396,15 +402,11 @@ internal static class YamlConfigSchemaValidator string? referenceTableName) { EnsureReferenceKeywordIsSupported(tableName, schemaPath, propertyPath, nodeType, referenceTableName); - return new YamlConfigSchemaNode( + return YamlConfigSchemaNode.CreateScalar( nodeType, - properties: null, - requiredProperties: null, - itemNode: null, referenceTableName, ParseEnumValues(tableName, schemaPath, propertyPath, element, nodeType, "enum"), ParseScalarConstraints(tableName, schemaPath, propertyPath, element, nodeType), - arrayConstraints: null, schemaPath); } @@ -424,7 +426,7 @@ internal static class YamlConfigSchemaValidator string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, - ICollection references) + ICollection? references) { switch (schemaNode.NodeType) { @@ -470,7 +472,7 @@ internal static class YamlConfigSchemaValidator string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, - ICollection references) + ICollection? references) { if (node is not YamlMappingNode mappingNode) { @@ -566,7 +568,7 @@ internal static class YamlConfigSchemaValidator string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, - ICollection references) + ICollection? references) { if (node is not YamlSequenceNode sequenceNode) { @@ -624,7 +626,7 @@ internal static class YamlConfigSchemaValidator string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, - ICollection references) + ICollection? references) { if (node is not YamlScalarNode scalarNode) { @@ -699,7 +701,8 @@ internal static class YamlConfigSchemaValidator ValidateScalarConstraints(tableName, yamlPath, displayPath, value, normalizedValue, schemaNode); } - if (schemaNode.ReferenceTableName != null) + if (schemaNode.ReferenceTableName != null && + references is not null) { references.Add( new YamlConfigReferenceUsage( @@ -814,32 +817,20 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(propertyPath)); } - if (!minimum.HasValue && - !maximum.HasValue && - !exclusiveMinimum.HasValue && - !exclusiveMaximum.HasValue && - !multipleOf.HasValue && - !minLength.HasValue && - !maxLength.HasValue && - pattern is null) - { - return null; - } - - return new YamlConfigScalarConstraints( + var numericConstraints = CreateNumericScalarConstraints( minimum, maximum, exclusiveMinimum, exclusiveMaximum, - multipleOf, + multipleOf); + var stringConstraints = CreateStringScalarConstraints( minLength, maxLength, - pattern, - pattern is null - ? null - : new Regex( - pattern, - SupportedPatternRegexOptions)); + pattern); + + return numericConstraints is null && stringConstraints is null + ? null + : new YamlConfigScalarConstraints(numericConstraints, stringConstraints); } /// @@ -1242,137 +1233,24 @@ internal static class YamlConfigSchemaValidator { case YamlConfigSchemaPropertyType.Integer: case YamlConfigSchemaPropertyType.Number: - if (!double.TryParse( - normalizedValue, - NumberStyles.Float | NumberStyles.AllowThousands, - CultureInfo.InvariantCulture, - out var numericValue)) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.UnexpectedFailure, - tableName, - $"Property '{displayPath}' in config file '{yamlPath}' could not be normalized into a comparable numeric value.", - yamlPath: yamlPath, - schemaPath: schemaNode.SchemaPathHint, - displayPath: GetDiagnosticPath(displayPath), - rawValue: rawValue); - } - - if (constraints.Minimum.HasValue && numericValue < constraints.Minimum.Value) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.ConstraintViolation, - tableName, - $"Property '{displayPath}' in config file '{yamlPath}' must be greater than or equal to {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", - yamlPath: yamlPath, - schemaPath: schemaNode.SchemaPathHint, - displayPath: GetDiagnosticPath(displayPath), - rawValue: rawValue, - detail: - $"Minimum allowed value: {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}."); - } - - if (constraints.ExclusiveMinimum.HasValue && numericValue <= constraints.ExclusiveMinimum.Value) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.ConstraintViolation, - tableName, - $"Property '{displayPath}' in config file '{yamlPath}' must be greater than {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", - yamlPath: yamlPath, - schemaPath: schemaNode.SchemaPathHint, - displayPath: GetDiagnosticPath(displayPath), - rawValue: rawValue, - detail: - $"Exclusive minimum allowed value: {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}."); - } - - if (constraints.Maximum.HasValue && numericValue > constraints.Maximum.Value) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.ConstraintViolation, - tableName, - $"Property '{displayPath}' in config file '{yamlPath}' must be less than or equal to {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", - yamlPath: yamlPath, - schemaPath: schemaNode.SchemaPathHint, - displayPath: GetDiagnosticPath(displayPath), - rawValue: rawValue, - detail: - $"Maximum allowed value: {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}."); - } - - if (constraints.ExclusiveMaximum.HasValue && numericValue >= constraints.ExclusiveMaximum.Value) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.ConstraintViolation, - tableName, - $"Property '{displayPath}' in config file '{yamlPath}' must be less than {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", - yamlPath: yamlPath, - schemaPath: schemaNode.SchemaPathHint, - displayPath: GetDiagnosticPath(displayPath), - rawValue: rawValue, - detail: - $"Exclusive maximum allowed value: {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}."); - } - - if (constraints.MultipleOf.HasValue && - !IsMultipleOf(numericValue, constraints.MultipleOf.Value)) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.ConstraintViolation, - tableName, - $"Property '{displayPath}' in config file '{yamlPath}' must be a multiple of {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", - yamlPath: yamlPath, - schemaPath: schemaNode.SchemaPathHint, - displayPath: GetDiagnosticPath(displayPath), - rawValue: rawValue, - detail: $"Required numeric step: {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}."); - } - + ValidateNumericScalarConstraints( + tableName, + yamlPath, + displayPath, + rawValue, + normalizedValue, + schemaNode, + constraints.NumericConstraints); return; case YamlConfigSchemaPropertyType.String: - var stringLength = rawValue.Length; - - if (constraints.MinLength.HasValue && stringLength < constraints.MinLength.Value) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.ConstraintViolation, - tableName, - $"Property '{displayPath}' in config file '{yamlPath}' must be at least {constraints.MinLength.Value} characters long, but the current YAML scalar value is '{rawValue}'.", - yamlPath: yamlPath, - schemaPath: schemaNode.SchemaPathHint, - displayPath: GetDiagnosticPath(displayPath), - rawValue: rawValue, - detail: $"Minimum length: {constraints.MinLength.Value}."); - } - - if (constraints.MaxLength.HasValue && stringLength > constraints.MaxLength.Value) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.ConstraintViolation, - tableName, - $"Property '{displayPath}' in config file '{yamlPath}' must be at most {constraints.MaxLength.Value} characters long, but the current YAML scalar value is '{rawValue}'.", - yamlPath: yamlPath, - schemaPath: schemaNode.SchemaPathHint, - displayPath: GetDiagnosticPath(displayPath), - rawValue: rawValue, - detail: $"Maximum length: {constraints.MaxLength.Value}."); - } - - if (constraints.PatternRegex is not null && - !constraints.PatternRegex.IsMatch(rawValue)) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.ConstraintViolation, - tableName, - $"Property '{displayPath}' in config file '{yamlPath}' must match regular expression '{constraints.Pattern}', but the current YAML scalar value is '{rawValue}'.", - yamlPath: yamlPath, - schemaPath: schemaNode.SchemaPathHint, - displayPath: GetDiagnosticPath(displayPath), - rawValue: rawValue, - detail: $"Expected pattern: {constraints.Pattern}."); - } - + ValidateStringScalarConstraints( + tableName, + yamlPath, + displayPath, + rawValue, + schemaNode, + constraints.StringConstraints); return; default: @@ -1387,6 +1265,265 @@ internal static class YamlConfigSchemaValidator } } + /// + /// 根据已读取的数值关键字创建数值约束对象。 + /// 该分组让调用方不必再维护一个超过 Sonar 默认阈值的长参数构造函数。 + /// + /// 最小值约束。 + /// 最大值约束。 + /// 开区间最小值约束。 + /// 开区间最大值约束。 + /// 数值步进约束。 + /// 数值约束对象;未声明任何数值约束时返回空。 + private static YamlConfigNumericConstraints? CreateNumericScalarConstraints( + double? minimum, + double? maximum, + double? exclusiveMinimum, + double? exclusiveMaximum, + double? multipleOf) + { + return !minimum.HasValue && + !maximum.HasValue && + !exclusiveMinimum.HasValue && + !exclusiveMaximum.HasValue && + !multipleOf.HasValue + ? null + : new YamlConfigNumericConstraints( + minimum, + maximum, + exclusiveMinimum, + exclusiveMaximum, + multipleOf); + } + + /// + /// 根据已读取的字符串关键字创建字符串约束对象。 + /// 正则会在 schema 解析阶段预编译,避免每次校验都重复实例化。 + /// + /// 最小长度约束。 + /// 最大长度约束。 + /// 正则模式约束。 + /// 字符串约束对象;未声明任何字符串约束时返回空。 + private static YamlConfigStringConstraints? CreateStringScalarConstraints( + int? minLength, + int? maxLength, + string? pattern) + { + return !minLength.HasValue && + !maxLength.HasValue && + pattern is null + ? null + : new YamlConfigStringConstraints( + minLength, + maxLength, + pattern, + pattern is null + ? null + : new Regex( + pattern, + SupportedPatternRegexOptions)); + } + + /// + /// 校验数值标量的区间与步进约束。 + /// 该方法把解析失败、闭区间、开区间和步进诊断集中到数值路径,避免主调度方法继续增长。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 字段路径。 + /// 原始 YAML 标量值。 + /// 归一化后的比较值。 + /// 标量 schema 节点。 + /// 数值约束对象。 + private static void ValidateNumericScalarConstraints( + string tableName, + string yamlPath, + string displayPath, + string rawValue, + string normalizedValue, + YamlConfigSchemaNode schemaNode, + YamlConfigNumericConstraints? constraints) + { + if (constraints is null) + { + return; + } + + var numericValue = ParseComparableNumericValue( + tableName, + yamlPath, + displayPath, + rawValue, + normalizedValue, + schemaNode); + if (constraints.Minimum.HasValue && numericValue < constraints.Minimum.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be greater than or equal to {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Minimum allowed value: {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}."); + } + + if (constraints.ExclusiveMinimum.HasValue && numericValue <= constraints.ExclusiveMinimum.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be greater than {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Exclusive minimum allowed value: {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}."); + } + + if (constraints.Maximum.HasValue && numericValue > constraints.Maximum.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be less than or equal to {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Maximum allowed value: {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}."); + } + + if (constraints.ExclusiveMaximum.HasValue && numericValue >= constraints.ExclusiveMaximum.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be less than {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Exclusive maximum allowed value: {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}."); + } + + if (constraints.MultipleOf.HasValue && + !IsMultipleOf(numericValue, constraints.MultipleOf.Value)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be a multiple of {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Required numeric step: {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}."); + } + } + + /// + /// 将归一化后的数值文本还原为双精度值,用于统一后续区间比较。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 字段路径。 + /// 原始 YAML 标量值。 + /// 归一化后的比较值。 + /// 标量 schema 节点。 + /// 可比较的双精度值。 + private static double ParseComparableNumericValue( + string tableName, + string yamlPath, + string displayPath, + string rawValue, + string normalizedValue, + YamlConfigSchemaNode schemaNode) + { + if (double.TryParse( + normalizedValue, + NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, + out var numericValue)) + { + return numericValue; + } + + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.UnexpectedFailure, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' could not be normalized into a comparable numeric value.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue); + } + + /// + /// 校验字符串标量的长度与模式约束。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 字段路径。 + /// 原始 YAML 标量值。 + /// 标量 schema 节点。 + /// 字符串约束对象。 + private static void ValidateStringScalarConstraints( + string tableName, + string yamlPath, + string displayPath, + string rawValue, + YamlConfigSchemaNode schemaNode, + YamlConfigStringConstraints? constraints) + { + if (constraints is null) + { + return; + } + + var stringLength = rawValue.Length; + if (constraints.MinLength.HasValue && stringLength < constraints.MinLength.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be at least {constraints.MinLength.Value} characters long, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Minimum length: {constraints.MinLength.Value}."); + } + + if (constraints.MaxLength.HasValue && stringLength > constraints.MaxLength.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be at most {constraints.MaxLength.Value} characters long, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Maximum length: {constraints.MaxLength.Value}."); + } + + if (constraints.PatternRegex is not null && + !constraints.PatternRegex.IsMatch(rawValue)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must match regular expression '{constraints.Pattern}', but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Expected pattern: {constraints.Pattern}."); + } + } + /// /// 校验数组值是否满足元素数量约束。 /// @@ -1492,67 +1629,101 @@ internal static class YamlConfigSchemaValidator /// 可稳定比较的归一化键。 private static string BuildComparableNodeValue(YamlNode node, YamlConfigSchemaNode schemaNode) { - switch (schemaNode.NodeType) + return schemaNode.NodeType switch { - case YamlConfigSchemaPropertyType.Object: - if (node is not YamlMappingNode mappingNode) - { - throw new InvalidOperationException("Validated object nodes must be YAML mappings."); - } + YamlConfigSchemaPropertyType.Object => BuildComparableObjectValue(node, schemaNode), + YamlConfigSchemaPropertyType.Array => BuildComparableArrayValue(node, schemaNode), + YamlConfigSchemaPropertyType.Integer => BuildComparableScalarValue(node, schemaNode), + YamlConfigSchemaPropertyType.Number => BuildComparableScalarValue(node, schemaNode), + YamlConfigSchemaPropertyType.Boolean => BuildComparableScalarValue(node, schemaNode), + YamlConfigSchemaPropertyType.String => BuildComparableScalarValue(node, schemaNode), + _ => throw new InvalidOperationException($"Unsupported schema node type '{schemaNode.NodeType}'.") + }; + } - var objectEntries = new List>(mappingNode.Children.Count); - foreach (var entry in mappingNode.Children) - { - if (entry.Key is not YamlScalarNode keyNode || - keyNode.Value is null || - schemaNode.Properties is null || - !schemaNode.Properties.TryGetValue(keyNode.Value, out var propertySchema)) - { - throw new InvalidOperationException("Validated object nodes must use declared scalar property names."); - } - - objectEntries.Add( - new KeyValuePair( - keyNode.Value, - BuildComparableNodeValue(entry.Value, propertySchema))); - } - - objectEntries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); - return string.Join( - "|", - objectEntries.Select(static entry => - $"{entry.Key.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Key}={entry.Value.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Value}")); - - case YamlConfigSchemaPropertyType.Array: - if (node is not YamlSequenceNode sequenceNode || - schemaNode.ItemNode is null) - { - throw new InvalidOperationException("Validated array nodes must be YAML sequences with item schema."); - } - - return "[" + - string.Join( - ",", - sequenceNode.Children.Select( - item => BuildComparableNodeValue(item, schemaNode.ItemNode))) + - "]"; - - case YamlConfigSchemaPropertyType.Integer: - case YamlConfigSchemaPropertyType.Number: - case YamlConfigSchemaPropertyType.Boolean: - case YamlConfigSchemaPropertyType.String: - if (node is not YamlScalarNode scalarNode || - scalarNode.Value is null) - { - throw new InvalidOperationException("Validated scalar nodes must be YAML scalars."); - } - - var normalizedScalar = NormalizeScalarValue(schemaNode.NodeType, scalarNode.Value); - return $"{schemaNode.NodeType}:{normalizedScalar.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedScalar}"; - - default: - throw new InvalidOperationException($"Unsupported schema node type '{schemaNode.NodeType}'."); + /// + /// 构建对象节点的可比较键。 + /// 对象字段会先按属性名排序,避免 YAML 原始字段顺序影响 uniqueItems 的等价关系。 + /// + /// YAML 节点。 + /// 对象 schema 节点。 + /// 对象节点的稳定比较键。 + private static string BuildComparableObjectValue(YamlNode node, YamlConfigSchemaNode schemaNode) + { + if (node is not YamlMappingNode mappingNode) + { + throw new InvalidOperationException("Validated object nodes must be YAML mappings."); } + + var properties = schemaNode.Properties + ?? throw new InvalidOperationException("Validated object nodes must expose declared properties."); + var objectEntries = new List>(mappingNode.Children.Count); + foreach (var entry in mappingNode.Children) + { + if (entry.Key is not YamlScalarNode keyNode || + keyNode.Value is null || + !properties.TryGetValue(keyNode.Value, out var propertySchema)) + { + throw new InvalidOperationException("Validated object nodes must use declared scalar property names."); + } + + objectEntries.Add( + new KeyValuePair( + keyNode.Value, + BuildComparableNodeValue(entry.Value, propertySchema))); + } + + objectEntries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); + return string.Join( + "|", + objectEntries.Select(static entry => + $"{entry.Key.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Key}={entry.Value.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Value}")); + } + + /// + /// 构建数组节点的可比较键。 + /// 数组仍保留元素顺序,因为 uniqueItems 只忽略对象字段顺序,不忽略数组顺序。 + /// + /// YAML 节点。 + /// 数组 schema 节点。 + /// 数组节点的稳定比较键。 + private static string BuildComparableArrayValue(YamlNode node, YamlConfigSchemaNode schemaNode) + { + if (node is not YamlSequenceNode sequenceNode || + schemaNode.ItemNode is null) + { + throw new InvalidOperationException("Validated array nodes must be YAML sequences with item schema."); + } + + return "[" + + string.Join( + ",", + sequenceNode.Children.Select( + item => + { + var comparableValue = BuildComparableNodeValue(item, schemaNode.ItemNode); + return $"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}"; + })) + + "]"; + } + + /// + /// 构建标量节点的可比较键。 + /// 标量会沿用与 enum / 引用校验一致的归一化规则,避免数字格式和引号形式导致伪差异。 + /// + /// YAML 节点。 + /// 标量 schema 节点。 + /// 标量节点的稳定比较键。 + private static string BuildComparableScalarValue(YamlNode node, YamlConfigSchemaNode schemaNode) + { + if (node is not YamlScalarNode scalarNode || + scalarNode.Value is null) + { + throw new InvalidOperationException("Validated scalar nodes must be YAML scalars."); + } + + var normalizedScalar = NormalizeScalarValue(schemaNode.NodeType, scalarNode.Value); + return $"{schemaNode.NodeType}:{normalizedScalar.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedScalar}"; } /// @@ -1899,37 +2070,98 @@ internal sealed class YamlConfigSchema /// internal sealed class YamlConfigSchemaNode { + private readonly NodeChildren _children; + private readonly NodeValidation _validation; + /// - /// 初始化一个 schema 节点描述。 + /// 创建对象节点描述。 /// - /// 节点类型。 /// 对象属性集合。 /// 对象必填属性集合。 - /// 数组元素节点。 - /// 目标引用表名称。 - /// 标量允许值集合。 - /// 标量范围与长度约束。 - /// 数组元素数量约束。 /// 用于错误信息的 schema 文件路径提示。 - public YamlConfigSchemaNode( - YamlConfigSchemaPropertyType nodeType, + /// 对象节点模型。 + public static YamlConfigSchemaNode CreateObject( IReadOnlyDictionary? properties, IReadOnlyCollection? requiredProperties, - YamlConfigSchemaNode? itemNode, - string? referenceTableName, - IReadOnlyCollection? allowedValues, - YamlConfigScalarConstraints? constraints, + string schemaPathHint) + { + return new YamlConfigSchemaNode( + YamlConfigSchemaPropertyType.Object, + new NodeChildren(properties, requiredProperties, itemNode: null), + NodeValidation.None, + schemaPathHint); + } + + /// + /// 创建数组节点描述。 + /// + /// 数组元素节点。 + /// 数组元素数量约束。 + /// 用于错误信息的 schema 文件路径提示。 + /// 数组节点模型。 + public static YamlConfigSchemaNode CreateArray( + YamlConfigSchemaNode itemNode, YamlConfigArrayConstraints? arrayConstraints, string schemaPathHint) { + return new YamlConfigSchemaNode( + YamlConfigSchemaPropertyType.Array, + new NodeChildren(properties: null, requiredProperties: null, itemNode), + new NodeValidation( + referenceTableName: null, + allowedValues: null, + constraints: null, + arrayConstraints), + schemaPathHint); + } + + /// + /// 创建标量节点描述。 + /// + /// 标量节点类型。 + /// 目标引用表名称。 + /// 标量允许值集合。 + /// 标量范围与长度约束。 + /// 用于错误信息的 schema 文件路径提示。 + /// 标量节点模型。 + public static YamlConfigSchemaNode CreateScalar( + YamlConfigSchemaPropertyType nodeType, + string? referenceTableName, + IReadOnlyCollection? allowedValues, + YamlConfigScalarConstraints? constraints, + string schemaPathHint) + { + return new YamlConfigSchemaNode( + nodeType, + NodeChildren.None, + new NodeValidation( + referenceTableName, + allowedValues, + constraints, + arrayConstraints: null), + schemaPathHint); + } + + private YamlConfigSchemaNode( + YamlConfigSchemaPropertyType nodeType, + NodeChildren children, + NodeValidation validation, + string schemaPathHint) + { + ArgumentNullException.ThrowIfNull(children); + ArgumentNullException.ThrowIfNull(validation); + ArgumentNullException.ThrowIfNull(schemaPathHint); + + _children = children; + _validation = validation; NodeType = nodeType; - Properties = properties; - RequiredProperties = requiredProperties; - ItemNode = itemNode; - ReferenceTableName = referenceTableName; - AllowedValues = allowedValues; - Constraints = constraints; - ArrayConstraints = arrayConstraints; + Properties = children.Properties; + RequiredProperties = children.RequiredProperties; + ItemNode = children.ItemNode; + ReferenceTableName = validation.ReferenceTableName; + AllowedValues = validation.AllowedValues; + Constraints = validation.Constraints; + ArrayConstraints = validation.ArrayConstraints; SchemaPathHint = schemaPathHint; } @@ -1989,55 +2221,123 @@ internal sealed class YamlConfigSchemaNode { return new YamlConfigSchemaNode( NodeType, - Properties, - RequiredProperties, - ItemNode, - referenceTableName, - AllowedValues, - Constraints, - ArrayConstraints, + _children, + _validation.WithReferenceTable(referenceTableName), SchemaPathHint); } + + private sealed class NodeChildren + { + public static NodeChildren None { get; } = new(properties: null, requiredProperties: null, itemNode: null); + + public NodeChildren( + IReadOnlyDictionary? properties, + IReadOnlyCollection? requiredProperties, + YamlConfigSchemaNode? itemNode) + { + Properties = properties; + RequiredProperties = requiredProperties; + ItemNode = itemNode; + } + + public IReadOnlyDictionary? Properties { get; } + + public IReadOnlyCollection? RequiredProperties { get; } + + public YamlConfigSchemaNode? ItemNode { get; } + } + + private sealed class NodeValidation + { + public static NodeValidation None { get; } = new( + referenceTableName: null, + allowedValues: null, + constraints: null, + arrayConstraints: null); + + public NodeValidation( + string? referenceTableName, + IReadOnlyCollection? allowedValues, + YamlConfigScalarConstraints? constraints, + YamlConfigArrayConstraints? arrayConstraints) + { + ReferenceTableName = referenceTableName; + AllowedValues = allowedValues; + Constraints = constraints; + ArrayConstraints = arrayConstraints; + } + + public string? ReferenceTableName { get; } + + public IReadOnlyCollection? AllowedValues { get; } + + public YamlConfigScalarConstraints? Constraints { get; } + + public YamlConfigArrayConstraints? ArrayConstraints { get; } + + public NodeValidation WithReferenceTable(string referenceTableName) + { + return new NodeValidation(referenceTableName, AllowedValues, Constraints, ArrayConstraints); + } + } } /// -/// 表示一个标量节点上声明的数值范围、步进或字符串长度约束。 -/// 该模型让运行时、热重载和跨文件诊断都能复用同一份最小约束信息。 +/// 聚合一个标量节点上声明的数值约束与字符串约束。 +/// 该包装层保留“标量字段有约束”的统一入口,同时把不同语义的约束分成更小的专用模型。 /// internal sealed class YamlConfigScalarConstraints { /// /// 初始化标量约束模型。 /// + /// 数值约束分组。 + /// 字符串约束分组。 + public YamlConfigScalarConstraints( + YamlConfigNumericConstraints? numericConstraints, + YamlConfigStringConstraints? stringConstraints) + { + NumericConstraints = numericConstraints; + StringConstraints = stringConstraints; + } + + /// + /// 获取数值约束分组。 + /// + public YamlConfigNumericConstraints? NumericConstraints { get; } + + /// + /// 获取字符串约束分组。 + /// + public YamlConfigStringConstraints? StringConstraints { get; } +} + +/// +/// 表示标量节点上声明的数值范围与步进约束。 +/// 该类型只覆盖整数 / 浮点共享的关键字,避免字符串字段继续暴露不相关的成员。 +/// +internal sealed class YamlConfigNumericConstraints +{ + /// + /// 初始化数值约束模型。 + /// /// 最小值约束。 /// 最大值约束。 /// 开区间最小值约束。 /// 开区间最大值约束。 /// 数值步进约束。 - /// 最小长度约束。 - /// 最大长度约束。 - /// 正则模式约束。 - /// 已编译的正则表达式。 - public YamlConfigScalarConstraints( + public YamlConfigNumericConstraints( double? minimum, double? maximum, double? exclusiveMinimum, double? exclusiveMaximum, - double? multipleOf, - int? minLength, - int? maxLength, - string? pattern, - Regex? patternRegex) + double? multipleOf) { Minimum = minimum; Maximum = maximum; ExclusiveMinimum = exclusiveMinimum; ExclusiveMaximum = exclusiveMaximum; MultipleOf = multipleOf; - MinLength = minLength; - MaxLength = maxLength; - Pattern = pattern; - PatternRegex = patternRegex; } /// @@ -2064,6 +2364,32 @@ internal sealed class YamlConfigScalarConstraints /// 获取数值步进约束。 /// public double? MultipleOf { get; } +} + +/// +/// 表示标量节点上声明的字符串长度与模式约束。 +/// 该模型将正则原文与预编译正则绑定保存,保证诊断内容与运行时匹配逻辑保持一致。 +/// +internal sealed class YamlConfigStringConstraints +{ + /// + /// 初始化字符串约束模型。 + /// + /// 最小长度约束。 + /// 最大长度约束。 + /// 正则模式约束原文。 + /// 已编译的正则表达式。 + public YamlConfigStringConstraints( + int? minLength, + int? maxLength, + string? pattern, + Regex? patternRegex) + { + MinLength = minLength; + MaxLength = maxLength; + Pattern = pattern; + PatternRegex = patternRegex; + } /// /// 获取最小长度约束。 diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 7b4389d6..327f8c27 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -708,7 +708,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `multipleOf`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前按运行时与 JS 共用的浮点容差策略判断十进制步进 - `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 -- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS 默认分组语义解释,非法模式会在 schema 解析阶段直接报错 +- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS Unicode `u` 模式解释,非法模式会在 schema 解析阶段直接报错 - `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用 - `uniqueItems`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序 @@ -807,7 +807,7 @@ var hotReload = loader.EnableHotReload( - 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 - 对空配置文件提供基于 schema 的示例 YAML 初始化入口 - 对同一配置域内的多份 YAML 文件执行批量字段更新 -- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table / multipleOf / uniqueItems` 元数据 +- 在表单入口中显示 `title / description / default / enum / ref-table / multipleOf / uniqueItems` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 25a49b19..d9d7d0a8 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -6,6 +6,10 @@ const { } = require("./configPath"); const {ValidationMessageKeys} = require("./localizationKeys"); +const IntegerScalarPattern = /^[+-]?\d+$/u; +const NumberScalarPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u; +const BooleanScalarPattern = /^(true|false)$/iu; + /** * Parse the repository's minimal config-schema subset into a recursive tree. * The parser intentionally mirrors the same high-level contract used by the @@ -262,11 +266,11 @@ function isScalarCompatible(expectedType, scalarValue) { const value = unquoteScalar(String(scalarValue)); switch (expectedType) { case "integer": - return /^-?\d+$/u.test(value); + return IntegerScalarPattern.test(value); case "number": - return /^-?\d+(?:\.\d+)?$/u.test(value); + return NumberScalarPattern.test(value); case "boolean": - return /^(true|false)$/iu.test(value); + return BooleanScalarPattern.test(value); case "string": return true; default: @@ -407,7 +411,7 @@ function normalizeSchemaBoolean(value) { * @param {unknown} value Raw schema value. * @param {string} displayPath Logical property path used in diagnostics. * @throws {Error} Thrown when the pattern string cannot be compiled. - * @returns {string | undefined} Normalized pattern string. + * @returns {{source: string, regex: RegExp} | undefined} Normalized pattern metadata. */ function normalizeSchemaPattern(value, displayPath) { if (typeof value !== "string") { @@ -415,8 +419,10 @@ function normalizeSchemaPattern(value, displayPath) { } try { - void new RegExp(value); - return value; + return { + source: value, + regex: new RegExp(value, "u") + }; } catch (error) { throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`); } @@ -454,24 +460,18 @@ function formatSchemaDefaultValue(value) { } /** - * Test one scalar value against one schema pattern string. + * Test one scalar value against one compiled schema pattern. * * @param {string} scalarValue Scalar value from YAML. - * @param {string | undefined} pattern Schema pattern string. - * @param {string} displayPath Logical property path used in diagnostics. - * @throws {Error} Thrown when the pattern string cannot be compiled. + * @param {RegExp | undefined} patternRegex Compiled schema pattern. * @returns {boolean} True when the value matches or no pattern is declared. */ -function matchesSchemaPattern(scalarValue, pattern, displayPath) { - if (typeof pattern !== "string") { +function matchesSchemaPattern(scalarValue, patternRegex) { + if (!(patternRegex instanceof RegExp)) { return true; } - try { - return new RegExp(pattern).test(scalarValue); - } catch (error) { - throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`); - } + return patternRegex.test(scalarValue); } /** @@ -500,7 +500,7 @@ function matchesSchemaMultipleOf(scalarValue, multipleOf) { * @returns {string} YAML-ready scalar. */ function formatYamlScalar(value) { - if (/^-?\d+(?:\.\d+)?$/u.test(value) || /^(true|false)$/iu.test(value)) { + if (NumberScalarPattern.test(value) || BooleanScalarPattern.test(value)) { return value; } @@ -536,6 +536,7 @@ function unquoteScalar(value) { function parseSchemaNode(rawNode, displayPath) { const value = rawNode && typeof rawNode === "object" ? rawNode : {}; const type = typeof value.type === "string" ? value.type : "object"; + const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath); const metadata = { title: typeof value.title === "string" ? value.title : undefined, description: typeof value.description === "string" ? value.description : undefined, @@ -547,7 +548,8 @@ function parseSchemaNode(rawNode, displayPath) { multipleOf: normalizeSchemaPositiveNumber(value.multipleOf), minLength: normalizeSchemaNonNegativeInteger(value.minLength), maxLength: normalizeSchemaNonNegativeInteger(value.maxLength), - pattern: normalizeSchemaPattern(value.pattern, displayPath), + pattern: patternMetadata ? patternMetadata.source : undefined, + patternRegex: patternMetadata ? patternMetadata.regex : undefined, minItems: normalizeSchemaNonNegativeInteger(value.minItems), maxItems: normalizeSchemaNonNegativeInteger(value.maxItems), uniqueItems: normalizeSchemaBoolean(value.uniqueItems), @@ -622,6 +624,9 @@ function parseSchemaNode(rawNode, displayPath) { pattern: type === "string" ? metadata.pattern : undefined, + patternRegex: type === "string" + ? metadata.patternRegex + : undefined, enumValues: normalizeSchemaEnumValues(value.enum), refTable: metadata.refTable }; @@ -675,19 +680,27 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) }); } + const comparableItems = []; for (let index = 0; index < yamlNode.items.length; index += 1) { + const diagnosticsBeforeValidation = diagnostics.length; validateNode( schemaNode.items, yamlNode.items[index], joinArrayIndexPath(displayPath, index), diagnostics, localizer); + + // Keep uniqueItems focused on values that are otherwise valid so a + // shape/type error does not also surface as a misleading duplicate. + if (diagnostics.length === diagnosticsBeforeValidation) { + comparableItems.push({index, node: yamlNode.items[index]}); + } } if (schemaNode.uniqueItems === true) { const seenItems = new Map(); - for (let index = 0; index < yamlNode.items.length; index += 1) { - const comparableValue = buildComparableNodeValue(schemaNode.items, yamlNode.items[index]); + for (const {index, node} of comparableItems) { + const comparableValue = buildComparableNodeValue(schemaNode.items, node); if (seenItems.has(comparableValue)) { diagnostics.push({ severity: "error", @@ -696,7 +709,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) duplicatePath: joinArrayIndexPath(displayPath, seenItems.get(comparableValue)) }) }); - break; + continue; } seenItems.set(comparableValue, index); @@ -830,7 +843,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } if (supportsPatternConstraints && - !matchesSchemaPattern(scalarValue, schemaNode.pattern, schemaNode.displayPath)) { + !matchesSchemaPattern(scalarValue, schemaNode.patternRegex)) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, { @@ -920,7 +933,10 @@ function buildComparableNodeValue(schemaNode, yamlNode) { return Object.keys(schemaNode.properties) .filter((key) => yamlNode.map.has(key)) .sort((left, right) => left.localeCompare(right)) - .map((key) => `${key.length}:${key}=${buildComparableNodeValue(schemaNode.properties[key], yamlNode.map.get(key))}`) + .map((key) => { + const valueKey = buildComparableNodeValue(schemaNode.properties[key], yamlNode.map.get(key)); + return `${key.length}:${key}=${valueKey.length}:${valueKey}`; + }) .join("|"); } @@ -929,7 +945,10 @@ function buildComparableNodeValue(schemaNode, yamlNode) { return yamlNode.kind; } - return `[${yamlNode.items.map((item) => buildComparableNodeValue(schemaNode.items, item)).join(",")}]`; + return `[${yamlNode.items.map((item) => { + const valueKey = buildComparableNodeValue(schemaNode.items, item); + return `${valueKey.length}:${valueKey}`; + }).join(",")}]`; } if (yamlNode.kind !== "scalar") { @@ -942,7 +961,7 @@ function buildComparableNodeValue(schemaNode, yamlNode) { : schemaNode.type === "boolean" ? String(/^true$/iu.test(scalarValue)) : scalarValue; - return `${schemaNode.type}:${normalizedScalar}`; + return `${schemaNode.type}:${normalizedScalar.length}:${normalizedScalar}`; } /** @@ -1704,6 +1723,7 @@ module.exports = { * minLength?: number, * maxLength?: number, * pattern?: string, + * patternRegex?: RegExp, * enumValues?: string[], * refTable?: string * }} SchemaNode diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 19020fdf..ea105c9b 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -349,6 +349,135 @@ phases: assert.match(diagnostics[1].message, /phases\[1\]|uniqueItems|元素唯一/u); }); +test("validateParsedConfig should accept scientific-notation numbers", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRate": { + "type": "number" + } + } + } + `); + const yaml = parseTopLevelYaml(` +dropRate: 1.5e10 +`); + + assert.deepEqual(validateParsedConfig(schema, yaml), []); +}); + +test("validateParsedConfig should apply schema patterns with Unicode semantics", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^\\\\p{L}+$" + } + } + } + `); + const yaml = parseTopLevelYaml(` +name: 测试 +`); + + assert.deepEqual(validateParsedConfig(schema, yaml), []); +}); + +test("validateParsedConfig should skip uniqueItems checks for invalid array items", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "values": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "integer" + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +values: + - + id: 1 + - + id: 2 +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 2); + assert.match(diagnostics[0].message, /values\[0\]/u); + assert.match(diagnostics[1].message, /values\[1\]/u); + assert.ok(diagnostics.every((diagnostic) => !/uniqueItems|元素唯一/u.test(diagnostic.message))); +}); + +test("validateParsedConfig should report every uniqueItems duplicate in one pass", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "tags": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +tags: + - alpha + - beta + - alpha + - beta +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 2); + assert.match(diagnostics[0].message, /tags\[2\]/u); + assert.match(diagnostics[1].message, /tags\[3\]/u); +}); + +test("validateParsedConfig should avoid uniqueItems comparable-key collisions for distinct objects", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "entries": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "a": { "type": "string" }, + "b": { "type": "string" } + } + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +entries: + - + a: "x|1:b=string:yz" + - + a: x + b: yz +`); + + assert.deepEqual(validateParsedConfig(schema, yaml), []); +}); + test("parseSchemaContent should capture scalar range and length metadata", () => { const schema = parseSchemaContent(` { From f9f608ad64f77265be8bd62a5b81ef97d7b1e0aa Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:23:17 +0800 Subject: [PATCH 5/8] =?UTF-8?q?docs(agents):=20=E6=B7=BB=E5=8A=A0AI?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E7=BC=96=E7=A0=81=E8=A1=8C=E4=B8=BA=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 定义了环境能力清单和工具选择规则 - 规定了XML文档注释、内联注释和架构级注释要求 - 明确了代码风格包括命名约定、格式化和C#惯例 - 设立了测试覆盖范围、组织结构和验证命令标准 - 制定了安全规则防止输入验证和敏感数据泄露 - 规范了代码文档、任务跟踪和仓库文档更新流程 - 建立了审查标准确保代码质量和完整性 --- AGENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4e39ad22..311042dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,10 @@ All AI agents and contributors must follow these rules when writing, reviewing, - Use `@.ai/environment/tools.raw.yaml` only when you need the full collected facts behind the AI-facing hints. - Prefer the project-relevant tools listed there instead of assuming every installed system tool is fair game. - If the real environment differs from the inventory, use the project-relevant installed tool and report the mismatch. +- When working in WSL against this repository's Windows-backed worktree, prefer Windows Git from WSL (for example + `git.exe`) instead of the Linux `git` binary. +- If a Git command in WSL fails with a worktree-style “not a git repository” path translation error, rerun it with the + Windows Git executable and treat that as the repository-default Git path for the rest of the task. ## Commenting Rules (MUST) @@ -114,6 +118,15 @@ All generated or modified code MUST include clear and meaningful comments where ### Analyzer and Validation Expectations - The repository uses `Meziantou.Analyzer`; treat analyzer feedback as part of the coding standard. +- Treat SonarQube maintainability rules as part of the coding standard as well, especially cognitive complexity and + oversized parameter list findings. +- When a method approaches analyzer complexity limits, prefer extracting named helper methods by semantic phase + (parsing, normalization, validation, diagnostics) instead of silencing the warning or doing cosmetic reshuffles. +- When a constructor or method exceeds parameter count limits, choose the refactor that matches the shape of the API: + use domain-specific value objects or parameter objects for naturally grouped data, and prefer named factory methods + when the call site is really selecting between different creation modes. +- Do not add suppressions for complexity or parameter-count findings unless the constraint is externally imposed and the + reason is documented in code comments. - Naming must remain compatible with `scripts/validate-csharp-naming.sh`. ## Testing Requirements From ccb6b5ad4283f4218a76103924389cfa41b42e78 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:43:15 +0800 Subject: [PATCH 6/8] =?UTF-8?q?docs(agents):=20=E6=B7=BB=E5=8A=A0AI?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E8=A1=8C=E4=B8=BA=E5=87=86=E5=88=99=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 定义了环境能力清单和工具选择规则 - 规定了XML文档注释、内联注释和架构级注释要求 - 明确了代码风格包括命名规范、格式化和C#约定 - 设立了测试覆盖范围、组织结构和验证命令标准 - 制定了安全输入验证、依赖管理和权限控制规则 - 确立了文档更新、任务跟踪和审查完成标准 - 更新了浮点数比较算法中的容差计算方式 - 优化了数值精度验证逻辑以避免十进制步进误差 --- AGENTS.md | 4 ++++ GFramework.Game/Config/YamlConfigSchemaValidator.cs | 9 +++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 311042dd..cb19f63c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,10 @@ All generated or modified code MUST include clear and meaningful comments where - Keep `using` directives at the top of the file and sort them consistently. - Separate logical blocks with blank lines when it improves readability. - Prefer one primary type per file unless the surrounding project already uses a different local pattern. +- Unless there is a clear and documented reason to keep a file large, keep a single source file under roughly 800-1000 + lines. +- If a file grows beyond that range, contributors MUST stop and check whether responsibilities should be split before + continuing; treating oversized files as the default is considered a design smell. - Keep line length readable. Around 120 characters is the preferred upper bound. ### C# Conventions diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index d8c09f9f..f7dc74c4 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -1745,8 +1745,9 @@ internal static class YamlConfigSchemaValidator /// /// 判断数值是否满足 multipleOf。 - /// 双精度浮点比较会保留一个与商值量级相关的微小容差, - /// 以避免运行时与 JS 工具侧在 0.1 / 0.01 这类十进制步进上出现伪失败。 + /// 双精度浮点比较会保留一个与步进量级相关的微小容差, + /// 以避免运行时与 JS 工具侧在 0.1 / 0.01 这类十进制步进上出现伪失败, + /// 同时避免值越大就无限放宽合法余数范围。 /// /// 当前值。 /// 步进约束。 @@ -1755,8 +1756,8 @@ internal static class YamlConfigSchemaValidator { var quotient = value / divisor; var nearestInteger = Math.Round(quotient); - var tolerance = 1e-9 * Math.Max(1d, Math.Abs(quotient)); - return Math.Abs(quotient - nearestInteger) <= tolerance; + var tolerance = 1e-9 * Math.Max(1d, Math.Abs(divisor)); + return Math.Abs(value - (nearestInteger * divisor)) <= tolerance; } /// From 3ec34298578003945bb7468276d125cf1e99ccfa Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:58:48 +0800 Subject: [PATCH 7/8] =?UTF-8?q?test(config):=20=E6=B7=BB=E5=8A=A0YAML?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 验证YAML文件扫描和注册表写入功能 - 测试带schema校验的配置表注册选项对象支持 - 验证空配置表注册选项的异常处理 - 测试配置目录不存在时的错误抛出 - 验证配置表加载失败时注册表状态回滚 - 测试非法YAML文件的反序列化错误处理 - 验证schema校验对必填字段缺失的检查 - 测试schema校验对类型不匹配的检查 - 验证schema校验对枚举值限制的支持 - 测试数值范围约束的校验功能 - 验证数值exclusive min/max约束 - 测试multipleOf约束校验 - 验证大数值和科学计数法支持 - 测试字符串长度和正则模式约束 - 验证数组元素数量和唯一性约束 - 测试未知字段检测和错误处理 - 验证嵌套对象和数组的递归校验 - 测试跨表引用校验功能 --- .../Config/YamlConfigLoaderTests.cs | 44 +++++++++++++++++++ .../Config/YamlConfigSchemaValidator.cs | 10 ++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 29d57119..a8f45394 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -558,6 +558,50 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证大数值配合十进制步进时,会沿用 JS 工具侧的 multipleOf 容差策略。 + /// + [Test] + public async Task LoadAsync_Should_Accept_Large_Decimal_Number_When_MultipleOf_Matches_Js_Tolerance() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + dropRate: 10000000.2 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "dropRate"], + "properties": { + "id": { "type": "integer" }, + "dropRate": { + "type": "number", + "multipleOf": 0.1 + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(table.Get(1).DropRate, Is.EqualTo(10000000.2d)); + }); + } + /// /// 验证科学计数法数值会按 number 类型被运行时接受。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index f7dc74c4..efeda8fa 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -1745,9 +1745,9 @@ internal static class YamlConfigSchemaValidator /// /// 判断数值是否满足 multipleOf。 - /// 双精度浮点比较会保留一个与步进量级相关的微小容差, - /// 以避免运行时与 JS 工具侧在 0.1 / 0.01 这类十进制步进上出现伪失败, - /// 同时避免值越大就无限放宽合法余数范围。 + /// 双精度浮点比较会在商空间保留一个与商量级相关的微小容差, + /// 以对齐 JS 工具侧对 0.1 / 0.01 这类十进制步进的判定方式, + /// 避免出现“编辑器通过、运行时拒绝”的跨环境漂移。 /// /// 当前值。 /// 步进约束。 @@ -1756,8 +1756,8 @@ internal static class YamlConfigSchemaValidator { var quotient = value / divisor; var nearestInteger = Math.Round(quotient); - var tolerance = 1e-9 * Math.Max(1d, Math.Abs(divisor)); - return Math.Abs(value - (nearestInteger * divisor)) <= tolerance; + var tolerance = 1e-9 * Math.Max(1d, Math.Abs(quotient)); + return Math.Abs(quotient - nearestInteger) <= tolerance; } /// From d263a4360ecd25ae2e447a72f959a7dbda18aacc Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:26:13 +0800 Subject: [PATCH 8/8] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=92=8C=E9=AA=8C=E8=AF=81=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增游戏内容配置系统完整文档,涵盖 YAML 配置、JSON Schema 结构、目录组织等 - 实现运行时只读查询、Source Generator 类型生成、VS Code 插件等功能 - 提供配置浏览、raw 编辑、schema 打开、递归校验和嵌套对象表单入口 - 添加配置系统接入模板,包括 csproj 模板、启动帮助器、运行时读取模板 - 实现热重载功能支持开发期配置文件自动刷新 - 提供完整的 schema 示例和 YAML 示例配置 - 添加跨表引用、索引查询辅助、批量编辑等高级功能支持 - 实现配置验证工具,支持类型校验、约束检查、注释提取等特性 --- .../Config/YamlConfigLoaderTests.cs | 50 ++++++- .../Config/YamlConfigSchemaValidator.cs | 132 +++++++++++++++++- docs/zh-CN/game/config-system.md | 2 +- .../src/configValidation.js | 87 ++++++++++++ .../test/configValidation.test.js | 41 ++++++ 5 files changed, 303 insertions(+), 9 deletions(-) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index a8f45394..1757e22d 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -559,10 +559,10 @@ public class YamlConfigLoaderTests } /// - /// 验证大数值配合十进制步进时,会沿用 JS 工具侧的 multipleOf 容差策略。 + /// 验证大数值配合十进制步进时,会按十进制精确整倍数规则被运行时接受。 /// [Test] - public async Task LoadAsync_Should_Accept_Large_Decimal_Number_When_MultipleOf_Matches_Js_Tolerance() + public async Task LoadAsync_Should_Accept_Large_Decimal_Number_When_MultipleOf_Matches_Exact_Decimal_Step() { CreateConfigFile( "monster/slime.yaml", @@ -602,6 +602,50 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证大数量级但实际不满足 multipleOf 的数值会被运行时拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Large_Number_Is_Not_Actually_MultipleOf() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + dropRate: 1000000000000.4 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "dropRate"], + "properties": { + "id": { "type": "integer" }, + "dropRate": { + "type": "number", + "multipleOf": 1 + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRate")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证科学计数法数值会按 number 类型被运行时接受。 /// @@ -1702,7 +1746,7 @@ public class YamlConfigLoaderTests Assert.That(exception!.ParamName, Is.EqualTo("options")); } - + /// /// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index efeda8fa..7af85af0 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -1,3 +1,4 @@ +using System.Numerics; using System.Text.RegularExpressions; using GFramework.Game.Abstractions.Config; @@ -15,6 +16,9 @@ internal static class YamlConfigSchemaValidator // The runtime intentionally uses the same culture-invariant regex semantics as the // JS tooling so grouping and backreferences behave consistently across environments. private const RegexOptions SupportedPatternRegexOptions = RegexOptions.CultureInvariant; + private static readonly Regex ExactDecimalPattern = new( + @"^(?[+-]?)(?:(?\d+)(?:\.(?\d*))?|\.(?\d+))(?:[eE](?[+-]?\d+))?$", + RegexOptions.CultureInvariant | RegexOptions.Compiled); /// /// 从磁盘加载并解析一个 JSON Schema 文件。 @@ -1409,7 +1413,7 @@ internal static class YamlConfigSchemaValidator } if (constraints.MultipleOf.HasValue && - !IsMultipleOf(numericValue, constraints.MultipleOf.Value)) + !IsMultipleOf(normalizedValue, numericValue, constraints.MultipleOf.Value)) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, @@ -1745,21 +1749,139 @@ internal static class YamlConfigSchemaValidator /// /// 判断数值是否满足 multipleOf。 - /// 双精度浮点比较会在商空间保留一个与商量级相关的微小容差, - /// 以对齐 JS 工具侧对 0.1 / 0.01 这类十进制步进的判定方式, - /// 避免出现“编辑器通过、运行时拒绝”的跨环境漂移。 + /// 优先按十进制字面量做精确整倍数判断, + /// 以同时避免 0.1 / 0.01 这类十进制步进的伪失败和大数量级非整倍数的伪通过; + /// 只有当值超出精确十进制路径时才退回双精度容差比较。 /// + /// 用于数值比较的规范化 YAML 标量文本。 /// 当前值。 /// 步进约束。 /// 是否满足整倍数关系。 - private static bool IsMultipleOf(double value, double divisor) + private static bool IsMultipleOf(string normalizedValue, double value, double divisor) { + if (TryIsExactDecimalMultiple(normalizedValue, divisor, out var exactResult)) + { + return exactResult; + } + var quotient = value / divisor; var nearestInteger = Math.Round(quotient); var tolerance = 1e-9 * Math.Max(1d, Math.Abs(quotient)); return Math.Abs(quotient - nearestInteger) <= tolerance; } + /// + /// 尝试按十进制字面量精确判断 multipleOf。 + /// 该路径直接对齐 YAML / JSON 中常见的有限十进制写法, + /// 避免双精度舍入把明显的非整倍数误判为合法。 + /// + /// 规范化后的 YAML 数值文本。 + /// Schema 声明的步进约束。 + /// 精确路径下的判断结果。 + /// 是否成功进入精确十进制判断路径。 + private static bool TryIsExactDecimalMultiple(string valueText, double divisor, out bool isMultiple) + { + var divisorText = divisor.ToString("R", CultureInfo.InvariantCulture); + if (!TryParseExactDecimal(valueText, out var valueSignificand, out var valueScale) || + !TryParseExactDecimal(divisorText, out var divisorSignificand, out var divisorScale) || + divisorSignificand.IsZero) + { + isMultiple = false; + return false; + } + + var commonScale = Math.Max(valueScale, divisorScale); + var scaledValue = ScaleDecimalSignificand(valueSignificand, valueScale, commonScale); + var scaledDivisor = ScaleDecimalSignificand(divisorSignificand, divisorScale, commonScale); + isMultiple = scaledValue % scaledDivisor == BigInteger.Zero; + return true; + } + + /// + /// 将有限十进制或科学计数法文本拆成“整数有效数字 + 十进制位数”形式。 + /// 这样可以把整倍数判断转成同一尺度下的整数取模,避免浮点误差参与计算。 + /// + /// 待解析的数值文本。 + /// 去掉小数点后的有效数字。 + /// 十进制缩放位数;原值等于 / 10^。 + /// 是否成功解析为有限十进制数。 + private static bool TryParseExactDecimal(string text, out BigInteger significand, out int scale) + { + var match = ExactDecimalPattern.Match(text); + if (!match.Success) + { + significand = BigInteger.Zero; + scale = 0; + return false; + } + + var exponentGroup = match.Groups["exponent"].Value; + var exponent = 0; + if (!string.IsNullOrEmpty(exponentGroup) && + !int.TryParse(exponentGroup, NumberStyles.Integer, CultureInfo.InvariantCulture, out exponent)) + { + significand = BigInteger.Zero; + scale = 0; + return false; + } + + var integerDigits = match.Groups["integer"].Value; + var fractionDigits = match.Groups["fraction"].Success + ? match.Groups["fraction"].Value + : match.Groups["fractionOnly"].Value; + var digits = string.Concat(integerDigits, fractionDigits); + if (digits.Length == 0) + { + digits = "0"; + } + + digits = digits.TrimStart('0'); + if (digits.Length == 0) + { + significand = BigInteger.Zero; + scale = 0; + return true; + } + + scale = checked(fractionDigits.Length - exponent); + if (scale < 0) + { + digits = string.Concat(digits, new string('0', -scale)); + scale = 0; + } + + while (scale > 0 && digits[^1] == '0') + { + digits = digits[..^1]; + scale--; + } + + significand = BigInteger.Parse(digits, CultureInfo.InvariantCulture); + if (match.Groups["sign"].Value == "-") + { + significand = BigInteger.Negate(significand); + } + + return true; + } + + /// + /// 将十进制有效数字放大到目标尺度,便于在同一量纲下执行整数取模。 + /// + /// 原始有效数字。 + /// 当前十进制位数。 + /// 目标十进制位数。 + /// 放大到目标尺度后的有效数字。 + private static BigInteger ScaleDecimalSignificand(BigInteger significand, int currentScale, int targetScale) + { + if (currentScale == targetScale) + { + return significand; + } + + return significand * BigInteger.Pow(10, targetScale - currentScale); + } + /// /// 解析跨表引用目标表名称。 /// diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 327f8c27..e156bfd2 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -706,7 +706,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `enum`:供运行时校验、VS Code 校验和表单枚举选择复用 - `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 -- `multipleOf`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前按运行时与 JS 共用的浮点容差策略判断十进制步进 +- `multipleOf`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前优先按运行时与 JS 共用的十进制精确整倍数判定处理常见十进制步进,并在必要时退回浮点容差兜底 - `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS Unicode `u` 模式解释,非法模式会在 schema 解析阶段直接报错 - `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index d9d7d0a8..39dc7786 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -486,6 +486,11 @@ function matchesSchemaMultipleOf(scalarValue, multipleOf) { return true; } + const exactDecimalResult = tryMatchesExactDecimalMultiple(scalarValue, String(multipleOf)); + if (exactDecimalResult !== null) { + return exactDecimalResult; + } + const numericValue = Number(scalarValue); const quotient = numericValue / multipleOf; const nearestInteger = Math.round(quotient); @@ -493,6 +498,88 @@ function matchesSchemaMultipleOf(scalarValue, multipleOf) { return Math.abs(quotient - nearestInteger) <= tolerance; } +/** + * Try to evaluate one multipleOf constraint using exact decimal arithmetic. + * This keeps common YAML / JSON decimal literals aligned with the runtime and + * avoids large-number false positives that a pure floating-point quotient check can miss. + * + * @param {string} valueText YAML scalar text. + * @param {string} divisorText Schema multipleOf text. + * @returns {boolean | null} Exact result, or null when the inputs cannot be normalized exactly. + */ +function tryMatchesExactDecimalMultiple(valueText, divisorText) { + const valueParts = tryParseExactDecimal(valueText); + const divisorParts = tryParseExactDecimal(divisorText); + if (!valueParts || !divisorParts || divisorParts.significand === 0n) { + return null; + } + + const commonScale = Math.max(valueParts.scale, divisorParts.scale); + const scaledValue = scaleDecimalSignificand(valueParts.significand, valueParts.scale, commonScale); + const scaledDivisor = scaleDecimalSignificand(divisorParts.significand, divisorParts.scale, commonScale); + return scaledValue % scaledDivisor === 0n; +} + +/** + * Normalize a finite decimal literal into an integer significand plus decimal scale. + * The normalized form lets multipleOf checks run as integer modulo instead of floating-point math. + * + * @param {string} text Numeric text to normalize. + * @returns {{significand: bigint, scale: number} | null} Normalized parts, or null for unsupported input. + */ +function tryParseExactDecimal(text) { + const match = /^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/u.exec(String(text).trim()); + if (!match) { + return null; + } + + const exponent = match[5] ? Number.parseInt(match[5], 10) : 0; + if (!Number.isSafeInteger(exponent)) { + return null; + } + + const integerDigits = match[2] ?? ""; + const fractionDigits = match[3] !== undefined ? match[3] : (match[4] ?? ""); + let digits = `${integerDigits}${fractionDigits}`.replace(/^0+/u, ""); + if (digits.length === 0) { + return {significand: 0n, scale: 0}; + } + + let scale = fractionDigits.length - exponent; + if (scale < 0) { + digits += "0".repeat(-scale); + scale = 0; + } + + while (scale > 0 && digits.endsWith("0")) { + digits = digits.slice(0, -1); + scale -= 1; + } + + let significand = BigInt(digits); + if (match[1] === "-") { + significand = -significand; + } + + return {significand, scale}; +} + +/** + * Scale one normalized decimal significand to a larger decimal precision. + * + * @param {bigint} significand Integer significand. + * @param {number} currentScale Current decimal scale. + * @param {number} targetScale Target decimal scale. + * @returns {bigint} Scaled significand. + */ +function scaleDecimalSignificand(significand, currentScale, targetScale) { + if (currentScale === targetScale) { + return significand; + } + + return significand * (10n ** BigInt(targetScale - currentScale)); +} + /** * Format a scalar value for YAML output. * diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index ea105c9b..3b793650 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -349,6 +349,47 @@ phases: assert.match(diagnostics[1].message, /phases\[1\]|uniqueItems|元素唯一/u); }); +test("validateParsedConfig should accept large decimal multiples without floating-point drift", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRate": { + "type": "number", + "multipleOf": 0.1 + } + } + } + `); + const yaml = parseTopLevelYaml(` +dropRate: 10000000.2 +`); + + assert.deepEqual(validateParsedConfig(schema, yaml), []); +}); + +test("validateParsedConfig should reject large numbers that are not actually multiples", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRate": { + "type": "number", + "multipleOf": 1 + } + } + } + `); + const yaml = parseTopLevelYaml(` +dropRate: 1000000000000.4 +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /multiple of 1|1 的整数倍/u); +}); + test("validateParsedConfig should accept scientific-notation numbers", () => { const schema = parseSchemaContent(` {