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(`
{