mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
docs(config): 添加游戏内容配置系统文档和验证工具
- 新增游戏内容配置系统完整文档,涵盖 YAML 配置、JSON Schema 结构、目录组织等 - 实现运行时只读查询、Source Generator 类型生成、VS Code 插件等功能 - 提供配置浏览、raw 编辑、schema 打开、递归校验和嵌套对象表单入口 - 添加配置系统接入模板,包括 csproj 模板、启动帮助器、运行时读取模板 - 实现热重载功能支持开发期配置文件自动刷新 - 提供完整的 schema 示例和 YAML 示例配置 - 添加跨表引用、索引查询辅助、批量编辑等高级功能支持 - 实现配置验证工具,支持类型校验、约束检查、注释提取等特性
This commit is contained in:
parent
3ec3429857
commit
d263a4360e
@ -559,10 +559,10 @@ public class YamlConfigLoaderTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证大数值配合十进制步进时,会沿用 JS 工具侧的 <c>multipleOf</c> 容差策略。
|
||||
/// 验证大数值配合十进制步进时,会按十进制精确整倍数规则被运行时接受。
|
||||
/// </summary>
|
||||
[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
|
||||
});
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 验证科学计数法数值会按 <c>number</c> 类型被运行时接受。
|
||||
/// </summary>
|
||||
|
||||
@ -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(
|
||||
@"^(?<sign>[+-]?)(?:(?<integer>\d+)(?:\.(?<fraction>\d*))?|\.(?<fractionOnly>\d+))(?:[eE](?<exponent>[+-]?\d+))?$",
|
||||
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// 从磁盘加载并解析一个 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
|
||||
|
||||
/// <summary>
|
||||
/// 判断数值是否满足 <c>multipleOf</c>。
|
||||
/// 双精度浮点比较会在商空间保留一个与商量级相关的微小容差,
|
||||
/// 以对齐 JS 工具侧对 0.1 / 0.01 这类十进制步进的判定方式,
|
||||
/// 避免出现“编辑器通过、运行时拒绝”的跨环境漂移。
|
||||
/// 优先按十进制字面量做精确整倍数判断,
|
||||
/// 以同时避免 0.1 / 0.01 这类十进制步进的伪失败和大数量级非整倍数的伪通过;
|
||||
/// 只有当值超出精确十进制路径时才退回双精度容差比较。
|
||||
/// </summary>
|
||||
/// <param name="normalizedValue">用于数值比较的规范化 YAML 标量文本。</param>
|
||||
/// <param name="value">当前值。</param>
|
||||
/// <param name="divisor">步进约束。</param>
|
||||
/// <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 nearestInteger = Math.Round(quotient);
|
||||
var tolerance = 1e-9 * Math.Max(1d, Math.Abs(quotient));
|
||||
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>
|
||||
|
||||
@ -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 文档复用
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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(`
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user