docs(config): 添加游戏内容配置系统文档和验证工具

- 新增游戏内容配置系统完整文档,涵盖 YAML 配置、JSON Schema 结构、目录组织等
- 实现运行时只读查询、Source Generator 类型生成、VS Code 插件等功能
- 提供配置浏览、raw 编辑、schema 打开、递归校验和嵌套对象表单入口
- 添加配置系统接入模板,包括 csproj 模板、启动帮助器、运行时读取模板
- 实现热重载功能支持开发期配置文件自动刷新
- 提供完整的 schema 示例和 YAML 示例配置
- 添加跨表引用、索引查询辅助、批量编辑等高级功能支持
- 实现配置验证工具,支持类型校验、约束检查、注释提取等特性
This commit is contained in:
GeWuYou 2026-04-09 20:26:13 +08:00
parent 3ec3429857
commit d263a4360e
5 changed files with 303 additions and 9 deletions

View File

@ -559,10 +559,10 @@ public class YamlConfigLoaderTests
} }
/// <summary> /// <summary>
/// 验证大数值配合十进制步进时,会沿用 JS 工具侧的 <c>multipleOf</c> 容差策略 /// 验证大数值配合十进制步进时,会按十进制精确整倍数规则被运行时接受
/// </summary> /// </summary>
[Test] [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( CreateConfigFile(
"monster/slime.yaml", "monster/slime.yaml",
@ -602,6 +602,50 @@ public class YamlConfigLoaderTests
}); });
} }
/// <summary>
/// 验证大数量级但实际不满足 <c>multipleOf</c> 的数值会被运行时拒绝。
/// </summary>
[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<int, MonsterNumberConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(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));
});
}
/// <summary> /// <summary>
/// 验证科学计数法数值会按 <c>number</c> 类型被运行时接受。 /// 验证科学计数法数值会按 <c>number</c> 类型被运行时接受。
/// </summary> /// </summary>
@ -1702,7 +1746,7 @@ public class YamlConfigLoaderTests
Assert.That(exception!.ParamName, Is.EqualTo("options")); Assert.That(exception!.ParamName, Is.EqualTo("options"));
} }
/// <summary> /// <summary>
/// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。 /// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。
/// </summary> /// </summary>

View File

@ -1,3 +1,4 @@
using System.Numerics;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using GFramework.Game.Abstractions.Config; 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 // The runtime intentionally uses the same culture-invariant regex semantics as the
// JS tooling so grouping and backreferences behave consistently across environments. // JS tooling so grouping and backreferences behave consistently across environments.
private const RegexOptions SupportedPatternRegexOptions = RegexOptions.CultureInvariant; private const RegexOptions SupportedPatternRegexOptions = RegexOptions.CultureInvariant;
private static readonly Regex ExactDecimalPattern = new(
@"^(?<sign>[+-]?)(?:(?<integer>\d+)(?:\.(?<fraction>\d*))?|\.(?<fractionOnly>\d+))(?:[eE](?<exponent>[+-]?\d+))?$",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
/// <summary> /// <summary>
/// 从磁盘加载并解析一个 JSON Schema 文件。 /// 从磁盘加载并解析一个 JSON Schema 文件。
@ -1409,7 +1413,7 @@ internal static class YamlConfigSchemaValidator
} }
if (constraints.MultipleOf.HasValue && if (constraints.MultipleOf.HasValue &&
!IsMultipleOf(numericValue, constraints.MultipleOf.Value)) !IsMultipleOf(normalizedValue, numericValue, constraints.MultipleOf.Value))
{ {
throw ConfigLoadExceptionFactory.Create( throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation, ConfigLoadFailureKind.ConstraintViolation,
@ -1745,21 +1749,139 @@ internal static class YamlConfigSchemaValidator
/// <summary> /// <summary>
/// 判断数值是否满足 <c>multipleOf</c>。 /// 判断数值是否满足 <c>multipleOf</c>。
/// 双精度浮点比较会在商空间保留一个与商量级相关的微小容差 /// 优先按十进制字面量做精确整倍数判断
/// 以对齐 JS 工具侧对 0.1 / 0.01 这类十进制步进的判定方式, /// 以同时避免 0.1 / 0.01 这类十进制步进的伪失败和大数量级非整倍数的伪通过;
/// 避免出现“编辑器通过、运行时拒绝”的跨环境漂移 /// 只有当值超出精确十进制路径时才退回双精度容差比较
/// </summary> /// </summary>
/// <param name="normalizedValue">用于数值比较的规范化 YAML 标量文本。</param>
/// <param name="value">当前值。</param> /// <param name="value">当前值。</param>
/// <param name="divisor">步进约束。</param> /// <param name="divisor">步进约束。</param>
/// <returns>是否满足整倍数关系。</returns> /// <returns>是否满足整倍数关系。</returns>
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 quotient = value / divisor;
var nearestInteger = Math.Round(quotient); var nearestInteger = Math.Round(quotient);
var tolerance = 1e-9 * Math.Max(1d, Math.Abs(quotient)); var tolerance = 1e-9 * Math.Max(1d, Math.Abs(quotient));
return Math.Abs(quotient - nearestInteger) <= tolerance; return Math.Abs(quotient - nearestInteger) <= tolerance;
} }
/// <summary>
/// 尝试按十进制字面量精确判断 <c>multipleOf</c>。
/// 该路径直接对齐 YAML / JSON 中常见的有限十进制写法,
/// 避免双精度舍入把明显的非整倍数误判为合法。
/// </summary>
/// <param name="valueText">规范化后的 YAML 数值文本。</param>
/// <param name="divisor">Schema 声明的步进约束。</param>
/// <param name="isMultiple">精确路径下的判断结果。</param>
/// <returns>是否成功进入精确十进制判断路径。</returns>
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;
}
/// <summary>
/// 将有限十进制或科学计数法文本拆成“整数有效数字 + 十进制位数”形式。
/// 这样可以把整倍数判断转成同一尺度下的整数取模,避免浮点误差参与计算。
/// </summary>
/// <param name="text">待解析的数值文本。</param>
/// <param name="significand">去掉小数点后的有效数字。</param>
/// <param name="scale">十进制缩放位数;原值等于 <paramref name="significand" /> / 10^<paramref name="scale" />。</param>
/// <returns>是否成功解析为有限十进制数。</returns>
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;
}
/// <summary>
/// 将十进制有效数字放大到目标尺度,便于在同一量纲下执行整数取模。
/// </summary>
/// <param name="significand">原始有效数字。</param>
/// <param name="currentScale">当前十进制位数。</param>
/// <param name="targetScale">目标十进制位数。</param>
/// <returns>放大到目标尺度后的有效数字。</returns>
private static BigInteger ScaleDecimalSignificand(BigInteger significand, int currentScale, int targetScale)
{
if (currentScale == targetScale)
{
return significand;
}
return significand * BigInteger.Pow(10, targetScale - currentScale);
}
/// <summary> /// <summary>
/// 解析跨表引用目标表名称。 /// 解析跨表引用目标表名称。
/// </summary> /// </summary>

View File

@ -706,7 +706,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
- `enum`供运行时校验、VS Code 校验和表单枚举选择复用 - `enum`供运行时校验、VS Code 校验和表单枚举选择复用
- `minimum` / `maximum`供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `minimum` / `maximum`供运行时校验、VS Code 校验和生成代码 XML 文档复用
- `exclusiveMinimum` / `exclusiveMaximum`供运行时校验、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 文档复用 - `minLength` / `maxLength`供运行时校验、VS Code 校验和生成代码 XML 文档复用
- `pattern`供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS Unicode `u` 模式解释,非法模式会在 schema 解析阶段直接报错 - `pattern`供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS Unicode `u` 模式解释,非法模式会在 schema 解析阶段直接报错
- `minItems` / `maxItems`供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用 - `minItems` / `maxItems`供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用

View File

@ -486,6 +486,11 @@ function matchesSchemaMultipleOf(scalarValue, multipleOf) {
return true; return true;
} }
const exactDecimalResult = tryMatchesExactDecimalMultiple(scalarValue, String(multipleOf));
if (exactDecimalResult !== null) {
return exactDecimalResult;
}
const numericValue = Number(scalarValue); const numericValue = Number(scalarValue);
const quotient = numericValue / multipleOf; const quotient = numericValue / multipleOf;
const nearestInteger = Math.round(quotient); const nearestInteger = Math.round(quotient);
@ -493,6 +498,88 @@ function matchesSchemaMultipleOf(scalarValue, multipleOf) {
return Math.abs(quotient - nearestInteger) <= tolerance; 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. * Format a scalar value for YAML output.
* *

View File

@ -349,6 +349,47 @@ phases:
assert.match(diagnostics[1].message, /phases\[1\]|uniqueItems|元素唯一/u); 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", () => { test("validateParsedConfig should accept scientific-notation numbers", () => {
const schema = parseSchemaContent(` const schema = parseSchemaContent(`
{ {