From 92eb365dc7fb5f042830325c976e79ca13035330 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:38:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=B3=BB=E7=BB=9F=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=92=8C=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 ArchitectureConfigIntegrationTests 验证架构初始化流程中配置加载 - 添加 GeneratedConfigConsumerIntegrationTests 测试消费者项目配置绑定功能 - 添加完整的游戏内容配置系统中文文档 - 添加生成配置目录和注册选项支持批量表注册与筛选 - 实现配置架构集成模板和热重载功能 - 添加跨表引用校验和运行时诊断功能 - 实现 VS Code 工具支持配置浏览和表单编辑 - 添加查询辅助方法支持按字段快速检索配置数据 --- .../ArchitectureConfigIntegrationTests.cs | 7 +- ...GeneratedConfigConsumerIntegrationTests.cs | 249 +++++++++++++----- .../schemas/item.schema.json | 24 ++ .../Config/SchemaConfigGeneratorTests.cs | 7 + .../GeneratedConfigCatalog.g.txt | 72 ++++- .../Config/SchemaConfigGenerator.cs | 93 ++++++- docs/zh-CN/game/config-system.md | 26 ++ 7 files changed, 411 insertions(+), 67 deletions(-) create mode 100644 GFramework.Game.Tests/schemas/item.schema.json diff --git a/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs index 6765ef03..510ef01b 100644 --- a/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs +++ b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs @@ -42,6 +42,7 @@ 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.Registry.TryGetItemTable(out _), Is.False); Assert.That(architecture.Context.GetUtility(), Is.SameAs(architecture.Registry)); }); } @@ -147,7 +148,11 @@ public class ArchitectureConfigIntegrationTests RegisterUtility(Registry); var loader = new YamlConfigLoader(_configRoot) - .RegisterAllGeneratedConfigTables(); + .RegisterAllGeneratedConfigTables( + new GeneratedConfigRegistrationOptions + { + IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain } + }); 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 3483592f..48c1a763 100644 --- a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs +++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs @@ -43,6 +43,152 @@ public class GeneratedConfigConsumerIntegrationTests /// [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")); + }); + } + + /// + /// 验证聚合注册入口可以通过生成配置域、表名集合和自定义谓词收敛多表项目的启动粒度。 + /// + [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 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(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); + }); + } + + /// + /// 在临时消费者根目录中创建测试文件。 + /// + /// 相对根目录的文件路径。 + /// 要写入的文件内容。 + private void CreateFile( + string relativePath, + string content) + { + var path = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var directoryPath = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + File.WriteAllText(path, content.Replace("\n", Environment.NewLine, StringComparison.Ordinal)); + } + + /// + /// 在临时消费者目录中创建 monster schema 与 YAML 测试数据。 + /// + private void CreateMonsterFiles() { CreateFile( "schemas/monster.schema.json", @@ -88,73 +234,50 @@ public class GeneratedConfigConsumerIntegrationTests hp: 30 faction: dungeon """); - - var registry = new ConfigRegistry(); - var loader = new YamlConfigLoader(_rootPath) - .RegisterAllGeneratedConfigTables(); - - await loader.LoadAsync(registry); - - var table = registry.GetMonsterTable(); - var dungeonMonsters = table.FindByFaction("dungeon"); - - Assert.Multiple(() => - { - 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")); - 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")); - 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" })); - }); } /// - /// 在临时消费者根目录中创建测试文件。 + /// 在临时消费者目录中创建 item schema 与 YAML 测试数据,用于验证多表聚合注册和筛选行为。 /// - /// 相对根目录的文件路径。 - /// 要写入的文件内容。 - 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 + """); } } diff --git a/GFramework.Game.Tests/schemas/item.schema.json b/GFramework.Game.Tests/schemas/item.schema.json new file mode 100644 index 00000000..005f83a1 --- /dev/null +++ b/GFramework.Game.Tests/schemas/item.schema.json @@ -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." + } + } +} diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 14b39fb2..e5a03042 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -452,15 +452,22 @@ public class SchemaConfigGeneratorTests 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? IncludedConfigDomains { get; init; }")); + Assert.That(catalogSource, Does.Contain("public global::System.Collections.Generic.IReadOnlyCollection? IncludedTableNames { get; init; }")); + Assert.That(catalogSource, Does.Contain("public global::System.Predicate? TableFilter { get; init; }")); 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("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(")); }); } } diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt index 66fc5c07..e5b79994 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt @@ -95,6 +95,21 @@ public static class GeneratedConfigCatalog /// public sealed class GeneratedConfigRegistrationOptions { + /// + /// 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. + /// + public global::System.Collections.Generic.IReadOnlyCollection? IncludedConfigDomains { get; init; } + + /// + /// 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. + /// + public global::System.Collections.Generic.IReadOnlyCollection? IncludedTableNames { get; init; } + + /// + /// Gets or sets the optional predicate that can reject individual generated table metadata entries after allow-list filtering has passed. + /// + public global::System.Predicate? TableFilter { get; init; } + /// /// 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. /// @@ -141,7 +156,62 @@ public static class GeneratedConfigRegistrationExtensions options ??= new GeneratedConfigRegistrationOptions(); - loader.RegisterMonsterTable(options.MonsterComparer); + if (ShouldRegisterTable(GeneratedConfigCatalog.Tables[0], options)) + { + loader.RegisterMonsterTable(options.MonsterComparer); + } + return loader; } + + /// + /// Applies the generated registration filters in a deterministic order so bootstrap code can narrow aggregate registration without hand-writing per-table calls. + /// + /// Generated table metadata under consideration. + /// Aggregate registration options supplied by the caller. + /// when the generated table should be registered; otherwise . + 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; + } + + /// + /// Treats a null or empty allow-list as an unrestricted match, and otherwise performs ordinal string comparison against the generated metadata value. + /// + /// Optional caller-supplied allow-list. + /// Generated metadata value being evaluated. + /// when the value should remain eligible for registration; otherwise . + private static bool MatchesOptionalAllowList( + global::System.Collections.Generic.IReadOnlyCollection? 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; + } } diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 880e628a..38eec75f 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -1090,6 +1090,31 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine("/// "); builder.AppendLine("public sealed class GeneratedConfigRegistrationOptions"); builder.AppendLine("{"); + builder.AppendLine(" /// "); + 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(" /// "); + builder.AppendLine( + " public global::System.Collections.Generic.IReadOnlyCollection? IncludedConfigDomains { get; init; }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + 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(" /// "); + builder.AppendLine( + " public global::System.Collections.Generic.IReadOnlyCollection? IncludedTableNames { get; init; }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine( + " /// Gets or sets the optional predicate that can reject individual generated table metadata entries after allow-list filtering has passed."); + builder.AppendLine(" /// "); + builder.AppendLine( + " public global::System.Predicate? TableFilter { get; init; }"); + + if (schemas.Count > 0) + { + builder.AppendLine(); + } for (var index = 0; index < schemas.Count; index++) { @@ -1158,13 +1183,77 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" options ??= new GeneratedConfigRegistrationOptions();"); builder.AppendLine(); - foreach (var schema in schemas) + for (var index = 0; index < schemas.Count; index++) { - builder.AppendLine($" loader.Register{schema.EntityName}Table(options.{schema.EntityName}Comparer);"); + 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(" /// "); + 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(" /// "); + builder.AppendLine(" /// Generated table metadata under consideration."); + builder.AppendLine(" /// Aggregate registration options supplied by the caller."); + builder.AppendLine( + " /// when the generated table should be registered; otherwise ."); + 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(" /// "); + 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(" /// "); + builder.AppendLine(" /// Optional caller-supplied allow-list."); + builder.AppendLine(" /// Generated metadata value being evaluated."); + builder.AppendLine( + " /// when the value should remain eligible for registration; otherwise ."); + builder.AppendLine(" private static bool MatchesOptionalAllowList("); + builder.AppendLine(" global::System.Collections.Generic.IReadOnlyCollection? 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(); } diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index b8595c55..d2f7a7f1 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -465,10 +465,36 @@ var loader = new YamlConfigLoader("config-root") }); ``` +如果项目已经生成了多张表,但当前场景只想注册其中一部分,也可以直接在聚合入口上加筛选,而不必退回手写逐表注册: + +```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(...)` 原始重载。