Merge pull request #157 from GeWuYou/feat/game-content-config-yaml-schema

feat(game): 添加游戏内容配置系统及YAML Schema校验器
This commit is contained in:
gewuyou 2026-04-01 21:09:58 +08:00 committed by GitHub
commit 65a6e2c257
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 2545 additions and 1152 deletions

0
.codex Normal file
View File

View File

@ -428,6 +428,194 @@ public class YamlConfigLoaderTests
}); });
} }
/// <summary>
/// 验证嵌套对象中的必填字段同样会按 schema 在运行时生效。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Nested_Object_Is_Missing_Required_Property()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
reward:
gold: 10
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "reward"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"reward": {
"type": "object",
"required": ["gold", "currency"],
"properties": {
"gold": { "type": "integer" },
"currency": { "type": "string" }
}
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterNestedConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("reward.currency"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证对象数组中的嵌套字段也会按 schema 递归校验。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Object_Array_Item_Contains_Unknown_Property()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
phases:
-
wave: 1
monsterId: slime
hpScale: 1.5
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "phases"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"phases": {
"type": "array",
"items": {
"type": "object",
"required": ["wave", "monsterId"],
"properties": {
"wave": { "type": "integer" },
"monsterId": { "type": "string" }
}
}
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterPhaseArrayConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("phases[0].hpScale"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证深层对象数组中的跨表引用也会参与整批加载校验。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Nested_Object_Array_Reference_Target_Is_Missing()
{
CreateConfigFile(
"item/potion.yaml",
"""
id: potion
name: Potion
""");
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
phases:
-
wave: 1
dropItemId: potion
-
wave: 2
dropItemId: bomb
""");
CreateSchemaFile(
"schemas/item.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
}
}
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "phases"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"phases": {
"type": "array",
"items": {
"type": "object",
"required": ["wave", "dropItemId"],
"properties": {
"wave": { "type": "integer" },
"dropItemId": {
"type": "string",
"x-gframework-ref-table": "item"
}
}
}
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<string, ItemConfigStub>("item", "item", "schemas/item.schema.json",
static config => config.Id)
.RegisterTable<int, MonsterPhaseDropConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("phases[1].dropItemId"));
Assert.That(exception!.Message, Does.Contain("bomb"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary> /// <summary>
/// 验证绑定跨表引用 schema 时,存在的目标行可以通过加载校验。 /// 验证绑定跨表引用 schema 时,存在的目标行可以通过加载校验。
/// </summary> /// </summary>
@ -949,6 +1137,117 @@ public class YamlConfigLoaderTests
public IReadOnlyList<int> DropRates { get; set; } = Array.Empty<int>(); public IReadOnlyList<int> DropRates { get; set; } = Array.Empty<int>();
} }
/// <summary>
/// 用于嵌套对象 schema 校验测试的最小怪物配置类型。
/// </summary>
private sealed class MonsterNestedConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 获取或设置奖励对象。
/// </summary>
public RewardConfigStub Reward { get; set; } = new();
}
/// <summary>
/// 表示嵌套奖励对象的测试桩类型。
/// </summary>
private sealed class RewardConfigStub
{
/// <summary>
/// 获取或设置金币数量。
/// </summary>
public int Gold { get; set; }
/// <summary>
/// 获取或设置货币类型。
/// </summary>
public string Currency { get; set; } = string.Empty;
}
/// <summary>
/// 用于对象数组 schema 校验测试的怪物配置类型。
/// </summary>
private sealed class MonsterPhaseArrayConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 获取或设置阶段数组。
/// </summary>
public IReadOnlyList<PhaseConfigStub> Phases { get; set; } = Array.Empty<PhaseConfigStub>();
}
/// <summary>
/// 表示对象数组中的阶段元素。
/// </summary>
private sealed class PhaseConfigStub
{
/// <summary>
/// 获取或设置波次编号。
/// </summary>
public int Wave { get; set; }
/// <summary>
/// 获取或设置怪物主键。
/// </summary>
public string MonsterId { get; set; } = string.Empty;
}
/// <summary>
/// 用于深层跨表引用测试的怪物配置类型。
/// </summary>
private sealed class MonsterPhaseDropConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 获取或设置阶段数组。
/// </summary>
public List<PhaseDropConfigStub> Phases { get; set; } = new();
}
/// <summary>
/// 表示带有掉落引用的阶段元素。
/// </summary>
private sealed class PhaseDropConfigStub
{
/// <summary>
/// 获取或设置波次编号。
/// </summary>
public int Wave { get; set; }
/// <summary>
/// 获取或设置掉落物品主键。
/// </summary>
public string DropItemId { get; set; } = string.Empty;
}
/// <summary> /// <summary>
/// 用于跨表引用测试的最小物品配置类型。 /// 用于跨表引用测试的最小物品配置类型。
/// </summary> /// </summary>

File diff suppressed because it is too large Load Diff

View File

@ -43,7 +43,7 @@ public class SchemaConfigGeneratorSnapshotTests
"title": "Monster Config", "title": "Monster Config",
"description": "Represents one monster entry generated from schema metadata.", "description": "Represents one monster entry generated from schema metadata.",
"type": "object", "type": "object",
"required": ["id", "name"], "required": ["id", "name", "reward", "phases"],
"properties": { "properties": {
"id": { "id": {
"type": "integer", "type": "integer",
@ -69,6 +69,38 @@ public class SchemaConfigGeneratorSnapshotTests
}, },
"default": ["potion"], "default": ["potion"],
"x-gframework-ref-table": "item" "x-gframework-ref-table": "item"
},
"reward": {
"type": "object",
"description": "Reward payload.",
"required": ["gold", "currency"],
"properties": {
"gold": {
"type": "integer",
"default": 10
},
"currency": {
"type": "string",
"enum": ["coin", "gem"]
}
}
},
"phases": {
"type": "array",
"description": "Encounter phases.",
"items": {
"type": "object",
"required": ["wave", "monsterId"],
"properties": {
"wave": {
"type": "integer"
},
"monsterId": {
"type": "string",
"description": "Monster reference id."
}
}
}
} }
} }
} }

View File

@ -45,4 +45,50 @@ public class SchemaConfigGeneratorTests
Assert.That(diagnostic.GetMessage(), Does.Contain("monster.schema.json")); Assert.That(diagnostic.GetMessage(), Does.Contain("monster.schema.json"));
}); });
} }
/// <summary>
/// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_Nested_Array_Type_Is_Not_Supported()
{
const string source = """
namespace TestApp
{
public sealed class Dummy
{
}
}
""";
const string schema = """
{
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "integer" },
"waves": {
"type": "array",
"items": {
"type": "array",
"items": { "type": "integer" }
}
}
}
}
""";
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_004"));
Assert.That(diagnostic.GetMessage(), Does.Contain("waves"));
Assert.That(diagnostic.GetMessage(), Does.Contain("array<array>"));
});
}
} }

View File

@ -13,7 +13,7 @@ public sealed partial class MonsterConfig
/// Unique monster identifier. /// Unique monster identifier.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Schema property: 'id'. /// Schema property path: 'id'.
/// </remarks> /// </remarks>
public int Id { get; set; } public int Id { get; set; }
@ -21,7 +21,7 @@ public sealed partial class MonsterConfig
/// Localized monster display name. /// Localized monster display name.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Schema property: 'name'. /// Schema property path: 'name'.
/// Display title: 'Monster Name'. /// Display title: 'Monster Name'.
/// Allowed values: Slime, Goblin. /// Allowed values: Slime, Goblin.
/// Generated default initializer: = "Slime"; /// Generated default initializer: = "Slime";
@ -29,10 +29,10 @@ public sealed partial class MonsterConfig
public string Name { get; set; } = "Slime"; public string Name { get; set; } = "Slime";
/// <summary> /// <summary>
/// Gets or sets the value mapped from schema property 'hp'. /// Gets or sets the value mapped from schema property path 'hp'.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Schema property: 'hp'. /// Schema property path: 'hp'.
/// Generated default initializer: = 10; /// Generated default initializer: = 10;
/// </remarks> /// </remarks>
public int? Hp { get; set; } = 10; public int? Hp { get; set; } = 10;
@ -41,11 +41,80 @@ public sealed partial class MonsterConfig
/// Referenced drop ids. /// Referenced drop ids.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Schema property: 'dropItems'. /// Schema property path: 'dropItems'.
/// Allowed values: potion, slime_gel. /// Allowed values: potion, slime_gel.
/// References config table: 'item'. /// References config table: 'item'.
/// Generated default initializer: = new string[] { "potion" }; /// Generated default initializer: = new string[] { "potion" };
/// </remarks> /// </remarks>
public global::System.Collections.Generic.IReadOnlyList<string> DropItems { get; set; } = new string[] { "potion" }; public global::System.Collections.Generic.IReadOnlyList<string> DropItems { get; set; } = new string[] { "potion" };
/// <summary>
/// Reward payload.
/// </summary>
/// <remarks>
/// Schema property path: 'reward'.
/// Generated default initializer: = new();
/// </remarks>
public RewardConfig Reward { get; set; } = new();
/// <summary>
/// Encounter phases.
/// </summary>
/// <remarks>
/// Schema property path: 'phases'.
/// Generated default initializer: = global::System.Array.Empty&lt;PhasesItemConfig&gt;();
/// </remarks>
public global::System.Collections.Generic.IReadOnlyList<PhasesItemConfig> Phases { get; set; } = global::System.Array.Empty<PhasesItemConfig>();
/// <summary>
/// Auto-generated nested config type for schema property path 'reward'.
/// Reward payload.
/// </summary>
public sealed partial class RewardConfig
{
/// <summary>
/// Gets or sets the value mapped from schema property path 'reward.gold'.
/// </summary>
/// <remarks>
/// Schema property path: 'reward.gold'.
/// Generated default initializer: = 10;
/// </remarks>
public int Gold { get; set; } = 10;
/// <summary>
/// Gets or sets the value mapped from schema property path 'reward.currency'.
/// </summary>
/// <remarks>
/// Schema property path: 'reward.currency'.
/// Allowed values: coin, gem.
/// Generated default initializer: = string.Empty;
/// </remarks>
public string Currency { get; set; } = string.Empty;
}
/// <summary>
/// Auto-generated nested config type for schema property path 'phases[]'.
/// This nested type is generated so object-valued schema fields remain strongly typed in consumer code.
/// </summary>
public sealed partial class PhasesItemConfig
{
/// <summary>
/// Gets or sets the value mapped from schema property path 'phases[].wave'.
/// </summary>
/// <remarks>
/// Schema property path: 'phases[].wave'.
/// </remarks>
public int Wave { get; set; }
/// <summary>
/// Monster reference id.
/// </summary>
/// <remarks>
/// Schema property path: 'phases[].monsterId'.
/// Generated default initializer: = string.Empty;
/// </remarks>
public string MonsterId { get; set; } = string.Empty;
}
} }

View File

@ -8,7 +8,8 @@ namespace GFramework.SourceGenerators.Config;
/// <summary> /// <summary>
/// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。 /// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。
/// 当前实现聚焦 Runtime MVP 需要的最小能力:单 schema 对应单配置类型,并约定使用必填的 id 字段作为表主键。 /// 当前实现聚焦 AI-First 配置系统共享的最小 schema 子集,
/// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / ref-table 元数据。
/// </summary> /// </summary>
[Generator] [Generator]
public sealed class SchemaConfigGenerator : IIncrementalGenerator public sealed class SchemaConfigGenerator : IIncrementalGenerator
@ -92,18 +93,92 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
Path.GetFileName(file.Path))); Path.GetFileName(file.Path)));
} }
if (!root.TryGetProperty("properties", out var propertiesElement) || var entityName = ToPascalCase(GetSchemaBaseName(file.Path));
propertiesElement.ValueKind != JsonValueKind.Object) var rootObject = ParseObjectSpec(
file.Path,
root,
"<root>",
$"{entityName}Config",
isRoot: true);
if (rootObject.Diagnostic is not null)
{
return SchemaParseResult.FromDiagnostic(rootObject.Diagnostic);
}
var schemaObject = rootObject.Object!;
var idProperty = schemaObject.Properties.FirstOrDefault(static property =>
string.Equals(property.SchemaName, "id", StringComparison.OrdinalIgnoreCase));
if (idProperty is null || !idProperty.IsRequired)
{ {
return SchemaParseResult.FromDiagnostic( return SchemaParseResult.FromDiagnostic(
Diagnostic.Create( Diagnostic.Create(
ConfigSchemaDiagnostics.RootObjectSchemaRequired, ConfigSchemaDiagnostics.IdPropertyRequired,
CreateFileLocation(file.Path), CreateFileLocation(file.Path),
Path.GetFileName(file.Path))); Path.GetFileName(file.Path)));
} }
if (idProperty.TypeSpec.SchemaType != "integer" &&
idProperty.TypeSpec.SchemaType != "string")
{
return SchemaParseResult.FromDiagnostic(
Diagnostic.Create(
ConfigSchemaDiagnostics.UnsupportedKeyType,
CreateFileLocation(file.Path),
Path.GetFileName(file.Path),
idProperty.TypeSpec.SchemaType));
}
var schema = new SchemaFileSpec(
Path.GetFileName(file.Path),
schemaObject.ClassName,
$"{entityName}Table",
GeneratedNamespace,
idProperty.TypeSpec.ClrType.TrimEnd('?'),
TryGetMetadataString(root, "title"),
TryGetMetadataString(root, "description"),
schemaObject);
return SchemaParseResult.FromSchema(schema);
}
catch (JsonException exception)
{
return SchemaParseResult.FromDiagnostic(
Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidSchemaJson,
CreateFileLocation(file.Path),
Path.GetFileName(file.Path),
exception.Message));
}
}
/// <summary>
/// 解析对象 schema并递归构建子属性模型。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="element">对象 schema 节点。</param>
/// <param name="displayPath">当前对象的逻辑字段路径。</param>
/// <param name="className">要生成的 CLR 类型名。</param>
/// <param name="isRoot">是否为根对象。</param>
/// <returns>对象模型或诊断。</returns>
private static ParsedObjectResult ParseObjectSpec(
string filePath,
JsonElement element,
string displayPath,
string className,
bool isRoot = false)
{
if (!element.TryGetProperty("properties", out var propertiesElement) ||
propertiesElement.ValueKind != JsonValueKind.Object)
{
return ParsedObjectResult.FromDiagnostic(
Diagnostic.Create(
ConfigSchemaDiagnostics.RootObjectSchemaRequired,
CreateFileLocation(filePath),
Path.GetFileName(filePath)));
}
var requiredProperties = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var requiredProperties = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (root.TryGetProperty("required", out var requiredElement) && if (element.TryGetProperty("required", out var requiredElement) &&
requiredElement.ValueKind == JsonValueKind.Array) requiredElement.ValueKind == JsonValueKind.Array)
{ {
foreach (var item in requiredElement.EnumerateArray()) foreach (var item in requiredElement.EnumerateArray())
@ -122,73 +197,40 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
var properties = new List<SchemaPropertySpec>(); var properties = new List<SchemaPropertySpec>();
foreach (var property in propertiesElement.EnumerateObject()) foreach (var property in propertiesElement.EnumerateObject())
{ {
var parsedProperty = ParseProperty(file.Path, property, requiredProperties.Contains(property.Name)); var parsedProperty = ParseProperty(
filePath,
property,
requiredProperties.Contains(property.Name),
CombinePath(displayPath, property.Name));
if (parsedProperty.Diagnostic is not null) if (parsedProperty.Diagnostic is not null)
{ {
return SchemaParseResult.FromDiagnostic(parsedProperty.Diagnostic); return ParsedObjectResult.FromDiagnostic(parsedProperty.Diagnostic);
} }
properties.Add(parsedProperty.Property!); properties.Add(parsedProperty.Property!);
} }
var idProperty = properties.FirstOrDefault(static property => return ParsedObjectResult.FromObject(new SchemaObjectSpec(
string.Equals(property.SchemaName, "id", StringComparison.OrdinalIgnoreCase)); displayPath,
if (idProperty is null || !idProperty.IsRequired) className,
{ TryGetMetadataString(element, "title"),
return SchemaParseResult.FromDiagnostic( TryGetMetadataString(element, "description"),
Diagnostic.Create( properties));
ConfigSchemaDiagnostics.IdPropertyRequired,
CreateFileLocation(file.Path),
Path.GetFileName(file.Path)));
}
if (!string.Equals(idProperty.SchemaType, "integer", StringComparison.Ordinal) &&
!string.Equals(idProperty.SchemaType, "string", StringComparison.Ordinal))
{
return SchemaParseResult.FromDiagnostic(
Diagnostic.Create(
ConfigSchemaDiagnostics.UnsupportedKeyType,
CreateFileLocation(file.Path),
Path.GetFileName(file.Path),
idProperty.SchemaType));
}
var entityName = ToPascalCase(GetSchemaBaseName(file.Path));
var schema = new SchemaFileSpec(
Path.GetFileName(file.Path),
entityName,
$"{entityName}Config",
$"{entityName}Table",
GeneratedNamespace,
idProperty.ClrType,
TryGetMetadataString(root, "title"),
TryGetMetadataString(root, "description"),
properties);
return SchemaParseResult.FromSchema(schema);
}
catch (JsonException exception)
{
return SchemaParseResult.FromDiagnostic(
Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidSchemaJson,
CreateFileLocation(file.Path),
Path.GetFileName(file.Path),
exception.Message));
}
} }
/// <summary> /// <summary>
/// 解析单个 schema 属性定义。 /// 解析单个 schema 属性定义。
/// </summary> /// </summary>
/// <param name="filePath">schema 文件路径。</param> /// <param name="filePath">Schema 文件路径。</param>
/// <param name="property">属性 JSON 节点。</param> /// <param name="property">属性 JSON 节点。</param>
/// <param name="isRequired">属性是否必填。</param> /// <param name="isRequired">属性是否必填。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <returns>解析后的属性信息或诊断。</returns> /// <returns>解析后的属性信息或诊断。</returns>
private static ParsedPropertyResult ParseProperty( private static ParsedPropertyResult ParseProperty(
string filePath, string filePath,
JsonProperty property, JsonProperty property,
bool isRequired) bool isRequired,
string displayPath)
{ {
if (!property.Value.TryGetProperty("type", out var typeElement) || if (!property.Value.TryGetProperty("type", out var typeElement) ||
typeElement.ValueKind != JsonValueKind.String) typeElement.ValueKind != JsonValueKind.String)
@ -198,7 +240,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
ConfigSchemaDiagnostics.UnsupportedPropertyType, ConfigSchemaDiagnostics.UnsupportedPropertyType,
CreateFileLocation(filePath), CreateFileLocation(filePath),
Path.GetFileName(filePath), Path.GetFileName(filePath),
property.Name, displayPath,
"<missing>")); "<missing>"));
} }
@ -206,64 +248,162 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
var title = TryGetMetadataString(property.Value, "title"); var title = TryGetMetadataString(property.Value, "title");
var description = TryGetMetadataString(property.Value, "description"); var description = TryGetMetadataString(property.Value, "description");
var refTableName = TryGetMetadataString(property.Value, "x-gframework-ref-table"); var refTableName = TryGetMetadataString(property.Value, "x-gframework-ref-table");
var propertyName = ToPascalCase(property.Name);
switch (schemaType) switch (schemaType)
{ {
case "integer": case "integer":
return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( return ParsedPropertyResult.FromProperty(new SchemaPropertySpec(
property.Name, property.Name,
ToPascalCase(property.Name), displayPath,
"integer", propertyName,
isRequired ? "int" : "int?",
isRequired, isRequired,
TryBuildScalarInitializer(property.Value, "integer"),
title, title,
description, description,
new SchemaTypeSpec(
SchemaNodeKind.Scalar,
"integer",
isRequired ? "int" : "int?",
TryBuildScalarInitializer(property.Value, "integer"),
TryBuildEnumDocumentation(property.Value, "integer"), TryBuildEnumDocumentation(property.Value, "integer"),
refTableName)); refTableName,
null,
null)));
case "number": case "number":
return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( return ParsedPropertyResult.FromProperty(new SchemaPropertySpec(
property.Name, property.Name,
ToPascalCase(property.Name), displayPath,
"number", propertyName,
isRequired ? "double" : "double?",
isRequired, isRequired,
TryBuildScalarInitializer(property.Value, "number"),
title, title,
description, description,
new SchemaTypeSpec(
SchemaNodeKind.Scalar,
"number",
isRequired ? "double" : "double?",
TryBuildScalarInitializer(property.Value, "number"),
TryBuildEnumDocumentation(property.Value, "number"), TryBuildEnumDocumentation(property.Value, "number"),
refTableName)); refTableName,
null,
null)));
case "boolean": case "boolean":
return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( return ParsedPropertyResult.FromProperty(new SchemaPropertySpec(
property.Name, property.Name,
ToPascalCase(property.Name), displayPath,
"boolean", propertyName,
isRequired ? "bool" : "bool?",
isRequired, isRequired,
TryBuildScalarInitializer(property.Value, "boolean"),
title, title,
description, description,
new SchemaTypeSpec(
SchemaNodeKind.Scalar,
"boolean",
isRequired ? "bool" : "bool?",
TryBuildScalarInitializer(property.Value, "boolean"),
TryBuildEnumDocumentation(property.Value, "boolean"), TryBuildEnumDocumentation(property.Value, "boolean"),
refTableName)); refTableName,
null,
null)));
case "string": case "string":
return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( return ParsedPropertyResult.FromProperty(new SchemaPropertySpec(
property.Name, property.Name,
ToPascalCase(property.Name), displayPath,
"string", propertyName,
isRequired ? "string" : "string?",
isRequired, isRequired,
TryBuildScalarInitializer(property.Value, "string") ??
(isRequired ? " = string.Empty;" : null),
title, title,
description, description,
new SchemaTypeSpec(
SchemaNodeKind.Scalar,
"string",
isRequired ? "string" : "string?",
TryBuildScalarInitializer(property.Value, "string") ??
(isRequired ? " = string.Empty;" : null),
TryBuildEnumDocumentation(property.Value, "string"), TryBuildEnumDocumentation(property.Value, "string"),
refTableName)); refTableName,
null,
null)));
case "object":
if (!string.IsNullOrWhiteSpace(refTableName))
{
return ParsedPropertyResult.FromDiagnostic(
Diagnostic.Create(
ConfigSchemaDiagnostics.UnsupportedPropertyType,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
"object-ref"));
}
var objectResult = ParseObjectSpec(
filePath,
property.Value,
displayPath,
$"{propertyName}Config");
if (objectResult.Diagnostic is not null)
{
return ParsedPropertyResult.FromDiagnostic(objectResult.Diagnostic);
}
var objectSpec = objectResult.Object!;
return ParsedPropertyResult.FromProperty(new SchemaPropertySpec(
property.Name,
displayPath,
propertyName,
isRequired,
title,
description,
new SchemaTypeSpec(
SchemaNodeKind.Object,
"object",
isRequired ? objectSpec.ClassName : $"{objectSpec.ClassName}?",
isRequired ? " = new();" : null,
null,
null,
objectSpec,
null)));
case "array": case "array":
return ParseArrayProperty(filePath, property, isRequired, displayPath, propertyName, title,
description, refTableName);
default:
return ParsedPropertyResult.FromDiagnostic(
Diagnostic.Create(
ConfigSchemaDiagnostics.UnsupportedPropertyType,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
schemaType));
}
}
/// <summary>
/// 解析数组属性,支持标量数组与对象数组。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="property">属性 JSON 节点。</param>
/// <param name="isRequired">属性是否必填。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="propertyName">CLR 属性名。</param>
/// <param name="title">标题元数据。</param>
/// <param name="description">说明元数据。</param>
/// <param name="refTableName">目标引用表名称。</param>
/// <returns>解析后的属性信息或诊断。</returns>
private static ParsedPropertyResult ParseArrayProperty(
string filePath,
JsonProperty property,
bool isRequired,
string displayPath,
string propertyName,
string? title,
string? description,
string? refTableName)
{
if (!property.Value.TryGetProperty("items", out var itemsElement) || if (!property.Value.TryGetProperty("items", out var itemsElement) ||
itemsElement.ValueKind != JsonValueKind.Object ||
!itemsElement.TryGetProperty("type", out var itemTypeElement) || !itemsElement.TryGetProperty("type", out var itemTypeElement) ||
itemTypeElement.ValueKind != JsonValueKind.String) itemTypeElement.ValueKind != JsonValueKind.String)
{ {
@ -272,43 +412,98 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
ConfigSchemaDiagnostics.UnsupportedPropertyType, ConfigSchemaDiagnostics.UnsupportedPropertyType,
CreateFileLocation(filePath), CreateFileLocation(filePath),
Path.GetFileName(filePath), Path.GetFileName(filePath),
property.Name, displayPath,
"array")); "array"));
} }
var itemType = itemTypeElement.GetString() ?? string.Empty; var itemType = itemTypeElement.GetString() ?? string.Empty;
switch (itemType)
{
case "integer":
case "number":
case "boolean":
case "string":
var itemClrType = itemType switch var itemClrType = itemType switch
{ {
"integer" => "int", "integer" => "int",
"number" => "double", "number" => "double",
"boolean" => "bool", "boolean" => "bool",
"string" => "string", _ => "string"
_ => string.Empty
}; };
if (string.IsNullOrEmpty(itemClrType)) return ParsedPropertyResult.FromProperty(new SchemaPropertySpec(
property.Name,
displayPath,
propertyName,
isRequired,
title,
description,
new SchemaTypeSpec(
SchemaNodeKind.Array,
"array",
$"global::System.Collections.Generic.IReadOnlyList<{itemClrType}>",
TryBuildArrayInitializer(property.Value, itemType, itemClrType) ??
$" = global::System.Array.Empty<{itemClrType}>();",
TryBuildEnumDocumentation(itemsElement, itemType),
refTableName,
null,
new SchemaTypeSpec(
SchemaNodeKind.Scalar,
itemType,
itemClrType,
null,
TryBuildEnumDocumentation(itemsElement, itemType),
refTableName,
null,
null))));
case "object":
if (!string.IsNullOrWhiteSpace(refTableName))
{ {
return ParsedPropertyResult.FromDiagnostic( return ParsedPropertyResult.FromDiagnostic(
Diagnostic.Create( Diagnostic.Create(
ConfigSchemaDiagnostics.UnsupportedPropertyType, ConfigSchemaDiagnostics.UnsupportedPropertyType,
CreateFileLocation(filePath), CreateFileLocation(filePath),
Path.GetFileName(filePath), Path.GetFileName(filePath),
property.Name, displayPath,
$"array<{itemType}>")); "array<object>-ref"));
} }
var objectResult = ParseObjectSpec(
filePath,
itemsElement,
$"{displayPath}[]",
$"{propertyName}ItemConfig");
if (objectResult.Diagnostic is not null)
{
return ParsedPropertyResult.FromDiagnostic(objectResult.Diagnostic);
}
var objectSpec = objectResult.Object!;
return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( return ParsedPropertyResult.FromProperty(new SchemaPropertySpec(
property.Name, property.Name,
ToPascalCase(property.Name), displayPath,
"array", propertyName,
$"global::System.Collections.Generic.IReadOnlyList<{itemClrType}>",
isRequired, isRequired,
TryBuildArrayInitializer(property.Value, itemType, itemClrType) ??
" = global::System.Array.Empty<" + itemClrType + ">();",
title, title,
description, description,
TryBuildEnumDocumentation(itemsElement, itemType), new SchemaTypeSpec(
refTableName)); SchemaNodeKind.Array,
"array",
$"global::System.Collections.Generic.IReadOnlyList<{objectSpec.ClassName}>",
$" = global::System.Array.Empty<{objectSpec.ClassName}>();",
null,
null,
null,
new SchemaTypeSpec(
SchemaNodeKind.Object,
"object",
objectSpec.ClassName,
null,
null,
null,
objectSpec,
null))));
default: default:
return ParsedPropertyResult.FromDiagnostic( return ParsedPropertyResult.FromDiagnostic(
@ -316,8 +511,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
ConfigSchemaDiagnostics.UnsupportedPropertyType, ConfigSchemaDiagnostics.UnsupportedPropertyType,
CreateFileLocation(filePath), CreateFileLocation(filePath),
Path.GetFileName(filePath), Path.GetFileName(filePath),
property.Name, displayPath,
schemaType)); $"array<{itemType}>"));
} }
} }
@ -334,34 +529,14 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(); builder.AppendLine();
builder.AppendLine($"namespace {schema.Namespace};"); builder.AppendLine($"namespace {schema.Namespace};");
builder.AppendLine(); builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine(
$"/// Auto-generated config type for schema file '{schema.FileName}'.");
builder.AppendLine(
$"/// {EscapeXmlDocumentation(schema.Description ?? schema.Title ?? "This type is generated from JSON schema so runtime loading and editor tooling can share the same contract.")}");
builder.AppendLine("/// </summary>");
builder.AppendLine($"public sealed partial class {schema.ClassName}");
builder.AppendLine("{");
foreach (var property in schema.Properties) AppendObjectType(builder, schema.RootObject, schema.FileName, schema.Title, schema.Description, isRoot: true,
{ indentationLevel: 0);
AppendPropertyDocumentation(builder, property);
builder.Append($" public {property.ClrType} {property.PropertyName} {{ get; set; }}");
if (!string.IsNullOrEmpty(property.Initializer))
{
builder.Append(property.Initializer);
}
builder.AppendLine();
builder.AppendLine();
}
builder.AppendLine("}");
return builder.ToString().TrimEnd(); return builder.ToString().TrimEnd();
} }
/// <summary> /// <summary>
/// 生成配置表包装源码。 /// 生成表包装源码。
/// </summary> /// </summary>
/// <param name="schema">已解析的 schema 模型。</param> /// <param name="schema">已解析的 schema 模型。</param>
/// <returns>配置表包装源码。</returns> /// <returns>配置表包装源码。</returns>
@ -432,11 +607,161 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return builder.ToString().TrimEnd(); return builder.ToString().TrimEnd();
} }
/// <summary>
/// 递归生成配置对象类型。
/// </summary>
/// <param name="builder">输出缓冲区。</param>
/// <param name="objectSpec">要生成的对象类型。</param>
/// <param name="fileName">Schema 文件名。</param>
/// <param name="title">对象标题元数据。</param>
/// <param name="description">对象说明元数据。</param>
/// <param name="isRoot">是否为根配置类型。</param>
/// <param name="indentationLevel">缩进层级。</param>
private static void AppendObjectType(
StringBuilder builder,
SchemaObjectSpec objectSpec,
string fileName,
string? title,
string? description,
bool isRoot,
int indentationLevel)
{
var indent = new string(' ', indentationLevel * 4);
builder.AppendLine($"{indent}/// <summary>");
if (isRoot)
{
builder.AppendLine(
$"{indent}/// Auto-generated config type for schema file '{fileName}'.");
builder.AppendLine(
$"{indent}/// {EscapeXmlDocumentation(description ?? title ?? "This type is generated from JSON schema so runtime loading and editor tooling can share the same contract.")}");
}
else
{
builder.AppendLine(
$"{indent}/// Auto-generated nested config type for schema property path '{EscapeXmlDocumentation(objectSpec.DisplayPath)}'.");
builder.AppendLine(
$"{indent}/// {EscapeXmlDocumentation(description ?? title ?? "This nested type is generated so object-valued schema fields remain strongly typed in consumer code.")}");
}
builder.AppendLine($"{indent}/// </summary>");
builder.AppendLine($"{indent}public sealed partial class {objectSpec.ClassName}");
builder.AppendLine($"{indent}{{");
for (var index = 0; index < objectSpec.Properties.Count; index++)
{
var property = objectSpec.Properties[index];
AppendPropertyDocumentation(builder, property, indentationLevel + 1);
var propertyIndent = new string(' ', (indentationLevel + 1) * 4);
builder.Append(
$"{propertyIndent}public {property.TypeSpec.ClrType} {property.PropertyName} {{ get; set; }}");
if (!string.IsNullOrEmpty(property.TypeSpec.Initializer))
{
builder.Append(property.TypeSpec.Initializer);
}
builder.AppendLine();
builder.AppendLine();
}
var nestedTypes = CollectNestedTypes(objectSpec.Properties).ToArray();
for (var index = 0; index < nestedTypes.Length; index++)
{
var nestedType = nestedTypes[index];
AppendObjectType(
builder,
nestedType,
fileName,
nestedType.Title,
nestedType.Description,
isRoot: false,
indentationLevel: indentationLevel + 1);
if (index < nestedTypes.Length - 1)
{
builder.AppendLine();
}
}
builder.AppendLine($"{indent}}}");
}
/// <summary>
/// 枚举一个对象直接拥有的嵌套类型。
/// </summary>
/// <param name="properties">对象属性集合。</param>
/// <returns>嵌套对象类型序列。</returns>
private static IEnumerable<SchemaObjectSpec> CollectNestedTypes(IEnumerable<SchemaPropertySpec> properties)
{
foreach (var property in properties)
{
if (property.TypeSpec.Kind == SchemaNodeKind.Object && property.TypeSpec.NestedObject is not null)
{
yield return property.TypeSpec.NestedObject;
continue;
}
if (property.TypeSpec.Kind == SchemaNodeKind.Array &&
property.TypeSpec.ItemTypeSpec?.Kind == SchemaNodeKind.Object &&
property.TypeSpec.ItemTypeSpec.NestedObject is not null)
{
yield return property.TypeSpec.ItemTypeSpec.NestedObject;
}
}
}
/// <summary>
/// 为生成属性输出 XML 文档。
/// </summary>
/// <param name="builder">输出缓冲区。</param>
/// <param name="property">属性模型。</param>
/// <param name="indentationLevel">缩进层级。</param>
private static void AppendPropertyDocumentation(
StringBuilder builder,
SchemaPropertySpec property,
int indentationLevel)
{
var indent = new string(' ', indentationLevel * 4);
builder.AppendLine($"{indent}/// <summary>");
builder.AppendLine(
$"{indent}/// {EscapeXmlDocumentation(property.Description ?? property.Title ?? $"Gets or sets the value mapped from schema property path '{property.DisplayPath}'.")}");
builder.AppendLine($"{indent}/// </summary>");
builder.AppendLine($"{indent}/// <remarks>");
builder.AppendLine(
$"{indent}/// Schema property path: '{EscapeXmlDocumentation(property.DisplayPath)}'.");
if (!string.IsNullOrWhiteSpace(property.Title))
{
builder.AppendLine(
$"{indent}/// Display title: '{EscapeXmlDocumentation(property.Title!)}'.");
}
if (!string.IsNullOrWhiteSpace(property.TypeSpec.EnumDocumentation))
{
builder.AppendLine(
$"{indent}/// Allowed values: {EscapeXmlDocumentation(property.TypeSpec.EnumDocumentation!)}.");
}
if (!string.IsNullOrWhiteSpace(property.TypeSpec.RefTableName))
{
builder.AppendLine(
$"{indent}/// References config table: '{EscapeXmlDocumentation(property.TypeSpec.RefTableName!)}'.");
}
if (!string.IsNullOrWhiteSpace(property.TypeSpec.Initializer))
{
builder.AppendLine(
$"{indent}/// Generated default initializer: {EscapeXmlDocumentation(property.TypeSpec.Initializer!.Trim())}");
}
builder.AppendLine($"{indent}/// </remarks>");
}
/// <summary> /// <summary>
/// 从 schema 文件路径提取实体基础名。 /// 从 schema 文件路径提取实体基础名。
/// </summary> /// </summary>
/// <param name="path">schema 文件路径。</param> /// <param name="path">Schema 文件路径。</param>
/// <returns>去掉扩展名和 `.schema` 后缀的实体基础名。</returns> /// <returns>去掉扩展名和 <c>.schema</c> 后缀的实体基础名。</returns>
private static string GetSchemaBaseName(string path) private static string GetSchemaBaseName(string path)
{ {
var fileName = Path.GetFileName(path); var fileName = Path.GetFileName(path);
@ -477,6 +802,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 0))); new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 0)));
} }
/// <summary>
/// 读取字符串元数据。
/// </summary>
/// <param name="element">Schema 节点。</param>
/// <param name="propertyName">元数据字段名。</param>
/// <returns>非空字符串值;不存在时返回空。</returns>
private static string? TryGetMetadataString(JsonElement element, string propertyName) private static string? TryGetMetadataString(JsonElement element, string propertyName)
{ {
if (!element.TryGetProperty(propertyName, out var metadataElement) || if (!element.TryGetProperty(propertyName, out var metadataElement) ||
@ -489,6 +820,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return string.IsNullOrWhiteSpace(value) ? null : value; return string.IsNullOrWhiteSpace(value) ? null : value;
} }
/// <summary>
/// 为标量字段构建可直接生成到属性上的默认值初始化器。
/// </summary>
/// <param name="element">Schema 节点。</param>
/// <param name="schemaType">标量类型。</param>
/// <returns>初始化器源码;不兼容时返回空。</returns>
private static string? TryBuildScalarInitializer(JsonElement element, string schemaType) private static string? TryBuildScalarInitializer(JsonElement element, string schemaType)
{ {
if (!element.TryGetProperty("default", out var defaultElement)) if (!element.TryGetProperty("default", out var defaultElement))
@ -511,6 +848,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}; };
} }
/// <summary>
/// 为标量数组构建默认值初始化器。
/// </summary>
/// <param name="element">Schema 节点。</param>
/// <param name="itemType">元素类型。</param>
/// <param name="itemClrType">元素 CLR 类型。</param>
/// <returns>初始化器源码;不兼容时返回空。</returns>
private static string? TryBuildArrayInitializer(JsonElement element, string itemType, string itemClrType) private static string? TryBuildArrayInitializer(JsonElement element, string itemType, string itemClrType)
{ {
if (!element.TryGetProperty("default", out var defaultElement) || if (!element.TryGetProperty("default", out var defaultElement) ||
@ -546,6 +890,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return $" = new {itemClrType}[] {{ {string.Join(", ", items)} }};"; return $" = new {itemClrType}[] {{ {string.Join(", ", items)} }};";
} }
/// <summary>
/// 将 enum 值整理成 XML 文档可读字符串。
/// </summary>
/// <param name="element">Schema 节点。</param>
/// <param name="schemaType">标量类型。</param>
/// <returns>格式化后的枚举说明。</returns>
private static string? TryBuildEnumDocumentation(JsonElement element, string schemaType) private static string? TryBuildEnumDocumentation(JsonElement element, string schemaType)
{ {
if (!element.TryGetProperty("enum", out var enumElement) || if (!element.TryGetProperty("enum", out var enumElement) ||
@ -578,43 +928,22 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return values.Count > 0 ? string.Join(", ", values) : null; return values.Count > 0 ? string.Join(", ", values) : null;
} }
private static void AppendPropertyDocumentation(StringBuilder builder, SchemaPropertySpec property) /// <summary>
/// 组合逻辑字段路径。
/// </summary>
/// <param name="parentPath">父路径。</param>
/// <param name="propertyName">当前属性名。</param>
/// <returns>组合后的路径。</returns>
private static string CombinePath(string parentPath, string propertyName)
{ {
builder.AppendLine(" /// <summary>"); return parentPath == "<root>" ? propertyName : $"{parentPath}.{propertyName}";
builder.AppendLine(
$" /// {EscapeXmlDocumentation(property.Description ?? property.Title ?? $"Gets or sets the value mapped from schema property '{property.SchemaName}'.")}");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <remarks>");
builder.AppendLine(
$" /// Schema property: '{EscapeXmlDocumentation(property.SchemaName)}'.");
if (!string.IsNullOrWhiteSpace(property.Title))
{
builder.AppendLine(
$" /// Display title: '{EscapeXmlDocumentation(property.Title!)}'.");
}
if (!string.IsNullOrWhiteSpace(property.EnumDocumentation))
{
builder.AppendLine(
$" /// Allowed values: {EscapeXmlDocumentation(property.EnumDocumentation!)}.");
}
if (!string.IsNullOrWhiteSpace(property.ReferenceTableName))
{
builder.AppendLine(
$" /// References config table: '{EscapeXmlDocumentation(property.ReferenceTableName!)}'.");
}
if (!string.IsNullOrWhiteSpace(property.Initializer))
{
builder.AppendLine(
$" /// Generated default initializer: {EscapeXmlDocumentation(property.Initializer!.Trim())}");
}
builder.AppendLine(" /// </remarks>");
} }
/// <summary>
/// 转义 XML 文档文本。
/// </summary>
/// <param name="value">原始字符串。</param>
/// <returns>已转义的字符串。</returns>
private static string EscapeXmlDocumentation(string value) private static string EscapeXmlDocumentation(string value)
{ {
return value return value
@ -624,81 +953,148 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
} }
/// <summary> /// <summary>
/// 表示单个 schema 文件的解析结果。 /// 解析结果包装
/// </summary> /// </summary>
/// <param name="Schema">成功解析出的 schema 模型。</param> /// <param name="Schema">解析出的 schema。</param>
/// <param name="Diagnostics">解析阶段产生的诊断。</param> /// <param name="Diagnostics">成过程中收集的诊断。</param>
private sealed record SchemaParseResult( private sealed record SchemaParseResult(
SchemaFileSpec? Schema, SchemaFileSpec? Schema,
ImmutableArray<Diagnostic> Diagnostics) IReadOnlyList<Diagnostic> Diagnostics)
{ {
/// <summary>
/// 从成功解析的 schema 模型创建结果。
/// </summary>
public static SchemaParseResult FromSchema(SchemaFileSpec schema) public static SchemaParseResult FromSchema(SchemaFileSpec schema)
{ {
return new SchemaParseResult(schema, ImmutableArray<Diagnostic>.Empty); return new SchemaParseResult(schema, Array.Empty<Diagnostic>());
} }
/// <summary>
/// 从单个诊断创建结果。
/// </summary>
public static SchemaParseResult FromDiagnostic(Diagnostic diagnostic) public static SchemaParseResult FromDiagnostic(Diagnostic diagnostic)
{ {
return new SchemaParseResult(null, ImmutableArray.Create(diagnostic)); return new SchemaParseResult(null, new[] { diagnostic });
} }
} }
/// <summary> /// <summary>
/// 表示已解析的 schema 文件模型 /// 对象解析结果包装
/// </summary> /// </summary>
/// <param name="Object">解析出的对象类型。</param>
/// <param name="Diagnostic">错误诊断。</param>
private sealed record ParsedObjectResult(
SchemaObjectSpec? Object,
Diagnostic? Diagnostic)
{
public static ParsedObjectResult FromObject(SchemaObjectSpec schemaObject)
{
return new ParsedObjectResult(schemaObject, null);
}
public static ParsedObjectResult FromDiagnostic(Diagnostic diagnostic)
{
return new ParsedObjectResult(null, diagnostic);
}
}
/// <summary>
/// 生成器级 schema 模型。
/// </summary>
/// <param name="FileName">Schema 文件名。</param>
/// <param name="ClassName">根配置类型名。</param>
/// <param name="TableName">配置表包装类型名。</param>
/// <param name="Namespace">目标命名空间。</param>
/// <param name="KeyClrType">主键 CLR 类型。</param>
/// <param name="Title">根标题元数据。</param>
/// <param name="Description">根描述元数据。</param>
/// <param name="RootObject">根对象模型。</param>
private sealed record SchemaFileSpec( private sealed record SchemaFileSpec(
string FileName, string FileName,
string EntityName,
string ClassName, string ClassName,
string TableName, string TableName,
string Namespace, string Namespace,
string KeyClrType, string KeyClrType,
string? Title, string? Title,
string? Description, string? Description,
SchemaObjectSpec RootObject);
/// <summary>
/// 生成器内部的对象类型模型。
/// </summary>
/// <param name="DisplayPath">对象字段路径。</param>
/// <param name="ClassName">要生成的 CLR 类型名。</param>
/// <param name="Title">对象标题元数据。</param>
/// <param name="Description">对象描述元数据。</param>
/// <param name="Properties">对象属性集合。</param>
private sealed record SchemaObjectSpec(
string DisplayPath,
string ClassName,
string? Title,
string? Description,
IReadOnlyList<SchemaPropertySpec> Properties); IReadOnlyList<SchemaPropertySpec> Properties);
/// <summary> /// <summary>
/// 表示已解析的 schema 属性。 /// 单个配置属性模型
/// </summary> /// </summary>
/// <param name="SchemaName">Schema 原始字段名。</param>
/// <param name="DisplayPath">逻辑字段路径。</param>
/// <param name="PropertyName">CLR 属性名。</param>
/// <param name="IsRequired">是否必填。</param>
/// <param name="Title">字段标题元数据。</param>
/// <param name="Description">字段描述元数据。</param>
/// <param name="TypeSpec">字段类型模型。</param>
private sealed record SchemaPropertySpec( private sealed record SchemaPropertySpec(
string SchemaName, string SchemaName,
string DisplayPath,
string PropertyName, string PropertyName,
string SchemaType,
string ClrType,
bool IsRequired, bool IsRequired,
string? Initializer,
string? Title, string? Title,
string? Description, string? Description,
string? EnumDocumentation, SchemaTypeSpec TypeSpec);
string? ReferenceTableName);
/// <summary> /// <summary>
/// 表示单个属性的解析结果。 /// 类型模型,覆盖标量、对象和数组
/// </summary> /// </summary>
/// <param name="Kind">节点种类。</param>
/// <param name="SchemaType">Schema 类型名。</param>
/// <param name="ClrType">CLR 类型名。</param>
/// <param name="Initializer">属性初始化器。</param>
/// <param name="EnumDocumentation">枚举文档说明。</param>
/// <param name="RefTableName">目标引用表名称。</param>
/// <param name="NestedObject">对象节点对应的嵌套类型。</param>
/// <param name="ItemTypeSpec">数组元素类型模型。</param>
private sealed record SchemaTypeSpec(
SchemaNodeKind Kind,
string SchemaType,
string ClrType,
string? Initializer,
string? EnumDocumentation,
string? RefTableName,
SchemaObjectSpec? NestedObject,
SchemaTypeSpec? ItemTypeSpec);
/// <summary>
/// 属性解析结果包装。
/// </summary>
/// <param name="Property">解析出的属性模型。</param>
/// <param name="Diagnostic">错误诊断。</param>
private sealed record ParsedPropertyResult( private sealed record ParsedPropertyResult(
SchemaPropertySpec? Property, SchemaPropertySpec? Property,
Diagnostic? Diagnostic) Diagnostic? Diagnostic)
{ {
/// <summary>
/// 从属性模型创建成功结果。
/// </summary>
public static ParsedPropertyResult FromProperty(SchemaPropertySpec property) public static ParsedPropertyResult FromProperty(SchemaPropertySpec property)
{ {
return new ParsedPropertyResult(property, null); return new ParsedPropertyResult(property, null);
} }
/// <summary>
/// 从诊断创建失败结果。
/// </summary>
public static ParsedPropertyResult FromDiagnostic(Diagnostic diagnostic) public static ParsedPropertyResult FromDiagnostic(Diagnostic diagnostic)
{ {
return new ParsedPropertyResult(null, diagnostic); return new ParsedPropertyResult(null, diagnostic);
} }
} }
/// <summary>
/// 类型节点种类。
/// </summary>
private enum SchemaNodeKind
{
Scalar,
Object,
Array
}
} }

View File

@ -13,7 +13,7 @@
- 一对象一文件的目录组织 - 一对象一文件的目录组织
- 运行时只读查询 - 运行时只读查询
- Source Generator 生成配置类型和表包装 - Source Generator 生成配置类型和表包装
- VS Code 插件提供配置浏览、raw 编辑、schema 打开和轻量校验入口 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
## 推荐目录结构 ## 推荐目录结构
@ -113,6 +113,8 @@ var slime = monsterTable.Get(1);
- 未在 schema 中声明的未知字段 - 未在 schema 中声明的未知字段
- 标量类型不匹配 - 标量类型不匹配
- 数组元素类型不匹配 - 数组元素类型不匹配
- 嵌套对象字段类型不匹配
- 对象数组元素结构不匹配
- 标量 `enum` 不匹配 - 标量 `enum` 不匹配
- 标量数组元素 `enum` 不匹配 - 标量数组元素 `enum` 不匹配
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行 - 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
@ -198,19 +200,22 @@ var hotReload = loader.EnableHotReload(
- 浏览 `config/` 目录 - 浏览 `config/` 目录
- 打开 raw YAML 文件 - 打开 raw YAML 文件
- 打开匹配的 schema 文件 - 打开匹配的 schema 文件
- 对必填字段、未知顶层字段、基础标量类型和标量数组元素做轻量校验 - 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验
- 对顶层标量字段和顶层标量数组提供轻量表单入口 - 对嵌套对象字段、顶层标量字段和顶层标量数组提供轻量表单入口
- 对同一配置域内的多份 YAML 文件执行批量字段更新 - 对同一配置域内的多份 YAML 文件执行批量字段更新
- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据 - 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据
当前批量编辑入口适合对同域文件统一改动顶层标量字段和顶层标量数组;复杂数组、嵌套对象仍建议放在 raw YAML 中完成。 当前表单入口适合编辑嵌套对象中的标量字段和标量数组;对象数组仍建议放在 raw YAML 中完成。
当前批量编辑入口仍刻意限制在“同域文件统一改动顶层标量字段和顶层标量数组”,避免复杂结构批量写回时破坏人工维护的 YAML 排版。
## 当前限制 ## 当前限制
以下能力尚未完全完成: 以下能力尚未完全完成:
- 更完整的 JSON Schema 支持 - 更完整的 JSON Schema 支持
- 更强的 VS Code 嵌套对象与复杂数组编辑器 - VS Code 中对象数组的安全表单编辑器
- 更强的复杂数组与更深 schema 关键字支持
因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。 因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。

View File

@ -7,8 +7,9 @@ Minimal VS Code extension scaffold for the GFramework AI-First config workflow.
- Browse config files from the workspace `config/` directory - Browse config files from the workspace `config/` directory
- Open raw YAML files - Open raw YAML files
- Open matching schema files from `schemas/` - Open matching schema files from `schemas/`
- Run lightweight schema validation for required fields, unknown top-level fields, scalar types, and scalar array items - Run lightweight schema validation for nested required fields, unknown nested fields, scalar types, scalar arrays, and
- Open a lightweight form preview for top-level scalar fields and top-level scalar arrays arrays of objects
- Open a lightweight form preview for nested object fields, top-level scalar fields, and scalar arrays
- Batch edit one config domain across multiple files for top-level scalar and scalar-array fields - Batch edit one config domain across multiple files for top-level scalar and scalar-array fields
- Surface schema metadata such as `title`, `description`, `default`, `enum`, and `x-gframework-ref-table` in the - Surface schema metadata such as `title`, `description`, `default`, `enum`, and `x-gframework-ref-table` in the
lightweight editors lightweight editors
@ -17,13 +18,14 @@ Minimal VS Code extension scaffold for the GFramework AI-First config workflow.
The extension currently validates the repository's minimal config-schema subset: The extension currently validates the repository's minimal config-schema subset:
- required top-level properties - required properties in nested objects
- unknown top-level properties - unknown properties in nested objects
- scalar compatibility for `integer`, `number`, `boolean`, and `string` - scalar compatibility for `integer`, `number`, `boolean`, and `string`
- top-level scalar arrays with scalar item type checks - scalar arrays with scalar item type checks
- arrays of objects whose items use the same supported subset recursively
- scalar `enum` constraints and scalar-array item `enum` constraints - scalar `enum` constraints and scalar-array item `enum` constraints
Nested objects and complex arrays should still be reviewed in raw YAML. Object-array editing should still be reviewed in raw YAML.
## Local Testing ## Local Testing
@ -36,8 +38,8 @@ node --test ./test/*.test.js
- Multi-root workspaces use the first workspace folder - Multi-root workspaces use the first workspace folder
- Validation only covers a minimal subset of JSON Schema - Validation only covers a minimal subset of JSON Schema
- Form and batch editing currently support top-level scalar fields and top-level scalar arrays - Form preview supports nested objects and scalar arrays, but object arrays remain raw-YAML-only for edits
- Nested objects and complex arrays should still be edited in raw YAML - Batch editing remains limited to top-level scalar fields and top-level scalar arrays
## Workspace Settings ## Workspace Settings

File diff suppressed because it is too large Load Diff

View File

@ -252,9 +252,9 @@ async function openSchemaFile(item) {
} }
/** /**
* Open a lightweight form preview for top-level scalar fields and scalar * Open a lightweight form preview for schema-bound config fields.
* arrays. Nested objects and more complex array shapes still use raw YAML as * The preview now walks nested object structures recursively, while complex
* the escape hatch. * object-array editing still falls back to raw YAML for safety.
* *
* @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item. * @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item.
* @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection. * @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection.
@ -284,7 +284,8 @@ async function openFormPreview(item, diagnostics) {
panel.webview.onDidReceiveMessage(async (message) => { panel.webview.onDidReceiveMessage(async (message) => {
if (message.type === "save") { if (message.type === "save") {
const updatedYaml = applyFormUpdates(yamlText, { const latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8");
const updatedYaml = applyFormUpdates(latestYamlText, {
scalars: message.scalars || {}, scalars: message.scalars || {},
arrays: parseArrayFieldPayload(message.arrays || {}) arrays: parseArrayFieldPayload(message.arrays || {})
}); });
@ -544,6 +545,7 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) {
return { return {
exists: true, exists: true,
schemaPath, schemaPath,
type: parsed.type,
required: parsed.required, required: parsed.required,
properties: parsed.properties properties: parsed.properties
}; };
@ -561,86 +563,67 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) {
* Render the form-preview webview HTML. * Render the form-preview webview HTML.
* *
* @param {string} fileName File name. * @param {string} fileName File name.
* @param {{exists: boolean, schemaPath: string, required: string[], properties: Record<string, { * @param {{exists: boolean, schemaPath: string, required: string[], properties: Record<string, unknown>, type?: string}} schemaInfo Schema info.
* type: string, * @param {unknown} parsedYaml Parsed YAML data.
* itemType?: string,
* title?: string,
* description?: string,
* defaultValue?: string,
* enumValues?: string[],
* itemEnumValues?: string[],
* refTable?: string
* }>}} schemaInfo Schema info.
* @param {{entries: Map<string, {kind: string, value?: string, items?: Array<{raw: string, isComplex: boolean}>}>, keys: Set<string>}} parsedYaml Parsed YAML data.
* @returns {string} HTML string. * @returns {string} HTML string.
*/ */
function renderFormHtml(fileName, schemaInfo, parsedYaml) { function renderFormHtml(fileName, schemaInfo, parsedYaml) {
const scalarFields = Array.from(parsedYaml.entries.entries()) const formModel = buildFormModel(schemaInfo, parsedYaml);
.filter(([, entry]) => entry.kind === "scalar") const renderedFields = formModel.fields
.map(([key, entry]) => { .map((field) => {
const propertySchema = schemaInfo.properties[key] || {}; if (field.kind === "section") {
const displayName = propertySchema.title || key; return `
const escapedKey = escapeHtml(key); <div class="section depth-${field.depth}">
const escapedDisplayName = escapeHtml(displayName); <div class="section-title">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</div>
const escapedValue = escapeHtml(unquoteScalar(entry.value || "")); <div class="meta-key">${escapeHtml(field.path)}</div>
const required = schemaInfo.required.includes(key) ? "<span class=\"badge\">required</span>" : ""; ${field.description ? `<span class="hint">${escapeHtml(field.description)}</span>` : ""}
const metadataHint = renderFieldHint(propertySchema, false); </div>
const enumValues = Array.isArray(propertySchema.enumValues) ? propertySchema.enumValues : []; `;
}
if (field.kind === "array") {
const itemType = field.itemType
? `array<${escapeHtml(field.itemType)}>`
: "array";
return `
<label class="field depth-${field.depth}">
<span class="label">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</span>
<span class="meta-key">${escapeHtml(field.path)}</span>
<span class="hint">One item per line. Expected type: ${itemType}</span>
${renderFieldHint(field.schema, true)}
<textarea data-array-path="${escapeHtml(field.path)}" rows="5">${escapeHtml(field.value.join("\n"))}</textarea>
</label>
`;
}
const enumValues = Array.isArray(field.schema.enumValues) ? field.schema.enumValues : [];
const inputControl = enumValues.length > 0 const inputControl = enumValues.length > 0
? ` ? `
<select data-key="${escapedKey}"> <select data-path="${escapeHtml(field.path)}">
${enumValues.map((value) => { ${enumValues.map((value) => {
const escapedOption = escapeHtml(value); const escapedOption = escapeHtml(value);
const selected = value === unquoteScalar(entry.value || "") ? " selected" : ""; const selected = value === field.value ? " selected" : "";
return `<option value="${escapedOption}"${selected}>${escapedOption}</option>`; return `<option value="${escapedOption}"${selected}>${escapedOption}</option>`;
}).join("\n")} }).join("\n")}
</select> </select>
` `
: `<input data-key="${escapedKey}" value="${escapedValue}" />`; : `<input data-path="${escapeHtml(field.path)}" value="${escapeHtml(field.value)}" />`;
return ` return `
<label class="field"> <label class="field depth-${field.depth}">
<span class="label">${escapedDisplayName} ${required}</span> <span class="label">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</span>
<span class="meta-key">${escapedKey}</span> <span class="meta-key">${escapeHtml(field.path)}</span>
${metadataHint} ${renderFieldHint(field.schema, false)}
${inputControl} ${inputControl}
</label> </label>
`; `;
}) })
.join("\n"); .join("\n");
const arrayFields = Array.from(parsedYaml.entries.entries()) const unsupportedFields = formModel.unsupported
.filter(([, entry]) => entry.kind === "array") .map((field) => `
.map(([key, entry]) => {
const propertySchema = schemaInfo.properties[key] || {};
const displayName = propertySchema.title || key;
const escapedKey = escapeHtml(key);
const escapedDisplayName = escapeHtml(displayName);
const escapedValue = escapeHtml((entry.items || [])
.map((item) => unquoteScalar(item.raw))
.join("\n"));
const required = schemaInfo.required.includes(key) ? "<span class=\"badge\">required</span>" : "";
const itemType = propertySchema.itemType
? `array<${escapeHtml(propertySchema.itemType)}>`
: "array";
const metadataHint = renderFieldHint(propertySchema, true);
return `
<label class="field">
<span class="label">${escapedDisplayName} ${required}</span>
<span class="meta-key">${escapedKey}</span>
<span class="hint">One item per line. Expected type: ${itemType}</span>
${metadataHint}
<textarea data-array-key="${escapedKey}" rows="5">${escapedValue}</textarea>
</label>
`;
})
.join("\n");
const unsupportedFields = Array.from(parsedYaml.entries.entries())
.filter(([, entry]) => entry.kind !== "scalar" && entry.kind !== "array")
.map(([key, entry]) => `
<div class="unsupported"> <div class="unsupported">
<strong>${escapeHtml(key)}</strong>: ${escapeHtml(entry.kind)} fields are currently raw-YAML-only. <strong>${escapeHtml(field.path)}</strong>: ${escapeHtml(field.message)}
</div> </div>
`) `)
.join("\n"); .join("\n");
@ -649,13 +632,13 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
? `Schema: ${escapeHtml(schemaInfo.schemaPath)}` ? `Schema: ${escapeHtml(schemaInfo.schemaPath)}`
: `Schema missing: ${escapeHtml(schemaInfo.schemaPath)}`; : `Schema missing: ${escapeHtml(schemaInfo.schemaPath)}`;
const editableContent = [scalarFields, arrayFields].filter((content) => content.length > 0).join("\n"); const editableContent = renderedFields;
const unsupportedSection = unsupportedFields.length > 0 const unsupportedSection = unsupportedFields.length > 0
? `<div class="unsupported-list">${unsupportedFields}</div>` ? `<div class="unsupported-list">${unsupportedFields}</div>`
: ""; : "";
const emptyState = editableContent.length > 0 const emptyState = editableContent.length > 0
? `${editableContent}${unsupportedSection}` ? `${editableContent}${unsupportedSection}`
: "<p>No editable top-level scalar or scalar-array fields were detected. Use raw YAML for nested objects or complex arrays.</p>"; : "<p>No editable schema-bound fields were detected. Use raw YAML for unsupported shapes.</p>";
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -689,6 +672,15 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
display: block; display: block;
margin-bottom: 12px; margin-bottom: 12px;
} }
.section {
margin: 18px 0 8px;
padding-top: 12px;
border-top: 1px solid var(--vscode-panel-border, transparent);
}
.section-title {
font-weight: 700;
margin-bottom: 4px;
}
.meta-key { .meta-key {
display: inline-block; display: inline-block;
margin-bottom: 6px; margin-bottom: 6px;
@ -742,6 +734,15 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
margin-bottom: 10px; margin-bottom: 10px;
color: var(--vscode-descriptionForeground); color: var(--vscode-descriptionForeground);
} }
.depth-1 {
margin-left: 12px;
}
.depth-2 {
margin-left: 24px;
}
.depth-3 {
margin-left: 36px;
}
</style> </style>
</head> </head>
<body> <body>
@ -759,11 +760,11 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
document.getElementById("save").addEventListener("click", () => { document.getElementById("save").addEventListener("click", () => {
const scalars = {}; const scalars = {};
const arrays = {}; const arrays = {};
for (const control of document.querySelectorAll("[data-key]")) { for (const control of document.querySelectorAll("[data-path]")) {
scalars[control.dataset.key] = control.value; scalars[control.dataset.path] = control.value;
} }
for (const textarea of document.querySelectorAll("textarea[data-array-key]")) { for (const textarea of document.querySelectorAll("textarea[data-array-path]")) {
arrays[textarea.dataset.arrayKey] = textarea.value; arrays[textarea.dataset.arrayPath] = textarea.value;
} }
vscode.postMessage({ type: "save", scalars, arrays }); vscode.postMessage({ type: "save", scalars, arrays });
}); });
@ -775,10 +776,145 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
</html>`; </html>`;
} }
/**
* Build a recursive form model from schema and parsed YAML.
*
* @param {{exists: boolean, schemaPath: string, required: string[], properties: Record<string, unknown>, type?: string}} schemaInfo Schema info.
* @param {unknown} parsedYaml Parsed YAML data.
* @returns {{fields: Array<Record<string, unknown>>, unsupported: Array<{path: string, message: string}>}} Form model.
*/
function buildFormModel(schemaInfo, parsedYaml) {
if (!schemaInfo || schemaInfo.type !== "object") {
return {fields: [], unsupported: []};
}
const fields = [];
const unsupported = [];
collectFormFields(schemaInfo, parsedYaml, "", 0, fields, unsupported);
return {fields, unsupported};
}
/**
* Recursively collect form-editable fields.
*
* @param {{type: string, required?: string[], properties?: Record<string, unknown>, title?: string, description?: string}} schemaNode Schema node.
* @param {unknown} yamlNode YAML node.
* @param {string} currentPath Current logical path.
* @param {number} depth Current depth.
* @param {Array<Record<string, unknown>>} fields Field sink.
* @param {Array<{path: string, message: string}>} unsupported Unsupported sink.
*/
function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, unsupported) {
if (!schemaNode || schemaNode.type !== "object") {
return;
}
const yamlMap = getYamlObjectMap(yamlNode);
const requiredSet = new Set(Array.isArray(schemaNode.required) ? schemaNode.required : []);
for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) {
const propertyPath = currentPath ? `${currentPath}.${key}` : key;
const label = propertySchema.title || key;
const propertyValue = yamlMap.get(key);
if (propertySchema.type === "object") {
fields.push({
kind: "section",
path: propertyPath,
label,
description: propertySchema.description,
required: requiredSet.has(key),
depth
});
collectFormFields(propertySchema, propertyValue, propertyPath, depth + 1, fields, unsupported);
continue;
}
if (propertySchema.type === "array" &&
propertySchema.items &&
["string", "integer", "number", "boolean"].includes(propertySchema.items.type)) {
fields.push({
kind: "array",
path: propertyPath,
label,
required: requiredSet.has(key),
depth,
itemType: propertySchema.items.type,
value: getScalarArrayValue(propertyValue),
schema: propertySchema
});
continue;
}
if (["string", "integer", "number", "boolean"].includes(propertySchema.type)) {
fields.push({
kind: "scalar",
path: propertyPath,
label,
required: requiredSet.has(key),
depth,
value: getScalarFieldValue(propertyValue, propertySchema.defaultValue),
schema: propertySchema
});
continue;
}
unsupported.push({
path: propertyPath,
message: propertySchema.type === "array"
? "Object-array fields are currently view-only in the form preview. Use raw YAML for edits."
: `${propertySchema.type} fields are currently raw-YAML-only.`
});
}
}
/**
* Get the mapping lookup for one parsed YAML object node.
*
* @param {unknown} yamlNode YAML node.
* @returns {Map<string, unknown>} Mapping lookup.
*/
function getYamlObjectMap(yamlNode) {
return yamlNode && yamlNode.kind === "object" && yamlNode.map instanceof Map
? yamlNode.map
: new Map();
}
/**
* Extract a scalar field value from a parsed YAML node.
*
* @param {unknown} yamlNode YAML node.
* @param {string | undefined} defaultValue Default value from schema metadata.
* @returns {string} Scalar display value.
*/
function getScalarFieldValue(yamlNode, defaultValue) {
if (yamlNode && yamlNode.kind === "scalar") {
return unquoteScalar(yamlNode.value || "");
}
return defaultValue || "";
}
/**
* Extract a scalar-array value list from a parsed YAML node.
*
* @param {unknown} yamlNode YAML node.
* @returns {string[]} Scalar array value list.
*/
function getScalarArrayValue(yamlNode) {
if (!yamlNode || yamlNode.kind !== "array") {
return [];
}
return yamlNode.items
.filter((item) => item && item.kind === "scalar")
.map((item) => unquoteScalar(item.value || ""));
}
/** /**
* Render human-facing metadata hints for one schema field. * Render human-facing metadata hints for one schema field.
* *
* @param {{description?: string, defaultValue?: string, enumValues?: string[], itemEnumValues?: string[], refTable?: string}} propertySchema Property schema metadata. * @param {{description?: string, defaultValue?: string, enumValues?: string[], items?: {enumValues?: string[]}, refTable?: string}} propertySchema Property schema metadata.
* @param {boolean} isArrayField Whether the field is an array. * @param {boolean} isArrayField Whether the field is an array.
* @returns {string} HTML fragment. * @returns {string} HTML fragment.
*/ */
@ -793,7 +929,11 @@ function renderFieldHint(propertySchema, isArrayField) {
hints.push(`Default: ${escapeHtml(propertySchema.defaultValue)}`); hints.push(`Default: ${escapeHtml(propertySchema.defaultValue)}`);
} }
const enumValues = isArrayField ? propertySchema.itemEnumValues : propertySchema.enumValues; const enumValues = isArrayField
? propertySchema.items && Array.isArray(propertySchema.items.enumValues)
? propertySchema.items.enumValues
: []
: propertySchema.enumValues;
if (Array.isArray(enumValues) && enumValues.length > 0) { if (Array.isArray(enumValues) && enumValues.length > 0) {
hints.push(`Allowed: ${escapeHtml(enumValues.join(", "))}`); hints.push(`Allowed: ${escapeHtml(enumValues.join(", "))}`);
} }

View File

@ -10,11 +10,11 @@ const {
validateParsedConfig validateParsedConfig
} = require("../src/configValidation"); } = require("../src/configValidation");
test("parseSchemaContent should capture scalar and array property metadata", () => { test("parseSchemaContent should capture nested objects and object-array metadata", () => {
const schema = parseSchemaContent(` const schema = parseSchemaContent(`
{ {
"type": "object", "type": "object",
"required": ["id", "name"], "required": ["id", "reward", "phases"],
"properties": { "properties": {
"id": { "id": {
"type": "integer", "type": "integer",
@ -22,169 +22,192 @@ test("parseSchemaContent should capture scalar and array property metadata", ()
"description": "Primary monster key.", "description": "Primary monster key.",
"default": 1 "default": 1
}, },
"name": { "reward": {
"type": "string",
"enum": ["Slime", "Goblin"]
},
"dropRates": {
"type": "array",
"description": "Drop rate list.",
"items": {
"type": "integer",
"enum": [1, 2, 3]
}
}
}
}
`);
assert.deepEqual(schema.required, ["id", "name"]);
assert.deepEqual(schema.properties, {
id: {
type: "integer",
title: "Monster Id",
description: "Primary monster key.",
defaultValue: "1",
enumValues: undefined,
refTable: undefined
},
name: {
type: "string",
title: undefined,
description: undefined,
defaultValue: undefined,
enumValues: ["Slime", "Goblin"],
refTable: undefined
},
dropRates: {
type: "array",
itemType: "integer",
title: undefined,
description: "Drop rate list.",
defaultValue: undefined,
refTable: undefined,
itemEnumValues: ["1", "2", "3"]
}
});
});
test("validateParsedConfig should report missing and unknown properties", () => {
const schema = parseSchemaContent(`
{
"type": "object", "type": "object",
"required": ["id", "name"], "required": ["gold"],
"properties": { "properties": {
"id": { "type": "integer" }, "gold": {
"name": { "type": "string" } "type": "integer",
"default": 10
},
"currency": {
"type": "string",
"enum": ["coin", "gem"]
}
}
},
"phases": {
"type": "array",
"description": "Encounter phases.",
"items": {
"type": "object",
"required": ["wave"],
"properties": {
"wave": { "type": "integer" },
"monsterId": { "type": "string" }
}
}
}
} }
} }
`); `);
assert.equal(schema.type, "object");
assert.deepEqual(schema.required, ["id", "reward", "phases"]);
assert.equal(schema.properties.id.defaultValue, "1");
assert.equal(schema.properties.reward.type, "object");
assert.deepEqual(schema.properties.reward.required, ["gold"]);
assert.equal(schema.properties.reward.properties.currency.enumValues[1], "gem");
assert.equal(schema.properties.phases.type, "array");
assert.equal(schema.properties.phases.items.type, "object");
assert.equal(schema.properties.phases.items.properties.wave.type, "integer");
});
test("parseTopLevelYaml should parse nested mappings and object arrays", () => {
const yaml = parseTopLevelYaml(` const yaml = parseTopLevelYaml(`
id: 1 id: 1
title: Slime reward:
gold: 10
currency: coin
phases:
-
wave: 1
monsterId: slime
`); `);
const diagnostics = validateParsedConfig(schema, yaml); assert.equal(yaml.kind, "object");
assert.equal(yaml.map.get("reward").kind, "object");
assert.equal(diagnostics.length, 2); assert.equal(yaml.map.get("reward").map.get("currency").value, "coin");
assert.equal(diagnostics[0].severity, "error"); assert.equal(yaml.map.get("phases").kind, "array");
assert.match(diagnostics[0].message, /name/u); assert.equal(yaml.map.get("phases").items[0].kind, "object");
assert.equal(diagnostics[1].severity, "error"); assert.equal(yaml.map.get("phases").items[0].map.get("wave").value, "1");
assert.match(diagnostics[1].message, /title/u);
}); });
test("validateParsedConfig should report array item type mismatches", () => { test("validateParsedConfig should report missing and unknown nested properties", () => {
const schema = parseSchemaContent(` const schema = parseSchemaContent(`
{ {
"type": "object", "type": "object",
"required": ["reward"],
"properties": { "properties": {
"dropRates": { "reward": {
"type": "array", "type": "object",
"items": { "type": "integer" } "required": ["gold", "currency"],
} "properties": {
} "gold": { "type": "integer" },
} "currency": { "type": "string" }
`); }
const yaml = parseTopLevelYaml(`
dropRates:
- 1
- potion
`);
const diagnostics = validateParsedConfig(schema, yaml);
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].severity, "error");
assert.match(diagnostics[0].message, /dropRates/u);
});
test("validateParsedConfig should report scalar enum mismatches", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"rarity": {
"type": "string",
"enum": ["common", "rare"]
} }
} }
} }
`); `);
const yaml = parseTopLevelYaml(` const yaml = parseTopLevelYaml(`
reward:
gold: 10
rarity: epic rarity: epic
`); `);
const diagnostics = validateParsedConfig(schema, yaml); const diagnostics = validateParsedConfig(schema, yaml);
assert.equal(diagnostics.length, 1); assert.equal(diagnostics.length, 2);
assert.match(diagnostics[0].message, /common, rare/u); assert.match(diagnostics[0].message, /reward\.currency/u);
assert.match(diagnostics[1].message, /reward\.rarity/u);
}); });
test("validateParsedConfig should report array item enum mismatches", () => { test("validateParsedConfig should report object-array item issues", () => {
const schema = parseSchemaContent(` const schema = parseSchemaContent(`
{ {
"type": "object", "type": "object",
"properties": { "properties": {
"tags": { "phases": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string", "type": "object",
"enum": ["fire", "ice"] "required": ["wave", "monsterId"],
"properties": {
"wave": { "type": "integer" },
"monsterId": { "type": "string" }
}
} }
} }
} }
} }
`); `);
const yaml = parseTopLevelYaml(` const yaml = parseTopLevelYaml(`
tags: phases:
- fire -
- poison wave: 1
hpScale: 1.5
`);
const diagnostics = validateParsedConfig(schema, yaml);
assert.equal(diagnostics.length, 2);
assert.match(diagnostics[0].message, /phases\[0\]\.monsterId/u);
assert.match(diagnostics[1].message, /phases\[0\]\.hpScale/u);
});
test("validateParsedConfig should report deep enum mismatches", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"currency": {
"type": "string",
"enum": ["coin", "gem"]
}
}
}
}
}
`);
const yaml = parseTopLevelYaml(`
reward:
currency: ticket
`); `);
const diagnostics = validateParsedConfig(schema, yaml); const diagnostics = validateParsedConfig(schema, yaml);
assert.equal(diagnostics.length, 1); assert.equal(diagnostics.length, 1);
assert.match(diagnostics[0].message, /fire, ice/u); assert.match(diagnostics[0].message, /coin, gem/u);
}); });
test("parseTopLevelYaml should classify nested mappings as object entries", () => { test("applyFormUpdates should update nested scalar and scalar-array paths", () => {
const yaml = parseTopLevelYaml(` const updated = applyFormUpdates(
reward: [
gold: 10 "id: 1",
name: Slime "reward:",
`); " gold: 10",
"phases:",
assert.equal(yaml.entries.get("reward").kind, "object"); " -",
assert.equal(yaml.entries.get("name").kind, "scalar"); " wave: 1"
].join("\n"),
{
scalars: {
"reward.currency": "coin",
name: "Slime"
},
arrays: {
dropItems: ["potion", "hi potion"]
}
}); });
test("applyScalarUpdates should update top-level scalars and append new keys", () => { assert.match(updated, /^name: Slime$/mu);
assert.match(updated, /^reward:$/mu);
assert.match(updated, /^ currency: coin$/mu);
assert.match(updated, /^dropItems:$/mu);
assert.match(updated, /^ - potion$/mu);
assert.match(updated, /^ - hi potion$/mu);
assert.match(updated, /^phases:$/mu);
});
test("applyScalarUpdates should preserve the scalar-only compatibility wrapper", () => {
const updated = applyScalarUpdates( const updated = applyScalarUpdates(
[ [
"id: 1", "id: 1",
"name: Slime", "name: Slime"
"dropRates:",
" - 1"
].join("\n"), ].join("\n"),
{ {
name: "Goblin", name: "Goblin",
@ -193,38 +216,9 @@ test("applyScalarUpdates should update top-level scalars and append new keys", (
assert.match(updated, /^name: Goblin$/mu); assert.match(updated, /^name: Goblin$/mu);
assert.match(updated, /^hp: 25$/mu); assert.match(updated, /^hp: 25$/mu);
assert.match(updated, /^ - 1$/mu);
}); });
test("applyFormUpdates should replace top-level scalar arrays and preserve unrelated content", () => { test("getEditableSchemaFields should keep batch editing limited to top-level scalar and scalar-array properties", () => {
const updated = applyFormUpdates(
[
"id: 1",
"name: Slime",
"dropItems:",
" - potion",
" - slime_gel",
"reward:",
" gold: 10"
].join("\n"),
{
scalars: {
name: "Goblin"
},
arrays: {
dropItems: ["bomb", "hi potion"]
}
});
assert.match(updated, /^name: Goblin$/mu);
assert.match(updated, /^dropItems:$/mu);
assert.match(updated, /^ - bomb$/mu);
assert.match(updated, /^ - hi potion$/mu);
assert.match(updated, /^reward:$/mu);
assert.match(updated, /^ gold: 10$/mu);
});
test("getEditableSchemaFields should expose only scalar and scalar-array properties", () => {
const schema = parseSchemaContent(` const schema = parseSchemaContent(`
{ {
"type": "object", "type": "object",
@ -236,7 +230,12 @@ test("getEditableSchemaFields should expose only scalar and scalar-array propert
"title": "Monster Name", "title": "Monster Name",
"description": "Display name." "description": "Display name."
}, },
"reward": { "type": "object" }, "reward": {
"type": "object",
"properties": {
"gold": { "type": "integer" }
}
},
"dropItems": { "dropItems": {
"type": "array", "type": "array",
"description": "Drop ids.", "description": "Drop ids.",
@ -247,7 +246,12 @@ test("getEditableSchemaFields should expose only scalar and scalar-array propert
}, },
"waypoints": { "waypoints": {
"type": "array", "type": "array",
"items": { "type": "object" } "items": {
"type": "object",
"properties": {
"x": { "type": "integer" }
}
}
} }
} }
} }
@ -256,6 +260,7 @@ test("getEditableSchemaFields should expose only scalar and scalar-array propert
assert.deepEqual(getEditableSchemaFields(schema), [ assert.deepEqual(getEditableSchemaFields(schema), [
{ {
key: "dropItems", key: "dropItems",
path: "dropItems",
type: "array", type: "array",
itemType: "string", itemType: "string",
title: undefined, title: undefined,
@ -268,6 +273,7 @@ test("getEditableSchemaFields should expose only scalar and scalar-array propert
}, },
{ {
key: "id", key: "id",
path: "id",
type: "integer", type: "integer",
title: undefined, title: undefined,
description: undefined, description: undefined,
@ -279,6 +285,7 @@ test("getEditableSchemaFields should expose only scalar and scalar-array propert
}, },
{ {
key: "name", key: "name",
path: "name",
type: "string", type: "string",
title: "Monster Name", title: "Monster Name",
description: "Display name.", description: "Display name.",
@ -291,11 +298,6 @@ test("getEditableSchemaFields should expose only scalar and scalar-array propert
]); ]);
}); });
test("parseBatchArrayValue should split comma-separated items and drop empty segments", () => { test("parseBatchArrayValue should keep comma-separated batch editing behavior", () => {
assert.deepEqual(parseBatchArrayValue(" potion, hi potion , ,bomb "), [ assert.deepEqual(parseBatchArrayValue(" potion, bomb , ,elixir "), ["potion", "bomb", "elixir"]);
"potion",
"hi potion",
"bomb"
]);
assert.deepEqual(parseBatchArrayValue(""), []);
}); });