diff --git a/AGENTS.md b/AGENTS.md index 8f4234f..d806c5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,6 +200,17 @@ bash scripts/validate-csharp-naming.sh - The main documentation site lives under `docs/`, with Chinese content under `docs/zh-CN/`. - Keep code samples, package names, and command examples aligned with the current repository state. - Prefer documenting behavior and design intent, not only API surface. +- When a feature is added, removed, renamed, or substantially refactored, contributors MUST update or create the + corresponding user-facing integration documentation in `docs/zh-CN/` in the same change. +- For integration-oriented features such as the AI-First config system, documentation MUST cover: + - project directory layout and file conventions + - required project or package wiring + - minimal working usage example + - migration or compatibility notes when behavior changes +- If an existing documentation page no longer reflects the current implementation, fixing the code without fixing the + documentation is considered incomplete work. +- Do not rely on “the code is self-explanatory” for framework features that consumers need to adopt; write the + adoption path down so future users do not need to rediscover it from source. ### Documentation Preview @@ -218,3 +229,4 @@ Before considering work complete, confirm: - Relevant tests were added or updated - Sensitive or unsafe behavior was not introduced - User-facing documentation is updated when needed +- Feature adoption docs under `docs/zh-CN/` were added or updated when functionality was added, removed, or refactored diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index f551eb6..c4d5e81 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -9,6 +9,8 @@ namespace GFramework.Game.Tests.Config; [TestFixture] public class YamlConfigLoaderTests { + private string _rootPath = null!; + /// /// 为每个测试创建独立临时目录,避免文件系统状态互相污染。 /// @@ -31,8 +33,6 @@ public class YamlConfigLoaderTests } } - private string _rootPath = null!; - /// /// 验证加载器能够扫描 YAML 文件并将结果写入注册表。 /// @@ -155,6 +155,182 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证启用 schema 校验后,缺失必填字段会在反序列化前被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Required_Property_Is_Missing_According_To_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { "type": "integer" } + } + } + """); + + 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("name")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证启用 schema 校验后,类型不匹配的标量字段会被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Property_Type_Does_Not_Match_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: high + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { "type": "integer" } + } + } + """); + + 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("hp")); + Assert.That(exception!.Message, Does.Contain("integer")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证启用 schema 校验后,未知字段不会再被静默忽略。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Unknown_Property_Is_Present_In_Schema_Bound_Mode() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + attackPower: 2 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { "type": "integer" } + } + } + """); + + 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("attackPower")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证数组字段的元素类型会按 schema 校验。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Array_Item_Type_Does_Not_Match_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 1 + - potion + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "items": { "type": "integer" } + } + } + } + """); + + 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("dropRates")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 创建测试用配置文件。 /// @@ -172,6 +348,16 @@ public class YamlConfigLoaderTests File.WriteAllText(fullPath, content); } + /// + /// 创建测试用 schema 文件。 + /// + /// 相对根目录的文件路径。 + /// 文件内容。 + private void CreateSchemaFile(string relativePath, string content) + { + CreateConfigFile(relativePath, content); + } + /// /// 用于 YAML 加载测试的最小怪物配置类型。 /// @@ -193,6 +379,27 @@ public class YamlConfigLoaderTests public int Hp { get; set; } } + /// + /// 用于数组 schema 校验测试的最小怪物配置类型。 + /// + private sealed class MonsterConfigIntegerArrayStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置掉落率列表。 + /// + public IReadOnlyList DropRates { get; set; } = Array.Empty(); + } + /// /// 用于验证注册表一致性的现有配置类型。 /// diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index f26308d..d6d4f59 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -1,7 +1,5 @@ using System.IO; using GFramework.Game.Abstractions.Config; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; namespace GFramework.Game.Config; @@ -16,6 +14,9 @@ public sealed class YamlConfigLoader : IConfigLoader private const string TableNameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace."; private const string RelativePathCannotBeNullOrWhiteSpaceMessage = "Relative path cannot be null or whitespace."; + private const string SchemaRelativePathCannotBeNullOrWhiteSpaceMessage = + "Schema relative path cannot be null or whitespace."; + private readonly IDeserializer _deserializer; private readonly List _registrations = new(); private readonly string _rootPath; @@ -86,6 +87,41 @@ public sealed class YamlConfigLoader : IConfigLoader Func keySelector, IEqualityComparer? comparer = null) where TKey : notnull + { + return RegisterTableCore(tableName, relativePath, null, keySelector, comparer); + } + + /// + /// 注册一个带 schema 校验的 YAML 配置表定义。 + /// 该重载会在 YAML 反序列化之前使用指定 schema 拒绝未知字段、缺失必填字段和基础类型错误, + /// 以避免错误配置以默认值形式悄悄进入运行时。 + /// + /// 配置主键类型。 + /// 配置值类型。 + /// 配置表名称。 + /// 相对配置根目录的子目录。 + /// 相对配置根目录的 schema 文件路径。 + /// 配置项主键提取器。 + /// 可选主键比较器。 + /// 当前加载器实例,以便链式注册。 + public YamlConfigLoader RegisterTable( + string tableName, + string relativePath, + string schemaRelativePath, + Func keySelector, + IEqualityComparer? comparer = null) + where TKey : notnull + { + return RegisterTableCore(tableName, relativePath, schemaRelativePath, keySelector, comparer); + } + + private YamlConfigLoader RegisterTableCore( + string tableName, + string relativePath, + string? schemaRelativePath, + Func keySelector, + IEqualityComparer? comparer) + where TKey : notnull { if (string.IsNullOrWhiteSpace(tableName)) { @@ -99,7 +135,20 @@ public sealed class YamlConfigLoader : IConfigLoader ArgumentNullException.ThrowIfNull(keySelector); - _registrations.Add(new YamlTableRegistration(tableName, relativePath, keySelector, comparer)); + if (schemaRelativePath != null && string.IsNullOrWhiteSpace(schemaRelativePath)) + { + throw new ArgumentException( + SchemaRelativePathCannotBeNullOrWhiteSpaceMessage, + nameof(schemaRelativePath)); + } + + _registrations.Add( + new YamlTableRegistration( + tableName, + relativePath, + schemaRelativePath, + keySelector, + comparer)); return this; } @@ -172,16 +221,19 @@ public sealed class YamlConfigLoader : IConfigLoader /// /// 配置表名称。 /// 相对配置根目录的子目录。 + /// 相对配置根目录的 schema 文件路径;未启用 schema 校验时为空。 /// 配置项主键提取器。 /// 可选主键比较器。 public YamlTableRegistration( string name, string relativePath, + string? schemaRelativePath, Func keySelector, IEqualityComparer? comparer) { Name = name; RelativePath = relativePath; + SchemaRelativePath = schemaRelativePath; _keySelector = keySelector; _comparer = comparer; } @@ -196,6 +248,11 @@ public sealed class YamlConfigLoader : IConfigLoader /// public string RelativePath { get; } + /// + /// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时返回空。 + /// + public string? SchemaRelativePath { get; } + /// public async Task<(string name, IConfigTable table)> LoadAsync( string rootPath, @@ -209,6 +266,13 @@ public sealed class YamlConfigLoader : IConfigLoader $"Config directory '{directoryPath}' was not found for table '{Name}'."); } + YamlConfigSchema? schema = null; + if (!string.IsNullOrEmpty(SchemaRelativePath)) + { + var schemaPath = Path.Combine(rootPath, SchemaRelativePath); + schema = await YamlConfigSchemaValidator.LoadAsync(schemaPath, cancellationToken); + } + var values = new List(); var files = Directory .EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly) @@ -234,6 +298,12 @@ public sealed class YamlConfigLoader : IConfigLoader exception); } + if (schema != null) + { + // 先按 schema 拒绝结构问题,避免被 IgnoreUnmatchedProperties 或默认值掩盖配置错误。 + YamlConfigSchemaValidator.Validate(schema, file, yaml); + } + try { var value = deserializer.Deserialize(yaml); diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs new file mode 100644 index 0000000..3998ec9 --- /dev/null +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -0,0 +1,441 @@ +using System.Globalization; +using System.IO; +using System.Text.Json; + +namespace GFramework.Game.Config; + +/// +/// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。 +/// 该校验器与当前配置生成器支持的 schema 子集保持一致, +/// 以便在配置进入运行时注册表之前就拒绝缺失字段、未知字段和基础类型错误。 +/// +internal static class YamlConfigSchemaValidator +{ + /// + /// 从磁盘加载并解析一个 JSON Schema 文件。 + /// + /// Schema 文件路径。 + /// 取消令牌。 + /// 解析后的 schema 模型。 + /// 为空时抛出。 + /// 当 schema 文件不存在时抛出。 + /// 当 schema 内容不符合当前运行时支持的子集时抛出。 + internal static async Task LoadAsync( + string schemaPath, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(schemaPath)) + { + throw new ArgumentException("Schema path cannot be null or whitespace.", nameof(schemaPath)); + } + + if (!File.Exists(schemaPath)) + { + throw new FileNotFoundException($"Schema file '{schemaPath}' was not found.", schemaPath); + } + + string schemaText; + try + { + schemaText = await File.ReadAllTextAsync(schemaPath, cancellationToken); + } + catch (Exception exception) + { + throw new InvalidOperationException($"Failed to read schema file '{schemaPath}'.", exception); + } + + try + { + using var document = JsonDocument.Parse(schemaText); + var root = document.RootElement; + if (!root.TryGetProperty("type", out var typeElement) || + !string.Equals(typeElement.GetString(), "object", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Schema file '{schemaPath}' must declare a root object schema."); + } + + if (!root.TryGetProperty("properties", out var propertiesElement) || + propertiesElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + $"Schema file '{schemaPath}' must declare an object-valued 'properties' section."); + } + + var requiredProperties = new HashSet(StringComparer.Ordinal); + if (root.TryGetProperty("required", out var requiredElement) && + requiredElement.ValueKind == JsonValueKind.Array) + { + foreach (var item in requiredElement.EnumerateArray()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (item.ValueKind != JsonValueKind.String) + { + continue; + } + + var propertyName = item.GetString(); + if (!string.IsNullOrWhiteSpace(propertyName)) + { + requiredProperties.Add(propertyName); + } + } + } + + var properties = new Dictionary(StringComparer.Ordinal); + foreach (var property in propertiesElement.EnumerateObject()) + { + cancellationToken.ThrowIfCancellationRequested(); + properties.Add(property.Name, ParseProperty(schemaPath, property)); + } + + return new YamlConfigSchema(schemaPath, properties, requiredProperties); + } + catch (JsonException exception) + { + throw new InvalidOperationException($"Schema file '{schemaPath}' contains invalid JSON.", exception); + } + } + + /// + /// 使用已解析的 schema 校验 YAML 文本。 + /// + /// 已解析的 schema 模型。 + /// YAML 文件路径,仅用于诊断信息。 + /// YAML 文本内容。 + /// 当参数为空时抛出。 + /// 当 YAML 内容与 schema 不匹配时抛出。 + internal static void Validate( + YamlConfigSchema schema, + string yamlPath, + string yamlText) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(yamlPath); + ArgumentNullException.ThrowIfNull(yamlText); + + YamlStream yamlStream = new(); + try + { + using var reader = new StringReader(yamlText); + yamlStream.Load(reader); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Config file '{yamlPath}' could not be parsed as YAML before schema validation.", + exception); + } + + if (yamlStream.Documents.Count != 1 || + yamlStream.Documents[0].RootNode is not YamlMappingNode rootMapping) + { + throw new InvalidOperationException( + $"Config file '{yamlPath}' must contain a single root mapping object."); + } + + var seenProperties = new HashSet(StringComparer.Ordinal); + foreach (var entry in rootMapping.Children) + { + if (entry.Key is not YamlScalarNode keyNode || + string.IsNullOrWhiteSpace(keyNode.Value)) + { + throw new InvalidOperationException( + $"Config file '{yamlPath}' contains a non-scalar or empty top-level property name."); + } + + var propertyName = keyNode.Value; + if (!seenProperties.Add(propertyName)) + { + throw new InvalidOperationException( + $"Config file '{yamlPath}' contains duplicate property '{propertyName}'."); + } + + if (!schema.Properties.TryGetValue(propertyName, out var property)) + { + throw new InvalidOperationException( + $"Config file '{yamlPath}' contains unknown property '{propertyName}' that is not declared in schema '{schema.SchemaPath}'."); + } + + ValidateNode(yamlPath, propertyName, entry.Value, property); + } + + foreach (var requiredProperty in schema.RequiredProperties) + { + if (!seenProperties.Contains(requiredProperty)) + { + throw new InvalidOperationException( + $"Config file '{yamlPath}' is missing required property '{requiredProperty}' defined by schema '{schema.SchemaPath}'."); + } + } + } + + private static YamlConfigSchemaProperty ParseProperty(string schemaPath, JsonProperty property) + { + if (!property.Value.TryGetProperty("type", out var typeElement) || + typeElement.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Property '{property.Name}' in schema file '{schemaPath}' must declare a string 'type'."); + } + + var typeName = typeElement.GetString() ?? string.Empty; + var propertyType = typeName switch + { + "integer" => YamlConfigSchemaPropertyType.Integer, + "number" => YamlConfigSchemaPropertyType.Number, + "boolean" => YamlConfigSchemaPropertyType.Boolean, + "string" => YamlConfigSchemaPropertyType.String, + "array" => YamlConfigSchemaPropertyType.Array, + _ => throw new InvalidOperationException( + $"Property '{property.Name}' in schema file '{schemaPath}' uses unsupported type '{typeName}'.") + }; + + if (propertyType != YamlConfigSchemaPropertyType.Array) + { + return new YamlConfigSchemaProperty(property.Name, propertyType, null); + } + + if (!property.Value.TryGetProperty("items", out var itemsElement) || + itemsElement.ValueKind != JsonValueKind.Object || + !itemsElement.TryGetProperty("type", out var itemTypeElement) || + itemTypeElement.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Array property '{property.Name}' in schema file '{schemaPath}' must declare an item type."); + } + + var itemTypeName = itemTypeElement.GetString() ?? string.Empty; + var itemType = itemTypeName switch + { + "integer" => YamlConfigSchemaPropertyType.Integer, + "number" => YamlConfigSchemaPropertyType.Number, + "boolean" => YamlConfigSchemaPropertyType.Boolean, + "string" => YamlConfigSchemaPropertyType.String, + _ => throw new InvalidOperationException( + $"Array property '{property.Name}' in schema file '{schemaPath}' uses unsupported item type '{itemTypeName}'.") + }; + + return new YamlConfigSchemaProperty(property.Name, propertyType, itemType); + } + + private static void ValidateNode( + string yamlPath, + string propertyName, + YamlNode node, + YamlConfigSchemaProperty property) + { + if (property.PropertyType == YamlConfigSchemaPropertyType.Array) + { + if (node is not YamlSequenceNode sequenceNode) + { + throw new InvalidOperationException( + $"Property '{propertyName}' in config file '{yamlPath}' must be an array."); + } + + foreach (var item in sequenceNode.Children) + { + ValidateScalarNode(yamlPath, propertyName, item, property.ItemType!.Value, isArrayItem: true); + } + + return; + } + + ValidateScalarNode(yamlPath, propertyName, node, property.PropertyType, isArrayItem: false); + } + + private static void ValidateScalarNode( + string yamlPath, + string propertyName, + YamlNode node, + YamlConfigSchemaPropertyType expectedType, + bool isArrayItem) + { + if (node is not YamlScalarNode scalarNode) + { + var subject = isArrayItem + ? $"Array item in property '{propertyName}'" + : $"Property '{propertyName}'"; + throw new InvalidOperationException( + $"{subject} in config file '{yamlPath}' must be a scalar value of type '{GetTypeName(expectedType)}'."); + } + + var value = scalarNode.Value; + if (value is null) + { + var subject = isArrayItem + ? $"Array item in property '{propertyName}'" + : $"Property '{propertyName}'"; + throw new InvalidOperationException( + $"{subject} in config file '{yamlPath}' cannot be null when schema type is '{GetTypeName(expectedType)}'."); + } + + var tag = scalarNode.Tag.ToString(); + var isValid = expectedType switch + { + YamlConfigSchemaPropertyType.String => IsStringScalar(tag), + YamlConfigSchemaPropertyType.Integer => long.TryParse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out _), + YamlConfigSchemaPropertyType.Number => double.TryParse( + value, + NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, + out _), + YamlConfigSchemaPropertyType.Boolean => bool.TryParse(value, out _), + _ => false + }; + + if (isValid) + { + return; + } + + var subjectName = isArrayItem + ? $"Array item in property '{propertyName}'" + : $"Property '{propertyName}'"; + throw new InvalidOperationException( + $"{subjectName} in config file '{yamlPath}' must be of type '{GetTypeName(expectedType)}', but the current YAML scalar value is '{value}'."); + } + + private static string GetTypeName(YamlConfigSchemaPropertyType type) + { + return type switch + { + YamlConfigSchemaPropertyType.Integer => "integer", + YamlConfigSchemaPropertyType.Number => "number", + YamlConfigSchemaPropertyType.Boolean => "boolean", + YamlConfigSchemaPropertyType.String => "string", + YamlConfigSchemaPropertyType.Array => "array", + _ => type.ToString() + }; + } + + private static bool IsStringScalar(string tag) + { + if (string.IsNullOrWhiteSpace(tag)) + { + return true; + } + + return !string.Equals(tag, "tag:yaml.org,2002:int", StringComparison.Ordinal) && + !string.Equals(tag, "tag:yaml.org,2002:float", StringComparison.Ordinal) && + !string.Equals(tag, "tag:yaml.org,2002:bool", StringComparison.Ordinal) && + !string.Equals(tag, "tag:yaml.org,2002:null", StringComparison.Ordinal); + } +} + +/// +/// 表示已解析并可用于运行时校验的 JSON Schema。 +/// 该模型只保留当前运行时加载器真正需要的最小信息,以避免在游戏运行时引入完整 schema 引擎。 +/// +internal sealed class YamlConfigSchema +{ + /// + /// 初始化一个可用于运行时校验的 schema 模型。 + /// + /// Schema 文件路径。 + /// Schema 属性定义。 + /// 必填属性集合。 + public YamlConfigSchema( + string schemaPath, + IReadOnlyDictionary properties, + IReadOnlyCollection requiredProperties) + { + ArgumentNullException.ThrowIfNull(schemaPath); + ArgumentNullException.ThrowIfNull(properties); + ArgumentNullException.ThrowIfNull(requiredProperties); + + SchemaPath = schemaPath; + Properties = properties; + RequiredProperties = requiredProperties; + } + + /// + /// 获取 schema 文件路径。 + /// + public string SchemaPath { get; } + + /// + /// 获取按属性名索引的 schema 属性定义。 + /// + public IReadOnlyDictionary Properties { get; } + + /// + /// 获取 schema 声明的必填属性集合。 + /// + public IReadOnlyCollection RequiredProperties { get; } +} + +/// +/// 表示单个 schema 属性的最小运行时描述。 +/// +internal sealed class YamlConfigSchemaProperty +{ + /// + /// 初始化一个 schema 属性描述。 + /// + /// 属性名称。 + /// 属性类型。 + /// 数组元素类型;仅当属性类型为数组时有效。 + public YamlConfigSchemaProperty( + string name, + YamlConfigSchemaPropertyType propertyType, + YamlConfigSchemaPropertyType? itemType) + { + ArgumentNullException.ThrowIfNull(name); + + Name = name; + PropertyType = propertyType; + ItemType = itemType; + } + + /// + /// 获取属性名称。 + /// + public string Name { get; } + + /// + /// 获取属性类型。 + /// + public YamlConfigSchemaPropertyType PropertyType { get; } + + /// + /// 获取数组元素类型;非数组属性时返回空。 + /// + public YamlConfigSchemaPropertyType? ItemType { get; } +} + +/// +/// 表示当前运行时 schema 校验器支持的属性类型。 +/// +internal enum YamlConfigSchemaPropertyType +{ + /// + /// 整数类型。 + /// + Integer, + + /// + /// 数值类型。 + /// + Number, + + /// + /// 布尔类型。 + /// + Boolean, + + /// + /// 字符串类型。 + /// + String, + + /// + /// 数组类型。 + /// + Array +} \ No newline at end of file diff --git a/GFramework.SourceGenerators/GeWuYou.GFramework.SourceGenerators.targets b/GFramework.SourceGenerators/GeWuYou.GFramework.SourceGenerators.targets index de3165c..b66b376 100644 --- a/GFramework.SourceGenerators/GeWuYou.GFramework.SourceGenerators.targets +++ b/GFramework.SourceGenerators/GeWuYou.GFramework.SourceGenerators.targets @@ -3,14 +3,30 @@ + + + schemas + + + + + + + - \ No newline at end of file + diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 51c1d68..5657f12 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -174,6 +174,7 @@ export default defineConfig({ text: 'Game 游戏模块', items: [ { text: '概览', link: '/zh-CN/game/' }, + { text: '内容配置系统', link: '/zh-CN/game/config-system' }, { text: '数据管理', link: '/zh-CN/game/data' }, { text: '场景系统', link: '/zh-CN/game/scene' }, { text: 'UI 系统', link: '/zh-CN/game/ui' }, diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md new file mode 100644 index 0000000..87a0cbe --- /dev/null +++ b/docs/zh-CN/game/config-system.md @@ -0,0 +1,126 @@ +# 游戏内容配置系统 + +> 面向静态游戏内容的 AI-First 配表方案 + +该配置系统用于管理怪物、物品、技能、任务等静态内容数据。 + +它与 `GFramework.Core.Configuration` 不同,后者面向运行时键值配置;它也不同于 `GFramework.Game.Setting`,后者面向玩家设置和持久化。 + +## 当前能力 + +- YAML 作为配置源文件 +- JSON Schema 作为结构描述 +- 一对象一文件的目录组织 +- 运行时只读查询 +- Source Generator 生成配置类型和表包装 +- VS Code 插件提供配置浏览、raw 编辑、schema 打开和轻量校验入口 + +## 推荐目录结构 + +```text +GameProject/ +├─ config/ +│ ├─ monster/ +│ │ ├─ slime.yaml +│ │ └─ goblin.yaml +│ └─ item/ +│ └─ potion.yaml +├─ schemas/ +│ ├─ monster.schema.json +│ └─ item.schema.json +``` + +## Schema 示例 + +```json +{ + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { "type": "integer" }, + "dropItems": { + "type": "array", + "items": { "type": "string" } + } + } +} +``` + +## YAML 示例 + +```yaml +id: 1 +name: Slime +hp: 10 +dropItems: + - potion + - slime_gel +``` + +## 运行时接入 + +当你希望加载后的配置在运行时以只读表形式暴露时,可以使用 `YamlConfigLoader` 和 `ConfigRegistry`: + +```csharp +using GFramework.Game.Config; + +var registry = new ConfigRegistry(); + +var loader = new YamlConfigLoader("config-root") + .RegisterTable( + "monster", + "monster", + "schemas/monster.schema.json", + static config => config.Id); + +await loader.LoadAsync(registry); + +var monsterTable = registry.GetTable("monster"); +var slime = monsterTable.Get(1); +``` + +这个重载会先按 schema 校验,再进行反序列化和注册。 + +## 运行时校验行为 + +绑定 schema 的表在加载时会拒绝以下问题: + +- 缺失必填字段 +- 未在 schema 中声明的未知字段 +- 标量类型不匹配 +- 数组元素类型不匹配 + +这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 + +## 生成器接入约定 + +配置生成器会从 `*.schema.json` 生成配置类型和表包装类。 + +通过已打包的 Source Generator 使用时,默认会自动收集 `schemas/**/*.schema.json` 作为 `AdditionalFiles`。 + +如果你在仓库内直接使用项目引用而不是打包后的 NuGet,请确认 schema 文件同样被加入 `AdditionalFiles`。 + +## VS Code 工具 + +仓库中的 `tools/vscode-config-extension` 当前提供以下能力: + +- 浏览 `config/` 目录 +- 打开 raw YAML 文件 +- 打开匹配的 schema 文件 +- 对必填字段和基础标量类型做轻量校验 +- 对顶层标量字段提供轻量表单入口 + +当前仍建议把复杂数组、嵌套对象和批量修改放在 raw YAML 中完成。 + +## 当前限制 + +以下能力尚未完全完成: + +- 运行时热重载 +- 跨表引用校验 +- 更完整的 JSON Schema 支持 +- 更强的 VS Code 表单编辑器 + +因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。 diff --git a/docs/zh-CN/game/index.md b/docs/zh-CN/game/index.md index 2e1803d..2919e27 100644 --- a/docs/zh-CN/game/index.md +++ b/docs/zh-CN/game/index.md @@ -8,6 +8,7 @@ GFramework.Game 是 GFramework 框架的游戏特定功能模块,提供了游 - [概述](#概述) - [核心特性](#核心特性) +- [内容配置系统](#内容配置系统) - [架构模块系统](#架构模块系统) - [资产管理](#资产管理) - [存储系统](#存储系统) @@ -57,6 +58,24 @@ GFramework.Game 为游戏开发提供了专门的功能模块,与 GFramework.C - **性能优化**:序列化缓存和优化策略 - **类型安全**:强类型的序列化和反序列化 +## 内容配置系统 + +`GFramework.Game` 当前包含面向静态游戏内容的 AI-First 配表能力,用于怪物、物品、技能、任务等只读内容数据。 + +这一能力的核心定位是: + +- 使用 `YAML` 作为配置源文件 +- 使用 `JSON Schema` 描述结构和约束 +- 在运行时以只读配置表形式暴露 +- 通过 Source Generator 生成配置类型和表包装 +- 配套 VS Code 工具提供浏览、校验和轻量编辑入口 + +如果你准备在游戏项目中接入这套系统,请先阅读: + +- [游戏内容配置系统](/zh-CN/game/config-system) + +该页面包含目录约定、运行时注册方式、schema 绑定方式、生成器接入约定和当前限制说明。 + ## 架构模块系统 ### AbstractModule 基础使用 @@ -1395,4 +1414,4 @@ graph TD - **.NET**: 6.0+ - **Newtonsoft.Json**: 13.0.3+ - **GFramework.Core**: 与 Core 模块版本保持同步 ---- \ No newline at end of file +--- diff --git a/docs/zh-CN/source-generators/index.md b/docs/zh-CN/source-generators/index.md index 5c400f7..f5c8fee 100644 --- a/docs/zh-CN/source-generators/index.md +++ b/docs/zh-CN/source-generators/index.md @@ -10,6 +10,7 @@ GFramework.SourceGenerators 是 GFramework 框架的源代码生成器包,通 - [核心特性](#核心特性) - [安装配置](#安装配置) - [Log 属性生成器](#log-属性生成器) +- [Config Schema 生成器](#config-schema-生成器) - [ContextAware 属性生成器](#contextaware-属性生成器) - [GenerateEnumExtensions 属性生成器](#generateenumextensions-属性生成器) - [Priority 属性生成器](#priority-属性生成器) @@ -36,6 +37,7 @@ GFramework.SourceGenerators 利用 Roslyn 源代码生成器技术,在编译 ### 🎯 主要生成器 - **[Log] 属性**:自动生成 ILogger 字段和日志方法 +- **Config Schema 生成器**:根据 `*.schema.json` 生成配置类型和表包装 - **[ContextAware] 属性**:自动实现 IContextAware 接口 - **[GenerateEnumExtensions] 属性**:自动生成枚举扩展方法 - **[Priority] 属性**:自动实现 IPrioritized 接口,为类添加优先级标记 @@ -68,6 +70,45 @@ GFramework.SourceGenerators 利用 Roslyn 源代码生成器技术,在编译 ``` +### Config Schema 文件约定 + +当项目引用 `GeWuYou.GFramework.SourceGenerators` 的打包产物时,生成器会默认从 `schemas/**/*.schema.json` 收集配置 schema +文件并作为 `AdditionalFiles` 输入。 + +这意味着消费者项目通常只需要维护如下结构: + +```text +GameProject/ +├─ config/ +│ └─ monster/ +│ └─ slime.yaml +└─ schemas/ + └─ monster.schema.json +``` + +如果你需要完整接入运行时加载、schema 校验和 VS Code 工具链,请继续阅读: + +- [游戏内容配置系统](/zh-CN/game/config-system) + +## Config Schema 生成器 + +Config Schema 生成器会扫描 `*.schema.json` 文件,并生成: + +- 配置数据类型 +- 与 `IConfigTable` 对齐的表包装类型 + +这一生成器适合与 `GFramework.Game.Config.YamlConfigLoader` 配合使用,让 schema、运行时和工具链共享同一份结构约定。 + +当前支持的 schema 子集以内容配置系统文档中的说明为准,重点覆盖: + +- `object` 根节点 +- `required` +- `integer` +- `number` +- `boolean` +- `string` +- `array` + ### 项目文件配置 ```xml