From 83c0c57f105ed5aea26d9f9853883a6d789ee0bf Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:17:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=92=8C=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增架构配置集成测试验证 YAML 配置加载功能 - 添加消费者项目配置生成器集成测试 - 创建完整的游戏内容配置系统中文文档 - 文档涵盖目录结构、Schema 示例、接入模板和运行时校验行为 - 提供 Architecture 推荐接入模板和热重载配置说明 - 完善 VS Code 工具功能介绍和当前限制说明 --- .../ArchitectureConfigIntegrationTests.cs | 6 +- ...GeneratedConfigConsumerIntegrationTests.cs | 11 +- .../SchemaConfigGeneratorSnapshotTests.cs | 4 +- .../Config/SchemaConfigGeneratorTests.cs | 107 +++++++++++ .../GeneratedConfigCatalog.g.txt | 115 +++++++++++ .../Config/SchemaConfigGenerator.cs | 179 ++++++++++++++++++ docs/zh-CN/game/config-system.md | 39 +++- 7 files changed, 445 insertions(+), 16 deletions(-) create mode 100644 GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt diff --git a/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs index 95b5c5e8..6765ef03 100644 --- a/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs +++ b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs @@ -10,13 +10,13 @@ using NUnit.Framework; namespace GFramework.Game.Tests.Config; /// -/// 验证在 初始化流程中可以注册配置注册表、执行加载并通过生成的表访问器读取数据。 +/// 验证在 初始化流程中可以通过聚合注册入口加载生成配置表,并通过表访问器读取数据。 /// [TestFixture] public class ArchitectureConfigIntegrationTests { /// - /// 架构初始化期间,通过 注册生成表, + /// 架构初始化期间,通过 的聚合注册入口注册生成表, /// 并将 作为 utility 暴露给架构上下文读取。 /// [Test] @@ -147,7 +147,7 @@ public class ArchitectureConfigIntegrationTests RegisterUtility(Registry); var loader = new YamlConfigLoader(_configRoot) - .RegisterMonsterTable(); + .RegisterAllGeneratedConfigTables(); loader.LoadAsync(Registry).GetAwaiter().GetResult(); MonsterTable = Registry.GetMonsterTable(); } diff --git a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs index d515788d..65289810 100644 --- a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs +++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs @@ -8,7 +8,7 @@ namespace GFramework.Game.Tests.Config; /// /// 验证消费者项目通过 `schemas/**/*.schema.json` 自动拾取 schema 后, -/// 可以直接编译并使用生成的注册辅助、强类型访问入口、查询辅助与运行时加载链路。 +/// 可以直接编译并使用生成的聚合注册辅助、强类型访问入口、查询辅助与运行时加载链路。 /// [TestFixture] public class GeneratedConfigConsumerIntegrationTests @@ -39,7 +39,7 @@ public class GeneratedConfigConsumerIntegrationTests /// /// 验证生成器自动拾取消费者项目的 schema 后, - /// 可以用生成的注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。 + /// 可以用生成的聚合注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。 /// [Test] public async Task LoadAsync_Should_Support_Generated_Bindings_In_Consumer_Project() @@ -91,7 +91,7 @@ public class GeneratedConfigConsumerIntegrationTests var registry = new ConfigRegistry(); var loader = new YamlConfigLoader(_rootPath) - .RegisterMonsterTable(); + .RegisterAllGeneratedConfigTables(); await loader.LoadAsync(registry); @@ -100,6 +100,11 @@ public class GeneratedConfigConsumerIntegrationTests Assert.Multiple(() => { + Assert.That(GeneratedConfigCatalog.Tables.Count, Is.EqualTo(1)); + 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(MonsterConfigBindings.ConfigDomain, Is.EqualTo("monster")); Assert.That(MonsterConfigBindings.TableName, Is.EqualTo("monster")); Assert.That(MonsterConfigBindings.ConfigRelativePath, Is.EqualTo("monster")); diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs index 6ff638e1..9c375432 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs @@ -168,6 +168,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"); } /// @@ -212,4 +214,4 @@ public class SchemaConfigGeneratorSnapshotTests { return text.Replace("\r\n", "\n", StringComparison.Ordinal).Trim(); } -} \ No newline at end of file +} diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 20f74ea9..1f3b24b1 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -351,4 +351,111 @@ public class SchemaConfigGeneratorTests Assert.That(tableSource, Does.Not.Contain("TryFindFirstByReward(")); }); } + + /// + /// 验证生成器会为当前消费者项目内的全部 schema 额外产出聚合注册入口, + /// 让 C# 启动代码可以一行注册所有生成表。 + /// + [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 : IConfigTable + where TKey : notnull + { + TValue Get(TKey key); + bool TryGet(TKey key, out TValue? value); + bool ContainsKey(TKey key); + IReadOnlyCollection All(); + } + + public interface IConfigRegistry + { + IConfigTable GetTable(string name) + where TKey : notnull; + + bool TryGetTable(string name, out IConfigTable? table) + where TKey : notnull; + } + } + + namespace GFramework.Game.Config + { + public sealed class YamlConfigLoader + { + public YamlConfigLoader RegisterTable( + string tableName, + string relativePath, + string schemaRelativePath, + Func keySelector, + IEqualityComparer? 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 static class GeneratedConfigRegistrationExtensions")); + Assert.That(catalogSource, Does.Contain("loader.RegisterItemTable();")); + Assert.That(catalogSource, Does.Contain("loader.RegisterMonsterTable();")); + 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)")); + }); + } } diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt new file mode 100644 index 00000000..d62e1889 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt @@ -0,0 +1,115 @@ +// +#nullable enable + +namespace GFramework.Game.Config.Generated; + +/// +/// 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. +/// +public static class GeneratedConfigCatalog +{ + /// + /// Describes one generated config table so bootstrap code can enumerate generated domains without re-parsing schema files at runtime. + /// + public readonly struct TableMetadata + { + /// + /// Initializes one generated table metadata entry. + /// + /// Logical config domain derived from the schema base name. + /// Runtime registration name. + /// Relative YAML directory path. + /// Relative schema file path. + 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)); + } + + /// + /// Gets the logical config domain derived from the schema base name. + /// + public string ConfigDomain { get; } + + /// + /// Gets the runtime registration name used by . + /// + public string TableName { get; } + + /// + /// Gets the relative directory that stores YAML files for the generated config table. + /// + public string ConfigRelativePath { get; } + + /// + /// Gets the relative schema file path collected by the source generator. + /// + public string SchemaRelativePath { get; } + } + + /// + /// Gets metadata for every generated config table in the current consumer project. + /// + public static global::System.Collections.Generic.IReadOnlyList Tables { get; } = global::System.Array.AsReadOnly(new TableMetadata[] + { + new( + MonsterConfigBindings.Metadata.ConfigDomain, + MonsterConfigBindings.Metadata.TableName, + MonsterConfigBindings.Metadata.ConfigRelativePath, + MonsterConfigBindings.Metadata.SchemaRelativePath), + }); + + /// + /// Tries to resolve generated table metadata by runtime registration name. + /// + /// Runtime registration name. + /// Resolved generated table metadata when the registration name exists; otherwise the default value. + /// when the registration name belongs to a generated config table; otherwise . + 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; + } +} + +/// +/// Provides a single extension method that registers every generated config table discovered in the current consumer project. +/// +public static class GeneratedConfigRegistrationExtensions +{ + /// + /// Registers all generated config tables using schema-derived conventions so bootstrap code can stay one-line even as schemas grow. + /// + /// Target YAML config loader. + /// The same loader instance after all generated table registrations have been applied. + /// When is null. + 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)); + } + + loader.RegisterMonsterTable(); + return loader; + } +} \ No newline at end of file diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 50ac393a..12af7bbd 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -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)); + }); } /// @@ -940,6 +959,166 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return builder.ToString().TrimEnd(); } + /// + /// 生成项目级聚合辅助源码。 + /// 该辅助把当前消费者项目内所有有效 schema 汇总为一个统一入口, + /// 以便运行时快速完成批量注册并在需要时枚举已生成的配置域元数据。 + /// + /// 当前编译中成功解析的 schema 集合。 + /// 聚合辅助源码。 + private static string GenerateCatalogClass(IReadOnlyList schemas) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine($"namespace {GeneratedNamespace};"); + builder.AppendLine(); + builder.AppendLine("/// "); + 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("/// "); + builder.AppendLine("public static class GeneratedConfigCatalog"); + builder.AppendLine("{"); + builder.AppendLine(" /// "); + builder.AppendLine( + " /// Describes one generated config table so bootstrap code can enumerate generated domains without re-parsing schema files at runtime."); + builder.AppendLine(" /// "); + builder.AppendLine(" public readonly struct TableMetadata"); + builder.AppendLine(" {"); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Initializes one generated table metadata entry."); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Logical config domain derived from the schema base name."); + builder.AppendLine(" /// Runtime registration name."); + builder.AppendLine(" /// Relative YAML directory path."); + builder.AppendLine(" /// Relative schema file path."); + 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(" /// "); + builder.AppendLine(" /// Gets the logical config domain derived from the schema base name."); + builder.AppendLine(" /// "); + builder.AppendLine(" public string ConfigDomain { get; }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Gets the runtime registration name used by ."); + builder.AppendLine(" /// "); + builder.AppendLine(" public string TableName { get; }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Gets the relative directory that stores YAML files for the generated config table."); + builder.AppendLine(" /// "); + builder.AppendLine(" public string ConfigRelativePath { get; }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Gets the relative schema file path collected by the source generator."); + builder.AppendLine(" /// "); + builder.AppendLine(" public string SchemaRelativePath { get; }"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Gets metadata for every generated config table in the current consumer project."); + builder.AppendLine(" /// "); + builder.AppendLine( + " public static global::System.Collections.Generic.IReadOnlyList 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(" /// "); + builder.AppendLine(" /// Tries to resolve generated table metadata by runtime registration name."); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Runtime registration name."); + builder.AppendLine( + " /// Resolved generated table metadata when the registration name exists; otherwise the default value."); + builder.AppendLine( + " /// when the registration name belongs to a generated config table; otherwise ."); + 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("/// "); + builder.AppendLine( + "/// Provides a single extension method that registers every generated config table discovered in the current consumer project."); + builder.AppendLine("/// "); + builder.AppendLine("public static class GeneratedConfigRegistrationExtensions"); + builder.AppendLine("{"); + builder.AppendLine(" /// "); + builder.AppendLine( + " /// Registers all generated config tables using schema-derived conventions so bootstrap code can stay one-line even as schemas grow."); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Target YAML config loader."); + builder.AppendLine(" /// The same loader instance after all generated table registrations have been applied."); + builder.AppendLine( + " /// When is null."); + 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(); + + foreach (var schema in schemas) + { + builder.AppendLine($" loader.Register{schema.EntityName}Table();"); + } + + builder.AppendLine(" return loader;"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + return builder.ToString().TrimEnd(); + } + /// /// 收集 schema 中声明的跨表引用元数据,并为生成代码分配稳定成员名。 /// diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index ab7d9b2d..30446960 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -13,7 +13,7 @@ - 一对象一文件的目录组织 - 运行时只读查询 - Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`minLength`、`maxLength` -- Source Generator 生成配置类型、表包装和注册/访问辅助 +- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 ## 推荐目录结构 @@ -193,8 +193,7 @@ public sealed class GameConfigBootstrap : IDisposable public async Task InitializeAsync(string configRootPath, bool enableHotReload = false) { var loader = new YamlConfigLoader(configRootPath) - .RegisterMonsterTable() - .RegisterItemTable(); + .RegisterAllGeneratedConfigTables(); await loader.LoadAsync(_registry); @@ -223,7 +222,7 @@ public sealed class GameConfigBootstrap : IDisposable 这段模板刻意遵循几个约定: -- 优先使用生成器产出的 `Register*Table()`,避免手写表名、路径和 key selector +- 优先使用生成器产出的 `RegisterAllGeneratedConfigTables()`,把多表注册收敛为一个稳定入口 - 由一个长生命周期对象持有 `ConfigRegistry` - 热重载句柄和配置生命周期绑在一起,避免监听器泄漏 @@ -331,8 +330,7 @@ public sealed class GameArchitecture : Architecture var registry = RegisterUtility(new ConfigRegistry()); var loader = new YamlConfigLoader(_configRootPath) - .RegisterMonsterTable() - .RegisterItemTable(); + .RegisterAllGeneratedConfigTables(); loader.LoadAsync(registry).GetAwaiter().GetResult(); } @@ -354,7 +352,7 @@ var slime = monsterTable.Get(1); - 在 `OnInitialize()` 内完成首次 `LoadAsync` - 初始化完成后只通过注册表和生成表包装访问配置 -当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + ConfigRegistry + YamlConfigLoader + Register*Table()` 组合已经足够作为官方推荐接入路径。 +当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + ConfigRegistry + YamlConfigLoader + RegisterAllGeneratedConfigTables()` 组合已经足够作为官方推荐接入路径。 ### 热重载模板 @@ -408,7 +406,7 @@ using GFramework.Game.Config.Generated; var registry = new ConfigRegistry(); var loader = new YamlConfigLoader("config-root") - .RegisterMonsterTable(); + .RegisterAllGeneratedConfigTables(); await loader.LoadAsync(registry); @@ -416,6 +414,11 @@ var monsterTable = registry.GetMonsterTable(); var slime = monsterTable.Get(1); ``` +这里推荐把“注册全部已生成配置表”和“读取单表强类型元数据”分成两层: + +- 启动层优先走 `RegisterAllGeneratedConfigTables()`,避免每新增一个 schema 都要回到启动代码继续补链式调用 +- 消费层继续通过 `GetMonsterTable()`、`MonsterConfigBindings.Metadata` 这类单表入口读取强类型信息 + 这组辅助会把以下约定固化到生成代码里: - 配置域常量,例如 `MonsterConfigBindings.ConfigDomain` @@ -433,6 +436,24 @@ 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 selector,仍然可以直接调用 `YamlConfigLoader.RegisterTable(...)` 原始重载。 如果你希望把 schema 路径、比较器以及未来扩展开关集中到一个对象里,推荐改用选项对象入口: @@ -553,7 +574,7 @@ using GFramework.Game.Config.Generated; var registry = new ConfigRegistry(); var loader = new YamlConfigLoader("config-root") - .RegisterMonsterTable(); + .RegisterAllGeneratedConfigTables(); await loader.LoadAsync(registry); From 4f966f9f50564df96762360126f8ad3c6be9e798 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:55:58 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(game):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=92=8C=E7=94=9F=E6=88=90=E5=99=A8=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加了 AI-First 配置系统完整文档,涵盖 YAML 配置、JSON Schema 结构、目录组织 - 实现了 Source Generator 自动生成配置类型、表包装、单表注册和访问辅助代码 - 提供了项目级聚合注册目录和配置浏览、校验、表单编辑等工具支持 - 集成了运行时只读查询、热重载、跨表引用校验等核心功能 - 添加了 VS Code 插件支持配置浏览、raw 编辑、schema 打开和递归校验功能 --- ...GeneratedConfigConsumerIntegrationTests.cs | 4 +- .../Config/SchemaConfigGeneratorTests.cs | 9 +++- .../GeneratedConfigCatalog.g.txt | 36 ++++++++++++- .../Config/SchemaConfigGenerator.cs | 52 ++++++++++++++++++- docs/zh-CN/game/config-system.md | 17 ++++++ 5 files changed, 112 insertions(+), 6 deletions(-) diff --git a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs index 65289810..3483592f 100644 --- a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs +++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs @@ -100,7 +100,9 @@ public class GeneratedConfigConsumerIntegrationTests Assert.Multiple(() => { - Assert.That(GeneratedConfigCatalog.Tables.Count, Is.EqualTo(1)); + Assert.That( + GeneratedConfigCatalog.Tables.Select(static metadata => metadata.TableName), + Does.Contain("monster")); Assert.That(GeneratedConfigCatalog.TryGetByTableName("monster", out var catalogEntry), Is.True); Assert.That(catalogEntry.ConfigDomain, Is.EqualTo("monster")); Assert.That(catalogEntry.ConfigRelativePath, Is.EqualTo("monster")); diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 1f3b24b1..14b39fb2 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -450,9 +450,14 @@ public class SchemaConfigGeneratorTests 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("loader.RegisterItemTable();")); - Assert.That(catalogSource, Does.Contain("loader.RegisterMonsterTable();")); + Assert.That(catalogSource, Does.Contain("public global::System.Collections.Generic.IEqualityComparer? ItemComparer { get; init; }")); + Assert.That(catalogSource, Does.Contain("public global::System.Collections.Generic.IEqualityComparer? 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("loader.RegisterItemTable(options.ItemComparer);")); + 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)")); diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt index d62e1889..66fc5c07 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt @@ -90,6 +90,17 @@ public static class GeneratedConfigCatalog } } +/// +/// Captures optional per-table registration overrides for the generated aggregate registration entry point. +/// +public sealed class GeneratedConfigRegistrationOptions +{ + /// + /// Gets or sets the optional key comparer forwarded to MonsterConfigBindings.RegisterMonsterTable(global::GFramework.Game.Config.YamlConfigLoader, global::System.Collections.Generic.IEqualityComparer?) when aggregate registration runs. + /// + public global::System.Collections.Generic.IEqualityComparer? MonsterComparer { get; init; } +} + /// /// Provides a single extension method that registers every generated config table discovered in the current consumer project. /// @@ -109,7 +120,28 @@ public static class GeneratedConfigRegistrationExtensions throw new global::System.ArgumentNullException(nameof(loader)); } - loader.RegisterMonsterTable(); + return RegisterAllGeneratedConfigTables(loader, options: null); + } + + /// + /// Registers all generated config tables while preserving optional per-table overrides such as custom key comparers. + /// + /// Target YAML config loader. + /// Optional per-table overrides for aggregate registration; when null, all tables use their default comparer behavior. + /// The same loader instance after all generated table registrations have been applied. + /// When is null. + 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(); + + loader.RegisterMonsterTable(options.MonsterComparer); return loader; } -} \ No newline at end of file +} diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 12af7bbd..880e628a 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -1082,6 +1082,31 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" metadata = default;"); builder.AppendLine(" return false;"); builder.AppendLine(" }"); + builder.AppendLine("}"); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine( + "/// Captures optional per-table registration overrides for the generated aggregate registration entry point."); + builder.AppendLine("/// "); + builder.AppendLine("public sealed class GeneratedConfigRegistrationOptions"); + builder.AppendLine("{"); + + for (var index = 0; index < schemas.Count; index++) + { + var schema = schemas[index]; + builder.AppendLine(" /// "); + 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(" /// "); + 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("/// "); @@ -1107,10 +1132,35 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator 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(" /// "); + builder.AppendLine( + " /// Registers all generated config tables while preserving optional per-table overrides such as custom key comparers."); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Target YAML config loader."); + builder.AppendLine( + " /// Optional per-table overrides for aggregate registration; when null, all tables use their default comparer behavior."); + builder.AppendLine(" /// The same loader instance after all generated table registrations have been applied."); + builder.AppendLine( + " /// When is null."); + 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(); foreach (var schema in schemas) { - builder.AppendLine($" loader.Register{schema.EntityName}Table();"); + builder.AppendLine($" loader.Register{schema.EntityName}Table(options.{schema.EntityName}Comparer);"); } builder.AppendLine(" return loader;"); diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 30446960..b8595c55 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -454,6 +454,23 @@ if (GeneratedConfigCatalog.TryGetByTableName("monster", out var metadata)) } ``` +如果你需要为某些表保留自定义 key comparer,也可以继续走聚合注册入口,而不是被迫退回逐表手写: + +```csharp +var loader = new YamlConfigLoader("config-root") + .RegisterAllGeneratedConfigTables( + new GeneratedConfigRegistrationOptions + { + ItemComparer = StringComparer.OrdinalIgnoreCase + }); +``` + +这里的规则是: + +- 未显式配置 comparer 的表,仍然使用各自 `Register{Entity}Table()` 的默认行为 +- 需要自定义 comparer 的表,可以通过 `GeneratedConfigRegistrationOptions` 按表覆盖 +- 如果项目希望继续完全手写某张表的注册流程,逐表 `Register*Table(...)` 入口仍然保留,作为兼容逃生通道 + 如果你需要自定义目录、表名或 key selector,仍然可以直接调用 `YamlConfigLoader.RegisterTable(...)` 原始重载。 如果你希望把 schema 路径、比较器以及未来扩展开关集中到一个对象里,推荐改用选项对象入口: