feat(config): 添加AI-First游戏内容配置系统

- 实现YAML配置文件与JSON Schema结构描述支持
- 提供一对象一文件的目录组织方式
- 集成Source Generator生成配置类型和表包装代码
- 添加VS Code插件支持配置浏览和表单编辑功能
- 实现运行时只读查询和开发期热重载机制
- 支持跨表引用校验和轻量元数据复用
- 添加配置加载异常诊断和批量编辑入口
This commit is contained in:
GeWuYou 2026-04-03 21:17:39 +08:00
parent 61cc7eaa6d
commit ec4e2edeab
6 changed files with 393 additions and 1 deletions

View File

@ -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));

View File

@ -132,7 +132,8 @@ public class SchemaConfigGeneratorSnapshotTests
"type": "string",
"description": "Monster reference id.",
"minLength": 2,
"maxLength": 32
"maxLength": 32,
"x-gframework-ref-table": "monster"
}
}
}

View File

@ -117,6 +117,7 @@ public sealed partial class MonsterConfig
/// <remarks>
/// Schema property path: 'phases[].monsterId'.
/// Constraints: minLength = 2, maxLength = 32.
/// References config table: 'monster'.
/// Generated default initializer: = string.Empty;
/// </remarks>
public string MonsterId { get; set; } = string.Empty;

View File

@ -9,6 +9,51 @@ namespace GFramework.Game.Config.Generated;
/// </summary>
public static class MonsterConfigBindings
{
/// <summary>
/// Describes one schema property that declares <c>x-gframework-ref-table</c> metadata.
/// </summary>
public readonly struct ReferenceMetadata
{
/// <summary>
/// Initializes one generated cross-table reference descriptor.
/// </summary>
/// <param name="displayPath">Schema property path.</param>
/// <param name="referencedTableName">Referenced runtime table name.</param>
/// <param name="valueSchemaType">Schema scalar type used by the reference value.</param>
/// <param name="isCollection">Whether the property stores multiple reference keys.</param>
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;
}
/// <summary>
/// Gets the schema property path such as <c>dropItems</c> or <c>phases[].monsterId</c>.
/// </summary>
public string DisplayPath { get; }
/// <summary>
/// Gets the runtime registration name of the referenced config table.
/// </summary>
public string ReferencedTableName { get; }
/// <summary>
/// Gets the schema scalar type used by the referenced key value.
/// </summary>
public string ValueSchemaType { get; }
/// <summary>
/// Gets a value indicating whether the property stores multiple reference keys.
/// </summary>
public bool IsCollection { get; }
}
/// <summary>
/// Groups the schema-derived metadata constants so consumer code can reuse one stable entry point.
/// </summary>
@ -55,6 +100,67 @@ public static class MonsterConfigBindings
/// </summary>
public const string SchemaRelativePath = Metadata.SchemaRelativePath;
/// <summary>
/// Exposes generated metadata for schema properties that declare <c>x-gframework-ref-table</c>.
/// </summary>
public static class References
{
/// <summary>
/// Gets generated reference metadata for schema property path 'dropItems'.
/// </summary>
public static readonly ReferenceMetadata DropItems = new(
"dropItems",
"item",
"string",
true);
/// <summary>
/// Gets generated reference metadata for schema property path 'phases[].monsterId'.
/// </summary>
public static readonly ReferenceMetadata PhasesItemsMonsterId = new(
"phases[].monsterId",
"monster",
"string",
false);
/// <summary>
/// Gets all generated cross-table reference descriptors for the current schema.
/// </summary>
public static global::System.Collections.Generic.IReadOnlyList<ReferenceMetadata> All { get; } = global::System.Array.AsReadOnly(new ReferenceMetadata[]
{
DropItems,
PhasesItemsMonsterId,
});
/// <summary>
/// Tries to resolve generated reference metadata by schema property path.
/// </summary>
/// <param name="displayPath">Schema property path.</param>
/// <param name="metadata">Resolved generated reference metadata when the path is known; otherwise the default value.</param>
/// <returns>True when the schema property path has generated cross-table metadata; otherwise false.</returns>
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;
}
}
/// <summary>
/// Registers the generated config table using the schema-derived runtime conventions.
/// </summary>

View File

@ -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("// <auto-generated />");
@ -650,6 +651,59 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine($"public static class {bindingsClassName}");
builder.AppendLine("{");
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Describes one schema property that declares <c>x-gframework-ref-table</c> metadata.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public readonly struct ReferenceMetadata");
builder.AppendLine(" {");
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Initializes one generated cross-table reference descriptor.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"displayPath\">Schema property path.</param>");
builder.AppendLine(" /// <param name=\"referencedTableName\">Referenced runtime table name.</param>");
builder.AppendLine(
" /// <param name=\"valueSchemaType\">Schema scalar type used by the reference value.</param>");
builder.AppendLine(
" /// <param name=\"isCollection\">Whether the property stores multiple reference keys.</param>");
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(" /// <summary>");
builder.AppendLine(
" /// Gets the schema property path such as <c>dropItems</c> or <c>phases[].monsterId</c>.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public string DisplayPath { get; }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the runtime registration name of the referenced config table.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public string ReferencedTableName { get; }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the schema scalar type used by the referenced key value.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public string ValueSchemaType { get; }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Gets a value indicating whether the property stores multiple reference keys.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public bool IsCollection { get; }");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Groups the schema-derived metadata constants so consumer code can reuse one stable entry point.");
builder.AppendLine(" /// </summary>");
@ -704,6 +758,97 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" public const string SchemaRelativePath = Metadata.SchemaRelativePath;");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Exposes generated metadata for schema properties that declare <c>x-gframework-ref-table</c>.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public static class References");
builder.AppendLine(" {");
foreach (var referenceSpec in referenceSpecs)
{
builder.AppendLine(" /// <summary>");
builder.AppendLine(
$" /// Gets generated reference metadata for schema property path '{EscapeXmlDocumentation(referenceSpec.DisplayPath)}'.");
builder.AppendLine(" /// </summary>");
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(" /// <summary>");
builder.AppendLine(
" /// Gets all generated cross-table reference descriptors for the current schema.");
builder.AppendLine(" /// </summary>");
if (referenceSpecs.Length == 0)
{
builder.AppendLine(
" public static global::System.Collections.Generic.IReadOnlyList<ReferenceMetadata> All { get; } = global::System.Array.Empty<ReferenceMetadata>();");
}
else
{
builder.AppendLine(
" public static global::System.Collections.Generic.IReadOnlyList<ReferenceMetadata> 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(" /// <summary>");
builder.AppendLine(" /// Tries to resolve generated reference metadata by schema property path.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"displayPath\">Schema property path.</param>");
builder.AppendLine(
" /// <param name=\"metadata\">Resolved generated reference metadata when the path is known; otherwise the default value.</param>");
builder.AppendLine(
" /// <returns>True when the schema property path has generated cross-table metadata; otherwise false.</returns>");
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(" /// <summary>");
builder.AppendLine(
" /// Registers the generated config table using the schema-derived runtime conventions.");
builder.AppendLine(" /// </summary>");
@ -782,6 +927,78 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return builder.ToString().TrimEnd();
}
/// <summary>
/// 收集 schema 中声明的跨表引用元数据,并为生成代码分配稳定成员名。
/// </summary>
/// <param name="rootObject">根对象模型。</param>
/// <returns>生成期引用元数据集合。</returns>
private static IEnumerable<GeneratedReferenceSpec> CollectReferenceSpecs(SchemaObjectSpec rootObject)
{
var memberNameCounts = new Dictionary<string, int>(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);
}
}
/// <summary>
/// 递归枚举对象树中所有带 ref-table 元数据的字段。
/// </summary>
/// <param name="properties">对象属性集合。</param>
/// <returns>原始引用字段信息。</returns>
private static IEnumerable<GeneratedReferenceSeed> EnumerateReferenceSeeds(
IEnumerable<SchemaPropertySpec> 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;
}
}
}
}
/// <summary>
/// 递归生成配置对象类型。
/// </summary>
@ -1004,6 +1221,28 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return tokens.Length == 0 ? "Config" : string.Concat(tokens);
}
/// <summary>
/// 将 schema 字段路径转换为可用于生成引用元数据成员的 PascalCase 标识符。
/// </summary>
/// <param name="displayPath">Schema 字段路径。</param>
/// <returns>稳定的成员名。</returns>
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();
}
/// <summary>
/// 为 AdditionalFiles 诊断创建文件位置。
/// </summary>
@ -1371,6 +1610,34 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
SchemaObjectSpec? NestedObject,
SchemaTypeSpec? ItemTypeSpec);
/// <summary>
/// 生成代码前的跨表引用字段种子信息。
/// </summary>
/// <param name="DisplayPath">Schema 字段路径。</param>
/// <param name="ReferencedTableName">目标表名称。</param>
/// <param name="ValueSchemaType">引用值的标量 schema 类型。</param>
/// <param name="IsCollection">是否为数组引用。</param>
private sealed record GeneratedReferenceSeed(
string DisplayPath,
string ReferencedTableName,
string ValueSchemaType,
bool IsCollection);
/// <summary>
/// 已分配稳定成员名的生成期跨表引用信息。
/// </summary>
/// <param name="MemberName">生成到绑定类中的成员名。</param>
/// <param name="DisplayPath">Schema 字段路径。</param>
/// <param name="ReferencedTableName">目标表名称。</param>
/// <param name="ValueSchemaType">引用值的标量 schema 类型。</param>
/// <param name="IsCollection">是否为数组引用。</param>
private sealed record GeneratedReferenceSpec(
string MemberName,
string DisplayPath,
string ReferencedTableName,
string ValueSchemaType,
bool IsCollection);
/// <summary>
/// 属性解析结果包装。
/// </summary>

View File

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