docs(config): 添加游戏内容配置系统文档和集成测试

- 新增游戏内容配置系统完整文档,包含 YAML 配置、JSON Schema 结构描述
- 添加推荐目录结构和配置示例,支持怪物、物品、技能等静态内容管理
- 实现 Source Generator 自动生成配置类型、表包装和注册访问辅助功能
- 集成 VS Code 插件提供配置浏览、raw 编辑、schema 打开和校验功能
- 添加生成查询辅助,为顶层标量字段生成 FindBy* 与 TryFindFirstBy* 方法
- 实现开发期热重载功能,支持配置文件修改后自动刷新运行时表
- 添加跨表引用校验,支持 x-gframework-ref-table 声明的引用关系检查
- 新增集成测试验证生成器自动拾取 schema 并支持强类型访问入口
- 添加 IsExternalInit 类型支持低版本 .NET 框架的 init-only setter 功能
This commit is contained in:
GeWuYou 2026-04-06 11:42:34 +08:00
parent 397611d47c
commit 2b30e859e9
8 changed files with 611 additions and 26 deletions

View File

@ -1,20 +1,20 @@
// IsExternalInit.cs // IsExternalInit.cs
// This type is required to support init-only setters and record types // This type is required to support init-only setters and record types
// when targeting netstandard2.0 or older frameworks. // when targeting netstandard2.0 or older frameworks.
#if !NET5_0_OR_GREATER #if !NET5_0_OR_GREATER
using System.ComponentModel; using System.ComponentModel;
// ReSharper disable CheckNamespace // ReSharper disable CheckNamespace
namespace System.Runtime.CompilerServices; namespace System.Runtime.CompilerServices;
/// <summary> /// <summary>
/// 提供一个占位符类型,用于支持 C# 9.0 的 init 访问器功能。 /// 提供一个占位符类型,用于支持 C# 9.0 的 init 访问器功能。
/// 该类型在 .NET 5.0 及更高版本中已内置,因此仅在较低版本的 .NET 中定义。 /// 该类型在 .NET 5.0 及更高版本中已内置,因此仅在较低版本的 .NET 中定义。
/// </summary> /// </summary>
[EditorBrowsable(EditorBrowsableState.Never)] [EditorBrowsable(EditorBrowsableState.Never)]
internal static class IsExternalInit internal static class IsExternalInit
{ {
} }
#endif #endif

View File

@ -0,0 +1,151 @@
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;
/// <summary>
/// 验证在 <see cref="Architecture" /> 初始化流程中可以注册配置注册表、执行加载并通过生成的表访问器读取数据。
/// </summary>
[TestFixture]
public class ArchitectureConfigIntegrationTests
{
/// <summary>
/// 架构初始化期间,通过 <see cref="YamlConfigLoader" /> 注册生成表,
/// 并将 <see cref="ConfigRegistry" /> 作为 utility 暴露给架构上下文读取。
/// </summary>
[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<ConfigRegistry>(), 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;
}
/// <summary>
/// 最佳努力尝试删除临时目录。
/// </summary>
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.
}
}
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();
}
}
}

View File

@ -1,4 +1,6 @@
using System;
using System.IO; using System.IO;
using System.Linq;
using GFramework.Game.Config; using GFramework.Game.Config;
using GFramework.Game.Config.Generated; using GFramework.Game.Config.Generated;
@ -6,7 +8,7 @@ namespace GFramework.Game.Tests.Config;
/// <summary> /// <summary>
/// 验证消费者项目通过 `schemas/**/*.schema.json` 自动拾取 schema 后, /// 验证消费者项目通过 `schemas/**/*.schema.json` 自动拾取 schema 后,
/// 可以直接编译并使用生成的注册辅助、强类型访问入口与运行时加载链路。 /// 可以直接编译并使用生成的注册辅助、强类型访问入口、查询辅助与运行时加载链路。
/// </summary> /// </summary>
[TestFixture] [TestFixture]
public class GeneratedConfigConsumerIntegrationTests public class GeneratedConfigConsumerIntegrationTests
@ -37,7 +39,7 @@ public class GeneratedConfigConsumerIntegrationTests
/// <summary> /// <summary>
/// 验证生成器自动拾取消费者项目的 schema 后, /// 验证生成器自动拾取消费者项目的 schema 后,
/// 可以用生成的注册辅助完成加载,并通过强类型表包装访问运行时数据 /// 可以用生成的注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助
/// </summary> /// </summary>
[Test] [Test]
public async Task LoadAsync_Should_Support_Generated_Bindings_In_Consumer_Project() public async Task LoadAsync_Should_Support_Generated_Bindings_In_Consumer_Project()
@ -49,7 +51,7 @@ public class GeneratedConfigConsumerIntegrationTests
"title": "Monster Config", "title": "Monster Config",
"description": "Defines one monster entry for the end-to-end consumer integration test.", "description": "Defines one monster entry for the end-to-end consumer integration test.",
"type": "object", "type": "object",
"required": ["id", "name", "hp"], "required": ["id", "name", "hp", "faction"],
"properties": { "properties": {
"id": { "id": {
"type": "integer", "type": "integer",
@ -62,6 +64,10 @@ public class GeneratedConfigConsumerIntegrationTests
"hp": { "hp": {
"type": "integer", "type": "integer",
"description": "Monster base health." "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 id: 1
name: Slime name: Slime
hp: 10 hp: 10
faction: dungeon
"""); """);
CreateFile( CreateFile(
"monster/goblin.yaml", "monster/goblin.yaml",
@ -79,6 +86,7 @@ public class GeneratedConfigConsumerIntegrationTests
id: 2 id: 2
name: Goblin name: Goblin
hp: 30 hp: 30
faction: dungeon
"""); """);
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
@ -88,6 +96,7 @@ public class GeneratedConfigConsumerIntegrationTests
await loader.LoadAsync(registry); await loader.LoadAsync(registry);
var table = registry.GetMonsterTable(); var table = registry.GetMonsterTable();
var dungeonMonsters = table.FindByFaction("dungeon");
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -106,6 +115,16 @@ public class GeneratedConfigConsumerIntegrationTests
Assert.That(table.Count, Is.EqualTo(2)); Assert.That(table.Count, Is.EqualTo(2));
Assert.That(table.Get(1).Name, Is.EqualTo("Slime")); Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
Assert.That(table.Get(2).Hp, Is.EqualTo(30)); 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(registry.TryGetMonsterTable(out var generatedTable), Is.True);
Assert.That(generatedTable, Is.Not.Null); Assert.That(generatedTable, Is.Not.Null);
Assert.That(generatedTable!.All().Select(static config => config.Name), 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)); File.WriteAllText(path, content.Replace("\n", Environment.NewLine, StringComparison.Ordinal));
} }
} }

View File

@ -5,7 +5,8 @@
"required": [ "required": [
"id", "id",
"name", "name",
"hp" "hp",
"faction"
], ],
"properties": { "properties": {
"id": { "id": {
@ -19,6 +20,10 @@
"hp": { "hp": {
"type": "integer", "type": "integer",
"description": "Monster base health." "description": "Monster base health."
},
"faction": {
"type": "string",
"description": "Used by integration tests to validate generated non-unique queries."
} }
} }
} }

View File

@ -236,4 +236,115 @@ public class SchemaConfigGeneratorTests
Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems1 =")); Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems1 ="));
Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems11 =")); Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems11 ="));
} }
}
/// <summary>
/// 验证生成器只为顶层非主键标量字段生成轻量查询辅助,
/// 避免把数组、对象和引用字段误生成为查询 API。
/// </summary>
[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<TKey, TValue> : IConfigTable
where TKey : notnull
{
TValue Get(TKey key);
bool TryGet(TKey key, out TValue? value);
bool ContainsKey(TKey key);
IReadOnlyCollection<TValue> All();
}
public interface IConfigRegistry
{
IConfigTable<TKey, TValue> GetTable<TKey, TValue>(string name)
where TKey : notnull;
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
where TKey : notnull;
}
}
namespace GFramework.Game.Config
{
public sealed class YamlConfigLoader
{
public YamlConfigLoader RegisterTable<TKey, TValue>(
string tableName,
string relativePath,
string schemaRelativePath,
Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? comparer = null)
where TKey : notnull
{
return this;
}
}
}
""";
const string 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("));
});
}
}

View File

@ -52,4 +52,102 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.
{ {
return _inner.All(); return _inner.All();
} }
/// <summary>
/// Finds all config entries whose property 'name' equals the supplied value.
/// </summary>
/// <param name="value">The property value to match.</param>
/// <returns>A read-only snapshot containing every matching config entry.</returns>
/// <remarks>
/// The generated helper performs a deterministic linear scan over <see cref="All"/> so it stays compatible with runtime hot reload and does not require secondary index infrastructure.
/// </remarks>
public global::System.Collections.Generic.IReadOnlyList<MonsterConfig> FindByName(string value)
{
var matches = new global::System.Collections.Generic.List<MonsterConfig>();
// 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<string>.Default.Equals(candidate.Name, value))
{
matches.Add(candidate);
}
}
return matches.Count == 0 ? global::System.Array.Empty<MonsterConfig>() : matches.AsReadOnly();
}
/// <summary>
/// Tries to find the first config entry whose property 'name' equals the supplied value.
/// </summary>
/// <param name="value">The property value to match.</param>
/// <param name="result">The first matching config entry when lookup succeeds; otherwise <see langword="null" />.</param>
/// <returns><see langword="true" /> when a matching config entry is found; otherwise <see langword="false" />.</returns>
/// <remarks>
/// The generated helper walks the same snapshot exposed by <see cref="All"/> and returns the first match in iteration order.
/// </remarks>
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<string>.Default.Equals(candidate.Name, value))
{
result = candidate;
return true;
}
}
result = null;
return false;
}
/// <summary>
/// Finds all config entries whose property 'hp' equals the supplied value.
/// </summary>
/// <param name="value">The property value to match.</param>
/// <returns>A read-only snapshot containing every matching config entry.</returns>
/// <remarks>
/// The generated helper performs a deterministic linear scan over <see cref="All"/> so it stays compatible with runtime hot reload and does not require secondary index infrastructure.
/// </remarks>
public global::System.Collections.Generic.IReadOnlyList<MonsterConfig> FindByHp(int? value)
{
var matches = new global::System.Collections.Generic.List<MonsterConfig>();
// 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<int?>.Default.Equals(candidate.Hp, value))
{
matches.Add(candidate);
}
}
return matches.Count == 0 ? global::System.Array.Empty<MonsterConfig>() : matches.AsReadOnly();
}
/// <summary>
/// Tries to find the first config entry whose property 'hp' equals the supplied value.
/// </summary>
/// <param name="value">The property value to match.</param>
/// <param name="result">The first matching config entry when lookup succeeds; otherwise <see langword="null" />.</param>
/// <returns><see langword="true" /> when a matching config entry is found; otherwise <see langword="false" />.</returns>
/// <remarks>
/// The generated helper walks the same snapshot exposed by <see cref="All"/> and returns the first match in iteration order.
/// </remarks>
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<int?>.Default.Equals(candidate.Hp, value))
{
result = candidate;
return true;
}
}
result = null;
return false;
}
} }

View File

@ -560,6 +560,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
private static string GenerateTableClass(SchemaFileSpec schema) private static string GenerateTableClass(SchemaFileSpec schema)
{ {
var builder = new StringBuilder(); var builder = new StringBuilder();
var queryableProperties = CollectQueryableProperties(schema).ToArray();
builder.AppendLine("// <auto-generated />"); builder.AppendLine("// <auto-generated />");
builder.AppendLine("#nullable enable"); builder.AppendLine("#nullable enable");
builder.AppendLine(); builder.AppendLine();
@ -620,6 +621,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" {"); builder.AppendLine(" {");
builder.AppendLine(" return _inner.All();"); builder.AppendLine(" return _inner.All();");
builder.AppendLine(" }"); builder.AppendLine(" }");
foreach (var property in queryableProperties)
{
builder.AppendLine();
AppendFindByPropertyMethod(builder, schema, property);
builder.AppendLine();
AppendTryFindFirstByPropertyMethod(builder, schema, property);
}
builder.AppendLine("}"); builder.AppendLine("}");
return builder.ToString().TrimEnd(); return builder.ToString().TrimEnd();
} }
@ -975,6 +985,121 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
} }
} }
/// <summary>
/// 收集适合生成轻量查询辅助的根级标量字段。
/// 当前实现故意限定在顶层非主键标量字段,避免把嵌套结构、数组或引用语义提前固化为运行时契约。
/// </summary>
/// <param name="schema">生成器级 schema 模型。</param>
/// <returns>可生成查询辅助的属性集合。</returns>
private static IEnumerable<SchemaPropertySpec> 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;
}
}
/// <summary>
/// 生成按字段匹配全部结果的轻量查询辅助。
/// </summary>
/// <param name="builder">输出缓冲区。</param>
/// <param name="schema">生成器级 schema 模型。</param>
/// <param name="property">要生成查询辅助的字段模型。</param>
private static void AppendFindByPropertyMethod(
StringBuilder builder,
SchemaFileSpec schema,
SchemaPropertySpec property)
{
builder.AppendLine(" /// <summary>");
builder.AppendLine(
$" /// Finds all config entries whose property '{EscapeXmlDocumentation(property.DisplayPath)}' equals the supplied value.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"value\">The property value to match.</param>");
builder.AppendLine(" /// <returns>A read-only snapshot containing every matching config entry.</returns>");
builder.AppendLine(" /// <remarks>");
builder.AppendLine(
" /// The generated helper performs a deterministic linear scan over <see cref=\"All\"/> so it stays compatible with runtime hot reload and does not require secondary index infrastructure.");
builder.AppendLine(" /// </remarks>");
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(" }");
}
/// <summary>
/// 生成按字段匹配首个结果的轻量查询辅助。
/// </summary>
/// <param name="builder">输出缓冲区。</param>
/// <param name="schema">生成器级 schema 模型。</param>
/// <param name="property">要生成查询辅助的字段模型。</param>
private static void AppendTryFindFirstByPropertyMethod(
StringBuilder builder,
SchemaFileSpec schema,
SchemaPropertySpec property)
{
builder.AppendLine(" /// <summary>");
builder.AppendLine(
$" /// Tries to find the first config entry whose property '{EscapeXmlDocumentation(property.DisplayPath)}' equals the supplied value.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"value\">The property value to match.</param>");
builder.AppendLine(
" /// <param name=\"result\">The first matching config entry when lookup succeeds; otherwise <see langword=\"null\" />.</param>");
builder.AppendLine(" /// <returns><see langword=\"true\" /> when a matching config entry is found; otherwise <see langword=\"false\" />.</returns>");
builder.AppendLine(" /// <remarks>");
builder.AppendLine(
" /// The generated helper walks the same snapshot exposed by <see cref=\"All\"/> and returns the first match in iteration order.");
builder.AppendLine(" /// </remarks>");
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(" }");
}
/// <summary> /// <summary>
/// 递归枚举对象树中所有带 ref-table 元数据的字段。 /// 递归枚举对象树中所有带 ref-table 元数据的字段。
/// </summary> /// </summary>
@ -1717,4 +1842,4 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
Object, Object,
Array Array
} }
} }

View File

@ -280,6 +280,82 @@ public sealed class GameConfigRuntime
`MonsterConfigBindings.References` `MonsterConfigBindings.References`
- 如果未来把配置初始化接入 `Architecture``Module`,迁移成本也更低 - 如果未来把配置初始化接入 `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<ConfigRegistry>();
var monsterTable = registry.GetMonsterTable();
var slime = monsterTable.Get(1);
```
推荐遵循以下顺序:
- 先注册 `ConfigRegistry`
- 再构造并配置 `YamlConfigLoader`
- 在 `OnInitialize()` 内完成首次 `LoadAsync`
- 初始化完成后只通过注册表和生成表包装访问配置
当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + ConfigRegistry + YamlConfigLoader + Register*Table()` 组合已经足够作为官方推荐接入路径。
### 热重载模板 ### 热重载模板
如果你希望把开发期热重载显式收敛为一个可选能力,建议把失败诊断一起写进模板,而不是只打印异常文本: 如果你希望把开发期热重载显式收敛为一个可选能力,建议把失败诊断一起写进模板,而不是只打印异常文本: