feat(config): 添加配置系统集成测试和文档

- 添加 ArchitectureConfigIntegrationTests 验证架构初始化流程中配置加载
- 添加 GeneratedConfigConsumerIntegrationTests 测试消费者项目配置绑定功能
- 添加完整的游戏内容配置系统中文文档
- 添加生成配置目录和注册选项支持批量表注册与筛选
- 实现配置架构集成模板和热重载功能
- 添加跨表引用校验和运行时诊断功能
- 实现 VS Code 工具支持配置浏览和表单编辑
- 添加查询辅助方法支持按字段快速检索配置数据
This commit is contained in:
GeWuYou 2026-04-06 16:38:42 +08:00
parent e9e04d9792
commit 92eb365dc7
7 changed files with 411 additions and 67 deletions

View File

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

View File

@ -43,6 +43,152 @@ public class GeneratedConfigConsumerIntegrationTests
/// </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 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);
});
}
/// <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,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" }));
});
}
/// <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

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

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

@ -95,6 +95,21 @@ public static class GeneratedConfigCatalog
/// </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>
@ -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;
}
/// <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

@ -1090,6 +1090,31 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
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++)
{
@ -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(" /// <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();
}

View File

@ -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(...)` 原始重载。