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.Abstractions/Config/IConfigLoader.cs b/GFramework.Game.Abstractions/Config/IConfigLoader.cs new file mode 100644 index 0000000..50abd08 --- /dev/null +++ b/GFramework.Game.Abstractions/Config/IConfigLoader.cs @@ -0,0 +1,19 @@ +using GFramework.Core.Abstractions.Utility; + +namespace GFramework.Game.Abstractions.Config; + +/// +/// 定义配置加载器契约。 +/// 具体实现负责从文件系统、资源包或其他配置源加载文本配置,并将解析结果注册到配置注册表。 +/// +public interface IConfigLoader : IUtility +{ + /// + /// 执行配置加载并将结果写入注册表。 + /// 实现应在同一次加载过程中保证注册结果的一致性,避免只加载部分配置后就暴露给运行时消费。 + /// + /// 用于接收配置表的注册表。 + /// 取消令牌。 + /// 表示异步加载流程的任务。 + Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/GFramework.Game.Abstractions/Config/IConfigRegistry.cs b/GFramework.Game.Abstractions/Config/IConfigRegistry.cs new file mode 100644 index 0000000..07d98d6 --- /dev/null +++ b/GFramework.Game.Abstractions/Config/IConfigRegistry.cs @@ -0,0 +1,84 @@ +using GFramework.Core.Abstractions.Utility; + +namespace GFramework.Game.Abstractions.Config; + +/// +/// 定义配置注册表契约,用于统一保存和解析按名称注册的配置表。 +/// 注册表是运行时配置系统的入口,负责在加载阶段收集配置表,并在消费阶段提供类型安全查询。 +/// +public interface IConfigRegistry : IUtility +{ + /// + /// 获取当前已注册配置表数量。 + /// + int Count { get; } + + /// + /// 获取所有已注册配置表名称。 + /// + /// 配置表名称集合。 + IReadOnlyCollection GetTableNames(); + + /// + /// 注册指定名称的配置表。 + /// 若名称已存在,则替换旧表,以便开发期热重载使用同一入口刷新配置。 + /// + /// 配置表主键类型。 + /// 配置项值类型。 + /// 配置表名称。 + /// 要注册的配置表实例。 + void RegisterTable(string name, IConfigTable table) + where TKey : notnull; + + /// + /// 获取指定名称的配置表。 + /// + /// 配置表主键类型。 + /// 配置项值类型。 + /// 配置表名称。 + /// 匹配的强类型配置表实例。 + /// 当配置表名称不存在时抛出。 + /// 当请求类型与已注册配置表类型不匹配时抛出。 + IConfigTable GetTable(string name) + where TKey : notnull; + + /// + /// 尝试获取指定名称的配置表。 + /// 当名称存在但类型不匹配时返回 false,避免消费端将类型错误误判为加载成功。 + /// + /// 配置表主键类型。 + /// 配置项值类型。 + /// 配置表名称。 + /// 匹配的强类型配置表;未找到或类型不匹配时返回空。 + /// 找到且类型匹配时返回 true,否则返回 false + bool TryGetTable(string name, out IConfigTable? table) + where TKey : notnull; + + /// + /// 尝试获取指定名称的原始配置表。 + /// 该入口用于跨表校验或诊断场景,以便在不知道泛型参数时仍能访问表元数据。 + /// + /// 配置表名称。 + /// 匹配的原始配置表;未找到时返回空。 + /// 找到配置表时返回 true,否则返回 false + bool TryGetTable(string name, out IConfigTable? table); + + /// + /// 检查指定名称的配置表是否存在。 + /// + /// 配置表名称。 + /// 存在时返回 true,否则返回 false + bool HasTable(string name); + + /// + /// 移除指定名称的配置表。 + /// + /// 配置表名称。 + /// 移除成功时返回 true,否则返回 false + bool RemoveTable(string name); + + /// + /// 清空所有已注册配置表。 + /// + void Clear(); +} \ No newline at end of file diff --git a/GFramework.Game.Abstractions/Config/IConfigTable.cs b/GFramework.Game.Abstractions/Config/IConfigTable.cs new file mode 100644 index 0000000..1cd781a --- /dev/null +++ b/GFramework.Game.Abstractions/Config/IConfigTable.cs @@ -0,0 +1,65 @@ +using GFramework.Core.Abstractions.Utility; + +namespace GFramework.Game.Abstractions.Config; + +/// +/// 定义配置表的非泛型公共契约,用于在注册表中保存异构配置表实例。 +/// 该接口只暴露运行时发现和诊断所需的元数据,不提供具体类型访问能力。 +/// +public interface IConfigTable : IUtility +{ + /// + /// 获取配置表主键类型。 + /// + Type KeyType { get; } + + /// + /// 获取配置项值类型。 + /// + Type ValueType { get; } + + /// + /// 获取当前配置表中的条目数量。 + /// + int Count { get; } +} + +/// +/// 定义强类型只读配置表契约。 +/// 运行时配置表应通过主键执行只读查询,而不是暴露可变集合接口, +/// 以保持配置数据在加载完成后的稳定性和可预测性。 +/// +/// 配置表主键类型。 +/// 配置项值类型。 +public interface IConfigTable : IConfigTable + where TKey : notnull +{ + /// + /// 获取指定主键的配置项。 + /// + /// 配置项主键。 + /// 找到的配置项。 + /// 当主键不存在时抛出。 + TValue Get(TKey key); + + /// + /// 尝试获取指定主键的配置项。 + /// + /// 配置项主键。 + /// 找到的配置项;未找到时返回默认值。 + /// 找到配置项时返回 true,否则返回 false + bool TryGet(TKey key, out TValue? value); + + /// + /// 检查指定主键是否存在。 + /// + /// 配置项主键。 + /// 主键存在时返回 true,否则返回 false + bool ContainsKey(TKey key); + + /// + /// 获取配置表中的所有配置项快照。 + /// + /// 只读配置项集合。 + IReadOnlyCollection All(); +} \ No newline at end of file diff --git a/GFramework.Game.Tests/Config/ConfigRegistryTests.cs b/GFramework.Game.Tests/Config/ConfigRegistryTests.cs new file mode 100644 index 0000000..8ae8eef --- /dev/null +++ b/GFramework.Game.Tests/Config/ConfigRegistryTests.cs @@ -0,0 +1,150 @@ +using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证配置注册表的注册、覆盖和类型检查行为。 +/// +[TestFixture] +public class ConfigRegistryTests +{ + /// + /// 验证注册后的配置表可以按名称和类型成功解析。 + /// + [Test] + public void RegisterTable_Then_GetTable_Should_Return_Registered_Instance() + { + var registry = new ConfigRegistry(); + var table = CreateMonsterTable(); + + registry.RegisterTable("monster", table); + + var resolved = registry.GetTable("monster"); + + Assert.That(resolved, Is.SameAs(table)); + } + + /// + /// 验证同名注册会覆盖旧表,用于后续热重载场景。 + /// + [Test] + public void RegisterTable_Should_Replace_Previous_Table_With_Same_Name() + { + var registry = new ConfigRegistry(); + var oldTable = CreateMonsterTable(); + var newTable = new InMemoryConfigTable( + new[] + { + new MonsterConfigStub(3, "Orc") + }, + static config => config.Id); + + registry.RegisterTable("monster", oldTable); + registry.RegisterTable("monster", newTable); + + var resolved = registry.GetTable("monster"); + + Assert.That(resolved, Is.SameAs(newTable)); + Assert.That(resolved.Count, Is.EqualTo(1)); + } + + /// + /// 验证请求类型与实际注册类型不匹配时会抛出异常,避免消费端默默读取错误表。 + /// + [Test] + public void GetTable_Should_Throw_When_Requested_Type_Does_Not_Match_Registered_Table() + { + var registry = new ConfigRegistry(); + registry.RegisterTable("monster", CreateMonsterTable()); + + Assert.Throws(() => registry.GetTable("monster")); + } + + /// + /// 验证弱类型查询入口可以在不知道泛型参数时返回原始配置表。 + /// + [Test] + public void TryGetTable_Should_Return_Raw_Table_When_Name_Exists() + { + var registry = new ConfigRegistry(); + var table = CreateMonsterTable(); + registry.RegisterTable("monster", table); + + var found = registry.TryGetTable("monster", out var rawTable); + + Assert.Multiple(() => + { + Assert.That(found, Is.True); + Assert.That(rawTable, Is.SameAs(table)); + Assert.That(rawTable!.KeyType, Is.EqualTo(typeof(int))); + }); + } + + /// + /// 验证移除和清空操作会更新注册表状态。 + /// + [Test] + public void RemoveTable_And_Clear_Should_Update_Registry_State() + { + var registry = new ConfigRegistry(); + registry.RegisterTable("monster", CreateMonsterTable()); + registry.RegisterTable("npc", CreateNpcTable()); + + var removed = registry.RemoveTable("monster"); + + Assert.Multiple(() => + { + Assert.That(removed, Is.True); + Assert.That(registry.HasTable("monster"), Is.False); + Assert.That(registry.Count, Is.EqualTo(1)); + }); + + registry.Clear(); + + Assert.That(registry.Count, Is.EqualTo(0)); + } + + /// + /// 创建怪物配置表测试实例。 + /// + /// 怪物配置表。 + private static IConfigTable CreateMonsterTable() + { + return new InMemoryConfigTable( + new[] + { + new MonsterConfigStub(1, "Slime"), + new MonsterConfigStub(2, "Goblin") + }, + static config => config.Id); + } + + /// + /// 创建 NPC 配置表测试实例。 + /// + /// NPC 配置表。 + private static IConfigTable CreateNpcTable() + { + return new InMemoryConfigTable( + new[] + { + new NpcConfigStub(Guid.NewGuid(), "Guide") + }, + static config => config.Id); + } + + /// + /// 用于怪物配置表测试的最小配置类型。 + /// + /// 配置主键。 + /// 配置名称。 + private sealed record MonsterConfigStub(int Id, string Name); + + /// + /// 用于 NPC 配置表测试的最小配置类型。 + /// + /// 配置主键。 + /// 配置名称。 + private sealed record NpcConfigStub(Guid Id, string Name); +} \ No newline at end of file diff --git a/GFramework.Game.Tests/Config/InMemoryConfigTableTests.cs b/GFramework.Game.Tests/Config/InMemoryConfigTableTests.cs new file mode 100644 index 0000000..0d5a7f4 --- /dev/null +++ b/GFramework.Game.Tests/Config/InMemoryConfigTableTests.cs @@ -0,0 +1,72 @@ +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证内存配置表的基础只读查询行为。 +/// +[TestFixture] +public class InMemoryConfigTableTests +{ + /// + /// 验证已存在主键可以被正确查询。 + /// + [Test] + public void Get_Should_Return_Config_When_Key_Exists() + { + var table = new InMemoryConfigTable( + new[] + { + new MonsterConfigStub(1, "Slime"), + new MonsterConfigStub(2, "Goblin") + }, + static config => config.Id); + + var result = table.Get(2); + + Assert.That(result.Name, Is.EqualTo("Goblin")); + } + + /// + /// 验证重复主键会在加载期被拒绝,避免运行期覆盖旧值。 + /// + [Test] + public void Constructor_Should_Throw_When_Duplicate_Key_Is_Detected() + { + Assert.Throws(() => + new InMemoryConfigTable( + new[] + { + new MonsterConfigStub(1, "Slime"), + new MonsterConfigStub(1, "Goblin") + }, + static config => config.Id)); + } + + /// + /// 验证 All 返回的集合包含完整快照。 + /// + [Test] + public void All_Should_Return_All_Configs() + { + var table = new InMemoryConfigTable( + new[] + { + new MonsterConfigStub(1, "Slime"), + new MonsterConfigStub(2, "Goblin") + }, + static config => config.Id); + + var all = table.All(); + + Assert.That(all, Has.Count.EqualTo(2)); + Assert.That(all.Select(static config => config.Name), Is.EquivalentTo(new[] { "Slime", "Goblin" })); + } + + /// + /// 用于配置表测试的最小配置类型。 + /// + /// 配置主键。 + /// 配置名称。 + private sealed record MonsterConfigStub(int Id, string Name); +} \ No newline at end of file diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs new file mode 100644 index 0000000..ebdea1c --- /dev/null +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -0,0 +1,1016 @@ +using System.IO; +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证 YAML 配置加载器的目录扫描与注册行为。 +/// +[TestFixture] +public class YamlConfigLoaderTests +{ + /// + /// 为每个测试创建独立临时目录,避免文件系统状态互相污染。 + /// + [SetUp] + public void SetUp() + { + _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_rootPath); + } + + /// + /// 清理测试期间创建的临时目录。 + /// + [TearDown] + public void TearDown() + { + if (Directory.Exists(_rootPath)) + { + Directory.Delete(_rootPath, true); + } + } + + private string _rootPath = null!; + + /// + /// 验证加载器能够扫描 YAML 文件并将结果写入注册表。 + /// + [Test] + public async Task LoadAsync_Should_Register_Table_From_Yaml_Files() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + CreateConfigFile( + "monster/goblin.yml", + """ + id: 2 + name: Goblin + hp: 30 + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(2)); + Assert.That(table.Get(1).Name, Is.EqualTo("Slime")); + Assert.That(table.Get(2).Hp, Is.EqualTo(30)); + }); + } + + /// + /// 验证注册的配置目录不存在时会抛出清晰错误。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Config_Directory_Does_Not_Exist() + { + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", 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("monster")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证某个配置表加载失败时,注册表不会留下部分成功的中间状态。 + /// + [Test] + public void LoadAsync_Should_Not_Mutate_Registry_When_A_Later_Table_Fails() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + + var registry = new ConfigRegistry(); + registry.RegisterTable( + "existing", + new InMemoryConfigTable( + new[] + { + new ExistingConfigStub(100, "Original") + }, + static config => config.Id)); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", static config => config.Id) + .RegisterTable("broken", "broken", static config => config.Id); + + Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(registry.Count, Is.EqualTo(1)); + Assert.That(registry.HasTable("monster"), Is.False); + Assert.That(registry.GetTable("existing").Get(100).Name, Is.EqualTo("Original")); + }); + } + + /// + /// 验证非法 YAML 会被包装成带文件路径的反序列化错误。 + /// + [Test] + public void LoadAsync_Should_Throw_With_File_Path_When_Yaml_Is_Invalid() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: [1 + name: Slime + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", 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("slime.yaml")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证启用 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 校验后,标量 enum 限制会在运行时被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Scalar_Value_Is_Not_Declared_In_Schema_Enum() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + rarity: epic + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "rarity"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "rarity": { + "type": "string", + "enum": ["common", "rare"] + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("common")); + Assert.That(exception!.Message, Does.Contain("rare")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证启用 schema 校验后,未知字段不会再被静默忽略。 + /// + [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)); + }); + } + + /// + /// 验证数组元素上的 enum 限制会按 schema 在运行时生效。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Array_Item_Is_Not_Declared_In_Schema_Enum() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + tags: + - fire + - poison + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "tags": { + "type": "array", + "items": { + "type": "string", + "enum": ["fire", "ice"] + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("fire")); + Assert.That(exception!.Message, Does.Contain("ice")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证绑定跨表引用 schema 时,存在的目标行可以通过加载校验。 + /// + [Test] + public async Task LoadAsync_Should_Accept_Existing_Cross_Table_Reference() + { + CreateConfigFile( + "item/potion.yaml", + """ + id: potion + name: Potion + """); + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropItemId: potion + """); + CreateSchemaFile( + "schemas/item.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + } + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropItemId"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropItemId": { + "type": "string", + "x-gframework-ref-table": "item" + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("item", "item", "schemas/item.schema.json", + static config => config.Id) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + Assert.Multiple(() => + { + Assert.That(registry.GetTable("item").ContainsKey("potion"), Is.True); + Assert.That(registry.GetTable("monster").Get(1).DropItemId, + Is.EqualTo("potion")); + }); + } + + /// + /// 验证缺失的跨表引用会阻止整批配置写入注册表。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Cross_Table_Reference_Target_Is_Missing() + { + CreateConfigFile( + "item/slime-gel.yaml", + """ + id: slime_gel + name: Slime Gel + """); + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropItemId: potion + """); + CreateSchemaFile( + "schemas/item.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + } + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropItemId"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropItemId": { + "type": "string", + "x-gframework-ref-table": "item" + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("item", "item", "schemas/item.schema.json", + static config => config.Id) + .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("dropItemId")); + Assert.That(exception!.Message, Does.Contain("potion")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证跨表引用同样支持标量数组中的每个元素。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Array_Reference_Item_Is_Missing() + { + CreateConfigFile( + "item/potion.yaml", + """ + id: potion + name: Potion + """); + CreateConfigFile( + "item/slime-gel.yaml", + """ + id: slime_gel + name: Slime Gel + """); + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropItemIds: + - potion + - missing_item + """); + CreateSchemaFile( + "schemas/item.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + } + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropItemIds"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropItemIds": { + "type": "array", + "items": { "type": "string" }, + "x-gframework-ref-table": "item" + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("item", "item", "schemas/item.schema.json", + static config => config.Id) + .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("dropItemIds[1]")); + Assert.That(exception!.Message, Does.Contain("missing_item")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。 + /// + [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(); + } + } + + /// + /// 验证当被引用表变更导致依赖表引用失效时,热重载会整体回滚受影响表。 + /// + [Test] + public async Task EnableHotReload_Should_Keep_Previous_State_When_Dependency_Table_Breaks_Cross_Table_Reference() + { + CreateConfigFile( + "item/potion.yaml", + """ + id: potion + name: Potion + """); + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropItemId: potion + """); + CreateSchemaFile( + "schemas/item.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + } + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropItemId"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropItemId": { + "type": "string", + "x-gframework-ref-table": "item" + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("item", "item", "schemas/item.schema.json", + static config => config.Id) + .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 + { + CreateConfigFile( + "item/potion.yaml", + """ + id: elixir + name: Elixir + """); + + var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5)); + + Assert.Multiple(() => + { + Assert.That(failure.TableName, Is.EqualTo("item")); + Assert.That(failure.Exception.Message, Does.Contain("dropItemId")); + Assert.That(registry.GetTable("item").ContainsKey("potion"), Is.True); + Assert.That(registry.GetTable("monster").Get(1).DropItemId, + Is.EqualTo("potion")); + }); + } + finally + { + hotReload.UnRegister(); + } + } + + /// + /// 创建测试用配置文件。 + /// + /// 相对根目录的文件路径。 + /// 文件内容。 + private void CreateConfigFile(string relativePath, string content) + { + var fullPath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(fullPath, content); + } + + /// + /// 创建测试用 schema 文件。 + /// + /// 相对根目录的文件路径。 + /// 文件内容。 + private void CreateSchemaFile(string relativePath, string content) + { + 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 加载测试的最小怪物配置类型。 + /// + private sealed class MonsterConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置生命值。 + /// + 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(); + } + + /// + /// 用于跨表引用测试的最小物品配置类型。 + /// + private sealed class ItemConfigStub + { + /// + /// 获取或设置主键。 + /// + public string Id { get; set; } = string.Empty; + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + } + + /// + /// 用于单值跨表引用测试的怪物配置类型。 + /// + private sealed class MonsterDropConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置掉落物品主键。 + /// + public string DropItemId { get; set; } = string.Empty; + } + + /// + /// 用于数组跨表引用测试的怪物配置类型。 + /// + private sealed class MonsterDropArrayConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置掉落物品主键列表。 + /// + public List DropItemIds { get; set; } = new(); + } + + /// + /// 用于验证注册表一致性的现有配置类型。 + /// + /// 配置主键。 + /// 配置名称。 + private sealed record ExistingConfigStub(int Id, string Name); +} \ No newline at end of file diff --git a/GFramework.Game/Config/ConfigRegistry.cs b/GFramework.Game/Config/ConfigRegistry.cs new file mode 100644 index 0000000..a637dff --- /dev/null +++ b/GFramework.Game/Config/ConfigRegistry.cs @@ -0,0 +1,181 @@ +using GFramework.Game.Abstractions.Config; + +namespace GFramework.Game.Config; + +/// +/// 默认配置注册表实现。 +/// 该类型负责统一管理按名称注册的配置表,并在消费端提供类型安全的解析入口。 +/// 为了支持开发期热重载,注册行为采用覆盖策略而不是拒绝重复名称。 +/// +public sealed class ConfigRegistry : IConfigRegistry +{ + private const string NameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace."; + + private readonly ConcurrentDictionary _tables = new(StringComparer.Ordinal); + + /// + /// 获取已注册的配置表数量。 + /// + public int Count => _tables.Count; + + /// + /// 获取所有已注册配置表的名称集合,按字典序排序。 + /// + /// 返回只读的配置表名称集合。 + public IReadOnlyCollection GetTableNames() + { + return _tables.Keys.OrderBy(static key => key, StringComparer.Ordinal).ToArray(); + } + + /// + /// 注册一个配置表到注册表中。 + /// 如果同名的配置表已存在,则会覆盖原有注册以支持热重载。 + /// + /// 配置表主键的类型,必须为非空类型。 + /// 配置表值的类型。 + /// 配置表的注册名称,用于后续查找。 + /// 要注册的配置表实例。 + /// 为 null、空或仅包含空白字符时抛出。 + /// 为 null 时抛出。 + public void RegisterTable(string name, IConfigTable table) + where TKey : notnull + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + ArgumentNullException.ThrowIfNull(table); + + _tables[name] = table; + } + + /// + /// 根据名称获取已注册的配置表,并进行类型验证。 + /// + /// 期望的主键类型,必须为非空类型。 + /// 期望的值类型。 + /// 要查找的配置表名称。 + /// 返回类型匹配的配置表实例。 + /// 为 null、空或仅包含空白字符时抛出。 + /// 当指定名称的配置表不存在时抛出。 + /// + /// 当找到的配置表类型与请求的类型不匹配时抛出。 + /// + public IConfigTable GetTable(string name) + where TKey : notnull + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + if (!_tables.TryGetValue(name, out var table)) + { + throw new KeyNotFoundException($"Config table '{name}' was not found."); + } + + if (table is IConfigTable typedTable) + { + return typedTable; + } + + throw new InvalidOperationException( + $"Config table '{name}' was registered as '{table.KeyType.Name} -> {table.ValueType.Name}', " + + $"but the caller requested '{typeof(TKey).Name} -> {typeof(TValue).Name}'."); + } + + /// + /// 尝试根据名称获取配置表,操作失败时不会抛出异常。 + /// + /// 期望的主键类型,必须为非空类型。 + /// 期望的值类型。 + /// 要查找的配置表名称。 + /// + /// 输出参数,如果查找成功则返回类型匹配的配置表实例,否则为 null。 + /// + /// 如果找到指定名称且类型匹配的配置表则返回 true,否则返回 false。 + /// 为 null、空或仅包含空白字符时抛出。 + public bool TryGetTable(string name, out IConfigTable? table) + where TKey : notnull + { + table = default; + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + if (!_tables.TryGetValue(name, out var rawTable)) + { + return false; + } + + if (rawTable is not IConfigTable typedTable) + { + return false; + } + + table = typedTable; + return true; + } + + /// + /// 尝试根据名称获取原始配置表。 + /// + /// 要查找的配置表名称。 + /// 输出参数,如果查找成功则返回原始配置表实例,否则为 null。 + /// 如果找到指定名称的配置表则返回 true,否则返回 false。 + /// 为 null、空或仅包含空白字符时抛出。 + public bool TryGetTable(string name, out IConfigTable? table) + { + table = default; + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + return _tables.TryGetValue(name, out table); + } + + /// + /// 检查指定名称的配置表是否已注册。 + /// + /// 要检查的配置表名称。 + /// 如果配置表已注册则返回 true,否则返回 false。 + /// 为 null、空或仅包含空白字符时抛出。 + public bool HasTable(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + return _tables.ContainsKey(name); + } + + /// + /// 从注册表中移除指定名称的配置表。 + /// + /// 要移除的配置表名称。 + /// 如果配置表存在并被成功移除则返回 true,否则返回 false。 + /// 为 null、空或仅包含空白字符时抛出。 + public bool RemoveTable(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + return _tables.TryRemove(name, out _); + } + + /// + /// 清空注册表中的所有配置表。 + /// + public void Clear() + { + _tables.Clear(); + } +} \ No newline at end of file diff --git a/GFramework.Game/Config/InMemoryConfigTable.cs b/GFramework.Game/Config/InMemoryConfigTable.cs new file mode 100644 index 0000000..bf6bd28 --- /dev/null +++ b/GFramework.Game/Config/InMemoryConfigTable.cs @@ -0,0 +1,118 @@ +using System.Collections.ObjectModel; +using GFramework.Game.Abstractions.Config; + +namespace GFramework.Game.Config; + +/// +/// 基于内存字典的只读配置表实现。 +/// 该实现用于 Runtime MVP 阶段,为加载器和注册表提供稳定的只读查询对象。 +/// +/// 配置表主键类型。 +/// 配置项值类型。 +public sealed class InMemoryConfigTable : IConfigTable + where TKey : notnull +{ + private readonly IReadOnlyCollection _allValues; + private readonly IReadOnlyDictionary _entries; + + /// + /// 使用配置项序列和主键选择器创建内存配置表。 + /// + /// 配置项序列。 + /// 用于提取主键的委托。 + /// 可选的主键比较器。 + /// 为空时抛出。 + /// 当配置项主键重复时抛出。 + public InMemoryConfigTable( + IEnumerable values, + Func keySelector, + IEqualityComparer? comparer = null) + { + ArgumentNullException.ThrowIfNull(values); + ArgumentNullException.ThrowIfNull(keySelector); + + var dictionary = new Dictionary(comparer); + var allValues = new List(); + + foreach (var value in values) + { + var key = keySelector(value); + + // 配置表必须在加载期拒绝重复主键,否则运行期查询结果将不可预测。 + if (!dictionary.TryAdd(key, value)) + { + throw new InvalidOperationException( + $"Duplicate config key '{key}' was detected for table value type '{typeof(TValue).Name}'."); + } + + allValues.Add(value); + } + + _entries = new ReadOnlyDictionary(dictionary); + _allValues = new ReadOnlyCollection(allValues); + } + + /// + /// 获取配置表的主键类型。 + /// + public Type KeyType => typeof(TKey); + + /// + /// 获取配置表的值类型。 + /// + public Type ValueType => typeof(TValue); + + /// + /// 获取配置表中配置项的数量。 + /// + public int Count => _entries.Count; + + /// + /// 根据主键获取配置项的值。 + /// + /// 要查找的配置项主键。 + /// 返回对应主键的配置项值。 + /// 当指定主键的配置项不存在时抛出。 + public TValue Get(TKey key) + { + if (!_entries.TryGetValue(key, out var value)) + { + throw new KeyNotFoundException( + $"Config key '{key}' was not found in table '{typeof(TValue).Name}'."); + } + + return value; + } + + /// + /// 尝试根据主键获取配置项的值,操作失败时不会抛出异常。 + /// + /// 要查找的配置项主键。 + /// + /// 输出参数,如果查找成功则返回对应的配置项值,否则为默认值。 + /// + /// 如果找到指定主键的配置项则返回 true,否则返回 false。 + public bool TryGet(TKey key, out TValue? value) + { + return _entries.TryGetValue(key, out value); + } + + /// + /// 检查指定主键的配置项是否存在于配置表中。 + /// + /// 要检查的配置项主键。 + /// 如果配置项已存在则返回 true,否则返回 false。 + public bool ContainsKey(TKey key) + { + return _entries.ContainsKey(key); + } + + /// + /// 获取配置表中所有配置项的集合。 + /// + /// 返回所有配置项值的只读集合。 + public IReadOnlyCollection All() + { + return _allValues; + } +} \ No newline at end of file diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs new file mode 100644 index 0000000..3ea6cdb --- /dev/null +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -0,0 +1,985 @@ +using GFramework.Core.Abstractions.Events; +using GFramework.Game.Abstractions.Config; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace GFramework.Game.Config; + +/// +/// 基于文件目录的 YAML 配置加载器。 +/// 该实现用于 Runtime MVP 的文本配置接入阶段,通过显式注册表定义描述要加载的配置域, +/// 再在一次加载流程中统一解析并写入配置注册表。 +/// +public sealed class YamlConfigLoader : IConfigLoader +{ + private const string RootPathCannotBeNullOrWhiteSpaceMessage = "Root path cannot be null or whitespace."; + 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 Dictionary> _lastSuccessfulDependencies = + new(StringComparer.Ordinal); + + private readonly List _registrations = new(); + private readonly string _rootPath; + + /// + /// 使用指定配置根目录创建 YAML 配置加载器。 + /// + /// 配置根目录。 + /// 为空时抛出。 + public YamlConfigLoader(string rootPath) + { + if (string.IsNullOrWhiteSpace(rootPath)) + { + throw new ArgumentException(RootPathCannotBeNullOrWhiteSpaceMessage, nameof(rootPath)); + } + + _rootPath = rootPath; + _deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + } + + /// + /// 获取配置根目录。 + /// + public string RootPath => _rootPath; + + /// + /// 获取当前已注册的配置表定义数量。 + /// + public int RegistrationCount => _registrations.Count; + + /// + public async Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(registry); + + var loadedTables = new List(_registrations.Count); + + foreach (var registration in _registrations) + { + cancellationToken.ThrowIfCancellationRequested(); + loadedTables.Add(await registration.LoadAsync(_rootPath, _deserializer, cancellationToken)); + } + + CrossTableReferenceValidator.Validate(registry, loadedTables); + + // 仅当本轮所有配置表都成功加载后才写入注册表,避免暴露部分成功的中间状态。 + foreach (var loadedTable in loadedTables) + { + RegistrationDispatcher.Register(registry, loadedTable.Name, loadedTable.Table); + } + + UpdateLastSuccessfulDependencies(loadedTables); + } + + /// + /// 启用开发期热重载。 + /// 该能力会监听已注册配置表对应的配置目录和 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, + _lastSuccessfulDependencies, + onTableReloaded, + onTableReloadFailed, + debounceDelay ?? TimeSpan.FromMilliseconds(200)); + } + + private void UpdateLastSuccessfulDependencies(IEnumerable loadedTables) + { + _lastSuccessfulDependencies.Clear(); + + foreach (var loadedTable in loadedTables) + { + _lastSuccessfulDependencies[loadedTable.Name] = loadedTable.ReferencedTableNames; + } + } + + /// + /// 注册一个 YAML 配置表定义。 + /// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。 + /// + /// 配置主键类型。 + /// 配置值类型。 + /// 配置表名称。 + /// 相对配置根目录的子目录。 + /// 配置项主键提取器。 + /// 可选主键比较器。 + /// 当前加载器实例,以便链式注册。 + public YamlConfigLoader RegisterTable( + string tableName, + string relativePath, + 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)) + { + throw new ArgumentException(TableNameCannotBeNullOrWhiteSpaceMessage, nameof(tableName)); + } + + if (string.IsNullOrWhiteSpace(relativePath)) + { + throw new ArgumentException(RelativePathCannotBeNullOrWhiteSpaceMessage, nameof(relativePath)); + } + + ArgumentNullException.ThrowIfNull(keySelector); + + if (schemaRelativePath != null && string.IsNullOrWhiteSpace(schemaRelativePath)) + { + throw new ArgumentException( + SchemaRelativePathCannotBeNullOrWhiteSpaceMessage, + nameof(schemaRelativePath)); + } + + _registrations.Add( + new YamlTableRegistration( + tableName, + relativePath, + schemaRelativePath, + keySelector, + comparer)); + return this; + } + + /// + /// 负责在非泛型配置表与泛型注册表方法之间做分派。 + /// 该静态助手将运行时反射局部封装在加载器内部,避免向外暴露弱类型注册 API。 + /// + private static class RegistrationDispatcher + { + /// + /// 将强类型配置表写入注册表。 + /// + /// 目标配置注册表。 + /// 配置表名称。 + /// 已加载的配置表实例。 + /// 当传入表未实现强类型配置表契约时抛出。 + public static void Register(IConfigRegistry registry, string name, IConfigTable table) + { + var tableInterface = table.GetType() + .GetInterfaces() + .FirstOrDefault(static type => + type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IConfigTable<,>)); + + if (tableInterface == null) + { + throw new InvalidOperationException( + $"Loaded config table '{name}' does not implement '{typeof(IConfigTable<,>).Name}'."); + } + + var genericArguments = tableInterface.GetGenericArguments(); + var method = typeof(IConfigRegistry) + .GetMethod(nameof(IConfigRegistry.RegisterTable))! + .MakeGenericMethod(genericArguments[0], genericArguments[1]); + + method.Invoke(registry, new object[] { name, table }); + } + } + + /// + /// 定义 YAML 配置表注册项的统一内部契约。 + /// + private interface IYamlTableRegistration + { + /// + /// 获取配置表名称。 + /// + string Name { get; } + + /// + /// 获取相对配置根目录的子目录。 + /// + string RelativePath { get; } + + /// + /// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时返回空。 + /// + string? SchemaRelativePath { get; } + + /// + /// 从指定根目录加载配置表。 + /// + /// 配置根目录。 + /// YAML 反序列化器。 + /// 取消令牌。 + /// 已加载的配置表结果。 + Task LoadAsync( + string rootPath, + IDeserializer deserializer, + CancellationToken cancellationToken); + } + + /// + /// YAML 配置表注册项。 + /// + /// 配置主键类型。 + /// 配置项值类型。 + private sealed class YamlTableRegistration : IYamlTableRegistration + where TKey : notnull + { + private readonly IEqualityComparer? _comparer; + private readonly Func _keySelector; + + /// + /// 初始化 YAML 配置表注册项。 + /// + /// 配置表名称。 + /// 相对配置根目录的子目录。 + /// 相对配置根目录的 schema 文件路径;未启用 schema 校验时为空。 + /// 配置项主键提取器。 + /// 可选主键比较器。 + public YamlTableRegistration( + string name, + string relativePath, + string? schemaRelativePath, + Func keySelector, + IEqualityComparer? comparer) + { + Name = name; + RelativePath = relativePath; + SchemaRelativePath = schemaRelativePath; + _keySelector = keySelector; + _comparer = comparer; + } + + /// + /// 获取配置表名称。 + /// + public string Name { get; } + + /// + /// 获取相对配置根目录的子目录。 + /// + public string RelativePath { get; } + + /// + /// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时返回空。 + /// + public string? SchemaRelativePath { get; } + + /// + public async Task LoadAsync( + string rootPath, + IDeserializer deserializer, + CancellationToken cancellationToken) + { + var directoryPath = Path.Combine(rootPath, RelativePath); + if (!Directory.Exists(directoryPath)) + { + throw new DirectoryNotFoundException( + $"Config directory '{directoryPath}' was not found for table '{Name}'."); + } + + YamlConfigSchema? schema = null; + IReadOnlyCollection referencedTableNames = Array.Empty(); + if (!string.IsNullOrEmpty(SchemaRelativePath)) + { + var schemaPath = Path.Combine(rootPath, SchemaRelativePath); + schema = await YamlConfigSchemaValidator.LoadAsync(schemaPath, cancellationToken); + referencedTableNames = schema.ReferencedTableNames; + } + + var referenceUsages = new List(); + var values = new List(); + var files = Directory + .EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly) + .Where(static path => + path.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)) + .OrderBy(static path => path, StringComparer.Ordinal) + .ToArray(); + + foreach (var file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + + string yaml; + try + { + yaml = await File.ReadAllTextAsync(file, cancellationToken); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Failed to read config file '{file}' for table '{Name}'.", + exception); + } + + if (schema != null) + { + // 先按 schema 拒绝结构问题并提取跨表引用,避免被 IgnoreUnmatchedProperties 或默认值掩盖配置错误。 + referenceUsages.AddRange( + YamlConfigSchemaValidator.ValidateAndCollectReferences(schema, file, yaml)); + } + + try + { + var value = deserializer.Deserialize(yaml); + + if (value == null) + { + throw new InvalidOperationException("YAML content was deserialized to null."); + } + + values.Add(value); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Failed to deserialize config file '{file}' for table '{Name}' as '{typeof(TValue).Name}'.", + exception); + } + } + + try + { + var table = new InMemoryConfigTable(values, _keySelector, _comparer); + return new YamlTableLoadResult(Name, table, referencedTableNames, referenceUsages); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Failed to build config table '{Name}' from directory '{directoryPath}'.", + exception); + } + } + } + + /// + /// 表示单个注册项加载完成后的中间结果。 + /// 该结果同时携带配置表实例、schema 声明的依赖关系和 YAML 中提取出的实际引用,以便在批量提交前完成跨表一致性校验。 + /// + private sealed class YamlTableLoadResult + { + /// + /// 初始化一个表加载结果。 + /// + /// 配置表名称。 + /// 已构建好的配置表。 + /// schema 声明的依赖表名称集合。 + /// YAML 中提取出的实际引用集合。 + public YamlTableLoadResult( + string name, + IConfigTable table, + IReadOnlyCollection referencedTableNames, + IReadOnlyCollection referenceUsages) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(table); + ArgumentNullException.ThrowIfNull(referencedTableNames); + ArgumentNullException.ThrowIfNull(referenceUsages); + + Name = name; + Table = table; + ReferencedTableNames = referencedTableNames; + ReferenceUsages = referenceUsages; + } + + /// + /// 获取配置表名称。 + /// + public string Name { get; } + + /// + /// 获取已构建好的配置表。 + /// + public IConfigTable Table { get; } + + /// + /// 获取 schema 声明的依赖表名称集合。 + /// + public IReadOnlyCollection ReferencedTableNames { get; } + + /// + /// 获取 YAML 中提取出的实际引用集合。 + /// + public IReadOnlyCollection ReferenceUsages { get; } + } + + /// + /// 负责在所有注册项加载完成后执行跨表引用校验。 + /// 该阶段在真正写入注册表之前运行,确保任何缺失目标表、主键类型不兼容或目标行不存在的情况都会整体回滚。 + /// + private static class CrossTableReferenceValidator + { + /// + /// 使用本轮新加载结果与注册表中保留的旧表,一起验证跨表引用是否全部有效。 + /// + /// 当前配置注册表。 + /// 本轮加载出的配置表集合。 + public static void Validate(IConfigRegistry registry, IReadOnlyCollection loadedTables) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(loadedTables); + + var loadedTableLookup = loadedTables.ToDictionary(static table => table.Name, StringComparer.Ordinal); + + foreach (var loadedTable in loadedTables) + { + foreach (var referenceUsage in loadedTable.ReferenceUsages) + { + if (!TryResolveTargetTable(registry, loadedTableLookup, referenceUsage.ReferencedTableName, + out var targetTable)) + { + throw new InvalidOperationException( + $"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' references table '{referenceUsage.ReferencedTableName}', but that table is not available in the current loader batch or registry."); + } + + if (!TryConvertReferenceKey(referenceUsage, targetTable.KeyType, out var convertedKey, + out var conversionError)) + { + throw new InvalidOperationException( + $"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' cannot target table '{referenceUsage.ReferencedTableName}' with key type '{targetTable.KeyType.Name}'. {conversionError}"); + } + + if (!ContainsKey(targetTable, convertedKey!)) + { + throw new InvalidOperationException( + $"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' references missing key '{referenceUsage.RawValue}' in table '{referenceUsage.ReferencedTableName}'."); + } + } + } + } + + private static bool TryResolveTargetTable( + IConfigRegistry registry, + IReadOnlyDictionary loadedTableLookup, + string tableName, + out IConfigTable table) + { + if (loadedTableLookup.TryGetValue(tableName, out var loadedTable)) + { + table = loadedTable.Table; + return true; + } + + if (registry.TryGetTable(tableName, out var registeredTable) && registeredTable != null) + { + table = registeredTable; + return true; + } + + table = null!; + return false; + } + + private static bool TryConvertReferenceKey( + YamlConfigReferenceUsage referenceUsage, + Type targetKeyType, + out object? convertedKey, + out string errorMessage) + { + convertedKey = null; + errorMessage = string.Empty; + + if (referenceUsage.ValueType == YamlConfigSchemaPropertyType.String) + { + if (targetKeyType != typeof(string)) + { + errorMessage = + $"Reference values declared as schema type 'string' can currently only target string-key tables, but the target key type is '{targetKeyType.Name}'."; + return false; + } + + convertedKey = referenceUsage.RawValue; + return true; + } + + if (referenceUsage.ValueType != YamlConfigSchemaPropertyType.Integer) + { + errorMessage = + $"Reference values currently only support schema scalar types 'string' and 'integer', but the actual type is '{referenceUsage.ValueType}'."; + return false; + } + + return TryConvertIntegerKey(referenceUsage.RawValue, targetKeyType, out convertedKey, out errorMessage); + } + + private static bool TryConvertIntegerKey( + string rawValue, + Type targetKeyType, + out object? convertedKey, + out string errorMessage) + { + convertedKey = null; + errorMessage = string.Empty; + + if (targetKeyType == typeof(int) && + int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) + { + convertedKey = intValue; + return true; + } + + if (targetKeyType == typeof(long) && + long.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longValue)) + { + convertedKey = longValue; + return true; + } + + if (targetKeyType == typeof(short) && + short.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var shortValue)) + { + convertedKey = shortValue; + return true; + } + + if (targetKeyType == typeof(byte) && + byte.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var byteValue)) + { + convertedKey = byteValue; + return true; + } + + if (targetKeyType == typeof(uint) && + uint.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var uintValue)) + { + convertedKey = uintValue; + return true; + } + + if (targetKeyType == typeof(ulong) && + ulong.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ulongValue)) + { + convertedKey = ulongValue; + return true; + } + + if (targetKeyType == typeof(ushort) && + ushort.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ushortValue)) + { + convertedKey = ushortValue; + return true; + } + + if (targetKeyType == typeof(sbyte) && + sbyte.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sbyteValue)) + { + convertedKey = sbyteValue; + return true; + } + + errorMessage = + $"Reference value '{rawValue}' cannot be converted to supported target key type '{targetKeyType.Name}'. Integer references currently support the standard signed and unsigned integer CLR key types."; + return false; + } + + private static bool ContainsKey(IConfigTable table, object key) + { + var tableInterface = table.GetType() + .GetInterfaces() + .First(static type => + type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IConfigTable<,>)); + var containsKeyMethod = tableInterface.GetMethod(nameof(IConfigTable.ContainsKey))!; + return (bool)containsKeyMethod.Invoke(table, new[] { key })!; + } + } + + /// + /// 封装开发期热重载所需的文件监听与按表重载逻辑。 + /// 该会话只影响通过当前加载器注册的表,不尝试接管注册表中的其他来源数据。 + /// + private sealed class HotReloadSession : IUnRegister, IDisposable + { + private readonly TimeSpan _debounceDelay; + + private readonly Dictionary> _dependenciesByTable = + new(StringComparer.Ordinal); + + 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, + IReadOnlyDictionary> initialDependencies, + Action? onTableReloaded, + Action? onTableReloadFailed, + TimeSpan debounceDelay) + { + ArgumentNullException.ThrowIfNull(rootPath); + ArgumentNullException.ThrowIfNull(deserializer); + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(registrations); + ArgumentNullException.ThrowIfNull(initialDependencies); + + _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)); + _dependenciesByTable[registration.Name] = + initialDependencies.TryGetValue(registration.Name, out var dependencies) + ? dependencies + : Array.Empty(); + 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.ContainsKey(tableName)) + { + return; + } + + var reloadLock = _reloadLocks[tableName]; + await reloadLock.WaitAsync(cancellationToken); + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + var affectedTableNames = GetAffectedTableNames(tableName); + var loadedTables = new List(affectedTableNames.Count); + + // 目标表变更可能让依赖它的表立即失效,因此热重载需要按受影响闭包整体重验并整体提交。 + foreach (var affectedTableName in affectedTableNames) + { + cancellationToken.ThrowIfCancellationRequested(); + loadedTables.Add(await _registrations[affectedTableName].LoadAsync(_rootPath, _deserializer, + cancellationToken)); + } + + CrossTableReferenceValidator.Validate(_registry, loadedTables); + + foreach (var loadedTable in loadedTables) + { + RegistrationDispatcher.Register(_registry, loadedTable.Name, loadedTable.Table); + _dependenciesByTable[loadedTable.Name] = loadedTable.ReferencedTableNames; + } + + InvokeReloaded(tableName); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // 防抖替换或会话关闭导致的取消不应视为错误。 + } + catch (Exception exception) + { + InvokeReloadFailed(tableName, exception); + } + finally + { + reloadLock.Release(); + } + } + + private IReadOnlyCollection GetAffectedTableNames(string changedTableName) + { + var affectedTableNames = new HashSet(StringComparer.Ordinal) + { + changedTableName + }; + var pendingTableNames = new Queue(); + pendingTableNames.Enqueue(changedTableName); + + while (pendingTableNames.Count > 0) + { + var currentTableName = pendingTableNames.Dequeue(); + + foreach (var dependency in _dependenciesByTable) + { + if (!dependency.Value.Contains(currentTableName)) + { + continue; + } + + if (affectedTableNames.Add(dependency.Key)) + { + pendingTableNames.Enqueue(dependency.Key); + } + } + } + + return affectedTableNames + .OrderBy(static name => name, StringComparer.Ordinal) + .ToArray(); + } + + 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/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs new file mode 100644 index 0000000..857d960 --- /dev/null +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -0,0 +1,752 @@ +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)); + } + + var referencedTableNames = properties.Values + .Select(static property => property.ReferenceTableName) + .Where(static tableName => !string.IsNullOrWhiteSpace(tableName)) + .Cast() + .Distinct(StringComparer.Ordinal) + .ToArray(); + + return new YamlConfigSchema(schemaPath, properties, requiredProperties, referencedTableNames); + } + 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) + { + ValidateAndCollectReferences(schema, yamlPath, yamlText); + } + + /// + /// 使用已解析的 schema 校验 YAML 文本,并提取声明过的跨表引用。 + /// 该方法让结构校验与引用采集共享同一份 YAML 解析结果,避免加载器重复解析同一文件。 + /// + /// 已解析的 schema 模型。 + /// YAML 文件路径,仅用于诊断信息。 + /// YAML 文本内容。 + /// 当前 YAML 文件中声明的跨表引用集合。 + /// 当参数为空时抛出。 + /// 当 YAML 内容与 schema 不匹配时抛出。 + internal static IReadOnlyList ValidateAndCollectReferences( + 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 references = new List(); + 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, references); + } + + 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}'."); + } + } + + return references; + } + + 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}'.") + }; + + string? referenceTableName = null; + if (property.Value.TryGetProperty("x-gframework-ref-table", out var referenceTableElement)) + { + if (referenceTableElement.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Property '{property.Name}' in schema file '{schemaPath}' must declare a string 'x-gframework-ref-table' value."); + } + + referenceTableName = referenceTableElement.GetString(); + if (string.IsNullOrWhiteSpace(referenceTableName)) + { + throw new InvalidOperationException( + $"Property '{property.Name}' in schema file '{schemaPath}' must declare a non-empty 'x-gframework-ref-table' value."); + } + } + + if (propertyType != YamlConfigSchemaPropertyType.Array) + { + EnsureReferenceKeywordIsSupported(schemaPath, property.Name, propertyType, null, referenceTableName); + return new YamlConfigSchemaProperty( + property.Name, + propertyType, + null, + referenceTableName, + ParseEnumValues(schemaPath, property.Name, property.Value, propertyType, "enum"), + null); + } + + if (!property.Value.TryGetProperty("items", out var itemsElement) || + 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}'.") + }; + + EnsureReferenceKeywordIsSupported(schemaPath, property.Name, propertyType, itemType, referenceTableName); + return new YamlConfigSchemaProperty( + property.Name, + propertyType, + itemType, + referenceTableName, + null, + ParseEnumValues(schemaPath, property.Name, itemsElement, itemType, "items.enum")); + } + + private static void EnsureReferenceKeywordIsSupported( + string schemaPath, + string propertyName, + YamlConfigSchemaPropertyType propertyType, + YamlConfigSchemaPropertyType? itemType, + string? referenceTableName) + { + if (referenceTableName == null) + { + return; + } + + if (propertyType == YamlConfigSchemaPropertyType.String || + propertyType == YamlConfigSchemaPropertyType.Integer) + { + return; + } + + if (propertyType == YamlConfigSchemaPropertyType.Array && + (itemType == YamlConfigSchemaPropertyType.String || itemType == YamlConfigSchemaPropertyType.Integer)) + { + return; + } + + throw new InvalidOperationException( + $"Property '{propertyName}' in schema file '{schemaPath}' uses 'x-gframework-ref-table', but only string, integer, or arrays of those scalar types can declare cross-table references."); + } + + private static void ValidateNode( + string yamlPath, + string propertyName, + YamlNode node, + YamlConfigSchemaProperty property, + ICollection references) + { + if (property.PropertyType == YamlConfigSchemaPropertyType.Array) + { + if (node is not YamlSequenceNode sequenceNode) + { + throw new InvalidOperationException( + $"Property '{propertyName}' in config file '{yamlPath}' must be an array."); + } + + for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) + { + ValidateScalarNode( + yamlPath, + propertyName, + sequenceNode.Children[itemIndex], + property.ItemType!.Value, + property.ReferenceTableName, + property.ItemAllowedValues, + references, + isArrayItem: true, + itemIndex); + } + + return; + } + + ValidateScalarNode( + yamlPath, + propertyName, + node, + property.PropertyType, + property.ReferenceTableName, + property.AllowedValues, + references, + isArrayItem: false, + itemIndex: null); + } + + private static void ValidateScalarNode( + string yamlPath, + string propertyName, + YamlNode node, + YamlConfigSchemaPropertyType expectedType, + string? referenceTableName, + IReadOnlyCollection? allowedValues, + ICollection references, + bool isArrayItem, + int? itemIndex) + { + 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) + { + var normalizedValue = NormalizeScalarValue(expectedType, value); + if (allowedValues is { Count: > 0 } && + !allowedValues.Contains(normalizedValue, StringComparer.Ordinal)) + { + var enumSubject = isArrayItem + ? $"Array item in property '{propertyName}'" + : $"Property '{propertyName}'"; + throw new InvalidOperationException( + $"{enumSubject} in config file '{yamlPath}' must be one of [{string.Join(", ", allowedValues)}], but the current YAML scalar value is '{value}'."); + } + + if (referenceTableName != null) + { + references.Add( + new YamlConfigReferenceUsage( + yamlPath, + propertyName, + itemIndex, + normalizedValue, + referenceTableName, + expectedType)); + } + + 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 IReadOnlyCollection? ParseEnumValues( + string schemaPath, + string propertyName, + JsonElement element, + YamlConfigSchemaPropertyType expectedType, + string keywordName) + { + if (!element.TryGetProperty("enum", out var enumElement)) + { + return null; + } + + if (enumElement.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + $"Property '{propertyName}' in schema file '{schemaPath}' must declare '{keywordName}' as an array."); + } + + var allowedValues = new List(); + foreach (var item in enumElement.EnumerateArray()) + { + allowedValues.Add(NormalizeEnumValue(schemaPath, propertyName, keywordName, expectedType, item)); + } + + return allowedValues; + } + + private static string NormalizeEnumValue( + string schemaPath, + string propertyName, + string keywordName, + YamlConfigSchemaPropertyType expectedType, + JsonElement item) + { + try + { + return expectedType switch + { + YamlConfigSchemaPropertyType.String when item.ValueKind == JsonValueKind.String => + item.GetString() ?? string.Empty, + YamlConfigSchemaPropertyType.Integer when item.ValueKind == JsonValueKind.Number => + item.GetInt64().ToString(CultureInfo.InvariantCulture), + YamlConfigSchemaPropertyType.Number when item.ValueKind == JsonValueKind.Number => + item.GetDouble().ToString(CultureInfo.InvariantCulture), + YamlConfigSchemaPropertyType.Boolean when item.ValueKind == JsonValueKind.True => + bool.TrueString.ToLowerInvariant(), + YamlConfigSchemaPropertyType.Boolean when item.ValueKind == JsonValueKind.False => + bool.FalseString.ToLowerInvariant(), + _ => throw new InvalidOperationException() + }; + } + catch + { + throw new InvalidOperationException( + $"Property '{propertyName}' in schema file '{schemaPath}' contains a '{keywordName}' value that is incompatible with schema type '{GetTypeName(expectedType)}'."); + } + } + + private static string NormalizeScalarValue(YamlConfigSchemaPropertyType expectedType, string value) + { + return expectedType switch + { + YamlConfigSchemaPropertyType.String => value, + YamlConfigSchemaPropertyType.Integer => long.Parse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), + YamlConfigSchemaPropertyType.Number => double.Parse( + value, + NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), + YamlConfigSchemaPropertyType.Boolean => bool.Parse(value).ToString().ToLowerInvariant(), + _ => value + }; + } + + private static string GetTypeName(YamlConfigSchemaPropertyType type) + { + return type switch + { + 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 属性定义。 + /// 必填属性集合。 + /// Schema 声明的目标引用表名称集合。 + public YamlConfigSchema( + string schemaPath, + IReadOnlyDictionary properties, + IReadOnlyCollection requiredProperties, + IReadOnlyCollection referencedTableNames) + { + ArgumentNullException.ThrowIfNull(schemaPath); + ArgumentNullException.ThrowIfNull(properties); + ArgumentNullException.ThrowIfNull(requiredProperties); + ArgumentNullException.ThrowIfNull(referencedTableNames); + + SchemaPath = schemaPath; + Properties = properties; + RequiredProperties = requiredProperties; + ReferencedTableNames = referencedTableNames; + } + + /// + /// 获取 schema 文件路径。 + /// + public string SchemaPath { get; } + + /// + /// 获取按属性名索引的 schema 属性定义。 + /// + public IReadOnlyDictionary Properties { get; } + + /// + /// 获取 schema 声明的必填属性集合。 + /// + public IReadOnlyCollection RequiredProperties { get; } + + /// + /// 获取 schema 声明的目标引用表名称集合。 + /// 该信息用于热重载时推导受影响的依赖表闭包。 + /// + public IReadOnlyCollection ReferencedTableNames { get; } +} + +/// +/// 表示单个 schema 属性的最小运行时描述。 +/// +internal sealed class YamlConfigSchemaProperty +{ + /// + /// 初始化一个 schema 属性描述。 + /// + /// 属性名称。 + /// 属性类型。 + /// 数组元素类型;仅当属性类型为数组时有效。 + /// 目标引用表名称;未声明跨表引用时为空。 + /// 标量允许值集合;未声明 enum 时为空。 + /// 数组元素允许值集合;未声明 items.enum 时为空。 + public YamlConfigSchemaProperty( + string name, + YamlConfigSchemaPropertyType propertyType, + YamlConfigSchemaPropertyType? itemType, + string? referenceTableName, + IReadOnlyCollection? allowedValues, + IReadOnlyCollection? itemAllowedValues) + { + ArgumentNullException.ThrowIfNull(name); + + Name = name; + PropertyType = propertyType; + ItemType = itemType; + ReferenceTableName = referenceTableName; + AllowedValues = allowedValues; + ItemAllowedValues = itemAllowedValues; + } + + /// + /// 获取属性名称。 + /// + public string Name { get; } + + /// + /// 获取属性类型。 + /// + public YamlConfigSchemaPropertyType PropertyType { get; } + + /// + /// 获取数组元素类型;非数组属性时返回空。 + /// + public YamlConfigSchemaPropertyType? ItemType { get; } + + /// + /// 获取目标引用表名称;未声明跨表引用时返回空。 + /// + public string? ReferenceTableName { get; } + + /// + /// 获取标量允许值集合;未声明 enum 时返回空。 + /// + public IReadOnlyCollection? AllowedValues { get; } + + /// + /// 获取数组元素允许值集合;未声明 items.enum 时返回空。 + /// + public IReadOnlyCollection? ItemAllowedValues { get; } +} + +/// +/// 表示单个 YAML 文件中提取出的跨表引用。 +/// 该模型保留源文件、字段路径和目标表等诊断信息,以便加载器在批量校验失败时给出可定位的错误。 +/// +internal sealed class YamlConfigReferenceUsage +{ + /// + /// 初始化一个跨表引用使用记录。 + /// + /// 源 YAML 文件路径。 + /// 声明引用的属性名。 + /// 数组元素索引;标量属性时为空。 + /// YAML 中的原始标量值。 + /// 目标配置表名称。 + /// 引用值的 schema 标量类型。 + public YamlConfigReferenceUsage( + string yamlPath, + string propertyName, + int? itemIndex, + string rawValue, + string referencedTableName, + YamlConfigSchemaPropertyType valueType) + { + ArgumentNullException.ThrowIfNull(yamlPath); + ArgumentNullException.ThrowIfNull(propertyName); + ArgumentNullException.ThrowIfNull(rawValue); + ArgumentNullException.ThrowIfNull(referencedTableName); + + YamlPath = yamlPath; + PropertyName = propertyName; + ItemIndex = itemIndex; + RawValue = rawValue; + ReferencedTableName = referencedTableName; + ValueType = valueType; + } + + /// + /// 获取源 YAML 文件路径。 + /// + public string YamlPath { get; } + + /// + /// 获取声明引用的属性名。 + /// + public string PropertyName { get; } + + /// + /// 获取数组元素索引;标量属性时返回空。 + /// + public int? ItemIndex { get; } + + /// + /// 获取 YAML 中的原始标量值。 + /// + public string RawValue { get; } + + /// + /// 获取目标配置表名称。 + /// + public string ReferencedTableName { get; } + + /// + /// 获取引用值的 schema 标量类型。 + /// + public YamlConfigSchemaPropertyType ValueType { get; } + + /// + /// 获取便于诊断显示的字段路径。 + /// + public string DisplayPath => ItemIndex.HasValue ? $"{PropertyName}[{ItemIndex.Value}]" : PropertyName; +} + +/// +/// 表示当前运行时 schema 校验器支持的属性类型。 +/// +internal enum YamlConfigSchemaPropertyType +{ + /// + /// 整数类型。 + /// + Integer, + + /// + /// 数值类型。 + /// + Number, + + /// + /// 布尔类型。 + /// + Boolean, + + /// + /// 字符串类型。 + /// + String, + + /// + /// 数组类型。 + /// + Array +} \ No newline at end of file diff --git a/GFramework.Game/GFramework.Game.csproj b/GFramework.Game/GFramework.Game.csproj index 5bb94d9..6a81f01 100644 --- a/GFramework.Game/GFramework.Game.csproj +++ b/GFramework.Game/GFramework.Game.csproj @@ -14,5 +14,6 @@ + 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 diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs new file mode 100644 index 0000000..242bfe9 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs @@ -0,0 +1,145 @@ +using System.IO; + +namespace GFramework.SourceGenerators.Tests.Config; + +/// +/// 验证 schema 配置生成器的生成快照。 +/// +[TestFixture] +public class SchemaConfigGeneratorSnapshotTests +{ + /// + /// 验证一个最小 monster schema 能生成配置类型和表包装。 + /// + [Test] + public async Task Snapshot_SchemaConfigGenerator() + { + const string source = """ + using System; + using System.Collections.Generic; + + namespace GFramework.Game.Abstractions.Config + { + public interface IConfigTable + { + Type KeyType { get; } + Type ValueType { get; } + int Count { get; } + } + + public interface IConfigTable : IConfigTable + where TKey : notnull + { + TValue Get(TKey key); + bool TryGet(TKey key, out TValue? value); + bool ContainsKey(TKey key); + IReadOnlyCollection All(); + } + } + """; + + const string schema = """ + { + "title": "Monster Config", + "description": "Represents one monster entry generated from schema metadata.", + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "integer", + "description": "Unique monster identifier." + }, + "name": { + "type": "string", + "title": "Monster Name", + "description": "Localized monster display name.", + "default": "Slime", + "enum": ["Slime", "Goblin"] + }, + "hp": { + "type": "integer", + "default": 10 + }, + "dropItems": { + "description": "Referenced drop ids.", + "type": "array", + "items": { + "type": "string", + "enum": ["potion", "slime_gel"] + }, + "default": ["potion"], + "x-gframework-ref-table": "item" + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var generatedSources = result.Results + .Single() + .GeneratedSources + .ToDictionary( + static sourceResult => sourceResult.HintName, + static sourceResult => sourceResult.SourceText.ToString(), + StringComparer.Ordinal); + + var snapshotFolder = Path.Combine( + TestContext.CurrentContext.TestDirectory, + "..", + "..", + "..", + "Config", + "snapshots", + "SchemaConfigGenerator"); + snapshotFolder = Path.GetFullPath(snapshotFolder); + + await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterConfig.g.cs", "MonsterConfig.g.txt"); + await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterTable.g.cs", "MonsterTable.g.txt"); + } + + /// + /// 对单个生成文件执行快照断言。 + /// + /// 生成结果字典。 + /// 快照目录。 + /// 快照文件名。 + private static async Task AssertSnapshotAsync( + IReadOnlyDictionary generatedSources, + string snapshotFolder, + string generatedFileName, + string snapshotFileName) + { + if (!generatedSources.TryGetValue(generatedFileName, out var actual)) + { + Assert.Fail($"Generated source '{generatedFileName}' was not found."); + return; + } + + var path = Path.Combine(snapshotFolder, snapshotFileName); + if (!File.Exists(path)) + { + Directory.CreateDirectory(snapshotFolder); + await File.WriteAllTextAsync(path, actual); + Assert.Fail($"Snapshot not found. Generated new snapshot at:\n{path}"); + } + + var expected = await File.ReadAllTextAsync(path); + Assert.That( + Normalize(expected), + Is.EqualTo(Normalize(actual)), + $"Snapshot mismatch: {generatedFileName}"); + } + + /// + /// 标准化快照文本以避免平台换行差异。 + /// + /// 原始文本。 + /// 标准化后的文本。 + private static string Normalize(string text) + { + return text.Replace("\r\n", "\n", StringComparison.Ordinal).Trim(); + } +} \ No newline at end of file diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs new file mode 100644 index 0000000..6a71777 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -0,0 +1,48 @@ +namespace GFramework.SourceGenerators.Tests.Config; + +/// +/// 验证 schema 配置生成器的错误诊断行为。 +/// +[TestFixture] +public class SchemaConfigGeneratorTests +{ + /// + /// 验证缺失必填 id 字段时会产生命名明确的诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Id_Property_Is_Missing() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostics = result.Results.Single().Diagnostics; + var diagnostic = diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_003")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("monster.schema.json")); + }); + } +} \ No newline at end of file diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs b/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs new file mode 100644 index 0000000..4da9519 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/SchemaGeneratorTestDriver.cs @@ -0,0 +1,88 @@ +using System.Collections.Immutable; +using System.IO; +using GFramework.SourceGenerators.Config; +using Microsoft.CodeAnalysis.CSharp; + +namespace GFramework.SourceGenerators.Tests.Config; + +/// +/// 为 schema 配置生成器提供测试驱动。 +/// 该驱动直接使用 Roslyn GeneratorDriver 运行 AdditionalFiles 场景, +/// 以便测试基于 schema 文件的代码生成行为。 +/// +public static class SchemaGeneratorTestDriver +{ + /// + /// 运行 schema 配置生成器,并返回生成结果。 + /// + /// 测试用源码。 + /// AdditionalFiles 集合。 + /// 生成器运行结果。 + public static GeneratorDriverRunResult Run( + string source, + params (string path, string content)[] additionalFiles) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create( + "SchemaConfigGeneratorTests", + new[] { syntaxTree }, + GetMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var additionalTexts = additionalFiles + .Select(static item => (AdditionalText)new InMemoryAdditionalText(item.path, item.content)) + .ToImmutableArray(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: new[] { new SchemaConfigGenerator().AsSourceGenerator() }, + additionalTexts: additionalTexts, + parseOptions: (CSharpParseOptions)syntaxTree.Options); + + driver = driver.RunGenerators(compilation); + return driver.GetRunResult(); + } + + /// + /// 获取测试编译所需的运行时元数据引用。 + /// + /// 元数据引用集合。 + private static IEnumerable GetMetadataReferences() + { + var trustedPlatformAssemblies = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))? + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + ?? Array.Empty(); + + return trustedPlatformAssemblies + .Select(static path => MetadataReference.CreateFromFile(path)); + } + + /// + /// 用于测试 AdditionalFiles 的内存实现。 + /// + private sealed class InMemoryAdditionalText : AdditionalText + { + private readonly SourceText _text; + + /// + /// 创建内存 AdditionalText。 + /// + /// 虚拟文件路径。 + /// 文件内容。 + public InMemoryAdditionalText( + string path, + string content) + { + Path = path; + _text = SourceText.From(content); + } + + /// + public override string Path { get; } + + /// + public override SourceText GetText(CancellationToken cancellationToken = default) + { + return _text; + } + } +} \ No newline at end of file diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt new file mode 100644 index 0000000..6b4a36a --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt @@ -0,0 +1,51 @@ +// +#nullable enable + +namespace GFramework.Game.Config.Generated; + +/// +/// Auto-generated config type for schema file 'monster.schema.json'. +/// Represents one monster entry generated from schema metadata. +/// +public sealed partial class MonsterConfig +{ + /// + /// Unique monster identifier. + /// + /// + /// Schema property: 'id'. + /// + public int Id { get; set; } + + /// + /// Localized monster display name. + /// + /// + /// Schema property: 'name'. + /// Display title: 'Monster Name'. + /// Allowed values: Slime, Goblin. + /// Generated default initializer: = "Slime"; + /// + public string Name { get; set; } = "Slime"; + + /// + /// Gets or sets the value mapped from schema property 'hp'. + /// + /// + /// Schema property: 'hp'. + /// Generated default initializer: = 10; + /// + public int? Hp { get; set; } = 10; + + /// + /// Referenced drop ids. + /// + /// + /// Schema property: 'dropItems'. + /// Allowed values: potion, slime_gel. + /// References config table: 'item'. + /// Generated default initializer: = new string[] { "potion" }; + /// + public global::System.Collections.Generic.IReadOnlyList DropItems { get; set; } = new string[] { "potion" }; + +} diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt new file mode 100644 index 0000000..2e1a442 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt @@ -0,0 +1,55 @@ +// +#nullable enable + +namespace GFramework.Game.Config.Generated; + +/// +/// Auto-generated table wrapper for schema file 'monster.schema.json'. +/// The wrapper keeps generated call sites strongly typed while delegating actual storage to the runtime config table implementation. +/// +public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.Config.IConfigTable +{ + private readonly global::GFramework.Game.Abstractions.Config.IConfigTable _inner; + + /// + /// Creates a generated table wrapper around the runtime config table instance. + /// + /// The runtime config table instance. + public MonsterTable(global::GFramework.Game.Abstractions.Config.IConfigTable inner) + { + _inner = inner ?? throw new global::System.ArgumentNullException(nameof(inner)); + } + + /// + public global::System.Type KeyType => _inner.KeyType; + + /// + public global::System.Type ValueType => _inner.ValueType; + + /// + public int Count => _inner.Count; + + /// + public MonsterConfig Get(int key) + { + return _inner.Get(key); + } + + /// + public bool TryGet(int key, out MonsterConfig? value) + { + return _inner.TryGet(key, out value); + } + + /// + public bool ContainsKey(int key) + { + return _inner.ContainsKey(key); + } + + /// + public global::System.Collections.Generic.IReadOnlyCollection All() + { + return _inner.All(); + } +} diff --git a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md index 8b25a37..356e4cb 100644 --- a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -3,21 +3,26 @@ ### New Rules - Rule ID | Category | Severity | Notes ------------------------|----------------------------------|----------|------------------------ - GF_Logging_001 | GFramework.Godot.logging | Warning | LoggerDiagnostics - GF_Rule_001 | GFramework.SourceGenerators.rule | Error | ContextAwareDiagnostic - GF_ContextGet_001 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_002 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_003 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_004 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_005 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_006 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics - GF_ContextGet_007 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics - GF_ContextGet_008 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics - GF_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic - GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic - GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic - GF_Priority_004 | GFramework.Priority | Error | PriorityDiagnostic - GF_Priority_005 | GFramework.Priority | Error | PriorityDiagnostic - GF_Priority_Usage_001 | GFramework.Usage | Info | PriorityUsageAnalyzer + Rule ID | Category | Severity | Notes +-----------------------|------------------------------------|----------|------------------------- + GF_Logging_001 | GFramework.Godot.logging | Warning | LoggerDiagnostics + GF_Rule_001 | GFramework.SourceGenerators.rule | Error | ContextAwareDiagnostic + GF_ContextGet_001 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_002 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_003 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_004 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_005 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_006 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics + GF_ContextGet_007 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics + GF_ContextGet_008 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics + GF_ConfigSchema_001 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_002 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_003 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_004 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_005 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic + GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic + GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic + GF_Priority_004 | GFramework.Priority | Error | PriorityDiagnostic + GF_Priority_005 | GFramework.Priority | Error | PriorityDiagnostic + GF_Priority_Usage_001 | GFramework.Usage | Info | PriorityUsageAnalyzer diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs new file mode 100644 index 0000000..174f0ed --- /dev/null +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -0,0 +1,704 @@ +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.Json; +using GFramework.SourceGenerators.Diagnostics; + +namespace GFramework.SourceGenerators.Config; + +/// +/// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。 +/// 当前实现聚焦 Runtime MVP 需要的最小能力:单 schema 对应单配置类型,并约定使用必填的 id 字段作为表主键。 +/// +[Generator] +public sealed class SchemaConfigGenerator : IIncrementalGenerator +{ + private const string GeneratedNamespace = "GFramework.Game.Config.Generated"; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var schemaFiles = context.AdditionalTextsProvider + .Where(static file => file.Path.EndsWith(".schema.json", StringComparison.OrdinalIgnoreCase)) + .Select(static (file, cancellationToken) => ParseSchema(file, cancellationToken)); + + context.RegisterSourceOutput(schemaFiles, static (productionContext, result) => + { + foreach (var diagnostic in result.Diagnostics) + { + productionContext.ReportDiagnostic(diagnostic); + } + + if (result.Schema is null) + { + return; + } + + productionContext.AddSource( + $"{result.Schema.ClassName}.g.cs", + SourceText.From(GenerateConfigClass(result.Schema), Encoding.UTF8)); + productionContext.AddSource( + $"{result.Schema.TableName}.g.cs", + SourceText.From(GenerateTableClass(result.Schema), Encoding.UTF8)); + }); + } + + /// + /// 解析单个 schema 文件。 + /// + /// AdditionalFiles 中的 schema 文件。 + /// 取消令牌。 + /// 解析结果,包含 schema 模型或诊断。 + private static SchemaParseResult ParseSchema( + AdditionalText file, + CancellationToken cancellationToken) + { + SourceText? text; + try + { + text = file.GetText(cancellationToken); + } + catch (Exception exception) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidSchemaJson, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path), + exception.Message)); + } + + if (text is null) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidSchemaJson, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path), + "File content could not be read.")); + } + + try + { + using var document = JsonDocument.Parse(text.ToString()); + var root = document.RootElement; + if (!root.TryGetProperty("type", out var rootTypeElement) || + !string.Equals(rootTypeElement.GetString(), "object", StringComparison.Ordinal)) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.RootObjectSchemaRequired, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path))); + } + + if (!root.TryGetProperty("properties", out var propertiesElement) || + propertiesElement.ValueKind != JsonValueKind.Object) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.RootObjectSchemaRequired, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path))); + } + + var requiredProperties = new HashSet(StringComparer.OrdinalIgnoreCase); + if (root.TryGetProperty("required", out var requiredElement) && + requiredElement.ValueKind == JsonValueKind.Array) + { + foreach (var item in requiredElement.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var value = item.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + { + requiredProperties.Add(value!); + } + } + } + } + + var properties = new List(); + foreach (var property in propertiesElement.EnumerateObject()) + { + var parsedProperty = ParseProperty(file.Path, property, requiredProperties.Contains(property.Name)); + if (parsedProperty.Diagnostic is not null) + { + return SchemaParseResult.FromDiagnostic(parsedProperty.Diagnostic); + } + + properties.Add(parsedProperty.Property!); + } + + var idProperty = properties.FirstOrDefault(static property => + string.Equals(property.SchemaName, "id", StringComparison.OrdinalIgnoreCase)); + if (idProperty is null || !idProperty.IsRequired) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.IdPropertyRequired, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path))); + } + + if (!string.Equals(idProperty.SchemaType, "integer", StringComparison.Ordinal) && + !string.Equals(idProperty.SchemaType, "string", StringComparison.Ordinal)) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedKeyType, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path), + idProperty.SchemaType)); + } + + var entityName = ToPascalCase(GetSchemaBaseName(file.Path)); + var schema = new SchemaFileSpec( + Path.GetFileName(file.Path), + entityName, + $"{entityName}Config", + $"{entityName}Table", + GeneratedNamespace, + idProperty.ClrType, + TryGetMetadataString(root, "title"), + TryGetMetadataString(root, "description"), + properties); + + return SchemaParseResult.FromSchema(schema); + } + catch (JsonException exception) + { + return SchemaParseResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidSchemaJson, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path), + exception.Message)); + } + } + + /// + /// 解析单个 schema 属性定义。 + /// + /// schema 文件路径。 + /// 属性 JSON 节点。 + /// 属性是否必填。 + /// 解析后的属性信息或诊断。 + private static ParsedPropertyResult ParseProperty( + string filePath, + JsonProperty property, + bool isRequired) + { + if (!property.Value.TryGetProperty("type", out var typeElement) || + typeElement.ValueKind != JsonValueKind.String) + { + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + property.Name, + "")); + } + + var schemaType = typeElement.GetString() ?? string.Empty; + var title = TryGetMetadataString(property.Value, "title"); + var description = TryGetMetadataString(property.Value, "description"); + var refTableName = TryGetMetadataString(property.Value, "x-gframework-ref-table"); + + switch (schemaType) + { + case "integer": + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + ToPascalCase(property.Name), + "integer", + isRequired ? "int" : "int?", + isRequired, + TryBuildScalarInitializer(property.Value, "integer"), + title, + description, + TryBuildEnumDocumentation(property.Value, "integer"), + refTableName)); + + case "number": + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + ToPascalCase(property.Name), + "number", + isRequired ? "double" : "double?", + isRequired, + TryBuildScalarInitializer(property.Value, "number"), + title, + description, + TryBuildEnumDocumentation(property.Value, "number"), + refTableName)); + + case "boolean": + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + ToPascalCase(property.Name), + "boolean", + isRequired ? "bool" : "bool?", + isRequired, + TryBuildScalarInitializer(property.Value, "boolean"), + title, + description, + TryBuildEnumDocumentation(property.Value, "boolean"), + refTableName)); + + case "string": + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + ToPascalCase(property.Name), + "string", + isRequired ? "string" : "string?", + isRequired, + TryBuildScalarInitializer(property.Value, "string") ?? + (isRequired ? " = string.Empty;" : null), + title, + description, + TryBuildEnumDocumentation(property.Value, "string"), + refTableName)); + + case "array": + if (!property.Value.TryGetProperty("items", out var itemsElement) || + !itemsElement.TryGetProperty("type", out var itemTypeElement) || + itemTypeElement.ValueKind != JsonValueKind.String) + { + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + property.Name, + "array")); + } + + var itemType = itemTypeElement.GetString() ?? string.Empty; + var itemClrType = itemType switch + { + "integer" => "int", + "number" => "double", + "boolean" => "bool", + "string" => "string", + _ => string.Empty + }; + + if (string.IsNullOrEmpty(itemClrType)) + { + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + property.Name, + $"array<{itemType}>")); + } + + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + ToPascalCase(property.Name), + "array", + $"global::System.Collections.Generic.IReadOnlyList<{itemClrType}>", + isRequired, + TryBuildArrayInitializer(property.Value, itemType, itemClrType) ?? + " = global::System.Array.Empty<" + itemClrType + ">();", + title, + description, + TryBuildEnumDocumentation(itemsElement, itemType), + refTableName)); + + default: + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + property.Name, + schemaType)); + } + } + + /// + /// 生成配置类型源码。 + /// + /// 已解析的 schema 模型。 + /// 配置类型源码。 + private static string GenerateConfigClass(SchemaFileSpec schema) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine($"namespace {schema.Namespace};"); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine( + $"/// Auto-generated config type for schema file '{schema.FileName}'."); + builder.AppendLine( + $"/// {EscapeXmlDocumentation(schema.Description ?? schema.Title ?? "This type is generated from JSON schema so runtime loading and editor tooling can share the same contract.")}"); + builder.AppendLine("/// "); + builder.AppendLine($"public sealed partial class {schema.ClassName}"); + builder.AppendLine("{"); + + foreach (var property in schema.Properties) + { + AppendPropertyDocumentation(builder, property); + builder.Append($" public {property.ClrType} {property.PropertyName} {{ get; set; }}"); + if (!string.IsNullOrEmpty(property.Initializer)) + { + builder.Append(property.Initializer); + } + + builder.AppendLine(); + builder.AppendLine(); + } + + builder.AppendLine("}"); + return builder.ToString().TrimEnd(); + } + + /// + /// 生成配置表包装源码。 + /// + /// 已解析的 schema 模型。 + /// 配置表包装源码。 + private static string GenerateTableClass(SchemaFileSpec schema) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine($"namespace {schema.Namespace};"); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine( + $"/// Auto-generated table wrapper for schema file '{schema.FileName}'."); + builder.AppendLine( + "/// The wrapper keeps generated call sites strongly typed while delegating actual storage to the runtime config table implementation."); + builder.AppendLine("/// "); + builder.AppendLine( + $"public sealed partial class {schema.TableName} : global::GFramework.Game.Abstractions.Config.IConfigTable<{schema.KeyClrType}, {schema.ClassName}>"); + builder.AppendLine("{"); + builder.AppendLine( + $" private readonly global::GFramework.Game.Abstractions.Config.IConfigTable<{schema.KeyClrType}, {schema.ClassName}> _inner;"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Creates a generated table wrapper around the runtime config table instance."); + builder.AppendLine(" /// "); + builder.AppendLine(" /// The runtime config table instance."); + builder.AppendLine( + $" public {schema.TableName}(global::GFramework.Game.Abstractions.Config.IConfigTable<{schema.KeyClrType}, {schema.ClassName}> inner)"); + builder.AppendLine(" {"); + builder.AppendLine(" _inner = inner ?? throw new global::System.ArgumentNullException(nameof(inner));"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" public global::System.Type KeyType => _inner.KeyType;"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" public global::System.Type ValueType => _inner.ValueType;"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" public int Count => _inner.Count;"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine($" public {schema.ClassName} Get({schema.KeyClrType} key)"); + builder.AppendLine(" {"); + builder.AppendLine(" return _inner.Get(key);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine($" public bool TryGet({schema.KeyClrType} key, out {schema.ClassName}? value)"); + builder.AppendLine(" {"); + builder.AppendLine(" return _inner.TryGet(key, out value);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine($" public bool ContainsKey({schema.KeyClrType} key)"); + builder.AppendLine(" {"); + builder.AppendLine(" return _inner.ContainsKey(key);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine( + $" public global::System.Collections.Generic.IReadOnlyCollection<{schema.ClassName}> All()"); + builder.AppendLine(" {"); + builder.AppendLine(" return _inner.All();"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + return builder.ToString().TrimEnd(); + } + + /// + /// 从 schema 文件路径提取实体基础名。 + /// + /// schema 文件路径。 + /// 去掉扩展名和 `.schema` 后缀的实体基础名。 + private static string GetSchemaBaseName(string path) + { + var fileName = Path.GetFileName(path); + if (fileName.EndsWith(".schema.json", StringComparison.OrdinalIgnoreCase)) + { + return fileName.Substring(0, fileName.Length - ".schema.json".Length); + } + + return Path.GetFileNameWithoutExtension(fileName); + } + + /// + /// 将 schema 名称转换为 PascalCase 标识符。 + /// + /// 原始名称。 + /// PascalCase 标识符。 + private static string ToPascalCase(string value) + { + var tokens = value + .Split(new[] { '-', '_', '.', ' ' }, StringSplitOptions.RemoveEmptyEntries) + .Select(static token => + char.ToUpperInvariant(token[0]) + token.Substring(1)) + .ToArray(); + + return tokens.Length == 0 ? "Config" : string.Concat(tokens); + } + + /// + /// 为 AdditionalFiles 诊断创建文件位置。 + /// + /// 文件路径。 + /// 指向文件开头的位置。 + private static Location CreateFileLocation(string path) + { + return Location.Create( + path, + TextSpan.FromBounds(0, 0), + new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 0))); + } + + private static string? TryGetMetadataString(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var metadataElement) || + metadataElement.ValueKind != JsonValueKind.String) + { + return null; + } + + var value = metadataElement.GetString(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static string? TryBuildScalarInitializer(JsonElement element, string schemaType) + { + if (!element.TryGetProperty("default", out var defaultElement)) + { + return null; + } + + return schemaType switch + { + "integer" when defaultElement.ValueKind == JsonValueKind.Number && + defaultElement.TryGetInt64(out var intValue) => + $" = {intValue.ToString(CultureInfo.InvariantCulture)};", + "number" when defaultElement.ValueKind == JsonValueKind.Number => + $" = {defaultElement.GetDouble().ToString(CultureInfo.InvariantCulture)};", + "boolean" when defaultElement.ValueKind == JsonValueKind.True => " = true;", + "boolean" when defaultElement.ValueKind == JsonValueKind.False => " = false;", + "string" when defaultElement.ValueKind == JsonValueKind.String => + $" = {SymbolDisplay.FormatLiteral(defaultElement.GetString() ?? string.Empty, true)};", + _ => null + }; + } + + private static string? TryBuildArrayInitializer(JsonElement element, string itemType, string itemClrType) + { + if (!element.TryGetProperty("default", out var defaultElement) || + defaultElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + var items = new List(); + foreach (var item in defaultElement.EnumerateArray()) + { + var literal = itemType switch + { + "integer" when item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out var intValue) => + intValue.ToString(CultureInfo.InvariantCulture), + "number" when item.ValueKind == JsonValueKind.Number => + item.GetDouble().ToString(CultureInfo.InvariantCulture), + "boolean" when item.ValueKind == JsonValueKind.True => "true", + "boolean" when item.ValueKind == JsonValueKind.False => "false", + "string" when item.ValueKind == JsonValueKind.String => + SymbolDisplay.FormatLiteral(item.GetString() ?? string.Empty, true), + _ => string.Empty + }; + + if (string.IsNullOrEmpty(literal)) + { + return null; + } + + items.Add(literal); + } + + return $" = new {itemClrType}[] {{ {string.Join(", ", items)} }};"; + } + + private static string? TryBuildEnumDocumentation(JsonElement element, string schemaType) + { + if (!element.TryGetProperty("enum", out var enumElement) || + enumElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + var values = new List(); + foreach (var item in enumElement.EnumerateArray()) + { + var displayValue = schemaType switch + { + "integer" when item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out var intValue) => + intValue.ToString(CultureInfo.InvariantCulture), + "number" when item.ValueKind == JsonValueKind.Number => + item.GetDouble().ToString(CultureInfo.InvariantCulture), + "boolean" when item.ValueKind == JsonValueKind.True => "true", + "boolean" when item.ValueKind == JsonValueKind.False => "false", + "string" when item.ValueKind == JsonValueKind.String => item.GetString(), + _ => null + }; + + if (!string.IsNullOrWhiteSpace(displayValue)) + { + values.Add(displayValue!); + } + } + + return values.Count > 0 ? string.Join(", ", values) : null; + } + + private static void AppendPropertyDocumentation(StringBuilder builder, SchemaPropertySpec property) + { + builder.AppendLine(" /// "); + builder.AppendLine( + $" /// {EscapeXmlDocumentation(property.Description ?? property.Title ?? $"Gets or sets the value mapped from schema property '{property.SchemaName}'.")}"); + builder.AppendLine(" /// "); + builder.AppendLine(" /// "); + builder.AppendLine( + $" /// Schema property: '{EscapeXmlDocumentation(property.SchemaName)}'."); + + if (!string.IsNullOrWhiteSpace(property.Title)) + { + builder.AppendLine( + $" /// Display title: '{EscapeXmlDocumentation(property.Title!)}'."); + } + + if (!string.IsNullOrWhiteSpace(property.EnumDocumentation)) + { + builder.AppendLine( + $" /// Allowed values: {EscapeXmlDocumentation(property.EnumDocumentation!)}."); + } + + if (!string.IsNullOrWhiteSpace(property.ReferenceTableName)) + { + builder.AppendLine( + $" /// References config table: '{EscapeXmlDocumentation(property.ReferenceTableName!)}'."); + } + + if (!string.IsNullOrWhiteSpace(property.Initializer)) + { + builder.AppendLine( + $" /// Generated default initializer: {EscapeXmlDocumentation(property.Initializer!.Trim())}"); + } + + builder.AppendLine(" /// "); + } + + private static string EscapeXmlDocumentation(string value) + { + return value + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">"); + } + + /// + /// 表示单个 schema 文件的解析结果。 + /// + /// 成功解析出的 schema 模型。 + /// 解析阶段产生的诊断。 + private sealed record SchemaParseResult( + SchemaFileSpec? Schema, + ImmutableArray Diagnostics) + { + /// + /// 从成功解析的 schema 模型创建结果。 + /// + public static SchemaParseResult FromSchema(SchemaFileSpec schema) + { + return new SchemaParseResult(schema, ImmutableArray.Empty); + } + + /// + /// 从单个诊断创建结果。 + /// + public static SchemaParseResult FromDiagnostic(Diagnostic diagnostic) + { + return new SchemaParseResult(null, ImmutableArray.Create(diagnostic)); + } + } + + /// + /// 表示已解析的 schema 文件模型。 + /// + private sealed record SchemaFileSpec( + string FileName, + string EntityName, + string ClassName, + string TableName, + string Namespace, + string KeyClrType, + string? Title, + string? Description, + IReadOnlyList Properties); + + /// + /// 表示已解析的 schema 属性。 + /// + private sealed record SchemaPropertySpec( + string SchemaName, + string PropertyName, + string SchemaType, + string ClrType, + bool IsRequired, + string? Initializer, + string? Title, + string? Description, + string? EnumDocumentation, + string? ReferenceTableName); + + /// + /// 表示单个属性的解析结果。 + /// + private sealed record ParsedPropertyResult( + SchemaPropertySpec? Property, + Diagnostic? Diagnostic) + { + /// + /// 从属性模型创建成功结果。 + /// + public static ParsedPropertyResult FromProperty(SchemaPropertySpec property) + { + return new ParsedPropertyResult(property, null); + } + + /// + /// 从诊断创建失败结果。 + /// + public static ParsedPropertyResult FromDiagnostic(Diagnostic diagnostic) + { + return new ParsedPropertyResult(null, diagnostic); + } + } +} \ No newline at end of file diff --git a/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs new file mode 100644 index 0000000..9229868 --- /dev/null +++ b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs @@ -0,0 +1,66 @@ +using GFramework.SourceGenerators.Common.Constants; + +namespace GFramework.SourceGenerators.Diagnostics; + +/// +/// 提供配置 schema 代码生成相关诊断。 +/// +public static class ConfigSchemaDiagnostics +{ + private const string SourceGeneratorsConfigCategory = $"{PathContests.SourceGeneratorsPath}.Config"; + + /// + /// schema JSON 无法解析。 + /// + public static readonly DiagnosticDescriptor InvalidSchemaJson = new( + "GF_ConfigSchema_001", + "Config schema JSON is invalid", + "Schema file '{0}' could not be parsed: {1}", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); + + /// + /// schema 顶层必须是 object。 + /// + public static readonly DiagnosticDescriptor RootObjectSchemaRequired = new( + "GF_ConfigSchema_002", + "Config schema root must describe an object", + "Schema file '{0}' must declare a root object schema", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); + + /// + /// schema 必须声明 id 字段作为主键。 + /// + public static readonly DiagnosticDescriptor IdPropertyRequired = new( + "GF_ConfigSchema_003", + "Config schema must declare an id property", + "Schema file '{0}' must declare a required 'id' property for table generation", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); + + /// + /// schema 包含暂不支持的字段类型。 + /// + public static readonly DiagnosticDescriptor UnsupportedPropertyType = new( + "GF_ConfigSchema_004", + "Config schema contains an unsupported property type", + "Property '{1}' in schema file '{0}' uses unsupported type '{2}'", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); + + /// + /// schema 的 id 字段类型不支持作为主键。 + /// + public static readonly DiagnosticDescriptor UnsupportedKeyType = new( + "GF_ConfigSchema_005", + "Config schema uses an unsupported key type", + "Schema file '{0}' uses unsupported id type '{1}'. Supported key types are 'integer' and 'string'.", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); +} \ No newline at end of file diff --git a/GFramework.SourceGenerators/GFramework.SourceGenerators.csproj b/GFramework.SourceGenerators/GFramework.SourceGenerators.csproj index 99240bd..540ecde 100644 --- a/GFramework.SourceGenerators/GFramework.SourceGenerators.csproj +++ b/GFramework.SourceGenerators/GFramework.SourceGenerators.csproj @@ -24,6 +24,7 @@ all runtime; build; native; contentfiles; analyzers + 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/GFramework.csproj b/GFramework.csproj index 1594e9a..6221359 100644 --- a/GFramework.csproj +++ b/GFramework.csproj @@ -134,6 +134,8 @@ + + diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 5de6e15..dc1a872 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -197,6 +197,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..283d30f --- /dev/null +++ b/docs/zh-CN/game/config-system.md @@ -0,0 +1,233 @@ +# 游戏内容配置系统 + +> 面向静态游戏内容的 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 +{ + "title": "Monster Config", + "description": "定义怪物静态配置。", + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "integer", + "description": "怪物主键。" + }, + "name": { + "type": "string", + "title": "Monster Name", + "description": "怪物显示名。", + "default": "Slime" + }, + "hp": { + "type": "integer", + "default": 10 + }, + "rarity": { + "type": "string", + "enum": ["common", "rare", "boss"] + }, + "dropItems": { + "type": "array", + "description": "掉落物品表主键。", + "items": { + "type": "string", + "enum": ["potion", "slime_gel", "bomb"] + }, + "x-gframework-ref-table": "item" + } + } +} +``` + +## 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 中声明的未知字段 +- 标量类型不匹配 +- 数组元素类型不匹配 +- 标量 `enum` 不匹配 +- 标量数组元素 `enum` 不匹配 +- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行 + +跨表引用当前使用最小扩展关键字: + +```json +{ + "type": "object", + "required": ["id", "dropItemId"], + "properties": { + "id": { "type": "integer" }, + "dropItemId": { + "type": "string", + "x-gframework-ref-table": "item" + } + } +} +``` + +约束如下: + +- 仅支持 `string`、`integer` 及其标量数组声明跨表引用 +- 引用目标表需要由同一个 `YamlConfigLoader` 注册,或已存在于当前 `IConfigRegistry` +- 热重载中若目标表变更导致依赖表引用失效,会整体回滚受影响表,避免注册表进入不一致状态 + +当前还支持以下“轻量元数据”: + +- `title`:供 VS Code 插件表单和批量编辑入口显示更友好的字段标题 +- `description`:供表单提示、生成代码 XML 文档和接入说明复用 +- `default`:供生成类型属性初始值和工具提示复用 +- `enum`:供运行时校验、VS Code 校验和表单枚举选择复用 + +这样可以避免错误配置被默认值或 `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` 生成配置类型和表包装类。 + +通过已打包的 Source Generator 使用时,默认会自动收集 `schemas/**/*.schema.json` 作为 `AdditionalFiles`。 + +如果你在仓库内直接使用项目引用而不是打包后的 NuGet,请确认 schema 文件同样被加入 `AdditionalFiles`。 + +## VS Code 工具 + +仓库中的 `tools/vscode-config-extension` 当前提供以下能力: + +- 浏览 `config/` 目录 +- 打开 raw YAML 文件 +- 打开匹配的 schema 文件 +- 对必填字段、未知顶层字段、基础标量类型和标量数组元素做轻量校验 +- 对顶层标量字段和顶层标量数组提供轻量表单入口 +- 对同一配置域内的多份 YAML 文件执行批量字段更新 +- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据 + +当前批量编辑入口适合对同域文件统一改动顶层标量字段和顶层标量数组;复杂数组、嵌套对象仍建议放在 raw YAML 中完成。 + +## 当前限制 + +以下能力尚未完全完成: + +- 更完整的 JSON Schema 支持 +- 更强的 VS Code 嵌套对象与复杂数组编辑器 + +因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。 + +## 独立 Config Studio 评估 + +当前阶段的结论是:`不建议立即启动独立 Config Studio`,继续以 `VS Code Extension` 作为主工具形态更合适。 + +当前不单独启动桌面版的原因: + +- 当前已落地的能力主要仍围绕 schema 校验、轻量表单、批量编辑和 raw YAML 回退,这些都能在 VS Code 宿主里低成本迭代 +- runtime、generator、tooling 之间仍在持续收敛 schema 子集和元数据语义,过早拆出桌面工具会放大版本协同成本 +- 当前待补强点仍是更完整 schema 支持和复杂编辑体验,先在插件里验证真实工作流更稳妥 +- 仓库当前的主要使用者仍偏开发者和技术策划,独立桌面版带来的“免开发环境”收益还不足以抵消额外维护面 + +只有在以下条件明显成立时,再建议启动独立 `Config Studio`: + +- 主要使用者变成非开发人员,且 VS Code 安装与使用成本成为持续阻力 +- 需要更重的表格视图、跨表可视化关系编辑、复杂审批流或离线发布流程 +- 插件形态已经频繁受限于 VS Code Webview/Extension API,而不是 schema 与工作流本身 +- 已经沉淀出稳定的 schema 元数据约定,能够支撑单独桌面产品的长期维护 diff --git a/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 f564456..b56d9ca 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-属性生成器) @@ -38,6 +39,7 @@ GFramework.SourceGenerators 利用 Roslyn 源代码生成器技术,在编译 ### 🎯 主要生成器 - **[Log] 属性**:自动生成 ILogger 字段和日志方法 +- **Config Schema 生成器**:根据 `*.schema.json` 生成配置类型和表包装 - **[ContextAware] 属性**:自动实现 IContextAware 接口 - **[GenerateEnumExtensions] 属性**:自动生成枚举扩展方法 - **[Priority] 属性**:自动实现 IPrioritized 接口,为类添加优先级标记 @@ -75,6 +77,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 diff --git a/tools/vscode-config-extension/README.md b/tools/vscode-config-extension/README.md new file mode 100644 index 0000000..8dc730a --- /dev/null +++ b/tools/vscode-config-extension/README.md @@ -0,0 +1,45 @@ +# GFramework Config Tools + +Minimal VS Code extension scaffold for the GFramework AI-First config workflow. + +## Current MVP + +- 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, unknown top-level fields, scalar types, and scalar array items +- Open a lightweight form preview for top-level scalar fields and top-level scalar arrays +- Batch edit one config domain across multiple files for top-level scalar and scalar-array fields +- Surface schema metadata such as `title`, `description`, `default`, `enum`, and `x-gframework-ref-table` in the + lightweight editors + +## Validation Coverage + +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 +- scalar `enum` constraints and scalar-array item `enum` constraints + +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 +- Validation only covers a minimal subset of JSON Schema +- Form and batch editing currently support top-level scalar fields and top-level scalar arrays +- Nested objects and complex arrays should still be edited in raw YAML + +## Workspace Settings + +- `gframeworkConfig.configPath` +- `gframeworkConfig.schemasPath` diff --git a/tools/vscode-config-extension/package.json b/tools/vscode-config-extension/package.json new file mode 100644 index 0000000..4ff3cf8 --- /dev/null +++ b/tools/vscode-config-extension/package.json @@ -0,0 +1,114 @@ +{ + "name": "gframework-config-extension", + "displayName": "GFramework Config Tools", + "description": "Workspace tools for browsing, validating, and editing AI-First config files in GFramework projects.", + "version": "0.0.1", + "publisher": "gewuyou", + "license": "Apache-2.0", + "engines": { + "vscode": "^1.90.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onView:gframeworkConfigExplorer", + "onCommand:gframeworkConfig.refresh", + "onCommand:gframeworkConfig.openRaw", + "onCommand:gframeworkConfig.openSchema", + "onCommand:gframeworkConfig.openFormPreview", + "onCommand:gframeworkConfig.batchEditDomain", + "onCommand:gframeworkConfig.validateAll" + ], + "main": "./src/extension.js", + "scripts": { + "test": "node --test ./test/*.test.js" + }, + "contributes": { + "views": { + "explorer": [ + { + "id": "gframeworkConfigExplorer", + "name": "GFramework Config" + } + ] + }, + "commands": [ + { + "command": "gframeworkConfig.refresh", + "title": "GFramework Config: Refresh" + }, + { + "command": "gframeworkConfig.openRaw", + "title": "GFramework Config: Open Raw File" + }, + { + "command": "gframeworkConfig.openSchema", + "title": "GFramework Config: Open Schema" + }, + { + "command": "gframeworkConfig.openFormPreview", + "title": "GFramework Config: Open Form Preview" + }, + { + "command": "gframeworkConfig.batchEditDomain", + "title": "GFramework Config: Batch Edit Domain" + }, + { + "command": "gframeworkConfig.validateAll", + "title": "GFramework Config: Validate All" + } + ], + "menus": { + "view/title": [ + { + "command": "gframeworkConfig.refresh", + "when": "view == gframeworkConfigExplorer", + "group": "navigation" + }, + { + "command": "gframeworkConfig.validateAll", + "when": "view == gframeworkConfigExplorer", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "gframeworkConfig.openRaw", + "when": "view == gframeworkConfigExplorer && viewItem == gframeworkConfigFile", + "group": "inline" + }, + { + "command": "gframeworkConfig.openSchema", + "when": "view == gframeworkConfigExplorer && viewItem == gframeworkConfigFile", + "group": "navigation" + }, + { + "command": "gframeworkConfig.openFormPreview", + "when": "view == gframeworkConfigExplorer && viewItem == gframeworkConfigFile", + "group": "navigation" + }, + { + "command": "gframeworkConfig.batchEditDomain", + "when": "view == gframeworkConfigExplorer && viewItem == domain", + "group": "navigation" + } + ] + }, + "configuration": { + "title": "GFramework Config", + "properties": { + "gframeworkConfig.configPath": { + "type": "string", + "default": "config", + "description": "Relative path from the workspace root to the config directory." + }, + "gframeworkConfig.schemasPath": { + "type": "string", + "default": "schemas", + "description": "Relative path from the workspace root to the schema directory." + } + } + } + } +} diff --git a/tools/vscode-config-extension/src/configValidation.js b/tools/vscode-config-extension/src/configValidation.js new file mode 100644 index 0000000..4bd6b24 --- /dev/null +++ b/tools/vscode-config-extension/src/configValidation.js @@ -0,0 +1,667 @@ +/** + * 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; + } + + const metadata = { + title: typeof value.title === "string" ? value.title : undefined, + description: typeof value.description === "string" ? value.description : undefined, + defaultValue: formatSchemaDefaultValue(value.default), + enumValues: normalizeSchemaEnumValues(value.enum), + refTable: typeof value["x-gframework-ref-table"] === "string" + ? value["x-gframework-ref-table"] + : undefined + }; + + if (value.type === "array" && + value.items && + typeof value.items === "object" && + typeof value.items.type === "string") { + properties[key] = { + type: "array", + itemType: value.items.type, + title: metadata.title, + description: metadata.description, + defaultValue: metadata.defaultValue, + refTable: metadata.refTable, + itemEnumValues: normalizeSchemaEnumValues(value.items.enum) + }; + continue; + } + + properties[key] = { + type: value.type, + title: metadata.title, + description: metadata.description, + defaultValue: metadata.defaultValue, + enumValues: metadata.enumValues, + refTable: metadata.refTable + }; + } + + return { + required, + properties + }; +} + +/** + * Collect top-level schema fields that the current tooling can edit in bulk. + * The bulk editor intentionally stays aligned with the lightweight form editor: + * top-level scalars and scalar arrays are supported, while nested objects and + * complex array items remain raw-YAML-only. + * + * @param {{required: string[], properties: Record}} schemaInfo Parsed schema info. + * @returns {Array<{ + * key: string, + * type: string, + * itemType?: string, + * title?: string, + * description?: string, + * defaultValue?: string, + * enumValues?: string[], + * itemEnumValues?: string[], + * refTable?: string, + * inputKind: "scalar" | "array", + * required: boolean + * }>} Editable field descriptors. + */ +function getEditableSchemaFields(schemaInfo) { + const editableFields = []; + const requiredSet = new Set(Array.isArray(schemaInfo.required) ? schemaInfo.required : []); + + for (const [key, property] of Object.entries(schemaInfo.properties || {})) { + if (isEditableScalarType(property.type)) { + editableFields.push({ + key, + type: property.type, + title: property.title, + description: property.description, + defaultValue: property.defaultValue, + enumValues: property.enumValues, + refTable: property.refTable, + inputKind: "scalar", + required: requiredSet.has(key) + }); + continue; + } + + if (property.type === "array" && isEditableScalarType(property.itemType || "")) { + editableFields.push({ + key, + type: property.type, + itemType: property.itemType, + title: property.title, + description: property.description, + defaultValue: property.defaultValue, + itemEnumValues: property.itemEnumValues, + refTable: property.refTable, + inputKind: "array", + required: requiredSet.has(key) + }); + } + } + + return editableFields.sort((left, right) => left.key.localeCompare(right.key)); +} + +/** + * 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; + } + + if (Array.isArray(propertySchema.itemEnumValues) && + propertySchema.itemEnumValues.length > 0 && + !propertySchema.itemEnumValues.includes(unquoteScalar(item.raw))) { + diagnostics.push({ + severity: "error", + message: `Array item in property '${propertyName}' must be one of: ${propertySchema.itemEnumValues.join(", ")}.` + }); + break; + } + } + + continue; + } + + 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.` + }); + continue; + } + + if (Array.isArray(propertySchema.enumValues) && + propertySchema.enumValues.length > 0 && + !propertySchema.enumValues.includes(unquoteScalar(entry.value || ""))) { + diagnostics.push({ + severity: "error", + message: `Property '${propertyName}' must be one of: ${propertySchema.enumValues.join(", ")}.` + }); + } + } + + return diagnostics; +} + +/** + * Determine whether the current schema type can be edited through the + * lightweight form or batch-edit tooling. + * + * @param {string} schemaType Schema type. + * @returns {boolean} True when the type is supported by the lightweight editors. + */ +function isEditableScalarType(schemaType) { + return schemaType === "string" || + schemaType === "integer" || + schemaType === "number" || + schemaType === "boolean"; +} + +/** + * 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 form field updates back into the original YAML text. + * The current form editor supports top-level scalar fields and top-level scalar + * arrays, while nested objects and complex arrays remain raw-YAML-only. + * + * @param {string} originalYaml Original YAML content. + * @param {{scalars?: Record, arrays?: Record}} updates Updated form values. + * @returns {string} Updated YAML content. + */ +function applyFormUpdates(originalYaml, updates) { + const lines = originalYaml.split(/\r?\n/u); + const scalarUpdates = updates.scalars || {}; + const arrayUpdates = updates.arrays || {}; + const touchedScalarKeys = new Set(); + const touchedArrayKeys = new Set(); + const blocks = findTopLevelBlocks(lines); + const updatedLines = []; + let cursor = 0; + + for (const block of blocks) { + while (cursor < block.start) { + updatedLines.push(lines[cursor]); + cursor += 1; + } + + if (Object.prototype.hasOwnProperty.call(scalarUpdates, block.key)) { + touchedScalarKeys.add(block.key); + updatedLines.push(renderScalarLine(block.key, scalarUpdates[block.key])); + cursor = block.end + 1; + continue; + } + + if (Object.prototype.hasOwnProperty.call(arrayUpdates, block.key)) { + touchedArrayKeys.add(block.key); + updatedLines.push(...renderArrayBlock(block.key, arrayUpdates[block.key])); + cursor = block.end + 1; + continue; + } + + while (cursor <= block.end) { + updatedLines.push(lines[cursor]); + cursor += 1; + } + } + + while (cursor < lines.length) { + updatedLines.push(lines[cursor]); + cursor += 1; + } + + for (const [key, value] of Object.entries(scalarUpdates)) { + if (touchedScalarKeys.has(key)) { + continue; + } + + updatedLines.push(renderScalarLine(key, value)); + } + + for (const [key, value] of Object.entries(arrayUpdates)) { + if (touchedArrayKeys.has(key)) { + continue; + } + + updatedLines.push(...renderArrayBlock(key, value)); + } + + return updatedLines.join("\n"); +} + +/** + * Apply only scalar updates back into the original YAML text. + * This helper is preserved for compatibility with existing tests and callers + * that only edit top-level scalar fields. + * + * @param {string} originalYaml Original YAML content. + * @param {Record} updates Updated scalar values. + * @returns {string} Updated YAML content. + */ +function applyScalarUpdates(originalYaml, updates) { + return applyFormUpdates(originalYaml, {scalars: updates}); +} + +/** + * Parse the batch editor's comma-separated array input. + * + * @param {string} value Raw input value. + * @returns {string[]} Parsed array items. + */ +function parseBatchArrayValue(value) { + return String(value) + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +/** + * Normalize a schema enum array into string values that can be shown in UI + * hints and compared against parsed YAML scalar content. + * + * @param {unknown} value Raw schema enum value. + * @returns {string[] | undefined} Normalized enum values. + */ +function normalizeSchemaEnumValues(value) { + if (!Array.isArray(value)) { + return undefined; + } + + const normalized = value + .filter((item) => ["string", "number", "boolean"].includes(typeof item)) + .map((item) => String(item)); + + return normalized.length > 0 ? normalized : undefined; +} + +/** + * Convert a schema default value into a compact string that can be shown in UI + * metadata hints. + * + * @param {unknown} value Raw schema default value. + * @returns {string | undefined} Display string for the default value. + */ +function formatSchemaDefaultValue(value) { + if (value === null || value === undefined) { + return undefined; + } + + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (Array.isArray(value)) { + const normalized = value + .filter((item) => ["string", "number", "boolean"].includes(typeof item)) + .map((item) => String(item)); + + return normalized.length > 0 ? normalized.join(", ") : undefined; + } + + return undefined; +} + +/** + * Format a scalar value for YAML output. + * + * @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; +} + +/** + * Find top-level YAML blocks so form updates can replace whole entries without + * touching unrelated domains in the file. + * + * @param {string[]} lines YAML lines. + * @returns {Array<{key: string, start: number, end: number}>} Top-level blocks. + */ +function findTopLevelBlocks(lines) { + const blocks = []; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (!line || line.trim().length === 0 || line.trim().startsWith("#") || /^\s/u.test(line)) { + continue; + } + + const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line); + if (!match) { + continue; + } + + let cursor = index + 1; + while (cursor < lines.length) { + const nextLine = lines[cursor]; + if (nextLine.trim().length === 0 || nextLine.trim().startsWith("#")) { + cursor += 1; + continue; + } + + if (!/^\s/u.test(nextLine)) { + break; + } + + cursor += 1; + } + + blocks.push({ + key: match[1], + start: index, + end: cursor - 1 + }); + index = cursor - 1; + } + + return blocks; +} + +/** + * Render a top-level scalar line. + * + * @param {string} key Property name. + * @param {string} value Scalar value. + * @returns {string} Rendered YAML line. + */ +function renderScalarLine(key, value) { + return `${key}: ${formatYamlScalar(value)}`; +} + +/** + * Render a top-level scalar array block. + * + * @param {string} key Property name. + * @param {string[]} items Array items. + * @returns {string[]} Rendered YAML lines. + */ +function renderArrayBlock(key, items) { + const normalizedItems = Array.isArray(items) + ? items + .map((item) => String(item).trim()) + .filter((item) => item.length > 0) + : []; + + const lines = [`${key}:`]; + for (const item of normalizedItems) { + lines.push(` - ${formatYamlScalar(item)}`); + } + + return lines; +} + +module.exports = { + applyFormUpdates, + applyScalarUpdates, + findTopLevelBlocks, + formatYamlScalar, + getEditableSchemaFields, + isScalarCompatible, + normalizeSchemaEnumValues, + parseBatchArrayValue, + parseSchemaContent, + parseTopLevelYaml, + unquoteScalar, + validateParsedConfig, + formatSchemaDefaultValue +}; diff --git a/tools/vscode-config-extension/src/extension.js b/tools/vscode-config-extension/src/extension.js new file mode 100644 index 0000000..0217a98 --- /dev/null +++ b/tools/vscode-config-extension/src/extension.js @@ -0,0 +1,1007 @@ +const fs = require("fs"); +const path = require("path"); +const vscode = require("vscode"); +const { + applyFormUpdates, + getEditableSchemaFields, + parseBatchArrayValue, + parseSchemaContent, + parseTopLevelYaml, + unquoteScalar, + validateParsedConfig +} = require("./configValidation"); + +/** + * Activate the GFramework config extension. + * The initial MVP focuses on workspace file navigation, lightweight validation, + * and a small form-preview entry for top-level scalar values. + * + * @param {vscode.ExtensionContext} context Extension context. + */ +function activate(context) { + const diagnostics = vscode.languages.createDiagnosticCollection("gframeworkConfig"); + const provider = new ConfigTreeDataProvider(); + + context.subscriptions.push(diagnostics); + context.subscriptions.push( + vscode.window.registerTreeDataProvider("gframeworkConfigExplorer", provider), + vscode.commands.registerCommand("gframeworkConfig.refresh", async () => { + provider.refresh(); + await validateAllConfigs(diagnostics); + }), + vscode.commands.registerCommand("gframeworkConfig.openRaw", async (item) => { + await openRawFile(item); + }), + vscode.commands.registerCommand("gframeworkConfig.openSchema", async (item) => { + await openSchemaFile(item); + }), + vscode.commands.registerCommand("gframeworkConfig.openFormPreview", async (item) => { + await openFormPreview(item, diagnostics); + }), + vscode.commands.registerCommand("gframeworkConfig.batchEditDomain", async (item) => { + await openBatchEdit(item, diagnostics, provider); + }), + vscode.commands.registerCommand("gframeworkConfig.validateAll", async () => { + await validateAllConfigs(diagnostics); + }), + vscode.workspace.onDidSaveTextDocument(async (document) => { + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + return; + } + + if (!isConfigFile(document.uri, workspaceRoot)) { + return; + } + + await validateConfigFile(document.uri, diagnostics); + provider.refresh(); + }), + vscode.workspace.onDidChangeWorkspaceFolders(async () => { + provider.refresh(); + await validateAllConfigs(diagnostics); + }) + ); + + void validateAllConfigs(diagnostics); +} + +/** + * Deactivate the extension. + */ +function deactivate() { +} + +/** + * Tree provider for the GFramework config explorer view. + */ +class ConfigTreeDataProvider { + constructor() { + this._emitter = new vscode.EventEmitter(); + this.onDidChangeTreeData = this._emitter.event; + } + + /** + * Refresh the tree view. + */ + refresh() { + this._emitter.fire(undefined); + } + + /** + * Resolve a tree item. + * + * @param {ConfigTreeItem} element Tree element. + * @returns {vscode.TreeItem} Tree item. + */ + getTreeItem(element) { + return element; + } + + /** + * Resolve child elements. + * + * @param {ConfigTreeItem | undefined} element Parent element. + * @returns {Thenable} Child items. + */ + async getChildren(element) { + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + return []; + } + + if (!element) { + return this.getRootItems(workspaceRoot); + } + + if (element.kind !== "domain" || !element.resourceUri) { + return []; + } + + return this.getFileItems(workspaceRoot, element.resourceUri); + } + + /** + * Build root domain items from the config directory. + * + * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. + * @returns {Promise} Root items. + */ + async getRootItems(workspaceRoot) { + const configRoot = getConfigRoot(workspaceRoot); + if (!configRoot || !fs.existsSync(configRoot.fsPath)) { + return [ + new ConfigTreeItem( + "No config directory", + "info", + vscode.TreeItemCollapsibleState.None, + undefined, + "Set gframeworkConfig.configPath or create the directory.") + ]; + } + + const entries = fs.readdirSync(configRoot.fsPath, {withFileTypes: true}) + .filter((entry) => entry.isDirectory()) + .sort((left, right) => left.name.localeCompare(right.name)); + + return entries.map((entry) => { + const domainUri = vscode.Uri.joinPath(configRoot, entry.name); + return new ConfigTreeItem( + entry.name, + "domain", + vscode.TreeItemCollapsibleState.Collapsed, + domainUri, + undefined); + }); + } + + /** + * Build file items for a config domain directory. + * + * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. + * @param {vscode.Uri} domainUri Domain directory URI. + * @returns {Promise} File items. + */ + async getFileItems(workspaceRoot, domainUri) { + const entries = fs.readdirSync(domainUri.fsPath, {withFileTypes: true}) + .filter((entry) => entry.isFile() && isYamlPath(entry.name)) + .sort((left, right) => left.name.localeCompare(right.name)); + + return entries.map((entry) => { + const fileUri = vscode.Uri.joinPath(domainUri, entry.name); + const schemaUri = getSchemaUriForConfigFile(fileUri, workspaceRoot); + const description = schemaUri && fs.existsSync(schemaUri.fsPath) + ? "schema" + : "schema missing"; + const item = new ConfigTreeItem( + entry.name, + "file", + vscode.TreeItemCollapsibleState.None, + fileUri, + description); + + item.contextValue = "gframeworkConfigFile"; + item.command = { + command: "gframeworkConfig.openRaw", + title: "Open Raw", + arguments: [item] + }; + + return item; + }); + } +} + +/** + * Tree item used by the config explorer. + */ +class ConfigTreeItem extends vscode.TreeItem { + /** + * @param {string} label Display label. + * @param {"domain" | "file" | "info"} kind Item kind. + * @param {vscode.TreeItemCollapsibleState} collapsibleState Collapsible state. + * @param {vscode.Uri | undefined} resourceUri Resource URI. + * @param {string | undefined} description Description. + */ + constructor(label, kind, collapsibleState, resourceUri, description) { + super(label, collapsibleState); + this.kind = kind; + this.resourceUri = resourceUri; + this.description = description; + this.contextValue = kind === "file" ? "gframeworkConfigFile" : kind; + } +} + +/** + * Open the selected raw config file. + * + * @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item. + * @returns {Promise} Async task. + */ +async function openRawFile(item) { + const uri = item && item.resourceUri; + if (!uri) { + return; + } + + const document = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(document, {preview: false}); +} + +/** + * Open the matching schema file for a selected config item. + * + * @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item. + * @returns {Promise} Async task. + */ +async function openSchemaFile(item) { + const workspaceRoot = getWorkspaceRoot(); + const configUri = item && item.resourceUri; + if (!workspaceRoot || !configUri) { + return; + } + + const schemaUri = getSchemaUriForConfigFile(configUri, workspaceRoot); + if (!schemaUri || !fs.existsSync(schemaUri.fsPath)) { + void vscode.window.showWarningMessage("Matching schema file was not found."); + return; + } + + const document = await vscode.workspace.openTextDocument(schemaUri); + await vscode.window.showTextDocument(document, {preview: false}); +} + +/** + * Open a lightweight form preview for top-level scalar fields and scalar + * arrays. Nested objects and more complex array shapes still use raw YAML as + * the escape hatch. + * + * @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item. + * @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection. + * @returns {Promise} Async task. + */ +async function openFormPreview(item, diagnostics) { + const workspaceRoot = getWorkspaceRoot(); + const configUri = item && item.resourceUri; + if (!workspaceRoot || !configUri) { + return; + } + + const yamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); + const parsedYaml = parseTopLevelYaml(yamlText); + const schemaInfo = await loadSchemaInfoForConfig(configUri, workspaceRoot); + + const panel = vscode.window.createWebviewPanel( + "gframeworkConfigFormPreview", + `Config Form: ${path.basename(configUri.fsPath)}`, + vscode.ViewColumn.Beside, + {enableScripts: true}); + + panel.webview.html = renderFormHtml( + path.basename(configUri.fsPath), + schemaInfo, + parsedYaml); + + panel.webview.onDidReceiveMessage(async (message) => { + if (message.type === "save") { + const updatedYaml = applyFormUpdates(yamlText, { + scalars: message.scalars || {}, + arrays: parseArrayFieldPayload(message.arrays || {}) + }); + await fs.promises.writeFile(configUri.fsPath, updatedYaml, "utf8"); + const document = await vscode.workspace.openTextDocument(configUri); + await document.save(); + await validateConfigFile(configUri, diagnostics); + void vscode.window.showInformationMessage("Config file saved from form preview."); + } + + if (message.type === "openRaw") { + await openRawFile({resourceUri: configUri}); + } + }); +} + +/** + * Validate all config files in the configured config directory. + * + * @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection. + * @returns {Promise} Async task. + */ +async function validateAllConfigs(diagnostics) { + diagnostics.clear(); + + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + return; + } + + const configRoot = getConfigRoot(workspaceRoot); + if (!configRoot || !fs.existsSync(configRoot.fsPath)) { + return; + } + + const files = enumerateYamlFiles(configRoot.fsPath); + for (const filePath of files) { + await validateConfigFile(vscode.Uri.file(filePath), diagnostics); + } +} + +/** + * Validate a single config file against its matching schema. + * + * @param {vscode.Uri} configUri Config file URI. + * @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection. + * @returns {Promise} Async task. + */ +async function validateConfigFile(configUri, diagnostics) { + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + return; + } + + if (!isConfigFile(configUri, workspaceRoot)) { + return; + } + + const yamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); + const parsedYaml = parseTopLevelYaml(yamlText); + const schemaInfo = await loadSchemaInfoForConfig(configUri, workspaceRoot); + const fileDiagnostics = []; + + if (!schemaInfo.exists) { + fileDiagnostics.push(new vscode.Diagnostic( + new vscode.Range(0, 0, 0, 1), + `Matching schema file not found: ${schemaInfo.schemaPath}`, + vscode.DiagnosticSeverity.Warning)); + diagnostics.set(configUri, fileDiagnostics); + return; + } + + 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); +} + +/** + * Open a minimal batch editor for one config domain. + * The workflow intentionally focuses on one schema-bound directory at a time + * so designers can apply the same top-level scalar or scalar-array values + * across multiple files without dropping down to repetitive raw-YAML edits. + * + * @param {ConfigTreeItem | { kind?: string, resourceUri?: vscode.Uri }} item Tree item. + * @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection. + * @param {ConfigTreeDataProvider} provider Tree provider. + * @returns {Promise} Async task. + */ +async function openBatchEdit(item, diagnostics, provider) { + const workspaceRoot = getWorkspaceRoot(); + const domainUri = item && item.resourceUri; + if (!workspaceRoot || !domainUri || item.kind !== "domain") { + return; + } + + const fileItems = fs.readdirSync(domainUri.fsPath, {withFileTypes: true}) + .filter((entry) => entry.isFile() && isYamlPath(entry.name)) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((entry) => { + const fileUri = vscode.Uri.joinPath(domainUri, entry.name); + return { + label: entry.name, + description: path.relative(workspaceRoot.uri.fsPath, fileUri.fsPath), + fileUri, + picked: true + }; + }); + + if (fileItems.length === 0) { + void vscode.window.showWarningMessage("No YAML config files were found in the selected domain."); + return; + } + + const selectedFiles = await vscode.window.showQuickPick(fileItems, { + canPickMany: true, + title: `Batch Edit: ${path.basename(domainUri.fsPath)}`, + placeHolder: "Select the config files to update." + }); + if (!selectedFiles || selectedFiles.length === 0) { + return; + } + + const schemaInfo = await loadSchemaInfoForConfig(selectedFiles[0].fileUri, workspaceRoot); + if (!schemaInfo.exists) { + void vscode.window.showWarningMessage("Batch edit requires a matching schema file for the selected domain."); + return; + } + + const editableFields = getEditableSchemaFields(schemaInfo); + if (editableFields.length === 0) { + void vscode.window.showWarningMessage( + "No top-level scalar or scalar-array fields were found in the matching schema."); + return; + } + + const selectedFields = await vscode.window.showQuickPick( + editableFields.map((field) => ({ + label: field.title || field.key, + description: field.inputKind === "array" + ? `array<${field.itemType}>` + : field.type, + detail: [ + field.required ? "required" : "", + field.description || "", + field.refTable ? `ref: ${field.refTable}` : "" + ].filter((part) => part.length > 0).join(" · ") || undefined, + field + })), + { + canPickMany: true, + title: `Batch Edit Fields: ${path.basename(domainUri.fsPath)}`, + placeHolder: "Select the fields to apply across the chosen files." + }); + if (!selectedFields || selectedFields.length === 0) { + return; + } + + const updates = { + scalars: {}, + arrays: {} + }; + + for (const selectedField of selectedFields) { + const field = selectedField.field; + const rawValue = await promptBatchFieldValue(field); + if (rawValue === undefined) { + return; + } + + if (field.inputKind === "array") { + updates.arrays[field.key] = parseBatchArrayValue(rawValue); + continue; + } + + updates.scalars[field.key] = rawValue; + } + + const edit = new vscode.WorkspaceEdit(); + const touchedDocuments = []; + let changedFileCount = 0; + + for (const fileItem of selectedFiles) { + const document = await vscode.workspace.openTextDocument(fileItem.fileUri); + const originalYaml = document.getText(); + const updatedYaml = applyFormUpdates(originalYaml, updates); + if (updatedYaml === originalYaml) { + continue; + } + + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(originalYaml.length)); + edit.replace(fileItem.fileUri, fullRange, updatedYaml); + touchedDocuments.push(document); + changedFileCount += 1; + } + + if (changedFileCount === 0) { + void vscode.window.showInformationMessage("Batch edit did not change any selected config files."); + return; + } + + const applied = await vscode.workspace.applyEdit(edit); + if (!applied) { + throw new Error("VS Code rejected the batch edit workspace update."); + } + + for (const document of touchedDocuments) { + await document.save(); + await validateConfigFile(document.uri, diagnostics); + } + + provider.refresh(); + void vscode.window.showInformationMessage( + `Batch updated ${changedFileCount} config file(s) in '${path.basename(domainUri.fsPath)}'.`); +} + +/** + * Load schema info for a config file. + * + * @param {vscode.Uri} configUri Config file URI. + * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. + * @returns {Promise<{exists: boolean, schemaPath: string, required: string[], properties: Record}>} Schema info. + */ +async function loadSchemaInfoForConfig(configUri, workspaceRoot) { + const schemaUri = getSchemaUriForConfigFile(configUri, workspaceRoot); + const schemaPath = schemaUri ? schemaUri.fsPath : ""; + if (!schemaUri || !fs.existsSync(schemaUri.fsPath)) { + return { + exists: false, + schemaPath, + required: [], + properties: {} + }; + } + + const content = await fs.promises.readFile(schemaUri.fsPath, "utf8"); + try { + const parsed = parseSchemaContent(content); + + return { + exists: true, + schemaPath, + required: parsed.required, + properties: parsed.properties + }; + } catch (error) { + return { + exists: false, + schemaPath, + required: [], + properties: {} + }; + } +} + +/** + * Render the form-preview webview HTML. + * + * @param {string} fileName File name. + * @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 scalarFields = Array.from(parsedYaml.entries.entries()) + .filter(([, entry]) => entry.kind === "scalar") + .map(([key, entry]) => { + const propertySchema = schemaInfo.properties[key] || {}; + const displayName = propertySchema.title || key; + const escapedKey = escapeHtml(key); + const escapedDisplayName = escapeHtml(displayName); + const escapedValue = escapeHtml(unquoteScalar(entry.value || "")); + const required = schemaInfo.required.includes(key) ? "required" : ""; + const metadataHint = renderFieldHint(propertySchema, false); + const enumValues = Array.isArray(propertySchema.enumValues) ? propertySchema.enumValues : []; + const inputControl = enumValues.length > 0 + ? ` + + ` + : ``; + return ` + + `; + }) + .join("\n"); + + const arrayFields = Array.from(parsedYaml.entries.entries()) + .filter(([, entry]) => entry.kind === "array") + .map(([key, entry]) => { + const propertySchema = schemaInfo.properties[key] || {}; + const displayName = propertySchema.title || key; + const escapedKey = escapeHtml(key); + const escapedDisplayName = escapeHtml(displayName); + const escapedValue = escapeHtml((entry.items || []) + .map((item) => unquoteScalar(item.raw)) + .join("\n")); + const required = schemaInfo.required.includes(key) ? "required" : ""; + const itemType = propertySchema.itemType + ? `array<${escapeHtml(propertySchema.itemType)}>` + : "array"; + const metadataHint = renderFieldHint(propertySchema, true); + + return ` + + `; + }) + .join("\n"); + + const unsupportedFields = Array.from(parsedYaml.entries.entries()) + .filter(([, entry]) => entry.kind !== "scalar" && entry.kind !== "array") + .map(([key, entry]) => ` +
+ ${escapeHtml(key)}: ${escapeHtml(entry.kind)} fields are currently raw-YAML-only. +
+ `) + .join("\n"); + + const schemaStatus = schemaInfo.exists + ? `Schema: ${escapeHtml(schemaInfo.schemaPath)}` + : `Schema missing: ${escapeHtml(schemaInfo.schemaPath)}`; + + const editableContent = [scalarFields, arrayFields].filter((content) => content.length > 0).join("\n"); + const unsupportedSection = unsupportedFields.length > 0 + ? `
${unsupportedFields}
` + : ""; + const emptyState = editableContent.length > 0 + ? `${editableContent}${unsupportedSection}` + : "

No editable top-level scalar or scalar-array fields were detected. Use raw YAML for nested objects or complex arrays.

"; + + return ` + + + + + + + +
+ + +
+
+
File: ${escapeHtml(fileName)}
+
${schemaStatus}
+
+
${emptyState}
+ + +`; +} + +/** + * Render human-facing metadata hints for one schema field. + * + * @param {{description?: string, defaultValue?: string, enumValues?: string[], itemEnumValues?: string[], refTable?: string}} propertySchema Property schema metadata. + * @param {boolean} isArrayField Whether the field is an array. + * @returns {string} HTML fragment. + */ +function renderFieldHint(propertySchema, isArrayField) { + const hints = []; + + if (propertySchema.description) { + hints.push(escapeHtml(propertySchema.description)); + } + + if (propertySchema.defaultValue) { + hints.push(`Default: ${escapeHtml(propertySchema.defaultValue)}`); + } + + const enumValues = isArrayField ? propertySchema.itemEnumValues : propertySchema.enumValues; + if (Array.isArray(enumValues) && enumValues.length > 0) { + hints.push(`Allowed: ${escapeHtml(enumValues.join(", "))}`); + } + + if (propertySchema.refTable) { + hints.push(`Ref table: ${escapeHtml(propertySchema.refTable)}`); + } + + if (hints.length === 0) { + return ""; + } + + return `${hints.join(" · ")}`; +} + +/** + * Prompt for one batch-edit field value. + * + * @param {{key: string, type: string, itemType?: string, title?: string, description?: string, defaultValue?: string, enumValues?: string[], itemEnumValues?: string[], refTable?: string, inputKind: "scalar" | "array", required: boolean}} field Editable field descriptor. + * @returns {Promise} User input, or undefined when cancelled. + */ +async function promptBatchFieldValue(field) { + if (field.inputKind === "array") { + const hintParts = []; + if (field.itemEnumValues && field.itemEnumValues.length > 0) { + hintParts.push(`Allowed items: ${field.itemEnumValues.join(", ")}`); + } + + if (field.defaultValue) { + hintParts.push(`Default: ${field.defaultValue}`); + } + + return vscode.window.showInputBox({ + title: `Batch Edit Array: ${field.title || field.key}`, + prompt: `Enter comma-separated items for '${field.key}' (expected array<${field.itemType}>). Leave empty to clear the array.`, + placeHolder: hintParts.join(" | "), + ignoreFocusOut: true + }); + } + + if (field.enumValues && field.enumValues.length > 0) { + const picked = await vscode.window.showQuickPick( + field.enumValues.map((value) => ({ + label: value, + description: value === field.defaultValue ? "default" : undefined + })), + { + title: `Batch Edit Field: ${field.title || field.key}`, + placeHolder: `Select a value for '${field.key}'.` + }); + return picked ? picked.label : undefined; + } + + return vscode.window.showInputBox({ + title: `Batch Edit Field: ${field.title || field.key}`, + prompt: `Enter the new value for '${field.key}' (expected ${field.type}).`, + placeHolder: [ + field.description || "", + field.defaultValue ? `Default: ${field.defaultValue}` : "", + field.refTable ? `Ref table: ${field.refTable}` : "" + ].filter((part) => part.length > 0).join(" | ") || undefined, + ignoreFocusOut: true + }); +} + +/** + * 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, "'"); +} + +/** + * Convert raw textarea payloads into scalar-array items. + * + * @param {Record} arrays Raw array editor payload. + * @returns {Record} Parsed array updates. + */ +function parseArrayFieldPayload(arrays) { + const parsed = {}; + + for (const [key, value] of Object.entries(arrays)) { + parsed[key] = String(value) + .split(/\r?\n/u) + .map((item) => item.trim()) + .filter((item) => item.length > 0); + } + + return parsed; +} + +module.exports = { + activate, + deactivate +}; diff --git a/tools/vscode-config-extension/test/configValidation.test.js b/tools/vscode-config-extension/test/configValidation.test.js new file mode 100644 index 0000000..072b8c6 --- /dev/null +++ b/tools/vscode-config-extension/test/configValidation.test.js @@ -0,0 +1,301 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + applyFormUpdates, + applyScalarUpdates, + getEditableSchemaFields, + parseBatchArrayValue, + parseSchemaContent, + parseTopLevelYaml, + validateParsedConfig +} = require("../src/configValidation"); + +test("parseSchemaContent should capture scalar and array property metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "integer", + "title": "Monster Id", + "description": "Primary monster key.", + "default": 1 + }, + "name": { + "type": "string", + "enum": ["Slime", "Goblin"] + }, + "dropRates": { + "type": "array", + "description": "Drop rate list.", + "items": { + "type": "integer", + "enum": [1, 2, 3] + } + } + } + } + `); + + assert.deepEqual(schema.required, ["id", "name"]); + assert.deepEqual(schema.properties, { + id: { + type: "integer", + title: "Monster Id", + description: "Primary monster key.", + defaultValue: "1", + enumValues: undefined, + refTable: undefined + }, + name: { + type: "string", + title: undefined, + description: undefined, + defaultValue: undefined, + enumValues: ["Slime", "Goblin"], + refTable: undefined + }, + dropRates: { + type: "array", + itemType: "integer", + title: undefined, + description: "Drop rate list.", + defaultValue: undefined, + refTable: undefined, + itemEnumValues: ["1", "2", "3"] + } + }); +}); + +test("validateParsedConfig should report missing and unknown properties", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" } + } + } + `); + const yaml = parseTopLevelYaml(` +id: 1 +title: Slime +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 2); + assert.equal(diagnostics[0].severity, "error"); + assert.match(diagnostics[0].message, /name/u); + assert.equal(diagnostics[1].severity, "error"); + assert.match(diagnostics[1].message, /title/u); +}); + +test("validateParsedConfig should report array item type mismatches", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRates": { + "type": "array", + "items": { "type": "integer" } + } + } + } + `); + const yaml = parseTopLevelYaml(` +dropRates: + - 1 + - potion +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].severity, "error"); + assert.match(diagnostics[0].message, /dropRates/u); +}); + +test("validateParsedConfig should report scalar enum mismatches", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "rarity": { + "type": "string", + "enum": ["common", "rare"] + } + } + } + `); + const yaml = parseTopLevelYaml(` +rarity: epic +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /common, rare/u); +}); + +test("validateParsedConfig should report array item enum mismatches", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string", + "enum": ["fire", "ice"] + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +tags: + - fire + - poison +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /fire, ice/u); +}); + +test("parseTopLevelYaml should classify nested mappings as object entries", () => { + const yaml = parseTopLevelYaml(` +reward: + gold: 10 +name: Slime +`); + + assert.equal(yaml.entries.get("reward").kind, "object"); + assert.equal(yaml.entries.get("name").kind, "scalar"); +}); + +test("applyScalarUpdates should update top-level scalars and append new keys", () => { + const updated = applyScalarUpdates( + [ + "id: 1", + "name: Slime", + "dropRates:", + " - 1" + ].join("\n"), + { + name: "Goblin", + hp: "25" + }); + + assert.match(updated, /^name: Goblin$/mu); + assert.match(updated, /^hp: 25$/mu); + assert.match(updated, /^ - 1$/mu); +}); + +test("applyFormUpdates should replace top-level scalar arrays and preserve unrelated content", () => { + const updated = applyFormUpdates( + [ + "id: 1", + "name: Slime", + "dropItems:", + " - potion", + " - slime_gel", + "reward:", + " gold: 10" + ].join("\n"), + { + scalars: { + name: "Goblin" + }, + arrays: { + dropItems: ["bomb", "hi potion"] + } + }); + + assert.match(updated, /^name: Goblin$/mu); + assert.match(updated, /^dropItems:$/mu); + assert.match(updated, /^ - bomb$/mu); + assert.match(updated, /^ - hi potion$/mu); + assert.match(updated, /^reward:$/mu); + assert.match(updated, /^ gold: 10$/mu); +}); + +test("getEditableSchemaFields should expose only scalar and scalar-array properties", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "required": ["id", "dropItems"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "title": "Monster Name", + "description": "Display name." + }, + "reward": { "type": "object" }, + "dropItems": { + "type": "array", + "description": "Drop ids.", + "items": { + "type": "string", + "enum": ["potion", "bomb"] + } + }, + "waypoints": { + "type": "array", + "items": { "type": "object" } + } + } + } + `); + + assert.deepEqual(getEditableSchemaFields(schema), [ + { + key: "dropItems", + type: "array", + itemType: "string", + title: undefined, + description: "Drop ids.", + defaultValue: undefined, + itemEnumValues: ["potion", "bomb"], + refTable: undefined, + inputKind: "array", + required: true + }, + { + key: "id", + type: "integer", + title: undefined, + description: undefined, + defaultValue: undefined, + enumValues: undefined, + refTable: undefined, + inputKind: "scalar", + required: true + }, + { + key: "name", + type: "string", + title: "Monster Name", + description: "Display name.", + defaultValue: undefined, + enumValues: undefined, + refTable: undefined, + inputKind: "scalar", + required: false + } + ]); +}); + +test("parseBatchArrayValue should split comma-separated items and drop empty segments", () => { + assert.deepEqual(parseBatchArrayValue(" potion, hi potion , ,bomb "), [ + "potion", + "hi potion", + "bomb" + ]); + assert.deepEqual(parseBatchArrayValue(""), []); +});