mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
Merge pull request #186 from GeWuYou/feat/add-game-content-config-with-source-generator
docs(config): 添加游戏内容配置系统文档和集成测试
This commit is contained in:
commit
77f67c8a9c
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 提供一个占位符类型,用于支持 C# 9.0 的 init 访问器功能。
|
||||
/// 该类型在 .NET 5.0 及更高版本中已内置,因此仅在较低版本的 .NET 中定义。
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
/// <summary>
|
||||
/// 提供一个占位符类型,用于支持 C# 9.0 的 init 访问器功能。
|
||||
/// 该类型在 .NET 5.0 及更高版本中已内置,因此仅在较低版本的 .NET 中定义。
|
||||
/// </summary>
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
internal static class IsExternalInit
|
||||
{
|
||||
}
|
||||
#endif
|
||||
@ -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;
|
||||
|
||||
/// <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.
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 验证消费者项目通过 `schemas/**/*.schema.json` 自动拾取 schema 后,
|
||||
/// 可以直接编译并使用生成的注册辅助、强类型访问入口与运行时加载链路。
|
||||
/// 可以直接编译并使用生成的注册辅助、强类型访问入口、查询辅助与运行时加载链路。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class GeneratedConfigConsumerIntegrationTests
|
||||
@ -37,7 +39,7 @@ public class GeneratedConfigConsumerIntegrationTests
|
||||
|
||||
/// <summary>
|
||||
/// 验证生成器自动拾取消费者项目的 schema 后,
|
||||
/// 可以用生成的注册辅助完成加载,并通过强类型表包装访问运行时数据。
|
||||
/// 可以用生成的注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ="));
|
||||
}
|
||||
}
|
||||
|
||||
/// <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("));
|
||||
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("));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,4 +52,102 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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("// <auto-generated />");
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 递归枚举对象树中所有带 ref-table 元数据的字段。
|
||||
/// </summary>
|
||||
@ -1717,4 +1842,4 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
Object,
|
||||
Array
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ConfigRegistry>();
|
||||
var monsterTable = registry.GetMonsterTable();
|
||||
var slime = monsterTable.Get(1);
|
||||
```
|
||||
|
||||
推荐遵循以下顺序:
|
||||
|
||||
- 先注册 `ConfigRegistry`
|
||||
- 再构造并配置 `YamlConfigLoader`
|
||||
- 在 `OnInitialize()` 内完成首次 `LoadAsync`
|
||||
- 初始化完成后只通过注册表和生成表包装访问配置
|
||||
|
||||
当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + ConfigRegistry + YamlConfigLoader + Register*Table()` 组合已经足够作为官方推荐接入路径。
|
||||
|
||||
### 热重载模板
|
||||
|
||||
如果你希望把开发期热重载显式收敛为一个可选能力,建议把失败诊断一起写进模板,而不是只打印异常文本:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user