mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-06 16:16:44 +08:00
docs(config): 添加游戏内容配置系统完整文档
- 新增 CI/CD 工作流配置文件,集成代码质量检查、安全扫描和构建测试 - 详细介绍配置系统架构,包括 YAML 源文件、JSON Schema 结构描述和运行时只读查询 - 提供完整的目录结构推荐和 Schema/JSON 示例配置 - 包含项目接入模板,涵盖 csproj 配置、启动帮助器和运行时读取模板 - 说明运行时校验行为,支持必填字段、类型匹配、数值范围等校验规则 - 介绍开发期热重载功能,支持配置文件变更自动刷新 - 详述生成器接入约定,包括配置类型、表包装和注册辅助生成 - 提供 VS Code 工具使用指南,支持配置浏览、表单编辑和批量操作 - 说明当前系统限制和未来发展规划,明确适用场景
This commit is contained in:
parent
c732285dfb
commit
bba589a853
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@ -123,6 +123,24 @@ jobs:
|
|||||||
# 恢复.NET本地工具
|
# 恢复.NET本地工具
|
||||||
- name: Restore .NET tools
|
- name: Restore .NET tools
|
||||||
run: dotnet tool restore
|
run: dotnet tool restore
|
||||||
|
|
||||||
|
- name: Setup Node.js 20
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.2.15
|
||||||
|
|
||||||
|
- name: Install config tool dependencies
|
||||||
|
working-directory: tools/gframework-config-tool
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Run config tool tests
|
||||||
|
working-directory: tools/gframework-config-tool
|
||||||
|
run: bun run test
|
||||||
|
|
||||||
# 构建项目,使用Release配置且跳过恢复步骤
|
# 构建项目,使用Release配置且跳过恢复步骤
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GFramework.Game.Abstractions.Config;
|
using GFramework.Game.Abstractions.Config;
|
||||||
using GFramework.Game.Config;
|
using GFramework.Game.Config;
|
||||||
@ -126,6 +127,92 @@ public class GameConfigBootstrapTests
|
|||||||
Assert.That(exception!.ParamName, Is.EqualTo("ConfigureLoader"));
|
Assert.That(exception!.ParamName, Is.EqualTo("ConfigureLoader"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证初始化链路进行中时,第二个调用者不会再次进入并发初始化流程。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void InitializeAsync_Should_Reject_Concurrent_Caller_While_Initialization_Is_In_Progress()
|
||||||
|
{
|
||||||
|
CreateMonsterFiles();
|
||||||
|
|
||||||
|
using ManualResetEventSlim initializeEntered = new(false);
|
||||||
|
using ManualResetEventSlim continueInitialization = new(false);
|
||||||
|
using var bootstrap = new GameConfigBootstrap(
|
||||||
|
new GameConfigBootstrapOptions
|
||||||
|
{
|
||||||
|
RootPath = _rootPath,
|
||||||
|
ConfigureLoader = loader =>
|
||||||
|
{
|
||||||
|
initializeEntered.Set();
|
||||||
|
Assert.That(
|
||||||
|
continueInitialization.Wait(TimeSpan.FromSeconds(5)),
|
||||||
|
Is.True,
|
||||||
|
"The first initialization attempt did not resume within the expected timeout.");
|
||||||
|
loader.RegisterAllGeneratedConfigTables(
|
||||||
|
new GeneratedConfigRegistrationOptions
|
||||||
|
{
|
||||||
|
IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var firstInitializeTask = Task.Run(() => bootstrap.InitializeAsync());
|
||||||
|
|
||||||
|
Assert.That(
|
||||||
|
initializeEntered.Wait(TimeSpan.FromSeconds(5)),
|
||||||
|
Is.True,
|
||||||
|
"The first initialization attempt did not reach the guarded lifecycle section.");
|
||||||
|
|
||||||
|
var secondCallerException = Assert.ThrowsAsync<InvalidOperationException>(async () => await bootstrap.InitializeAsync());
|
||||||
|
|
||||||
|
continueInitialization.Set();
|
||||||
|
|
||||||
|
Assert.DoesNotThrowAsync(async () => await firstInitializeTask);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(secondCallerException, Is.Not.Null);
|
||||||
|
Assert.That(secondCallerException!.Message, Does.Contain("only be initialized once"));
|
||||||
|
Assert.That(bootstrap.IsInitialized, Is.True);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证在可选热重载启动失败时,不会提前公开加载器与初始化成功状态。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void InitializeAsync_Should_Not_Publish_State_When_HotReload_Enable_Fails()
|
||||||
|
{
|
||||||
|
CreateMonsterFiles();
|
||||||
|
|
||||||
|
using var bootstrap = new GameConfigBootstrap(
|
||||||
|
new GameConfigBootstrapOptions
|
||||||
|
{
|
||||||
|
RootPath = _rootPath,
|
||||||
|
EnableHotReload = true,
|
||||||
|
HotReloadOptions = new YamlConfigHotReloadOptions
|
||||||
|
{
|
||||||
|
DebounceDelay = TimeSpan.FromMilliseconds(-1)
|
||||||
|
},
|
||||||
|
ConfigureLoader = static loader =>
|
||||||
|
loader.RegisterAllGeneratedConfigTables(
|
||||||
|
new GeneratedConfigRegistrationOptions
|
||||||
|
{
|
||||||
|
IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
var exception = Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () => await bootstrap.InitializeAsync());
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(exception, Is.Not.Null);
|
||||||
|
Assert.That(bootstrap.IsInitialized, Is.False);
|
||||||
|
Assert.That(bootstrap.IsHotReloadEnabled, Is.False);
|
||||||
|
Assert.Throws<InvalidOperationException>(() => _ = bootstrap.Loader);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 创建一个使用生成聚合注册入口的官方启动帮助器。
|
/// 创建一个使用生成聚合注册入口的官方启动帮助器。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -413,10 +413,10 @@ public class YamlConfigLoaderTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证开区间数值边界约束会在运行时被统一拒绝。
|
/// 验证数值命中开区间下界时会按 schema 在运行时被拒绝。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Test]
|
[Test]
|
||||||
public void LoadAsync_Should_Throw_When_Number_Violates_Exclusive_Minimum_Or_Exclusive_Maximum()
|
public void LoadAsync_Should_Throw_When_Number_Violates_Exclusive_Minimum()
|
||||||
{
|
{
|
||||||
CreateConfigFile(
|
CreateConfigFile(
|
||||||
"monster/slime.yaml",
|
"monster/slime.yaml",
|
||||||
@ -461,6 +461,55 @@ public class YamlConfigLoaderTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证数值命中开区间上界时会按 schema 在运行时被拒绝。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void LoadAsync_Should_Throw_When_Number_Violates_Exclusive_Maximum()
|
||||||
|
{
|
||||||
|
CreateConfigFile(
|
||||||
|
"monster/slime.yaml",
|
||||||
|
"""
|
||||||
|
id: 1
|
||||||
|
name: Slime
|
||||||
|
hp: 100
|
||||||
|
""");
|
||||||
|
CreateSchemaFile(
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name", "hp"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"hp": {
|
||||||
|
"type": "integer",
|
||||||
|
"exclusiveMinimum": 10,
|
||||||
|
"exclusiveMaximum": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var loader = new YamlConfigLoader(_rootPath)
|
||||||
|
.RegisterTable<int, MonsterConfigStub>("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("hp"));
|
||||||
|
Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("100"));
|
||||||
|
Assert.That(exception.Message, Does.Contain("less than 100"));
|
||||||
|
Assert.That(registry.Count, Is.EqualTo(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。
|
/// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -560,10 +609,56 @@ public class YamlConfigLoaderTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证数组元素数量约束会在运行时被统一拒绝。
|
/// 验证运行时 schema 校验与 JS 工具对反向引用模式保持一致。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Test]
|
[Test]
|
||||||
public void LoadAsync_Should_Throw_When_Array_Violates_MinItems_Or_MaxItems()
|
public async Task LoadAsync_Should_Accept_Backreference_Pattern_When_Value_Matches()
|
||||||
|
{
|
||||||
|
CreateConfigFile(
|
||||||
|
"monster/slime.yaml",
|
||||||
|
"""
|
||||||
|
id: 1
|
||||||
|
name: aa
|
||||||
|
hp: 10
|
||||||
|
""");
|
||||||
|
CreateSchemaFile(
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name", "hp"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^(a)\\1$"
|
||||||
|
},
|
||||||
|
"hp": { "type": "integer" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var loader = new YamlConfigLoader(_rootPath)
|
||||||
|
.RegisterTable<int, MonsterConfigStub>("monster", "monster", "schemas/monster.schema.json",
|
||||||
|
static config => config.Id);
|
||||||
|
var registry = new ConfigRegistry();
|
||||||
|
|
||||||
|
await loader.LoadAsync(registry);
|
||||||
|
|
||||||
|
var table = registry.GetTable<int, MonsterConfigStub>("monster");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(table.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(table.Get(1).Name, Is.EqualTo("aa"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证数组元素数量命中上界时会在运行时被统一拒绝。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void LoadAsync_Should_Throw_When_Array_Violates_MaxItems()
|
||||||
{
|
{
|
||||||
CreateConfigFile(
|
CreateConfigFile(
|
||||||
"monster/slime.yaml",
|
"monster/slime.yaml",
|
||||||
@ -615,6 +710,58 @@ public class YamlConfigLoaderTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证数组元素数量命中下界时会在运行时被统一拒绝。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void LoadAsync_Should_Throw_When_Array_Violates_MinItems()
|
||||||
|
{
|
||||||
|
CreateConfigFile(
|
||||||
|
"monster/slime.yaml",
|
||||||
|
"""
|
||||||
|
id: 1
|
||||||
|
name: Slime
|
||||||
|
dropRates: []
|
||||||
|
""");
|
||||||
|
CreateSchemaFile(
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name", "dropRates"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"dropRates": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 3,
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var loader = new YamlConfigLoader(_rootPath)
|
||||||
|
.RegisterTable<int, MonsterConfigIntegerArrayStub>("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("dropRates"));
|
||||||
|
Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("0"));
|
||||||
|
Assert.That(exception.Message, Does.Contain("at least 1 items"));
|
||||||
|
Assert.That(registry.Count, Is.EqualTo(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证启用 schema 校验后,未知字段不会再被静默忽略。
|
/// 验证启用 schema 校验后,未知字段不会再被静默忽略。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -8,15 +8,25 @@ namespace GFramework.Game.Config;
|
|||||||
/// 该类型负责把配置注册表、YAML 加载器与开发期热重载句柄收敛到一个长生命周期对象中,
|
/// 该类型负责把配置注册表、YAML 加载器与开发期热重载句柄收敛到一个长生命周期对象中,
|
||||||
/// 让消费者项目可以通过一个稳定入口完成配置启动,而不是在多个脚本里重复拼装运行时细节。
|
/// 让消费者项目可以通过一个稳定入口完成配置启动,而不是在多个脚本里重复拼装运行时细节。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 生命周期转换会串行化执行,因此并发调用只会观察到已经提交完成的加载器与热重载句柄。
|
||||||
|
/// 如果初始化或热重载启动在中途失败,当前实例会保留失败前的稳定状态,而不会公开半初始化对象。
|
||||||
|
/// </remarks>
|
||||||
public sealed class GameConfigBootstrap : IDisposable
|
public sealed class GameConfigBootstrap : IDisposable
|
||||||
{
|
{
|
||||||
private const string ConfigureLoaderCannotBeNullMessage = "ConfigureLoader must be provided.";
|
private const string ConfigureLoaderCannotBeNullMessage = "ConfigureLoader must be provided.";
|
||||||
private const string RootPathCannotBeNullOrWhiteSpaceMessage = "Root path cannot be null or whitespace.";
|
private const string RootPathCannotBeNullOrWhiteSpaceMessage = "Root path cannot be null or whitespace.";
|
||||||
|
|
||||||
|
// All lifecycle transitions share one gate so initialization, hot-reload startup,
|
||||||
|
// stop, and disposal never publish half-finished state to concurrent callers.
|
||||||
|
private readonly object _stateGate = new();
|
||||||
private readonly GameConfigBootstrapOptions _options;
|
private readonly GameConfigBootstrapOptions _options;
|
||||||
private IUnRegister? _hotReload;
|
private IUnRegister? _hotReload;
|
||||||
private YamlConfigLoader? _loader;
|
private YamlConfigLoader? _loader;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
private bool _isInitializing;
|
||||||
|
private bool _isStartingHotReload;
|
||||||
|
private bool _stopHotReloadAfterStart;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 使用指定选项创建配置启动帮助器。
|
/// 使用指定选项创建配置启动帮助器。
|
||||||
@ -35,14 +45,14 @@ public sealed class GameConfigBootstrap : IDisposable
|
|||||||
{
|
{
|
||||||
throw new ArgumentException(
|
throw new ArgumentException(
|
||||||
RootPathCannotBeNullOrWhiteSpaceMessage,
|
RootPathCannotBeNullOrWhiteSpaceMessage,
|
||||||
nameof(options.RootPath));
|
nameof(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.ConfigureLoader == null)
|
if (options.ConfigureLoader == null)
|
||||||
{
|
{
|
||||||
throw new ArgumentException(
|
throw new ArgumentException(
|
||||||
ConfigureLoaderCannotBeNullMessage,
|
ConfigureLoaderCannotBeNullMessage,
|
||||||
nameof(options.ConfigureLoader));
|
nameof(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
_options = options;
|
_options = options;
|
||||||
@ -63,13 +73,34 @@ public sealed class GameConfigBootstrap : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取一个值,指示启动帮助器是否已经成功完成初次加载。
|
/// 获取一个值,指示启动帮助器是否已经成功完成初次加载。
|
||||||
|
/// 该状态只会在完整初始化链路(包括可选热重载启动)成功后才对外可见,
|
||||||
|
/// 避免并发调用观察到半初始化生命周期。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsInitialized => _loader != null;
|
public bool IsInitialized
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_stateGate)
|
||||||
|
{
|
||||||
|
return _loader != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取一个值,指示开发期热重载是否已启用。
|
/// 获取一个值,指示开发期热重载是否已启用。
|
||||||
|
/// 只有当监听句柄已经成功创建并提交到当前生命周期后,该属性才会返回 <see langword="true" />。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsHotReloadEnabled => _hotReload != null;
|
public bool IsHotReloadEnabled
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_stateGate)
|
||||||
|
{
|
||||||
|
return _hotReload != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取当前生效的 YAML 配置加载器。
|
/// 获取当前生效的 YAML 配置加载器。
|
||||||
@ -81,10 +112,13 @@ public sealed class GameConfigBootstrap : IDisposable
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
lock (_stateGate)
|
||||||
|
{
|
||||||
|
ThrowIfDisposedCore();
|
||||||
|
|
||||||
return _loader ?? throw new InvalidOperationException(
|
return _loader ?? throw new InvalidOperationException(
|
||||||
"The config bootstrap has not been initialized yet.");
|
"The config bootstrap has not been initialized yet.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,24 +133,59 @@ public sealed class GameConfigBootstrap : IDisposable
|
|||||||
/// <exception cref="ConfigLoadException">当配置加载失败时抛出。</exception>
|
/// <exception cref="ConfigLoadException">当配置加载失败时抛出。</exception>
|
||||||
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
lock (_stateGate)
|
||||||
|
|
||||||
if (_loader != null)
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
ThrowIfDisposedCore();
|
||||||
"The config bootstrap can only be initialized once per instance.");
|
|
||||||
|
if (_isInitializing || _loader != null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The config bootstrap can only be initialized once per instance.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInitializing = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var loader = new YamlConfigLoader(RootPath);
|
IUnRegister? hotReload = null;
|
||||||
_options.ConfigureLoader!(loader);
|
|
||||||
await loader.LoadAsync(Registry, cancellationToken);
|
|
||||||
|
|
||||||
// 仅在初次加载完全成功后才公开加载器实例,避免上层观察到半初始化状态。
|
try
|
||||||
_loader = loader;
|
|
||||||
|
|
||||||
if (_options.EnableHotReload)
|
|
||||||
{
|
{
|
||||||
StartHotReload(_options.HotReloadOptions);
|
var loader = new YamlConfigLoader(RootPath);
|
||||||
|
_options.ConfigureLoader!(loader);
|
||||||
|
await loader.LoadAsync(Registry, cancellationToken);
|
||||||
|
|
||||||
|
if (_options.EnableHotReload)
|
||||||
|
{
|
||||||
|
hotReload = loader.EnableHotReload(Registry, _options.HotReloadOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_stateGate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ThrowIfDisposedCore();
|
||||||
|
|
||||||
|
// 仅在初次加载与可选热重载都完整成功后才提交结果,
|
||||||
|
// 避免 IsInitialized / Loader 暴露半初始化生命周期。
|
||||||
|
_loader = loader;
|
||||||
|
_hotReload = hotReload;
|
||||||
|
hotReload = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isInitializing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
lock (_stateGate)
|
||||||
|
{
|
||||||
|
_isInitializing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
hotReload?.UnRegister();
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,17 +204,70 @@ public sealed class GameConfigBootstrap : IDisposable
|
|||||||
/// </exception>
|
/// </exception>
|
||||||
public void StartHotReload(YamlConfigHotReloadOptions? options = null)
|
public void StartHotReload(YamlConfigHotReloadOptions? options = null)
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
YamlConfigLoader loader;
|
||||||
|
lock (_stateGate)
|
||||||
var loader = _loader ?? throw new InvalidOperationException(
|
|
||||||
"Hot reload can only be started after the initial config load succeeds.");
|
|
||||||
|
|
||||||
if (_hotReload != null)
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Hot reload is already enabled.");
|
ThrowIfDisposedCore();
|
||||||
|
|
||||||
|
loader = _loader ?? throw new InvalidOperationException(
|
||||||
|
"Hot reload can only be started after the initial config load succeeds.");
|
||||||
|
|
||||||
|
if (_isStartingHotReload || _hotReload != null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Hot reload is already enabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_isStartingHotReload = true;
|
||||||
|
_stopHotReloadAfterStart = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_hotReload = loader.EnableHotReload(Registry, options);
|
IUnRegister? hotReload = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
hotReload = loader.EnableHotReload(Registry, options);
|
||||||
|
|
||||||
|
var shouldStop = false;
|
||||||
|
lock (_stateGate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ThrowIfDisposedCore();
|
||||||
|
|
||||||
|
// Stop/Dispose may arrive while the watcher is being created. In that
|
||||||
|
// case, release the new handle immediately instead of publishing it.
|
||||||
|
if (_stopHotReloadAfterStart)
|
||||||
|
{
|
||||||
|
shouldStop = true;
|
||||||
|
_stopHotReloadAfterStart = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_hotReload = hotReload;
|
||||||
|
hotReload = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isStartingHotReload = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldStop)
|
||||||
|
{
|
||||||
|
hotReload?.UnRegister();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
lock (_stateGate)
|
||||||
|
{
|
||||||
|
_isStartingHotReload = false;
|
||||||
|
_stopHotReloadAfterStart = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
hotReload?.UnRegister();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -154,8 +276,19 @@ public sealed class GameConfigBootstrap : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void StopHotReload()
|
public void StopHotReload()
|
||||||
{
|
{
|
||||||
var hotReload = _hotReload;
|
IUnRegister? hotReload;
|
||||||
_hotReload = null;
|
lock (_stateGate)
|
||||||
|
{
|
||||||
|
if (_isStartingHotReload && _hotReload == null)
|
||||||
|
{
|
||||||
|
_stopHotReloadAfterStart = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hotReload = _hotReload;
|
||||||
|
_hotReload = null;
|
||||||
|
}
|
||||||
|
|
||||||
hotReload?.UnRegister();
|
hotReload?.UnRegister();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,16 +297,29 @@ public sealed class GameConfigBootstrap : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
IUnRegister? hotReload;
|
||||||
|
lock (_stateGate)
|
||||||
{
|
{
|
||||||
return;
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
|
||||||
|
if (_isStartingHotReload && _hotReload == null)
|
||||||
|
{
|
||||||
|
_stopHotReloadAfterStart = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
hotReload = _hotReload;
|
||||||
|
_hotReload = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_disposed = true;
|
hotReload?.UnRegister();
|
||||||
StopHotReload();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ThrowIfDisposed()
|
private void ThrowIfDisposedCore()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -10,6 +10,10 @@ namespace GFramework.Game.Config;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class YamlConfigSchemaValidator
|
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;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 从磁盘加载并解析一个 JSON Schema 文件。
|
/// 从磁盘加载并解析一个 JSON Schema 文件。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -828,7 +832,7 @@ internal static class YamlConfigSchemaValidator
|
|||||||
? null
|
? null
|
||||||
: new Regex(
|
: new Regex(
|
||||||
pattern,
|
pattern,
|
||||||
RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture));
|
SupportedPatternRegexOptions));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -1005,7 +1009,7 @@ internal static class YamlConfigSchemaValidator
|
|||||||
var pattern = patternElement.GetString() ?? string.Empty;
|
var pattern = patternElement.GetString() ?? string.Empty;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_ = new Regex(pattern, RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture);
|
_ = new Regex(pattern, SupportedPatternRegexOptions);
|
||||||
}
|
}
|
||||||
catch (ArgumentException exception)
|
catch (ArgumentException exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -607,7 +607,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
|
|||||||
- `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
|
- `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
|
||||||
- `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
|
- `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
|
||||||
- `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
|
- `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
|
||||||
- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用
|
- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS 默认分组语义解释,非法模式会在 schema 解析阶段直接报错
|
||||||
- `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用
|
- `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用
|
||||||
|
|
||||||
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
|
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
|
||||||
|
|||||||
@ -12,6 +12,7 @@ const {ValidationMessageKeys} = require("./localizationKeys");
|
|||||||
* runtime validator and source generator so tooling diagnostics stay aligned.
|
* runtime validator and source generator so tooling diagnostics stay aligned.
|
||||||
*
|
*
|
||||||
* @param {string} content Raw schema JSON text.
|
* @param {string} content Raw schema JSON text.
|
||||||
|
* @throws {Error} Thrown when the schema declares one unsupported or invalid pattern string.
|
||||||
* @returns {{
|
* @returns {{
|
||||||
* type: "object",
|
* type: "object",
|
||||||
* required: string[],
|
* required: string[],
|
||||||
@ -384,9 +385,11 @@ function normalizeSchemaNonNegativeInteger(value) {
|
|||||||
* compiled by the local tooling runtime.
|
* compiled by the local tooling runtime.
|
||||||
*
|
*
|
||||||
* @param {unknown} value Raw schema 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 {string | undefined} Normalized pattern string.
|
||||||
*/
|
*/
|
||||||
function normalizeSchemaPattern(value) {
|
function normalizeSchemaPattern(value, displayPath) {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -394,8 +397,8 @@ function normalizeSchemaPattern(value) {
|
|||||||
try {
|
try {
|
||||||
void new RegExp(value);
|
void new RegExp(value);
|
||||||
return value;
|
return value;
|
||||||
} catch {
|
} catch (error) {
|
||||||
return undefined;
|
throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -435,17 +438,19 @@ function formatSchemaDefaultValue(value) {
|
|||||||
*
|
*
|
||||||
* @param {string} scalarValue Scalar value from YAML.
|
* @param {string} scalarValue Scalar value from YAML.
|
||||||
* @param {string | undefined} pattern Schema pattern string.
|
* @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.
|
||||||
* @returns {boolean} True when the value matches or no pattern is declared.
|
* @returns {boolean} True when the value matches or no pattern is declared.
|
||||||
*/
|
*/
|
||||||
function matchesSchemaPattern(scalarValue, pattern) {
|
function matchesSchemaPattern(scalarValue, pattern, displayPath) {
|
||||||
if (typeof pattern !== "string") {
|
if (typeof pattern !== "string") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new RegExp(pattern).test(scalarValue);
|
return new RegExp(pattern).test(scalarValue);
|
||||||
} catch {
|
} catch (error) {
|
||||||
return true;
|
throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -502,7 +507,7 @@ function parseSchemaNode(rawNode, displayPath) {
|
|||||||
exclusiveMaximum: normalizeSchemaNumber(value.exclusiveMaximum),
|
exclusiveMaximum: normalizeSchemaNumber(value.exclusiveMaximum),
|
||||||
minLength: normalizeSchemaNonNegativeInteger(value.minLength),
|
minLength: normalizeSchemaNonNegativeInteger(value.minLength),
|
||||||
maxLength: normalizeSchemaNonNegativeInteger(value.maxLength),
|
maxLength: normalizeSchemaNonNegativeInteger(value.maxLength),
|
||||||
pattern: normalizeSchemaPattern(value.pattern),
|
pattern: normalizeSchemaPattern(value.pattern, displayPath),
|
||||||
minItems: normalizeSchemaNonNegativeInteger(value.minItems),
|
minItems: normalizeSchemaNonNegativeInteger(value.minItems),
|
||||||
maxItems: normalizeSchemaNonNegativeInteger(value.maxItems),
|
maxItems: normalizeSchemaNonNegativeInteger(value.maxItems),
|
||||||
refTable: typeof value["x-gframework-ref-table"] === "string"
|
refTable: typeof value["x-gframework-ref-table"] === "string"
|
||||||
@ -749,7 +754,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (supportsPatternConstraints &&
|
if (supportsPatternConstraints &&
|
||||||
!matchesSchemaPattern(scalarValue, schemaNode.pattern)) {
|
!matchesSchemaPattern(scalarValue, schemaNode.pattern, schemaNode.displayPath)) {
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
severity: "error",
|
severity: "error",
|
||||||
message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, {
|
message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, {
|
||||||
|
|||||||
@ -271,6 +271,43 @@ tags:
|
|||||||
assert.match(diagnostics[2].message, /at least 2 items|至少需要包含 2 个元素/u);
|
assert.match(diagnostics[2].message, /at least 2 items|至少需要包含 2 个元素/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("validateParsedConfig should report exclusive maximum and maxItems violations", () => {
|
||||||
|
const schema = parseSchemaContent(`
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"hp": {
|
||||||
|
"type": "integer",
|
||||||
|
"exclusiveMinimum": 10,
|
||||||
|
"exclusiveMaximum": 20
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 3,
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const yaml = parseTopLevelYaml(`
|
||||||
|
hp: 20
|
||||||
|
tags:
|
||||||
|
- a
|
||||||
|
- b
|
||||||
|
- c
|
||||||
|
- d
|
||||||
|
`);
|
||||||
|
|
||||||
|
const diagnostics = validateParsedConfig(schema, yaml);
|
||||||
|
|
||||||
|
assert.equal(diagnostics.length, 2);
|
||||||
|
assert.match(diagnostics[0].message, /less than 20|小于 20/u);
|
||||||
|
assert.match(diagnostics[1].message, /at most 3 items|最多只能包含 3 个元素/u);
|
||||||
|
});
|
||||||
|
|
||||||
test("parseSchemaContent should capture scalar range and length metadata", () => {
|
test("parseSchemaContent should capture scalar range and length metadata", () => {
|
||||||
const schema = parseSchemaContent(`
|
const schema = parseSchemaContent(`
|
||||||
{
|
{
|
||||||
@ -341,6 +378,23 @@ test("parseSchemaContent should capture exclusive bounds, pattern, and array ite
|
|||||||
assert.equal(schema.properties.tags.items.pattern, "^[a-z]+$");
|
assert.equal(schema.properties.tags.items.pattern, "^[a-z]+$");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parseSchemaContent should reject invalid pattern declarations instead of dropping them", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => parseSchemaContent(`
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "["
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
/invalid 'pattern' regular expression/u
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("parseSchemaContent should ignore mismatched constraint metadata on unsupported scalar types", () => {
|
test("parseSchemaContent should ignore mismatched constraint metadata on unsupported scalar types", () => {
|
||||||
const schema = parseSchemaContent(`
|
const schema = parseSchemaContent(`
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user