docs(config): 添加游戏内容配置系统完整文档

- 新增 CI/CD 工作流配置文件,集成代码质量检查、安全扫描和构建测试
- 详细介绍配置系统架构,包括 YAML 源文件、JSON Schema 结构描述和运行时只读查询
- 提供完整的目录结构推荐和 Schema/JSON 示例配置
- 包含项目接入模板,涵盖 csproj 配置、启动帮助器和运行时读取模板
- 说明运行时校验行为,支持必填字段、类型匹配、数值范围等校验规则
- 介绍开发期热重载功能,支持配置文件变更自动刷新
- 详述生成器接入约定,包括配置类型、表包装和注册辅助生成
- 提供 VS Code 工具使用指南,支持配置浏览、表单编辑和批量操作
- 说明当前系统限制和未来发展规划,明确适用场景
This commit is contained in:
GeWuYou 2026-04-06 21:19:49 +08:00
parent c732285dfb
commit bba589a853
8 changed files with 511 additions and 50 deletions

View File

@ -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

View File

@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
@ -126,6 +127,92 @@ public class GameConfigBootstrapTests
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>

View File

@ -413,10 +413,10 @@ public class YamlConfigLoaderTests
}
/// <summary>
/// 验证开区间数值边界约束会在运行时被统一拒绝。
/// 验证数值命中开区间下界时会按 schema 在运行时被拒绝。
/// </summary>
[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(
"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>
@ -560,10 +609,56 @@ public class YamlConfigLoaderTests
}
/// <summary>
/// 验证数组元素数量约束会在运行时被统一拒绝
/// 验证运行时 schema 校验与 JS 工具对反向引用模式保持一致
/// </summary>
[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(
"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>
/// 验证启用 schema 校验后,未知字段不会再被静默忽略。
/// </summary>

View File

@ -8,15 +8,25 @@ namespace GFramework.Game.Config;
/// 该类型负责把配置注册表、YAML 加载器与开发期热重载句柄收敛到一个长生命周期对象中,
/// 让消费者项目可以通过一个稳定入口完成配置启动,而不是在多个脚本里重复拼装运行时细节。
/// </summary>
/// <remarks>
/// 生命周期转换会串行化执行,因此并发调用只会观察到已经提交完成的加载器与热重载句柄。
/// 如果初始化或热重载启动在中途失败,当前实例会保留失败前的稳定状态,而不会公开半初始化对象。
/// </remarks>
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;
/// <summary>
/// 使用指定选项创建配置启动帮助器。
@ -35,14 +45,14 @@ public sealed class GameConfigBootstrap : IDisposable
{
throw new ArgumentException(
RootPathCannotBeNullOrWhiteSpaceMessage,
nameof(options.RootPath));
nameof(options));
}
if (options.ConfigureLoader == null)
{
throw new ArgumentException(
ConfigureLoaderCannotBeNullMessage,
nameof(options.ConfigureLoader));
nameof(options));
}
_options = options;
@ -63,13 +73,34 @@ public sealed class GameConfigBootstrap : IDisposable
/// <summary>
/// 获取一个值,指示启动帮助器是否已经成功完成初次加载。
/// 该状态只会在完整初始化链路(包括可选热重载启动)成功后才对外可见,
/// 避免并发调用观察到半初始化生命周期。
/// </summary>
public bool IsInitialized => _loader != null;
public bool IsInitialized
{
get
{
lock (_stateGate)
{
return _loader != null;
}
}
}
/// <summary>
/// 获取一个值,指示开发期热重载是否已启用。
/// 只有当监听句柄已经成功创建并提交到当前生命周期后,该属性才会返回 <see langword="true" />。
/// </summary>
public bool IsHotReloadEnabled => _hotReload != null;
public bool IsHotReloadEnabled
{
get
{
lock (_stateGate)
{
return _hotReload != null;
}
}
}
/// <summary>
/// 获取当前生效的 YAML 配置加载器。
@ -81,10 +112,13 @@ public sealed class GameConfigBootstrap : IDisposable
{
get
{
ThrowIfDisposed();
lock (_stateGate)
{
ThrowIfDisposedCore();
return _loader ?? throw new InvalidOperationException(
"The config bootstrap has not been initialized yet.");
return _loader ?? throw new InvalidOperationException(
"The config bootstrap has not been initialized yet.");
}
}
}
@ -99,24 +133,59 @@ public sealed class GameConfigBootstrap : IDisposable
/// <exception cref="ConfigLoadException">当配置加载失败时抛出。</exception>
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (_loader != null)
lock (_stateGate)
{
throw new InvalidOperationException(
"The config bootstrap can only be initialized once per instance.");
ThrowIfDisposedCore();
if (_isInitializing || _loader != null)
{
throw new InvalidOperationException(
"The config bootstrap can only be initialized once per instance.");
}
_isInitializing = true;
}
var loader = new YamlConfigLoader(RootPath);
_options.ConfigureLoader!(loader);
await loader.LoadAsync(Registry, cancellationToken);
IUnRegister? hotReload = null;
// 仅在初次加载完全成功后才公开加载器实例,避免上层观察到半初始化状态。
_loader = loader;
if (_options.EnableHotReload)
try
{
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>
public void StartHotReload(YamlConfigHotReloadOptions? options = null)
{
ThrowIfDisposed();
var loader = _loader ?? throw new InvalidOperationException(
"Hot reload can only be started after the initial config load succeeds.");
if (_hotReload != null)
YamlConfigLoader loader;
lock (_stateGate)
{
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>
@ -154,8 +276,19 @@ public sealed class GameConfigBootstrap : IDisposable
/// </summary>
public void StopHotReload()
{
var hotReload = _hotReload;
_hotReload = null;
IUnRegister? hotReload;
lock (_stateGate)
{
if (_isStartingHotReload && _hotReload == null)
{
_stopHotReloadAfterStart = true;
return;
}
hotReload = _hotReload;
_hotReload = null;
}
hotReload?.UnRegister();
}
@ -164,16 +297,29 @@ public sealed class GameConfigBootstrap : IDisposable
/// </summary>
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;
StopHotReload();
hotReload?.UnRegister();
}
private void ThrowIfDisposed()
private void ThrowIfDisposedCore()
{
if (_disposed)
{

View File

@ -10,6 +10,10 @@ namespace GFramework.Game.Config;
/// </summary>
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>
/// 从磁盘加载并解析一个 JSON Schema 文件。
/// </summary>
@ -828,7 +832,7 @@ internal static class YamlConfigSchemaValidator
? null
: new Regex(
pattern,
RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture));
SupportedPatternRegexOptions));
}
/// <summary>
@ -1005,7 +1009,7 @@ internal static class YamlConfigSchemaValidator
var pattern = patternElement.GetString() ?? string.Empty;
try
{
_ = new Regex(pattern, RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture);
_ = new Regex(pattern, SupportedPatternRegexOptions);
}
catch (ArgumentException exception)
{

View File

@ -607,7 +607,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
- `minimum` / `maximum`供运行时校验、VS Code 校验和生成代码 XML 文档复用
- `exclusiveMinimum` / `exclusiveMaximum`供运行时校验、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 文档复用
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。

View File

@ -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[],
@ -384,9 +385,11 @@ function normalizeSchemaNonNegativeInteger(value) {
* 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) {
function normalizeSchemaPattern(value, displayPath) {
if (typeof value !== "string") {
return undefined;
}
@ -394,8 +397,8 @@ function normalizeSchemaPattern(value) {
try {
void new RegExp(value);
return value;
} catch {
return undefined;
} catch (error) {
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 | 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) {
function matchesSchemaPattern(scalarValue, pattern, displayPath) {
if (typeof pattern !== "string") {
return true;
}
try {
return new RegExp(pattern).test(scalarValue);
} catch {
return true;
} catch (error) {
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),
minLength: normalizeSchemaNonNegativeInteger(value.minLength),
maxLength: normalizeSchemaNonNegativeInteger(value.maxLength),
pattern: normalizeSchemaPattern(value.pattern),
pattern: normalizeSchemaPattern(value.pattern, displayPath),
minItems: normalizeSchemaNonNegativeInteger(value.minItems),
maxItems: normalizeSchemaNonNegativeInteger(value.maxItems),
refTable: typeof value["x-gframework-ref-table"] === "string"
@ -749,7 +754,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
}
if (supportsPatternConstraints &&
!matchesSchemaPattern(scalarValue, schemaNode.pattern)) {
!matchesSchemaPattern(scalarValue, schemaNode.pattern, schemaNode.displayPath)) {
diagnostics.push({
severity: "error",
message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, {

View File

@ -271,6 +271,43 @@ tags:
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(`
{
@ -341,6 +378,23 @@ test("parseSchemaContent should capture exclusive bounds, pattern, and array ite
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(`
{