Compare commits

..

12 Commits

Author SHA1 Message Date
gewuyou
d120236e13
Merge pull request #190 from GeWuYou/feat/config-system-integration
Feat/config system integration
2026-04-06 23:01:14 +08:00
GeWuYou
d99af1cfac docs(game): 添加游戏内容配置系统文档
- 介绍面向静态游戏内容的 AI-First 配表方案
- 详细说明 YAML 配置源文件和 JSON Schema 结构描述功能
- 提供推荐的目录结构和完整的 Schema 与 YAML 示例
- 包含配置系统的接入模板和运行时读取方法
- 说明开发期热重载功能和 VS Code 工具集成
- 记录当前限制和独立 Config Studio 评估结论
- 提供 Architecture 推荐接入模板和热重载配置方法
2026-04-06 22:39:37 +08:00
GeWuYou
cd32a006c6 fix(tests): 修正配置加载器参数名称断言
- 将断言中的参数名从 "ConfigureLoader" 更新为 "options"
- 确保测试用例与实际实现保持一致
2026-04-06 22:15:30 +08:00
GeWuYou
bba589a853 docs(config): 添加游戏内容配置系统完整文档
- 新增 CI/CD 工作流配置文件,集成代码质量检查、安全扫描和构建测试
- 详细介绍配置系统架构,包括 YAML 源文件、JSON Schema 结构描述和运行时只读查询
- 提供完整的目录结构推荐和 Schema/JSON 示例配置
- 包含项目接入模板,涵盖 csproj 配置、启动帮助器和运行时读取模板
- 说明运行时校验行为,支持必填字段、类型匹配、数值范围等校验规则
- 介绍开发期热重载功能,支持配置文件变更自动刷新
- 详述生成器接入约定,包括配置类型、表包装和注册辅助生成
- 提供 VS Code 工具使用指南,支持配置浏览、表单编辑和批量操作
- 说明当前系统限制和未来发展规划,明确适用场景
2026-04-06 21:19:49 +08:00
GeWuYou
c732285dfb docs(config): 添加游戏内容配置系统文档和验证工具
- 新增游戏内容配置系统完整文档,涵盖 YAML 配置、JSON Schema 结构、目录组织
- 实现配置系统的运行时查询、类型生成、VS Code 插件集成等功能说明
- 添加 Schema 示例、YAML 示例和推荐接入模板
- 提供运行时读取、热重载、批处理编辑等功能的使用指南
- 实现配置校验行为、跨表引用、诊断对象等核心功能文档
- 集成开发期工具支持,包括表单编辑、批量更新、注释渲染等能力
- 添加架构接入模板和生产部署相关建议
2026-04-06 20:17:57 +08:00
GeWuYou
67149ab2b2 feat(config): 添加配置系统集成测试和官方启动帮助器
- 添加 ArchitectureConfigIntegrationTests 验证架构初始化流程中配置加载
- 实现 GameConfigBootstrap 收敛配置注册、加载与热重载生命周期管理
- 提供 GameConfigBootstrapOptions 配置启动约定选项对象
- 添加 GameConfigBootstrapTests 验证启动帮助器功能完整性
- 更新中文文档详述配置系统接入模板和最佳实践
- 提供 Architecture 推荐接入模板简化框架集成步骤
- 实现热重载支持和错误诊断机制提升开发体验
2026-04-06 18:41:05 +08:00
gewuyou
a76630ad16
Merge pull request #189 from GeWuYou/feat/config-system-integration
feat(config): 添加配置系统集成测试和文档
2026-04-06 17:58:42 +08:00
GeWuYou
975f556ab0 docs(config): 添加AI代理编码规范和配置系统集成测试
- 创建AGENTS.md文档定义AI代理编码行为规则
- 包含环境能力清单、注释规则、代码风格要求
- 定义测试要求、安全规则和文档规范
- 添加端到端集成测试验证生成配置消费者功能
- 实现跨域配置表加载和强类型访问验证
- 添加按域、表名和自定义谓词过滤注册支持
- 提供完整的测试验证命令和执行期望说明
2026-04-06 17:24:04 +08:00
GeWuYou
92eb365dc7 feat(config): 添加配置系统集成测试和文档
- 添加 ArchitectureConfigIntegrationTests 验证架构初始化流程中配置加载
- 添加 GeneratedConfigConsumerIntegrationTests 测试消费者项目配置绑定功能
- 添加完整的游戏内容配置系统中文文档
- 添加生成配置目录和注册选项支持批量表注册与筛选
- 实现配置架构集成模板和热重载功能
- 添加跨表引用校验和运行时诊断功能
- 实现 VS Code 工具支持配置浏览和表单编辑
- 添加查询辅助方法支持按字段快速检索配置数据
2026-04-06 16:38:42 +08:00
gewuyou
e9e04d9792
Merge pull request #188 from GeWuYou/feat/game-content-config-system
docs(config): 添加游戏内容配置系统文档和集成测试
2026-04-06 16:03:56 +08:00
GeWuYou
4f966f9f50 docs(game): 添加游戏内容配置系统文档和生成器功能
- 添加了 AI-First 配置系统完整文档,涵盖 YAML 配置、JSON Schema 结构、目录组织
- 实现了 Source Generator 自动生成配置类型、表包装、单表注册和访问辅助代码
- 提供了项目级聚合注册目录和配置浏览、校验、表单编辑等工具支持
- 集成了运行时只读查询、热重载、跨表引用校验等核心功能
- 添加了 VS Code 插件支持配置浏览、raw 编辑、schema 打开和递归校验功能
2026-04-06 15:55:58 +08:00
GeWuYou
83c0c57f10 docs(config): 添加游戏内容配置系统文档和集成测试
- 新增架构配置集成测试验证 YAML 配置加载功能
- 添加消费者项目配置生成器集成测试
- 创建完整的游戏内容配置系统中文文档
- 文档涵盖目录结构、Schema 示例、接入模板和运行时校验行为
- 提供 Architecture 推荐接入模板和热重载配置说明
- 完善 VS Code 工具功能介绍和当前限制说明
2026-04-06 15:17:33 +08:00
21 changed files with 2937 additions and 168 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

@ -123,6 +123,8 @@ All generated or modified code MUST include clear and meaningful comments where
- Every non-trivial feature, bug fix, or behavior change MUST include tests or an explicit justification for why a test
is not practical.
- Public API changes must be covered by unit or integration tests.
- When a public API defines multiple contract branches, tests MUST cover the meaningful variants, including null,
empty, default, and filtered inputs when those branches change behavior.
- Regression fixes should include a test that fails before the fix and passes after it.
### Test Organization

View File

@ -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;
/// <summary>
/// 验证在 <see cref="Architecture" /> 初始化流程中可以注册配置注册表、执行加载并通过生成的表访问器读取数据。
/// 验证在 <see cref="Architecture" /> 初始化流程中可以通过官方配置启动帮助器加载生成配置表,并通过表访问器读取数据。
/// </summary>
[TestFixture]
public class ArchitectureConfigIntegrationTests
{
/// <summary>
/// 架构初始化期间,通过 <see cref="YamlConfigLoader" /> 注册生成表
/// 并将 <see cref="ConfigRegistry" /> 作为 utility 暴露给架构上下文读取。
/// 架构初始化期间,通过 <see cref="GameConfigBootstrap" /> 收敛生成表注册、加载与注册表暴露
/// 并将 <see cref="IConfigRegistry" /> 作为 utility 暴露给架构上下文读取。
/// </summary>
[Test]
public async Task ConfigLoaderCanRunDuringArchitectureInitialization()
@ -42,7 +43,8 @@ public class ArchitectureConfigIntegrationTests
Assert.That(architecture.Registry.TryGetMonsterTable(out var retrieved), Is.True);
Assert.That(retrieved, Is.Not.Null);
Assert.That(retrieved!.Get(1).Name, Is.EqualTo("Slime"));
Assert.That(architecture.Context.GetUtility<ConfigRegistry>(), Is.SameAs(architecture.Registry));
Assert.That(architecture.Registry.TryGetItemTable(out _), Is.False);
Assert.That(architecture.Context.GetUtility<IConfigRegistry>(), Is.SameAs(architecture.Registry));
});
}
finally
@ -130,26 +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)
.RegisterMonsterTable();
loader.LoadAsync(Registry).GetAwaiter().GetResult();
_bootstrap.InitializeAsync().GetAwaiter().GetResult();
MonsterTable = Registry.GetMonsterTable();
}
public override async ValueTask DestroyAsync()
{
_bootstrap.Dispose();
await base.DestroyAsync();
}
}
}

View File

@ -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;
/// <summary>
/// 验证官方配置启动帮助器能够收敛注册、加载与热重载生命周期。
/// </summary>
[TestFixture]
public class GameConfigBootstrapTests
{
/// <summary>
/// 为每个测试准备独立的临时配置目录。
/// </summary>
[SetUp]
public void SetUp()
{
_rootPath = Path.Combine(Path.GetTempPath(), "GFramework.GameConfigBootstrapTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_rootPath);
}
/// <summary>
/// 清理测试期间创建的临时目录。
/// </summary>
[TearDown]
public void TearDown()
{
if (Directory.Exists(_rootPath))
{
Directory.Delete(_rootPath, true);
}
}
private string _rootPath = null!;
/// <summary>
/// 验证启动帮助器能够加载生成表,并复用调用方显式提供的注册表实例。
/// </summary>
[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));
});
}
/// <summary>
/// 验证启动帮助器可以在初始化后显式启用热重载,并将刷新结果写回共享注册表。
/// </summary>
[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<string>(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();
}
}
/// <summary>
/// 验证缺少加载器配置回调时会在构造阶段被拒绝,避免启动帮助器静默创建空加载流程。
/// </summary>
[Test]
public void Constructor_Should_Throw_When_ConfigureLoader_Is_Missing()
{
var exception = Assert.Throws<ArgumentException>(() =>
_ = new GameConfigBootstrap(
new GameConfigBootstrapOptions
{
RootPath = _rootPath
}));
Assert.That(exception!.ParamName, Is.EqualTo("options"));
}
/// <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>
/// <param name="registry">可选的外部注册表;为空时使用默认注册表。</param>
/// <returns>已配置但尚未初始化的启动帮助器。</returns>
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 }
})
});
}
/// <summary>
/// 在临时消费者根目录中创建测试文件。
/// </summary>
/// <param name="relativePath">相对根目录的文件路径。</param>
/// <param name="content">要写入的文件内容。</param>
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));
}
/// <summary>
/// 在临时目录中创建 monster schema 与 YAML 测试数据。
/// </summary>
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
""");
}
/// <summary>
/// 在限定时间内等待异步任务完成,避免文件监听测试无限挂起。
/// </summary>
/// <typeparam name="T">任务结果类型。</typeparam>
/// <param name="task">要等待的任务。</param>
/// <param name="timeout">超时时间。</param>
/// <returns>任务结果。</returns>
private static async Task<T> WaitForTaskWithinAsync<T>(Task<T> 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;
}
}

View File

@ -8,7 +8,7 @@ namespace GFramework.Game.Tests.Config;
/// <summary>
/// 验证消费者项目通过 `schemas/**/*.schema.json` 自动拾取 schema 后,
/// 可以直接编译并使用生成的注册辅助、强类型访问入口、查询辅助与运行时加载链路。
/// 可以直接编译并使用生成的聚合注册辅助、强类型访问入口、查询辅助与运行时加载链路。
/// </summary>
[TestFixture]
public class GeneratedConfigConsumerIntegrationTests
@ -39,10 +39,171 @@ public class GeneratedConfigConsumerIntegrationTests
/// <summary>
/// 验证生成器自动拾取消费者项目的 schema 后,
/// 可以用生成的注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。
/// 可以用生成的聚合注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。
/// </summary>
[Test]
public async Task LoadAsync_Should_Support_Generated_Bindings_In_Consumer_Project()
{
CreateMonsterFiles();
CreateItemFiles();
var registry = new ConfigRegistry();
var loader = new YamlConfigLoader(_rootPath)
.RegisterAllGeneratedConfigTables();
await loader.LoadAsync(registry);
var monsterTable = registry.GetMonsterTable();
var dungeonMonsters = monsterTable.FindByFaction("dungeon");
var itemTable = registry.GetItemTable();
Assert.Multiple(() =>
{
Assert.That(
GeneratedConfigCatalog.Tables.Select(static metadata => metadata.TableName),
Is.SupersetOf(new[] { "item", "monster" }));
Assert.That(GeneratedConfigCatalog.TryGetByTableName("item", out var itemCatalogEntry), Is.True);
Assert.That(itemCatalogEntry.ConfigDomain, Is.EqualTo("item"));
Assert.That(itemCatalogEntry.ConfigRelativePath, Is.EqualTo("item"));
Assert.That(itemCatalogEntry.SchemaRelativePath, Is.EqualTo("schemas/item.schema.json"));
Assert.That(GeneratedConfigCatalog.TryGetByTableName("monster", out var catalogEntry), Is.True);
Assert.That(catalogEntry.ConfigDomain, Is.EqualTo("monster"));
Assert.That(catalogEntry.ConfigRelativePath, Is.EqualTo("monster"));
Assert.That(catalogEntry.SchemaRelativePath, Is.EqualTo("schemas/monster.schema.json"));
Assert.That(ItemConfigBindings.ConfigDomain, Is.EqualTo("item"));
Assert.That(ItemConfigBindings.Metadata.TableName, Is.EqualTo("item"));
Assert.That(MonsterConfigBindings.ConfigDomain, Is.EqualTo("monster"));
Assert.That(MonsterConfigBindings.TableName, Is.EqualTo("monster"));
Assert.That(MonsterConfigBindings.ConfigRelativePath, Is.EqualTo("monster"));
Assert.That(MonsterConfigBindings.SchemaRelativePath, Is.EqualTo("schemas/monster.schema.json"));
Assert.That(MonsterConfigBindings.Metadata.ConfigDomain, Is.EqualTo(MonsterConfigBindings.ConfigDomain));
Assert.That(MonsterConfigBindings.Metadata.TableName, Is.EqualTo(MonsterConfigBindings.TableName));
Assert.That(MonsterConfigBindings.Metadata.ConfigRelativePath,
Is.EqualTo(MonsterConfigBindings.ConfigRelativePath));
Assert.That(MonsterConfigBindings.Metadata.SchemaRelativePath,
Is.EqualTo(MonsterConfigBindings.SchemaRelativePath));
Assert.That(MonsterConfigBindings.References.All, Is.Empty);
Assert.That(MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out _), Is.False);
Assert.That(monsterTable.Count, Is.EqualTo(2));
Assert.That(monsterTable.Get(1).Name, Is.EqualTo("Slime"));
Assert.That(monsterTable.Get(2).Hp, Is.EqualTo(30));
Assert.That(monsterTable.FindByName("Slime").Select(static config => config.Id), Is.EqualTo(new[] { 1 }));
Assert.That(dungeonMonsters.Select(static config => config.Name), Is.EquivalentTo(new[] { "Slime", "Goblin" }));
Assert.That(monsterTable.TryFindFirstByName("Goblin", out var goblin), Is.True);
Assert.That(goblin, Is.Not.Null);
Assert.That(goblin!.Id, Is.EqualTo(2));
Assert.That(monsterTable.TryFindFirstByFaction("dungeon", out var firstDungeonMonster), Is.True);
Assert.That(firstDungeonMonster, Is.Not.Null);
Assert.That(firstDungeonMonster!.Name, Is.AnyOf("Slime", "Goblin"));
Assert.That(monsterTable.TryFindFirstByFaction("forest", out var missingMonster), Is.False);
Assert.That(missingMonster, Is.Null);
Assert.That(registry.TryGetMonsterTable(out var generatedTable), Is.True);
Assert.That(generatedTable, Is.Not.Null);
Assert.That(generatedTable!.All().Select(static config => config.Name),
Is.EquivalentTo(new[] { "Slime", "Goblin" }));
Assert.That(itemTable.Count, Is.EqualTo(2));
Assert.That(itemTable.Get("potion").Name, Is.EqualTo("Potion"));
Assert.That(itemTable.FindByCategory("consumable").Select(static config => config.Id),
Is.EquivalentTo(new[] { "potion", "ether" }));
Assert.That(registry.TryGetItemTable(out var generatedItemTable), Is.True);
Assert.That(generatedItemTable, Is.Not.Null);
Assert.That(generatedItemTable!.Get("ether").Name, Is.EqualTo("Ether"));
});
}
/// <summary>
/// 验证聚合注册入口可以通过生成配置域、表名集合和自定义谓词收敛多表项目的启动粒度。
/// </summary>
[Test]
public async Task RegisterAllGeneratedConfigTables_Should_Support_Filtering_By_Domain_Table_Name_And_Predicate()
{
CreateMonsterFiles();
CreateItemFiles();
var domainRegistry = new ConfigRegistry();
var domainLoader = new YamlConfigLoader(_rootPath)
.RegisterAllGeneratedConfigTables(
new GeneratedConfigRegistrationOptions
{
IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
});
await domainLoader.LoadAsync(domainRegistry);
var tableNameRegistry = new ConfigRegistry();
var tableNameLoader = new YamlConfigLoader(_rootPath)
.RegisterAllGeneratedConfigTables(
new GeneratedConfigRegistrationOptions
{
IncludedTableNames = new[] { ItemConfigBindings.TableName }
});
await tableNameLoader.LoadAsync(tableNameRegistry);
var emptyAllowListRegistry = new ConfigRegistry();
var emptyAllowListLoader = new YamlConfigLoader(_rootPath)
.RegisterAllGeneratedConfigTables(
new GeneratedConfigRegistrationOptions
{
IncludedConfigDomains = Array.Empty<string>(),
IncludedTableNames = Array.Empty<string>()
});
await emptyAllowListLoader.LoadAsync(emptyAllowListRegistry);
var monsterDomain = MonsterConfigBindings.ConfigDomain;
var predicateRegistry = new ConfigRegistry();
var predicateLoader = new YamlConfigLoader(_rootPath)
.RegisterAllGeneratedConfigTables(
new GeneratedConfigRegistrationOptions
{
TableFilter = metadata =>
string.Equals(metadata.ConfigDomain, monsterDomain, StringComparison.Ordinal)
});
await predicateLoader.LoadAsync(predicateRegistry);
Assert.Multiple(() =>
{
Assert.That(emptyAllowListRegistry.TryGetMonsterTable(out var emptyAllowListMonsterTable), Is.True);
Assert.That(emptyAllowListMonsterTable, Is.Not.Null);
Assert.That(emptyAllowListRegistry.TryGetItemTable(out var emptyAllowListItemTable), Is.True);
Assert.That(emptyAllowListItemTable, Is.Not.Null);
Assert.That(domainRegistry.TryGetMonsterTable(out var domainMonsterTable), Is.True);
Assert.That(domainMonsterTable, Is.Not.Null);
Assert.That(domainRegistry.TryGetItemTable(out _), Is.False);
Assert.That(tableNameRegistry.TryGetMonsterTable(out _), Is.False);
Assert.That(tableNameRegistry.TryGetItemTable(out var tableNameItemTable), Is.True);
Assert.That(tableNameItemTable, Is.Not.Null);
Assert.That(tableNameItemTable!.Get("potion").Name, Is.EqualTo("Potion"));
Assert.That(predicateRegistry.TryGetMonsterTable(out var predicateMonsterTable), Is.True);
Assert.That(predicateMonsterTable, Is.Not.Null);
Assert.That(predicateRegistry.TryGetItemTable(out _), Is.False);
});
}
/// <summary>
/// 在临时消费者根目录中创建测试文件。
/// </summary>
/// <param name="relativePath">相对根目录的文件路径。</param>
/// <param name="content">要写入的文件内容。</param>
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));
}
/// <summary>
/// 在临时消费者目录中创建 monster schema 与 YAML 测试数据。
/// </summary>
private void CreateMonsterFiles()
{
CreateFile(
"schemas/monster.schema.json",
@ -88,66 +249,50 @@ public class GeneratedConfigConsumerIntegrationTests
hp: 30
faction: dungeon
""");
var registry = new ConfigRegistry();
var loader = new YamlConfigLoader(_rootPath)
.RegisterMonsterTable();
await loader.LoadAsync(registry);
var table = registry.GetMonsterTable();
var dungeonMonsters = table.FindByFaction("dungeon");
Assert.Multiple(() =>
{
Assert.That(MonsterConfigBindings.ConfigDomain, Is.EqualTo("monster"));
Assert.That(MonsterConfigBindings.TableName, Is.EqualTo("monster"));
Assert.That(MonsterConfigBindings.ConfigRelativePath, Is.EqualTo("monster"));
Assert.That(MonsterConfigBindings.SchemaRelativePath, Is.EqualTo("schemas/monster.schema.json"));
Assert.That(MonsterConfigBindings.Metadata.ConfigDomain, Is.EqualTo(MonsterConfigBindings.ConfigDomain));
Assert.That(MonsterConfigBindings.Metadata.TableName, Is.EqualTo(MonsterConfigBindings.TableName));
Assert.That(MonsterConfigBindings.Metadata.ConfigRelativePath,
Is.EqualTo(MonsterConfigBindings.ConfigRelativePath));
Assert.That(MonsterConfigBindings.Metadata.SchemaRelativePath,
Is.EqualTo(MonsterConfigBindings.SchemaRelativePath));
Assert.That(MonsterConfigBindings.References.All, Is.Empty);
Assert.That(MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out _), Is.False);
Assert.That(table.Count, Is.EqualTo(2));
Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
Assert.That(table.Get(2).Hp, Is.EqualTo(30));
Assert.That(table.FindByName("Slime").Select(static config => config.Id), Is.EqualTo(new[] { 1 }));
Assert.That(dungeonMonsters.Select(static config => config.Name), Is.EquivalentTo(new[] { "Slime", "Goblin" }));
Assert.That(table.TryFindFirstByName("Goblin", out var goblin), Is.True);
Assert.That(goblin, Is.Not.Null);
Assert.That(goblin!.Id, Is.EqualTo(2));
Assert.That(table.TryFindFirstByFaction("dungeon", out var firstDungeonMonster), Is.True);
Assert.That(firstDungeonMonster, Is.Not.Null);
Assert.That(firstDungeonMonster!.Name, Is.AnyOf("Slime", "Goblin"));
Assert.That(table.TryFindFirstByFaction("forest", out var missingMonster), Is.False);
Assert.That(missingMonster, Is.Null);
Assert.That(registry.TryGetMonsterTable(out var generatedTable), Is.True);
Assert.That(generatedTable, Is.Not.Null);
Assert.That(generatedTable!.All().Select(static config => config.Name),
Is.EquivalentTo(new[] { "Slime", "Goblin" }));
});
}
/// <summary>
/// 在临时消费者根目录中创建测试文件
/// 在临时消费者目录中创建 item schema 与 YAML 测试数据,用于验证多表聚合注册和筛选行为。
/// </summary>
/// <param name="relativePath">相对根目录的文件路径。</param>
/// <param name="content">要写入的文件内容。</param>
private void CreateFile(
string relativePath,
string content)
private void CreateItemFiles()
{
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));
CreateFile(
"schemas/item.schema.json",
"""
{
"title": "Item Config",
"description": "Defines one item entry for aggregate registration filtering integration tests.",
"type": "object",
"required": ["id", "name", "category"],
"properties": {
"id": {
"type": "string",
"description": "Item identifier."
},
"name": {
"type": "string",
"description": "Item display name."
},
"category": {
"type": "string",
"description": "Used by integration tests to validate generated non-unique queries."
}
}
}
""");
CreateFile(
"item/potion.yaml",
"""
id: potion
name: Potion
category: consumable
""");
CreateFile(
"item/ether.yaml",
"""
id: ether
name: Ether
category: consumable
""");
}
}

View File

@ -412,6 +412,104 @@ public class YamlConfigLoaderTests
});
}
/// <summary>
/// 验证数值命中开区间下界时会按 schema 在运行时被拒绝。
/// </summary>
[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<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("10"));
Assert.That(exception.Message, Does.Contain("greater than 10"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <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>
@ -461,6 +559,209 @@ public class YamlConfigLoaderTests
});
}
/// <summary>
/// 验证字符串正则模式约束会在运行时被统一拒绝。
/// </summary>
[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<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("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));
});
}
/// <summary>
/// 验证运行时 schema 校验与 JS 工具对反向引用模式保持一致。
/// </summary>
[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<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",
"""
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<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("4"));
Assert.That(exception.Message, Does.Contain("at most 3 items"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <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>
@ -1594,4 +1895,4 @@ public class YamlConfigLoaderTests
/// <param name="Id">配置主键。</param>
/// <param name="Name">配置名称。</param>
private sealed record ExistingConfigStub(int Id, string Name);
}
}

View File

@ -0,0 +1,24 @@
{
"title": "Item Config",
"description": "Defines one item entry for aggregate registration filtering integration tests.",
"type": "object",
"required": [
"id",
"name",
"category"
],
"properties": {
"id": {
"type": "string",
"description": "Item identifier."
},
"name": {
"type": "string",
"description": "Item display name."
},
"category": {
"type": "string",
"description": "Used by integration tests to validate generated non-unique queries."
}
}
}

View File

@ -0,0 +1,329 @@
using GFramework.Core.Abstractions.Events;
using GFramework.Game.Abstractions.Config;
namespace GFramework.Game.Config;
/// <summary>
/// 提供官方的 C# 配置启动帮助器。
/// 该类型负责把配置注册表、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>
/// 使用指定选项创建配置启动帮助器。
/// </summary>
/// <param name="options">配置启动约定。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="options" /> 为空时抛出。</exception>
/// <exception cref="ArgumentException">
/// 当 <paramref name="options" /> 的 <see cref="GameConfigBootstrapOptions.RootPath" /> 为空,
/// 或 <see cref="GameConfigBootstrapOptions.ConfigureLoader" /> 未提供时抛出。
/// </exception>
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();
}
/// <summary>
/// 获取配置根目录。
/// </summary>
public string RootPath { get; }
/// <summary>
/// 获取当前配置生命周期共享的注册表。
/// 默认情况下该实例由启动帮助器创建;如调用方传入自定义注册表,则返回同一个对象。
/// </summary>
public IConfigRegistry Registry { get; }
/// <summary>
/// 获取一个值,指示启动帮助器是否已经成功完成初次加载。
/// 该状态只会在完整初始化链路(包括可选热重载启动)成功后才对外可见,
/// 避免并发调用观察到半初始化生命周期。
/// </summary>
public bool IsInitialized
{
get
{
lock (_stateGate)
{
return _loader != null;
}
}
}
/// <summary>
/// 获取一个值,指示开发期热重载是否已启用。
/// 只有当监听句柄已经成功创建并提交到当前生命周期后,该属性才会返回 <see langword="true" />。
/// </summary>
public bool IsHotReloadEnabled
{
get
{
lock (_stateGate)
{
return _hotReload != null;
}
}
}
/// <summary>
/// 获取当前生效的 YAML 配置加载器。
/// 只有在 <see cref="InitializeAsync" /> 成功返回后该属性才可访问。
/// </summary>
/// <exception cref="ObjectDisposedException">当当前实例已释放时抛出。</exception>
/// <exception cref="InvalidOperationException">当启动帮助器尚未初始化成功时抛出。</exception>
public YamlConfigLoader Loader
{
get
{
lock (_stateGate)
{
ThrowIfDisposedCore();
return _loader ?? throw new InvalidOperationException(
"The config bootstrap has not been initialized yet.");
}
}
}
/// <summary>
/// 执行初次配置加载,并在需要时启动开发期热重载。
/// 该方法只能成功调用一次,避免同一个生命周期对象在运行中被重新拼装为另一套加载约定。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示异步初始化流程的任务。</returns>
/// <exception cref="ObjectDisposedException">当当前实例已释放时抛出。</exception>
/// <exception cref="InvalidOperationException">当当前实例已经初始化成功时抛出。</exception>
/// <exception cref="ConfigLoadException">当配置加载失败时抛出。</exception>
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;
}
}
/// <summary>
/// 启用开发期热重载。
/// 该入口让调用方可以先完成一次确定性的初始加载,再按环境决定是否追加文件监听。
/// </summary>
/// <param name="options">热重载选项;为空时使用 <see cref="YamlConfigLoader" /> 的默认行为。</param>
/// <exception cref="ObjectDisposedException">当当前实例已释放时抛出。</exception>
/// <exception cref="InvalidOperationException">
/// 当初始加载尚未完成,或热重载已经处于启用状态时抛出。
/// </exception>
/// <exception cref="ArgumentOutOfRangeException">
/// 当 <paramref name="options" /> 的 <see cref="YamlConfigHotReloadOptions.DebounceDelay" /> 小于
/// <see cref="TimeSpan.Zero" /> 时抛出。
/// </exception>
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;
}
}
/// <summary>
/// 停止开发期热重载并释放监听资源。
/// 该方法是幂等的,允许启动层在销毁阶段无条件调用。
/// </summary>
public void StopHotReload()
{
IUnRegister? hotReload;
lock (_stateGate)
{
if (_isStartingHotReload && _hotReload == null)
{
_stopHotReloadAfterStart = true;
return;
}
hotReload = _hotReload;
_hotReload = null;
}
hotReload?.UnRegister();
}
/// <summary>
/// 停止热重载并释放当前帮助器持有的资源。
/// </summary>
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));
}
}
}

View File

@ -0,0 +1,41 @@
using GFramework.Game.Abstractions.Config;
namespace GFramework.Game.Config;
/// <summary>
/// 描述官方配置启动帮助器的初始化约定。
/// 该选项对象把配置根目录、表注册回调和热重载策略收敛到一个稳定入口,
/// 让消费项目不必在多个启动脚本里重复拼装加载器细节。
/// </summary>
public sealed class GameConfigBootstrapOptions
{
/// <summary>
/// 获取或设置配置根目录。
/// 该路径会直接传给 <see cref="YamlConfigLoader" /> 作为 YAML 与 schema 的共同根目录。
/// </summary>
public string RootPath { get; init; } = string.Empty;
/// <summary>
/// 获取或设置用于配置 <see cref="YamlConfigLoader" /> 的回调。
/// 调用方通常应在这里调用生成器产出的 <c>RegisterAllGeneratedConfigTables()</c>
/// 或显式注册当前场景所需的手写表定义。
/// </summary>
public Action<YamlConfigLoader>? ConfigureLoader { get; init; }
/// <summary>
/// 获取或设置要复用的配置注册表。
/// 为空时启动帮助器会创建默认的 <see cref="ConfigRegistry" /> 实例。
/// </summary>
public IConfigRegistry? Registry { get; init; }
/// <summary>
/// 获取或设置是否在初次加载成功后立即启用开发期热重载。
/// </summary>
public bool EnableHotReload { get; init; }
/// <summary>
/// 获取或设置初始化阶段启用热重载时使用的选项。
/// 当 <see cref="EnableHotReload" /> 为 <see langword="false" /> 时,该值会被忽略。
/// </summary>
public YamlConfigHotReloadOptions? HotReloadOptions { get; init; }
}

View File

@ -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;
/// </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>
@ -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
}
/// <summary>
/// 解析标量字段支持的范围与长度约束。
/// 当前共享子集只支持 `integer/number` 上的 `minimum/maximum` 和 `string` 上的 `minLength/maxLength`。
/// 解析标量字段支持的范围、长度与模式约束。
/// 当前共享子集支持:
/// `integer/number` 上的 `minimum/maximum/exclusiveMinimum/exclusiveMaximum`
/// 以及 `string` 上的 `minLength/maxLength/pattern`。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
@ -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));
}
/// <summary>
/// 解析数组节点支持的元素数量约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">数组字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <returns>数组约束模型;未声明时返回空。</returns>
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);
}
/// <summary>
@ -886,6 +965,180 @@ internal static class YamlConfigSchemaValidator
return constraintValue;
}
/// <summary>
/// 读取字符串正则约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="nodeType">字段类型。</param>
/// <returns>正则模式;未声明时返回空。</returns>
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;
}
/// <summary>
/// 读取数组元素数量约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="keywordName">关键字名称。</param>
/// <returns>数组元素数量约束;未声明时返回空。</returns>
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;
}
/// <summary>
/// 校验数值上下界组合不会形成空区间。
/// 这里把闭区间与开区间统一折算为最强边界,避免 schema 进入“无任何合法值”的状态。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="minimum">闭区间最小值。</param>
/// <param name="maximum">闭区间最大值。</param>
/// <param name="exclusiveMinimum">开区间最小值。</param>
/// <param name="exclusiveMaximum">开区间最大值。</param>
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));
}
}
/// <summary>
/// 校验标量值是否满足范围与长度约束。
/// </summary>
@ -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
}
}
/// <summary>
/// 校验数组值是否满足元素数量约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">字段路径。</param>
/// <param name="itemCount">当前数组元素数量。</param>
/// <param name="schemaNode">数组 schema 节点。</param>
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}.");
}
}
/// <summary>
/// 解析跨表引用目标表名称。
/// </summary>
@ -1323,6 +1666,7 @@ internal sealed class YamlConfigSchemaNode
/// <param name="referenceTableName">目标引用表名称。</param>
/// <param name="allowedValues">标量允许值集合。</param>
/// <param name="constraints">标量范围与长度约束。</param>
/// <param name="arrayConstraints">数组元素数量约束。</param>
/// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param>
public YamlConfigSchemaNode(
YamlConfigSchemaPropertyType nodeType,
@ -1332,6 +1676,7 @@ internal sealed class YamlConfigSchemaNode
string? referenceTableName,
IReadOnlyCollection<string>? 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
/// </summary>
public YamlConfigScalarConstraints? Constraints { get; }
/// <summary>
/// 获取数组元素数量约束;未声明时返回空。
/// </summary>
public YamlConfigArrayConstraints? ArrayConstraints { get; }
/// <summary>
/// 获取用于诊断显示的 schema 路径提示。
/// 当前节点本身不记录独立路径,因此对象校验会回退到所属根 schema 路径。
@ -1401,6 +1752,7 @@ internal sealed class YamlConfigSchemaNode
referenceTableName,
AllowedValues,
Constraints,
ArrayConstraints,
SchemaPathHint);
}
}
@ -1416,18 +1768,30 @@ internal sealed class YamlConfigScalarConstraints
/// </summary>
/// <param name="minimum">最小值约束。</param>
/// <param name="maximum">最大值约束。</param>
/// <param name="exclusiveMinimum">开区间最小值约束。</param>
/// <param name="exclusiveMaximum">开区间最大值约束。</param>
/// <param name="minLength">最小长度约束。</param>
/// <param name="maxLength">最大长度约束。</param>
/// <param name="pattern">正则模式约束。</param>
/// <param name="patternRegex">已编译的正则表达式。</param>
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;
}
/// <summary>
@ -1440,6 +1804,16 @@ internal sealed class YamlConfigScalarConstraints
/// </summary>
public double? Maximum { get; }
/// <summary>
/// 获取开区间最小值约束。
/// </summary>
public double? ExclusiveMinimum { get; }
/// <summary>
/// 获取开区间最大值约束。
/// </summary>
public double? ExclusiveMaximum { get; }
/// <summary>
/// 获取最小长度约束。
/// </summary>
@ -1449,6 +1823,44 @@ internal sealed class YamlConfigScalarConstraints
/// 获取最大长度约束。
/// </summary>
public int? MaxLength { get; }
/// <summary>
/// 获取正则模式约束原文。
/// </summary>
public string? Pattern { get; }
/// <summary>
/// 获取已编译的正则表达式。
/// </summary>
public Regex? PatternRegex { get; }
}
/// <summary>
/// 表示一个数组节点上声明的元素数量约束。
/// 该模型与标量约束拆分保存,避免数组节点继续共享不适用的标量字段。
/// </summary>
internal sealed class YamlConfigArrayConstraints
{
/// <summary>
/// 初始化数组约束模型。
/// </summary>
/// <param name="minItems">最小元素数量约束。</param>
/// <param name="maxItems">最大元素数量约束。</param>
public YamlConfigArrayConstraints(int? minItems, int? maxItems)
{
MinItems = minItems;
MaxItems = maxItems;
}
/// <summary>
/// 获取最小元素数量约束。
/// </summary>
public int? MinItems { get; }
/// <summary>
/// 获取最大元素数量约束。
/// </summary>
public int? MaxItems { get; }
}
/// <summary>
@ -1558,4 +1970,4 @@ internal enum YamlConfigSchemaPropertyType
/// 数组类型。
/// </summary>
Array
}
}

View File

@ -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,
@ -168,6 +173,8 @@ public class SchemaConfigGeneratorSnapshotTests
await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterTable.g.cs", "MonsterTable.g.txt");
await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterConfigBindings.g.cs",
"MonsterConfigBindings.g.txt");
await AssertSnapshotAsync(generatedSources, snapshotFolder, "GeneratedConfigCatalog.g.cs",
"GeneratedConfigCatalog.g.txt");
}
/// <summary>
@ -212,4 +219,4 @@ public class SchemaConfigGeneratorSnapshotTests
{
return text.Replace("\r\n", "\n", StringComparison.Ordinal).Trim();
}
}
}

View File

@ -351,4 +351,123 @@ public class SchemaConfigGeneratorTests
Assert.That(tableSource, Does.Not.Contain("TryFindFirstByReward("));
});
}
/// <summary>
/// 验证生成器会为当前消费者项目内的全部 schema 额外产出聚合注册入口,
/// 让 C# 启动代码可以一行注册所有生成表。
/// </summary>
[Test]
public void Run_Should_Generate_Project_Level_Registration_Catalog()
{
const string source = """
using System;
using System.Collections.Generic;
namespace GFramework.Game.Abstractions.Config
{
public interface IConfigTable
{
Type KeyType { get; }
Type ValueType { get; }
int Count { get; }
}
public interface IConfigTable<TKey, TValue> : IConfigTable
where TKey : notnull
{
TValue Get(TKey key);
bool TryGet(TKey key, out TValue? value);
bool ContainsKey(TKey key);
IReadOnlyCollection<TValue> All();
}
public interface IConfigRegistry
{
IConfigTable<TKey, TValue> GetTable<TKey, TValue>(string name)
where TKey : notnull;
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
where TKey : notnull;
}
}
namespace GFramework.Game.Config
{
public sealed class YamlConfigLoader
{
public YamlConfigLoader RegisterTable<TKey, TValue>(
string tableName,
string relativePath,
string schemaRelativePath,
Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? comparer = null)
where TKey : notnull
{
return this;
}
}
}
""";
const string monsterSchema = """
{
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" }
}
}
""";
const string itemSchema = """
{
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "string" },
"rarity": { "type": "string" }
}
}
""";
var result = SchemaGeneratorTestDriver.Run(
source,
("monster.schema.json", monsterSchema),
("item.schema.json", itemSchema));
var generatedSources = result.Results
.Single()
.GeneratedSources
.ToDictionary(
static sourceResult => sourceResult.HintName,
static sourceResult => sourceResult.SourceText.ToString(),
StringComparer.Ordinal);
Assert.That(result.Results.Single().Diagnostics, Is.Empty);
Assert.That(generatedSources.TryGetValue("GeneratedConfigCatalog.g.cs", out var catalogSource), Is.True);
Assert.Multiple(() =>
{
Assert.That(catalogSource, Does.Contain("public static class GeneratedConfigCatalog"));
Assert.That(catalogSource, Does.Contain("public sealed class GeneratedConfigRegistrationOptions"));
Assert.That(catalogSource, Does.Contain("public static class GeneratedConfigRegistrationExtensions"));
Assert.That(catalogSource, Does.Contain("public global::System.Collections.Generic.IReadOnlyCollection<string>? IncludedConfigDomains { get; init; }"));
Assert.That(catalogSource, Does.Contain("public global::System.Collections.Generic.IReadOnlyCollection<string>? IncludedTableNames { get; init; }"));
Assert.That(catalogSource, Does.Contain("public global::System.Predicate<GeneratedConfigCatalog.TableMetadata>? TableFilter { get; init; }"));
Assert.That(catalogSource, Does.Contain("public global::System.Collections.Generic.IEqualityComparer<string>? ItemComparer { get; init; }"));
Assert.That(catalogSource, Does.Contain("public global::System.Collections.Generic.IEqualityComparer<int>? MonsterComparer { get; init; }"));
Assert.That(catalogSource, Does.Contain("return RegisterAllGeneratedConfigTables(loader, options: null);"));
Assert.That(catalogSource, Does.Contain("GeneratedConfigRegistrationOptions? options"));
Assert.That(catalogSource, Does.Contain("if (ShouldRegisterTable(GeneratedConfigCatalog.Tables[0], options))"));
Assert.That(catalogSource, Does.Contain("loader.RegisterItemTable(options.ItemComparer);"));
Assert.That(catalogSource, Does.Contain("if (ShouldRegisterTable(GeneratedConfigCatalog.Tables[1], options))"));
Assert.That(catalogSource, Does.Contain("loader.RegisterMonsterTable(options.MonsterComparer);"));
Assert.That(catalogSource, Does.Contain("ItemConfigBindings.Metadata.TableName"));
Assert.That(catalogSource, Does.Contain("MonsterConfigBindings.Metadata.TableName"));
Assert.That(catalogSource, Does.Contain("public static bool TryGetByTableName(string tableName, out TableMetadata metadata)"));
Assert.That(catalogSource, Does.Contain("private static bool ShouldRegisterTable("));
Assert.That(catalogSource, Does.Contain("private static bool MatchesOptionalAllowList("));
});
}
}

View File

@ -0,0 +1,217 @@
// <auto-generated />
#nullable enable
namespace GFramework.Game.Config.Generated;
/// <summary>
/// Provides a project-level catalog for every config table generated from the current consumer project's schemas.
/// Use this entry point when you want the C# runtime bootstrap path to register all generated tables without repeating one call per schema.
/// </summary>
public static class GeneratedConfigCatalog
{
/// <summary>
/// Describes one generated config table so bootstrap code can enumerate generated domains without re-parsing schema files at runtime.
/// </summary>
public readonly struct TableMetadata
{
/// <summary>
/// Initializes one generated table metadata entry.
/// </summary>
/// <param name="configDomain">Logical config domain derived from the schema base name.</param>
/// <param name="tableName">Runtime registration name.</param>
/// <param name="configRelativePath">Relative YAML directory path.</param>
/// <param name="schemaRelativePath">Relative schema file path.</param>
public TableMetadata(
string configDomain,
string tableName,
string configRelativePath,
string schemaRelativePath)
{
ConfigDomain = configDomain ?? throw new global::System.ArgumentNullException(nameof(configDomain));
TableName = tableName ?? throw new global::System.ArgumentNullException(nameof(tableName));
ConfigRelativePath = configRelativePath ?? throw new global::System.ArgumentNullException(nameof(configRelativePath));
SchemaRelativePath = schemaRelativePath ?? throw new global::System.ArgumentNullException(nameof(schemaRelativePath));
}
/// <summary>
/// Gets the logical config domain derived from the schema base name.
/// </summary>
public string ConfigDomain { get; }
/// <summary>
/// Gets the runtime registration name used by <see cref="global::GFramework.Game.Config.YamlConfigLoader" />.
/// </summary>
public string TableName { get; }
/// <summary>
/// Gets the relative directory that stores YAML files for the generated config table.
/// </summary>
public string ConfigRelativePath { get; }
/// <summary>
/// Gets the relative schema file path collected by the source generator.
/// </summary>
public string SchemaRelativePath { get; }
}
/// <summary>
/// Gets metadata for every generated config table in the current consumer project.
/// </summary>
public static global::System.Collections.Generic.IReadOnlyList<TableMetadata> Tables { get; } = global::System.Array.AsReadOnly(new TableMetadata[]
{
new(
MonsterConfigBindings.Metadata.ConfigDomain,
MonsterConfigBindings.Metadata.TableName,
MonsterConfigBindings.Metadata.ConfigRelativePath,
MonsterConfigBindings.Metadata.SchemaRelativePath),
});
/// <summary>
/// Tries to resolve generated table metadata by runtime registration name.
/// </summary>
/// <param name="tableName">Runtime registration name.</param>
/// <param name="metadata">Resolved generated table metadata when the registration name exists; otherwise the default value.</param>
/// <returns><see langword="true" /> when the registration name belongs to a generated config table; otherwise <see langword="false" />.</returns>
public static bool TryGetByTableName(string tableName, out TableMetadata metadata)
{
if (tableName is null)
{
throw new global::System.ArgumentNullException(nameof(tableName));
}
if (string.Equals(tableName, MonsterConfigBindings.Metadata.TableName, global::System.StringComparison.Ordinal))
{
metadata = Tables[0];
return true;
}
metadata = default;
return false;
}
}
/// <summary>
/// Captures optional per-table registration overrides for the generated aggregate registration entry point.
/// </summary>
public sealed class GeneratedConfigRegistrationOptions
{
/// <summary>
/// Gets or sets the optional allow-list of generated config domains that aggregate registration should include. When null or empty, every generated domain remains eligible.
/// </summary>
public global::System.Collections.Generic.IReadOnlyCollection<string>? IncludedConfigDomains { get; init; }
/// <summary>
/// Gets or sets the optional allow-list of runtime table names that aggregate registration should include. When null or empty, every generated table remains eligible.
/// </summary>
public global::System.Collections.Generic.IReadOnlyCollection<string>? IncludedTableNames { get; init; }
/// <summary>
/// Gets or sets the optional predicate that can reject individual generated table metadata entries after allow-list filtering has passed.
/// </summary>
public global::System.Predicate<GeneratedConfigCatalog.TableMetadata>? TableFilter { get; init; }
/// <summary>
/// Gets or sets the optional key comparer forwarded to MonsterConfigBindings.RegisterMonsterTable(global::GFramework.Game.Config.YamlConfigLoader, global::System.Collections.Generic.IEqualityComparer<int>?) when aggregate registration runs.
/// </summary>
public global::System.Collections.Generic.IEqualityComparer<int>? MonsterComparer { get; init; }
}
/// <summary>
/// Provides a single extension method that registers every generated config table discovered in the current consumer project.
/// </summary>
public static class GeneratedConfigRegistrationExtensions
{
/// <summary>
/// Registers all generated config tables using schema-derived conventions so bootstrap code can stay one-line even as schemas grow.
/// </summary>
/// <param name="loader">Target YAML config loader.</param>
/// <returns>The same loader instance after all generated table registrations have been applied.</returns>
/// <exception cref="global::System.ArgumentNullException">When <paramref name="loader"/> is null.</exception>
public static global::GFramework.Game.Config.YamlConfigLoader RegisterAllGeneratedConfigTables(
this global::GFramework.Game.Config.YamlConfigLoader loader)
{
if (loader is null)
{
throw new global::System.ArgumentNullException(nameof(loader));
}
return RegisterAllGeneratedConfigTables(loader, options: null);
}
/// <summary>
/// Registers all generated config tables while preserving optional per-table overrides such as custom key comparers.
/// </summary>
/// <param name="loader">Target YAML config loader.</param>
/// <param name="options">Optional per-table overrides for aggregate registration; when null, all tables use their default comparer behavior.</param>
/// <returns>The same loader instance after all generated table registrations have been applied.</returns>
/// <exception cref="global::System.ArgumentNullException">When <paramref name="loader"/> is null.</exception>
public static global::GFramework.Game.Config.YamlConfigLoader RegisterAllGeneratedConfigTables(
this global::GFramework.Game.Config.YamlConfigLoader loader,
GeneratedConfigRegistrationOptions? options)
{
if (loader is null)
{
throw new global::System.ArgumentNullException(nameof(loader));
}
options ??= new GeneratedConfigRegistrationOptions();
if (ShouldRegisterTable(GeneratedConfigCatalog.Tables[0], options))
{
loader.RegisterMonsterTable(options.MonsterComparer);
}
return loader;
}
/// <summary>
/// Applies the generated registration filters in a deterministic order so bootstrap code can narrow aggregate registration without hand-writing per-table calls.
/// </summary>
/// <param name="metadata">Generated table metadata under consideration.</param>
/// <param name="options">Aggregate registration options supplied by the caller.</param>
/// <returns><see langword="true" /> when the generated table should be registered; otherwise <see langword="false" />.</returns>
private static bool ShouldRegisterTable(
GeneratedConfigCatalog.TableMetadata metadata,
GeneratedConfigRegistrationOptions options)
{
// Apply cheap generated allow-lists before invoking the optional caller predicate so startup filtering stays predictable.
if (!MatchesOptionalAllowList(options.IncludedConfigDomains, metadata.ConfigDomain))
{
return false;
}
if (!MatchesOptionalAllowList(options.IncludedTableNames, metadata.TableName))
{
return false;
}
return options.TableFilter?.Invoke(metadata) ?? true;
}
/// <summary>
/// Treats a null or empty allow-list as an unrestricted match, and otherwise performs ordinal string comparison against the generated metadata value.
/// </summary>
/// <param name="allowedValues">Optional caller-supplied allow-list.</param>
/// <param name="candidate">Generated metadata value being evaluated.</param>
/// <returns><see langword="true" /> when the value should remain eligible for registration; otherwise <see langword="false" />.</returns>
private static bool MatchesOptionalAllowList(
global::System.Collections.Generic.IReadOnlyCollection<string>? allowedValues,
string candidate)
{
if (allowedValues is null || allowedValues.Count == 0)
{
return true;
}
foreach (var allowedValue in allowedValues)
{
if (allowedValue is not null &&
string.Equals(allowedValue, candidate, global::System.StringComparison.Ordinal))
{
return true;
}
}
return false;
}
}

View File

@ -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";
/// </remarks>
public string Name { get; set; } = "Slime";
@ -34,7 +34,7 @@ public sealed partial class MonsterConfig
/// </summary>
/// <remarks>
/// Schema property path: 'hp'.
/// Constraints: minimum = 1, maximum = 999.
/// Constraints: minimum = 1, exclusiveMinimum = 0, maximum = 999, exclusiveMaximum = 1000.
/// Generated default initializer: = 10;
/// </remarks>
public int? Hp { get; set; } = 10;
@ -45,6 +45,7 @@ public sealed partial class MonsterConfig
/// <remarks>
/// 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" };

View File

@ -41,6 +41,25 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
$"{result.Schema.EntityName}ConfigBindings.g.cs",
SourceText.From(GenerateBindingsClass(result.Schema), Encoding.UTF8));
});
var collectedSchemas = schemaFiles.Collect();
context.RegisterSourceOutput(collectedSchemas, static (productionContext, results) =>
{
var schemas = results
.Where(static result => result.Schema is not null)
.Select(static result => result.Schema!)
.OrderBy(static schema => schema.TableRegistrationName, StringComparer.Ordinal)
.ToArray();
if (schemas.Length == 0)
{
return;
}
productionContext.AddSource(
"GeneratedConfigCatalog.g.cs",
SourceText.From(GenerateCatalogClass(schemas), Encoding.UTF8));
});
}
/// <summary>
@ -458,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(
@ -508,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(
@ -940,6 +959,305 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return builder.ToString().TrimEnd();
}
/// <summary>
/// 生成项目级聚合辅助源码。
/// 该辅助把当前消费者项目内所有有效 schema 汇总为一个统一入口,
/// 以便运行时快速完成批量注册并在需要时枚举已生成的配置域元数据。
/// </summary>
/// <param name="schemas">当前编译中成功解析的 schema 集合。</param>
/// <returns>聚合辅助源码。</returns>
private static string GenerateCatalogClass(IReadOnlyList<SchemaFileSpec> schemas)
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("#nullable enable");
builder.AppendLine();
builder.AppendLine($"namespace {GeneratedNamespace};");
builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine(
"/// Provides a project-level catalog for every config table generated from the current consumer project's schemas.");
builder.AppendLine(
"/// Use this entry point when you want the C# runtime bootstrap path to register all generated tables without repeating one call per schema.");
builder.AppendLine("/// </summary>");
builder.AppendLine("public static class GeneratedConfigCatalog");
builder.AppendLine("{");
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Describes one generated config table so bootstrap code can enumerate generated domains without re-parsing schema files at runtime.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public readonly struct TableMetadata");
builder.AppendLine(" {");
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Initializes one generated table metadata entry.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"configDomain\">Logical config domain derived from the schema base name.</param>");
builder.AppendLine(" /// <param name=\"tableName\">Runtime registration name.</param>");
builder.AppendLine(" /// <param name=\"configRelativePath\">Relative YAML directory path.</param>");
builder.AppendLine(" /// <param name=\"schemaRelativePath\">Relative schema file path.</param>");
builder.AppendLine(" public TableMetadata(");
builder.AppendLine(" string configDomain,");
builder.AppendLine(" string tableName,");
builder.AppendLine(" string configRelativePath,");
builder.AppendLine(" string schemaRelativePath)");
builder.AppendLine(" {");
builder.AppendLine(
" ConfigDomain = configDomain ?? throw new global::System.ArgumentNullException(nameof(configDomain));");
builder.AppendLine(
" TableName = tableName ?? throw new global::System.ArgumentNullException(nameof(tableName));");
builder.AppendLine(
" ConfigRelativePath = configRelativePath ?? throw new global::System.ArgumentNullException(nameof(configRelativePath));");
builder.AppendLine(
" SchemaRelativePath = schemaRelativePath ?? throw new global::System.ArgumentNullException(nameof(schemaRelativePath));");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the logical config domain derived from the schema base name.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public string ConfigDomain { get; }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the runtime registration name used by <see cref=\"global::GFramework.Game.Config.YamlConfigLoader\" />.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public string TableName { get; }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the relative directory that stores YAML files for the generated config table.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public string ConfigRelativePath { get; }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the relative schema file path collected by the source generator.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public string SchemaRelativePath { get; }");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets metadata for every generated config table in the current consumer project.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
" public static global::System.Collections.Generic.IReadOnlyList<TableMetadata> Tables { get; } = global::System.Array.AsReadOnly(new TableMetadata[]");
builder.AppendLine(" {");
foreach (var schema in schemas)
{
builder.AppendLine(" new(");
builder.AppendLine($" {schema.EntityName}ConfigBindings.Metadata.ConfigDomain,");
builder.AppendLine($" {schema.EntityName}ConfigBindings.Metadata.TableName,");
builder.AppendLine($" {schema.EntityName}ConfigBindings.Metadata.ConfigRelativePath,");
builder.AppendLine($" {schema.EntityName}ConfigBindings.Metadata.SchemaRelativePath),");
}
builder.AppendLine(" });");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Tries to resolve generated table metadata by runtime registration name.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"tableName\">Runtime registration name.</param>");
builder.AppendLine(
" /// <param name=\"metadata\">Resolved generated table metadata when the registration name exists; otherwise the default value.</param>");
builder.AppendLine(
" /// <returns><see langword=\"true\" /> when the registration name belongs to a generated config table; otherwise <see langword=\"false\" />.</returns>");
builder.AppendLine(" public static bool TryGetByTableName(string tableName, out TableMetadata metadata)");
builder.AppendLine(" {");
builder.AppendLine(" if (tableName is null)");
builder.AppendLine(" {");
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(tableName));");
builder.AppendLine(" }");
builder.AppendLine();
for (var index = 0; index < schemas.Count; index++)
{
var schema = schemas[index];
builder.AppendLine(
$" if (string.Equals(tableName, {schema.EntityName}ConfigBindings.Metadata.TableName, global::System.StringComparison.Ordinal))");
builder.AppendLine(" {");
builder.AppendLine(
$" metadata = Tables[{index.ToString(CultureInfo.InvariantCulture)}];");
builder.AppendLine(" return true;");
builder.AppendLine(" }");
builder.AppendLine();
}
builder.AppendLine(" metadata = default;");
builder.AppendLine(" return false;");
builder.AppendLine(" }");
builder.AppendLine("}");
builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine(
"/// Captures optional per-table registration overrides for the generated aggregate registration entry point.");
builder.AppendLine("/// </summary>");
builder.AppendLine("public sealed class GeneratedConfigRegistrationOptions");
builder.AppendLine("{");
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Gets or sets the optional allow-list of generated config domains that aggregate registration should include. When null or empty, every generated domain remains eligible.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
" public global::System.Collections.Generic.IReadOnlyCollection<string>? IncludedConfigDomains { get; init; }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Gets or sets the optional allow-list of runtime table names that aggregate registration should include. When null or empty, every generated table remains eligible.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
" public global::System.Collections.Generic.IReadOnlyCollection<string>? IncludedTableNames { get; init; }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Gets or sets the optional predicate that can reject individual generated table metadata entries after allow-list filtering has passed.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
" public global::System.Predicate<GeneratedConfigCatalog.TableMetadata>? TableFilter { get; init; }");
if (schemas.Count > 0)
{
builder.AppendLine();
}
for (var index = 0; index < schemas.Count; index++)
{
var schema = schemas[index];
builder.AppendLine(" /// <summary>");
builder.AppendLine(
$" /// Gets or sets the optional key comparer forwarded to {schema.EntityName}ConfigBindings.Register{schema.EntityName}Table(global::GFramework.Game.Config.YamlConfigLoader, global::System.Collections.Generic.IEqualityComparer<{schema.KeyClrType}>?) when aggregate registration runs.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
$" public global::System.Collections.Generic.IEqualityComparer<{schema.KeyClrType}>? {schema.EntityName}Comparer {{ get; init; }}");
if (index < schemas.Count - 1)
{
builder.AppendLine();
}
}
builder.AppendLine("}");
builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine(
"/// Provides a single extension method that registers every generated config table discovered in the current consumer project.");
builder.AppendLine("/// </summary>");
builder.AppendLine("public static class GeneratedConfigRegistrationExtensions");
builder.AppendLine("{");
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Registers all generated config tables using schema-derived conventions so bootstrap code can stay one-line even as schemas grow.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"loader\">Target YAML config loader.</param>");
builder.AppendLine(" /// <returns>The same loader instance after all generated table registrations have been applied.</returns>");
builder.AppendLine(
" /// <exception cref=\"global::System.ArgumentNullException\">When <paramref name=\"loader\"/> is null.</exception>");
builder.AppendLine(
" public static global::GFramework.Game.Config.YamlConfigLoader RegisterAllGeneratedConfigTables(");
builder.AppendLine(" this global::GFramework.Game.Config.YamlConfigLoader loader)");
builder.AppendLine(" {");
builder.AppendLine(" if (loader is null)");
builder.AppendLine(" {");
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(loader));");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" return RegisterAllGeneratedConfigTables(loader, options: null);");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Registers all generated config tables while preserving optional per-table overrides such as custom key comparers.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"loader\">Target YAML config loader.</param>");
builder.AppendLine(
" /// <param name=\"options\">Optional per-table overrides for aggregate registration; when null, all tables use their default comparer behavior.</param>");
builder.AppendLine(" /// <returns>The same loader instance after all generated table registrations have been applied.</returns>");
builder.AppendLine(
" /// <exception cref=\"global::System.ArgumentNullException\">When <paramref name=\"loader\"/> is null.</exception>");
builder.AppendLine(
" public static global::GFramework.Game.Config.YamlConfigLoader RegisterAllGeneratedConfigTables(");
builder.AppendLine(" this global::GFramework.Game.Config.YamlConfigLoader loader,");
builder.AppendLine(" GeneratedConfigRegistrationOptions? options)");
builder.AppendLine(" {");
builder.AppendLine(" if (loader is null)");
builder.AppendLine(" {");
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(loader));");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" options ??= new GeneratedConfigRegistrationOptions();");
builder.AppendLine();
for (var index = 0; index < schemas.Count; index++)
{
var schema = schemas[index];
builder.AppendLine(
$" if (ShouldRegisterTable(GeneratedConfigCatalog.Tables[{index.ToString(CultureInfo.InvariantCulture)}], options))");
builder.AppendLine(" {");
builder.AppendLine($" loader.Register{schema.EntityName}Table(options.{schema.EntityName}Comparer);");
builder.AppendLine(" }");
builder.AppendLine();
}
builder.AppendLine(" return loader;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Applies the generated registration filters in a deterministic order so bootstrap code can narrow aggregate registration without hand-writing per-table calls.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"metadata\">Generated table metadata under consideration.</param>");
builder.AppendLine(" /// <param name=\"options\">Aggregate registration options supplied by the caller.</param>");
builder.AppendLine(
" /// <returns><see langword=\"true\" /> when the generated table should be registered; otherwise <see langword=\"false\" />.</returns>");
builder.AppendLine(
" private static bool ShouldRegisterTable(");
builder.AppendLine(" GeneratedConfigCatalog.TableMetadata metadata,");
builder.AppendLine(" GeneratedConfigRegistrationOptions options)");
builder.AppendLine(" {");
builder.AppendLine(
" // Apply cheap generated allow-lists before invoking the optional caller predicate so startup filtering stays predictable.");
builder.AppendLine(" if (!MatchesOptionalAllowList(options.IncludedConfigDomains, metadata.ConfigDomain))");
builder.AppendLine(" {");
builder.AppendLine(" return false;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" if (!MatchesOptionalAllowList(options.IncludedTableNames, metadata.TableName))");
builder.AppendLine(" {");
builder.AppendLine(" return false;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" return options.TableFilter?.Invoke(metadata) ?? true;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Treats a null or empty allow-list as an unrestricted match, and otherwise performs ordinal string comparison against the generated metadata value.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"allowedValues\">Optional caller-supplied allow-list.</param>");
builder.AppendLine(" /// <param name=\"candidate\">Generated metadata value being evaluated.</param>");
builder.AppendLine(
" /// <returns><see langword=\"true\" /> when the value should remain eligible for registration; otherwise <see langword=\"false\" />.</returns>");
builder.AppendLine(" private static bool MatchesOptionalAllowList(");
builder.AppendLine(" global::System.Collections.Generic.IReadOnlyCollection<string>? allowedValues,");
builder.AppendLine(" string candidate)");
builder.AppendLine(" {");
builder.AppendLine(" if (allowedValues is null || allowedValues.Count == 0)");
builder.AppendLine(" {");
builder.AppendLine(" return true;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" foreach (var allowedValue in allowedValues)");
builder.AppendLine(" {");
builder.AppendLine(" if (allowedValue is not null &&");
builder.AppendLine(
" string.Equals(allowedValue, candidate, global::System.StringComparison.Ordinal))");
builder.AppendLine(" {");
builder.AppendLine(" return true;");
builder.AppendLine(" }");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" return false;");
builder.AppendLine(" }");
builder.AppendLine("}");
return builder.ToString().TrimEnd();
}
/// <summary>
/// 收集 schema 中声明的跨表引用元数据,并为生成代码分配稳定成员名。
/// </summary>
@ -1558,7 +1876,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
/// <summary>
/// 将 shared schema 子集中的范围与长度约束整理成 XML 文档可读字符串。
/// 将 shared schema 子集中的范围、长度、模式与数组数量约束整理成 XML 文档可读字符串。
/// </summary>
/// <param name="element">Schema 节点。</param>
/// <param name="schemaType">标量类型。</param>
@ -1573,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))
{
@ -1591,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;
}

View File

@ -12,8 +12,8 @@
- JSON Schema 作为结构描述
- 一对象一文件的目录组织
- 运行时只读查询
- Runtime / Generator / Tooling 共享支持 `minimum``maximum``minLength`、`maxLength`
- Source Generator 生成配置类型、表包装和注册/访问辅助
- 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/
</PropertyGroup>
```
### 启动引导模板
### 官方启动帮助器
推荐把配置系统的初始化收敛到一个单独入口,避免把 `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,65 +212,71 @@ using GFramework.Game.Config.Generated;
namespace GameProject.Config;
/// <summary>
/// 负责初始化游戏内容配置运行时入口
/// 封装当前游戏进程的配置启动生命周期
/// </summary>
public sealed class GameConfigBootstrap : IDisposable
public sealed class GameConfigHost : IDisposable
{
private readonly ConfigRegistry _registry = new();
private IUnRegister? _hotReload;
private readonly GameConfigBootstrap _bootstrap;
/// <summary>
/// 获取当前游戏进程共享的配置注册表。
/// </summary>
public IConfigRegistry Registry => _registry;
/// <summary>
/// 从指定配置根目录加载所有已注册配置表。
/// 使用指定配置根目录创建运行时入口。
/// </summary>
/// <param name="configRootPath">配置根目录。</param>
/// <param name="enableHotReload">是否启用开发期热重载。</param>
public async Task InitializeAsync(string configRootPath, bool enableHotReload = false)
public GameConfigHost(string configRootPath)
{
var loader = new YamlConfigLoader(configRootPath)
.RegisterMonsterTable()
.RegisterItemTable();
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()
});
}
/// <summary>
/// 停止开发期热重载并释放相关资源。
/// 获取共享配置注册表。
/// </summary>
public IConfigRegistry Registry => _bootstrap.Registry;
/// <summary>
/// 执行初次配置加载。
/// </summary>
public Task InitializeAsync()
{
return _bootstrap.InitializeAsync();
}
/// <summary>
/// 创建业务层使用的只读配置入口。
/// </summary>
/// <returns>封装强类型表访问的读取入口。</returns>
public GameConfigRuntime CreateRuntime()
{
return new GameConfigRuntime(_bootstrap.Registry);
}
/// <summary>
/// 释放底层热重载句柄等资源。
/// </summary>
public void Dispose()
{
_hotReload?.UnRegister();
_bootstrap.Dispose();
}
}
```
这段模板刻意遵循几个约定:
个官方帮助器刻意遵循几个约定:
- 优先使用生成器产出的 `Register*Table()`,避免手写表名、路径和 key selector
- 由一个长生命周期对象持有 `ConfigRegistry`
- 热重载句柄和配置生命周期绑在一起,避免监听器泄漏
- 优先通过 `ConfigureLoader` 调用生成器产出的 `RegisterAllGeneratedConfigTables()`,把多表注册收敛为一个稳定入口
- 由 `GameConfigBootstrap` 持有 `ConfigRegistry``YamlConfigLoader` 和热重载句柄
- `InitializeAsync()` 只在首次加载完整成功后才公开运行时状态,避免半初始化对象泄漏到业务层
- 热重载既可以在初始化时自动启用,也可以在初次加载后显式调用 `StartHotReload(...)`
### 运行时读取模板
推荐不要在业务代码里直接散落字符串表名查询,而是统一依赖生成的强类型入口:
```csharp
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config.Generated;
namespace GameProject.Config;
@ -273,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
@ -310,31 +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)
.RegisterMonsterTable()
.RegisterItemTable();
loader.LoadAsync(registry).GetAwaiter().GetResult();
public override async ValueTask DestroyAsync()
{
_configBootstrap.Dispose();
await base.DestroyAsync();
}
}
```
@ -342,35 +404,40 @@ public sealed class GameArchitecture : Architecture
初始化完成后,业务组件可以继续通过架构上下文读取 utility再走生成的强类型入口
```csharp
var registry = Context.GetUtility<ConfigRegistry>();
var registry = Context.GetUtility<IConfigRegistry>();
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 + Register*Table()` 组合已经足够作为官方推荐接入路径。
当前阶段不建议为了配置系统额外引入新的 `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}");
}
});
```
@ -380,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` 持有并停止监听句柄。
## 运行时接入
@ -408,7 +460,7 @@ using GFramework.Game.Config.Generated;
var registry = new ConfigRegistry();
var loader = new YamlConfigLoader("config-root")
.RegisterMonsterTable();
.RegisterAllGeneratedConfigTables();
await loader.LoadAsync(registry);
@ -416,6 +468,11 @@ var monsterTable = registry.GetMonsterTable();
var slime = monsterTable.Get(1);
```
这里推荐把“注册全部已生成配置表”和“读取单表强类型元数据”分成两层:
- 启动层优先走 `RegisterAllGeneratedConfigTables()`,避免每新增一个 schema 都要回到启动代码继续补链式调用
- 消费层继续通过 `GetMonsterTable()``MonsterConfigBindings.Metadata` 这类单表入口读取强类型信息
这组辅助会把以下约定固化到生成代码里:
- 配置域常量,例如 `MonsterConfigBindings.ConfigDomain`
@ -433,6 +490,67 @@ var configPath = MonsterConfigBindings.Metadata.ConfigRelativePath;
var schemaPath = MonsterConfigBindings.Metadata.SchemaRelativePath;
```
如果你需要在启动或诊断代码里枚举当前消费者项目里有哪些生成表,也可以直接读取项目级目录:
```csharp
foreach (var metadata in GeneratedConfigCatalog.Tables)
{
Console.WriteLine($"{metadata.TableName} -> {metadata.SchemaRelativePath}");
}
```
也可以按表名回查:
```csharp
if (GeneratedConfigCatalog.TryGetByTableName("monster", out var metadata))
{
Console.WriteLine(metadata.ConfigRelativePath);
}
```
如果你需要为某些表保留自定义 key comparer也可以继续走聚合注册入口而不是被迫退回逐表手写
```csharp
var loader = new YamlConfigLoader("config-root")
.RegisterAllGeneratedConfigTables(
new GeneratedConfigRegistrationOptions
{
ItemComparer = StringComparer.OrdinalIgnoreCase
});
```
如果项目已经生成了多张表,但当前场景只想注册其中一部分,也可以直接在聚合入口上加筛选,而不必退回手写逐表注册:
```csharp
var loader = new YamlConfigLoader("config-root")
.RegisterAllGeneratedConfigTables(
new GeneratedConfigRegistrationOptions
{
IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
});
```
如果你更习惯按表名白名单或自定义谓词裁剪启动集,也可以继续在同一个 options 对象里完成:
```csharp
var loader = new YamlConfigLoader("config-root")
.RegisterAllGeneratedConfigTables(
new GeneratedConfigRegistrationOptions
{
IncludedTableNames = new[] { MonsterConfigBindings.TableName, ItemConfigBindings.TableName },
TableFilter = static metadata => metadata.SchemaRelativePath.EndsWith(".schema.json", StringComparison.Ordinal)
});
```
这里的规则是:
- `IncludedConfigDomains``IncludedTableNames` 都按 `StringComparison.Ordinal` 做白名单匹配;传 `null` 或空集合表示“不限制”
- `TableFilter` 会在上述白名单通过后执行,适合继续按 schema 路径、配置目录等元数据做更细的启动裁剪
- 未显式配置 comparer 的表,仍然使用各自 `Register{Entity}Table()` 的默认行为
- 需要自定义 comparer 的表,可以通过 `GeneratedConfigRegistrationOptions` 按表覆盖
- 当前 `ConfigDomain` 约定仍与生成表名保持一致,但建议优先引用 `*ConfigBindings.ConfigDomain`,为后续更细的分组策略保留稳定入口
- 如果项目希望继续完全手写某张表的注册流程,逐表 `Register*Table(...)` 入口仍然保留,作为兼容逃生通道
如果你需要自定义目录、表名或 key selector仍然可以直接调用 `YamlConfigLoader.RegisterTable(...)` 原始重载。
如果你希望把 schema 路径、比较器以及未来扩展开关集中到一个对象里,推荐改用选项对象入口:
@ -460,7 +578,10 @@ var loader = new YamlConfigLoader("config-root")
- 嵌套对象字段类型不匹配
- 对象数组元素结构不匹配
- 数值字段违反 `minimum` / `maximum`
- 数值字段违反 `exclusiveMinimum` / `exclusiveMaximum`
- 字符串字段违反 `minLength` / `maxLength`
- 字符串字段违反 `pattern`
- 数组字段违反 `minItems` / `maxItems`
- 标量 `enum` 不匹配
- 标量数组元素 `enum` 不匹配
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
@ -509,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` 静默吞掉。
@ -553,7 +677,7 @@ using GFramework.Game.Config.Generated;
var registry = new ConfigRegistry();
var loader = new YamlConfigLoader("config-root")
.RegisterMonsterTable();
.RegisterAllGeneratedConfigTables();
await loader.LoadAsync(registry);

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[],
@ -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

View File

@ -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})));
}

View File

@ -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}",

View File

@ -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"
});

View File

@ -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(`
{