diff --git a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
index a419c260..7556db18 100644
--- a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
+++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
@@ -91,9 +91,18 @@ public class GeneratedConfigConsumerIntegrationTests
Assert.Multiple(() =>
{
+ 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));
diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
index 476f3dea..86f01098 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -71,6 +71,68 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证加载器支持通过选项对象注册带 schema 校验的配置表。
+ ///
+ [Test]
+ public async Task RegisterTable_Should_Support_Options_Object()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 10
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "hp": { "type": "integer" }
+ }
+ }
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable(
+ new YamlConfigTableRegistrationOptions(
+ "monster",
+ "monster",
+ static config => config.Id)
+ {
+ SchemaRelativePath = "schemas/monster.schema.json"
+ });
+ var registry = new ConfigRegistry();
+
+ await loader.LoadAsync(registry);
+
+ var table = registry.GetTable("monster");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(table.Count, Is.EqualTo(1));
+ Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
+ Assert.That(table.Get(1).Hp, Is.EqualTo(10));
+ });
+ }
+
+ ///
+ /// 验证加载器会拒绝空的配置表注册选项对象。
+ ///
+ [Test]
+ public void RegisterTable_Should_Throw_When_Options_Are_Null()
+ {
+ var loader = new YamlConfigLoader(_rootPath);
+
+ Assert.Throws(() =>
+ loader.RegisterTable(null!));
+ }
+
///
/// 验证注册的配置目录不存在时会抛出清晰错误。
///
@@ -999,6 +1061,98 @@ public class YamlConfigLoaderTests
}
}
+ ///
+ /// 验证热重载支持通过选项对象配置回调和防抖延迟。
+ ///
+ [Test]
+ public async Task EnableHotReload_Should_Support_Options_Object()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 10
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "hp": { "type": "integer" }
+ }
+ }
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable(
+ new YamlConfigTableRegistrationOptions(
+ "monster",
+ "monster",
+ static config => config.Id)
+ {
+ SchemaRelativePath = "schemas/monster.schema.json"
+ });
+ var registry = new ConfigRegistry();
+ await loader.LoadAsync(registry);
+
+ var reloadTaskSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var hotReload = loader.EnableHotReload(
+ registry,
+ new YamlConfigHotReloadOptions
+ {
+ OnTableReloaded = tableName => reloadTaskSource.TrySetResult(tableName),
+ DebounceDelay = TimeSpan.FromMilliseconds(150)
+ });
+
+ try
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 25
+ """);
+
+ var tableName = await WaitForTaskWithinAsync(reloadTaskSource.Task, TimeSpan.FromSeconds(5));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(tableName, Is.EqualTo("monster"));
+ Assert.That(registry.GetTable("monster").Get(1).Hp, Is.EqualTo(25));
+ });
+ }
+ finally
+ {
+ hotReload.UnRegister();
+ }
+ }
+
+ ///
+ /// 验证热重载会在启动前拒绝负的防抖延迟,避免后台延迟任务才暴露参数错误。
+ ///
+ [Test]
+ public void EnableHotReload_Should_Throw_When_Debounce_Delay_Is_Negative()
+ {
+ var loader = new YamlConfigLoader(_rootPath);
+ var registry = new ConfigRegistry();
+
+ var exception = Assert.Throws(() =>
+ loader.EnableHotReload(
+ registry,
+ new YamlConfigHotReloadOptions
+ {
+ DebounceDelay = TimeSpan.FromMilliseconds(-1)
+ }));
+
+ Assert.That(exception!.ParamName, Is.EqualTo("options"));
+ }
+
///
/// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。
///
diff --git a/GFramework.Game.Tests/Config/YamlConfigTableRegistrationOptionsTests.cs b/GFramework.Game.Tests/Config/YamlConfigTableRegistrationOptionsTests.cs
new file mode 100644
index 00000000..b2d01d72
--- /dev/null
+++ b/GFramework.Game.Tests/Config/YamlConfigTableRegistrationOptionsTests.cs
@@ -0,0 +1,46 @@
+using GFramework.Game.Config;
+
+namespace GFramework.Game.Tests.Config;
+
+///
+/// 验证 YAML 配置表注册选项会在构造阶段建立最小不变量,避免非法路径状态继续向后传播。
+///
+[TestFixture]
+public class YamlConfigTableRegistrationOptionsTests
+{
+ ///
+ /// 验证构造函数会拒绝空的或仅空白字符的表名。
+ ///
+ /// 待验证的表名。
+ [TestCase(null)]
+ [TestCase("")]
+ [TestCase(" ")]
+ public void Constructor_Should_Throw_When_Table_Name_Is_Null_Or_Whitespace(string? tableName)
+ {
+ var exception = Assert.Throws(() =>
+ _ = new YamlConfigTableRegistrationOptions(
+ tableName!,
+ "monster",
+ static config => config.Length));
+
+ Assert.That(exception!.ParamName, Is.EqualTo("tableName"));
+ }
+
+ ///
+ /// 验证构造函数会拒绝空的或仅空白字符的相对目录路径。
+ ///
+ /// 待验证的相对目录路径。
+ [TestCase(null)]
+ [TestCase("")]
+ [TestCase(" ")]
+ public void Constructor_Should_Throw_When_Relative_Path_Is_Null_Or_Whitespace(string? relativePath)
+ {
+ var exception = Assert.Throws(() =>
+ _ = new YamlConfigTableRegistrationOptions(
+ "monster",
+ relativePath!,
+ static config => config.Length));
+
+ Assert.That(exception!.ParamName, Is.EqualTo("relativePath"));
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Game/Config/YamlConfigHotReloadOptions.cs b/GFramework.Game/Config/YamlConfigHotReloadOptions.cs
new file mode 100644
index 00000000..52b17006
--- /dev/null
+++ b/GFramework.Game/Config/YamlConfigHotReloadOptions.cs
@@ -0,0 +1,27 @@
+namespace GFramework.Game.Config;
+
+///
+/// 描述开发期热重载的可选行为。
+/// 该选项对象集中承载回调和防抖等可扩展参数,
+/// 以避免后续继续在
+/// 上堆叠额外重载。
+///
+public sealed class YamlConfigHotReloadOptions
+{
+ ///
+ /// 获取或设置单个配置表重载成功后的可选回调。
+ ///
+ public Action? OnTableReloaded { get; init; }
+
+ ///
+ /// 获取或设置单个配置表重载失败后的可选回调。
+ /// 当失败来自加载器本身时,异常通常为 。
+ ///
+ public Action? OnTableReloadFailed { get; init; }
+
+ ///
+ /// 获取或设置文件系统事件的防抖延迟。
+ /// 默认值为 200 毫秒,用于吸收编辑器保存时的短时间重复触发。
+ ///
+ public TimeSpan DebounceDelay { get; init; } = TimeSpan.FromMilliseconds(200);
+}
\ No newline at end of file
diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs
index 5034f2cf..012caa32 100644
--- a/GFramework.Game/Config/YamlConfigLoader.cs
+++ b/GFramework.Game/Config/YamlConfigLoader.cs
@@ -20,6 +20,8 @@ public sealed class YamlConfigLoader : IConfigLoader
private const string SchemaRelativePathCannotBeNullOrWhiteSpaceMessage =
"Schema relative path cannot be null or whitespace.";
+ private static readonly TimeSpan DefaultHotReloadDebounceDelay = TimeSpan.FromMilliseconds(200);
+
private readonly IDeserializer _deserializer;
private readonly Dictionary> _lastSuccessfulDependencies =
@@ -95,13 +97,50 @@ public sealed class YamlConfigLoader : IConfigLoader
/// 防抖延迟;为空时默认使用 200 毫秒。
/// 用于停止热重载监听的注销句柄。
/// 当 为空时抛出。
+ ///
+ /// 当显式提供的 小于 时抛出。
+ ///
public IUnRegister EnableHotReload(
IConfigRegistry registry,
Action? onTableReloaded = null,
Action? onTableReloadFailed = null,
TimeSpan? debounceDelay = null)
+ {
+ return EnableHotReload(
+ registry,
+ new YamlConfigHotReloadOptions
+ {
+ OnTableReloaded = onTableReloaded,
+ OnTableReloadFailed = onTableReloadFailed,
+ DebounceDelay = debounceDelay ?? DefaultHotReloadDebounceDelay
+ });
+ }
+
+ ///
+ /// 启用开发期热重载,并通过选项对象集中配置回调和防抖行为。
+ /// 该入口用于减少继续堆叠位置参数重载的需要,
+ /// 也为未来扩展过滤策略或日志钩子预留稳定形态。
+ ///
+ /// 要被热重载更新的配置注册表。
+ /// 热重载配置选项;为空时使用默认选项。
+ /// 用于停止热重载监听的注销句柄。
+ /// 当 为空时抛出。
+ ///
+ /// 当 的 小于
+ /// 时抛出。
+ ///
+ public IUnRegister EnableHotReload(
+ IConfigRegistry registry,
+ YamlConfigHotReloadOptions? options)
{
ArgumentNullException.ThrowIfNull(registry);
+ options ??= new YamlConfigHotReloadOptions();
+ if (options.DebounceDelay < TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(options),
+ "DebounceDelay must be greater than or equal to zero.");
+ }
return new HotReloadSession(
_rootPath,
@@ -109,9 +148,9 @@ public sealed class YamlConfigLoader : IConfigLoader
registry,
_registrations,
_lastSuccessfulDependencies,
- onTableReloaded,
- onTableReloadFailed,
- debounceDelay ?? TimeSpan.FromMilliseconds(200));
+ options.OnTableReloaded,
+ options.OnTableReloadFailed,
+ options.DebounceDelay);
}
private void UpdateLastSuccessfulDependencies(IEnumerable loadedTables)
@@ -135,6 +174,10 @@ public sealed class YamlConfigLoader : IConfigLoader
/// 配置项主键提取器。
/// 可选主键比较器。
/// 当前加载器实例,以便链式注册。
+ ///
+ /// 当 或 为 null、空字符串或空白字符串时抛出。
+ ///
+ /// 当 为 null 时抛出。
public YamlConfigLoader RegisterTable(
string tableName,
string relativePath,
@@ -142,7 +185,11 @@ public sealed class YamlConfigLoader : IConfigLoader
IEqualityComparer? comparer = null)
where TKey : notnull
{
- return RegisterTableCore(tableName, relativePath, null, keySelector, comparer);
+ return RegisterTable(
+ new YamlConfigTableRegistrationOptions(tableName, relativePath, keySelector)
+ {
+ Comparer = comparer
+ });
}
///
@@ -158,6 +205,11 @@ public sealed class YamlConfigLoader : IConfigLoader
/// 配置项主键提取器。
/// 可选主键比较器。
/// 当前加载器实例,以便链式注册。
+ ///
+ /// 当 、 或
+ /// 为 null、空字符串或空白字符串时抛出。
+ ///
+ /// 当 为 null 时抛出。
public YamlConfigLoader RegisterTable(
string tableName,
string relativePath,
@@ -166,7 +218,40 @@ public sealed class YamlConfigLoader : IConfigLoader
IEqualityComparer? comparer = null)
where TKey : notnull
{
- return RegisterTableCore(tableName, relativePath, schemaRelativePath, keySelector, comparer);
+ return RegisterTable(
+ new YamlConfigTableRegistrationOptions(tableName, relativePath, keySelector)
+ {
+ SchemaRelativePath = schemaRelativePath,
+ Comparer = comparer
+ });
+ }
+
+ ///
+ /// 使用选项对象注册一个 YAML 配置表定义。
+ /// 该入口集中承载配置目录、schema 路径、主键提取器和比较器,
+ /// 以避免未来继续为新增开关叠加更多重载。
+ ///
+ /// 配置主键类型。
+ /// 配置值类型。
+ /// 配置表注册选项。
+ /// 当前加载器实例,以便链式注册。
+ /// 当 为空时抛出。
+ ///
+ /// 当 内的 、
+ /// 或
+ /// 为 null、空字符串或空白字符串时抛出。
+ ///
+ public YamlConfigLoader RegisterTable(YamlConfigTableRegistrationOptions options)
+ where TKey : notnull
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return RegisterTableCore(
+ options.TableName,
+ options.RelativePath,
+ options.SchemaRelativePath,
+ options.KeySelector,
+ options.Comparer);
}
private YamlConfigLoader RegisterTableCore(
diff --git a/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs b/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs
new file mode 100644
index 00000000..0f34687d
--- /dev/null
+++ b/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs
@@ -0,0 +1,73 @@
+namespace GFramework.Game.Config;
+
+///
+/// 描述一个 YAML 配置表注册项的参数集合。
+/// 该选项对象用于替代不断增加的位置参数重载,
+/// 让消费者在启用 schema 校验、主键比较器或未来扩展项时仍能保持调用点可读。
+///
+/// 配置主键类型。
+/// 配置值类型。
+public sealed class YamlConfigTableRegistrationOptions
+ where TKey : notnull
+{
+ private const string TableNameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace.";
+ private const string RelativePathCannotBeNullOrWhiteSpaceMessage = "Relative path cannot be null or whitespace.";
+
+ ///
+ /// 使用最小必需参数创建配置表注册选项。
+ ///
+ /// 运行时配置表名称。
+ /// 相对配置根目录的子目录。
+ /// 配置项主键提取器。
+ ///
+ /// 当 或 为 null、空字符串或空白字符串时抛出。
+ ///
+ /// 当 为 null 时抛出。
+ public YamlConfigTableRegistrationOptions(
+ string tableName,
+ string relativePath,
+ Func keySelector)
+ {
+ if (string.IsNullOrWhiteSpace(tableName))
+ {
+ throw new ArgumentException(TableNameCannotBeNullOrWhiteSpaceMessage, nameof(tableName));
+ }
+
+ if (string.IsNullOrWhiteSpace(relativePath))
+ {
+ throw new ArgumentException(RelativePathCannotBeNullOrWhiteSpaceMessage, nameof(relativePath));
+ }
+
+ ArgumentNullException.ThrowIfNull(keySelector);
+
+ TableName = tableName;
+ RelativePath = relativePath;
+ KeySelector = keySelector;
+ }
+
+ ///
+ /// 获取运行时配置表名称。
+ ///
+ public string TableName { get; }
+
+ ///
+ /// 获取相对配置根目录的子目录。
+ ///
+ public string RelativePath { get; }
+
+ ///
+ /// 获取相对配置根目录的 schema 文件路径。
+ /// 当该值为空时,当前注册项不会启用 schema 校验。
+ ///
+ public string? SchemaRelativePath { get; init; }
+
+ ///
+ /// 获取配置项主键提取器。
+ ///
+ public Func KeySelector { get; }
+
+ ///
+ /// 获取可选的主键比较器。
+ ///
+ public IEqualityComparer? Comparer { get; init; }
+}
\ No newline at end of file
diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs
index 0b237543..6ff638e1 100644
--- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs
+++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs
@@ -132,7 +132,8 @@ public class SchemaConfigGeneratorSnapshotTests
"type": "string",
"description": "Monster reference id.",
"minLength": 2,
- "maxLength": 32
+ "maxLength": 32,
+ "x-gframework-ref-table": "monster"
}
}
}
diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
index 507bf36f..07b3b81c 100644
--- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
+++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
@@ -91,4 +91,149 @@ public class SchemaConfigGeneratorTests
Assert.That(diagnostic.GetMessage(), Does.Contain("array"));
});
}
+
+ ///
+ /// 验证 schema 字段名无法映射为合法 C# 标识符时会直接给出诊断,而不是生成不可编译代码。
+ ///
+ /// 会映射为非法 C# 标识符的 schema key。
+ /// 当前命名规范化逻辑生成出的非法标识符。
+ [TestCase("drop$item", "Drop$item")]
+ [TestCase("1-phase", "1Phase")]
+ public void Run_Should_Report_Diagnostic_When_Schema_Key_Maps_To_Invalid_CSharp_Identifier(
+ string schemaKey,
+ string generatedIdentifier)
+ {
+ const string source = """
+ namespace TestApp
+ {
+ public sealed class Dummy
+ {
+ }
+ }
+ """;
+
+ var schema = $$"""
+ {
+ "type": "object",
+ "required": ["id", "{{schemaKey}}"],
+ "properties": {
+ "id": { "type": "integer" },
+ "{{schemaKey}}": { "type": "string" }
+ }
+ }
+ """;
+
+ var result = SchemaGeneratorTestDriver.Run(
+ source,
+ ("monster.schema.json", schema));
+
+ var diagnostic = result.Results.Single().Diagnostics.Single();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_006"));
+ Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
+ Assert.That(diagnostic.GetMessage(), Does.Contain(schemaKey));
+ Assert.That(diagnostic.GetMessage(), Does.Contain(generatedIdentifier));
+ });
+ }
+
+ ///
+ /// 验证引用元数据成员名在不同路径规范化后发生碰撞时,生成器仍会分配全局唯一的成员名。
+ ///
+ [Test]
+ public void Run_Should_Assign_Globally_Unique_Reference_Metadata_Member_Names()
+ {
+ 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"],
+ "properties": {
+ "id": { "type": "integer" },
+ "drop-items": {
+ "type": "array",
+ "items": { "type": "string" },
+ "x-gframework-ref-table": "item"
+ },
+ "drop_items": {
+ "type": "array",
+ "items": { "type": "string" },
+ "x-gframework-ref-table": "item"
+ },
+ "dropItems1": {
+ "type": "string",
+ "x-gframework-ref-table": "item"
+ }
+ }
+ }
+ """;
+
+ 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("MonsterConfigBindings.g.cs", out var bindingsSource), Is.True);
+ Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems ="));
+ 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
diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt
index f0d29e3a..482d016a 100644
--- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt
+++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt
@@ -117,6 +117,7 @@ public sealed partial class MonsterConfig
///
/// Schema property path: 'phases[].monsterId'.
/// Constraints: minLength = 2, maxLength = 32.
+ /// References config table: 'monster'.
/// Generated default initializer: = string.Empty;
///
public string MonsterId { get; set; } = string.Empty;
diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt
index a2954ce7..66ea9bf9 100644
--- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt
+++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt
@@ -9,20 +9,157 @@ namespace GFramework.Game.Config.Generated;
///
public static class MonsterConfigBindings
{
+ ///
+ /// Describes one schema property that declares x-gframework-ref-table metadata.
+ ///
+ public readonly struct ReferenceMetadata
+ {
+ ///
+ /// Initializes one generated cross-table reference descriptor.
+ ///
+ /// Schema property path.
+ /// Referenced runtime table name.
+ /// Schema scalar type used by the reference value.
+ /// Whether the property stores multiple reference keys.
+ public ReferenceMetadata(
+ string displayPath,
+ string referencedTableName,
+ string valueSchemaType,
+ bool isCollection)
+ {
+ DisplayPath = displayPath ?? throw new global::System.ArgumentNullException(nameof(displayPath));
+ ReferencedTableName = referencedTableName ?? throw new global::System.ArgumentNullException(nameof(referencedTableName));
+ ValueSchemaType = valueSchemaType ?? throw new global::System.ArgumentNullException(nameof(valueSchemaType));
+ IsCollection = isCollection;
+ }
+
+ ///
+ /// Gets the schema property path such as dropItems or phases[].monsterId.
+ ///
+ public string DisplayPath { get; }
+
+ ///
+ /// Gets the runtime registration name of the referenced config table.
+ ///
+ public string ReferencedTableName { get; }
+
+ ///
+ /// Gets the schema scalar type used by the referenced key value.
+ ///
+ public string ValueSchemaType { get; }
+
+ ///
+ /// Gets a value indicating whether the property stores multiple reference keys.
+ ///
+ public bool IsCollection { get; }
+ }
+
+ ///
+ /// Groups the schema-derived metadata constants so consumer code can reuse one stable entry point.
+ ///
+ public static class Metadata
+ {
+ ///
+ /// Gets the logical config domain derived from the schema base name. The current runtime convention keeps this value aligned with the generated table name.
+ ///
+ public const string ConfigDomain = "monster";
+
+ ///
+ /// Gets the runtime registration name of the generated config table.
+ ///
+ public const string TableName = "monster";
+
+ ///
+ /// Gets the config directory path expected by the generated registration helper.
+ ///
+ public const string ConfigRelativePath = "monster";
+
+ ///
+ /// Gets the schema file path expected by the generated registration helper.
+ ///
+ public const string SchemaRelativePath = "schemas/monster.schema.json";
+ }
+
+ ///
+ /// Gets the logical config domain derived from the schema base name. The current runtime convention keeps this value aligned with the generated table name.
+ ///
+ public const string ConfigDomain = Metadata.ConfigDomain;
+
///
/// Gets the runtime registration name of the generated config table.
///
- public const string TableName = "monster";
+ public const string TableName = Metadata.TableName;
///
/// Gets the config directory path expected by the generated registration helper.
///
- public const string ConfigRelativePath = "monster";
+ public const string ConfigRelativePath = Metadata.ConfigRelativePath;
///
/// Gets the schema file path expected by the generated registration helper.
///
- public const string SchemaRelativePath = "schemas/monster.schema.json";
+ public const string SchemaRelativePath = Metadata.SchemaRelativePath;
+
+ ///
+ /// Exposes generated metadata for schema properties that declare x-gframework-ref-table.
+ ///
+ public static class References
+ {
+ ///
+ /// Gets generated reference metadata for schema property path 'dropItems'.
+ ///
+ public static readonly ReferenceMetadata DropItems = new(
+ "dropItems",
+ "item",
+ "string",
+ true);
+
+ ///
+ /// Gets generated reference metadata for schema property path 'phases[].monsterId'.
+ ///
+ public static readonly ReferenceMetadata PhasesItemsMonsterId = new(
+ "phases[].monsterId",
+ "monster",
+ "string",
+ false);
+
+ ///
+ /// Gets all generated cross-table reference descriptors for the current schema.
+ ///
+ public static global::System.Collections.Generic.IReadOnlyList All { get; } = global::System.Array.AsReadOnly(new ReferenceMetadata[]
+ {
+ DropItems,
+ PhasesItemsMonsterId,
+ });
+
+ ///
+ /// Tries to resolve generated reference metadata by schema property path.
+ ///
+ /// Schema property path.
+ /// Resolved generated reference metadata when the path is known; otherwise the default value.
+ /// True when the schema property path has generated cross-table metadata; otherwise false.
+ public static bool TryGetByDisplayPath(string displayPath, out ReferenceMetadata metadata)
+ {
+ if (displayPath is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(displayPath));
+ }
+
+ if (string.Equals(displayPath, "dropItems", global::System.StringComparison.Ordinal))
+ {
+ metadata = DropItems;
+ return true;
+ }
+ if (string.Equals(displayPath, "phases[].monsterId", global::System.StringComparison.Ordinal))
+ {
+ metadata = PhasesItemsMonsterId;
+ return true;
+ }
+
+ metadata = default;
+ return false;
+ }
+ }
///
/// Registers the generated config table using the schema-derived runtime conventions.
@@ -40,9 +177,9 @@ public static class MonsterConfigBindings
}
return loader.RegisterTable(
- TableName,
- ConfigRelativePath,
- SchemaRelativePath,
+ Metadata.TableName,
+ Metadata.ConfigRelativePath,
+ Metadata.SchemaRelativePath,
static config => config.Id,
comparer);
}
@@ -60,7 +197,7 @@ public static class MonsterConfigBindings
throw new global::System.ArgumentNullException(nameof(registry));
}
- return new MonsterTable(registry.GetTable(TableName));
+ return new MonsterTable(registry.GetTable(Metadata.TableName));
}
///
@@ -77,7 +214,7 @@ public static class MonsterConfigBindings
throw new global::System.ArgumentNullException(nameof(registry));
}
- if (registry.TryGetTable(TableName, out var innerTable) && innerTable is not null)
+ if (registry.TryGetTable(Metadata.TableName, out var innerTable) && innerTable is not null)
{
table = new MonsterTable(innerTable);
return true;
diff --git a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
index 72d01b30..9e6900ff 100644
--- a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
+++ b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
@@ -23,6 +23,7 @@
GF_ConfigSchema_003 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_004 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_005 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
+ GF_ConfigSchema_006 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic
GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic
GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic
diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
index c0e5560d..dc33408f 100644
--- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
+++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
@@ -253,7 +253,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
var title = TryGetMetadataString(property.Value, "title");
var description = TryGetMetadataString(property.Value, "description");
var refTableName = TryGetMetadataString(property.Value, "x-gframework-ref-table");
- var propertyName = ToPascalCase(property.Name);
+ if (!TryBuildPropertyIdentifier(filePath, displayPath, property.Name, out var propertyName, out var diagnostic))
+ {
+ return ParsedPropertyResult.FromDiagnostic(diagnostic!);
+ }
switch (schemaType)
{
@@ -634,6 +637,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
var getMethodName = $"Get{schema.EntityName}Table";
var tryGetMethodName = $"TryGet{schema.EntityName}Table";
var bindingsClassName = $"{schema.EntityName}ConfigBindings";
+ var referenceSpecs = CollectReferenceSpecs(schema.RootObject).ToArray();
var builder = new StringBuilder();
builder.AppendLine("// ");
@@ -650,22 +654,202 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine($"public static class {bindingsClassName}");
builder.AppendLine("{");
builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Describes one schema property that declares x-gframework-ref-table metadata.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" public readonly struct ReferenceMetadata");
+ builder.AppendLine(" {");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" /// Initializes one generated cross-table reference descriptor.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" /// Schema property path.");
+ builder.AppendLine(" /// Referenced runtime table name.");
+ builder.AppendLine(
+ " /// Schema scalar type used by the reference value.");
+ builder.AppendLine(
+ " /// Whether the property stores multiple reference keys.");
+ builder.AppendLine(" public ReferenceMetadata(");
+ builder.AppendLine(" string displayPath,");
+ builder.AppendLine(" string referencedTableName,");
+ builder.AppendLine(" string valueSchemaType,");
+ builder.AppendLine(" bool isCollection)");
+ builder.AppendLine(" {");
+ builder.AppendLine(
+ " DisplayPath = displayPath ?? throw new global::System.ArgumentNullException(nameof(displayPath));");
+ builder.AppendLine(
+ " ReferencedTableName = referencedTableName ?? throw new global::System.ArgumentNullException(nameof(referencedTableName));");
+ builder.AppendLine(
+ " ValueSchemaType = valueSchemaType ?? throw new global::System.ArgumentNullException(nameof(valueSchemaType));");
+ builder.AppendLine(" IsCollection = isCollection;");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Gets the schema property path such as dropItems or phases[].monsterId.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" public string DisplayPath { get; }");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" /// Gets the runtime registration name of the referenced config table.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" public string ReferencedTableName { get; }");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" /// Gets the schema scalar type used by the referenced key value.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" public string ValueSchemaType { get; }");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Gets a value indicating whether the property stores multiple reference keys.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" public bool IsCollection { get; }");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Groups the schema-derived metadata constants so consumer code can reuse one stable entry point.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" public static class Metadata");
+ builder.AppendLine(" {");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Gets the logical config domain derived from the schema base name. The current runtime convention keeps this value aligned with the generated table name.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ $" public const string ConfigDomain = {SymbolDisplay.FormatLiteral(schema.TableRegistrationName, true)};");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" /// Gets the runtime registration name of the generated config table.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ $" public const string TableName = {SymbolDisplay.FormatLiteral(schema.TableRegistrationName, true)};");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Gets the config directory path expected by the generated registration helper.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ $" public const string ConfigRelativePath = {SymbolDisplay.FormatLiteral(schema.ConfigRelativePath, true)};");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" /// Gets the schema file path expected by the generated registration helper.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ $" public const string SchemaRelativePath = {SymbolDisplay.FormatLiteral(schema.SchemaRelativePath, true)};");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Gets the logical config domain derived from the schema base name. The current runtime convention keeps this value aligned with the generated table name.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" public const string ConfigDomain = Metadata.ConfigDomain;");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
builder.AppendLine(" /// Gets the runtime registration name of the generated config table.");
builder.AppendLine(" /// ");
- builder.AppendLine(
- $" public const string TableName = {SymbolDisplay.FormatLiteral(schema.TableRegistrationName, true)};");
+ builder.AppendLine(" public const string TableName = Metadata.TableName;");
builder.AppendLine();
builder.AppendLine(" /// ");
builder.AppendLine(" /// Gets the config directory path expected by the generated registration helper.");
builder.AppendLine(" /// ");
- builder.AppendLine(
- $" public const string ConfigRelativePath = {SymbolDisplay.FormatLiteral(schema.ConfigRelativePath, true)};");
+ builder.AppendLine(" public const string ConfigRelativePath = Metadata.ConfigRelativePath;");
builder.AppendLine();
builder.AppendLine(" /// ");
builder.AppendLine(" /// Gets the schema file path expected by the generated registration helper.");
builder.AppendLine(" /// ");
+ builder.AppendLine(" public const string SchemaRelativePath = Metadata.SchemaRelativePath;");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
builder.AppendLine(
- $" public const string SchemaRelativePath = {SymbolDisplay.FormatLiteral(schema.SchemaRelativePath, true)};");
+ " /// Exposes generated metadata for schema properties that declare x-gframework-ref-table.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" public static class References");
+ builder.AppendLine(" {");
+
+ foreach (var referenceSpec in referenceSpecs)
+ {
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ $" /// Gets generated reference metadata for schema property path '{EscapeXmlDocumentation(referenceSpec.DisplayPath)}'.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ $" public static readonly ReferenceMetadata {referenceSpec.MemberName} = new(");
+ builder.AppendLine(
+ $" {SymbolDisplay.FormatLiteral(referenceSpec.DisplayPath, true)},");
+ builder.AppendLine(
+ $" {SymbolDisplay.FormatLiteral(referenceSpec.ReferencedTableName, true)},");
+ builder.AppendLine(
+ $" {SymbolDisplay.FormatLiteral(referenceSpec.ValueSchemaType, true)},");
+ builder.AppendLine(
+ $" {(referenceSpec.IsCollection ? "true" : "false")});");
+ builder.AppendLine();
+ }
+
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Gets all generated cross-table reference descriptors for the current schema.");
+ builder.AppendLine(" /// ");
+ if (referenceSpecs.Length == 0)
+ {
+ builder.AppendLine(
+ " public static global::System.Collections.Generic.IReadOnlyList All { get; } = global::System.Array.Empty();");
+ }
+ else
+ {
+ builder.AppendLine(
+ " public static global::System.Collections.Generic.IReadOnlyList All { get; } = global::System.Array.AsReadOnly(new ReferenceMetadata[]");
+ builder.AppendLine(" {");
+ foreach (var referenceSpec in referenceSpecs)
+ {
+ builder.AppendLine($" {referenceSpec.MemberName},");
+ }
+
+ builder.AppendLine(" });");
+ }
+
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" /// Tries to resolve generated reference metadata by schema property path.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" /// Schema property path.");
+ builder.AppendLine(
+ " /// Resolved generated reference metadata when the path is known; otherwise the default value.");
+ builder.AppendLine(
+ " /// True when the schema property path has generated cross-table metadata; otherwise false.");
+ builder.AppendLine(
+ " public static bool TryGetByDisplayPath(string displayPath, out ReferenceMetadata metadata)");
+ builder.AppendLine(" {");
+ builder.AppendLine(" if (displayPath is null)");
+ builder.AppendLine(" {");
+ builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(displayPath));");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+
+ if (referenceSpecs.Length == 0)
+ {
+ builder.AppendLine(" metadata = default;");
+ builder.AppendLine(" return false;");
+ }
+ else
+ {
+ foreach (var referenceSpec in referenceSpecs)
+ {
+ builder.AppendLine(
+ $" if (string.Equals(displayPath, {SymbolDisplay.FormatLiteral(referenceSpec.DisplayPath, true)}, global::System.StringComparison.Ordinal))");
+ builder.AppendLine(" {");
+ builder.AppendLine($" metadata = {referenceSpec.MemberName};");
+ builder.AppendLine(" return true;");
+ builder.AppendLine(" }");
+ }
+
+ builder.AppendLine();
+ builder.AppendLine(" metadata = default;");
+ builder.AppendLine(" return false;");
+ }
+
+ builder.AppendLine(" }");
+ builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// ");
builder.AppendLine(
@@ -688,9 +872,9 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine();
builder.AppendLine(
$" return loader.RegisterTable<{schema.KeyClrType}, {schema.ClassName}>(");
- builder.AppendLine(" TableName,");
- builder.AppendLine(" ConfigRelativePath,");
- builder.AppendLine(" SchemaRelativePath,");
+ builder.AppendLine(" Metadata.TableName,");
+ builder.AppendLine(" Metadata.ConfigRelativePath,");
+ builder.AppendLine(" Metadata.SchemaRelativePath,");
builder.AppendLine($" static config => config.{schema.KeyPropertyName},");
builder.AppendLine(" comparer);");
builder.AppendLine(" }");
@@ -711,7 +895,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(
- $" return new {schema.TableName}(registry.GetTable<{schema.KeyClrType}, {schema.ClassName}>(TableName));");
+ $" return new {schema.TableName}(registry.GetTable<{schema.KeyClrType}, {schema.ClassName}>(Metadata.TableName));");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// ");
@@ -733,7 +917,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(
- $" if (registry.TryGetTable<{schema.KeyClrType}, {schema.ClassName}>(TableName, out var innerTable) && innerTable is not null)");
+ $" if (registry.TryGetTable<{schema.KeyClrType}, {schema.ClassName}>(Metadata.TableName, out var innerTable) && innerTable is not null)");
builder.AppendLine(" {");
builder.AppendLine($" table = new {schema.TableName}(innerTable);");
builder.AppendLine(" return true;");
@@ -746,6 +930,91 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return builder.ToString().TrimEnd();
}
+ ///
+ /// 收集 schema 中声明的跨表引用元数据,并为生成代码分配稳定成员名。
+ ///
+ /// 根对象模型。
+ /// 生成期引用元数据集合。
+ private static IEnumerable CollectReferenceSpecs(SchemaObjectSpec rootObject)
+ {
+ var nextSuffixByBaseMemberName = new Dictionary(StringComparer.Ordinal);
+ var allocatedMemberNames = new HashSet(StringComparer.Ordinal);
+
+ foreach (var referenceSeed in EnumerateReferenceSeeds(rootObject.Properties))
+ {
+ var baseMemberName = BuildReferenceMemberName(referenceSeed.DisplayPath);
+ var memberName = baseMemberName;
+ if (!allocatedMemberNames.Add(memberName))
+ {
+ // Track globally allocated member names because a suffixed duplicate from one path can collide
+ // with the unsuffixed base name produced by a later, different path.
+ var duplicateCount = nextSuffixByBaseMemberName.TryGetValue(baseMemberName, out var nextSuffix)
+ ? nextSuffix + 1
+ : 1;
+
+ memberName = $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}";
+ while (!allocatedMemberNames.Add(memberName))
+ {
+ duplicateCount++;
+ memberName = $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}";
+ }
+
+ nextSuffixByBaseMemberName[baseMemberName] = duplicateCount;
+ }
+ else
+ {
+ nextSuffixByBaseMemberName[baseMemberName] = 0;
+ }
+
+ yield return new GeneratedReferenceSpec(
+ memberName,
+ referenceSeed.DisplayPath,
+ referenceSeed.ReferencedTableName,
+ referenceSeed.ValueSchemaType,
+ referenceSeed.IsCollection);
+ }
+ }
+
+ ///
+ /// 递归枚举对象树中所有带 ref-table 元数据的字段。
+ ///
+ /// 对象属性集合。
+ /// 原始引用字段信息。
+ private static IEnumerable EnumerateReferenceSeeds(
+ IEnumerable properties)
+ {
+ foreach (var property in properties)
+ {
+ if (!string.IsNullOrWhiteSpace(property.TypeSpec.RefTableName))
+ {
+ yield return new GeneratedReferenceSeed(
+ property.DisplayPath,
+ property.TypeSpec.RefTableName!,
+ property.TypeSpec.Kind == SchemaNodeKind.Array
+ ? property.TypeSpec.ItemTypeSpec?.SchemaType ?? property.TypeSpec.SchemaType
+ : property.TypeSpec.SchemaType,
+ property.TypeSpec.Kind == SchemaNodeKind.Array);
+ }
+
+ if (property.TypeSpec.NestedObject is not null)
+ {
+ foreach (var nestedReference in EnumerateReferenceSeeds(property.TypeSpec.NestedObject.Properties))
+ {
+ yield return nestedReference;
+ }
+ }
+
+ if (property.TypeSpec.ItemTypeSpec?.NestedObject is not null)
+ {
+ foreach (var nestedReference in EnumerateReferenceSeeds(property.TypeSpec.ItemTypeSpec.NestedObject
+ .Properties))
+ {
+ yield return nestedReference;
+ }
+ }
+ }
+ }
+
///
/// 递归生成配置对象类型。
///
@@ -910,6 +1179,40 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine($"{indent}/// ");
}
+ ///
+ /// 将 schema 字段名转换并验证为生成代码可直接使用的属性标识符。
+ /// 生成器会在这里拒绝无法映射为合法 C# 标识符的外部输入,避免生成源码后才在编译阶段失败。
+ ///
+ /// Schema 文件路径。
+ /// 逻辑字段路径。
+ /// Schema 原始字段名。
+ /// 生成后的属性名。
+ /// 字段名非法时生成的诊断。
+ /// 是否成功生成合法属性标识符。
+ private static bool TryBuildPropertyIdentifier(
+ string filePath,
+ string displayPath,
+ string schemaName,
+ out string propertyName,
+ out Diagnostic? diagnostic)
+ {
+ propertyName = ToPascalCase(schemaName);
+ if (SyntaxFacts.IsValidIdentifier(propertyName))
+ {
+ diagnostic = null;
+ return true;
+ }
+
+ diagnostic = Diagnostic.Create(
+ ConfigSchemaDiagnostics.InvalidGeneratedIdentifier,
+ CreateFileLocation(filePath),
+ Path.GetFileName(filePath),
+ displayPath,
+ schemaName,
+ propertyName);
+ return false;
+ }
+
///
/// 从 schema 文件路径提取实体基础名。
///
@@ -968,6 +1271,28 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return tokens.Length == 0 ? "Config" : string.Concat(tokens);
}
+ ///
+ /// 将 schema 字段路径转换为可用于生成引用元数据成员的 PascalCase 标识符。
+ ///
+ /// Schema 字段路径。
+ /// 稳定的成员名。
+ private static string BuildReferenceMemberName(string displayPath)
+ {
+ var segments = displayPath.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries);
+ var builder = new StringBuilder();
+
+ foreach (var segment in segments)
+ {
+ var normalizedSegment = segment
+ .Replace("[]", "Items")
+ .Replace("[", " ")
+ .Replace("]", " ");
+ builder.Append(ToPascalCase(normalizedSegment));
+ }
+
+ return builder.Length == 0 ? "Reference" : builder.ToString();
+ }
+
///
/// 为 AdditionalFiles 诊断创建文件位置。
///
@@ -1335,6 +1660,34 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
SchemaObjectSpec? NestedObject,
SchemaTypeSpec? ItemTypeSpec);
+ ///
+ /// 生成代码前的跨表引用字段种子信息。
+ ///
+ /// Schema 字段路径。
+ /// 目标表名称。
+ /// 引用值的标量 schema 类型。
+ /// 是否为数组引用。
+ private sealed record GeneratedReferenceSeed(
+ string DisplayPath,
+ string ReferencedTableName,
+ string ValueSchemaType,
+ bool IsCollection);
+
+ ///
+ /// 已分配稳定成员名的生成期跨表引用信息。
+ ///
+ /// 生成到绑定类中的成员名。
+ /// Schema 字段路径。
+ /// 目标表名称。
+ /// 引用值的标量 schema 类型。
+ /// 是否为数组引用。
+ private sealed record GeneratedReferenceSpec(
+ string MemberName,
+ string DisplayPath,
+ string ReferencedTableName,
+ string ValueSchemaType,
+ bool IsCollection);
+
///
/// 属性解析结果包装。
///
diff --git a/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs
index 9229868f..b03057a1 100644
--- a/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs
+++ b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs
@@ -63,4 +63,15 @@ public static class ConfigSchemaDiagnostics
SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error,
true);
+
+ ///
+ /// schema 字段名无法安全映射为 C# 标识符。
+ ///
+ public static readonly DiagnosticDescriptor InvalidGeneratedIdentifier = new(
+ "GF_ConfigSchema_006",
+ "Config schema property name cannot be converted to a valid C# identifier",
+ "Property '{1}' in schema file '{0}' uses schema key '{2}', which generates invalid C# identifier '{3}'",
+ SourceGeneratorsConfigCategory,
+ DiagnosticSeverity.Error,
+ true);
}
\ 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 03331442..b390afd7 100644
--- a/docs/zh-CN/game/config-system.md
+++ b/docs/zh-CN/game/config-system.md
@@ -82,6 +82,245 @@ dropItems:
- slime_gel
```
+## 推荐接入模板
+
+如果你准备在一个真实游戏项目里首次接入这套配置系统,建议直接采用下面这套目录与启动模板,而不是零散拼装。
+
+### 目录模板
+
+```text
+GameProject/
+├─ GameProject.csproj
+├─ Config/
+│ ├─ GameConfigBootstrap.cs
+│ └─ GameConfigRuntime.cs
+├─ config/
+│ ├─ monster/
+│ │ ├─ slime.yaml
+│ │ └─ goblin.yaml
+│ └─ item/
+│ └─ potion.yaml
+└─ schemas/
+ ├─ monster.schema.json
+ └─ item.schema.json
+```
+
+推荐约定如下:
+
+- `schemas/` 放所有 `*.schema.json`,由 Source Generator 自动拾取
+- `config/` 放运行时加载的 YAML 数据,一对象一文件
+- `Config/` 放你自己的接入代码,例如启动注册、热重载句柄和对外读取入口
+
+### `csproj` 模板
+
+如果你在仓库内直接用项目引用,最小模板可以写成下面这样:
+
+```xml
+
+
+ net10.0
+ disable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+```
+
+这段配置的作用:
+
+- `GFramework.Game` 提供运行时 `YamlConfigLoader`、`ConfigRegistry` 和只读表实现
+- 三个 `ProjectReference(... OutputItemType="Analyzer")` 把生成器接进当前消费者项目
+- `GeWuYou.GFramework.SourceGenerators.targets` 自动把 `schemas/**/*.schema.json` 加入 `AdditionalFiles`
+
+如果你使用打包后的 NuGet,而不是仓库内项目引用,原则保持不变:
+
+- 运行时项目需要引用 `GeWuYou.GFramework.Game`
+- 生成器项目需要引用 `GeWuYou.GFramework.SourceGenerators`
+- schema 目录默认仍然是 `schemas/`
+
+如果你的 schema 不放在默认目录,可以在项目文件里覆盖:
+
+```xml
+
+ GameSchemas
+
+```
+
+### 启动引导模板
+
+推荐把配置系统的初始化收敛到一个单独入口,避免把 `YamlConfigLoader` 注册逻辑散落到多个启动脚本中:
+
+```csharp
+using GFramework.Core.Abstractions.Events;
+using GFramework.Game.Abstractions.Config;
+using GFramework.Game.Config;
+using GFramework.Game.Config.Generated;
+
+namespace GameProject.Config;
+
+///
+/// 负责初始化游戏内容配置运行时入口。
+///
+public sealed class GameConfigBootstrap : IDisposable
+{
+ private readonly ConfigRegistry _registry = new();
+ private IUnRegister? _hotReload;
+
+ ///
+ /// 获取当前游戏进程共享的配置注册表。
+ ///
+ public IConfigRegistry Registry => _registry;
+
+ ///
+ /// 从指定配置根目录加载所有已注册配置表。
+ ///
+ /// 配置根目录。
+ /// 是否启用开发期热重载。
+ public async Task InitializeAsync(string configRootPath, bool enableHotReload = false)
+ {
+ var loader = new YamlConfigLoader(configRootPath)
+ .RegisterMonsterTable()
+ .RegisterItemTable();
+
+ await loader.LoadAsync(_registry);
+
+ if (enableHotReload)
+ {
+ _hotReload = loader.EnableHotReload(
+ _registry,
+ onTableReloaded: tableName => Console.WriteLine($"Reloaded config table: {tableName}"),
+ onTableReloadFailed: static (_, exception) =>
+ {
+ var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
+ Console.WriteLine($"Config reload failed: {diagnostic?.FailureKind}");
+ });
+ }
+ }
+
+ ///
+ /// 停止开发期热重载并释放相关资源。
+ ///
+ public void Dispose()
+ {
+ _hotReload?.UnRegister();
+ }
+}
+```
+
+这段模板刻意遵循几个约定:
+
+- 优先使用生成器产出的 `Register*Table()`,避免手写表名、路径和 key selector
+- 由一个长生命周期对象持有 `ConfigRegistry`
+- 热重载句柄和配置生命周期绑在一起,避免监听器泄漏
+
+### 运行时读取模板
+
+推荐不要在业务代码里直接散落字符串表名查询,而是统一依赖生成的强类型入口:
+
+```csharp
+using GFramework.Game.Config.Generated;
+
+namespace GameProject.Config;
+
+///
+/// 封装游戏内容配置读取入口。
+///
+public sealed class GameConfigRuntime
+{
+ private readonly IConfigRegistry _registry;
+
+ ///
+ /// 使用已初始化的配置注册表创建读取入口。
+ ///
+ /// 配置注册表。
+ public GameConfigRuntime(IConfigRegistry registry)
+ {
+ _registry = registry ?? throw new ArgumentNullException(nameof(registry));
+ }
+
+ ///
+ /// 获取指定怪物配置。
+ ///
+ /// 怪物主键。
+ /// 强类型怪物配置。
+ public MonsterConfig GetMonster(int monsterId)
+ {
+ return _registry.GetMonsterTable().Get(monsterId);
+ }
+
+ ///
+ /// 获取怪物配置表。
+ ///
+ /// 生成的强类型表包装。
+ public MonsterTable GetMonsterTable()
+ {
+ return _registry.GetMonsterTable();
+ }
+}
+```
+
+这样做的收益:
+
+- 配置系统对业务层暴露的是强类型表,而不是 `"monster"` 这类 magic string
+- 后续如果你要复用配置域、schema 路径或引用元数据,可以继续依赖 `MonsterConfigBindings.Metadata` 和
+ `MonsterConfigBindings.References`
+- 如果未来把配置初始化接入 `Architecture` 或 `Module`,迁移成本也更低
+
+### 热重载模板
+
+如果你希望把开发期热重载显式收敛为一个可选能力,建议把失败诊断一起写进模板,而不是只打印异常文本:
+
+```csharp
+var hotReload = loader.EnableHotReload(
+ registry,
+ onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"),
+ onTableReloadFailed: (tableName, exception) =>
+ {
+ var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
+ Console.WriteLine($"Reload failed: {tableName}");
+ Console.WriteLine($"Failure kind: {diagnostic?.FailureKind}");
+ Console.WriteLine($"Yaml path: {diagnostic?.YamlPath}");
+ Console.WriteLine($"Display path: {diagnostic?.DisplayPath}");
+ });
+```
+
+建议只在开发期启用这项能力:
+
+- 生产环境默认更适合静态加载和固定生命周期
+- 热重载失败时应优先依赖 `ConfigLoadException.Diagnostic` 做稳定日志或 UI 提示
+- 如果你的项目已经有统一日志系统,建议在这里把诊断字段转成结构化日志,而不是拼接一整段字符串
+
+如果你后续还需要为热重载增加更多开关,推荐优先使用选项对象入口,而不是继续叠加位置参数:
+
+```csharp
+var hotReload = loader.EnableHotReload(
+ registry,
+ new YamlConfigHotReloadOptions
+ {
+ OnTableReloaded = tableName => Console.WriteLine($"Reloaded: {tableName}"),
+ OnTableReloadFailed = (tableName, exception) =>
+ {
+ var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
+ Console.WriteLine($"{tableName}: {diagnostic?.FailureKind}");
+ },
+ DebounceDelay = TimeSpan.FromMilliseconds(150)
+ });
+```
+
## 运行时接入
当你希望加载后的配置在运行时以只读表形式暴露时,优先使用生成器产出的注册与访问辅助:
@@ -103,13 +342,37 @@ var slime = monsterTable.Get(1);
这组辅助会把以下约定固化到生成代码里:
+- 配置域常量,例如 `MonsterConfigBindings.ConfigDomain`
- 表注册名,例如 `monster`
- 配置目录相对路径,例如 `monster`
- schema 相对路径,例如 `schemas/monster.schema.json`
- 主键提取逻辑,例如 `config => config.Id`
+如果你希望把这些约定作为一个统一入口传递或复用,也可以优先读取 `MonsterConfigBindings.Metadata` 下的常量:
+
+```csharp
+var domain = MonsterConfigBindings.Metadata.ConfigDomain;
+var tableName = MonsterConfigBindings.Metadata.TableName;
+var configPath = MonsterConfigBindings.Metadata.ConfigRelativePath;
+var schemaPath = MonsterConfigBindings.Metadata.SchemaRelativePath;
+```
+
如果你需要自定义目录、表名或 key selector,仍然可以直接调用 `YamlConfigLoader.RegisterTable(...)` 原始重载。
+如果你希望把 schema 路径、比较器以及未来扩展开关集中到一个对象里,推荐改用选项对象入口:
+
+```csharp
+var loader = new YamlConfigLoader("config-root")
+ .RegisterTable(
+ new YamlConfigTableRegistrationOptions(
+ "monster",
+ "monster",
+ static config => config.Id)
+ {
+ SchemaRelativePath = "schemas/monster.schema.json"
+ });
+```
+
## 运行时校验行为
绑定 schema 的表在加载时会拒绝以下问题:
@@ -148,6 +411,21 @@ var slime = monsterTable.Get(1);
- 引用目标表需要由同一个 `YamlConfigLoader` 注册,或已存在于当前 `IConfigRegistry`
- 热重载中若目标表变更导致依赖表引用失效,会整体回滚受影响表,避免注册表进入不一致状态
+如果你希望在消费者代码里复用这些跨表约定,而不是继续手写字段路径或目标表名,生成的 `*ConfigBindings` 还会暴露引用元数据:
+
+```csharp
+var allReferences = MonsterConfigBindings.References.All;
+
+if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var reference))
+{
+ Console.WriteLine(reference.ReferencedTableName);
+ Console.WriteLine(reference.ValueSchemaType);
+ Console.WriteLine(reference.IsCollection);
+}
+```
+
+当 schema 中存在具体引用字段时,还可以直接通过生成成员访问,例如 `MonsterConfigBindings.References.DropItems`。
+
当前还支持以下“轻量元数据”:
- `title`:供 VS Code 插件表单和批量编辑入口显示更友好的字段标题
@@ -195,14 +473,11 @@ catch (ConfigLoadException exception)
```csharp
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
+using GFramework.Game.Config.Generated;
var registry = new ConfigRegistry();
var loader = new YamlConfigLoader("config-root")
- .RegisterTable(
- "monster",
- "monster",
- "schemas/monster.schema.json",
- static config => config.Id);
+ .RegisterMonsterTable();
await loader.LoadAsync(registry);