feat(game): 添加游戏内容配置系统和YAML配置校验器

- 实现面向静态游戏内容的AI-First配置方案,支持怪物、物品、技能、任务等数据管理
- 集成YAML作为配置源文件格式,JSON Schema作为结构描述标准
- 提供一对象一文件的目录组织结构和运行时只读查询功能
- 实现Source Generator生成配置类型、表包装和注册/访问辅助代码
- 添加VS Code插件支持配置浏览、raw编辑、schema打开和递归校验功能
- 创建YamlConfigSchemaValidator类提供YAML与JSON Schema的运行时校验能力
- 支持嵌套对象、对象数组、标量数组的递归校验和深层约束检查
- 实现跨表引用验证和配置热重载功能
- 提供详细的错误诊断信息和开发期工具链支持
This commit is contained in:
GeWuYou 2026-04-03 16:32:14 +08:00
parent f63714f1e1
commit 0e538738df
12 changed files with 749 additions and 2 deletions

View File

@ -77,6 +77,11 @@ public enum ConfigLoadFailureKind
/// </summary>
EnumValueNotAllowed,
/// <summary>
/// YAML 标量值违反了 schema 声明的最小值、最大值或长度约束。
/// </summary>
ConstraintViolation,
/// <summary>
/// YAML 可被读取,但无法成功反序列化到目标 CLR 类型。
/// </summary>

View File

@ -301,6 +301,104 @@ public class YamlConfigLoaderTests
});
}
/// <summary>
/// 验证数值最小值与最大值约束会在运行时被统一拒绝。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Number_Violates_Minimum_Or_Maximum()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
hp: 101
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "hp"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"hp": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation));
Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("hp"));
Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("101"));
Assert.That(exception.Message, Does.Contain("100"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_String_Violates_MinLength_Or_MaxLength()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Sl
hp: 10
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "hp"],
"properties": {
"id": { "type": "integer" },
"name": {
"type": "string",
"minLength": 3,
"maxLength": 12
},
"hp": { "type": "integer" }
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation));
Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("name"));
Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("Sl"));
Assert.That(exception.Message, Does.Contain("at least 3 characters"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证启用 schema 校验后,未知字段不会再被静默忽略。
/// </summary>

View File

@ -296,6 +296,7 @@ internal static class YamlConfigSchemaValidator
itemNode: null,
referenceTableName: null,
allowedValues: null,
constraints: null,
schemaPath);
}
@ -363,6 +364,7 @@ internal static class YamlConfigSchemaValidator
itemNode,
referenceTableName: null,
allowedValues: null,
constraints: null,
schemaPath);
}
@ -392,6 +394,7 @@ internal static class YamlConfigSchemaValidator
itemNode: null,
referenceTableName,
ParseEnumValues(tableName, schemaPath, propertyPath, element, nodeType, "enum"),
ParseScalarConstraints(tableName, schemaPath, propertyPath, element, nodeType),
schemaPath);
}
@ -674,6 +677,11 @@ internal static class YamlConfigSchemaValidator
detail: $"Allowed values: {string.Join(", ", schemaNode.AllowedValues)}.");
}
if (schemaNode.Constraints is not null)
{
ValidateScalarConstraints(tableName, yamlPath, displayPath, value, normalizedValue, schemaNode);
}
if (schemaNode.ReferenceTableName != null)
{
references.Add(
@ -730,6 +738,246 @@ internal static class YamlConfigSchemaValidator
return allowedValues;
}
/// <summary>
/// 解析标量字段支持的范围与长度约束。
/// 当前共享子集只支持 `integer/number` 上的 `minimum/maximum` 和 `string` 上的 `minLength/maxLength`。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="nodeType">标量类型。</param>
/// <returns>解析后的约束模型;未声明时返回空。</returns>
private static YamlConfigScalarConstraints? ParseScalarConstraints(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
YamlConfigSchemaPropertyType nodeType)
{
var minimum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minimum");
var maximum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maximum");
var minLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minLength");
var maxLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maxLength");
if (minimum.HasValue && maximum.HasValue && minimum.Value > maximum.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minimum' greater than 'maximum'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
if (minLength.HasValue && maxLength.HasValue && minLength.Value > maxLength.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minLength' greater than 'maxLength'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
if (!minimum.HasValue && !maximum.HasValue && !minLength.HasValue && !maxLength.HasValue)
{
return null;
}
return new YamlConfigScalarConstraints(minimum, maximum, minLength, maxLength);
}
/// <summary>
/// 读取数值区间约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="nodeType">字段类型。</param>
/// <param name="keywordName">关键字名称。</param>
/// <returns>数值约束;未声明时返回空。</returns>
private static double? TryParseNumericConstraint(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
YamlConfigSchemaPropertyType nodeType,
string keywordName)
{
if (!element.TryGetProperty(keywordName, out var constraintElement))
{
return null;
}
if (nodeType != YamlConfigSchemaPropertyType.Integer &&
nodeType != YamlConfigSchemaPropertyType.Number)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but only 'integer' and 'number' scalar types support numeric range constraints.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
if (constraintElement.ValueKind != JsonValueKind.Number ||
!constraintElement.TryGetDouble(out var constraintValue) ||
double.IsNaN(constraintValue) ||
double.IsInfinity(constraintValue))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as a finite number.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
return constraintValue;
}
/// <summary>
/// 读取字符串长度约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="nodeType">字段类型。</param>
/// <param name="keywordName">关键字名称。</param>
/// <returns>长度约束;未声明时返回空。</returns>
private static int? TryParseLengthConstraint(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
YamlConfigSchemaPropertyType nodeType,
string keywordName)
{
if (!element.TryGetProperty(keywordName, out var constraintElement))
{
return null;
}
if (nodeType != YamlConfigSchemaPropertyType.String)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but only 'string' scalar types support length constraints.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
if (constraintElement.ValueKind != JsonValueKind.Number ||
!constraintElement.TryGetInt32(out var constraintValue) ||
constraintValue < 0)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as a non-negative integer.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
return constraintValue;
}
/// <summary>
/// 校验标量值是否满足范围与长度约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">字段路径。</param>
/// <param name="rawValue">原始 YAML 标量值。</param>
/// <param name="normalizedValue">归一化后的比较值。</param>
/// <param name="schemaNode">标量 schema 节点。</param>
private static void ValidateScalarConstraints(
string tableName,
string yamlPath,
string displayPath,
string rawValue,
string normalizedValue,
YamlConfigSchemaNode schemaNode)
{
var constraints = schemaNode.Constraints;
if (constraints is null)
{
return;
}
switch (schemaNode.NodeType)
{
case YamlConfigSchemaPropertyType.Integer:
case YamlConfigSchemaPropertyType.Number:
var numericValue = double.Parse(normalizedValue, CultureInfo.InvariantCulture);
if (constraints.Minimum.HasValue && numericValue < constraints.Minimum.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must be greater than or equal to {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail:
$"Minimum allowed value: {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}.");
}
if (constraints.Maximum.HasValue && numericValue > constraints.Maximum.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must be less than or equal to {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail:
$"Maximum allowed value: {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}.");
}
return;
case YamlConfigSchemaPropertyType.String:
var stringLength = rawValue.Length;
if (constraints.MinLength.HasValue && stringLength < constraints.MinLength.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must be at least {constraints.MinLength.Value} characters long, but the current YAML scalar value is '{rawValue}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail: $"Minimum length: {constraints.MinLength.Value}.");
}
if (constraints.MaxLength.HasValue && stringLength > constraints.MaxLength.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must be at most {constraints.MaxLength.Value} characters long, but the current YAML scalar value is '{rawValue}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail: $"Maximum length: {constraints.MaxLength.Value}.");
}
return;
}
}
/// <summary>
/// 解析跨表引用目标表名称。
/// </summary>
@ -1037,6 +1285,7 @@ internal sealed class YamlConfigSchemaNode
/// <param name="itemNode">数组元素节点。</param>
/// <param name="referenceTableName">目标引用表名称。</param>
/// <param name="allowedValues">标量允许值集合。</param>
/// <param name="constraints">标量范围与长度约束。</param>
/// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param>
public YamlConfigSchemaNode(
YamlConfigSchemaPropertyType nodeType,
@ -1045,6 +1294,7 @@ internal sealed class YamlConfigSchemaNode
YamlConfigSchemaNode? itemNode,
string? referenceTableName,
IReadOnlyCollection<string>? allowedValues,
YamlConfigScalarConstraints? constraints,
string schemaPathHint)
{
NodeType = nodeType;
@ -1053,6 +1303,7 @@ internal sealed class YamlConfigSchemaNode
ItemNode = itemNode;
ReferenceTableName = referenceTableName;
AllowedValues = allowedValues;
Constraints = constraints;
SchemaPathHint = schemaPathHint;
}
@ -1086,6 +1337,11 @@ internal sealed class YamlConfigSchemaNode
/// </summary>
public IReadOnlyCollection<string>? AllowedValues { get; }
/// <summary>
/// 获取标量范围与长度约束;未声明时返回空。
/// </summary>
public YamlConfigScalarConstraints? Constraints { get; }
/// <summary>
/// 获取用于诊断显示的 schema 路径提示。
/// 当前节点本身不记录独立路径,因此对象校验会回退到所属根 schema 路径。
@ -1107,10 +1363,57 @@ internal sealed class YamlConfigSchemaNode
ItemNode,
referenceTableName,
AllowedValues,
Constraints,
SchemaPathHint);
}
}
/// <summary>
/// 表示一个标量节点上声明的数值范围或字符串长度约束。
/// 该模型让运行时、热重载和跨文件诊断都能复用同一份最小约束信息。
/// </summary>
internal sealed class YamlConfigScalarConstraints
{
/// <summary>
/// 初始化标量约束模型。
/// </summary>
/// <param name="minimum">最小值约束。</param>
/// <param name="maximum">最大值约束。</param>
/// <param name="minLength">最小长度约束。</param>
/// <param name="maxLength">最大长度约束。</param>
public YamlConfigScalarConstraints(
double? minimum,
double? maximum,
int? minLength,
int? maxLength)
{
Minimum = minimum;
Maximum = maximum;
MinLength = minLength;
MaxLength = maxLength;
}
/// <summary>
/// 获取最小值约束。
/// </summary>
public double? Minimum { get; }
/// <summary>
/// 获取最大值约束。
/// </summary>
public double? Maximum { get; }
/// <summary>
/// 获取最小长度约束。
/// </summary>
public int? MinLength { get; }
/// <summary>
/// 获取最大长度约束。
/// </summary>
public int? MaxLength { get; }
}
/// <summary>
/// 表示单个 YAML 文件中提取出的跨表引用。
/// 该模型保留源文件、字段路径和目标表等诊断信息,以便加载器在批量校验失败时给出可定位的错误。

View File

@ -79,11 +79,15 @@ public class SchemaConfigGeneratorSnapshotTests
"type": "string",
"title": "Monster Name",
"description": "Localized monster display name.",
"minLength": 3,
"maxLength": 16,
"default": "Slime",
"enum": ["Slime", "Goblin"]
},
"hp": {
"type": "integer",
"minimum": 1,
"maximum": 999,
"default": 10
},
"dropItems": {
@ -91,6 +95,8 @@ public class SchemaConfigGeneratorSnapshotTests
"type": "array",
"items": {
"type": "string",
"minLength": 3,
"maxLength": 12,
"enum": ["potion", "slime_gel"]
},
"default": ["potion"],
@ -103,6 +109,7 @@ public class SchemaConfigGeneratorSnapshotTests
"properties": {
"gold": {
"type": "integer",
"minimum": 0,
"default": 10
},
"currency": {
@ -123,7 +130,9 @@ public class SchemaConfigGeneratorSnapshotTests
},
"monsterId": {
"type": "string",
"description": "Monster reference id."
"description": "Monster reference id.",
"minLength": 2,
"maxLength": 32
}
}
}

View File

@ -24,6 +24,7 @@ public sealed partial class MonsterConfig
/// Schema property path: 'name'.
/// Display title: 'Monster Name'.
/// Allowed values: Slime, Goblin.
/// Constraints: minLength = 3, maxLength = 16.
/// Generated default initializer: = "Slime";
/// </remarks>
public string Name { get; set; } = "Slime";
@ -33,6 +34,7 @@ public sealed partial class MonsterConfig
/// </summary>
/// <remarks>
/// Schema property path: 'hp'.
/// Constraints: minimum = 1, maximum = 999.
/// Generated default initializer: = 10;
/// </remarks>
public int? Hp { get; set; } = 10;
@ -44,6 +46,7 @@ public sealed partial class MonsterConfig
/// Schema property path: 'dropItems'.
/// Allowed values: potion, slime_gel.
/// References config table: 'item'.
/// Item constraints: minLength = 3, maxLength = 12.
/// Generated default initializer: = new string[] { "potion" };
/// </remarks>
public global::System.Collections.Generic.IReadOnlyList<string> DropItems { get; set; } = new string[] { "potion" };
@ -77,6 +80,7 @@ public sealed partial class MonsterConfig
/// </summary>
/// <remarks>
/// Schema property path: 'reward.gold'.
/// Constraints: minimum = 0.
/// Generated default initializer: = 10;
/// </remarks>
public int Gold { get; set; } = 10;
@ -112,6 +116,7 @@ public sealed partial class MonsterConfig
/// </summary>
/// <remarks>
/// Schema property path: 'phases[].monsterId'.
/// Constraints: minLength = 2, maxLength = 32.
/// Generated default initializer: = string.Empty;
/// </remarks>
public string MonsterId { get; set; } = string.Empty;

View File

@ -271,6 +271,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired ? "int" : "int?",
TryBuildScalarInitializer(property.Value, "integer"),
TryBuildEnumDocumentation(property.Value, "integer"),
TryBuildConstraintDocumentation(property.Value, "integer"),
refTableName,
null,
null)));
@ -289,6 +290,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired ? "double" : "double?",
TryBuildScalarInitializer(property.Value, "number"),
TryBuildEnumDocumentation(property.Value, "number"),
TryBuildConstraintDocumentation(property.Value, "number"),
refTableName,
null,
null)));
@ -307,6 +309,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired ? "bool" : "bool?",
TryBuildScalarInitializer(property.Value, "boolean"),
TryBuildEnumDocumentation(property.Value, "boolean"),
TryBuildConstraintDocumentation(property.Value, "boolean"),
refTableName,
null,
null)));
@ -326,6 +329,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
TryBuildScalarInitializer(property.Value, "string") ??
(isRequired ? " = string.Empty;" : null),
TryBuildEnumDocumentation(property.Value, "string"),
TryBuildConstraintDocumentation(property.Value, "string"),
refTableName,
null,
null)));
@ -367,6 +371,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired ? " = new();" : null,
null,
null,
null,
objectSpec,
null)));
@ -450,6 +455,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
TryBuildArrayInitializer(property.Value, itemType, itemClrType) ??
$" = global::System.Array.Empty<{itemClrType}>();",
TryBuildEnumDocumentation(itemsElement, itemType),
null,
refTableName,
null,
new SchemaTypeSpec(
@ -458,6 +464,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
itemClrType,
null,
TryBuildEnumDocumentation(itemsElement, itemType),
TryBuildConstraintDocumentation(itemsElement, itemType),
refTableName,
null,
null))));
@ -500,6 +507,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
null,
null,
null,
null,
new SchemaTypeSpec(
SchemaNodeKind.Object,
"object",
@ -507,6 +515,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
null,
null,
null,
null,
objectSpec,
null))));
@ -872,12 +881,26 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
$"{indent}/// Allowed values: {EscapeXmlDocumentation(property.TypeSpec.EnumDocumentation!)}.");
}
if (!string.IsNullOrWhiteSpace(property.TypeSpec.ConstraintDocumentation))
{
builder.AppendLine(
$"{indent}/// Constraints: {EscapeXmlDocumentation(property.TypeSpec.ConstraintDocumentation!)}.");
}
if (!string.IsNullOrWhiteSpace(property.TypeSpec.RefTableName))
{
builder.AppendLine(
$"{indent}/// References config table: '{EscapeXmlDocumentation(property.TypeSpec.RefTableName!)}'.");
}
var itemConstraintDocumentation = property.TypeSpec.ItemTypeSpec?.ConstraintDocumentation;
if (property.TypeSpec.Kind == SchemaNodeKind.Array &&
!string.IsNullOrWhiteSpace(itemConstraintDocumentation))
{
builder.AppendLine(
$"{indent}/// Item constraints: {EscapeXmlDocumentation(itemConstraintDocumentation!)}.");
}
if (!string.IsNullOrWhiteSpace(property.TypeSpec.Initializer))
{
builder.AppendLine(
@ -1084,6 +1107,82 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return values.Count > 0 ? string.Join(", ", values) : null;
}
/// <summary>
/// 将 shared schema 子集中的范围与长度约束整理成 XML 文档可读字符串。
/// </summary>
/// <param name="element">Schema 节点。</param>
/// <param name="schemaType">标量类型。</param>
/// <returns>格式化后的约束说明。</returns>
private static string? TryBuildConstraintDocumentation(JsonElement element, string schemaType)
{
var parts = new List<string>();
if ((schemaType == "integer" || schemaType == "number") &&
TryGetFiniteNumber(element, "minimum", out var minimum))
{
parts.Add($"minimum = {minimum.ToString(CultureInfo.InvariantCulture)}");
}
if ((schemaType == "integer" || schemaType == "number") &&
TryGetFiniteNumber(element, "maximum", out var maximum))
{
parts.Add($"maximum = {maximum.ToString(CultureInfo.InvariantCulture)}");
}
if (schemaType == "string" &&
TryGetNonNegativeInt32(element, "minLength", out var minLength))
{
parts.Add($"minLength = {minLength.ToString(CultureInfo.InvariantCulture)}");
}
if (schemaType == "string" &&
TryGetNonNegativeInt32(element, "maxLength", out var maxLength))
{
parts.Add($"maxLength = {maxLength.ToString(CultureInfo.InvariantCulture)}");
}
return parts.Count > 0 ? string.Join(", ", parts) : null;
}
/// <summary>
/// 读取有限数值元数据。
/// </summary>
/// <param name="element">Schema 节点。</param>
/// <param name="propertyName">元数据名称。</param>
/// <param name="value">读取到的数值。</param>
/// <returns>是否读取成功。</returns>
private static bool TryGetFiniteNumber(
JsonElement element,
string propertyName,
out double value)
{
value = default;
return element.TryGetProperty(propertyName, out var metadataElement) &&
metadataElement.ValueKind == JsonValueKind.Number &&
metadataElement.TryGetDouble(out value) &&
!double.IsNaN(value) &&
!double.IsInfinity(value);
}
/// <summary>
/// 读取非负整数元数据。
/// </summary>
/// <param name="element">Schema 节点。</param>
/// <param name="propertyName">元数据名称。</param>
/// <param name="value">读取到的整数值。</param>
/// <returns>是否读取成功。</returns>
private static bool TryGetNonNegativeInt32(
JsonElement element,
string propertyName,
out int value)
{
value = default;
return element.TryGetProperty(propertyName, out var metadataElement) &&
metadataElement.ValueKind == JsonValueKind.Number &&
metadataElement.TryGetInt32(out value) &&
value >= 0;
}
/// <summary>
/// 组合逻辑字段路径。
/// </summary>
@ -1221,6 +1320,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
/// <param name="ClrType">CLR 类型名。</param>
/// <param name="Initializer">属性初始化器。</param>
/// <param name="EnumDocumentation">枚举文档说明。</param>
/// <param name="ConstraintDocumentation">范围或长度约束说明。</param>
/// <param name="RefTableName">目标引用表名称。</param>
/// <param name="NestedObject">对象节点对应的嵌套类型。</param>
/// <param name="ItemTypeSpec">数组元素类型模型。</param>
@ -1230,6 +1330,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
string ClrType,
string? Initializer,
string? EnumDocumentation,
string? ConstraintDocumentation,
string? RefTableName,
SchemaObjectSpec? NestedObject,
SchemaTypeSpec? ItemTypeSpec);

View File

@ -12,6 +12,7 @@
- JSON Schema 作为结构描述
- 一对象一文件的目录组织
- 运行时只读查询
- Runtime / Generator / Tooling 共享支持 `minimum``maximum``minLength``maxLength`
- Source Generator 生成配置类型、表包装和注册/访问辅助
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
@ -119,6 +120,8 @@ var slime = monsterTable.Get(1);
- 数组元素类型不匹配
- 嵌套对象字段类型不匹配
- 对象数组元素结构不匹配
- 数值字段违反 `minimum` / `maximum`
- 字符串字段违反 `minLength` / `maxLength`
- 标量 `enum` 不匹配
- 标量数组元素 `enum` 不匹配
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
@ -151,6 +154,8 @@ var slime = monsterTable.Get(1);
- `description`:供表单提示、生成代码 XML 文档和接入说明复用
- `default`:供生成类型属性初始值和工具提示复用
- `enum`供运行时校验、VS Code 校验和表单枚举选择复用
- `minimum` / `maximum`供运行时校验、VS Code 校验和生成代码 XML 文档复用
- `minLength` / `maxLength`供运行时校验、VS Code 校验和生成代码 XML 文档复用
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。

View File

@ -359,6 +359,26 @@ function normalizeSchemaEnumValues(value) {
return normalized.length > 0 ? normalized : undefined;
}
/**
* Normalize one finite schema number for tooling metadata and comparisons.
*
* @param {unknown} value Raw schema value.
* @returns {number | undefined} Normalized finite number.
*/
function normalizeSchemaNumber(value) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
/**
* Normalize one non-negative integer schema value for length constraints.
*
* @param {unknown} value Raw schema value.
* @returns {number | undefined} Normalized non-negative integer.
*/
function normalizeSchemaNonNegativeInteger(value) {
return Number.isInteger(value) && value >= 0 ? value : undefined;
}
/**
* Convert a schema default value into a compact string that can be shown in UI
* metadata hints.
@ -437,6 +457,10 @@ function parseSchemaNode(rawNode, displayPath) {
title: typeof value.title === "string" ? value.title : undefined,
description: typeof value.description === "string" ? value.description : undefined,
defaultValue: formatSchemaDefaultValue(value.default),
minimum: normalizeSchemaNumber(value.minimum),
maximum: normalizeSchemaNumber(value.maximum),
minLength: normalizeSchemaNonNegativeInteger(value.minLength),
maxLength: normalizeSchemaNonNegativeInteger(value.maxLength),
refTable: typeof value["x-gframework-ref-table"] === "string"
? value["x-gframework-ref-table"]
: undefined
@ -481,6 +505,10 @@ function parseSchemaNode(rawNode, displayPath) {
title: metadata.title,
description: metadata.description,
defaultValue: metadata.defaultValue,
minimum: metadata.minimum,
maximum: metadata.maximum,
minLength: metadata.minLength,
maxLength: metadata.maxLength,
enumValues: normalizeSchemaEnumValues(value.enum),
refTable: metadata.refTable
};
@ -557,6 +585,47 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
})
});
}
const scalarValue = unquoteScalar(yamlNode.value);
if (typeof schemaNode.minimum === "number" && Number(scalarValue) < schemaNode.minimum) {
diagnostics.push({
severity: "error",
message: localizeValidationMessage(ValidationMessageKeys.minimumViolation, localizer, {
displayPath,
value: String(schemaNode.minimum)
})
});
}
if (typeof schemaNode.maximum === "number" && Number(scalarValue) > schemaNode.maximum) {
diagnostics.push({
severity: "error",
message: localizeValidationMessage(ValidationMessageKeys.maximumViolation, localizer, {
displayPath,
value: String(schemaNode.maximum)
})
});
}
if (typeof schemaNode.minLength === "number" && scalarValue.length < schemaNode.minLength) {
diagnostics.push({
severity: "error",
message: localizeValidationMessage(ValidationMessageKeys.minLengthViolation, localizer, {
displayPath,
value: String(schemaNode.minLength)
})
});
}
if (typeof schemaNode.maxLength === "number" && scalarValue.length > schemaNode.maxLength) {
diagnostics.push({
severity: "error",
message: localizeValidationMessage(ValidationMessageKeys.maxLengthViolation, localizer, {
displayPath,
value: String(schemaNode.maxLength)
})
});
}
}
/**
@ -641,6 +710,14 @@ function localizeValidationMessage(key, localizer, params) {
return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前标量值不兼容。`;
case ValidationMessageKeys.enumMismatch:
return `属性“${params.displayPath}”必须是以下值之一:${params.values}`;
case ValidationMessageKeys.maximumViolation:
return `属性“${params.displayPath}”必须小于或等于 ${params.value}`;
case ValidationMessageKeys.maxLengthViolation:
return `属性“${params.displayPath}”长度必须不超过 ${params.value} 个字符。`;
case ValidationMessageKeys.minimumViolation:
return `属性“${params.displayPath}”必须大于或等于 ${params.value}`;
case ValidationMessageKeys.minLengthViolation:
return `属性“${params.displayPath}”长度必须至少为 ${params.value} 个字符。`;
case ValidationMessageKeys.expectedObject:
return params.subject;
case ValidationMessageKeys.missingRequired:
@ -661,6 +738,14 @@ function localizeValidationMessage(key, localizer, params) {
return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current scalar value is incompatible.`;
case ValidationMessageKeys.enumMismatch:
return `Property '${params.displayPath}' must be one of: ${params.values}.`;
case ValidationMessageKeys.maximumViolation:
return `Property '${params.displayPath}' must be less than or equal to ${params.value}.`;
case ValidationMessageKeys.maxLengthViolation:
return `Property '${params.displayPath}' must be at most ${params.value} characters long.`;
case ValidationMessageKeys.minimumViolation:
return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`;
case ValidationMessageKeys.minLengthViolation:
return `Property '${params.displayPath}' must be at least ${params.value} characters long.`;
case ValidationMessageKeys.expectedObject:
return params.subject;
case ValidationMessageKeys.missingRequired:

View File

@ -1574,7 +1574,7 @@ function getScalarArrayValue(yamlNode) {
/**
* Render human-facing metadata hints for one schema field.
*
* @param {{description?: string, defaultValue?: string, enumValues?: string[], items?: {enumValues?: string[]}, refTable?: string}} propertySchema Property schema metadata.
* @param {{description?: string, defaultValue?: string, minimum?: number, maximum?: number, minLength?: number, maxLength?: number, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, maximum?: number, minLength?: number, maxLength?: number}, refTable?: string}} propertySchema Property schema metadata.
* @param {boolean} isArrayField Whether the field is an array.
* @returns {string} HTML fragment.
*/
@ -1598,6 +1598,38 @@ function renderFieldHint(propertySchema, isArrayField) {
hints.push(escapeHtml(localizer.t("webview.hint.allowed", {values: enumValues.join(", ")})));
}
if (!isArrayField && typeof propertySchema.minimum === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.minimum", {value: propertySchema.minimum})));
}
if (!isArrayField && typeof propertySchema.maximum === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.maximum", {value: propertySchema.maximum})));
}
if (!isArrayField && typeof propertySchema.minLength === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.minLength", {value: propertySchema.minLength})));
}
if (!isArrayField && typeof propertySchema.maxLength === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.maxLength", {value: propertySchema.maxLength})));
}
if (isArrayField && propertySchema.items && typeof propertySchema.items.minimum === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.itemMinimum", {value: propertySchema.items.minimum})));
}
if (isArrayField && propertySchema.items && typeof propertySchema.items.maximum === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.itemMaximum", {value: propertySchema.items.maximum})));
}
if (isArrayField && propertySchema.items && typeof propertySchema.items.minLength === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.itemMinLength", {value: propertySchema.items.minLength})));
}
if (isArrayField && propertySchema.items && typeof propertySchema.items.maxLength === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.itemMaxLength", {value: propertySchema.items.maxLength})));
}
if (propertySchema.refTable) {
hints.push(escapeHtml(localizer.t("webview.hint.refTable", {refTable: propertySchema.refTable})));
}

View File

@ -105,11 +105,23 @@ const enMessages = {
"webview.array.hint": "One item per line. Expected type: {itemType}",
"webview.hint.default": "Default: {value}",
"webview.hint.allowed": "Allowed: {values}",
"webview.hint.minimum": "Minimum: {value}",
"webview.hint.maximum": "Maximum: {value}",
"webview.hint.minLength": "Min length: {value}",
"webview.hint.maxLength": "Max length: {value}",
"webview.hint.itemMinimum": "Item minimum: {value}",
"webview.hint.itemMaximum": "Item maximum: {value}",
"webview.hint.itemMinLength": "Item min length: {value}",
"webview.hint.itemMaxLength": "Item max length: {value}",
"webview.hint.refTable": "Ref table: {refTable}",
"webview.unsupported.array": "Unsupported array shapes are currently raw-YAML-only in the form preview.",
"webview.unsupported.type": "{type} fields are currently raw-YAML-only.",
"webview.unsupported.objectArrayMixed": "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items.",
"webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor.",
[ValidationMessageKeys.maximumViolation]: "Property '{displayPath}' must be less than or equal to {value}.",
[ValidationMessageKeys.maxLengthViolation]: "Property '{displayPath}' must be at most {value} characters long.",
[ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.",
[ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.",
[ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.",
[ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.",
[ValidationMessageKeys.expectedObject]: "{subject} is expected to be an object.",
@ -179,11 +191,23 @@ const zhCnMessages = {
"webview.array.hint": "每行一个元素。期望类型:{itemType}",
"webview.hint.default": "默认值:{value}",
"webview.hint.allowed": "允许值:{values}",
"webview.hint.minimum": "最小值:{value}",
"webview.hint.maximum": "最大值:{value}",
"webview.hint.minLength": "最小长度:{value}",
"webview.hint.maxLength": "最大长度:{value}",
"webview.hint.itemMinimum": "元素最小值:{value}",
"webview.hint.itemMaximum": "元素最大值:{value}",
"webview.hint.itemMinLength": "元素最小长度:{value}",
"webview.hint.itemMaxLength": "元素最大长度:{value}",
"webview.hint.refTable": "引用表:{refTable}",
"webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。",
"webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。",
"webview.unsupported.objectArrayMixed": "对象数组中的每一项都必须是映射对象。如果当前文件混用了标量项和对象项,请改用原始 YAML。",
"webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。",
[ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。",
[ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。",
[ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。",
[ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。",
[ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。",
[ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。",
[ValidationMessageKeys.expectedObject]: "{subject}",

View File

@ -4,6 +4,10 @@ const ValidationMessageKeys = Object.freeze({
expectedObject: "validation.expectedObject",
expectedScalarShape: "validation.expectedScalarShape",
expectedScalarValue: "validation.expectedScalarValue",
maximumViolation: "validation.maximumViolation",
maxLengthViolation: "validation.maxLengthViolation",
minimumViolation: "validation.minimumViolation",
minLengthViolation: "validation.minLengthViolation",
missingRequired: "validation.missingRequired",
unknownProperty: "validation.unknownProperty"
});

View File

@ -190,6 +190,82 @@ reward:
assert.match(diagnostics[0].message, /coin, gem/u);
});
test("validateParsedConfig should report numeric range and string length mismatches", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 3,
"maxLength": 8
},
"hp": {
"type": "integer",
"minimum": 1,
"maximum": 10
},
"tags": {
"type": "array",
"items": {
"type": "string",
"maxLength": 4
}
}
}
}
`);
const yaml = parseTopLevelYaml(`
name: Sl
hp: 12
tags:
- safe
- shield
`);
const diagnostics = validateParsedConfig(schema, yaml);
assert.equal(diagnostics.length, 3);
assert.match(diagnostics[0].message, /at least 3 characters|至少为 3 个字符/u);
assert.match(diagnostics[1].message, /less than or equal to 10|小于或等于 10/u);
assert.match(diagnostics[2].message, /tags\[1\]|shield/u);
});
test("parseSchemaContent should capture scalar range and length metadata", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 3,
"maxLength": 12
},
"hp": {
"type": "integer",
"minimum": 1,
"maximum": 99
},
"tags": {
"type": "array",
"items": {
"type": "string",
"minLength": 2,
"maxLength": 6
}
}
}
}
`);
assert.equal(schema.properties.name.minLength, 3);
assert.equal(schema.properties.name.maxLength, 12);
assert.equal(schema.properties.hp.minimum, 1);
assert.equal(schema.properties.hp.maximum, 99);
assert.equal(schema.properties.tags.items.minLength, 2);
assert.equal(schema.properties.tags.items.maxLength, 6);
});
test("validateParsedConfig should localize diagnostics when Chinese UI is requested", () => {
const schema = parseSchemaContent(`
{