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