}} parsedYaml Parsed YAML data.
+ * @returns {string} HTML string.
+ */
+function renderFormHtml(fileName, schemaInfo, parsedYaml) {
+ const fields = Array.from(parsedYaml.scalars.entries())
+ .map(([key, value]) => {
+ const escapedKey = escapeHtml(key);
+ const escapedValue = escapeHtml(unquoteScalar(value));
+ const required = schemaInfo.required.includes(key) ? "required" : "";
+ return `
+
+ `;
+ })
+ .join("\n");
+
+ const schemaStatus = schemaInfo.exists
+ ? `Schema: ${escapeHtml(schemaInfo.schemaPath)}`
+ : `Schema missing: ${escapeHtml(schemaInfo.schemaPath)}`;
+
+ const emptyState = fields.length > 0
+ ? fields
+ : "No editable top-level scalar fields were detected. Use raw YAML for nested objects or arrays.
";
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emptyState}
+
+
+`;
+}
+
+/**
+ * Determine whether a scalar value matches a minimal schema type.
+ *
+ * @param {string} expectedType Schema type.
+ * @param {string} scalarValue YAML scalar value.
+ * @returns {boolean} True when compatible.
+ */
+function isScalarCompatible(expectedType, scalarValue) {
+ const value = unquoteScalar(scalarValue);
+ switch (expectedType) {
+ case "integer":
+ return /^-?\d+$/u.test(value);
+ case "number":
+ return /^-?\d+(?:\.\d+)?$/u.test(value);
+ case "boolean":
+ return /^(true|false)$/iu.test(value);
+ case "string":
+ return true;
+ default:
+ return true;
+ }
+}
+
+/**
+ * Format a scalar value for YAML output.
+ *
+ * @param {string} value Scalar value.
+ * @returns {string} YAML-ready scalar.
+ */
+function formatYamlScalar(value) {
+ if (/^-?\d+(?:\.\d+)?$/u.test(value) || /^(true|false)$/iu.test(value)) {
+ return value;
+ }
+
+ if (value.length === 0 || /[:#\[\]\{\},]|^\s|\s$/u.test(value)) {
+ return JSON.stringify(value);
+ }
+
+ return value;
+}
+
+/**
+ * Remove a simple YAML string quote wrapper.
+ *
+ * @param {string} value Scalar value.
+ * @returns {string} Unquoted value.
+ */
+function unquoteScalar(value) {
+ if ((value.startsWith("\"") && value.endsWith("\"")) ||
+ (value.startsWith("'") && value.endsWith("'"))) {
+ return value.slice(1, -1);
+ }
+
+ return value;
+}
+
+/**
+ * Enumerate all YAML files recursively.
+ *
+ * @param {string} rootPath Root path.
+ * @returns {string[]} YAML file paths.
+ */
+function enumerateYamlFiles(rootPath) {
+ const results = [];
+
+ for (const entry of fs.readdirSync(rootPath, {withFileTypes: true})) {
+ const fullPath = path.join(rootPath, entry.name);
+ if (entry.isDirectory()) {
+ results.push(...enumerateYamlFiles(fullPath));
+ continue;
+ }
+
+ if (entry.isFile() && isYamlPath(entry.name)) {
+ results.push(fullPath);
+ }
+ }
+
+ return results;
+}
+
+/**
+ * Check whether a path is a YAML file.
+ *
+ * @param {string} filePath File path.
+ * @returns {boolean} True for YAML files.
+ */
+function isYamlPath(filePath) {
+ return filePath.endsWith(".yaml") || filePath.endsWith(".yml");
+}
+
+/**
+ * Resolve the first workspace root.
+ *
+ * @returns {vscode.WorkspaceFolder | undefined} Workspace root.
+ */
+function getWorkspaceRoot() {
+ const folders = vscode.workspace.workspaceFolders;
+ return folders && folders.length > 0 ? folders[0] : undefined;
+}
+
+/**
+ * Resolve the configured config root.
+ *
+ * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
+ * @returns {vscode.Uri | undefined} Config root URI.
+ */
+function getConfigRoot(workspaceRoot) {
+ const relativePath = vscode.workspace.getConfiguration("gframeworkConfig")
+ .get("configPath", "config");
+ return vscode.Uri.joinPath(workspaceRoot.uri, relativePath);
+}
+
+/**
+ * Resolve the configured schemas root.
+ *
+ * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
+ * @returns {vscode.Uri | undefined} Schema root URI.
+ */
+function getSchemasRoot(workspaceRoot) {
+ const relativePath = vscode.workspace.getConfiguration("gframeworkConfig")
+ .get("schemasPath", "schemas");
+ return vscode.Uri.joinPath(workspaceRoot.uri, relativePath);
+}
+
+/**
+ * Resolve the matching schema URI for a config file.
+ *
+ * @param {vscode.Uri} configUri Config file URI.
+ * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
+ * @returns {vscode.Uri | undefined} Schema URI.
+ */
+function getSchemaUriForConfigFile(configUri, workspaceRoot) {
+ const configRoot = getConfigRoot(workspaceRoot);
+ const schemaRoot = getSchemasRoot(workspaceRoot);
+ if (!configRoot || !schemaRoot) {
+ return undefined;
+ }
+
+ const relativePath = path.relative(configRoot.fsPath, configUri.fsPath);
+ const segments = relativePath.split(path.sep);
+ if (segments.length === 0 || !segments[0]) {
+ return undefined;
+ }
+
+ return vscode.Uri.joinPath(schemaRoot, `${segments[0]}.schema.json`);
+}
+
+/**
+ * Check whether a URI is inside the configured config root.
+ *
+ * @param {vscode.Uri} uri File URI.
+ * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
+ * @returns {boolean} True when the file belongs to the config tree.
+ */
+function isConfigFile(uri, workspaceRoot) {
+ const configRoot = getConfigRoot(workspaceRoot);
+ if (!configRoot) {
+ return false;
+ }
+
+ const relativePath = path.relative(configRoot.fsPath, uri.fsPath);
+ return !relativePath.startsWith("..") && !path.isAbsolute(relativePath) && isYamlPath(uri.fsPath);
+}
+
+/**
+ * Escape HTML text.
+ *
+ * @param {string} value Raw string.
+ * @returns {string} Escaped string.
+ */
+function escapeHtml(value) {
+ return String(value)
+ .replace(/&/gu, "&")
+ .replace(//gu, ">")
+ .replace(/"/gu, """)
+ .replace(/'/gu, "'");
+}
+
+module.exports = {
+ activate,
+ deactivate
+};
From 91f03754615fcc25d64b515e96b2ad024ceeda64 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Mon, 30 Mar 2026 18:41:52 +0800
Subject: [PATCH 05/14] =?UTF-8?q?refactor(tests):=20=E6=9B=B4=E6=96=B0?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=A9=B1=E5=8A=A8=E7=A8=8B=E5=BA=8F=E4=BE=9D?=
=?UTF-8?q?=E8=B5=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 添加 Microsoft.CodeAnalysis.CSharp 命名空间引用
- 优化代码结构以支持 C# 语法分析功能
- 提升测试驱动程序的编译器集成能力
---
.../Config/SchemaGeneratorTestDriver.cs | 1 +
1 file changed, 1 insertion(+)
diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs b/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs
index 0c33647..4da9519 100644
--- a/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs
+++ b/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs
@@ -1,6 +1,7 @@
using System.Collections.Immutable;
using System.IO;
using GFramework.SourceGenerators.Config;
+using Microsoft.CodeAnalysis.CSharp;
namespace GFramework.SourceGenerators.Tests.Config;
From b87e51133466f763295883a147422c824266cd4d Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Tue, 31 Mar 2026 22:30:33 +0800
Subject: [PATCH 06/14] =?UTF-8?q?docs(game):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?=
=?UTF-8?q?=E6=88=8F=E6=A8=A1=E5=9D=97=E5=AE=8C=E6=95=B4=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 创建了 GFramework.Game 模块的全面文档
- 包含架构模块系统、资产管理、存储系统和序列化系统详解
- 提供了 AbstractModule、AbstractAssetCatalogUtility 等核心组件使用示例
- 添加了分层存储、加密存储和缓存存储的实现方案
- 集成了 JSON 序列化、自定义转换器和版本化数据管理
- 提供了完整的游戏数据管理系统和自动保存系统实现
- 修改了 VitePress 配置文件
---
AGENTS.md | 12 +
.../Config/YamlConfigLoaderTests.cs | 211 ++++++++-
GFramework.Game/Config/YamlConfigLoader.cs | 76 ++-
.../Config/YamlConfigSchemaValidator.cs | 441 ++++++++++++++++++
...eWuYou.GFramework.SourceGenerators.targets | 18 +-
docs/.vitepress/config.mts | 1 +
docs/zh-CN/game/config-system.md | 126 +++++
docs/zh-CN/game/index.md | 21 +-
docs/zh-CN/source-generators/index.md | 41 ++
9 files changed, 940 insertions(+), 7 deletions(-)
create mode 100644 GFramework.Game/Config/YamlConfigSchemaValidator.cs
create mode 100644 docs/zh-CN/game/config-system.md
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
From ae9693e0ff03a31fb702f0e69ab61377cb5531d1 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Tue, 31 Mar 2026 22:32:57 +0800
Subject: [PATCH 07/14] =?UTF-8?q?refactor(config):=20=E6=9B=B4=E6=96=B0?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E4=BE=9D=E8=B5=96?=
=?UTF-8?q?=E9=A1=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在YamlConfigLoader中添加YamlDotNet序列化库引用
- 移除YamlConfigSchemaValidator中的未使用引用
- 在全局引用中添加YAML处理相关库引用
- 优化配置模块的依赖管理和命名空间使用
---
GFramework.Game/Config/YamlConfigLoader.cs | 3 ++-
GFramework.Game/Config/YamlConfigSchemaValidator.cs | 4 ----
GFramework.Game/GlobalUsings.cs | 6 +++++-
3 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs
index d6d4f59..11f2d99 100644
--- a/GFramework.Game/Config/YamlConfigLoader.cs
+++ b/GFramework.Game/Config/YamlConfigLoader.cs
@@ -1,5 +1,6 @@
-using System.IO;
using GFramework.Game.Abstractions.Config;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
namespace GFramework.Game.Config;
diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
index 3998ec9..bc16016 100644
--- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs
+++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
@@ -1,7 +1,3 @@
-using System.Globalization;
-using System.IO;
-using System.Text.Json;
-
namespace GFramework.Game.Config;
///
diff --git a/GFramework.Game/GlobalUsings.cs b/GFramework.Game/GlobalUsings.cs
index 74db065..b91413e 100644
--- a/GFramework.Game/GlobalUsings.cs
+++ b/GFramework.Game/GlobalUsings.cs
@@ -16,4 +16,8 @@ global using System.Collections.Generic;
global using System.Collections.Concurrent;
global using System.Linq;
global using System.Threading;
-global using System.Threading.Tasks;
\ No newline at end of file
+global using System.Threading.Tasks;
+global using System.Globalization;
+global using System.IO;
+global using System.Text.Json;
+global using YamlDotNet.RepresentationModel;
\ No newline at end of file
From 3332aaff7bf384663953583c94d5c093a71b2529 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Tue, 31 Mar 2026 22:39:39 +0800
Subject: [PATCH 08/14] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0YAML?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E5=8F=8A=E5=BC=80?=
=?UTF-8?q?=E5=8F=91=E6=9C=9F=E7=83=AD=E9=87=8D=E8=BD=BD=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现YamlConfigLoader支持基于文件目录的YAML配置加载
- 添加EnableHotReload方法支持开发期配置文件变更自动重载
- 提供带schema校验的配置表注册功能
- 实现按表粒度的热重载机制及错误处理回调
- 添加配置文件变更监听和防抖处理
- 更新文档说明热重载使用方法和行为特性
- 移除未完成功能列表中的运行时热重载项
---
.../Config/YamlConfigLoaderTests.cs | 159 ++++++++-
GFramework.Game/Config/YamlConfigLoader.cs | 331 ++++++++++++++++++
docs/zh-CN/game/config-system.md | 35 +-
3 files changed, 522 insertions(+), 3 deletions(-)
diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
index c4d5e81..236bf34 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -9,8 +9,6 @@ namespace GFramework.Game.Tests.Config;
[TestFixture]
public class YamlConfigLoaderTests
{
- private string _rootPath = null!;
-
///
/// 为每个测试创建独立临时目录,避免文件系统状态互相污染。
///
@@ -33,6 +31,8 @@ public class YamlConfigLoaderTests
}
}
+ private string _rootPath = null!;
+
///
/// 验证加载器能够扫描 YAML 文件并将结果写入注册表。
///
@@ -331,6 +331,143 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。
+ ///
+ [Test]
+ public async Task EnableHotReload_Should_Update_Registered_Table_When_Config_File_Changes()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ 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();
+ await loader.LoadAsync(registry);
+
+ var reloadTaskSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var hotReload = loader.EnableHotReload(
+ registry,
+ onTableReloaded: tableName => reloadTaskSource.TrySetResult(tableName),
+ debounceDelay: TimeSpan.FromMilliseconds(150));
+
+ try
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 25
+ """);
+
+ var tableName = await WaitForTaskWithinAsync(reloadTaskSource.Task, TimeSpan.FromSeconds(5));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(tableName, Is.EqualTo("monster"));
+ Assert.That(registry.GetTable("monster").Get(1).Hp, Is.EqualTo(25));
+ });
+ }
+ finally
+ {
+ hotReload.UnRegister();
+ }
+ }
+
+ ///
+ /// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。
+ ///
+ [Test]
+ public async Task EnableHotReload_Should_Keep_Previous_Table_When_Schema_Change_Makes_Reload_Fail()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ 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();
+ await loader.LoadAsync(registry);
+
+ var reloadFailureTaskSource =
+ new TaskCompletionSource<(string TableName, Exception Exception)>(TaskCreationOptions
+ .RunContinuationsAsynchronously);
+ var hotReload = loader.EnableHotReload(
+ registry,
+ onTableReloadFailed: (tableName, exception) =>
+ reloadFailureTaskSource.TrySetResult((tableName, exception)),
+ debounceDelay: TimeSpan.FromMilliseconds(150));
+
+ try
+ {
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "rarity"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "hp": { "type": "integer" },
+ "rarity": { "type": "string" }
+ }
+ }
+ """);
+
+ var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(failure.TableName, Is.EqualTo("monster"));
+ Assert.That(failure.Exception.Message, Does.Contain("rarity"));
+ Assert.That(registry.GetTable("monster").Get(1).Hp, Is.EqualTo(10));
+ });
+ }
+ finally
+ {
+ hotReload.UnRegister();
+ }
+ }
+
///
/// 创建测试用配置文件。
///
@@ -358,6 +495,24 @@ public class YamlConfigLoaderTests
CreateConfigFile(relativePath, content);
}
+ ///
+ /// 在限定时间内等待异步任务完成,避免文件监听测试无限挂起。
+ ///
+ /// 任务结果类型。
+ /// 要等待的任务。
+ /// 超时时间。
+ /// 任务结果。
+ private static async Task WaitForTaskWithinAsync(Task task, TimeSpan timeout)
+ {
+ var completedTask = await Task.WhenAny(task, Task.Delay(timeout));
+ if (!ReferenceEquals(completedTask, task))
+ {
+ Assert.Fail($"Timed out after {timeout} while waiting for file watcher notification.");
+ }
+
+ return await task;
+ }
+
///
/// 用于 YAML 加载测试的最小怪物配置类型。
///
diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs
index 11f2d99..6570035 100644
--- a/GFramework.Game/Config/YamlConfigLoader.cs
+++ b/GFramework.Game/Config/YamlConfigLoader.cs
@@ -1,3 +1,4 @@
+using GFramework.Core.Abstractions.Events;
using GFramework.Game.Abstractions.Config;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
@@ -71,6 +72,35 @@ public sealed class YamlConfigLoader : IConfigLoader
}
}
+ ///
+ /// 启用开发期热重载。
+ /// 该能力会监听已注册配置表对应的配置目录和 schema 文件,并在检测到文件变更后按表粒度重新加载。
+ /// 重载失败时会保留注册表中的旧表,避免开发期错误配置直接破坏当前运行时状态。
+ ///
+ /// 要被热重载更新的配置注册表。
+ /// 单个配置表重载成功后的可选回调。
+ /// 单个配置表重载失败后的可选回调。
+ /// 防抖延迟;为空时默认使用 200 毫秒。
+ /// 用于停止热重载监听的注销句柄。
+ /// 当 为空时抛出。
+ public IUnRegister EnableHotReload(
+ IConfigRegistry registry,
+ Action? onTableReloaded = null,
+ Action? onTableReloadFailed = null,
+ TimeSpan? debounceDelay = null)
+ {
+ ArgumentNullException.ThrowIfNull(registry);
+
+ return new HotReloadSession(
+ _rootPath,
+ _deserializer,
+ registry,
+ _registrations,
+ onTableReloaded,
+ onTableReloadFailed,
+ debounceDelay ?? TimeSpan.FromMilliseconds(200));
+ }
+
///
/// 注册一个 YAML 配置表定义。
/// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。
@@ -193,6 +223,21 @@ public sealed class YamlConfigLoader : IConfigLoader
///
private interface IYamlTableRegistration
{
+ ///
+ /// 获取配置表名称。
+ ///
+ string Name { get; }
+
+ ///
+ /// 获取相对配置根目录的子目录。
+ ///
+ string RelativePath { get; }
+
+ ///
+ /// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时返回空。
+ ///
+ string? SchemaRelativePath { get; }
+
///
/// 从指定根目录加载配置表。
///
@@ -337,4 +382,290 @@ public sealed class YamlConfigLoader : IConfigLoader
}
}
}
+
+ ///
+ /// 封装开发期热重载所需的文件监听与按表重载逻辑。
+ /// 该会话只影响通过当前加载器注册的表,不尝试接管注册表中的其他来源数据。
+ ///
+ private sealed class HotReloadSession : IUnRegister, IDisposable
+ {
+ private readonly TimeSpan _debounceDelay;
+ private readonly IDeserializer _deserializer;
+ private readonly object _gate = new();
+ private readonly Action? _onTableReloaded;
+ private readonly Action? _onTableReloadFailed;
+ private readonly Dictionary _registrations = new(StringComparer.Ordinal);
+ private readonly IConfigRegistry _registry;
+ private readonly Dictionary _reloadLocks = new(StringComparer.Ordinal);
+ private readonly Dictionary _reloadTokens = new(StringComparer.Ordinal);
+ private readonly string _rootPath;
+ private readonly List _watchers = new();
+ private bool _disposed;
+
+ ///
+ /// 初始化一个热重载会话并立即开始监听文件变更。
+ ///
+ /// 配置根目录。
+ /// YAML 反序列化器。
+ /// 要更新的配置注册表。
+ /// 已注册的配置表定义。
+ /// 单表重载成功回调。
+ /// 单表重载失败回调。
+ /// 监听事件防抖延迟。
+ public HotReloadSession(
+ string rootPath,
+ IDeserializer deserializer,
+ IConfigRegistry registry,
+ IEnumerable registrations,
+ Action? onTableReloaded,
+ Action? onTableReloadFailed,
+ TimeSpan debounceDelay)
+ {
+ ArgumentNullException.ThrowIfNull(rootPath);
+ ArgumentNullException.ThrowIfNull(deserializer);
+ ArgumentNullException.ThrowIfNull(registry);
+ ArgumentNullException.ThrowIfNull(registrations);
+
+ _rootPath = rootPath;
+ _deserializer = deserializer;
+ _registry = registry;
+ _onTableReloaded = onTableReloaded;
+ _onTableReloadFailed = onTableReloadFailed;
+ _debounceDelay = debounceDelay;
+
+ foreach (var registration in registrations)
+ {
+ _registrations.Add(registration.Name, registration);
+ _reloadLocks.Add(registration.Name, new SemaphoreSlim(1, 1));
+ CreateWatchersForRegistration(registration);
+ }
+ }
+
+ ///
+ /// 释放热重载会话持有的文件监听器与等待资源。
+ ///
+ public void Dispose()
+ {
+ List watchersToDispose;
+ List reloadTokensToDispose;
+ List reloadLocksToDispose;
+
+ lock (_gate)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ watchersToDispose = _watchers.ToList();
+ _watchers.Clear();
+ reloadTokensToDispose = _reloadTokens.Values.ToList();
+ _reloadTokens.Clear();
+ reloadLocksToDispose = _reloadLocks.Values.ToList();
+ _reloadLocks.Clear();
+ }
+
+ foreach (var reloadToken in reloadTokensToDispose)
+ {
+ reloadToken.Cancel();
+ reloadToken.Dispose();
+ }
+
+ foreach (var watcher in watchersToDispose)
+ {
+ watcher.Dispose();
+ }
+
+ foreach (var reloadLock in reloadLocksToDispose)
+ {
+ reloadLock.Dispose();
+ }
+ }
+
+ ///
+ /// 停止热重载监听。
+ ///
+ public void UnRegister()
+ {
+ Dispose();
+ }
+
+ private void CreateWatchersForRegistration(IYamlTableRegistration registration)
+ {
+ var configDirectoryPath = Path.Combine(_rootPath, registration.RelativePath);
+ AddWatcher(configDirectoryPath, "*.yaml", registration.Name);
+ AddWatcher(configDirectoryPath, "*.yml", registration.Name);
+
+ if (string.IsNullOrEmpty(registration.SchemaRelativePath))
+ {
+ return;
+ }
+
+ var schemaFullPath = Path.Combine(_rootPath, registration.SchemaRelativePath);
+ var schemaDirectoryPath = Path.GetDirectoryName(schemaFullPath);
+ if (string.IsNullOrWhiteSpace(schemaDirectoryPath))
+ {
+ schemaDirectoryPath = _rootPath;
+ }
+
+ AddWatcher(schemaDirectoryPath, Path.GetFileName(schemaFullPath), registration.Name);
+ }
+
+ private void AddWatcher(string directoryPath, string filter, string tableName)
+ {
+ if (!Directory.Exists(directoryPath))
+ {
+ return;
+ }
+
+ var watcher = new FileSystemWatcher(directoryPath, filter)
+ {
+ IncludeSubdirectories = false,
+ NotifyFilter = NotifyFilters.FileName |
+ NotifyFilters.LastWrite |
+ NotifyFilters.Size |
+ NotifyFilters.CreationTime |
+ NotifyFilters.DirectoryName
+ };
+
+ watcher.Changed += (_, _) => ScheduleReload(tableName);
+ watcher.Created += (_, _) => ScheduleReload(tableName);
+ watcher.Deleted += (_, _) => ScheduleReload(tableName);
+ watcher.Renamed += (_, _) => ScheduleReload(tableName);
+ watcher.Error += (_, eventArgs) =>
+ {
+ var exception = eventArgs.GetException() ?? new InvalidOperationException(
+ $"Hot reload watcher for table '{tableName}' encountered an unknown error.");
+ InvokeReloadFailed(tableName, exception);
+ };
+
+ watcher.EnableRaisingEvents = true;
+
+ lock (_gate)
+ {
+ if (_disposed)
+ {
+ watcher.Dispose();
+ return;
+ }
+
+ _watchers.Add(watcher);
+ }
+ }
+
+ private void ScheduleReload(string tableName)
+ {
+ CancellationTokenSource reloadTokenSource;
+
+ lock (_gate)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (_reloadTokens.TryGetValue(tableName, out var previousTokenSource))
+ {
+ previousTokenSource.Cancel();
+ previousTokenSource.Dispose();
+ }
+
+ reloadTokenSource = new CancellationTokenSource();
+ _reloadTokens[tableName] = reloadTokenSource;
+ }
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await Task.Delay(_debounceDelay, reloadTokenSource.Token);
+ await ReloadTableAsync(tableName, reloadTokenSource.Token);
+ }
+ catch (OperationCanceledException) when (reloadTokenSource.IsCancellationRequested)
+ {
+ // 新事件会替换旧任务;取消属于正常防抖行为。
+ }
+ finally
+ {
+ lock (_gate)
+ {
+ if (_reloadTokens.TryGetValue(tableName, out var currentTokenSource) &&
+ ReferenceEquals(currentTokenSource, reloadTokenSource))
+ {
+ _reloadTokens.Remove(tableName);
+ }
+ }
+
+ reloadTokenSource.Dispose();
+ }
+ });
+ }
+
+ private async Task ReloadTableAsync(string tableName, CancellationToken cancellationToken)
+ {
+ if (!_registrations.TryGetValue(tableName, out var registration))
+ {
+ return;
+ }
+
+ var reloadLock = _reloadLocks[tableName];
+ await reloadLock.WaitAsync(cancellationToken);
+
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var (name, table) = await registration.LoadAsync(_rootPath, _deserializer, cancellationToken);
+ RegistrationDispatcher.Register(_registry, name, table);
+ InvokeReloaded(name);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // 防抖替换或会话关闭导致的取消不应视为错误。
+ }
+ catch (Exception exception)
+ {
+ InvokeReloadFailed(tableName, exception);
+ }
+ finally
+ {
+ reloadLock.Release();
+ }
+ }
+
+ private void InvokeReloaded(string tableName)
+ {
+ if (_onTableReloaded == null)
+ {
+ return;
+ }
+
+ try
+ {
+ _onTableReloaded(tableName);
+ }
+ catch
+ {
+ // 诊断回调不应反向破坏热重载流程。
+ }
+ }
+
+ private void InvokeReloadFailed(string tableName, Exception exception)
+ {
+ if (_onTableReloadFailed == null)
+ {
+ return;
+ }
+
+ try
+ {
+ _onTableReloadFailed(tableName, exception);
+ }
+ catch
+ {
+ // 诊断回调不应反向破坏热重载流程。
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md
index 87a0cbe..591febc 100644
--- a/docs/zh-CN/game/config-system.md
+++ b/docs/zh-CN/game/config-system.md
@@ -94,6 +94,40 @@ var slime = monsterTable.Get(1);
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
+## 开发期热重载
+
+如果你希望在开发期修改配置文件后自动刷新运行时表,可以在初次加载完成后启用热重载:
+
+```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 hotReload = loader.EnableHotReload(
+ registry,
+ onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"),
+ onTableReloadFailed: (tableName, exception) =>
+ Console.WriteLine($"Reload failed: {tableName}, {exception.Message}"));
+```
+
+当前热重载行为如下:
+
+- 监听已注册表对应的配置目录
+- 监听该表绑定的 schema 文件
+- 检测到变更后按表粒度重载
+- 重载成功后替换该表在 `IConfigRegistry` 中的注册
+- 重载失败时保留旧表,并通过失败回调提供诊断
+
+这项能力默认定位为开发期工具,不承诺生产环境热更新平台语义。
+
## 生成器接入约定
配置生成器会从 `*.schema.json` 生成配置类型和表包装类。
@@ -118,7 +152,6 @@ var slime = monsterTable.Get(1);
以下能力尚未完全完成:
-- 运行时热重载
- 跨表引用校验
- 更完整的 JSON Schema 支持
- 更强的 VS Code 表单编辑器
From e8d0ea2daf34c85b802cfcd0fe077b3a9d7b9927 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Tue, 31 Mar 2026 22:44:58 +0800
Subject: [PATCH 09/14] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?=
=?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?=
=?UTF-8?q?=E5=92=8CVS=20Code=E6=89=A9=E5=B1=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 引入基于YAML和JSON Schema的静态内容配置系统
- 实现运行时只读查询和Source Generator支持
- 提供VS Code扩展用于配置浏览、验证和轻量编辑
- 支持开发期热重载和跨表引用校验功能
- 包含完整的文档说明和工具链集成
---
docs/zh-CN/game/config-system.md | 2 +-
tools/vscode-config-extension/README.md | 20 +-
tools/vscode-config-extension/package.json | 3 +
.../src/configValidation.js | 340 ++++++++++++++++++
.../vscode-config-extension/src/extension.js | 215 ++---------
.../test/configValidation.test.js | 111 ++++++
6 files changed, 500 insertions(+), 191 deletions(-)
create mode 100644 tools/vscode-config-extension/src/configValidation.js
create mode 100644 tools/vscode-config-extension/test/configValidation.test.js
diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md
index 591febc..f840f0c 100644
--- a/docs/zh-CN/game/config-system.md
+++ b/docs/zh-CN/game/config-system.md
@@ -143,7 +143,7 @@ var hotReload = loader.EnableHotReload(
- 浏览 `config/` 目录
- 打开 raw YAML 文件
- 打开匹配的 schema 文件
-- 对必填字段和基础标量类型做轻量校验
+- 对必填字段、未知顶层字段、基础标量类型和标量数组元素做轻量校验
- 对顶层标量字段提供轻量表单入口
当前仍建议把复杂数组、嵌套对象和批量修改放在 raw YAML 中完成。
diff --git a/tools/vscode-config-extension/README.md b/tools/vscode-config-extension/README.md
index c9e3a58..6007c32 100644
--- a/tools/vscode-config-extension/README.md
+++ b/tools/vscode-config-extension/README.md
@@ -7,9 +7,27 @@ Minimal VS Code extension scaffold for the GFramework AI-First config workflow.
- Browse config files from the workspace `config/` directory
- Open raw YAML files
- Open matching schema files from `schemas/`
-- Run lightweight schema validation for required fields and simple scalar types
+- 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
+## Validation Coverage
+
+The extension currently validates the repository's minimal config-schema subset:
+
+- required top-level properties
+- unknown top-level properties
+- scalar compatibility for `integer`, `number`, `boolean`, and `string`
+- top-level scalar arrays with scalar item type checks
+
+Nested objects and complex arrays should still be reviewed in raw YAML.
+
+## Local Testing
+
+```bash
+cd tools/vscode-config-extension
+node --test ./test/*.test.js
+```
+
## Current Constraints
- Multi-root workspaces use the first workspace folder
diff --git a/tools/vscode-config-extension/package.json b/tools/vscode-config-extension/package.json
index 92c5c71..33b7832 100644
--- a/tools/vscode-config-extension/package.json
+++ b/tools/vscode-config-extension/package.json
@@ -20,6 +20,9 @@
"onCommand:gframeworkConfig.validateAll"
],
"main": "./src/extension.js",
+ "scripts": {
+ "test": "node --test ./test/*.test.js"
+ },
"contributes": {
"views": {
"explorer": [
diff --git a/tools/vscode-config-extension/src/configValidation.js b/tools/vscode-config-extension/src/configValidation.js
new file mode 100644
index 0000000..ef71331
--- /dev/null
+++ b/tools/vscode-config-extension/src/configValidation.js
@@ -0,0 +1,340 @@
+/**
+ * Parse a minimal JSON schema document used by the config extension.
+ * The parser intentionally supports the same schema subset that the current
+ * runtime validator and source generator depend on.
+ *
+ * @param {string} content Raw schema JSON text.
+ * @returns {{required: string[], properties: Record}} Parsed schema info.
+ */
+function parseSchemaContent(content) {
+ const parsed = JSON.parse(content);
+ const required = Array.isArray(parsed.required)
+ ? parsed.required.filter((value) => typeof value === "string")
+ : [];
+ const properties = {};
+ const propertyBag = parsed.properties || {};
+
+ for (const [key, value] of Object.entries(propertyBag)) {
+ if (!value || typeof value !== "object" || typeof value.type !== "string") {
+ continue;
+ }
+
+ if (value.type === "array" &&
+ value.items &&
+ typeof value.items === "object" &&
+ typeof value.items.type === "string") {
+ properties[key] = {
+ type: "array",
+ itemType: value.items.type
+ };
+ continue;
+ }
+
+ properties[key] = {
+ type: value.type
+ };
+ }
+
+ return {
+ required,
+ properties
+ };
+}
+
+/**
+ * Parse a minimal top-level YAML structure for config validation and form
+ * preview. This parser intentionally focuses on the repository's current
+ * config conventions: one root mapping object per file, top-level scalar
+ * fields, and top-level scalar arrays.
+ *
+ * @param {string} text YAML text.
+ * @returns {{entries: Map}>, keys: Set}} Parsed YAML.
+ */
+function parseTopLevelYaml(text) {
+ const entries = new Map();
+ const keys = new Set();
+ const lines = text.split(/\r?\n/u);
+
+ for (let index = 0; index < lines.length; index += 1) {
+ const line = lines[index];
+ if (!line || line.trim().length === 0 || line.trim().startsWith("#")) {
+ continue;
+ }
+
+ if (/^\s/u.test(line)) {
+ continue;
+ }
+
+ const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line);
+ if (!match) {
+ continue;
+ }
+
+ const key = match[1];
+ const rawValue = match[2] || "";
+ keys.add(key);
+
+ if (rawValue.length > 0 && !rawValue.startsWith("|") && !rawValue.startsWith(">")) {
+ entries.set(key, {
+ kind: "scalar",
+ value: rawValue.trim()
+ });
+ continue;
+ }
+
+ const childLines = [];
+ let cursor = index + 1;
+ while (cursor < lines.length) {
+ const childLine = lines[cursor];
+ if (childLine.trim().length === 0 || childLine.trim().startsWith("#")) {
+ cursor += 1;
+ continue;
+ }
+
+ if (!/^\s/u.test(childLine)) {
+ break;
+ }
+
+ childLines.push(childLine);
+ cursor += 1;
+ }
+
+ if (childLines.length === 0) {
+ entries.set(key, {
+ kind: "empty"
+ });
+ continue;
+ }
+
+ const arrayItems = parseTopLevelArray(childLines);
+ if (arrayItems) {
+ entries.set(key, {
+ kind: "array",
+ items: arrayItems
+ });
+ index = cursor - 1;
+ continue;
+ }
+
+ entries.set(key, {
+ kind: "object"
+ });
+ index = cursor - 1;
+ }
+
+ return {
+ entries,
+ keys
+ };
+}
+
+/**
+ * Produce extension-facing validation diagnostics from schema and parsed YAML.
+ *
+ * @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.
+ */
+function validateParsedConfig(schemaInfo, parsedYaml) {
+ const diagnostics = [];
+
+ for (const requiredProperty of schemaInfo.required) {
+ if (!parsedYaml.keys.has(requiredProperty)) {
+ diagnostics.push({
+ severity: "error",
+ message: `Required property '${requiredProperty}' is missing.`
+ });
+ }
+ }
+
+ for (const key of parsedYaml.keys) {
+ if (!Object.prototype.hasOwnProperty.call(schemaInfo.properties, key)) {
+ diagnostics.push({
+ severity: "error",
+ message: `Property '${key}' is not declared in the matching schema.`
+ });
+ }
+ }
+
+ for (const [propertyName, propertySchema] of Object.entries(schemaInfo.properties)) {
+ if (!parsedYaml.entries.has(propertyName)) {
+ continue;
+ }
+
+ const entry = parsedYaml.entries.get(propertyName);
+ if (propertySchema.type === "array") {
+ if (entry.kind !== "array") {
+ diagnostics.push({
+ severity: "error",
+ message: `Property '${propertyName}' is expected to be an array.`
+ });
+ continue;
+ }
+
+ for (const item of entry.items || []) {
+ if (item.isComplex || !isScalarCompatible(propertySchema.itemType || "", item.raw)) {
+ diagnostics.push({
+ severity: "error",
+ message: `Array item in property '${propertyName}' is expected to be '${propertySchema.itemType}', but the current value is incompatible.`
+ });
+ break;
+ }
+ }
+
+ continue;
+ }
+
+ if (entry.kind !== "scalar") {
+ diagnostics.push({
+ severity: "error",
+ message: `Property '${propertyName}' is expected to be '${propertySchema.type}', but the current YAML shape is '${entry.kind}'.`
+ });
+ continue;
+ }
+
+ if (!isScalarCompatible(propertySchema.type, entry.value || "")) {
+ diagnostics.push({
+ severity: "error",
+ message: `Property '${propertyName}' is expected to be '${propertySchema.type}', but the current scalar value is incompatible.`
+ });
+ }
+ }
+
+ return diagnostics;
+}
+
+/**
+ * Determine whether a scalar value matches a minimal schema type.
+ *
+ * @param {string} expectedType Schema type.
+ * @param {string} scalarValue YAML scalar value.
+ * @returns {boolean} True when compatible.
+ */
+function isScalarCompatible(expectedType, scalarValue) {
+ const value = unquoteScalar(scalarValue);
+ switch (expectedType) {
+ case "integer":
+ return /^-?\d+$/u.test(value);
+ case "number":
+ return /^-?\d+(?:\.\d+)?$/u.test(value);
+ case "boolean":
+ return /^(true|false)$/iu.test(value);
+ case "string":
+ return true;
+ default:
+ return true;
+ }
+}
+
+/**
+ * Apply scalar field updates back into the original YAML text.
+ *
+ * @param {string} originalYaml Original YAML content.
+ * @param {Record} updates Updated scalar values.
+ * @returns {string} Updated YAML content.
+ */
+function applyScalarUpdates(originalYaml, updates) {
+ const lines = originalYaml.split(/\r?\n/u);
+ const touched = new Set();
+
+ const updatedLines = lines.map((line) => {
+ if (/^\s/u.test(line)) {
+ return line;
+ }
+
+ const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line);
+ if (!match) {
+ return line;
+ }
+
+ const key = match[1];
+ if (!Object.prototype.hasOwnProperty.call(updates, key)) {
+ return line;
+ }
+
+ touched.add(key);
+ return `${key}: ${formatYamlScalar(updates[key])}`;
+ });
+
+ for (const [key, value] of Object.entries(updates)) {
+ if (touched.has(key)) {
+ continue;
+ }
+
+ updatedLines.push(`${key}: ${formatYamlScalar(value)}`);
+ }
+
+ return updatedLines.join("\n");
+}
+
+/**
+ * Format a scalar value for YAML output.
+ *
+ * @param {string} value Scalar value.
+ * @returns {string} YAML-ready scalar.
+ */
+function formatYamlScalar(value) {
+ if (/^-?\d+(?:\.\d+)?$/u.test(value) || /^(true|false)$/iu.test(value)) {
+ return value;
+ }
+
+ if (value.length === 0 || /[:#\[\]\{\},]|^\s|\s$/u.test(value)) {
+ return JSON.stringify(value);
+ }
+
+ return value;
+}
+
+/**
+ * Remove a simple YAML string quote wrapper.
+ *
+ * @param {string} value Scalar value.
+ * @returns {string} Unquoted value.
+ */
+function unquoteScalar(value) {
+ if ((value.startsWith("\"") && value.endsWith("\"")) ||
+ (value.startsWith("'") && value.endsWith("'"))) {
+ return value.slice(1, -1);
+ }
+
+ return value;
+}
+
+/**
+ * Parse a sequence of child lines as a top-level scalar array.
+ *
+ * @param {string[]} childLines Indented child lines.
+ * @returns {Array<{raw: string, isComplex: boolean}> | null} Parsed array items or null when the block is not an array.
+ */
+function parseTopLevelArray(childLines) {
+ const items = [];
+
+ for (const line of childLines) {
+ if (line.trim().length === 0 || line.trim().startsWith("#")) {
+ continue;
+ }
+
+ const trimmed = line.trimStart();
+ if (!trimmed.startsWith("-")) {
+ return null;
+ }
+
+ const raw = trimmed.slice(1).trim();
+ items.push({
+ raw,
+ isComplex: raw.length === 0 || raw.startsWith("{") || raw.startsWith("[") || /^[A-Za-z0-9_]+:\s*/u.test(raw)
+ });
+ }
+
+ return items;
+}
+
+module.exports = {
+ applyScalarUpdates,
+ formatYamlScalar,
+ isScalarCompatible,
+ parseSchemaContent,
+ parseTopLevelYaml,
+ unquoteScalar,
+ validateParsedConfig
+};
diff --git a/tools/vscode-config-extension/src/extension.js b/tools/vscode-config-extension/src/extension.js
index 633cf6a..c73775c 100644
--- a/tools/vscode-config-extension/src/extension.js
+++ b/tools/vscode-config-extension/src/extension.js
@@ -1,6 +1,13 @@
const fs = require("fs");
const path = require("path");
const vscode = require("vscode");
+const {
+ applyScalarUpdates,
+ parseSchemaContent,
+ parseTopLevelYaml,
+ unquoteScalar,
+ validateParsedConfig
+} = require("./configValidation");
/**
* Activate the GFramework config extension.
@@ -342,27 +349,13 @@ async function validateConfigFile(configUri, diagnostics) {
return;
}
- for (const requiredProperty of schemaInfo.required) {
- if (!parsedYaml.keys.has(requiredProperty)) {
- fileDiagnostics.push(new vscode.Diagnostic(
- new vscode.Range(0, 0, 0, 1),
- `Required property '${requiredProperty}' is missing.`,
- vscode.DiagnosticSeverity.Error));
- }
- }
-
- for (const [propertyName, expectedType] of Object.entries(schemaInfo.propertyTypes)) {
- if (!parsedYaml.scalars.has(propertyName)) {
- continue;
- }
-
- const scalarValue = parsedYaml.scalars.get(propertyName);
- if (!isScalarCompatible(expectedType, scalarValue)) {
- fileDiagnostics.push(new vscode.Diagnostic(
- new vscode.Range(0, 0, 0, 1),
- `Property '${propertyName}' is expected to be '${expectedType}', but the current scalar value is incompatible.`,
- vscode.DiagnosticSeverity.Warning));
- }
+ for (const diagnostic of validateParsedConfig(schemaInfo, parsedYaml)) {
+ fileDiagnostics.push(new vscode.Diagnostic(
+ new vscode.Range(0, 0, 0, 1),
+ diagnostic.message,
+ diagnostic.severity === "error"
+ ? vscode.DiagnosticSeverity.Error
+ : vscode.DiagnosticSeverity.Warning));
}
diagnostics.set(configUri, fileDiagnostics);
@@ -373,7 +366,7 @@ async function validateConfigFile(configUri, diagnostics) {
*
* @param {vscode.Uri} configUri Config file URI.
* @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
- * @returns {Promise<{exists: boolean, schemaPath: string, required: string[], propertyTypes: 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);
@@ -383,144 +376,44 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) {
exists: false,
schemaPath,
required: [],
- propertyTypes: {}
+ properties: {}
};
}
const content = await fs.promises.readFile(schemaUri.fsPath, "utf8");
try {
- const parsed = JSON.parse(content);
- const required = Array.isArray(parsed.required)
- ? parsed.required.filter((value) => typeof value === "string")
- : [];
- const propertyTypes = {};
- const properties = parsed.properties || {};
-
- for (const [key, value] of Object.entries(properties)) {
- if (!value || typeof value !== "object") {
- continue;
- }
-
- if (typeof value.type === "string") {
- propertyTypes[key] = value.type;
- }
- }
+ const parsed = parseSchemaContent(content);
return {
exists: true,
schemaPath,
- required,
- propertyTypes
+ required: parsed.required,
+ properties: parsed.properties
};
} catch (error) {
return {
exists: false,
schemaPath,
required: [],
- propertyTypes: {}
+ properties: {}
};
}
}
-/**
- * Parse top-level YAML keys and scalar values.
- * This intentionally supports only the MVP subset needed for lightweight form
- * preview and validation.
- *
- * @param {string} text YAML text.
- * @returns {{keys: Set, scalars: Map}} Parsed shape.
- */
-function parseTopLevelYaml(text) {
- const keys = new Set();
- const scalars = new Map();
- const lines = text.split(/\r?\n/u);
-
- for (const line of lines) {
- if (!line || line.trim().length === 0 || line.trim().startsWith("#")) {
- continue;
- }
-
- if (/^\s/u.test(line)) {
- continue;
- }
-
- const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line);
- if (!match) {
- continue;
- }
-
- const key = match[1];
- const rawValue = match[2] || "";
- keys.add(key);
-
- if (rawValue.length === 0) {
- continue;
- }
-
- if (rawValue.startsWith("|") || rawValue.startsWith(">")) {
- continue;
- }
-
- scalars.set(key, rawValue.trim());
- }
-
- return {keys, scalars};
-}
-
-/**
- * Apply scalar field updates back into the original YAML text.
- *
- * @param {string} originalYaml Original YAML content.
- * @param {Record} updates Updated scalar values.
- * @returns {string} Updated YAML content.
- */
-function applyScalarUpdates(originalYaml, updates) {
- const lines = originalYaml.split(/\r?\n/u);
- const touched = new Set();
-
- const updatedLines = lines.map((line) => {
- if (/^\s/u.test(line)) {
- return line;
- }
-
- const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line);
- if (!match) {
- return line;
- }
-
- const key = match[1];
- if (!Object.prototype.hasOwnProperty.call(updates, key)) {
- return line;
- }
-
- touched.add(key);
- return `${key}: ${formatYamlScalar(updates[key])}`;
- });
-
- for (const [key, value] of Object.entries(updates)) {
- if (touched.has(key)) {
- continue;
- }
-
- updatedLines.push(`${key}: ${formatYamlScalar(value)}`);
- }
-
- return updatedLines.join("\n");
-}
-
/**
* Render the form-preview webview HTML.
*
* @param {string} fileName File name.
- * @param {{exists: boolean, schemaPath: string, required: string[], propertyTypes: Record}} schemaInfo Schema info.
- * @param {{keys: Set, scalars: Map}} parsedYaml Parsed YAML data.
+ * @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.
*/
function renderFormHtml(fileName, schemaInfo, parsedYaml) {
- const fields = Array.from(parsedYaml.scalars.entries())
- .map(([key, value]) => {
+ const fields = Array.from(parsedYaml.entries.entries())
+ .filter(([, entry]) => entry.kind === "scalar")
+ .map(([key, entry]) => {
const escapedKey = escapeHtml(key);
- const escapedValue = escapeHtml(unquoteScalar(value));
+ const escapedValue = escapeHtml(unquoteScalar(entry.value || ""));
const required = schemaInfo.required.includes(key) ? "required" : "";
return `