From 61cc7eaa6d9fe5c9c01295deabb946d7fa3cda7c Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:10:23 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0AI-First?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F=E5=8F=8A=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入YAML配置源文件支持 - 实现JSON Schema结构描述功能 - 提供一对象一文件的目录组织方式 - 添加运行时只读查询能力 - 实现Source Generator生成配置类型和表包装 - 集成VS Code插件提供配置浏览和编辑功能 - 添加开发期热重载支持 - 提供跨表引用校验机制 - 创建配置生成器约定和绑定辅助类 - 添加详细的中文文档说明 - 实现集成测试验证生成器功能 --- ...GeneratedConfigConsumerIntegrationTests.cs | 7 +++ .../MonsterConfigBindings.g.txt | 47 ++++++++++++--- .../Config/SchemaConfigGenerator.cs | 58 +++++++++++++++---- docs/zh-CN/game/config-system.md | 10 ++++ 4 files changed, 103 insertions(+), 19 deletions(-) diff --git a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs index a419c260..d9aee6da 100644 --- a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs +++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs @@ -91,9 +91,16 @@ 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(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.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt index a2954ce7..36c4dbcc 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt @@ -9,20 +9,51 @@ namespace GFramework.Game.Config.Generated; /// public static class MonsterConfigBindings { + /// + /// 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; /// /// Registers the generated config table using the schema-derived runtime conventions. @@ -40,9 +71,9 @@ public static class MonsterConfigBindings } return loader.RegisterTable( - TableName, - ConfigRelativePath, - SchemaRelativePath, + Metadata.TableName, + Metadata.ConfigRelativePath, + Metadata.SchemaRelativePath, static config => config.Id, comparer); } @@ -60,7 +91,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 +108,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/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index c0e5560d..96956f8b 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -650,22 +650,58 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine($"public static class {bindingsClassName}"); 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 = {SymbolDisplay.FormatLiteral(schema.SchemaRelativePath, true)};"); + builder.AppendLine(" public const string SchemaRelativePath = Metadata.SchemaRelativePath;"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine( @@ -688,9 +724,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 +747,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 +769,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;"); diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 03331442..163cbca4 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -103,11 +103,21 @@ 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(...)` 原始重载。 ## 运行时校验行为 From ec4e2edeabc0c3eef8ab3f88b050d9ba5041c664 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:17:39 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0AI-First?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现YAML配置文件与JSON Schema结构描述支持 - 提供一对象一文件的目录组织方式 - 集成Source Generator生成配置类型和表包装代码 - 添加VS Code插件支持配置浏览和表单编辑功能 - 实现运行时只读查询和开发期热重载机制 - 支持跨表引用校验和轻量元数据复用 - 添加配置加载异常诊断和批量编辑入口 --- ...GeneratedConfigConsumerIntegrationTests.cs | 2 + .../SchemaConfigGeneratorSnapshotTests.cs | 3 +- .../SchemaConfigGenerator/MonsterConfig.g.txt | 1 + .../MonsterConfigBindings.g.txt | 106 +++++++ .../Config/SchemaConfigGenerator.cs | 267 ++++++++++++++++++ docs/zh-CN/game/config-system.md | 15 + 6 files changed, 393 insertions(+), 1 deletion(-) diff --git a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs index d9aee6da..7556db18 100644 --- a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs +++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs @@ -101,6 +101,8 @@ public class GeneratedConfigConsumerIntegrationTests 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.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/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 36c4dbcc..66ea9bf9 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt @@ -9,6 +9,51 @@ 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. /// @@ -55,6 +100,67 @@ public static class MonsterConfigBindings /// 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. /// diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 96956f8b..c77bebaa 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -634,6 +634,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,6 +651,59 @@ 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(" /// "); @@ -704,6 +758,97 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" public const string SchemaRelativePath = Metadata.SchemaRelativePath;"); builder.AppendLine(); builder.AppendLine(" /// "); + builder.AppendLine( + " /// 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( " /// Registers the generated config table using the schema-derived runtime conventions."); builder.AppendLine(" /// "); @@ -782,6 +927,78 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return builder.ToString().TrimEnd(); } + /// + /// 收集 schema 中声明的跨表引用元数据,并为生成代码分配稳定成员名。 + /// + /// 根对象模型。 + /// 生成期引用元数据集合。 + private static IEnumerable CollectReferenceSpecs(SchemaObjectSpec rootObject) + { + var memberNameCounts = new Dictionary(StringComparer.Ordinal); + + foreach (var referenceSeed in EnumerateReferenceSeeds(rootObject.Properties)) + { + var baseMemberName = BuildReferenceMemberName(referenceSeed.DisplayPath); + if (memberNameCounts.ContainsKey(baseMemberName)) + { + memberNameCounts[baseMemberName]++; + baseMemberName = + $"{baseMemberName}{memberNameCounts[baseMemberName].ToString(CultureInfo.InvariantCulture)}"; + } + else + { + memberNameCounts[baseMemberName] = 0; + } + + yield return new GeneratedReferenceSpec( + baseMemberName, + 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; + } + } + } + } + /// /// 递归生成配置对象类型。 /// @@ -1004,6 +1221,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 诊断创建文件位置。 /// @@ -1371,6 +1610,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/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 163cbca4..aa1dee07 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -158,6 +158,21 @@ var schemaPath = MonsterConfigBindings.Metadata.SchemaRelativePath; - 引用目标表需要由同一个 `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 插件表单和批量编辑入口显示更友好的字段标题 From ecf2309e1159b597b4b062fe051a67a3bc010bfd Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:01:10 +0800 Subject: [PATCH 3/8] =?UTF-8?q?docs(game):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 介绍面向静态游戏内容的 AI-First 配表方案 - 说明配置系统管理怪物、物品、技能、任务等静态内容数据 - 描述 YAML 作为配置源文件和 JSON Schema 作为结构描述的支持 - 展示推荐的目录结构和 Schema 示例 - 提供完整的接入模板包括 csproj 配置、启动引导和运行时读取 - 详述运行时校验行为和跨表引用机制 - 说明开发期热重载功能和 VS Code 工具集成 - 列出当前限制和独立 Config Studio 评估结论 --- docs/zh-CN/game/config-system.md | 229 ++++++++++++++++++++++++++++++- 1 file changed, 224 insertions(+), 5 deletions(-) diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index aa1dee07..8fdc20ea 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -82,6 +82,228 @@ 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 提示 +- 如果你的项目已经有统一日志系统,建议在这里把诊断字段转成结构化日志,而不是拼接一整段字符串 + ## 运行时接入 当你希望加载后的配置在运行时以只读表形式暴露时,优先使用生成器产出的注册与访问辅助: @@ -220,14 +442,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); From 7fda40de42767d9df3b3d06535681ffffcf14cef Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:58:05 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现基于 YAML 的配置加载器支持 - 添加 JSON Schema 结构验证功能 - 实现一对象一文件的目录组织方式 - 提供运行时只读查询接口 - 添加 Source Generator 生成配置类型和表包装 - 实现 VS Code 插件配置浏览和编辑功能 - 添加开发期热重载支持 - 实现跨表引用校验机制 - 提供完整的配置系统文档说明 --- .../Config/YamlConfigLoaderTests.cs | 139 +++++++++++++++++- .../Config/YamlConfigHotReloadOptions.cs | 27 ++++ GFramework.Game/Config/YamlConfigLoader.cs | 71 ++++++++- .../YamlConfigTableRegistrationOptions.cs | 57 +++++++ docs/zh-CN/game/config-system.md | 31 ++++ 5 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 GFramework.Game/Config/YamlConfigHotReloadOptions.cs create mode 100644 GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 476f3dea..bd81952f 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -1,5 +1,4 @@ using System.IO; -using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; namespace GFramework.Game.Tests.Config; @@ -10,6 +9,8 @@ namespace GFramework.Game.Tests.Config; [TestFixture] public class YamlConfigLoaderTests { + private string _rootPath = null!; + /// /// 为每个测试创建独立临时目录,避免文件系统状态互相污染。 /// @@ -32,8 +33,6 @@ public class YamlConfigLoaderTests } } - private string _rootPath = null!; - /// /// 验证加载器能够扫描 YAML 文件并将结果写入注册表。 /// @@ -71,6 +70,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 +1060,78 @@ 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(); + } + } + /// /// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。 /// 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..dd219ff2 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -1,8 +1,5 @@ using System.Diagnostics; -using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; namespace GFramework.Game.Config; @@ -20,6 +17,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 = @@ -100,8 +99,32 @@ public sealed class YamlConfigLoader : IConfigLoader 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(); return new HotReloadSession( _rootPath, @@ -109,9 +132,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) @@ -142,7 +165,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 + }); } /// @@ -166,7 +193,35 @@ 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 路径、主键提取器和比较器, + /// 以避免未来继续为新增开关叠加更多重载。 + /// + /// 配置主键类型。 + /// 配置值类型。 + /// 配置表注册选项。 + /// 当前加载器实例,以便链式注册。 + /// 为空时抛出。 + 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..16f1a7b7 --- /dev/null +++ b/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs @@ -0,0 +1,57 @@ +namespace GFramework.Game.Config; + +/// +/// 描述一个 YAML 配置表注册项的参数集合。 +/// 该选项对象用于替代不断增加的位置参数重载, +/// 让消费者在启用 schema 校验、主键比较器或未来扩展项时仍能保持调用点可读。 +/// +/// 配置主键类型。 +/// 配置值类型。 +public sealed class YamlConfigTableRegistrationOptions + where TKey : notnull +{ + /// + /// 使用最小必需参数创建配置表注册选项。 + /// + /// 运行时配置表名称。 + /// 相对配置根目录的子目录。 + /// 配置项主键提取器。 + /// 为 null 时抛出。 + public YamlConfigTableRegistrationOptions( + string tableName, + string relativePath, + Func keySelector) + { + 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/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 8fdc20ea..b390afd7 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -304,6 +304,23 @@ var hotReload = loader.EnableHotReload( - 热重载失败时应优先依赖 `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) + }); +``` + ## 运行时接入 当你希望加载后的配置在运行时以只读表形式暴露时,优先使用生成器产出的注册与访问辅助: @@ -342,6 +359,20 @@ 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 的表在加载时会拒绝以下问题: From 8c9fbb39b23b771d6d420498773b24d9e448570b Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:34:50 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0YAML?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入GFramework.Core.Abstractions.Events命名空间 - 添加YamlDotNet反序列化库相关引用 - 注册配置抽象层依赖到YAML配置加载器 - 更新测试文件中的命名空间引用以匹配新架构 --- GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs | 5 +++-- GFramework.Game/Config/YamlConfigLoader.cs | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index bd81952f..f2045ad3 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -1,4 +1,5 @@ using System.IO; +using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; namespace GFramework.Game.Tests.Config; @@ -9,8 +10,6 @@ namespace GFramework.Game.Tests.Config; [TestFixture] public class YamlConfigLoaderTests { - private string _rootPath = null!; - /// /// 为每个测试创建独立临时目录,避免文件系统状态互相污染。 /// @@ -33,6 +32,8 @@ public class YamlConfigLoaderTests } } + private string _rootPath = null!; + /// /// 验证加载器能够扫描 YAML 文件并将结果写入注册表。 /// diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index dd219ff2..6b15d3e5 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -1,5 +1,8 @@ using System.Diagnostics; +using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; namespace GFramework.Game.Config; From 34a333a0c10e3e42c557797cca5dc0fdab1b49ec Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:44:31 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(generator):=20=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=BC=95=E7=94=A8=E8=B7=AF=E5=BE=84=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=88=90=E5=91=98=E5=90=8D=E7=A7=B0=E4=B8=8D=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 TryGetValue 方法替代 ContainsKey 检查避免多次访问字典 - 重用跟踪的重复计数确保重复引用路径保持生成的成员名称稳定 - 修复重复计数递增逻辑确保正确的序号生成 - 简化重复计数器的使用方式提高代码可读性 - 移除文件末尾多余空行保持代码整洁 --- .../Config/SchemaConfigGenerator.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index c77bebaa..7ee0770f 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -939,11 +939,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator foreach (var referenceSeed in EnumerateReferenceSeeds(rootObject.Properties)) { var baseMemberName = BuildReferenceMemberName(referenceSeed.DisplayPath); - if (memberNameCounts.ContainsKey(baseMemberName)) + if (memberNameCounts.TryGetValue(baseMemberName, out var duplicateCount)) { - memberNameCounts[baseMemberName]++; + // Reuse the tracked duplicate count so repeated reference paths keep their generated member names stable. + duplicateCount++; + memberNameCounts[baseMemberName] = duplicateCount; baseMemberName = - $"{baseMemberName}{memberNameCounts[baseMemberName].ToString(CultureInfo.InvariantCulture)}"; + $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}"; } else { From a416e093ee7d603f0d79498a97fb6b5c78132db0 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:37:59 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E5=9F=BA?= =?UTF-8?q?=E4=BA=8EYAML=E7=9A=84=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=99=A8=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现YamlConfigLoader类,支持从文件目录加载YAML配置 - 提供RegisterTable方法支持配置表定义注册 - 实现热重载功能,监听文件变更并自动重新加载 - 支持schema校验,拒绝未知字段和类型错误 - 实现跨表引用校验,确保配置一致性 - 添加YamlConfigTableRegistrationOptions选项类 - 支持防抖机制避免频繁重载 - 提供详细的错误诊断信息 --- ...YamlConfigTableRegistrationOptionsTests.cs | 46 ++++++ GFramework.Game/Config/YamlConfigLoader.cs | 14 ++ .../YamlConfigTableRegistrationOptions.cs | 16 ++ .../Config/SchemaConfigGeneratorTests.cs | 140 ++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + .../Config/SchemaConfigGenerator.cs | 68 +++++++-- .../Diagnostics/ConfigSchemaDiagnostics.cs | 11 ++ 7 files changed, 286 insertions(+), 10 deletions(-) create mode 100644 GFramework.Game.Tests/Config/YamlConfigTableRegistrationOptionsTests.cs 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/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index 6b15d3e5..491505fd 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -161,6 +161,10 @@ public sealed class YamlConfigLoader : IConfigLoader /// 配置项主键提取器。 /// 可选主键比较器。 /// 当前加载器实例,以便链式注册。 + /// + /// 当 为 null、空字符串或空白字符串时抛出。 + /// + /// 为 null 时抛出。 public YamlConfigLoader RegisterTable( string tableName, string relativePath, @@ -188,6 +192,11 @@ public sealed class YamlConfigLoader : IConfigLoader /// 配置项主键提取器。 /// 可选主键比较器。 /// 当前加载器实例,以便链式注册。 + /// + /// 当 + /// 为 null、空字符串或空白字符串时抛出。 + /// + /// 为 null 时抛出。 public YamlConfigLoader RegisterTable( string tableName, string relativePath, @@ -214,6 +223,11 @@ public sealed class YamlConfigLoader : IConfigLoader /// 配置表注册选项。 /// 当前加载器实例,以便链式注册。 /// 为空时抛出。 + /// + /// 当 内的 、 + /// 或 + /// 为 null、空字符串或空白字符串时抛出。 + /// public YamlConfigLoader RegisterTable(YamlConfigTableRegistrationOptions options) where TKey : notnull { diff --git a/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs b/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs index 16f1a7b7..0f34687d 100644 --- a/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs +++ b/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs @@ -10,18 +10,34 @@ namespace GFramework.Game.Config; 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; diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 507bf36f..9c777f4c 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -91,4 +91,144 @@ public class SchemaConfigGeneratorTests Assert.That(diagnostic.GetMessage(), Does.Contain("array")); }); } + + /// + /// 验证 schema 字段名无法映射为合法 C# 标识符时会直接给出诊断,而不是生成不可编译代码。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Schema_Key_Maps_To_Invalid_CSharp_Identifier() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "drop$item"], + "properties": { + "id": { "type": "integer" }, + "drop$item": { "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("drop$item")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Drop$item")); + }); + } + + /// + /// 验证引用元数据成员名在不同路径规范化后发生碰撞时,生成器仍会分配全局唯一的成员名。 + /// + [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/AnalyzerReleases.Unshipped.md b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md index 356e4cbe..1f5a1cc8 100644 --- a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -20,6 +20,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 7ee0770f..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) { @@ -934,26 +937,37 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// 生成期引用元数据集合。 private static IEnumerable CollectReferenceSpecs(SchemaObjectSpec rootObject) { - var memberNameCounts = new Dictionary(StringComparer.Ordinal); + var nextSuffixByBaseMemberName = new Dictionary(StringComparer.Ordinal); + var allocatedMemberNames = new HashSet(StringComparer.Ordinal); foreach (var referenceSeed in EnumerateReferenceSeeds(rootObject.Properties)) { var baseMemberName = BuildReferenceMemberName(referenceSeed.DisplayPath); - if (memberNameCounts.TryGetValue(baseMemberName, out var duplicateCount)) + var memberName = baseMemberName; + if (!allocatedMemberNames.Add(memberName)) { - // Reuse the tracked duplicate count so repeated reference paths keep their generated member names stable. - duplicateCount++; - memberNameCounts[baseMemberName] = duplicateCount; - baseMemberName = - $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}"; + // 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 { - memberNameCounts[baseMemberName] = 0; + nextSuffixByBaseMemberName[baseMemberName] = 0; } yield return new GeneratedReferenceSpec( - baseMemberName, + memberName, referenceSeed.DisplayPath, referenceSeed.ReferencedTableName, referenceSeed.ValueSchemaType, @@ -1165,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 文件路径提取实体基础名。 /// 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 From 7da35c00b268aa81234c9b65f782bd8ea6da42dd Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:53:46 +0800 Subject: [PATCH 8/8] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=E6=96=87=E4=BB=B6=E7=9B=AE=E5=BD=95=E7=9A=84YAML?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现YamlConfigLoader类,支持从文件目录加载YAML配置 - 提供RegisterTable方法注册配置表定义,支持schema校验 - 添加LoadAsync异步加载功能,支持批量加载配置表 - 实现EnableHotReload方法,支持开发期配置热重载 - 添加跨表引用校验功能,确保配置依赖关系正确性 - 支持YAML文件和YML文件格式,自动识别文件扩展名 - 提供配置表主键提取器和比较器自定义功能 - 实现文件变更监听和防抖机制,避免频繁重载 - 支持配置目录和schema文件路径的灵活配置 - 提供详细的加载异常信息和诊断支持 --- .../Config/YamlConfigLoaderTests.cs | 20 +++++++++++ GFramework.Game/Config/YamlConfigLoader.cs | 13 ++++++++ .../Config/SchemaConfigGeneratorTests.cs | 33 +++++++++++-------- 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index f2045ad3..86f01098 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -1133,6 +1133,26 @@ public class YamlConfigLoaderTests } } + /// + /// 验证热重载会在启动前拒绝负的防抖延迟,避免后台延迟任务才暴露参数错误。 + /// + [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/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index 491505fd..012caa32 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -97,6 +97,9 @@ public sealed class YamlConfigLoader : IConfigLoader /// 防抖延迟;为空时默认使用 200 毫秒。 /// 用于停止热重载监听的注销句柄。 /// 为空时抛出。 + /// + /// 当显式提供的 小于 时抛出。 + /// public IUnRegister EnableHotReload( IConfigRegistry registry, Action? onTableReloaded = null, @@ -122,12 +125,22 @@ public sealed class YamlConfigLoader : IConfigLoader /// 热重载配置选项;为空时使用默认选项。 /// 用于停止热重载监听的注销句柄。 /// 为空时抛出。 + /// + /// 当 小于 + /// 时抛出。 + /// 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, diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 9c777f4c..07b3b81c 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -95,8 +95,13 @@ public class SchemaConfigGeneratorTests /// /// 验证 schema 字段名无法映射为合法 C# 标识符时会直接给出诊断,而不是生成不可编译代码。 /// - [Test] - public void Run_Should_Report_Diagnostic_When_Schema_Key_Maps_To_Invalid_CSharp_Identifier() + /// 会映射为非法 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 @@ -107,16 +112,16 @@ public class SchemaConfigGeneratorTests } """; - const string schema = """ - { - "type": "object", - "required": ["id", "drop$item"], - "properties": { - "id": { "type": "integer" }, - "drop$item": { "type": "string" } - } - } - """; + var schema = $$""" + { + "type": "object", + "required": ["id", "{{schemaKey}}"], + "properties": { + "id": { "type": "integer" }, + "{{schemaKey}}": { "type": "string" } + } + } + """; var result = SchemaGeneratorTestDriver.Run( source, @@ -128,8 +133,8 @@ public class SchemaConfigGeneratorTests { Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_006")); Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); - Assert.That(diagnostic.GetMessage(), Does.Contain("drop$item")); - Assert.That(diagnostic.GetMessage(), Does.Contain("Drop$item")); + Assert.That(diagnostic.GetMessage(), Does.Contain(schemaKey)); + Assert.That(diagnostic.GetMessage(), Does.Contain(generatedIdentifier)); }); }