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] =?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 插件表单和批量编辑入口显示更友好的字段标题