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] =?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(` {