diff --git a/GFramework.Core.Abstractions/Internals/IsExternalInit.cs b/GFramework.Core.Abstractions/Internals/IsExternalInit.cs
index 1de16c68..8a76104d 100644
--- a/GFramework.Core.Abstractions/Internals/IsExternalInit.cs
+++ b/GFramework.Core.Abstractions/Internals/IsExternalInit.cs
@@ -1,20 +1,20 @@
-// IsExternalInit.cs
-// This type is required to support init-only setters and record types
-// when targeting netstandard2.0 or older frameworks.
-
-#if !NET5_0_OR_GREATER
-using System.ComponentModel;
-
-// ReSharper disable CheckNamespace
-
-namespace System.Runtime.CompilerServices;
-
-///
-/// 提供一个占位符类型,用于支持 C# 9.0 的 init 访问器功能。
-/// 该类型在 .NET 5.0 及更高版本中已内置,因此仅在较低版本的 .NET 中定义。
-///
-[EditorBrowsable(EditorBrowsableState.Never)]
-internal static class IsExternalInit
-{
-}
+// IsExternalInit.cs
+// This type is required to support init-only setters and record types
+// when targeting netstandard2.0 or older frameworks.
+
+#if !NET5_0_OR_GREATER
+using System.ComponentModel;
+
+// ReSharper disable CheckNamespace
+
+namespace System.Runtime.CompilerServices;
+
+///
+/// 提供一个占位符类型,用于支持 C# 9.0 的 init 访问器功能。
+/// 该类型在 .NET 5.0 及更高版本中已内置,因此仅在较低版本的 .NET 中定义。
+///
+[EditorBrowsable(EditorBrowsableState.Never)]
+internal static class IsExternalInit
+{
+}
#endif
\ No newline at end of file
diff --git a/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs
new file mode 100644
index 00000000..95b5c5e8
--- /dev/null
+++ b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs
@@ -0,0 +1,155 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using GFramework.Core.Architectures;
+using GFramework.Game.Config;
+using GFramework.Game.Config.Generated;
+using NUnit.Framework;
+
+namespace GFramework.Game.Tests.Config;
+
+///
+/// 验证在 初始化流程中可以注册配置注册表、执行加载并通过生成的表访问器读取数据。
+///
+[TestFixture]
+public class ArchitectureConfigIntegrationTests
+{
+ ///
+ /// 架构初始化期间,通过 注册生成表,
+ /// 并将 作为 utility 暴露给架构上下文读取。
+ ///
+ [Test]
+ public async Task ConfigLoaderCanRunDuringArchitectureInitialization()
+ {
+ var rootPath = CreateTempConfigRoot();
+ ConsumerArchitecture? architecture = null;
+ var initialized = false;
+ try
+ {
+ architecture = new ConsumerArchitecture(rootPath);
+ await architecture.InitializeAsync();
+ initialized = true;
+
+ var table = architecture.MonsterTable;
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
+ Assert.That(table.Get(2).Hp, Is.EqualTo(30));
+ Assert.That(table.FindByFaction("dungeon").Select(static config => config.Name),
+ Is.EquivalentTo(new[] { "Slime", "Goblin" }));
+ 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(), Is.SameAs(architecture.Registry));
+ });
+ }
+ finally
+ {
+ if (architecture is not null && initialized)
+ {
+ await architecture.DestroyAsync();
+ }
+
+ DeleteDirectoryIfExists(rootPath);
+ }
+ }
+
+ private static string CreateTempConfigRoot()
+ {
+ var rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigArchitecture", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(rootPath);
+ Directory.CreateDirectory(Path.Combine(rootPath, "schemas"));
+ Directory.CreateDirectory(Path.Combine(rootPath, "monster"));
+ File.WriteAllText(Path.Combine(rootPath, "schemas", "monster.schema.json"), MonsterSchemaJson);
+ File.WriteAllText(Path.Combine(rootPath, "monster", "slime.yaml"), MonsterSlimeYaml);
+ File.WriteAllText(Path.Combine(rootPath, "monster", "goblin.yaml"), MonsterGoblinYaml);
+ return rootPath;
+ }
+
+ ///
+ /// 最佳努力尝试删除临时目录。
+ ///
+ private static void DeleteDirectoryIfExists(string path)
+ {
+ if (!Directory.Exists(path))
+ {
+ return;
+ }
+
+ try
+ {
+ Directory.Delete(path, true);
+ }
+ catch (IOException)
+ {
+ // Ignored: cleanup is best effort and should not fail the test.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Ignored: cleanup is best effort and can transiently fail when files are still being released.
+ }
+ }
+
+ private const string MonsterSchemaJson = @"{
+ ""title"": ""Monster Config"",
+ ""description"": ""Defines one monster entry for the generated consumer integration test."",
+ ""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 integration test to validate generated non-unique queries.""
+ }
+ }
+}";
+
+ private const string MonsterSlimeYaml =
+ "id: 1\nname: Slime\nhp: 10\nfaction: dungeon\n";
+
+ private const string MonsterGoblinYaml =
+ "id: 2\nname: Goblin\nhp: 30\nfaction: dungeon\n";
+
+ private sealed class ConsumerArchitecture : Architecture
+ {
+ private readonly string _configRoot;
+
+ public ConfigRegistry Registry { get; }
+
+ public MonsterTable MonsterTable { get; private set; } = null!;
+
+ public ConsumerArchitecture(string configRoot)
+ {
+ _configRoot = configRoot ?? throw new ArgumentNullException(nameof(configRoot));
+ Registry = new ConfigRegistry();
+ }
+
+ protected override void OnInitialize()
+ {
+ RegisterUtility(Registry);
+
+ var loader = new YamlConfigLoader(_configRoot)
+ .RegisterMonsterTable();
+ 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 7556db18..d515788d 100644
--- a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
+++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
@@ -1,4 +1,6 @@
+using System;
using System.IO;
+using System.Linq;
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
@@ -6,7 +8,7 @@ namespace GFramework.Game.Tests.Config;
///
/// 验证消费者项目通过 `schemas/**/*.schema.json` 自动拾取 schema 后,
-/// 可以直接编译并使用生成的注册辅助、强类型访问入口与运行时加载链路。
+/// 可以直接编译并使用生成的注册辅助、强类型访问入口、查询辅助与运行时加载链路。
///
[TestFixture]
public class GeneratedConfigConsumerIntegrationTests
@@ -37,7 +39,7 @@ public class GeneratedConfigConsumerIntegrationTests
///
/// 验证生成器自动拾取消费者项目的 schema 后,
- /// 可以用生成的注册辅助完成加载,并通过强类型表包装访问运行时数据。
+ /// 可以用生成的注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。
///
[Test]
public async Task LoadAsync_Should_Support_Generated_Bindings_In_Consumer_Project()
@@ -49,7 +51,7 @@ public class GeneratedConfigConsumerIntegrationTests
"title": "Monster Config",
"description": "Defines one monster entry for the end-to-end consumer integration test.",
"type": "object",
- "required": ["id", "name", "hp"],
+ "required": ["id", "name", "hp", "faction"],
"properties": {
"id": {
"type": "integer",
@@ -62,6 +64,10 @@ public class GeneratedConfigConsumerIntegrationTests
"hp": {
"type": "integer",
"description": "Monster base health."
+ },
+ "faction": {
+ "type": "string",
+ "description": "Used by the integration test to validate generated non-unique queries."
}
}
}
@@ -72,6 +78,7 @@ public class GeneratedConfigConsumerIntegrationTests
id: 1
name: Slime
hp: 10
+ faction: dungeon
""");
CreateFile(
"monster/goblin.yaml",
@@ -79,6 +86,7 @@ public class GeneratedConfigConsumerIntegrationTests
id: 2
name: Goblin
hp: 30
+ faction: dungeon
""");
var registry = new ConfigRegistry();
@@ -88,6 +96,7 @@ public class GeneratedConfigConsumerIntegrationTests
await loader.LoadAsync(registry);
var table = registry.GetMonsterTable();
+ var dungeonMonsters = table.FindByFaction("dungeon");
Assert.Multiple(() =>
{
@@ -106,6 +115,16 @@ public class GeneratedConfigConsumerIntegrationTests
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),
@@ -131,4 +150,4 @@ public class GeneratedConfigConsumerIntegrationTests
File.WriteAllText(path, content.Replace("\n", Environment.NewLine, StringComparison.Ordinal));
}
-}
\ No newline at end of file
+}
diff --git a/GFramework.Game.Tests/schemas/monster.schema.json b/GFramework.Game.Tests/schemas/monster.schema.json
index 4b16aa59..5bda7ba0 100644
--- a/GFramework.Game.Tests/schemas/monster.schema.json
+++ b/GFramework.Game.Tests/schemas/monster.schema.json
@@ -5,7 +5,8 @@
"required": [
"id",
"name",
- "hp"
+ "hp",
+ "faction"
],
"properties": {
"id": {
@@ -19,6 +20,10 @@
"hp": {
"type": "integer",
"description": "Monster base health."
+ },
+ "faction": {
+ "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 07b3b81c..20f74ea9 100644
--- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
+++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
@@ -236,4 +236,119 @@ public class SchemaConfigGeneratorTests
Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems1 ="));
Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems11 ="));
}
-}
\ No newline at end of file
+
+ ///
+ /// 验证生成器只为顶层非主键标量字段生成轻量查询辅助,
+ /// 避免把数组、对象和引用字段误生成为查询 API。
+ ///
+ [Test]
+ public void Run_Should_Generate_Query_Helpers_Only_For_Top_Level_Scalar_Properties()
+ {
+ 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 schema = """
+ {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "hp": { "type": "integer" },
+ "dropItems": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "targetId": {
+ "type": "string",
+ "x-gframework-ref-table": "monster"
+ },
+ "reward": {
+ "type": "object",
+ "properties": {
+ "gold": { "type": "integer" }
+ }
+ }
+ }
+ }
+ """;
+
+ var result = SchemaGeneratorTestDriver.Run(
+ source,
+ ("monster.schema.json", schema));
+
+ 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("MonsterTable.g.cs", out var tableSource), Is.True);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(tableSource, Does.Contain("FindByName(string value)"));
+ Assert.That(tableSource, Does.Contain("TryFindFirstByName(string value, out MonsterConfig? result)"));
+ Assert.That(tableSource, Does.Contain("FindByHp(int? value)"));
+ Assert.That(tableSource, Does.Contain("TryFindFirstByHp(int? value, out MonsterConfig? result)"));
+ Assert.That(tableSource, Does.Not.Contain("FindById("));
+ Assert.That(tableSource, Does.Not.Contain("FindByDropItems("));
+ Assert.That(tableSource, Does.Not.Contain("FindByTargetId("));
+ Assert.That(tableSource, Does.Not.Contain("FindByReward("));
+ Assert.That(tableSource, Does.Not.Contain("TryFindFirstById("));
+ Assert.That(tableSource, Does.Not.Contain("TryFindFirstByDropItems("));
+ Assert.That(tableSource, Does.Not.Contain("TryFindFirstByTargetId("));
+ Assert.That(tableSource, Does.Not.Contain("TryFindFirstByReward("));
+ });
+ }
+}
diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt
index 2e1a442b..f9cd82a2 100644
--- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt
+++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt
@@ -52,4 +52,102 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.
{
return _inner.All();
}
+
+ ///
+ /// Finds all config entries whose property 'name' equals the supplied value.
+ ///
+ /// The property value to match.
+ /// A read-only snapshot containing every matching config entry.
+ ///
+ /// The generated helper performs a deterministic linear scan over so it stays compatible with runtime hot reload and does not require secondary index infrastructure.
+ ///
+ public global::System.Collections.Generic.IReadOnlyList FindByName(string value)
+ {
+ var matches = new global::System.Collections.Generic.List();
+
+ // Scan the current table snapshot on demand so generated helpers stay aligned with reloadable runtime data.
+ foreach (var candidate in All())
+ {
+ if (global::System.Collections.Generic.EqualityComparer.Default.Equals(candidate.Name, value))
+ {
+ matches.Add(candidate);
+ }
+ }
+
+ return matches.Count == 0 ? global::System.Array.Empty() : matches.AsReadOnly();
+ }
+
+ ///
+ /// Tries to find the first config entry whose property 'name' equals the supplied value.
+ ///
+ /// The property value to match.
+ /// The first matching config entry when lookup succeeds; otherwise .
+ /// when a matching config entry is found; otherwise .
+ ///
+ /// The generated helper walks the same snapshot exposed by and returns the first match in iteration order.
+ ///
+ public bool TryFindFirstByName(string value, out MonsterConfig? result)
+ {
+ // Keep the search path allocation-free for the first-match case by exiting as soon as one entry matches.
+ foreach (var candidate in All())
+ {
+ if (global::System.Collections.Generic.EqualityComparer.Default.Equals(candidate.Name, value))
+ {
+ result = candidate;
+ return true;
+ }
+ }
+
+ result = null;
+ return false;
+ }
+
+ ///
+ /// Finds all config entries whose property 'hp' equals the supplied value.
+ ///
+ /// The property value to match.
+ /// A read-only snapshot containing every matching config entry.
+ ///
+ /// The generated helper performs a deterministic linear scan over so it stays compatible with runtime hot reload and does not require secondary index infrastructure.
+ ///
+ public global::System.Collections.Generic.IReadOnlyList FindByHp(int? value)
+ {
+ var matches = new global::System.Collections.Generic.List();
+
+ // Scan the current table snapshot on demand so generated helpers stay aligned with reloadable runtime data.
+ foreach (var candidate in All())
+ {
+ if (global::System.Collections.Generic.EqualityComparer.Default.Equals(candidate.Hp, value))
+ {
+ matches.Add(candidate);
+ }
+ }
+
+ return matches.Count == 0 ? global::System.Array.Empty() : matches.AsReadOnly();
+ }
+
+ ///
+ /// Tries to find the first config entry whose property 'hp' equals the supplied value.
+ ///
+ /// The property value to match.
+ /// The first matching config entry when lookup succeeds; otherwise .
+ /// when a matching config entry is found; otherwise .
+ ///
+ /// The generated helper walks the same snapshot exposed by and returns the first match in iteration order.
+ ///
+ public bool TryFindFirstByHp(int? value, out MonsterConfig? result)
+ {
+ // Keep the search path allocation-free for the first-match case by exiting as soon as one entry matches.
+ foreach (var candidate in All())
+ {
+ if (global::System.Collections.Generic.EqualityComparer.Default.Equals(candidate.Hp, value))
+ {
+ result = candidate;
+ return true;
+ }
+ }
+
+ result = null;
+ return false;
+ }
}
diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
index dc33408f..50ac393a 100644
--- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
+++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
@@ -560,6 +560,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
private static string GenerateTableClass(SchemaFileSpec schema)
{
var builder = new StringBuilder();
+ var queryableProperties = CollectQueryableProperties(schema).ToArray();
builder.AppendLine("// ");
builder.AppendLine("#nullable enable");
builder.AppendLine();
@@ -620,6 +621,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" {");
builder.AppendLine(" return _inner.All();");
builder.AppendLine(" }");
+
+ foreach (var property in queryableProperties)
+ {
+ builder.AppendLine();
+ AppendFindByPropertyMethod(builder, schema, property);
+ builder.AppendLine();
+ AppendTryFindFirstByPropertyMethod(builder, schema, property);
+ }
+
builder.AppendLine("}");
return builder.ToString().TrimEnd();
}
@@ -975,6 +985,121 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
}
+ ///
+ /// 收集适合生成轻量查询辅助的根级标量字段。
+ /// 当前实现故意限定在顶层非主键标量字段,避免把嵌套结构、数组或引用语义提前固化为运行时契约。
+ ///
+ /// 生成器级 schema 模型。
+ /// 可生成查询辅助的属性集合。
+ private static IEnumerable CollectQueryableProperties(SchemaFileSpec schema)
+ {
+ foreach (var property in schema.RootObject.Properties)
+ {
+ if (property.TypeSpec.Kind != SchemaNodeKind.Scalar)
+ {
+ continue;
+ }
+
+ if (!string.IsNullOrWhiteSpace(property.TypeSpec.RefTableName))
+ {
+ continue;
+ }
+
+ if (string.Equals(property.PropertyName, schema.KeyPropertyName, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ yield return property;
+ }
+ }
+
+ ///
+ /// 生成按字段匹配全部结果的轻量查询辅助。
+ ///
+ /// 输出缓冲区。
+ /// 生成器级 schema 模型。
+ /// 要生成查询辅助的字段模型。
+ private static void AppendFindByPropertyMethod(
+ StringBuilder builder,
+ SchemaFileSpec schema,
+ SchemaPropertySpec property)
+ {
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ $" /// Finds all config entries whose property '{EscapeXmlDocumentation(property.DisplayPath)}' equals the supplied value.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" /// The property value to match.");
+ builder.AppendLine(" /// A read-only snapshot containing every matching config entry.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// The generated helper performs a deterministic linear scan over so it stays compatible with runtime hot reload and does not require secondary index infrastructure.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ $" public global::System.Collections.Generic.IReadOnlyList<{schema.ClassName}> FindBy{property.PropertyName}({property.TypeSpec.ClrType} value)");
+ builder.AppendLine(" {");
+ builder.AppendLine(
+ $" var matches = new global::System.Collections.Generic.List<{schema.ClassName}>();");
+ builder.AppendLine();
+ builder.AppendLine(
+ " // Scan the current table snapshot on demand so generated helpers stay aligned with reloadable runtime data.");
+ builder.AppendLine(" foreach (var candidate in All())");
+ builder.AppendLine(" {");
+ builder.AppendLine(
+ $" if (global::System.Collections.Generic.EqualityComparer<{property.TypeSpec.ClrType}>.Default.Equals(candidate.{property.PropertyName}, value))");
+ builder.AppendLine(" {");
+ builder.AppendLine(" matches.Add(candidate);");
+ builder.AppendLine(" }");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(
+ $" return matches.Count == 0 ? global::System.Array.Empty<{schema.ClassName}>() : matches.AsReadOnly();");
+ builder.AppendLine(" }");
+ }
+
+ ///
+ /// 生成按字段匹配首个结果的轻量查询辅助。
+ ///
+ /// 输出缓冲区。
+ /// 生成器级 schema 模型。
+ /// 要生成查询辅助的字段模型。
+ private static void AppendTryFindFirstByPropertyMethod(
+ StringBuilder builder,
+ SchemaFileSpec schema,
+ SchemaPropertySpec property)
+ {
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ $" /// Tries to find the first config entry whose property '{EscapeXmlDocumentation(property.DisplayPath)}' equals the supplied value.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" /// The property value to match.");
+ builder.AppendLine(
+ " /// The first matching config entry when lookup succeeds; otherwise .");
+ builder.AppendLine(" /// when a matching config entry is found; otherwise .");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// The generated helper walks the same snapshot exposed by and returns the first match in iteration order.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ $" public bool TryFindFirstBy{property.PropertyName}({property.TypeSpec.ClrType} value, out {schema.ClassName}? result)");
+ builder.AppendLine(" {");
+ builder.AppendLine(
+ " // Keep the search path allocation-free for the first-match case by exiting as soon as one entry matches.");
+ builder.AppendLine(" foreach (var candidate in All())");
+ builder.AppendLine(" {");
+ builder.AppendLine(
+ $" if (global::System.Collections.Generic.EqualityComparer<{property.TypeSpec.ClrType}>.Default.Equals(candidate.{property.PropertyName}, value))");
+ builder.AppendLine(" {");
+ builder.AppendLine(" result = candidate;");
+ builder.AppendLine(" return true;");
+ builder.AppendLine(" }");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(" result = null;");
+ builder.AppendLine(" return false;");
+ builder.AppendLine(" }");
+ }
+
///
/// 递归枚举对象树中所有带 ref-table 元数据的字段。
///
@@ -1717,4 +1842,4 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
Object,
Array
}
-}
\ No newline at end of file
+}
diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md
index b390afd7..ab7d9b2d 100644
--- a/docs/zh-CN/game/config-system.md
+++ b/docs/zh-CN/game/config-system.md
@@ -280,6 +280,82 @@ public sealed class GameConfigRuntime
`MonsterConfigBindings.References`
- 如果未来把配置初始化接入 `Architecture` 或 `Module`,迁移成本也更低
+### 生成查询辅助
+
+从当前阶段开始,生成的 `*Table` 包装会为“顶层、非主键、非引用的标量字段”额外产出轻量查询辅助。
+
+如果 `monster.schema.json` 包含顶层标量字段 `name`、`faction`,则可以直接这样使用:
+
+```csharp
+var monsterTable = registry.GetMonsterTable();
+
+var slime = monsterTable.FindByName("Slime");
+
+if (monsterTable.TryFindFirstByFaction("dungeon", out var firstDungeonMonster))
+{
+ Console.WriteLine(firstDungeonMonster.Name);
+}
+```
+
+当前生成规则刻意保持保守:
+
+- 只为顶层标量字段生成 `FindBy*` 与 `TryFindFirstBy*`
+- 主键字段继续只走 `Get / TryGet`
+- 嵌套对象、对象数组、标量数组和 `x-gframework-ref-table` 字段暂不生成查询辅助
+- 查询实现基于 `All()` 做线性扫描,不引入运行时索引或缓存
+
+这意味着它的定位是“减少业务层手写过滤样板”,而不是“替代专门索引结构”。
+
+如果你依赖 `TryFindFirstBy*`,应当把它理解为“返回当前表快照遍历顺序下的第一个匹配项”,而不是固定排序语义。
+
+### Architecture 推荐接入模板
+
+如果你的项目已经基于 `GFramework.Core.Architectures.Architecture` 组织初始化流程,推荐把配置系统接到 `OnInitialize()` 阶段,并把 `ConfigRegistry` 注册为 utility:
+
+```csharp
+using GFramework.Core.Architectures;
+using GFramework.Game.Config;
+using GFramework.Game.Config.Generated;
+
+public sealed class GameArchitecture : Architecture
+{
+ private readonly string _configRootPath;
+
+ public GameArchitecture(string configRootPath)
+ {
+ _configRootPath = configRootPath ?? throw new ArgumentNullException(nameof(configRootPath));
+ }
+
+ protected override void OnInitialize()
+ {
+ var registry = RegisterUtility(new ConfigRegistry());
+
+ var loader = new YamlConfigLoader(_configRootPath)
+ .RegisterMonsterTable()
+ .RegisterItemTable();
+
+ loader.LoadAsync(registry).GetAwaiter().GetResult();
+ }
+}
+```
+
+初始化完成后,业务组件可以继续通过架构上下文读取 utility,再走生成的强类型入口:
+
+```csharp
+var registry = Context.GetUtility();
+var monsterTable = registry.GetMonsterTable();
+var slime = monsterTable.Get(1);
+```
+
+推荐遵循以下顺序:
+
+- 先注册 `ConfigRegistry`
+- 再构造并配置 `YamlConfigLoader`
+- 在 `OnInitialize()` 内完成首次 `LoadAsync`
+- 初始化完成后只通过注册表和生成表包装访问配置
+
+当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + ConfigRegistry + YamlConfigLoader + Register*Table()` 组合已经足够作为官方推荐接入路径。
+
### 热重载模板
如果你希望把开发期热重载显式收敛为一个可选能力,建议把失败诊断一起写进模板,而不是只打印异常文本: