diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index cf6d68c..ebdea1c 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -239,6 +239,52 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证启用 schema 校验后,标量 enum 限制会在运行时被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Scalar_Value_Is_Not_Declared_In_Schema_Enum() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + rarity: epic + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "rarity"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "rarity": { + "type": "string", + "enum": ["common", "rare"] + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("common")); + Assert.That(exception!.Message, Does.Contain("rare")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证启用 schema 校验后,未知字段不会再被静默忽略。 /// @@ -331,6 +377,57 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证数组元素上的 enum 限制会按 schema 在运行时生效。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Array_Item_Is_Not_Declared_In_Schema_Enum() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + tags: + - fire + - poison + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "tags": { + "type": "array", + "items": { + "type": "string", + "enum": ["fire", "ice"] + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("fire")); + Assert.That(exception!.Message, Does.Contain("ice")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证绑定跨表引用 schema 时,存在的目标行可以通过加载校验。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 3519325..67dbed7 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -236,7 +236,13 @@ internal static class YamlConfigSchemaValidator if (propertyType != YamlConfigSchemaPropertyType.Array) { EnsureReferenceKeywordIsSupported(schemaPath, property.Name, propertyType, null, referenceTableName); - return new YamlConfigSchemaProperty(property.Name, propertyType, null, referenceTableName); + return new YamlConfigSchemaProperty( + property.Name, + propertyType, + null, + referenceTableName, + ParseEnumValues(schemaPath, property.Name, property.Value, propertyType, "enum"), + null); } if (!property.Value.TryGetProperty("items", out var itemsElement) || @@ -260,7 +266,13 @@ internal static class YamlConfigSchemaValidator }; EnsureReferenceKeywordIsSupported(schemaPath, property.Name, propertyType, itemType, referenceTableName); - return new YamlConfigSchemaProperty(property.Name, propertyType, itemType, referenceTableName); + return new YamlConfigSchemaProperty( + property.Name, + propertyType, + itemType, + referenceTableName, + null, + ParseEnumValues(schemaPath, property.Name, itemsElement, itemType, "items.enum")); } private static void EnsureReferenceKeywordIsSupported( @@ -314,6 +326,7 @@ internal static class YamlConfigSchemaValidator sequenceNode.Children[itemIndex], property.ItemType!.Value, property.ReferenceTableName, + property.ItemAllowedValues, references, isArrayItem: true, itemIndex); @@ -328,6 +341,7 @@ internal static class YamlConfigSchemaValidator node, property.PropertyType, property.ReferenceTableName, + property.AllowedValues, references, isArrayItem: false, itemIndex: null); @@ -339,6 +353,7 @@ internal static class YamlConfigSchemaValidator YamlNode node, YamlConfigSchemaPropertyType expectedType, string? referenceTableName, + IReadOnlyCollection? allowedValues, ICollection references, bool isArrayItem, int? itemIndex) @@ -382,6 +397,18 @@ internal static class YamlConfigSchemaValidator if (isValid) { + var normalizedValue = NormalizeScalarValue(expectedType, value); + if (allowedValues != null && + allowedValues.Count > 0 && + !allowedValues.Contains(normalizedValue, StringComparer.Ordinal)) + { + var enumSubject = isArrayItem + ? $"Array item in property '{propertyName}'" + : $"Property '{propertyName}'"; + throw new InvalidOperationException( + $"{enumSubject} in config file '{yamlPath}' must be one of [{string.Join(", ", allowedValues)}], but the current YAML scalar value is '{value}'."); + } + if (referenceTableName != null) { references.Add( @@ -389,7 +416,7 @@ internal static class YamlConfigSchemaValidator yamlPath, propertyName, itemIndex, - value, + normalizedValue, referenceTableName, expectedType)); } @@ -404,6 +431,82 @@ internal static class YamlConfigSchemaValidator $"{subjectName} in config file '{yamlPath}' must be of type '{GetTypeName(expectedType)}', but the current YAML scalar value is '{value}'."); } + private static IReadOnlyCollection? ParseEnumValues( + string schemaPath, + string propertyName, + JsonElement element, + YamlConfigSchemaPropertyType expectedType, + string keywordName) + { + if (!element.TryGetProperty("enum", out var enumElement)) + { + return null; + } + + if (enumElement.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + $"Property '{propertyName}' in schema file '{schemaPath}' must declare '{keywordName}' as an array."); + } + + var allowedValues = new List(); + foreach (var item in enumElement.EnumerateArray()) + { + allowedValues.Add(NormalizeEnumValue(schemaPath, propertyName, keywordName, expectedType, item)); + } + + return allowedValues; + } + + private static string NormalizeEnumValue( + string schemaPath, + string propertyName, + string keywordName, + YamlConfigSchemaPropertyType expectedType, + JsonElement item) + { + try + { + return expectedType switch + { + YamlConfigSchemaPropertyType.String when item.ValueKind == JsonValueKind.String => + item.GetString() ?? string.Empty, + YamlConfigSchemaPropertyType.Integer when item.ValueKind == JsonValueKind.Number => + item.GetInt64().ToString(CultureInfo.InvariantCulture), + YamlConfigSchemaPropertyType.Number when item.ValueKind == JsonValueKind.Number => + item.GetDouble().ToString(CultureInfo.InvariantCulture), + YamlConfigSchemaPropertyType.Boolean when item.ValueKind == JsonValueKind.True => + bool.TrueString.ToLowerInvariant(), + YamlConfigSchemaPropertyType.Boolean when item.ValueKind == JsonValueKind.False => + bool.FalseString.ToLowerInvariant(), + _ => throw new InvalidOperationException() + }; + } + catch + { + throw new InvalidOperationException( + $"Property '{propertyName}' in schema file '{schemaPath}' contains a '{keywordName}' value that is incompatible with schema type '{GetTypeName(expectedType)}'."); + } + } + + private static string NormalizeScalarValue(YamlConfigSchemaPropertyType expectedType, string value) + { + return expectedType switch + { + YamlConfigSchemaPropertyType.String => value, + YamlConfigSchemaPropertyType.Integer => long.Parse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), + YamlConfigSchemaPropertyType.Number => double.Parse( + value, + NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), + YamlConfigSchemaPropertyType.Boolean => bool.Parse(value).ToString().ToLowerInvariant(), + _ => value + }; + } + private static string GetTypeName(YamlConfigSchemaPropertyType type) { return type switch @@ -495,11 +598,15 @@ internal sealed class YamlConfigSchemaProperty /// 属性类型。 /// 数组元素类型;仅当属性类型为数组时有效。 /// 目标引用表名称;未声明跨表引用时为空。 + /// 标量允许值集合;未声明 enum 时为空。 + /// 数组元素允许值集合;未声明 items.enum 时为空。 public YamlConfigSchemaProperty( string name, YamlConfigSchemaPropertyType propertyType, YamlConfigSchemaPropertyType? itemType, - string? referenceTableName) + string? referenceTableName, + IReadOnlyCollection? allowedValues, + IReadOnlyCollection? itemAllowedValues) { ArgumentNullException.ThrowIfNull(name); @@ -507,6 +614,8 @@ internal sealed class YamlConfigSchemaProperty PropertyType = propertyType; ItemType = itemType; ReferenceTableName = referenceTableName; + AllowedValues = allowedValues; + ItemAllowedValues = itemAllowedValues; } /// @@ -528,6 +637,16 @@ internal sealed class YamlConfigSchemaProperty /// 获取目标引用表名称;未声明跨表引用时返回空。 /// public string? ReferenceTableName { get; } + + /// + /// 获取标量允许值集合;未声明 enum 时返回空。 + /// + public IReadOnlyCollection? AllowedValues { get; } + + /// + /// 获取数组元素允许值集合;未声明 items.enum 时返回空。 + /// + public IReadOnlyCollection? ItemAllowedValues { get; } } /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs index 8c5d327..242bfe9 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs @@ -40,15 +40,35 @@ public class SchemaConfigGeneratorSnapshotTests const string schema = """ { + "title": "Monster Config", + "description": "Represents one monster entry generated from schema metadata.", "type": "object", "required": ["id", "name"], "properties": { - "id": { "type": "integer" }, - "name": { "type": "string" }, - "hp": { "type": "integer" }, + "id": { + "type": "integer", + "description": "Unique monster identifier." + }, + "name": { + "type": "string", + "title": "Monster Name", + "description": "Localized monster display name.", + "default": "Slime", + "enum": ["Slime", "Goblin"] + }, + "hp": { + "type": "integer", + "default": 10 + }, "dropItems": { + "description": "Referenced drop ids.", "type": "array", - "items": { "type": "string" } + "items": { + "type": "string", + "enum": ["potion", "slime_gel"] + }, + "default": ["potion"], + "x-gframework-ref-table": "item" } } } diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt index 8d05f36..6b4a36a 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt @@ -5,28 +5,47 @@ namespace GFramework.Game.Config.Generated; /// /// Auto-generated config type for schema file 'monster.schema.json'. -/// This type is generated from JSON schema so runtime loading and editor tooling can share the same contract. +/// Represents one monster entry generated from schema metadata. /// public sealed partial class MonsterConfig { /// - /// Gets or sets the value mapped from schema property 'id'. + /// Unique monster identifier. /// + /// + /// Schema property: 'id'. + /// public int Id { get; set; } /// - /// Gets or sets the value mapped from schema property 'name'. + /// Localized monster display name. /// - public string Name { get; set; } = string.Empty; + /// + /// Schema property: 'name'. + /// Display title: 'Monster Name'. + /// Allowed values: Slime, Goblin. + /// Generated default initializer: = "Slime"; + /// + public string Name { get; set; } = "Slime"; /// /// Gets or sets the value mapped from schema property 'hp'. /// - public int? Hp { get; set; } + /// + /// Schema property: 'hp'. + /// Generated default initializer: = 10; + /// + public int? Hp { get; set; } = 10; /// - /// Gets or sets the value mapped from schema property 'dropItems'. + /// Referenced drop ids. /// - public global::System.Collections.Generic.IReadOnlyList DropItems { get; set; } = global::System.Array.Empty(); + /// + /// Schema property: 'dropItems'. + /// Allowed values: potion, slime_gel. + /// References config table: 'item'. + /// Generated default initializer: = new string[] { "potion" }; + /// + public global::System.Collections.Generic.IReadOnlyList DropItems { get; set; } = new string[] { "potion" }; } diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 4faf0e7..174f0ed 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.IO; using System.Text; using System.Text.Json; @@ -160,6 +161,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator $"{entityName}Table", GeneratedNamespace, idProperty.ClrType, + TryGetMetadataString(root, "title"), + TryGetMetadataString(root, "description"), properties); return SchemaParseResult.FromSchema(schema); @@ -200,6 +203,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } var schemaType = typeElement.GetString() ?? string.Empty; + var title = TryGetMetadataString(property.Value, "title"); + var description = TryGetMetadataString(property.Value, "description"); + var refTableName = TryGetMetadataString(property.Value, "x-gframework-ref-table"); + switch (schemaType) { case "integer": @@ -209,7 +216,11 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator "integer", isRequired ? "int" : "int?", isRequired, - null)); + TryBuildScalarInitializer(property.Value, "integer"), + title, + description, + TryBuildEnumDocumentation(property.Value, "integer"), + refTableName)); case "number": return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( @@ -218,7 +229,11 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator "number", isRequired ? "double" : "double?", isRequired, - null)); + TryBuildScalarInitializer(property.Value, "number"), + title, + description, + TryBuildEnumDocumentation(property.Value, "number"), + refTableName)); case "boolean": return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( @@ -227,7 +242,11 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator "boolean", isRequired ? "bool" : "bool?", isRequired, - null)); + TryBuildScalarInitializer(property.Value, "boolean"), + title, + description, + TryBuildEnumDocumentation(property.Value, "boolean"), + refTableName)); case "string": return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( @@ -236,7 +255,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator "string", isRequired ? "string" : "string?", isRequired, - isRequired ? " = string.Empty;" : null)); + TryBuildScalarInitializer(property.Value, "string") ?? + (isRequired ? " = string.Empty;" : null), + title, + description, + TryBuildEnumDocumentation(property.Value, "string"), + refTableName)); case "array": if (!property.Value.TryGetProperty("items", out var itemsElement) || @@ -279,7 +303,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator "array", $"global::System.Collections.Generic.IReadOnlyList<{itemClrType}>", isRequired, - " = global::System.Array.Empty<" + itemClrType + ">();")); + TryBuildArrayInitializer(property.Value, itemType, itemClrType) ?? + " = global::System.Array.Empty<" + itemClrType + ">();", + title, + description, + TryBuildEnumDocumentation(itemsElement, itemType), + refTableName)); default: return ParsedPropertyResult.FromDiagnostic( @@ -309,17 +338,14 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine( $"/// Auto-generated config type for schema file '{schema.FileName}'."); builder.AppendLine( - "/// This type is generated from JSON schema so runtime loading and editor tooling can share the same contract."); + $"/// {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("/// "); builder.AppendLine($"public sealed partial class {schema.ClassName}"); builder.AppendLine("{"); foreach (var property in schema.Properties) { - builder.AppendLine(" /// "); - builder.AppendLine( - $" /// Gets or sets the value mapped from schema property '{property.SchemaName}'."); - builder.AppendLine(" /// "); + AppendPropertyDocumentation(builder, property); builder.Append($" public {property.ClrType} {property.PropertyName} {{ get; set; }}"); if (!string.IsNullOrEmpty(property.Initializer)) { @@ -451,6 +477,152 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 0))); } + private static string? TryGetMetadataString(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var metadataElement) || + metadataElement.ValueKind != JsonValueKind.String) + { + return null; + } + + var value = metadataElement.GetString(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static string? TryBuildScalarInitializer(JsonElement element, string schemaType) + { + if (!element.TryGetProperty("default", out var defaultElement)) + { + return null; + } + + return schemaType switch + { + "integer" when defaultElement.ValueKind == JsonValueKind.Number && + defaultElement.TryGetInt64(out var intValue) => + $" = {intValue.ToString(CultureInfo.InvariantCulture)};", + "number" when defaultElement.ValueKind == JsonValueKind.Number => + $" = {defaultElement.GetDouble().ToString(CultureInfo.InvariantCulture)};", + "boolean" when defaultElement.ValueKind == JsonValueKind.True => " = true;", + "boolean" when defaultElement.ValueKind == JsonValueKind.False => " = false;", + "string" when defaultElement.ValueKind == JsonValueKind.String => + $" = {SymbolDisplay.FormatLiteral(defaultElement.GetString() ?? string.Empty, true)};", + _ => null + }; + } + + private static string? TryBuildArrayInitializer(JsonElement element, string itemType, string itemClrType) + { + if (!element.TryGetProperty("default", out var defaultElement) || + defaultElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + var items = new List(); + foreach (var item in defaultElement.EnumerateArray()) + { + var literal = itemType switch + { + "integer" when item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out var intValue) => + intValue.ToString(CultureInfo.InvariantCulture), + "number" when item.ValueKind == JsonValueKind.Number => + item.GetDouble().ToString(CultureInfo.InvariantCulture), + "boolean" when item.ValueKind == JsonValueKind.True => "true", + "boolean" when item.ValueKind == JsonValueKind.False => "false", + "string" when item.ValueKind == JsonValueKind.String => + SymbolDisplay.FormatLiteral(item.GetString() ?? string.Empty, true), + _ => string.Empty + }; + + if (string.IsNullOrEmpty(literal)) + { + return null; + } + + items.Add(literal); + } + + return $" = new {itemClrType}[] {{ {string.Join(", ", items)} }};"; + } + + private static string? TryBuildEnumDocumentation(JsonElement element, string schemaType) + { + if (!element.TryGetProperty("enum", out var enumElement) || + enumElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + var values = new List(); + foreach (var item in enumElement.EnumerateArray()) + { + var displayValue = schemaType switch + { + "integer" when item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out var intValue) => + intValue.ToString(CultureInfo.InvariantCulture), + "number" when item.ValueKind == JsonValueKind.Number => + item.GetDouble().ToString(CultureInfo.InvariantCulture), + "boolean" when item.ValueKind == JsonValueKind.True => "true", + "boolean" when item.ValueKind == JsonValueKind.False => "false", + "string" when item.ValueKind == JsonValueKind.String => item.GetString(), + _ => null + }; + + if (!string.IsNullOrWhiteSpace(displayValue)) + { + values.Add(displayValue!); + } + } + + return values.Count > 0 ? string.Join(", ", values) : null; + } + + private static void AppendPropertyDocumentation(StringBuilder builder, SchemaPropertySpec property) + { + builder.AppendLine(" /// "); + builder.AppendLine( + $" /// {EscapeXmlDocumentation(property.Description ?? property.Title ?? $"Gets or sets the value mapped from schema property '{property.SchemaName}'.")}"); + builder.AppendLine(" /// "); + builder.AppendLine(" /// "); + 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(" /// "); + } + + private static string EscapeXmlDocumentation(string value) + { + return value + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">"); + } + /// /// 表示单个 schema 文件的解析结果。 /// @@ -487,6 +659,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator string TableName, string Namespace, string KeyClrType, + string? Title, + string? Description, IReadOnlyList Properties); /// @@ -498,7 +672,11 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator string SchemaType, string ClrType, bool IsRequired, - string? Initializer); + string? Initializer, + string? Title, + string? Description, + string? EnumDocumentation, + string? ReferenceTableName); /// /// 表示单个属性的解析结果。 diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index e0950e1..283d30f 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -34,15 +34,37 @@ GameProject/ ```json { + "title": "Monster Config", + "description": "定义怪物静态配置。", "type": "object", "required": ["id", "name"], "properties": { - "id": { "type": "integer" }, - "name": { "type": "string" }, - "hp": { "type": "integer" }, + "id": { + "type": "integer", + "description": "怪物主键。" + }, + "name": { + "type": "string", + "title": "Monster Name", + "description": "怪物显示名。", + "default": "Slime" + }, + "hp": { + "type": "integer", + "default": 10 + }, + "rarity": { + "type": "string", + "enum": ["common", "rare", "boss"] + }, "dropItems": { "type": "array", - "items": { "type": "string" } + "description": "掉落物品表主键。", + "items": { + "type": "string", + "enum": ["potion", "slime_gel", "bomb"] + }, + "x-gframework-ref-table": "item" } } } @@ -91,6 +113,8 @@ var slime = monsterTable.Get(1); - 未在 schema 中声明的未知字段 - 标量类型不匹配 - 数组元素类型不匹配 +- 标量 `enum` 不匹配 +- 标量数组元素 `enum` 不匹配 - 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行 跨表引用当前使用最小扩展关键字: @@ -115,6 +139,13 @@ var slime = monsterTable.Get(1); - 引用目标表需要由同一个 `YamlConfigLoader` 注册,或已存在于当前 `IConfigRegistry` - 热重载中若目标表变更导致依赖表引用失效,会整体回滚受影响表,避免注册表进入不一致状态 +当前还支持以下“轻量元数据”: + +- `title`:供 VS Code 插件表单和批量编辑入口显示更友好的字段标题 +- `description`:供表单提示、生成代码 XML 文档和接入说明复用 +- `default`:供生成类型属性初始值和工具提示复用 +- `enum`:供运行时校验、VS Code 校验和表单枚举选择复用 + 这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 ## 开发期热重载 @@ -170,6 +201,7 @@ var hotReload = loader.EnableHotReload( - 对必填字段、未知顶层字段、基础标量类型和标量数组元素做轻量校验 - 对顶层标量字段和顶层标量数组提供轻量表单入口 - 对同一配置域内的多份 YAML 文件执行批量字段更新 +- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据 当前批量编辑入口适合对同域文件统一改动顶层标量字段和顶层标量数组;复杂数组、嵌套对象仍建议放在 raw YAML 中完成。 @@ -181,3 +213,21 @@ var hotReload = loader.EnableHotReload( - 更强的 VS Code 嵌套对象与复杂数组编辑器 因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。 + +## 独立 Config Studio 评估 + +当前阶段的结论是:`不建议立即启动独立 Config Studio`,继续以 `VS Code Extension` 作为主工具形态更合适。 + +当前不单独启动桌面版的原因: + +- 当前已落地的能力主要仍围绕 schema 校验、轻量表单、批量编辑和 raw YAML 回退,这些都能在 VS Code 宿主里低成本迭代 +- runtime、generator、tooling 之间仍在持续收敛 schema 子集和元数据语义,过早拆出桌面工具会放大版本协同成本 +- 当前待补强点仍是更完整 schema 支持和复杂编辑体验,先在插件里验证真实工作流更稳妥 +- 仓库当前的主要使用者仍偏开发者和技术策划,独立桌面版带来的“免开发环境”收益还不足以抵消额外维护面 + +只有在以下条件明显成立时,再建议启动独立 `Config Studio`: + +- 主要使用者变成非开发人员,且 VS Code 安装与使用成本成为持续阻力 +- 需要更重的表格视图、跨表可视化关系编辑、复杂审批流或离线发布流程 +- 插件形态已经频繁受限于 VS Code Webview/Extension API,而不是 schema 与工作流本身 +- 已经沉淀出稳定的 schema 元数据约定,能够支撑单独桌面产品的长期维护 diff --git a/tools/vscode-config-extension/README.md b/tools/vscode-config-extension/README.md index eb29ce9..8dc730a 100644 --- a/tools/vscode-config-extension/README.md +++ b/tools/vscode-config-extension/README.md @@ -10,6 +10,8 @@ Minimal VS Code extension scaffold for the GFramework AI-First config workflow. - Run lightweight schema validation for required fields, unknown top-level fields, scalar types, and scalar array items - Open a lightweight form preview for top-level scalar fields and top-level scalar arrays - 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 + lightweight editors ## Validation Coverage @@ -19,6 +21,7 @@ The extension currently validates the repository's minimal config-schema subset: - unknown top-level properties - scalar compatibility for `integer`, `number`, `boolean`, and `string` - top-level scalar arrays with scalar item type checks +- scalar `enum` constraints and scalar-array item `enum` constraints Nested objects and complex arrays should still be reviewed in raw YAML. diff --git a/tools/vscode-config-extension/src/configValidation.js b/tools/vscode-config-extension/src/configValidation.js index 36255b7..4bd6b24 100644 --- a/tools/vscode-config-extension/src/configValidation.js +++ b/tools/vscode-config-extension/src/configValidation.js @@ -4,7 +4,16 @@ * runtime validator and source generator depend on. * * @param {string} content Raw schema JSON text. - * @returns {{required: string[], properties: Record}} Parsed schema info. + * @returns {{required: string[], properties: Record}} Parsed schema info. */ function parseSchemaContent(content) { const parsed = JSON.parse(content); @@ -19,19 +28,39 @@ function parseSchemaContent(content) { continue; } + const metadata = { + title: typeof value.title === "string" ? value.title : undefined, + description: typeof value.description === "string" ? value.description : undefined, + defaultValue: formatSchemaDefaultValue(value.default), + enumValues: normalizeSchemaEnumValues(value.enum), + refTable: typeof value["x-gframework-ref-table"] === "string" + ? value["x-gframework-ref-table"] + : undefined + }; + if (value.type === "array" && value.items && typeof value.items === "object" && typeof value.items.type === "string") { properties[key] = { type: "array", - itemType: value.items.type + itemType: value.items.type, + title: metadata.title, + description: metadata.description, + defaultValue: metadata.defaultValue, + refTable: metadata.refTable, + itemEnumValues: normalizeSchemaEnumValues(value.items.enum) }; continue; } properties[key] = { - type: value.type + type: value.type, + title: metadata.title, + description: metadata.description, + defaultValue: metadata.defaultValue, + enumValues: metadata.enumValues, + refTable: metadata.refTable }; } @@ -47,8 +76,29 @@ function parseSchemaContent(content) { * top-level scalars and scalar arrays are supported, while nested objects and * complex array items remain raw-YAML-only. * - * @param {{required: string[], properties: Record}} schemaInfo Parsed schema info. - * @returns {Array<{key: string, type: string, itemType?: string, inputKind: "scalar" | "array", required: boolean}>} Editable field descriptors. + * @param {{required: string[], properties: Record}} schemaInfo Parsed schema info. + * @returns {Array<{ + * key: string, + * type: string, + * itemType?: string, + * title?: string, + * description?: string, + * defaultValue?: string, + * enumValues?: string[], + * itemEnumValues?: string[], + * refTable?: string, + * inputKind: "scalar" | "array", + * required: boolean + * }>} Editable field descriptors. */ function getEditableSchemaFields(schemaInfo) { const editableFields = []; @@ -59,6 +109,11 @@ function getEditableSchemaFields(schemaInfo) { editableFields.push({ key, type: property.type, + title: property.title, + description: property.description, + defaultValue: property.defaultValue, + enumValues: property.enumValues, + refTable: property.refTable, inputKind: "scalar", required: requiredSet.has(key) }); @@ -70,6 +125,11 @@ function getEditableSchemaFields(schemaInfo) { key, type: property.type, itemType: property.itemType, + title: property.title, + description: property.description, + defaultValue: property.defaultValue, + itemEnumValues: property.itemEnumValues, + refTable: property.refTable, inputKind: "array", required: requiredSet.has(key) }); @@ -169,7 +229,16 @@ function parseTopLevelYaml(text) { /** * Produce extension-facing validation diagnostics from schema and parsed YAML. * - * @param {{required: string[], properties: Record}} schemaInfo Parsed schema info. + * @param {{required: string[], properties: Record}} schemaInfo Parsed schema info. * @param {{entries: Map}>, keys: Set}} parsedYaml Parsed YAML. * @returns {Array<{severity: "error" | "warning", message: string}>} Validation diagnostics. */ @@ -217,6 +286,16 @@ function validateParsedConfig(schemaInfo, parsedYaml) { }); break; } + + if (Array.isArray(propertySchema.itemEnumValues) && + propertySchema.itemEnumValues.length > 0 && + !propertySchema.itemEnumValues.includes(unquoteScalar(item.raw))) { + diagnostics.push({ + severity: "error", + message: `Array item in property '${propertyName}' must be one of: ${propertySchema.itemEnumValues.join(", ")}.` + }); + break; + } } continue; @@ -235,6 +314,16 @@ function validateParsedConfig(schemaInfo, parsedYaml) { severity: "error", message: `Property '${propertyName}' is expected to be '${propertySchema.type}', but the current scalar value is incompatible.` }); + continue; + } + + if (Array.isArray(propertySchema.enumValues) && + propertySchema.enumValues.length > 0 && + !propertySchema.enumValues.includes(unquoteScalar(entry.value || ""))) { + diagnostics.push({ + severity: "error", + message: `Property '${propertyName}' must be one of: ${propertySchema.enumValues.join(", ")}.` + }); } } @@ -373,6 +462,52 @@ function parseBatchArrayValue(value) { .filter((item) => item.length > 0); } +/** + * Normalize a schema enum array into string values that can be shown in UI + * hints and compared against parsed YAML scalar content. + * + * @param {unknown} value Raw schema enum value. + * @returns {string[] | undefined} Normalized enum values. + */ +function normalizeSchemaEnumValues(value) { + if (!Array.isArray(value)) { + return undefined; + } + + const normalized = value + .filter((item) => ["string", "number", "boolean"].includes(typeof item)) + .map((item) => String(item)); + + return normalized.length > 0 ? normalized : undefined; +} + +/** + * Convert a schema default value into a compact string that can be shown in UI + * metadata hints. + * + * @param {unknown} value Raw schema default value. + * @returns {string | undefined} Display string for the default value. + */ +function formatSchemaDefaultValue(value) { + if (value === null || value === undefined) { + return undefined; + } + + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (Array.isArray(value)) { + const normalized = value + .filter((item) => ["string", "number", "boolean"].includes(typeof item)) + .map((item) => String(item)); + + return normalized.length > 0 ? normalized.join(", ") : undefined; + } + + return undefined; +} + /** * Format a scalar value for YAML output. * @@ -522,9 +657,11 @@ module.exports = { formatYamlScalar, getEditableSchemaFields, isScalarCompatible, + normalizeSchemaEnumValues, parseBatchArrayValue, parseSchemaContent, parseTopLevelYaml, unquoteScalar, - validateParsedConfig + validateParsedConfig, + formatSchemaDefaultValue }; diff --git a/tools/vscode-config-extension/src/extension.js b/tools/vscode-config-extension/src/extension.js index 6984048..0217a98 100644 --- a/tools/vscode-config-extension/src/extension.js +++ b/tools/vscode-config-extension/src/extension.js @@ -429,11 +429,15 @@ async function openBatchEdit(item, diagnostics, provider) { const selectedFields = await vscode.window.showQuickPick( editableFields.map((field) => ({ - label: field.key, + label: field.title || field.key, description: field.inputKind === "array" ? `array<${field.itemType}>` : field.type, - detail: field.required ? "required" : undefined, + detail: [ + field.required ? "required" : "", + field.description || "", + field.refTable ? `ref: ${field.refTable}` : "" + ].filter((part) => part.length > 0).join(" · ") || undefined, field })), { @@ -510,7 +514,16 @@ async function openBatchEdit(item, diagnostics, provider) { * * @param {vscode.Uri} configUri Config file URI. * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. - * @returns {Promise<{exists: boolean, schemaPath: string, required: string[], properties: Record}>} Schema info. + * @returns {Promise<{exists: boolean, schemaPath: string, required: string[], properties: Record}>} Schema info. */ async function loadSchemaInfoForConfig(configUri, workspaceRoot) { const schemaUri = getSchemaUriForConfigFile(configUri, workspaceRoot); @@ -548,7 +561,16 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) { * Render the form-preview webview HTML. * * @param {string} fileName File name. - * @param {{exists: boolean, schemaPath: string, required: string[], properties: Record}} schemaInfo Schema info. + * @param {{exists: boolean, schemaPath: string, required: string[], properties: Record}} schemaInfo Schema info. * @param {{entries: Map}>, keys: Set}} parsedYaml Parsed YAML data. * @returns {string} HTML string. */ @@ -556,13 +578,31 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { const scalarFields = Array.from(parsedYaml.entries.entries()) .filter(([, entry]) => entry.kind === "scalar") .map(([key, entry]) => { + const propertySchema = schemaInfo.properties[key] || {}; + const displayName = propertySchema.title || key; const escapedKey = escapeHtml(key); + const escapedDisplayName = escapeHtml(displayName); const escapedValue = escapeHtml(unquoteScalar(entry.value || "")); const required = schemaInfo.required.includes(key) ? "required" : ""; + const metadataHint = renderFieldHint(propertySchema, false); + const enumValues = Array.isArray(propertySchema.enumValues) ? propertySchema.enumValues : []; + const inputControl = enumValues.length > 0 + ? ` + + ` + : ``; return ` `; }) @@ -571,19 +611,25 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { const arrayFields = Array.from(parsedYaml.entries.entries()) .filter(([, entry]) => entry.kind === "array") .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) ? "required" : ""; - const itemType = schemaInfo.properties[key] && schemaInfo.properties[key].itemType - ? `array<${escapeHtml(schemaInfo.properties[key].itemType)}>` + const itemType = propertySchema.itemType + ? `array<${escapeHtml(propertySchema.itemType)}>` : "array"; + const metadataHint = renderFieldHint(propertySchema, true); return ` `; @@ -643,12 +689,18 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { display: block; margin-bottom: 12px; } + .meta-key { + display: inline-block; + margin-bottom: 6px; + color: var(--vscode-descriptionForeground); + font-size: 12px; + } .label { display: block; margin-bottom: 4px; font-weight: 600; } - input { + input, select { width: 100%; padding: 8px; box-sizing: border-box; @@ -707,8 +759,8 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { document.getElementById("save").addEventListener("click", () => { const scalars = {}; const arrays = {}; - for (const input of document.querySelectorAll("input[data-key]")) { - scalars[input.dataset.key] = input.value; + for (const control of document.querySelectorAll("[data-key]")) { + scalars[control.dataset.key] = control.value; } for (const textarea of document.querySelectorAll("textarea[data-array-key]")) { arrays[textarea.dataset.arrayKey] = textarea.value; @@ -723,24 +775,86 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { `; } +/** + * 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 {boolean} isArrayField Whether the field is an array. + * @returns {string} HTML fragment. + */ +function renderFieldHint(propertySchema, isArrayField) { + const hints = []; + + if (propertySchema.description) { + hints.push(escapeHtml(propertySchema.description)); + } + + if (propertySchema.defaultValue) { + hints.push(`Default: ${escapeHtml(propertySchema.defaultValue)}`); + } + + const enumValues = isArrayField ? propertySchema.itemEnumValues : propertySchema.enumValues; + if (Array.isArray(enumValues) && enumValues.length > 0) { + hints.push(`Allowed: ${escapeHtml(enumValues.join(", "))}`); + } + + if (propertySchema.refTable) { + hints.push(`Ref table: ${escapeHtml(propertySchema.refTable)}`); + } + + if (hints.length === 0) { + return ""; + } + + return `${hints.join(" · ")}`; +} + /** * Prompt for one batch-edit field value. * - * @param {{key: string, type: string, itemType?: string, inputKind: "scalar" | "array", required: boolean}} field Editable field descriptor. + * @param {{key: string, type: string, itemType?: string, title?: string, description?: string, defaultValue?: string, enumValues?: string[], itemEnumValues?: string[], refTable?: string, inputKind: "scalar" | "array", required: boolean}} field Editable field descriptor. * @returns {Promise} User input, or undefined when cancelled. */ async function promptBatchFieldValue(field) { if (field.inputKind === "array") { + const hintParts = []; + if (field.itemEnumValues && field.itemEnumValues.length > 0) { + hintParts.push(`Allowed items: ${field.itemEnumValues.join(", ")}`); + } + + if (field.defaultValue) { + hintParts.push(`Default: ${field.defaultValue}`); + } + return vscode.window.showInputBox({ - title: `Batch Edit Array: ${field.key}`, + title: `Batch Edit Array: ${field.title || field.key}`, prompt: `Enter comma-separated items for '${field.key}' (expected array<${field.itemType}>). Leave empty to clear the array.`, + placeHolder: hintParts.join(" | "), ignoreFocusOut: true }); } + if (field.enumValues && field.enumValues.length > 0) { + const picked = await vscode.window.showQuickPick( + field.enumValues.map((value) => ({ + label: value, + description: value === field.defaultValue ? "default" : undefined + })), + { + title: `Batch Edit Field: ${field.title || field.key}`, + placeHolder: `Select a value for '${field.key}'.` + }); + return picked ? picked.label : undefined; + } + return vscode.window.showInputBox({ - title: `Batch Edit Field: ${field.key}`, + title: `Batch Edit Field: ${field.title || field.key}`, prompt: `Enter the new value for '${field.key}' (expected ${field.type}).`, + placeHolder: [ + field.description || "", + field.defaultValue ? `Default: ${field.defaultValue}` : "", + field.refTable ? `Ref table: ${field.refTable}` : "" + ].filter((part) => part.length > 0).join(" | ") || undefined, ignoreFocusOut: true }); } diff --git a/tools/vscode-config-extension/test/configValidation.test.js b/tools/vscode-config-extension/test/configValidation.test.js index 60ce73e..072b8c6 100644 --- a/tools/vscode-config-extension/test/configValidation.test.js +++ b/tools/vscode-config-extension/test/configValidation.test.js @@ -16,11 +16,23 @@ test("parseSchemaContent should capture scalar and array property metadata", () "type": "object", "required": ["id", "name"], "properties": { - "id": { "type": "integer" }, - "name": { "type": "string" }, + "id": { + "type": "integer", + "title": "Monster Id", + "description": "Primary monster key.", + "default": 1 + }, + "name": { + "type": "string", + "enum": ["Slime", "Goblin"] + }, "dropRates": { "type": "array", - "items": { "type": "integer" } + "description": "Drop rate list.", + "items": { + "type": "integer", + "enum": [1, 2, 3] + } } } } @@ -28,9 +40,31 @@ test("parseSchemaContent should capture scalar and array property metadata", () assert.deepEqual(schema.required, ["id", "name"]); assert.deepEqual(schema.properties, { - id: {type: "integer"}, - name: {type: "string"}, - dropRates: {type: "array", itemType: "integer"} + 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"] + } }); }); @@ -84,6 +118,55 @@ dropRates: 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(` +rarity: epic +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /common, rare/u); +}); + +test("validateParsedConfig should report array item enum mismatches", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string", + "enum": ["fire", "ice"] + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +tags: + - fire + - poison +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /fire, ice/u); +}); + test("parseTopLevelYaml should classify nested mappings as object entries", () => { const yaml = parseTopLevelYaml(` reward: @@ -148,11 +231,19 @@ test("getEditableSchemaFields should expose only scalar and scalar-array propert "required": ["id", "dropItems"], "properties": { "id": { "type": "integer" }, - "name": { "type": "string" }, + "name": { + "type": "string", + "title": "Monster Name", + "description": "Display name." + }, "reward": { "type": "object" }, "dropItems": { "type": "array", - "items": { "type": "string" } + "description": "Drop ids.", + "items": { + "type": "string", + "enum": ["potion", "bomb"] + } }, "waypoints": { "type": "array", @@ -163,9 +254,40 @@ test("getEditableSchemaFields should expose only scalar and scalar-array propert `); assert.deepEqual(getEditableSchemaFields(schema), [ - {key: "dropItems", type: "array", itemType: "string", inputKind: "array", required: true}, - {key: "id", type: "integer", inputKind: "scalar", required: true}, - {key: "name", type: "string", inputKind: "scalar", required: false} + { + key: "dropItems", + type: "array", + itemType: "string", + title: undefined, + description: "Drop ids.", + defaultValue: undefined, + itemEnumValues: ["potion", "bomb"], + refTable: undefined, + inputKind: "array", + required: true + }, + { + key: "id", + type: "integer", + title: undefined, + description: undefined, + defaultValue: undefined, + enumValues: undefined, + refTable: undefined, + inputKind: "scalar", + required: true + }, + { + key: "name", + type: "string", + title: "Monster Name", + description: "Display name.", + defaultValue: undefined, + enumValues: undefined, + refTable: undefined, + inputKind: "scalar", + required: false + } ]); });