diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bb3fb6b9..70a6b34c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -123,6 +123,24 @@ jobs:
# 恢复.NET本地工具
- name: Restore .NET tools
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配置且跳过恢复步骤
- name: Build
diff --git a/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs
index 510ef01b..d8631e5d 100644
--- a/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs
+++ b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs
@@ -3,6 +3,7 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using GFramework.Core.Architectures;
+using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
using NUnit.Framework;
@@ -10,14 +11,14 @@ using NUnit.Framework;
namespace GFramework.Game.Tests.Config;
///
-/// 验证在 初始化流程中可以通过聚合注册入口加载生成配置表,并通过表访问器读取数据。
+/// 验证在 初始化流程中可以通过官方配置启动帮助器加载生成配置表,并通过表访问器读取数据。
///
[TestFixture]
public class ArchitectureConfigIntegrationTests
{
///
- /// 架构初始化期间,通过 的聚合注册入口注册生成表,
- /// 并将 作为 utility 暴露给架构上下文读取。
+ /// 架构初始化期间,通过 收敛生成表注册、加载与注册表暴露,
+ /// 并将 作为 utility 暴露给架构上下文读取。
///
[Test]
public async Task ConfigLoaderCanRunDuringArchitectureInitialization()
@@ -43,7 +44,7 @@ public class ArchitectureConfigIntegrationTests
Assert.That(retrieved, Is.Not.Null);
Assert.That(retrieved!.Get(1).Name, Is.EqualTo("Slime"));
Assert.That(architecture.Registry.TryGetItemTable(out _), Is.False);
- Assert.That(architecture.Context.GetUtility(), Is.SameAs(architecture.Registry));
+ Assert.That(architecture.Context.GetUtility(), Is.SameAs(architecture.Registry));
});
}
finally
@@ -131,30 +132,43 @@ public class ArchitectureConfigIntegrationTests
private sealed class ConsumerArchitecture : Architecture
{
- private readonly string _configRoot;
+ private readonly GameConfigBootstrap _bootstrap;
- public ConfigRegistry Registry { get; }
+ public IConfigRegistry Registry => _bootstrap.Registry;
public MonsterTable MonsterTable { get; private set; } = null!;
public ConsumerArchitecture(string configRoot)
{
- _configRoot = configRoot ?? throw new ArgumentNullException(nameof(configRoot));
- Registry = new ConfigRegistry();
+ if (configRoot == null)
+ {
+ throw new ArgumentNullException(nameof(configRoot));
+ }
+
+ _bootstrap = new GameConfigBootstrap(
+ new GameConfigBootstrapOptions
+ {
+ RootPath = configRoot,
+ ConfigureLoader = static loader =>
+ loader.RegisterAllGeneratedConfigTables(
+ new GeneratedConfigRegistrationOptions
+ {
+ IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
+ })
+ });
}
protected override void OnInitialize()
{
RegisterUtility(Registry);
-
- var loader = new YamlConfigLoader(_configRoot)
- .RegisterAllGeneratedConfigTables(
- new GeneratedConfigRegistrationOptions
- {
- IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
- });
- loader.LoadAsync(Registry).GetAwaiter().GetResult();
+ _bootstrap.InitializeAsync().GetAwaiter().GetResult();
MonsterTable = Registry.GetMonsterTable();
}
+
+ public override async ValueTask DestroyAsync()
+ {
+ _bootstrap.Dispose();
+ await base.DestroyAsync();
+ }
}
}
diff --git a/GFramework.Game.Tests/Config/GameConfigBootstrapTests.cs b/GFramework.Game.Tests/Config/GameConfigBootstrapTests.cs
new file mode 100644
index 00000000..c6f28484
--- /dev/null
+++ b/GFramework.Game.Tests/Config/GameConfigBootstrapTests.cs
@@ -0,0 +1,322 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using GFramework.Game.Abstractions.Config;
+using GFramework.Game.Config;
+using GFramework.Game.Config.Generated;
+using NUnit.Framework;
+
+namespace GFramework.Game.Tests.Config;
+
+///
+/// 验证官方配置启动帮助器能够收敛注册、加载与热重载生命周期。
+///
+[TestFixture]
+public class GameConfigBootstrapTests
+{
+ ///
+ /// 为每个测试准备独立的临时配置目录。
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.GameConfigBootstrapTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_rootPath);
+ }
+
+ ///
+ /// 清理测试期间创建的临时目录。
+ ///
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_rootPath))
+ {
+ Directory.Delete(_rootPath, true);
+ }
+ }
+
+ private string _rootPath = null!;
+
+ ///
+ /// 验证启动帮助器能够加载生成表,并复用调用方显式提供的注册表实例。
+ ///
+ [Test]
+ public async Task InitializeAsync_Should_Load_Generated_Config_Tables_Into_Registry()
+ {
+ CreateMonsterFiles();
+
+ var registry = new ConfigRegistry();
+ using var bootstrap = CreateBootstrap(registry);
+
+ await bootstrap.InitializeAsync();
+
+ var monsterTable = registry.GetMonsterTable();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(bootstrap.Registry, Is.SameAs(registry));
+ Assert.That(bootstrap.Loader.RegistrationCount, Is.EqualTo(1));
+ Assert.That(bootstrap.IsInitialized, Is.True);
+ Assert.That(bootstrap.IsHotReloadEnabled, Is.False);
+ Assert.That(monsterTable.Get(1).Name, Is.EqualTo("Slime"));
+ Assert.That(monsterTable.Get(2).Hp, Is.EqualTo(30));
+ });
+ }
+
+ ///
+ /// 验证启动帮助器可以在初始化后显式启用热重载,并将刷新结果写回共享注册表。
+ ///
+ [Test]
+ public async Task StartHotReload_Should_Update_Registered_Table_When_Config_File_Changes()
+ {
+ CreateMonsterFiles();
+
+ using var bootstrap = CreateBootstrap();
+ await bootstrap.InitializeAsync();
+
+ var reloadTaskSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ bootstrap.StartHotReload(
+ new YamlConfigHotReloadOptions
+ {
+ OnTableReloaded = tableName => reloadTaskSource.TrySetResult(tableName),
+ DebounceDelay = TimeSpan.FromMilliseconds(150)
+ });
+
+ try
+ {
+ CreateFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 25
+ faction: dungeon
+ """);
+
+ var tableName = await WaitForTaskWithinAsync(reloadTaskSource.Task, TimeSpan.FromSeconds(5));
+ var monsterTable = bootstrap.Registry.GetMonsterTable();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(tableName, Is.EqualTo(MonsterConfigBindings.TableName));
+ Assert.That(bootstrap.IsHotReloadEnabled, Is.True);
+ Assert.That(monsterTable.Get(1).Hp, Is.EqualTo(25));
+ });
+ }
+ finally
+ {
+ bootstrap.StopHotReload();
+ }
+ }
+
+ ///
+ /// 验证缺少加载器配置回调时会在构造阶段被拒绝,避免启动帮助器静默创建空加载流程。
+ ///
+ [Test]
+ public void Constructor_Should_Throw_When_ConfigureLoader_Is_Missing()
+ {
+ var exception = Assert.Throws(() =>
+ _ = new GameConfigBootstrap(
+ new GameConfigBootstrapOptions
+ {
+ RootPath = _rootPath
+ }));
+
+ Assert.That(exception!.ParamName, Is.EqualTo("options"));
+ }
+
+ ///
+ /// 验证初始化链路进行中时,第二个调用者不会再次进入并发初始化流程。
+ ///
+ [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(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);
+ });
+ }
+
+ ///
+ /// 验证在可选热重载启动失败时,不会提前公开加载器与初始化成功状态。
+ ///
+ [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(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(() => _ = bootstrap.Loader);
+ });
+ }
+
+ ///
+ /// 创建一个使用生成聚合注册入口的官方启动帮助器。
+ ///
+ /// 可选的外部注册表;为空时使用默认注册表。
+ /// 已配置但尚未初始化的启动帮助器。
+ private GameConfigBootstrap CreateBootstrap(IConfigRegistry? registry = null)
+ {
+ return new GameConfigBootstrap(
+ new GameConfigBootstrapOptions
+ {
+ RootPath = _rootPath,
+ Registry = registry,
+ ConfigureLoader = static loader =>
+ loader.RegisterAllGeneratedConfigTables(
+ new GeneratedConfigRegistrationOptions
+ {
+ IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
+ })
+ });
+ }
+
+ ///
+ /// 在临时消费者根目录中创建测试文件。
+ ///
+ /// 相对根目录的文件路径。
+ /// 要写入的文件内容。
+ private void CreateFile(string relativePath, string content)
+ {
+ var path = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
+ var directoryPath = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directoryPath))
+ {
+ Directory.CreateDirectory(directoryPath);
+ }
+
+ File.WriteAllText(path, content.Replace("\n", Environment.NewLine, StringComparison.Ordinal));
+ }
+
+ ///
+ /// 在临时目录中创建 monster schema 与 YAML 测试数据。
+ ///
+ private void CreateMonsterFiles()
+ {
+ CreateFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "title": "Monster Config",
+ "description": "Defines one monster entry for the bootstrap tests.",
+ "type": "object",
+ "required": ["id", "name", "hp", "faction"],
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "Monster identifier."
+ },
+ "name": {
+ "type": "string",
+ "description": "Monster display name."
+ },
+ "hp": {
+ "type": "integer",
+ "description": "Monster base health."
+ },
+ "faction": {
+ "type": "string",
+ "description": "Used by the bootstrap tests to validate generated queries."
+ }
+ }
+ }
+ """);
+ CreateFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 10
+ faction: dungeon
+ """);
+ CreateFile(
+ "monster/goblin.yaml",
+ """
+ id: 2
+ name: Goblin
+ hp: 30
+ faction: dungeon
+ """);
+ }
+
+ ///
+ /// 在限定时间内等待异步任务完成,避免文件监听测试无限挂起。
+ ///
+ /// 任务结果类型。
+ /// 要等待的任务。
+ /// 超时时间。
+ /// 任务结果。
+ private static async Task WaitForTaskWithinAsync(Task task, TimeSpan timeout)
+ {
+ var completedTask = await Task.WhenAny(task, Task.Delay(timeout));
+ if (!ReferenceEquals(completedTask, task))
+ {
+ Assert.Fail($"Timed out after {timeout} while waiting for file watcher notification.");
+ }
+
+ return await task;
+ }
+}
diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
index 86f01098..059d61aa 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -412,6 +412,104 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证数值命中开区间下界时会按 schema 在运行时被拒绝。
+ ///
+ [Test]
+ public void LoadAsync_Should_Throw_When_Number_Violates_Exclusive_Minimum()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 10
+ """);
+ 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("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("hp"));
+ Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("10"));
+ Assert.That(exception.Message, Does.Contain("greater than 10"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
+ ///
+ /// 验证数值命中开区间上界时会按 schema 在运行时被拒绝。
+ ///
+ [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("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("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));
+ });
+ }
+
///
/// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。
///
@@ -461,6 +559,209 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证字符串正则模式约束会在运行时被统一拒绝。
+ ///
+ [Test]
+ public void LoadAsync_Should_Throw_When_String_Does_Not_Match_Pattern()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: slime
+ hp: 10
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "hp"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": {
+ "type": "string",
+ "pattern": "^[A-Z][a-z]+$"
+ },
+ "hp": { "type": "integer" }
+ }
+ }
+ """);
+
+ 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("name"));
+ Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("slime"));
+ Assert.That(exception.Message, Does.Contain("regular expression"));
+ Assert.That(exception.Message, Does.Contain("^[A-Z][a-z]+$"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
+ ///
+ /// 验证运行时 schema 校验与 JS 工具对反向引用模式保持一致。
+ ///
+ [Test]
+ 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("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ await loader.LoadAsync(registry);
+
+ var table = registry.GetTable("monster");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(table.Count, Is.EqualTo(1));
+ Assert.That(table.Get(1).Name, Is.EqualTo("aa"));
+ });
+ }
+
+ ///
+ /// 验证数组元素数量命中上界时会在运行时被统一拒绝。
+ ///
+ [Test]
+ public void LoadAsync_Should_Throw_When_Array_Violates_MaxItems()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ dropRates:
+ - 1
+ - 2
+ - 3
+ - 4
+ """);
+ 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("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("dropRates"));
+ Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("4"));
+ Assert.That(exception.Message, Does.Contain("at most 3 items"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
+ ///
+ /// 验证数组元素数量命中下界时会在运行时被统一拒绝。
+ ///
+ [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("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("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));
+ });
+ }
+
///
/// 验证启用 schema 校验后,未知字段不会再被静默忽略。
///
@@ -1594,4 +1895,4 @@ public class YamlConfigLoaderTests
/// 配置主键。
/// 配置名称。
private sealed record ExistingConfigStub(int Id, string Name);
-}
\ No newline at end of file
+}
diff --git a/GFramework.Game/Config/GameConfigBootstrap.cs b/GFramework.Game/Config/GameConfigBootstrap.cs
new file mode 100644
index 00000000..242002be
--- /dev/null
+++ b/GFramework.Game/Config/GameConfigBootstrap.cs
@@ -0,0 +1,329 @@
+using GFramework.Core.Abstractions.Events;
+using GFramework.Game.Abstractions.Config;
+
+namespace GFramework.Game.Config;
+
+///
+/// 提供官方的 C# 配置启动帮助器。
+/// 该类型负责把配置注册表、YAML 加载器与开发期热重载句柄收敛到一个长生命周期对象中,
+/// 让消费者项目可以通过一个稳定入口完成配置启动,而不是在多个脚本里重复拼装运行时细节。
+///
+///
+/// 生命周期转换会串行化执行,因此并发调用只会观察到已经提交完成的加载器与热重载句柄。
+/// 如果初始化或热重载启动在中途失败,当前实例会保留失败前的稳定状态,而不会公开半初始化对象。
+///
+public sealed class GameConfigBootstrap : IDisposable
+{
+ private const string ConfigureLoaderCannotBeNullMessage = "ConfigureLoader must be provided.";
+ 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 IUnRegister? _hotReload;
+ private YamlConfigLoader? _loader;
+ private bool _disposed;
+ private bool _isInitializing;
+ private bool _isStartingHotReload;
+ private bool _stopHotReloadAfterStart;
+
+ ///
+ /// 使用指定选项创建配置启动帮助器。
+ ///
+ /// 配置启动约定。
+ /// 当 为空时抛出。
+ ///
+ /// 当 的 为空,
+ /// 或 未提供时抛出。
+ ///
+ public GameConfigBootstrap(GameConfigBootstrapOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (string.IsNullOrWhiteSpace(options.RootPath))
+ {
+ throw new ArgumentException(
+ RootPathCannotBeNullOrWhiteSpaceMessage,
+ nameof(options));
+ }
+
+ if (options.ConfigureLoader == null)
+ {
+ throw new ArgumentException(
+ ConfigureLoaderCannotBeNullMessage,
+ nameof(options));
+ }
+
+ _options = options;
+ RootPath = options.RootPath;
+ Registry = options.Registry ?? new ConfigRegistry();
+ }
+
+ ///
+ /// 获取配置根目录。
+ ///
+ public string RootPath { get; }
+
+ ///
+ /// 获取当前配置生命周期共享的注册表。
+ /// 默认情况下该实例由启动帮助器创建;如调用方传入自定义注册表,则返回同一个对象。
+ ///
+ public IConfigRegistry Registry { get; }
+
+ ///
+ /// 获取一个值,指示启动帮助器是否已经成功完成初次加载。
+ /// 该状态只会在完整初始化链路(包括可选热重载启动)成功后才对外可见,
+ /// 避免并发调用观察到半初始化生命周期。
+ ///
+ public bool IsInitialized
+ {
+ get
+ {
+ lock (_stateGate)
+ {
+ return _loader != null;
+ }
+ }
+ }
+
+ ///
+ /// 获取一个值,指示开发期热重载是否已启用。
+ /// 只有当监听句柄已经成功创建并提交到当前生命周期后,该属性才会返回 。
+ ///
+ public bool IsHotReloadEnabled
+ {
+ get
+ {
+ lock (_stateGate)
+ {
+ return _hotReload != null;
+ }
+ }
+ }
+
+ ///
+ /// 获取当前生效的 YAML 配置加载器。
+ /// 只有在 成功返回后该属性才可访问。
+ ///
+ /// 当当前实例已释放时抛出。
+ /// 当启动帮助器尚未初始化成功时抛出。
+ public YamlConfigLoader Loader
+ {
+ get
+ {
+ lock (_stateGate)
+ {
+ ThrowIfDisposedCore();
+
+ return _loader ?? throw new InvalidOperationException(
+ "The config bootstrap has not been initialized yet.");
+ }
+ }
+ }
+
+ ///
+ /// 执行初次配置加载,并在需要时启动开发期热重载。
+ /// 该方法只能成功调用一次,避免同一个生命周期对象在运行中被重新拼装为另一套加载约定。
+ ///
+ /// 取消令牌。
+ /// 表示异步初始化流程的任务。
+ /// 当当前实例已释放时抛出。
+ /// 当当前实例已经初始化成功时抛出。
+ /// 当配置加载失败时抛出。
+ public async Task InitializeAsync(CancellationToken cancellationToken = default)
+ {
+ lock (_stateGate)
+ {
+ ThrowIfDisposedCore();
+
+ if (_isInitializing || _loader != null)
+ {
+ throw new InvalidOperationException(
+ "The config bootstrap can only be initialized once per instance.");
+ }
+
+ _isInitializing = true;
+ }
+
+ IUnRegister? hotReload = null;
+
+ try
+ {
+ 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;
+ }
+ }
+
+ ///
+ /// 启用开发期热重载。
+ /// 该入口让调用方可以先完成一次确定性的初始加载,再按环境决定是否追加文件监听。
+ ///
+ /// 热重载选项;为空时使用 的默认行为。
+ /// 当当前实例已释放时抛出。
+ ///
+ /// 当初始加载尚未完成,或热重载已经处于启用状态时抛出。
+ ///
+ ///
+ /// 当 的 小于
+ /// 时抛出。
+ ///
+ public void StartHotReload(YamlConfigHotReloadOptions? options = null)
+ {
+ YamlConfigLoader loader;
+ lock (_stateGate)
+ {
+ 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;
+ }
+
+ 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;
+ }
+ }
+
+ ///
+ /// 停止开发期热重载并释放监听资源。
+ /// 该方法是幂等的,允许启动层在销毁阶段无条件调用。
+ ///
+ public void StopHotReload()
+ {
+ IUnRegister? hotReload;
+ lock (_stateGate)
+ {
+ if (_isStartingHotReload && _hotReload == null)
+ {
+ _stopHotReloadAfterStart = true;
+ return;
+ }
+
+ hotReload = _hotReload;
+ _hotReload = null;
+ }
+
+ hotReload?.UnRegister();
+ }
+
+ ///
+ /// 停止热重载并释放当前帮助器持有的资源。
+ ///
+ public void Dispose()
+ {
+ IUnRegister? hotReload;
+ lock (_stateGate)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ if (_isStartingHotReload && _hotReload == null)
+ {
+ _stopHotReloadAfterStart = true;
+ }
+
+ hotReload = _hotReload;
+ _hotReload = null;
+ }
+
+ hotReload?.UnRegister();
+ }
+
+ private void ThrowIfDisposedCore()
+ {
+ if (_disposed)
+ {
+ throw new ObjectDisposedException(nameof(GameConfigBootstrap));
+ }
+ }
+}
diff --git a/GFramework.Game/Config/GameConfigBootstrapOptions.cs b/GFramework.Game/Config/GameConfigBootstrapOptions.cs
new file mode 100644
index 00000000..bb517c14
--- /dev/null
+++ b/GFramework.Game/Config/GameConfigBootstrapOptions.cs
@@ -0,0 +1,41 @@
+using GFramework.Game.Abstractions.Config;
+
+namespace GFramework.Game.Config;
+
+///
+/// 描述官方配置启动帮助器的初始化约定。
+/// 该选项对象把配置根目录、表注册回调和热重载策略收敛到一个稳定入口,
+/// 让消费项目不必在多个启动脚本里重复拼装加载器细节。
+///
+public sealed class GameConfigBootstrapOptions
+{
+ ///
+ /// 获取或设置配置根目录。
+ /// 该路径会直接传给 作为 YAML 与 schema 的共同根目录。
+ ///
+ public string RootPath { get; init; } = string.Empty;
+
+ ///
+ /// 获取或设置用于配置 的回调。
+ /// 调用方通常应在这里调用生成器产出的 RegisterAllGeneratedConfigTables(),
+ /// 或显式注册当前场景所需的手写表定义。
+ ///
+ public Action? ConfigureLoader { get; init; }
+
+ ///
+ /// 获取或设置要复用的配置注册表。
+ /// 为空时启动帮助器会创建默认的 实例。
+ ///
+ public IConfigRegistry? Registry { get; init; }
+
+ ///
+ /// 获取或设置是否在初次加载成功后立即启用开发期热重载。
+ ///
+ public bool EnableHotReload { get; init; }
+
+ ///
+ /// 获取或设置初始化阶段启用热重载时使用的选项。
+ /// 当 为 时,该值会被忽略。
+ ///
+ public YamlConfigHotReloadOptions? HotReloadOptions { get; init; }
+}
diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
index a53fccab..f435c10a 100644
--- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs
+++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
@@ -1,3 +1,4 @@
+using System.Text.RegularExpressions;
using GFramework.Game.Abstractions.Config;
namespace GFramework.Game.Config;
@@ -9,6 +10,10 @@ namespace GFramework.Game.Config;
///
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;
+
///
/// 从磁盘加载并解析一个 JSON Schema 文件。
///
@@ -297,6 +302,7 @@ internal static class YamlConfigSchemaValidator
referenceTableName: null,
allowedValues: null,
constraints: null,
+ arrayConstraints: null,
schemaPath);
}
@@ -365,6 +371,7 @@ internal static class YamlConfigSchemaValidator
referenceTableName: null,
allowedValues: null,
constraints: null,
+ arrayConstraints: ParseArrayConstraints(tableName, schemaPath, propertyPath, element),
schemaPath);
}
@@ -395,6 +402,7 @@ internal static class YamlConfigSchemaValidator
referenceTableName,
ParseEnumValues(tableName, schemaPath, propertyPath, element, nodeType, "enum"),
ParseScalarConstraints(tableName, schemaPath, propertyPath, element, nodeType),
+ arrayConstraints: null,
schemaPath);
}
@@ -580,6 +588,11 @@ internal static class YamlConfigSchemaValidator
displayPath: GetDiagnosticPath(displayPath));
}
+ if (schemaNode.ArrayConstraints is not null)
+ {
+ ValidateArrayConstraints(tableName, yamlPath, displayPath, sequenceNode.Children.Count, schemaNode);
+ }
+
for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++)
{
ValidateNode(
@@ -739,8 +752,10 @@ internal static class YamlConfigSchemaValidator
}
///
- /// 解析标量字段支持的范围与长度约束。
- /// 当前共享子集只支持 `integer/number` 上的 `minimum/maximum` 和 `string` 上的 `minLength/maxLength`。
+ /// 解析标量字段支持的范围、长度与模式约束。
+ /// 当前共享子集支持:
+ /// `integer/number` 上的 `minimum/maximum/exclusiveMinimum/exclusiveMaximum`,
+ /// 以及 `string` 上的 `minLength/maxLength/pattern`。
///
/// 所属配置表名称。
/// Schema 文件路径。
@@ -757,8 +772,13 @@ internal static class YamlConfigSchemaValidator
{
var minimum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minimum");
var maximum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maximum");
+ var exclusiveMinimum =
+ TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "exclusiveMinimum");
+ var exclusiveMaximum =
+ TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "exclusiveMaximum");
var minLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minLength");
var maxLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maxLength");
+ var pattern = TryParsePatternConstraint(tableName, schemaPath, propertyPath, element, nodeType);
if (minimum.HasValue && maximum.HasValue && minimum.Value > maximum.Value)
{
@@ -770,6 +790,15 @@ internal static class YamlConfigSchemaValidator
displayPath: GetDiagnosticPath(propertyPath));
}
+ ValidateNumericConstraintRange(
+ tableName,
+ schemaPath,
+ propertyPath,
+ minimum,
+ maximum,
+ exclusiveMinimum,
+ exclusiveMaximum);
+
if (minLength.HasValue && maxLength.HasValue && minLength.Value > maxLength.Value)
{
throw ConfigLoadExceptionFactory.Create(
@@ -780,12 +809,62 @@ internal static class YamlConfigSchemaValidator
displayPath: GetDiagnosticPath(propertyPath));
}
- if (!minimum.HasValue && !maximum.HasValue && !minLength.HasValue && !maxLength.HasValue)
+ if (!minimum.HasValue &&
+ !maximum.HasValue &&
+ !exclusiveMinimum.HasValue &&
+ !exclusiveMaximum.HasValue &&
+ !minLength.HasValue &&
+ !maxLength.HasValue &&
+ pattern is null)
{
return null;
}
- return new YamlConfigScalarConstraints(minimum, maximum, minLength, maxLength);
+ return new YamlConfigScalarConstraints(
+ minimum,
+ maximum,
+ exclusiveMinimum,
+ exclusiveMaximum,
+ minLength,
+ maxLength,
+ pattern,
+ pattern is null
+ ? null
+ : new Regex(
+ pattern,
+ SupportedPatternRegexOptions));
+ }
+
+ ///
+ /// 解析数组节点支持的元素数量约束。
+ ///
+ /// 所属配置表名称。
+ /// Schema 文件路径。
+ /// 数组字段路径。
+ /// Schema 节点。
+ /// 数组约束模型;未声明时返回空。
+ private static YamlConfigArrayConstraints? ParseArrayConstraints(
+ string tableName,
+ string schemaPath,
+ string propertyPath,
+ JsonElement element)
+ {
+ var minItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "minItems");
+ var maxItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "maxItems");
+
+ if (minItems.HasValue && maxItems.HasValue && minItems.Value > maxItems.Value)
+ {
+ throw ConfigLoadExceptionFactory.Create(
+ ConfigLoadFailureKind.SchemaUnsupported,
+ tableName,
+ $"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minItems' greater than 'maxItems'.",
+ schemaPath: schemaPath,
+ displayPath: GetDiagnosticPath(propertyPath));
+ }
+
+ return !minItems.HasValue && !maxItems.HasValue
+ ? null
+ : new YamlConfigArrayConstraints(minItems, maxItems);
}
///
@@ -886,6 +965,180 @@ internal static class YamlConfigSchemaValidator
return constraintValue;
}
+ ///
+ /// 读取字符串正则约束。
+ ///
+ /// 所属配置表名称。
+ /// Schema 文件路径。
+ /// 字段路径。
+ /// Schema 节点。
+ /// 字段类型。
+ /// 正则模式;未声明时返回空。
+ private static string? TryParsePatternConstraint(
+ string tableName,
+ string schemaPath,
+ string propertyPath,
+ JsonElement element,
+ YamlConfigSchemaPropertyType nodeType)
+ {
+ if (!element.TryGetProperty("pattern", out var patternElement))
+ {
+ return null;
+ }
+
+ if (nodeType != YamlConfigSchemaPropertyType.String)
+ {
+ throw ConfigLoadExceptionFactory.Create(
+ ConfigLoadFailureKind.SchemaUnsupported,
+ tableName,
+ $"Property '{propertyPath}' in schema file '{schemaPath}' uses 'pattern', but only 'string' scalar types support regular-expression constraints.",
+ schemaPath: schemaPath,
+ displayPath: GetDiagnosticPath(propertyPath));
+ }
+
+ if (patternElement.ValueKind != JsonValueKind.String)
+ {
+ throw ConfigLoadExceptionFactory.Create(
+ ConfigLoadFailureKind.SchemaUnsupported,
+ tableName,
+ $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'pattern' as a string.",
+ schemaPath: schemaPath,
+ displayPath: GetDiagnosticPath(propertyPath));
+ }
+
+ var pattern = patternElement.GetString() ?? string.Empty;
+ try
+ {
+ _ = new Regex(pattern, SupportedPatternRegexOptions);
+ }
+ catch (ArgumentException exception)
+ {
+ throw ConfigLoadExceptionFactory.Create(
+ ConfigLoadFailureKind.SchemaUnsupported,
+ tableName,
+ $"Property '{propertyPath}' in schema file '{schemaPath}' declares an invalid 'pattern' regular expression.",
+ schemaPath: schemaPath,
+ displayPath: GetDiagnosticPath(propertyPath),
+ rawValue: pattern,
+ innerException: exception);
+ }
+
+ return pattern;
+ }
+
+ ///
+ /// 读取数组元素数量约束。
+ ///
+ /// 所属配置表名称。
+ /// Schema 文件路径。
+ /// 字段路径。
+ /// Schema 节点。
+ /// 关键字名称。
+ /// 数组元素数量约束;未声明时返回空。
+ private static int? TryParseArrayLengthConstraint(
+ string tableName,
+ string schemaPath,
+ string propertyPath,
+ JsonElement element,
+ string keywordName)
+ {
+ if (!element.TryGetProperty(keywordName, out var constraintElement))
+ {
+ return null;
+ }
+
+ if (constraintElement.ValueKind != JsonValueKind.Number ||
+ !constraintElement.TryGetInt32(out var constraintValue) ||
+ constraintValue < 0)
+ {
+ throw ConfigLoadExceptionFactory.Create(
+ ConfigLoadFailureKind.SchemaUnsupported,
+ tableName,
+ $"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as a non-negative integer.",
+ schemaPath: schemaPath,
+ displayPath: GetDiagnosticPath(propertyPath));
+ }
+
+ return constraintValue;
+ }
+
+ ///
+ /// 校验数值上下界组合不会形成空区间。
+ /// 这里把闭区间与开区间统一折算为最强边界,避免 schema 进入“无任何合法值”的状态。
+ ///
+ /// 所属配置表名称。
+ /// Schema 文件路径。
+ /// 字段路径。
+ /// 闭区间最小值。
+ /// 闭区间最大值。
+ /// 开区间最小值。
+ /// 开区间最大值。
+ private static void ValidateNumericConstraintRange(
+ string tableName,
+ string schemaPath,
+ string propertyPath,
+ double? minimum,
+ double? maximum,
+ double? exclusiveMinimum,
+ double? exclusiveMaximum)
+ {
+ var hasLowerBound = false;
+ var lowerBound = double.MinValue;
+ var isLowerBoundExclusive = false;
+
+ if (minimum.HasValue)
+ {
+ hasLowerBound = true;
+ lowerBound = minimum.Value;
+ }
+
+ if (exclusiveMinimum.HasValue &&
+ (!hasLowerBound ||
+ exclusiveMinimum.Value > lowerBound ||
+ (exclusiveMinimum.Value.Equals(lowerBound) && !isLowerBoundExclusive)))
+ {
+ hasLowerBound = true;
+ lowerBound = exclusiveMinimum.Value;
+ isLowerBoundExclusive = true;
+ }
+
+ var hasUpperBound = false;
+ var upperBound = double.MaxValue;
+ var isUpperBoundExclusive = false;
+
+ if (maximum.HasValue)
+ {
+ hasUpperBound = true;
+ upperBound = maximum.Value;
+ }
+
+ if (exclusiveMaximum.HasValue &&
+ (!hasUpperBound ||
+ exclusiveMaximum.Value < upperBound ||
+ (exclusiveMaximum.Value.Equals(upperBound) && !isUpperBoundExclusive)))
+ {
+ hasUpperBound = true;
+ upperBound = exclusiveMaximum.Value;
+ isUpperBoundExclusive = true;
+ }
+
+ if (!hasLowerBound || !hasUpperBound)
+ {
+ return;
+ }
+
+ if (lowerBound > upperBound ||
+ (lowerBound.Equals(upperBound) && (isLowerBoundExclusive || isUpperBoundExclusive)))
+ {
+ throw ConfigLoadExceptionFactory.Create(
+ ConfigLoadFailureKind.SchemaUnsupported,
+ tableName,
+ $"Property '{propertyPath}' in schema file '{schemaPath}' declares numeric constraints that do not leave any valid value range.",
+ schemaPath: schemaPath,
+ displayPath: GetDiagnosticPath(propertyPath));
+ }
+ }
+
///
/// 校验标量值是否满足范围与长度约束。
///
@@ -943,6 +1196,20 @@ internal static class YamlConfigSchemaValidator
$"Minimum allowed value: {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}.");
}
+ if (constraints.ExclusiveMinimum.HasValue && numericValue <= constraints.ExclusiveMinimum.Value)
+ {
+ throw ConfigLoadExceptionFactory.Create(
+ ConfigLoadFailureKind.ConstraintViolation,
+ tableName,
+ $"Property '{displayPath}' in config file '{yamlPath}' must be greater than {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.",
+ yamlPath: yamlPath,
+ schemaPath: schemaNode.SchemaPathHint,
+ displayPath: GetDiagnosticPath(displayPath),
+ rawValue: rawValue,
+ detail:
+ $"Exclusive minimum allowed value: {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}.");
+ }
+
if (constraints.Maximum.HasValue && numericValue > constraints.Maximum.Value)
{
throw ConfigLoadExceptionFactory.Create(
@@ -957,6 +1224,20 @@ internal static class YamlConfigSchemaValidator
$"Maximum allowed value: {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}.");
}
+ if (constraints.ExclusiveMaximum.HasValue && numericValue >= constraints.ExclusiveMaximum.Value)
+ {
+ throw ConfigLoadExceptionFactory.Create(
+ ConfigLoadFailureKind.ConstraintViolation,
+ tableName,
+ $"Property '{displayPath}' in config file '{yamlPath}' must be less than {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.",
+ yamlPath: yamlPath,
+ schemaPath: schemaNode.SchemaPathHint,
+ displayPath: GetDiagnosticPath(displayPath),
+ rawValue: rawValue,
+ detail:
+ $"Exclusive maximum allowed value: {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}.");
+ }
+
return;
case YamlConfigSchemaPropertyType.String:
@@ -988,6 +1269,20 @@ internal static class YamlConfigSchemaValidator
detail: $"Maximum length: {constraints.MaxLength.Value}.");
}
+ if (constraints.PatternRegex is not null &&
+ !constraints.PatternRegex.IsMatch(rawValue))
+ {
+ throw ConfigLoadExceptionFactory.Create(
+ ConfigLoadFailureKind.ConstraintViolation,
+ tableName,
+ $"Property '{displayPath}' in config file '{yamlPath}' must match regular expression '{constraints.Pattern}', but the current YAML scalar value is '{rawValue}'.",
+ yamlPath: yamlPath,
+ schemaPath: schemaNode.SchemaPathHint,
+ displayPath: GetDiagnosticPath(displayPath),
+ rawValue: rawValue,
+ detail: $"Expected pattern: {constraints.Pattern}.");
+ }
+
return;
default:
@@ -1002,6 +1297,54 @@ internal static class YamlConfigSchemaValidator
}
}
+ ///
+ /// 校验数组值是否满足元素数量约束。
+ ///
+ /// 所属配置表名称。
+ /// YAML 文件路径。
+ /// 字段路径。
+ /// 当前数组元素数量。
+ /// 数组 schema 节点。
+ private static void ValidateArrayConstraints(
+ string tableName,
+ string yamlPath,
+ string displayPath,
+ int itemCount,
+ YamlConfigSchemaNode schemaNode)
+ {
+ var constraints = schemaNode.ArrayConstraints;
+ if (constraints is null)
+ {
+ return;
+ }
+
+ if (constraints.MinItems.HasValue && itemCount < constraints.MinItems.Value)
+ {
+ throw ConfigLoadExceptionFactory.Create(
+ ConfigLoadFailureKind.ConstraintViolation,
+ tableName,
+ $"Property '{displayPath}' in config file '{yamlPath}' must contain at least {constraints.MinItems.Value} items, but the current YAML sequence contains {itemCount}.",
+ yamlPath: yamlPath,
+ schemaPath: schemaNode.SchemaPathHint,
+ displayPath: GetDiagnosticPath(displayPath),
+ rawValue: itemCount.ToString(CultureInfo.InvariantCulture),
+ detail: $"Minimum item count: {constraints.MinItems.Value}.");
+ }
+
+ if (constraints.MaxItems.HasValue && itemCount > constraints.MaxItems.Value)
+ {
+ throw ConfigLoadExceptionFactory.Create(
+ ConfigLoadFailureKind.ConstraintViolation,
+ tableName,
+ $"Property '{displayPath}' in config file '{yamlPath}' must contain at most {constraints.MaxItems.Value} items, but the current YAML sequence contains {itemCount}.",
+ yamlPath: yamlPath,
+ schemaPath: schemaNode.SchemaPathHint,
+ displayPath: GetDiagnosticPath(displayPath),
+ rawValue: itemCount.ToString(CultureInfo.InvariantCulture),
+ detail: $"Maximum item count: {constraints.MaxItems.Value}.");
+ }
+ }
+
///
/// 解析跨表引用目标表名称。
///
@@ -1323,6 +1666,7 @@ internal sealed class YamlConfigSchemaNode
/// 目标引用表名称。
/// 标量允许值集合。
/// 标量范围与长度约束。
+ /// 数组元素数量约束。
/// 用于错误信息的 schema 文件路径提示。
public YamlConfigSchemaNode(
YamlConfigSchemaPropertyType nodeType,
@@ -1332,6 +1676,7 @@ internal sealed class YamlConfigSchemaNode
string? referenceTableName,
IReadOnlyCollection? allowedValues,
YamlConfigScalarConstraints? constraints,
+ YamlConfigArrayConstraints? arrayConstraints,
string schemaPathHint)
{
NodeType = nodeType;
@@ -1341,6 +1686,7 @@ internal sealed class YamlConfigSchemaNode
ReferenceTableName = referenceTableName;
AllowedValues = allowedValues;
Constraints = constraints;
+ ArrayConstraints = arrayConstraints;
SchemaPathHint = schemaPathHint;
}
@@ -1379,6 +1725,11 @@ internal sealed class YamlConfigSchemaNode
///
public YamlConfigScalarConstraints? Constraints { get; }
+ ///
+ /// 获取数组元素数量约束;未声明时返回空。
+ ///
+ public YamlConfigArrayConstraints? ArrayConstraints { get; }
+
///
/// 获取用于诊断显示的 schema 路径提示。
/// 当前节点本身不记录独立路径,因此对象校验会回退到所属根 schema 路径。
@@ -1401,6 +1752,7 @@ internal sealed class YamlConfigSchemaNode
referenceTableName,
AllowedValues,
Constraints,
+ ArrayConstraints,
SchemaPathHint);
}
}
@@ -1416,18 +1768,30 @@ internal sealed class YamlConfigScalarConstraints
///
/// 最小值约束。
/// 最大值约束。
+ /// 开区间最小值约束。
+ /// 开区间最大值约束。
/// 最小长度约束。
/// 最大长度约束。
+ /// 正则模式约束。
+ /// 已编译的正则表达式。
public YamlConfigScalarConstraints(
double? minimum,
double? maximum,
+ double? exclusiveMinimum,
+ double? exclusiveMaximum,
int? minLength,
- int? maxLength)
+ int? maxLength,
+ string? pattern,
+ Regex? patternRegex)
{
Minimum = minimum;
Maximum = maximum;
+ ExclusiveMinimum = exclusiveMinimum;
+ ExclusiveMaximum = exclusiveMaximum;
MinLength = minLength;
MaxLength = maxLength;
+ Pattern = pattern;
+ PatternRegex = patternRegex;
}
///
@@ -1440,6 +1804,16 @@ internal sealed class YamlConfigScalarConstraints
///
public double? Maximum { get; }
+ ///
+ /// 获取开区间最小值约束。
+ ///
+ public double? ExclusiveMinimum { get; }
+
+ ///
+ /// 获取开区间最大值约束。
+ ///
+ public double? ExclusiveMaximum { get; }
+
///
/// 获取最小长度约束。
///
@@ -1449,6 +1823,44 @@ internal sealed class YamlConfigScalarConstraints
/// 获取最大长度约束。
///
public int? MaxLength { get; }
+
+ ///
+ /// 获取正则模式约束原文。
+ ///
+ public string? Pattern { get; }
+
+ ///
+ /// 获取已编译的正则表达式。
+ ///
+ public Regex? PatternRegex { get; }
+}
+
+///
+/// 表示一个数组节点上声明的元素数量约束。
+/// 该模型与标量约束拆分保存,避免数组节点继续共享不适用的标量字段。
+///
+internal sealed class YamlConfigArrayConstraints
+{
+ ///
+ /// 初始化数组约束模型。
+ ///
+ /// 最小元素数量约束。
+ /// 最大元素数量约束。
+ public YamlConfigArrayConstraints(int? minItems, int? maxItems)
+ {
+ MinItems = minItems;
+ MaxItems = maxItems;
+ }
+
+ ///
+ /// 获取最小元素数量约束。
+ ///
+ public int? MinItems { get; }
+
+ ///
+ /// 获取最大元素数量约束。
+ ///
+ public int? MaxItems { get; }
}
///
@@ -1558,4 +1970,4 @@ internal enum YamlConfigSchemaPropertyType
/// 数组类型。
///
Array
-}
\ No newline at end of file
+}
diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs
index 9c375432..8dd98ed5 100644
--- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs
+++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs
@@ -81,6 +81,7 @@ public class SchemaConfigGeneratorSnapshotTests
"description": "Localized monster display name.",
"minLength": 3,
"maxLength": 16,
+ "pattern": "^[A-Z][a-z]+$",
"default": "Slime",
"enum": ["Slime", "Goblin"]
},
@@ -88,11 +89,15 @@ public class SchemaConfigGeneratorSnapshotTests
"type": "integer",
"minimum": 1,
"maximum": 999,
+ "exclusiveMinimum": 0,
+ "exclusiveMaximum": 1000,
"default": 10
},
"dropItems": {
"description": "Referenced drop ids.",
"type": "array",
+ "minItems": 1,
+ "maxItems": 3,
"items": {
"type": "string",
"minLength": 3,
diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt
index 482d016a..2306fa00 100644
--- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt
+++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt
@@ -24,7 +24,7 @@ public sealed partial class MonsterConfig
/// Schema property path: 'name'.
/// Display title: 'Monster Name'.
/// Allowed values: Slime, Goblin.
- /// Constraints: minLength = 3, maxLength = 16.
+ /// Constraints: minLength = 3, maxLength = 16, pattern = '^[A-Z][a-z]+$'.
/// Generated default initializer: = "Slime";
///
public string Name { get; set; } = "Slime";
@@ -34,7 +34,7 @@ public sealed partial class MonsterConfig
///
///
/// Schema property path: 'hp'.
- /// Constraints: minimum = 1, maximum = 999.
+ /// Constraints: minimum = 1, exclusiveMinimum = 0, maximum = 999, exclusiveMaximum = 1000.
/// Generated default initializer: = 10;
///
public int? Hp { get; set; } = 10;
@@ -45,6 +45,7 @@ public sealed partial class MonsterConfig
///
/// Schema property path: 'dropItems'.
/// Allowed values: potion, slime_gel.
+ /// Constraints: minItems = 1, maxItems = 3.
/// References config table: 'item'.
/// Item constraints: minLength = 3, maxLength = 12.
/// Generated default initializer: = new string[] { "potion" };
diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
index 38eec75f..cd4e1392 100644
--- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
+++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
@@ -477,7 +477,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
TryBuildArrayInitializer(property.Value, itemType, itemClrType) ??
$" = global::System.Array.Empty<{itemClrType}>();",
TryBuildEnumDocumentation(itemsElement, itemType),
- null,
+ TryBuildConstraintDocumentation(property.Value, "array"),
refTableName,
null,
new SchemaTypeSpec(
@@ -527,7 +527,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
$"global::System.Collections.Generic.IReadOnlyList<{objectSpec.ClassName}>",
$" = global::System.Array.Empty<{objectSpec.ClassName}>();",
null,
- null,
+ TryBuildConstraintDocumentation(property.Value, "array"),
null,
null,
new SchemaTypeSpec(
@@ -1876,7 +1876,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
///
- /// 将 shared schema 子集中的范围与长度约束整理成 XML 文档可读字符串。
+ /// 将 shared schema 子集中的范围、长度、模式与数组数量约束整理成 XML 文档可读字符串。
///
/// Schema 节点。
/// 标量类型。
@@ -1891,12 +1891,24 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
parts.Add($"minimum = {minimum.ToString(CultureInfo.InvariantCulture)}");
}
+ if ((schemaType == "integer" || schemaType == "number") &&
+ TryGetFiniteNumber(element, "exclusiveMinimum", out var exclusiveMinimum))
+ {
+ parts.Add($"exclusiveMinimum = {exclusiveMinimum.ToString(CultureInfo.InvariantCulture)}");
+ }
+
if ((schemaType == "integer" || schemaType == "number") &&
TryGetFiniteNumber(element, "maximum", out var maximum))
{
parts.Add($"maximum = {maximum.ToString(CultureInfo.InvariantCulture)}");
}
+ if ((schemaType == "integer" || schemaType == "number") &&
+ TryGetFiniteNumber(element, "exclusiveMaximum", out var exclusiveMaximum))
+ {
+ parts.Add($"exclusiveMaximum = {exclusiveMaximum.ToString(CultureInfo.InvariantCulture)}");
+ }
+
if (schemaType == "string" &&
TryGetNonNegativeInt32(element, "minLength", out var minLength))
{
@@ -1909,6 +1921,25 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
parts.Add($"maxLength = {maxLength.ToString(CultureInfo.InvariantCulture)}");
}
+ if (schemaType == "string" &&
+ element.TryGetProperty("pattern", out var patternElement) &&
+ patternElement.ValueKind == JsonValueKind.String)
+ {
+ parts.Add($"pattern = '{patternElement.GetString() ?? string.Empty}'");
+ }
+
+ if (schemaType == "array" &&
+ TryGetNonNegativeInt32(element, "minItems", out var minItems))
+ {
+ parts.Add($"minItems = {minItems.ToString(CultureInfo.InvariantCulture)}");
+ }
+
+ if (schemaType == "array" &&
+ TryGetNonNegativeInt32(element, "maxItems", out var maxItems))
+ {
+ parts.Add($"maxItems = {maxItems.ToString(CultureInfo.InvariantCulture)}");
+ }
+
return parts.Count > 0 ? string.Join(", ", parts) : null;
}
diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md
index d2f7a7f1..e52ae0c5 100644
--- a/docs/zh-CN/game/config-system.md
+++ b/docs/zh-CN/game/config-system.md
@@ -12,7 +12,7 @@
- JSON Schema 作为结构描述
- 一对象一文件的目录组织
- 运行时只读查询
-- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`minLength`、`maxLength`
+- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems`
- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
@@ -92,7 +92,7 @@ dropItems:
GameProject/
├─ GameProject.csproj
├─ Config/
-│ ├─ GameConfigBootstrap.cs
+│ ├─ GameConfigHost.cs
│ └─ GameConfigRuntime.cs
├─ config/
│ ├─ monster/
@@ -142,7 +142,7 @@ GameProject/
这段配置的作用:
-- `GFramework.Game` 提供运行时 `YamlConfigLoader`、`ConfigRegistry` 和只读表实现
+- `GFramework.Game` 提供运行时 `YamlConfigLoader`、`ConfigRegistry`、`GameConfigBootstrap` 和只读表实现
- 三个 `ProjectReference(... OutputItemType="Analyzer")` 把生成器接进当前消费者项目
- `GeWuYou.GFramework.SourceGenerators.targets` 自动把 `schemas/**/*.schema.json` 加入 `AdditionalFiles`
@@ -160,12 +160,51 @@ GameProject/
```
-### 启动引导模板
+### 官方启动帮助器
-推荐把配置系统的初始化收敛到一个单独入口,避免把 `YamlConfigLoader` 注册逻辑散落到多个启动脚本中:
+`GFramework.Game` 现在内置 `GameConfigBootstrap` 与 `GameConfigBootstrapOptions`,用于把 `ConfigRegistry`、`YamlConfigLoader`、`LoadAsync` 和热重载句柄收敛到一个正式的 C# 入口中。
+
+推荐直接组合这个帮助器,而不是继续在消费者项目里复制文档模板:
+
+```csharp
+using GFramework.Game.Abstractions.Config;
+using GFramework.Game.Config;
+using GFramework.Game.Config.Generated;
+
+var bootstrap = new GameConfigBootstrap(
+ new GameConfigBootstrapOptions
+ {
+ RootPath = configRootPath,
+ ConfigureLoader = static loader => loader.RegisterAllGeneratedConfigTables(),
+ EnableHotReload = true,
+ HotReloadOptions = new YamlConfigHotReloadOptions
+ {
+ OnTableReloaded = tableName => Console.WriteLine($"Reloaded config table: {tableName}"),
+ OnTableReloadFailed = static (_, exception) =>
+ {
+ var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
+ Console.WriteLine($"Config reload failed: {diagnostic?.FailureKind}");
+ }
+ }
+ });
+
+await bootstrap.InitializeAsync();
+
+var registry = bootstrap.Registry;
+var monsterTable = registry.GetMonsterTable();
+var slime = monsterTable.Get(1);
+
+bootstrap.Dispose();
+```
+
+如果你希望把它继续包装进自己的进程级入口,也建议只包一层生命周期壳,而不是重新拼装底层加载器。为了避免和后面的“运行时读取模板”冲突,推荐明确拆成两类文件:
+
+- `GameConfigHost.cs` 负责生命周期管理、初始化和热重载
+- `GameConfigRuntime.cs` 负责把已初始化的 `IConfigRegistry` 封装成业务层读取入口
+
+如果你采用这套双层模板,建议把上面的生命周期壳文件命名为 `GameConfigHost.cs`,并把类型名同步改成 `GameConfigHost`:
```csharp
-using GFramework.Core.Abstractions.Events;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
@@ -173,64 +212,71 @@ using GFramework.Game.Config.Generated;
namespace GameProject.Config;
///
-/// 负责初始化游戏内容配置运行时入口。
+/// 封装当前游戏进程的配置启动生命周期。
///
-public sealed class GameConfigBootstrap : IDisposable
+public sealed class GameConfigHost : IDisposable
{
- private readonly ConfigRegistry _registry = new();
- private IUnRegister? _hotReload;
+ private readonly GameConfigBootstrap _bootstrap;
///
- /// 获取当前游戏进程共享的配置注册表。
- ///
- public IConfigRegistry Registry => _registry;
-
- ///
- /// 从指定配置根目录加载所有已注册配置表。
+ /// 使用指定配置根目录创建运行时入口。
///
/// 配置根目录。
- /// 是否启用开发期热重载。
- public async Task InitializeAsync(string configRootPath, bool enableHotReload = false)
+ public GameConfigHost(string configRootPath)
{
- var loader = new YamlConfigLoader(configRootPath)
- .RegisterAllGeneratedConfigTables();
-
- await loader.LoadAsync(_registry);
-
- if (enableHotReload)
- {
- _hotReload = loader.EnableHotReload(
- _registry,
- onTableReloaded: tableName => Console.WriteLine($"Reloaded config table: {tableName}"),
- onTableReloadFailed: static (_, exception) =>
- {
- var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
- Console.WriteLine($"Config reload failed: {diagnostic?.FailureKind}");
- });
- }
+ _bootstrap = new GameConfigBootstrap(
+ new GameConfigBootstrapOptions
+ {
+ RootPath = configRootPath,
+ ConfigureLoader = static loader => loader.RegisterAllGeneratedConfigTables()
+ });
}
///
- /// 停止开发期热重载并释放相关资源。
+ /// 获取共享配置注册表。
+ ///
+ public IConfigRegistry Registry => _bootstrap.Registry;
+
+ ///
+ /// 执行初次配置加载。
+ ///
+ public Task InitializeAsync()
+ {
+ return _bootstrap.InitializeAsync();
+ }
+
+ ///
+ /// 创建业务层使用的只读配置入口。
+ ///
+ /// 封装强类型表访问的读取入口。
+ public GameConfigRuntime CreateRuntime()
+ {
+ return new GameConfigRuntime(_bootstrap.Registry);
+ }
+
+ ///
+ /// 释放底层热重载句柄等资源。
///
public void Dispose()
{
- _hotReload?.UnRegister();
+ _bootstrap.Dispose();
}
}
```
-这段模板刻意遵循几个约定:
+这个官方帮助器刻意遵循几个约定:
-- 优先使用生成器产出的 `RegisterAllGeneratedConfigTables()`,把多表注册收敛为一个稳定入口
-- 由一个长生命周期对象持有 `ConfigRegistry`
-- 热重载句柄和配置生命周期绑在一起,避免监听器泄漏
+- 优先通过 `ConfigureLoader` 调用生成器产出的 `RegisterAllGeneratedConfigTables()`,把多表注册收敛为一个稳定入口
+- 由 `GameConfigBootstrap` 持有 `ConfigRegistry`、`YamlConfigLoader` 和热重载句柄
+- `InitializeAsync()` 只在首次加载完整成功后才公开运行时状态,避免半初始化对象泄漏到业务层
+- 热重载既可以在初始化时自动启用,也可以在初次加载后显式调用 `StartHotReload(...)`
### 运行时读取模板
推荐不要在业务代码里直接散落字符串表名查询,而是统一依赖生成的强类型入口:
```csharp
+using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config.Generated;
namespace GameProject.Config;
@@ -272,6 +318,16 @@ public sealed class GameConfigRuntime
}
```
+它通常与上面的 `GameConfigHost` 配合使用:
+
+```csharp
+var configHost = new GameConfigHost("config-root");
+await configHost.InitializeAsync();
+
+var runtime = configHost.CreateRuntime();
+var slime = runtime.GetMonster(1);
+```
+
这样做的收益:
- 配置系统对业务层暴露的是强类型表,而不是 `"monster"` 这类 magic string
@@ -309,30 +365,38 @@ if (monsterTable.TryFindFirstByFaction("dungeon", out var firstDungeonMonster))
### Architecture 推荐接入模板
-如果你的项目已经基于 `GFramework.Core.Architectures.Architecture` 组织初始化流程,推荐把配置系统接到 `OnInitialize()` 阶段,并把 `ConfigRegistry` 注册为 utility:
+如果你的项目已经基于 `GFramework.Core.Architectures.Architecture` 组织初始化流程,推荐把配置系统接到 `OnInitialize()` 阶段,并把 `GameConfigBootstrap.Registry` 注册为 utility:
```csharp
using GFramework.Core.Architectures;
+using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
public sealed class GameArchitecture : Architecture
{
- private readonly string _configRootPath;
+ private readonly GameConfigBootstrap _configBootstrap;
public GameArchitecture(string configRootPath)
{
- _configRootPath = configRootPath ?? throw new ArgumentNullException(nameof(configRootPath));
+ _configBootstrap = new GameConfigBootstrap(
+ new GameConfigBootstrapOptions
+ {
+ RootPath = configRootPath,
+ ConfigureLoader = static loader => loader.RegisterAllGeneratedConfigTables()
+ });
}
protected override void OnInitialize()
{
- var registry = RegisterUtility(new ConfigRegistry());
+ RegisterUtility(_configBootstrap.Registry);
+ _configBootstrap.InitializeAsync().GetAwaiter().GetResult();
+ }
- var loader = new YamlConfigLoader(_configRootPath)
- .RegisterAllGeneratedConfigTables();
-
- loader.LoadAsync(registry).GetAwaiter().GetResult();
+ public override async ValueTask DestroyAsync()
+ {
+ _configBootstrap.Dispose();
+ await base.DestroyAsync();
}
}
```
@@ -340,35 +404,40 @@ public sealed class GameArchitecture : Architecture
初始化完成后,业务组件可以继续通过架构上下文读取 utility,再走生成的强类型入口:
```csharp
-var registry = Context.GetUtility();
+var registry = Context.GetUtility();
var monsterTable = registry.GetMonsterTable();
var slime = monsterTable.Get(1);
```
推荐遵循以下顺序:
-- 先注册 `ConfigRegistry`
-- 再构造并配置 `YamlConfigLoader`
-- 在 `OnInitialize()` 内完成首次 `LoadAsync`
+- 先构造 `GameConfigBootstrap`
+- 在 `OnInitialize()` 里注册 `bootstrap.Registry`
+- 再调用 `bootstrap.InitializeAsync()` 完成首次加载
+- 架构销毁时释放 `GameConfigBootstrap`
- 初始化完成后只通过注册表和生成表包装访问配置
-当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + ConfigRegistry + YamlConfigLoader + RegisterAllGeneratedConfigTables()` 组合已经足够作为官方推荐接入路径。
+当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + GameConfigBootstrap + RegisterAllGeneratedConfigTables()` 组合已经足够作为官方推荐接入路径。
### 热重载模板
-如果你希望把开发期热重载显式收敛为一个可选能力,建议把失败诊断一起写进模板,而不是只打印异常文本:
+如果你希望把开发期热重载显式收敛为一个可选能力,推荐直接通过 `GameConfigBootstrap.StartHotReload(...)` 管理,而不是让监听句柄散落在启动层之外:
```csharp
-var hotReload = loader.EnableHotReload(
- registry,
- onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"),
- onTableReloadFailed: (tableName, exception) =>
+await bootstrap.InitializeAsync();
+
+bootstrap.StartHotReload(
+ new YamlConfigHotReloadOptions
{
- var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
- Console.WriteLine($"Reload failed: {tableName}");
- Console.WriteLine($"Failure kind: {diagnostic?.FailureKind}");
- Console.WriteLine($"Yaml path: {diagnostic?.YamlPath}");
- Console.WriteLine($"Display path: {diagnostic?.DisplayPath}");
+ OnTableReloaded = tableName => Console.WriteLine($"Reloaded: {tableName}"),
+ OnTableReloadFailed = (tableName, exception) =>
+ {
+ var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
+ Console.WriteLine($"Reload failed: {tableName}");
+ Console.WriteLine($"Failure kind: {diagnostic?.FailureKind}");
+ Console.WriteLine($"Yaml path: {diagnostic?.YamlPath}");
+ Console.WriteLine($"Display path: {diagnostic?.DisplayPath}");
+ }
});
```
@@ -378,22 +447,7 @@ var hotReload = loader.EnableHotReload(
- 热重载失败时应优先依赖 `ConfigLoadException.Diagnostic` 做稳定日志或 UI 提示
- 如果你的项目已经有统一日志系统,建议在这里把诊断字段转成结构化日志,而不是拼接一整段字符串
-如果你后续还需要为热重载增加更多开关,推荐优先使用选项对象入口,而不是继续叠加位置参数:
-
-```csharp
-var hotReload = loader.EnableHotReload(
- registry,
- new YamlConfigHotReloadOptions
- {
- OnTableReloaded = tableName => Console.WriteLine($"Reloaded: {tableName}"),
- OnTableReloadFailed = (tableName, exception) =>
- {
- var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
- Console.WriteLine($"{tableName}: {diagnostic?.FailureKind}");
- },
- DebounceDelay = TimeSpan.FromMilliseconds(150)
- });
-```
+如果你确实需要直接控制底层加载器,`YamlConfigLoader.EnableHotReload(...)` 仍然保留;但在一般启动路径下,优先让 `GameConfigBootstrap` 持有并停止监听句柄。
## 运行时接入
@@ -524,7 +578,10 @@ var loader = new YamlConfigLoader("config-root")
- 嵌套对象字段类型不匹配
- 对象数组元素结构不匹配
- 数值字段违反 `minimum` / `maximum`
+- 数值字段违反 `exclusiveMinimum` / `exclusiveMaximum`
- 字符串字段违反 `minLength` / `maxLength`
+- 字符串字段违反 `pattern`
+- 数组字段违反 `minItems` / `maxItems`
- 标量 `enum` 不匹配
- 标量数组元素 `enum` 不匹配
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
@@ -573,7 +630,10 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
- `default`:供生成类型属性初始值和工具提示复用
- `enum`:供运行时校验、VS Code 校验和表单枚举选择复用
- `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
+- `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
- `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
+- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS 默认分组语义解释,非法模式会在 schema 解析阶段直接报错
+- `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js
index 31467f78..e80a559e 100644
--- a/tools/gframework-config-tool/src/configValidation.js
+++ b/tools/gframework-config-tool/src/configValidation.js
@@ -12,6 +12,7 @@ const {ValidationMessageKeys} = require("./localizationKeys");
* runtime validator and source generator so tooling diagnostics stay aligned.
*
* @param {string} content Raw schema JSON text.
+ * @throws {Error} Thrown when the schema declares one unsupported or invalid pattern string.
* @returns {{
* type: "object",
* required: string[],
@@ -379,6 +380,28 @@ function normalizeSchemaNonNegativeInteger(value) {
return Number.isInteger(value) && value >= 0 ? value : undefined;
}
+/**
+ * Normalize one schema pattern string when the regular expression can be
+ * compiled by the local tooling runtime.
+ *
+ * @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.
+ */
+function normalizeSchemaPattern(value, displayPath) {
+ if (typeof value !== "string") {
+ return undefined;
+ }
+
+ try {
+ void new RegExp(value);
+ return value;
+ } catch (error) {
+ throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`);
+ }
+}
+
/**
* Convert a schema default value into a compact string that can be shown in UI
* metadata hints.
@@ -410,6 +433,27 @@ function formatSchemaDefaultValue(value) {
return undefined;
}
+/**
+ * Test one scalar value against one schema pattern string.
+ *
+ * @param {string} scalarValue Scalar value from YAML.
+ * @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.
+ */
+function matchesSchemaPattern(scalarValue, pattern, displayPath) {
+ if (typeof pattern !== "string") {
+ return true;
+ }
+
+ try {
+ return new RegExp(pattern).test(scalarValue);
+ } catch (error) {
+ throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`);
+ }
+}
+
/**
* Format a scalar value for YAML output.
*
@@ -458,9 +502,14 @@ function parseSchemaNode(rawNode, displayPath) {
description: typeof value.description === "string" ? value.description : undefined,
defaultValue: formatSchemaDefaultValue(value.default),
minimum: normalizeSchemaNumber(value.minimum),
+ exclusiveMinimum: normalizeSchemaNumber(value.exclusiveMinimum),
maximum: normalizeSchemaNumber(value.maximum),
+ exclusiveMaximum: normalizeSchemaNumber(value.exclusiveMaximum),
minLength: normalizeSchemaNonNegativeInteger(value.minLength),
maxLength: normalizeSchemaNonNegativeInteger(value.maxLength),
+ pattern: normalizeSchemaPattern(value.pattern, displayPath),
+ minItems: normalizeSchemaNonNegativeInteger(value.minItems),
+ maxItems: normalizeSchemaNonNegativeInteger(value.maxItems),
refTable: typeof value["x-gframework-ref-table"] === "string"
? value["x-gframework-ref-table"]
: undefined
@@ -494,6 +543,8 @@ function parseSchemaNode(rawNode, displayPath) {
title: metadata.title,
description: metadata.description,
defaultValue: metadata.defaultValue,
+ minItems: metadata.minItems,
+ maxItems: metadata.maxItems,
refTable: metadata.refTable,
items: itemNode
};
@@ -508,15 +559,24 @@ function parseSchemaNode(rawNode, displayPath) {
minimum: type === "integer" || type === "number"
? metadata.minimum
: undefined,
+ exclusiveMinimum: type === "integer" || type === "number"
+ ? metadata.exclusiveMinimum
+ : undefined,
maximum: type === "integer" || type === "number"
? metadata.maximum
: undefined,
+ exclusiveMaximum: type === "integer" || type === "number"
+ ? metadata.exclusiveMaximum
+ : undefined,
minLength: type === "string"
? metadata.minLength
: undefined,
maxLength: type === "string"
? metadata.maxLength
: undefined,
+ pattern: type === "string"
+ ? metadata.pattern
+ : undefined,
enumValues: normalizeSchemaEnumValues(value.enum),
refTable: metadata.refTable
};
@@ -548,6 +608,28 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
return;
}
+ if (typeof schemaNode.minItems === "number" &&
+ yamlNode.items.length < schemaNode.minItems) {
+ diagnostics.push({
+ severity: "error",
+ message: localizeValidationMessage(ValidationMessageKeys.minItemsViolation, localizer, {
+ displayPath,
+ value: String(schemaNode.minItems)
+ })
+ });
+ }
+
+ if (typeof schemaNode.maxItems === "number" &&
+ yamlNode.items.length > schemaNode.maxItems) {
+ diagnostics.push({
+ severity: "error",
+ message: localizeValidationMessage(ValidationMessageKeys.maxItemsViolation, localizer, {
+ displayPath,
+ value: String(schemaNode.maxItems)
+ })
+ });
+ }
+
for (let index = 0; index < yamlNode.items.length; index += 1) {
validateNode(
schemaNode.items,
@@ -597,6 +679,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
const scalarValue = unquoteScalar(yamlNode.value);
const supportsNumericConstraints = schemaNode.type === "integer" || schemaNode.type === "number";
const supportsLengthConstraints = schemaNode.type === "string";
+ const supportsPatternConstraints = schemaNode.type === "string";
if (supportsNumericConstraints &&
typeof schemaNode.minimum === "number" &&
@@ -610,6 +693,18 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
});
}
+ if (supportsNumericConstraints &&
+ typeof schemaNode.exclusiveMinimum === "number" &&
+ Number(scalarValue) <= schemaNode.exclusiveMinimum) {
+ diagnostics.push({
+ severity: "error",
+ message: localizeValidationMessage(ValidationMessageKeys.exclusiveMinimumViolation, localizer, {
+ displayPath,
+ value: String(schemaNode.exclusiveMinimum)
+ })
+ });
+ }
+
if (supportsNumericConstraints &&
typeof schemaNode.maximum === "number" &&
Number(scalarValue) > schemaNode.maximum) {
@@ -622,6 +717,18 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
});
}
+ if (supportsNumericConstraints &&
+ typeof schemaNode.exclusiveMaximum === "number" &&
+ Number(scalarValue) >= schemaNode.exclusiveMaximum) {
+ diagnostics.push({
+ severity: "error",
+ message: localizeValidationMessage(ValidationMessageKeys.exclusiveMaximumViolation, localizer, {
+ displayPath,
+ value: String(schemaNode.exclusiveMaximum)
+ })
+ });
+ }
+
if (supportsLengthConstraints &&
typeof schemaNode.minLength === "number" &&
scalarValue.length < schemaNode.minLength) {
@@ -645,6 +752,17 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
})
});
}
+
+ if (supportsPatternConstraints &&
+ !matchesSchemaPattern(scalarValue, schemaNode.pattern, schemaNode.displayPath)) {
+ diagnostics.push({
+ severity: "error",
+ message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, {
+ displayPath,
+ value: schemaNode.pattern
+ })
+ });
+ }
}
/**
@@ -729,14 +847,24 @@ function localizeValidationMessage(key, localizer, params) {
return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前标量值不兼容。`;
case ValidationMessageKeys.enumMismatch:
return `属性“${params.displayPath}”必须是以下值之一:${params.values}。`;
+ case ValidationMessageKeys.exclusiveMaximumViolation:
+ return `属性“${params.displayPath}”必须小于 ${params.value}。`;
+ case ValidationMessageKeys.exclusiveMinimumViolation:
+ return `属性“${params.displayPath}”必须大于 ${params.value}。`;
case ValidationMessageKeys.maximumViolation:
return `属性“${params.displayPath}”必须小于或等于 ${params.value}。`;
+ case ValidationMessageKeys.maxItemsViolation:
+ return `属性“${params.displayPath}”最多只能包含 ${params.value} 个元素。`;
case ValidationMessageKeys.maxLengthViolation:
return `属性“${params.displayPath}”长度必须不超过 ${params.value} 个字符。`;
case ValidationMessageKeys.minimumViolation:
return `属性“${params.displayPath}”必须大于或等于 ${params.value}。`;
+ case ValidationMessageKeys.minItemsViolation:
+ return `属性“${params.displayPath}”至少需要包含 ${params.value} 个元素。`;
case ValidationMessageKeys.minLengthViolation:
return `属性“${params.displayPath}”长度必须至少为 ${params.value} 个字符。`;
+ case ValidationMessageKeys.patternViolation:
+ return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`;
case ValidationMessageKeys.expectedObject:
return params.subject;
case ValidationMessageKeys.missingRequired:
@@ -757,14 +885,24 @@ function localizeValidationMessage(key, localizer, params) {
return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current scalar value is incompatible.`;
case ValidationMessageKeys.enumMismatch:
return `Property '${params.displayPath}' must be one of: ${params.values}.`;
+ case ValidationMessageKeys.exclusiveMaximumViolation:
+ return `Property '${params.displayPath}' must be less than ${params.value}.`;
+ case ValidationMessageKeys.exclusiveMinimumViolation:
+ return `Property '${params.displayPath}' must be greater than ${params.value}.`;
case ValidationMessageKeys.maximumViolation:
return `Property '${params.displayPath}' must be less than or equal to ${params.value}.`;
+ case ValidationMessageKeys.maxItemsViolation:
+ return `Property '${params.displayPath}' must contain at most ${params.value} items.`;
case ValidationMessageKeys.maxLengthViolation:
return `Property '${params.displayPath}' must be at most ${params.value} characters long.`;
case ValidationMessageKeys.minimumViolation:
return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`;
+ case ValidationMessageKeys.minItemsViolation:
+ return `Property '${params.displayPath}' must contain at least ${params.value} items.`;
case ValidationMessageKeys.minLengthViolation:
return `Property '${params.displayPath}' must be at least ${params.value} characters long.`;
+ case ValidationMessageKeys.patternViolation:
+ return `Property '${params.displayPath}' must match pattern '${params.value}'.`;
case ValidationMessageKeys.expectedObject:
return params.subject;
case ValidationMessageKeys.missingRequired:
@@ -1418,6 +1556,8 @@ module.exports = {
* title?: string,
* description?: string,
* defaultValue?: string,
+ * minItems?: number,
+ * maxItems?: number,
* refTable?: string,
* items: SchemaNode
* } | {
@@ -1426,6 +1566,13 @@ module.exports = {
* title?: string,
* description?: string,
* defaultValue?: string,
+ * minimum?: number,
+ * exclusiveMinimum?: number,
+ * maximum?: number,
+ * exclusiveMaximum?: number,
+ * minLength?: number,
+ * maxLength?: number,
+ * pattern?: string,
* enumValues?: string[],
* refTable?: string
* }} SchemaNode
diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js
index 5268bea3..870f01e7 100644
--- a/tools/gframework-config-tool/src/extension.js
+++ b/tools/gframework-config-tool/src/extension.js
@@ -1574,7 +1574,7 @@ function getScalarArrayValue(yamlNode) {
/**
* Render human-facing metadata hints for one schema field.
*
- * @param {{description?: string, defaultValue?: string, minimum?: number, maximum?: number, minLength?: number, maxLength?: number, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, maximum?: number, minLength?: number, maxLength?: number}, refTable?: string}} propertySchema Property schema metadata.
+ * @param {{description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata.
* @param {boolean} isArrayField Whether the field is an array.
* @returns {string} HTML fragment.
*/
@@ -1602,10 +1602,18 @@ function renderFieldHint(propertySchema, isArrayField) {
hints.push(escapeHtml(localizer.t("webview.hint.minimum", {value: propertySchema.minimum})));
}
+ if (!isArrayField && typeof propertySchema.exclusiveMinimum === "number") {
+ hints.push(escapeHtml(localizer.t("webview.hint.exclusiveMinimum", {value: propertySchema.exclusiveMinimum})));
+ }
+
if (!isArrayField && typeof propertySchema.maximum === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.maximum", {value: propertySchema.maximum})));
}
+ if (!isArrayField && typeof propertySchema.exclusiveMaximum === "number") {
+ hints.push(escapeHtml(localizer.t("webview.hint.exclusiveMaximum", {value: propertySchema.exclusiveMaximum})));
+ }
+
if (!isArrayField && typeof propertySchema.minLength === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.minLength", {value: propertySchema.minLength})));
}
@@ -1614,14 +1622,34 @@ function renderFieldHint(propertySchema, isArrayField) {
hints.push(escapeHtml(localizer.t("webview.hint.maxLength", {value: propertySchema.maxLength})));
}
+ if (!isArrayField && propertySchema.pattern) {
+ hints.push(escapeHtml(localizer.t("webview.hint.pattern", {value: propertySchema.pattern})));
+ }
+
+ if (isArrayField && typeof propertySchema.minItems === "number") {
+ hints.push(escapeHtml(localizer.t("webview.hint.minItems", {value: propertySchema.minItems})));
+ }
+
+ if (isArrayField && typeof propertySchema.maxItems === "number") {
+ hints.push(escapeHtml(localizer.t("webview.hint.maxItems", {value: propertySchema.maxItems})));
+ }
+
if (isArrayField && propertySchema.items && typeof propertySchema.items.minimum === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.itemMinimum", {value: propertySchema.items.minimum})));
}
+ if (isArrayField && propertySchema.items && typeof propertySchema.items.exclusiveMinimum === "number") {
+ hints.push(escapeHtml(localizer.t("webview.hint.itemExclusiveMinimum", {value: propertySchema.items.exclusiveMinimum})));
+ }
+
if (isArrayField && propertySchema.items && typeof propertySchema.items.maximum === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.itemMaximum", {value: propertySchema.items.maximum})));
}
+ if (isArrayField && propertySchema.items && typeof propertySchema.items.exclusiveMaximum === "number") {
+ hints.push(escapeHtml(localizer.t("webview.hint.itemExclusiveMaximum", {value: propertySchema.items.exclusiveMaximum})));
+ }
+
if (isArrayField && propertySchema.items && typeof propertySchema.items.minLength === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.itemMinLength", {value: propertySchema.items.minLength})));
}
@@ -1630,6 +1658,10 @@ function renderFieldHint(propertySchema, isArrayField) {
hints.push(escapeHtml(localizer.t("webview.hint.itemMaxLength", {value: propertySchema.items.maxLength})));
}
+ if (isArrayField && propertySchema.items && propertySchema.items.pattern) {
+ hints.push(escapeHtml(localizer.t("webview.hint.itemPattern", {value: propertySchema.items.pattern})));
+ }
+
if (propertySchema.refTable) {
hints.push(escapeHtml(localizer.t("webview.hint.refTable", {refTable: propertySchema.refTable})));
}
diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js
index 6c25487b..b73c3848 100644
--- a/tools/gframework-config-tool/src/localization.js
+++ b/tools/gframework-config-tool/src/localization.js
@@ -106,22 +106,35 @@ const enMessages = {
"webview.hint.default": "Default: {value}",
"webview.hint.allowed": "Allowed: {values}",
"webview.hint.minimum": "Minimum: {value}",
+ "webview.hint.exclusiveMinimum": "Exclusive minimum: {value}",
"webview.hint.maximum": "Maximum: {value}",
+ "webview.hint.exclusiveMaximum": "Exclusive maximum: {value}",
"webview.hint.minLength": "Min length: {value}",
"webview.hint.maxLength": "Max length: {value}",
+ "webview.hint.pattern": "Pattern: {value}",
+ "webview.hint.minItems": "Min items: {value}",
+ "webview.hint.maxItems": "Max items: {value}",
"webview.hint.itemMinimum": "Item minimum: {value}",
+ "webview.hint.itemExclusiveMinimum": "Item exclusive minimum: {value}",
"webview.hint.itemMaximum": "Item maximum: {value}",
+ "webview.hint.itemExclusiveMaximum": "Item exclusive maximum: {value}",
"webview.hint.itemMinLength": "Item min length: {value}",
"webview.hint.itemMaxLength": "Item max length: {value}",
+ "webview.hint.itemPattern": "Item pattern: {value}",
"webview.hint.refTable": "Ref table: {refTable}",
"webview.unsupported.array": "Unsupported array shapes are currently raw-YAML-only in the form preview.",
"webview.unsupported.type": "{type} fields are currently raw-YAML-only.",
"webview.unsupported.objectArrayMixed": "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items.",
"webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor.",
+ [ValidationMessageKeys.exclusiveMaximumViolation]: "Property '{displayPath}' must be less than {value}.",
+ [ValidationMessageKeys.exclusiveMinimumViolation]: "Property '{displayPath}' must be greater than {value}.",
[ValidationMessageKeys.maximumViolation]: "Property '{displayPath}' must be less than or equal to {value}.",
+ [ValidationMessageKeys.maxItemsViolation]: "Property '{displayPath}' must contain at most {value} items.",
[ValidationMessageKeys.maxLengthViolation]: "Property '{displayPath}' must be at most {value} characters long.",
[ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.",
+ [ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.",
[ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.",
+ [ValidationMessageKeys.patternViolation]: "Property '{displayPath}' must match pattern '{value}'.",
[ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.",
[ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.",
[ValidationMessageKeys.expectedObject]: "{subject} is expected to be an object.",
@@ -192,22 +205,35 @@ const zhCnMessages = {
"webview.hint.default": "默认值:{value}",
"webview.hint.allowed": "允许值:{values}",
"webview.hint.minimum": "最小值:{value}",
+ "webview.hint.exclusiveMinimum": "开区间最小值:{value}",
"webview.hint.maximum": "最大值:{value}",
+ "webview.hint.exclusiveMaximum": "开区间最大值:{value}",
"webview.hint.minLength": "最小长度:{value}",
"webview.hint.maxLength": "最大长度:{value}",
+ "webview.hint.pattern": "正则模式:{value}",
+ "webview.hint.minItems": "最少元素数:{value}",
+ "webview.hint.maxItems": "最多元素数:{value}",
"webview.hint.itemMinimum": "元素最小值:{value}",
+ "webview.hint.itemExclusiveMinimum": "元素开区间最小值:{value}",
"webview.hint.itemMaximum": "元素最大值:{value}",
+ "webview.hint.itemExclusiveMaximum": "元素开区间最大值:{value}",
"webview.hint.itemMinLength": "元素最小长度:{value}",
"webview.hint.itemMaxLength": "元素最大长度:{value}",
+ "webview.hint.itemPattern": "元素正则模式:{value}",
"webview.hint.refTable": "引用表:{refTable}",
"webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。",
"webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。",
"webview.unsupported.objectArrayMixed": "对象数组中的每一项都必须是映射对象。如果当前文件混用了标量项和对象项,请改用原始 YAML。",
"webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。",
+ [ValidationMessageKeys.exclusiveMaximumViolation]: "属性“{displayPath}”必须小于 {value}。",
+ [ValidationMessageKeys.exclusiveMinimumViolation]: "属性“{displayPath}”必须大于 {value}。",
[ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。",
+ [ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。",
[ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。",
[ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。",
+ [ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。",
[ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。",
+ [ValidationMessageKeys.patternViolation]: "属性“{displayPath}”必须匹配正则模式“{value}”。",
[ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。",
[ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。",
[ValidationMessageKeys.expectedObject]: "{subject}",
diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js
index 785f51ee..2eb991ff 100644
--- a/tools/gframework-config-tool/src/localizationKeys.js
+++ b/tools/gframework-config-tool/src/localizationKeys.js
@@ -1,14 +1,19 @@
const ValidationMessageKeys = Object.freeze({
enumMismatch: "validation.enumMismatch",
+ exclusiveMaximumViolation: "validation.exclusiveMaximumViolation",
+ exclusiveMinimumViolation: "validation.exclusiveMinimumViolation",
expectedArray: "validation.expectedArray",
expectedObject: "validation.expectedObject",
expectedScalarShape: "validation.expectedScalarShape",
expectedScalarValue: "validation.expectedScalarValue",
maximumViolation: "validation.maximumViolation",
+ maxItemsViolation: "validation.maxItemsViolation",
maxLengthViolation: "validation.maxLengthViolation",
minimumViolation: "validation.minimumViolation",
+ minItemsViolation: "validation.minItemsViolation",
minLengthViolation: "validation.minLengthViolation",
missingRequired: "validation.missingRequired",
+ patternViolation: "validation.patternViolation",
unknownProperty: "validation.unknownProperty"
});
diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js
index a88becb6..0c3ebaab 100644
--- a/tools/gframework-config-tool/test/configValidation.test.js
+++ b/tools/gframework-config-tool/test/configValidation.test.js
@@ -231,6 +231,83 @@ tags:
assert.match(diagnostics[2].message, /tags\[1\]|shield/u);
});
+test("validateParsedConfig should report exclusive bounds, pattern, and array item-count mismatches", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "pattern": "^[A-Z][a-z]+$"
+ },
+ "hp": {
+ "type": "integer",
+ "exclusiveMinimum": 10,
+ "exclusiveMaximum": 20
+ },
+ "tags": {
+ "type": "array",
+ "minItems": 2,
+ "maxItems": 3,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ `);
+ const yaml = parseTopLevelYaml(`
+name: slime
+hp: 10
+tags:
+ - onlyOne
+`);
+
+ const diagnostics = validateParsedConfig(schema, yaml);
+
+ assert.equal(diagnostics.length, 3);
+ assert.match(diagnostics[0].message, /pattern|正则模式/u);
+ assert.match(diagnostics[1].message, /greater than 10|大于 10/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", () => {
const schema = parseSchemaContent(`
{
@@ -266,6 +343,58 @@ test("parseSchemaContent should capture scalar range and length metadata", () =>
assert.equal(schema.properties.tags.items.maxLength, 6);
});
+test("parseSchemaContent should capture exclusive bounds, pattern, and array item-count metadata", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "pattern": "^[A-Z][a-z]+$"
+ },
+ "hp": {
+ "type": "integer",
+ "exclusiveMinimum": 1,
+ "exclusiveMaximum": 99
+ },
+ "tags": {
+ "type": "array",
+ "minItems": 2,
+ "maxItems": 4,
+ "items": {
+ "type": "string",
+ "pattern": "^[a-z]+$"
+ }
+ }
+ }
+ }
+ `);
+
+ assert.equal(schema.properties.name.pattern, "^[A-Z][a-z]+$");
+ assert.equal(schema.properties.hp.exclusiveMinimum, 1);
+ assert.equal(schema.properties.hp.exclusiveMaximum, 99);
+ assert.equal(schema.properties.tags.minItems, 2);
+ assert.equal(schema.properties.tags.maxItems, 4);
+ 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", () => {
const schema = parseSchemaContent(`
{