Merge pull request #156 from GeWuYou/feat/config-management

Feat/config management
This commit is contained in:
gewuyou 2026-04-01 12:54:45 +08:00 committed by GitHub
commit 965f20059f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 7089 additions and 21 deletions

View File

@ -200,6 +200,17 @@ bash scripts/validate-csharp-naming.sh
- The main documentation site lives under `docs/`, with Chinese content under `docs/zh-CN/`. - 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. - Keep code samples, package names, and command examples aligned with the current repository state.
- Prefer documenting behavior and design intent, not only API surface. - 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 ### Documentation Preview
@ -218,3 +229,4 @@ Before considering work complete, confirm:
- Relevant tests were added or updated - Relevant tests were added or updated
- Sensitive or unsafe behavior was not introduced - Sensitive or unsafe behavior was not introduced
- User-facing documentation is updated when needed - 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

View File

@ -0,0 +1,19 @@
using GFramework.Core.Abstractions.Utility;
namespace GFramework.Game.Abstractions.Config;
/// <summary>
/// 定义配置加载器契约。
/// 具体实现负责从文件系统、资源包或其他配置源加载文本配置,并将解析结果注册到配置注册表。
/// </summary>
public interface IConfigLoader : IUtility
{
/// <summary>
/// 执行配置加载并将结果写入注册表。
/// 实现应在同一次加载过程中保证注册结果的一致性,避免只加载部分配置后就暴露给运行时消费。
/// </summary>
/// <param name="registry">用于接收配置表的注册表。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示异步加载流程的任务。</returns>
Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,84 @@
using GFramework.Core.Abstractions.Utility;
namespace GFramework.Game.Abstractions.Config;
/// <summary>
/// 定义配置注册表契约,用于统一保存和解析按名称注册的配置表。
/// 注册表是运行时配置系统的入口,负责在加载阶段收集配置表,并在消费阶段提供类型安全查询。
/// </summary>
public interface IConfigRegistry : IUtility
{
/// <summary>
/// 获取当前已注册配置表数量。
/// </summary>
int Count { get; }
/// <summary>
/// 获取所有已注册配置表名称。
/// </summary>
/// <returns>配置表名称集合。</returns>
IReadOnlyCollection<string> GetTableNames();
/// <summary>
/// 注册指定名称的配置表。
/// 若名称已存在,则替换旧表,以便开发期热重载使用同一入口刷新配置。
/// </summary>
/// <typeparam name="TKey">配置表主键类型。</typeparam>
/// <typeparam name="TValue">配置项值类型。</typeparam>
/// <param name="name">配置表名称。</param>
/// <param name="table">要注册的配置表实例。</param>
void RegisterTable<TKey, TValue>(string name, IConfigTable<TKey, TValue> table)
where TKey : notnull;
/// <summary>
/// 获取指定名称的配置表。
/// </summary>
/// <typeparam name="TKey">配置表主键类型。</typeparam>
/// <typeparam name="TValue">配置项值类型。</typeparam>
/// <param name="name">配置表名称。</param>
/// <returns>匹配的强类型配置表实例。</returns>
/// <exception cref="KeyNotFoundException">当配置表名称不存在时抛出。</exception>
/// <exception cref="InvalidOperationException">当请求类型与已注册配置表类型不匹配时抛出。</exception>
IConfigTable<TKey, TValue> GetTable<TKey, TValue>(string name)
where TKey : notnull;
/// <summary>
/// 尝试获取指定名称的配置表。
/// 当名称存在但类型不匹配时返回 <c>false</c>,避免消费端将类型错误误判为加载成功。
/// </summary>
/// <typeparam name="TKey">配置表主键类型。</typeparam>
/// <typeparam name="TValue">配置项值类型。</typeparam>
/// <param name="name">配置表名称。</param>
/// <param name="table">匹配的强类型配置表;未找到或类型不匹配时返回空。</param>
/// <returns>找到且类型匹配时返回 <c>true</c>,否则返回 <c>false</c>。</returns>
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
where TKey : notnull;
/// <summary>
/// 尝试获取指定名称的原始配置表。
/// 该入口用于跨表校验或诊断场景,以便在不知道泛型参数时仍能访问表元数据。
/// </summary>
/// <param name="name">配置表名称。</param>
/// <param name="table">匹配的原始配置表;未找到时返回空。</param>
/// <returns>找到配置表时返回 <c>true</c>,否则返回 <c>false</c>。</returns>
bool TryGetTable(string name, out IConfigTable? table);
/// <summary>
/// 检查指定名称的配置表是否存在。
/// </summary>
/// <param name="name">配置表名称。</param>
/// <returns>存在时返回 <c>true</c>,否则返回 <c>false</c>。</returns>
bool HasTable(string name);
/// <summary>
/// 移除指定名称的配置表。
/// </summary>
/// <param name="name">配置表名称。</param>
/// <returns>移除成功时返回 <c>true</c>,否则返回 <c>false</c>。</returns>
bool RemoveTable(string name);
/// <summary>
/// 清空所有已注册配置表。
/// </summary>
void Clear();
}

View File

@ -0,0 +1,65 @@
using GFramework.Core.Abstractions.Utility;
namespace GFramework.Game.Abstractions.Config;
/// <summary>
/// 定义配置表的非泛型公共契约,用于在注册表中保存异构配置表实例。
/// 该接口只暴露运行时发现和诊断所需的元数据,不提供具体类型访问能力。
/// </summary>
public interface IConfigTable : IUtility
{
/// <summary>
/// 获取配置表主键类型。
/// </summary>
Type KeyType { get; }
/// <summary>
/// 获取配置项值类型。
/// </summary>
Type ValueType { get; }
/// <summary>
/// 获取当前配置表中的条目数量。
/// </summary>
int Count { get; }
}
/// <summary>
/// 定义强类型只读配置表契约。
/// 运行时配置表应通过主键执行只读查询,而不是暴露可变集合接口,
/// 以保持配置数据在加载完成后的稳定性和可预测性。
/// </summary>
/// <typeparam name="TKey">配置表主键类型。</typeparam>
/// <typeparam name="TValue">配置项值类型。</typeparam>
public interface IConfigTable<TKey, TValue> : IConfigTable
where TKey : notnull
{
/// <summary>
/// 获取指定主键的配置项。
/// </summary>
/// <param name="key">配置项主键。</param>
/// <returns>找到的配置项。</returns>
/// <exception cref="KeyNotFoundException">当主键不存在时抛出。</exception>
TValue Get(TKey key);
/// <summary>
/// 尝试获取指定主键的配置项。
/// </summary>
/// <param name="key">配置项主键。</param>
/// <param name="value">找到的配置项;未找到时返回默认值。</param>
/// <returns>找到配置项时返回 <c>true</c>,否则返回 <c>false</c>。</returns>
bool TryGet(TKey key, out TValue? value);
/// <summary>
/// 检查指定主键是否存在。
/// </summary>
/// <param name="key">配置项主键。</param>
/// <returns>主键存在时返回 <c>true</c>,否则返回 <c>false</c>。</returns>
bool ContainsKey(TKey key);
/// <summary>
/// 获取配置表中的所有配置项快照。
/// </summary>
/// <returns>只读配置项集合。</returns>
IReadOnlyCollection<TValue> All();
}

View File

@ -0,0 +1,150 @@
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
namespace GFramework.Game.Tests.Config;
/// <summary>
/// 验证配置注册表的注册、覆盖和类型检查行为。
/// </summary>
[TestFixture]
public class ConfigRegistryTests
{
/// <summary>
/// 验证注册后的配置表可以按名称和类型成功解析。
/// </summary>
[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<int, MonsterConfigStub>("monster");
Assert.That(resolved, Is.SameAs(table));
}
/// <summary>
/// 验证同名注册会覆盖旧表,用于后续热重载场景。
/// </summary>
[Test]
public void RegisterTable_Should_Replace_Previous_Table_With_Same_Name()
{
var registry = new ConfigRegistry();
var oldTable = CreateMonsterTable();
var newTable = new InMemoryConfigTable<int, MonsterConfigStub>(
new[]
{
new MonsterConfigStub(3, "Orc")
},
static config => config.Id);
registry.RegisterTable("monster", oldTable);
registry.RegisterTable("monster", newTable);
var resolved = registry.GetTable<int, MonsterConfigStub>("monster");
Assert.That(resolved, Is.SameAs(newTable));
Assert.That(resolved.Count, Is.EqualTo(1));
}
/// <summary>
/// 验证请求类型与实际注册类型不匹配时会抛出异常,避免消费端默默读取错误表。
/// </summary>
[Test]
public void GetTable_Should_Throw_When_Requested_Type_Does_Not_Match_Registered_Table()
{
var registry = new ConfigRegistry();
registry.RegisterTable("monster", CreateMonsterTable());
Assert.Throws<InvalidOperationException>(() => registry.GetTable<string, MonsterConfigStub>("monster"));
}
/// <summary>
/// 验证弱类型查询入口可以在不知道泛型参数时返回原始配置表。
/// </summary>
[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)));
});
}
/// <summary>
/// 验证移除和清空操作会更新注册表状态。
/// </summary>
[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));
}
/// <summary>
/// 创建怪物配置表测试实例。
/// </summary>
/// <returns>怪物配置表。</returns>
private static IConfigTable<int, MonsterConfigStub> CreateMonsterTable()
{
return new InMemoryConfigTable<int, MonsterConfigStub>(
new[]
{
new MonsterConfigStub(1, "Slime"),
new MonsterConfigStub(2, "Goblin")
},
static config => config.Id);
}
/// <summary>
/// 创建 NPC 配置表测试实例。
/// </summary>
/// <returns>NPC 配置表。</returns>
private static IConfigTable<Guid, NpcConfigStub> CreateNpcTable()
{
return new InMemoryConfigTable<Guid, NpcConfigStub>(
new[]
{
new NpcConfigStub(Guid.NewGuid(), "Guide")
},
static config => config.Id);
}
/// <summary>
/// 用于怪物配置表测试的最小配置类型。
/// </summary>
/// <param name="Id">配置主键。</param>
/// <param name="Name">配置名称。</param>
private sealed record MonsterConfigStub(int Id, string Name);
/// <summary>
/// 用于 NPC 配置表测试的最小配置类型。
/// </summary>
/// <param name="Id">配置主键。</param>
/// <param name="Name">配置名称。</param>
private sealed record NpcConfigStub(Guid Id, string Name);
}

View File

@ -0,0 +1,72 @@
using GFramework.Game.Config;
namespace GFramework.Game.Tests.Config;
/// <summary>
/// 验证内存配置表的基础只读查询行为。
/// </summary>
[TestFixture]
public class InMemoryConfigTableTests
{
/// <summary>
/// 验证已存在主键可以被正确查询。
/// </summary>
[Test]
public void Get_Should_Return_Config_When_Key_Exists()
{
var table = new InMemoryConfigTable<int, MonsterConfigStub>(
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"));
}
/// <summary>
/// 验证重复主键会在加载期被拒绝,避免运行期覆盖旧值。
/// </summary>
[Test]
public void Constructor_Should_Throw_When_Duplicate_Key_Is_Detected()
{
Assert.Throws<InvalidOperationException>(() =>
new InMemoryConfigTable<int, MonsterConfigStub>(
new[]
{
new MonsterConfigStub(1, "Slime"),
new MonsterConfigStub(1, "Goblin")
},
static config => config.Id));
}
/// <summary>
/// 验证 All 返回的集合包含完整快照。
/// </summary>
[Test]
public void All_Should_Return_All_Configs()
{
var table = new InMemoryConfigTable<int, MonsterConfigStub>(
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" }));
}
/// <summary>
/// 用于配置表测试的最小配置类型。
/// </summary>
/// <param name="Id">配置主键。</param>
/// <param name="Name">配置名称。</param>
private sealed record MonsterConfigStub(int Id, string Name);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,181 @@
using GFramework.Game.Abstractions.Config;
namespace GFramework.Game.Config;
/// <summary>
/// 默认配置注册表实现。
/// 该类型负责统一管理按名称注册的配置表,并在消费端提供类型安全的解析入口。
/// 为了支持开发期热重载,注册行为采用覆盖策略而不是拒绝重复名称。
/// </summary>
public sealed class ConfigRegistry : IConfigRegistry
{
private const string NameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace.";
private readonly ConcurrentDictionary<string, IConfigTable> _tables = new(StringComparer.Ordinal);
/// <summary>
/// 获取已注册的配置表数量。
/// </summary>
public int Count => _tables.Count;
/// <summary>
/// 获取所有已注册配置表的名称集合,按字典序排序。
/// </summary>
/// <returns>返回只读的配置表名称集合。</returns>
public IReadOnlyCollection<string> GetTableNames()
{
return _tables.Keys.OrderBy(static key => key, StringComparer.Ordinal).ToArray();
}
/// <summary>
/// 注册一个配置表到注册表中。
/// 如果同名的配置表已存在,则会覆盖原有注册以支持热重载。
/// </summary>
/// <typeparam name="TKey">配置表主键的类型,必须为非空类型。</typeparam>
/// <typeparam name="TValue">配置表值的类型。</typeparam>
/// <param name="name">配置表的注册名称,用于后续查找。</param>
/// <param name="table">要注册的配置表实例。</param>
/// <exception cref="ArgumentException">当 <paramref name="name" /> 为 null、空或仅包含空白字符时抛出。</exception>
/// <exception cref="ArgumentNullException">当 <paramref name="table" /> 为 null 时抛出。</exception>
public void RegisterTable<TKey, TValue>(string name, IConfigTable<TKey, TValue> table)
where TKey : notnull
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name));
}
ArgumentNullException.ThrowIfNull(table);
_tables[name] = table;
}
/// <summary>
/// 根据名称获取已注册的配置表,并进行类型验证。
/// </summary>
/// <typeparam name="TKey">期望的主键类型,必须为非空类型。</typeparam>
/// <typeparam name="TValue">期望的值类型。</typeparam>
/// <param name="name">要查找的配置表名称。</param>
/// <returns>返回类型匹配的配置表实例。</returns>
/// <exception cref="ArgumentException">当 <paramref name="name" /> 为 null、空或仅包含空白字符时抛出。</exception>
/// <exception cref="KeyNotFoundException">当指定名称的配置表不存在时抛出。</exception>
/// <exception cref="InvalidOperationException">
/// 当找到的配置表类型与请求的类型不匹配时抛出。
/// </exception>
public IConfigTable<TKey, TValue> GetTable<TKey, TValue>(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<TKey, TValue> 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}'.");
}
/// <summary>
/// 尝试根据名称获取配置表,操作失败时不会抛出异常。
/// </summary>
/// <typeparam name="TKey">期望的主键类型,必须为非空类型。</typeparam>
/// <typeparam name="TValue">期望的值类型。</typeparam>
/// <param name="name">要查找的配置表名称。</param>
/// <param name="table">
/// 输出参数,如果查找成功则返回类型匹配的配置表实例,否则为 null。
/// </param>
/// <returns>如果找到指定名称且类型匹配的配置表则返回 true否则返回 false。</returns>
/// <exception cref="ArgumentException">当 <paramref name="name" /> 为 null、空或仅包含空白字符时抛出。</exception>
public bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? 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<TKey, TValue> typedTable)
{
return false;
}
table = typedTable;
return true;
}
/// <summary>
/// 尝试根据名称获取原始配置表。
/// </summary>
/// <param name="name">要查找的配置表名称。</param>
/// <param name="table">输出参数,如果查找成功则返回原始配置表实例,否则为 null。</param>
/// <returns>如果找到指定名称的配置表则返回 true否则返回 false。</returns>
/// <exception cref="ArgumentException">当 <paramref name="name" /> 为 null、空或仅包含空白字符时抛出。</exception>
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);
}
/// <summary>
/// 检查指定名称的配置表是否已注册。
/// </summary>
/// <param name="name">要检查的配置表名称。</param>
/// <returns>如果配置表已注册则返回 true否则返回 false。</returns>
/// <exception cref="ArgumentException">当 <paramref name="name" /> 为 null、空或仅包含空白字符时抛出。</exception>
public bool HasTable(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name));
}
return _tables.ContainsKey(name);
}
/// <summary>
/// 从注册表中移除指定名称的配置表。
/// </summary>
/// <param name="name">要移除的配置表名称。</param>
/// <returns>如果配置表存在并被成功移除则返回 true否则返回 false。</returns>
/// <exception cref="ArgumentException">当 <paramref name="name" /> 为 null、空或仅包含空白字符时抛出。</exception>
public bool RemoveTable(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name));
}
return _tables.TryRemove(name, out _);
}
/// <summary>
/// 清空注册表中的所有配置表。
/// </summary>
public void Clear()
{
_tables.Clear();
}
}

View File

@ -0,0 +1,118 @@
using System.Collections.ObjectModel;
using GFramework.Game.Abstractions.Config;
namespace GFramework.Game.Config;
/// <summary>
/// 基于内存字典的只读配置表实现。
/// 该实现用于 Runtime MVP 阶段,为加载器和注册表提供稳定的只读查询对象。
/// </summary>
/// <typeparam name="TKey">配置表主键类型。</typeparam>
/// <typeparam name="TValue">配置项值类型。</typeparam>
public sealed class InMemoryConfigTable<TKey, TValue> : IConfigTable<TKey, TValue>
where TKey : notnull
{
private readonly IReadOnlyCollection<TValue> _allValues;
private readonly IReadOnlyDictionary<TKey, TValue> _entries;
/// <summary>
/// 使用配置项序列和主键选择器创建内存配置表。
/// </summary>
/// <param name="values">配置项序列。</param>
/// <param name="keySelector">用于提取主键的委托。</param>
/// <param name="comparer">可选的主键比较器。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="values" /> 或 <paramref name="keySelector" /> 为空时抛出。</exception>
/// <exception cref="InvalidOperationException">当配置项主键重复时抛出。</exception>
public InMemoryConfigTable(
IEnumerable<TValue> values,
Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? comparer = null)
{
ArgumentNullException.ThrowIfNull(values);
ArgumentNullException.ThrowIfNull(keySelector);
var dictionary = new Dictionary<TKey, TValue>(comparer);
var allValues = new List<TValue>();
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<TKey, TValue>(dictionary);
_allValues = new ReadOnlyCollection<TValue>(allValues);
}
/// <summary>
/// 获取配置表的主键类型。
/// </summary>
public Type KeyType => typeof(TKey);
/// <summary>
/// 获取配置表的值类型。
/// </summary>
public Type ValueType => typeof(TValue);
/// <summary>
/// 获取配置表中配置项的数量。
/// </summary>
public int Count => _entries.Count;
/// <summary>
/// 根据主键获取配置项的值。
/// </summary>
/// <param name="key">要查找的配置项主键。</param>
/// <returns>返回对应主键的配置项值。</returns>
/// <exception cref="KeyNotFoundException">当指定主键的配置项不存在时抛出。</exception>
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;
}
/// <summary>
/// 尝试根据主键获取配置项的值,操作失败时不会抛出异常。
/// </summary>
/// <param name="key">要查找的配置项主键。</param>
/// <param name="value">
/// 输出参数,如果查找成功则返回对应的配置项值,否则为默认值。
/// </param>
/// <returns>如果找到指定主键的配置项则返回 true否则返回 false。</returns>
public bool TryGet(TKey key, out TValue? value)
{
return _entries.TryGetValue(key, out value);
}
/// <summary>
/// 检查指定主键的配置项是否存在于配置表中。
/// </summary>
/// <param name="key">要检查的配置项主键。</param>
/// <returns>如果配置项已存在则返回 true否则返回 false。</returns>
public bool ContainsKey(TKey key)
{
return _entries.ContainsKey(key);
}
/// <summary>
/// 获取配置表中所有配置项的集合。
/// </summary>
/// <returns>返回所有配置项值的只读集合。</returns>
public IReadOnlyCollection<TValue> All()
{
return _allValues;
}
}

View File

@ -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;
/// <summary>
/// 基于文件目录的 YAML 配置加载器。
/// 该实现用于 Runtime MVP 的文本配置接入阶段,通过显式注册表定义描述要加载的配置域,
/// 再在一次加载流程中统一解析并写入配置注册表。
/// </summary>
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<string, IReadOnlyCollection<string>> _lastSuccessfulDependencies =
new(StringComparer.Ordinal);
private readonly List<IYamlTableRegistration> _registrations = new();
private readonly string _rootPath;
/// <summary>
/// 使用指定配置根目录创建 YAML 配置加载器。
/// </summary>
/// <param name="rootPath">配置根目录。</param>
/// <exception cref="ArgumentException">当 <paramref name="rootPath" /> 为空时抛出。</exception>
public YamlConfigLoader(string rootPath)
{
if (string.IsNullOrWhiteSpace(rootPath))
{
throw new ArgumentException(RootPathCannotBeNullOrWhiteSpaceMessage, nameof(rootPath));
}
_rootPath = rootPath;
_deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
}
/// <summary>
/// 获取配置根目录。
/// </summary>
public string RootPath => _rootPath;
/// <summary>
/// 获取当前已注册的配置表定义数量。
/// </summary>
public int RegistrationCount => _registrations.Count;
/// <inheritdoc />
public async Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(registry);
var loadedTables = new List<YamlTableLoadResult>(_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);
}
/// <summary>
/// 启用开发期热重载。
/// 该能力会监听已注册配置表对应的配置目录和 schema 文件,并在检测到文件变更后按表粒度重新加载。
/// 重载失败时会保留注册表中的旧表,避免开发期错误配置直接破坏当前运行时状态。
/// </summary>
/// <param name="registry">要被热重载更新的配置注册表。</param>
/// <param name="onTableReloaded">单个配置表重载成功后的可选回调。</param>
/// <param name="onTableReloadFailed">单个配置表重载失败后的可选回调。</param>
/// <param name="debounceDelay">防抖延迟;为空时默认使用 200 毫秒。</param>
/// <returns>用于停止热重载监听的注销句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为空时抛出。</exception>
public IUnRegister EnableHotReload(
IConfigRegistry registry,
Action<string>? onTableReloaded = null,
Action<string, Exception>? 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<YamlTableLoadResult> loadedTables)
{
_lastSuccessfulDependencies.Clear();
foreach (var loadedTable in loadedTables)
{
_lastSuccessfulDependencies[loadedTable.Name] = loadedTable.ReferencedTableNames;
}
}
/// <summary>
/// 注册一个 YAML 配置表定义。
/// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。
/// </summary>
/// <typeparam name="TKey">配置主键类型。</typeparam>
/// <typeparam name="TValue">配置值类型。</typeparam>
/// <param name="tableName">配置表名称。</param>
/// <param name="relativePath">相对配置根目录的子目录。</param>
/// <param name="keySelector">配置项主键提取器。</param>
/// <param name="comparer">可选主键比较器。</param>
/// <returns>当前加载器实例,以便链式注册。</returns>
public YamlConfigLoader RegisterTable<TKey, TValue>(
string tableName,
string relativePath,
Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? comparer = null)
where TKey : notnull
{
return RegisterTableCore(tableName, relativePath, null, keySelector, comparer);
}
/// <summary>
/// 注册一个带 schema 校验的 YAML 配置表定义。
/// 该重载会在 YAML 反序列化之前使用指定 schema 拒绝未知字段、缺失必填字段和基础类型错误,
/// 以避免错误配置以默认值形式悄悄进入运行时。
/// </summary>
/// <typeparam name="TKey">配置主键类型。</typeparam>
/// <typeparam name="TValue">配置值类型。</typeparam>
/// <param name="tableName">配置表名称。</param>
/// <param name="relativePath">相对配置根目录的子目录。</param>
/// <param name="schemaRelativePath">相对配置根目录的 schema 文件路径。</param>
/// <param name="keySelector">配置项主键提取器。</param>
/// <param name="comparer">可选主键比较器。</param>
/// <returns>当前加载器实例,以便链式注册。</returns>
public YamlConfigLoader RegisterTable<TKey, TValue>(
string tableName,
string relativePath,
string schemaRelativePath,
Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? comparer = null)
where TKey : notnull
{
return RegisterTableCore(tableName, relativePath, schemaRelativePath, keySelector, comparer);
}
private YamlConfigLoader RegisterTableCore<TKey, TValue>(
string tableName,
string relativePath,
string? schemaRelativePath,
Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? 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<TKey, TValue>(
tableName,
relativePath,
schemaRelativePath,
keySelector,
comparer));
return this;
}
/// <summary>
/// 负责在非泛型配置表与泛型注册表方法之间做分派。
/// 该静态助手将运行时反射局部封装在加载器内部,避免向外暴露弱类型注册 API。
/// </summary>
private static class RegistrationDispatcher
{
/// <summary>
/// 将强类型配置表写入注册表。
/// </summary>
/// <param name="registry">目标配置注册表。</param>
/// <param name="name">配置表名称。</param>
/// <param name="table">已加载的配置表实例。</param>
/// <exception cref="InvalidOperationException">当传入表未实现强类型配置表契约时抛出。</exception>
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 });
}
}
/// <summary>
/// 定义 YAML 配置表注册项的统一内部契约。
/// </summary>
private interface IYamlTableRegistration
{
/// <summary>
/// 获取配置表名称。
/// </summary>
string Name { get; }
/// <summary>
/// 获取相对配置根目录的子目录。
/// </summary>
string RelativePath { get; }
/// <summary>
/// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时返回空。
/// </summary>
string? SchemaRelativePath { get; }
/// <summary>
/// 从指定根目录加载配置表。
/// </summary>
/// <param name="rootPath">配置根目录。</param>
/// <param name="deserializer">YAML 反序列化器。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>已加载的配置表结果。</returns>
Task<YamlTableLoadResult> LoadAsync(
string rootPath,
IDeserializer deserializer,
CancellationToken cancellationToken);
}
/// <summary>
/// YAML 配置表注册项。
/// </summary>
/// <typeparam name="TKey">配置主键类型。</typeparam>
/// <typeparam name="TValue">配置项值类型。</typeparam>
private sealed class YamlTableRegistration<TKey, TValue> : IYamlTableRegistration
where TKey : notnull
{
private readonly IEqualityComparer<TKey>? _comparer;
private readonly Func<TValue, TKey> _keySelector;
/// <summary>
/// 初始化 YAML 配置表注册项。
/// </summary>
/// <param name="name">配置表名称。</param>
/// <param name="relativePath">相对配置根目录的子目录。</param>
/// <param name="schemaRelativePath">相对配置根目录的 schema 文件路径;未启用 schema 校验时为空。</param>
/// <param name="keySelector">配置项主键提取器。</param>
/// <param name="comparer">可选主键比较器。</param>
public YamlTableRegistration(
string name,
string relativePath,
string? schemaRelativePath,
Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? comparer)
{
Name = name;
RelativePath = relativePath;
SchemaRelativePath = schemaRelativePath;
_keySelector = keySelector;
_comparer = comparer;
}
/// <summary>
/// 获取配置表名称。
/// </summary>
public string Name { get; }
/// <summary>
/// 获取相对配置根目录的子目录。
/// </summary>
public string RelativePath { get; }
/// <summary>
/// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时返回空。
/// </summary>
public string? SchemaRelativePath { get; }
/// <inheritdoc />
public async Task<YamlTableLoadResult> 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<string> referencedTableNames = Array.Empty<string>();
if (!string.IsNullOrEmpty(SchemaRelativePath))
{
var schemaPath = Path.Combine(rootPath, SchemaRelativePath);
schema = await YamlConfigSchemaValidator.LoadAsync(schemaPath, cancellationToken);
referencedTableNames = schema.ReferencedTableNames;
}
var referenceUsages = new List<YamlConfigReferenceUsage>();
var values = new List<TValue>();
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<TValue>(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<TKey, TValue>(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);
}
}
}
/// <summary>
/// 表示单个注册项加载完成后的中间结果。
/// 该结果同时携带配置表实例、schema 声明的依赖关系和 YAML 中提取出的实际引用,以便在批量提交前完成跨表一致性校验。
/// </summary>
private sealed class YamlTableLoadResult
{
/// <summary>
/// 初始化一个表加载结果。
/// </summary>
/// <param name="name">配置表名称。</param>
/// <param name="table">已构建好的配置表。</param>
/// <param name="referencedTableNames">schema 声明的依赖表名称集合。</param>
/// <param name="referenceUsages">YAML 中提取出的实际引用集合。</param>
public YamlTableLoadResult(
string name,
IConfigTable table,
IReadOnlyCollection<string> referencedTableNames,
IReadOnlyCollection<YamlConfigReferenceUsage> referenceUsages)
{
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(table);
ArgumentNullException.ThrowIfNull(referencedTableNames);
ArgumentNullException.ThrowIfNull(referenceUsages);
Name = name;
Table = table;
ReferencedTableNames = referencedTableNames;
ReferenceUsages = referenceUsages;
}
/// <summary>
/// 获取配置表名称。
/// </summary>
public string Name { get; }
/// <summary>
/// 获取已构建好的配置表。
/// </summary>
public IConfigTable Table { get; }
/// <summary>
/// 获取 schema 声明的依赖表名称集合。
/// </summary>
public IReadOnlyCollection<string> ReferencedTableNames { get; }
/// <summary>
/// 获取 YAML 中提取出的实际引用集合。
/// </summary>
public IReadOnlyCollection<YamlConfigReferenceUsage> ReferenceUsages { get; }
}
/// <summary>
/// 负责在所有注册项加载完成后执行跨表引用校验。
/// 该阶段在真正写入注册表之前运行,确保任何缺失目标表、主键类型不兼容或目标行不存在的情况都会整体回滚。
/// </summary>
private static class CrossTableReferenceValidator
{
/// <summary>
/// 使用本轮新加载结果与注册表中保留的旧表,一起验证跨表引用是否全部有效。
/// </summary>
/// <param name="registry">当前配置注册表。</param>
/// <param name="loadedTables">本轮加载出的配置表集合。</param>
public static void Validate(IConfigRegistry registry, IReadOnlyCollection<YamlTableLoadResult> 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<string, YamlTableLoadResult> 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<int, int>.ContainsKey))!;
return (bool)containsKeyMethod.Invoke(table, new[] { key })!;
}
}
/// <summary>
/// 封装开发期热重载所需的文件监听与按表重载逻辑。
/// 该会话只影响通过当前加载器注册的表,不尝试接管注册表中的其他来源数据。
/// </summary>
private sealed class HotReloadSession : IUnRegister, IDisposable
{
private readonly TimeSpan _debounceDelay;
private readonly Dictionary<string, IReadOnlyCollection<string>> _dependenciesByTable =
new(StringComparer.Ordinal);
private readonly IDeserializer _deserializer;
private readonly object _gate = new();
private readonly Action<string>? _onTableReloaded;
private readonly Action<string, Exception>? _onTableReloadFailed;
private readonly Dictionary<string, IYamlTableRegistration> _registrations = new(StringComparer.Ordinal);
private readonly IConfigRegistry _registry;
private readonly Dictionary<string, SemaphoreSlim> _reloadLocks = new(StringComparer.Ordinal);
private readonly Dictionary<string, CancellationTokenSource> _reloadTokens = new(StringComparer.Ordinal);
private readonly string _rootPath;
private readonly List<FileSystemWatcher> _watchers = new();
private bool _disposed;
/// <summary>
/// 初始化一个热重载会话并立即开始监听文件变更。
/// </summary>
/// <param name="rootPath">配置根目录。</param>
/// <param name="deserializer">YAML 反序列化器。</param>
/// <param name="registry">要更新的配置注册表。</param>
/// <param name="registrations">已注册的配置表定义。</param>
/// <param name="initialDependencies">最近一次成功加载后记录下来的跨表依赖图。</param>
/// <param name="onTableReloaded">单表重载成功回调。</param>
/// <param name="onTableReloadFailed">单表重载失败回调。</param>
/// <param name="debounceDelay">监听事件防抖延迟。</param>
public HotReloadSession(
string rootPath,
IDeserializer deserializer,
IConfigRegistry registry,
IEnumerable<IYamlTableRegistration> registrations,
IReadOnlyDictionary<string, IReadOnlyCollection<string>> initialDependencies,
Action<string>? onTableReloaded,
Action<string, Exception>? 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<string>();
CreateWatchersForRegistration(registration);
}
}
/// <summary>
/// 释放热重载会话持有的文件监听器与等待资源。
/// </summary>
public void Dispose()
{
List<FileSystemWatcher> watchersToDispose;
List<CancellationTokenSource> reloadTokensToDispose;
List<SemaphoreSlim> 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();
}
}
/// <summary>
/// 停止热重载监听。
/// </summary>
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<YamlTableLoadResult>(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<string> GetAffectedTableNames(string changedTableName)
{
var affectedTableNames = new HashSet<string>(StringComparer.Ordinal)
{
changedTableName
};
var pendingTableNames = new Queue<string>();
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
{
// 诊断回调不应反向破坏热重载流程。
}
}
}
}

View File

@ -0,0 +1,752 @@
namespace GFramework.Game.Config;
/// <summary>
/// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。
/// 该校验器与当前配置生成器支持的 schema 子集保持一致,
/// 以便在配置进入运行时注册表之前就拒绝缺失字段、未知字段和基础类型错误。
/// </summary>
internal static class YamlConfigSchemaValidator
{
/// <summary>
/// 从磁盘加载并解析一个 JSON Schema 文件。
/// </summary>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>解析后的 schema 模型。</returns>
/// <exception cref="ArgumentException">当 <paramref name="schemaPath" /> 为空时抛出。</exception>
/// <exception cref="FileNotFoundException">当 schema 文件不存在时抛出。</exception>
/// <exception cref="InvalidOperationException">当 schema 内容不符合当前运行时支持的子集时抛出。</exception>
internal static async Task<YamlConfigSchema> 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<string>(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<string, YamlConfigSchemaProperty>(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<string>()
.Distinct(StringComparer.Ordinal)
.ToArray();
return new YamlConfigSchema(schemaPath, properties, requiredProperties, referencedTableNames);
}
catch (JsonException exception)
{
throw new InvalidOperationException($"Schema file '{schemaPath}' contains invalid JSON.", exception);
}
}
/// <summary>
/// 使用已解析的 schema 校验 YAML 文本。
/// </summary>
/// <param name="schema">已解析的 schema 模型。</param>
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
/// <param name="yamlText">YAML 文本内容。</param>
/// <exception cref="ArgumentNullException">当参数为空时抛出。</exception>
/// <exception cref="InvalidOperationException">当 YAML 内容与 schema 不匹配时抛出。</exception>
internal static void Validate(
YamlConfigSchema schema,
string yamlPath,
string yamlText)
{
ValidateAndCollectReferences(schema, yamlPath, yamlText);
}
/// <summary>
/// 使用已解析的 schema 校验 YAML 文本,并提取声明过的跨表引用。
/// 该方法让结构校验与引用采集共享同一份 YAML 解析结果,避免加载器重复解析同一文件。
/// </summary>
/// <param name="schema">已解析的 schema 模型。</param>
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
/// <param name="yamlText">YAML 文本内容。</param>
/// <returns>当前 YAML 文件中声明的跨表引用集合。</returns>
/// <exception cref="ArgumentNullException">当参数为空时抛出。</exception>
/// <exception cref="InvalidOperationException">当 YAML 内容与 schema 不匹配时抛出。</exception>
internal static IReadOnlyList<YamlConfigReferenceUsage> 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<YamlConfigReferenceUsage>();
var seenProperties = new HashSet<string>(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<YamlConfigReferenceUsage> 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<string>? allowedValues,
ICollection<YamlConfigReferenceUsage> 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<string>? 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<string>();
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);
}
}
/// <summary>
/// 表示已解析并可用于运行时校验的 JSON Schema。
/// 该模型只保留当前运行时加载器真正需要的最小信息,以避免在游戏运行时引入完整 schema 引擎。
/// </summary>
internal sealed class YamlConfigSchema
{
/// <summary>
/// 初始化一个可用于运行时校验的 schema 模型。
/// </summary>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="properties">Schema 属性定义。</param>
/// <param name="requiredProperties">必填属性集合。</param>
/// <param name="referencedTableNames">Schema 声明的目标引用表名称集合。</param>
public YamlConfigSchema(
string schemaPath,
IReadOnlyDictionary<string, YamlConfigSchemaProperty> properties,
IReadOnlyCollection<string> requiredProperties,
IReadOnlyCollection<string> referencedTableNames)
{
ArgumentNullException.ThrowIfNull(schemaPath);
ArgumentNullException.ThrowIfNull(properties);
ArgumentNullException.ThrowIfNull(requiredProperties);
ArgumentNullException.ThrowIfNull(referencedTableNames);
SchemaPath = schemaPath;
Properties = properties;
RequiredProperties = requiredProperties;
ReferencedTableNames = referencedTableNames;
}
/// <summary>
/// 获取 schema 文件路径。
/// </summary>
public string SchemaPath { get; }
/// <summary>
/// 获取按属性名索引的 schema 属性定义。
/// </summary>
public IReadOnlyDictionary<string, YamlConfigSchemaProperty> Properties { get; }
/// <summary>
/// 获取 schema 声明的必填属性集合。
/// </summary>
public IReadOnlyCollection<string> RequiredProperties { get; }
/// <summary>
/// 获取 schema 声明的目标引用表名称集合。
/// 该信息用于热重载时推导受影响的依赖表闭包。
/// </summary>
public IReadOnlyCollection<string> ReferencedTableNames { get; }
}
/// <summary>
/// 表示单个 schema 属性的最小运行时描述。
/// </summary>
internal sealed class YamlConfigSchemaProperty
{
/// <summary>
/// 初始化一个 schema 属性描述。
/// </summary>
/// <param name="name">属性名称。</param>
/// <param name="propertyType">属性类型。</param>
/// <param name="itemType">数组元素类型;仅当属性类型为数组时有效。</param>
/// <param name="referenceTableName">目标引用表名称;未声明跨表引用时为空。</param>
/// <param name="allowedValues">标量允许值集合;未声明 enum 时为空。</param>
/// <param name="itemAllowedValues">数组元素允许值集合;未声明 items.enum 时为空。</param>
public YamlConfigSchemaProperty(
string name,
YamlConfigSchemaPropertyType propertyType,
YamlConfigSchemaPropertyType? itemType,
string? referenceTableName,
IReadOnlyCollection<string>? allowedValues,
IReadOnlyCollection<string>? itemAllowedValues)
{
ArgumentNullException.ThrowIfNull(name);
Name = name;
PropertyType = propertyType;
ItemType = itemType;
ReferenceTableName = referenceTableName;
AllowedValues = allowedValues;
ItemAllowedValues = itemAllowedValues;
}
/// <summary>
/// 获取属性名称。
/// </summary>
public string Name { get; }
/// <summary>
/// 获取属性类型。
/// </summary>
public YamlConfigSchemaPropertyType PropertyType { get; }
/// <summary>
/// 获取数组元素类型;非数组属性时返回空。
/// </summary>
public YamlConfigSchemaPropertyType? ItemType { get; }
/// <summary>
/// 获取目标引用表名称;未声明跨表引用时返回空。
/// </summary>
public string? ReferenceTableName { get; }
/// <summary>
/// 获取标量允许值集合;未声明 enum 时返回空。
/// </summary>
public IReadOnlyCollection<string>? AllowedValues { get; }
/// <summary>
/// 获取数组元素允许值集合;未声明 items.enum 时返回空。
/// </summary>
public IReadOnlyCollection<string>? ItemAllowedValues { get; }
}
/// <summary>
/// 表示单个 YAML 文件中提取出的跨表引用。
/// 该模型保留源文件、字段路径和目标表等诊断信息,以便加载器在批量校验失败时给出可定位的错误。
/// </summary>
internal sealed class YamlConfigReferenceUsage
{
/// <summary>
/// 初始化一个跨表引用使用记录。
/// </summary>
/// <param name="yamlPath">源 YAML 文件路径。</param>
/// <param name="propertyName">声明引用的属性名。</param>
/// <param name="itemIndex">数组元素索引;标量属性时为空。</param>
/// <param name="rawValue">YAML 中的原始标量值。</param>
/// <param name="referencedTableName">目标配置表名称。</param>
/// <param name="valueType">引用值的 schema 标量类型。</param>
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;
}
/// <summary>
/// 获取源 YAML 文件路径。
/// </summary>
public string YamlPath { get; }
/// <summary>
/// 获取声明引用的属性名。
/// </summary>
public string PropertyName { get; }
/// <summary>
/// 获取数组元素索引;标量属性时返回空。
/// </summary>
public int? ItemIndex { get; }
/// <summary>
/// 获取 YAML 中的原始标量值。
/// </summary>
public string RawValue { get; }
/// <summary>
/// 获取目标配置表名称。
/// </summary>
public string ReferencedTableName { get; }
/// <summary>
/// 获取引用值的 schema 标量类型。
/// </summary>
public YamlConfigSchemaPropertyType ValueType { get; }
/// <summary>
/// 获取便于诊断显示的字段路径。
/// </summary>
public string DisplayPath => ItemIndex.HasValue ? $"{PropertyName}[{ItemIndex.Value}]" : PropertyName;
}
/// <summary>
/// 表示当前运行时 schema 校验器支持的属性类型。
/// </summary>
internal enum YamlConfigSchemaPropertyType
{
/// <summary>
/// 整数类型。
/// </summary>
Integer,
/// <summary>
/// 数值类型。
/// </summary>
Number,
/// <summary>
/// 布尔类型。
/// </summary>
Boolean,
/// <summary>
/// 字符串类型。
/// </summary>
String,
/// <summary>
/// 数组类型。
/// </summary>
Array
}

View File

@ -14,5 +14,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4"/> <PackageReference Include="Newtonsoft.Json" Version="13.0.4"/>
<PackageReference Include="YamlDotNet" Version="16.3.0"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -16,4 +16,8 @@ global using System.Collections.Generic;
global using System.Collections.Concurrent; global using System.Collections.Concurrent;
global using System.Linq; global using System.Linq;
global using System.Threading; global using System.Threading;
global using System.Threading.Tasks; global using System.Threading.Tasks;
global using System.Globalization;
global using System.IO;
global using System.Text.Json;
global using YamlDotNet.RepresentationModel;

View File

@ -0,0 +1,145 @@
using System.IO;
namespace GFramework.SourceGenerators.Tests.Config;
/// <summary>
/// 验证 schema 配置生成器的生成快照。
/// </summary>
[TestFixture]
public class SchemaConfigGeneratorSnapshotTests
{
/// <summary>
/// 验证一个最小 monster schema 能生成配置类型和表包装。
/// </summary>
[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<TKey, TValue> : IConfigTable
where TKey : notnull
{
TValue Get(TKey key);
bool TryGet(TKey key, out TValue? value);
bool ContainsKey(TKey key);
IReadOnlyCollection<TValue> 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");
}
/// <summary>
/// 对单个生成文件执行快照断言。
/// </summary>
/// <param name="generatedSources">生成结果字典。</param>
/// <param name="snapshotFolder">快照目录。</param>
/// <param name="fileName">快照文件名。</param>
private static async Task AssertSnapshotAsync(
IReadOnlyDictionary<string, string> 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}");
}
/// <summary>
/// 标准化快照文本以避免平台换行差异。
/// </summary>
/// <param name="text">原始文本。</param>
/// <returns>标准化后的文本。</returns>
private static string Normalize(string text)
{
return text.Replace("\r\n", "\n", StringComparison.Ordinal).Trim();
}
}

View File

@ -0,0 +1,48 @@
namespace GFramework.SourceGenerators.Tests.Config;
/// <summary>
/// 验证 schema 配置生成器的错误诊断行为。
/// </summary>
[TestFixture]
public class SchemaConfigGeneratorTests
{
/// <summary>
/// 验证缺失必填 id 字段时会产生命名明确的诊断。
/// </summary>
[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"));
});
}
}

View File

@ -0,0 +1,88 @@
using System.Collections.Immutable;
using System.IO;
using GFramework.SourceGenerators.Config;
using Microsoft.CodeAnalysis.CSharp;
namespace GFramework.SourceGenerators.Tests.Config;
/// <summary>
/// 为 schema 配置生成器提供测试驱动。
/// 该驱动直接使用 Roslyn GeneratorDriver 运行 AdditionalFiles 场景,
/// 以便测试基于 schema 文件的代码生成行为。
/// </summary>
public static class SchemaGeneratorTestDriver
{
/// <summary>
/// 运行 schema 配置生成器,并返回生成结果。
/// </summary>
/// <param name="source">测试用源码。</param>
/// <param name="additionalFiles">AdditionalFiles 集合。</param>
/// <returns>生成器运行结果。</returns>
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();
}
/// <summary>
/// 获取测试编译所需的运行时元数据引用。
/// </summary>
/// <returns>元数据引用集合。</returns>
private static IEnumerable<MetadataReference> GetMetadataReferences()
{
var trustedPlatformAssemblies = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))?
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
?? Array.Empty<string>();
return trustedPlatformAssemblies
.Select(static path => MetadataReference.CreateFromFile(path));
}
/// <summary>
/// 用于测试 AdditionalFiles 的内存实现。
/// </summary>
private sealed class InMemoryAdditionalText : AdditionalText
{
private readonly SourceText _text;
/// <summary>
/// 创建内存 AdditionalText。
/// </summary>
/// <param name="path">虚拟文件路径。</param>
/// <param name="content">文件内容。</param>
public InMemoryAdditionalText(
string path,
string content)
{
Path = path;
_text = SourceText.From(content);
}
/// <inheritdoc />
public override string Path { get; }
/// <inheritdoc />
public override SourceText GetText(CancellationToken cancellationToken = default)
{
return _text;
}
}
}

View File

@ -0,0 +1,51 @@
// <auto-generated />
#nullable enable
namespace GFramework.Game.Config.Generated;
/// <summary>
/// Auto-generated config type for schema file 'monster.schema.json'.
/// Represents one monster entry generated from schema metadata.
/// </summary>
public sealed partial class MonsterConfig
{
/// <summary>
/// Unique monster identifier.
/// </summary>
/// <remarks>
/// Schema property: 'id'.
/// </remarks>
public int Id { get; set; }
/// <summary>
/// Localized monster display name.
/// </summary>
/// <remarks>
/// Schema property: 'name'.
/// Display title: 'Monster Name'.
/// Allowed values: Slime, Goblin.
/// Generated default initializer: = "Slime";
/// </remarks>
public string Name { get; set; } = "Slime";
/// <summary>
/// Gets or sets the value mapped from schema property 'hp'.
/// </summary>
/// <remarks>
/// Schema property: 'hp'.
/// Generated default initializer: = 10;
/// </remarks>
public int? Hp { get; set; } = 10;
/// <summary>
/// Referenced drop ids.
/// </summary>
/// <remarks>
/// Schema property: 'dropItems'.
/// Allowed values: potion, slime_gel.
/// References config table: 'item'.
/// Generated default initializer: = new string[] { "potion" };
/// </remarks>
public global::System.Collections.Generic.IReadOnlyList<string> DropItems { get; set; } = new string[] { "potion" };
}

View File

@ -0,0 +1,55 @@
// <auto-generated />
#nullable enable
namespace GFramework.Game.Config.Generated;
/// <summary>
/// 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.
/// </summary>
public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.Config.IConfigTable<int, MonsterConfig>
{
private readonly global::GFramework.Game.Abstractions.Config.IConfigTable<int, MonsterConfig> _inner;
/// <summary>
/// Creates a generated table wrapper around the runtime config table instance.
/// </summary>
/// <param name="inner">The runtime config table instance.</param>
public MonsterTable(global::GFramework.Game.Abstractions.Config.IConfigTable<int, MonsterConfig> inner)
{
_inner = inner ?? throw new global::System.ArgumentNullException(nameof(inner));
}
/// <inheritdoc />
public global::System.Type KeyType => _inner.KeyType;
/// <inheritdoc />
public global::System.Type ValueType => _inner.ValueType;
/// <inheritdoc />
public int Count => _inner.Count;
/// <inheritdoc />
public MonsterConfig Get(int key)
{
return _inner.Get(key);
}
/// <inheritdoc />
public bool TryGet(int key, out MonsterConfig? value)
{
return _inner.TryGet(key, out value);
}
/// <inheritdoc />
public bool ContainsKey(int key)
{
return _inner.ContainsKey(key);
}
/// <inheritdoc />
public global::System.Collections.Generic.IReadOnlyCollection<MonsterConfig> All()
{
return _inner.All();
}
}

View File

@ -3,21 +3,26 @@
### New Rules ### New Rules
Rule ID | Category | Severity | Notes Rule ID | Category | Severity | Notes
-----------------------|----------------------------------|----------|------------------------ -----------------------|------------------------------------|----------|-------------------------
GF_Logging_001 | GFramework.Godot.logging | Warning | LoggerDiagnostics GF_Logging_001 | GFramework.Godot.logging | Warning | LoggerDiagnostics
GF_Rule_001 | GFramework.SourceGenerators.rule | Error | ContextAwareDiagnostic GF_Rule_001 | GFramework.SourceGenerators.rule | Error | ContextAwareDiagnostic
GF_ContextGet_001 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics GF_ContextGet_001 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
GF_ContextGet_002 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics GF_ContextGet_002 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
GF_ContextGet_003 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics GF_ContextGet_003 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
GF_ContextGet_004 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics GF_ContextGet_004 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
GF_ContextGet_005 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics GF_ContextGet_005 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
GF_ContextGet_006 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics GF_ContextGet_006 | GFramework.SourceGenerators.rule | Error | ContextGetDiagnostics
GF_ContextGet_007 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics GF_ContextGet_007 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics
GF_ContextGet_008 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics GF_ContextGet_008 | GFramework.SourceGenerators.rule | Warning | ContextGetDiagnostics
GF_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic GF_ConfigSchema_001 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic GF_ConfigSchema_002 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic GF_ConfigSchema_003 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_Priority_004 | GFramework.Priority | Error | PriorityDiagnostic GF_ConfigSchema_004 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_Priority_005 | GFramework.Priority | Error | PriorityDiagnostic GF_ConfigSchema_005 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_Priority_Usage_001 | GFramework.Usage | Info | PriorityUsageAnalyzer 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

View File

@ -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;
/// <summary>
/// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。
/// 当前实现聚焦 Runtime MVP 需要的最小能力:单 schema 对应单配置类型,并约定使用必填的 id 字段作为表主键。
/// </summary>
[Generator]
public sealed class SchemaConfigGenerator : IIncrementalGenerator
{
private const string GeneratedNamespace = "GFramework.Game.Config.Generated";
/// <inheritdoc />
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));
});
}
/// <summary>
/// 解析单个 schema 文件。
/// </summary>
/// <param name="file">AdditionalFiles 中的 schema 文件。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>解析结果,包含 schema 模型或诊断。</returns>
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<string>(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<SchemaPropertySpec>();
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));
}
}
/// <summary>
/// 解析单个 schema 属性定义。
/// </summary>
/// <param name="filePath">schema 文件路径。</param>
/// <param name="property">属性 JSON 节点。</param>
/// <param name="isRequired">属性是否必填。</param>
/// <returns>解析后的属性信息或诊断。</returns>
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,
"<missing>"));
}
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));
}
}
/// <summary>
/// 生成配置类型源码。
/// </summary>
/// <param name="schema">已解析的 schema 模型。</param>
/// <returns>配置类型源码。</returns>
private static string GenerateConfigClass(SchemaFileSpec schema)
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("#nullable enable");
builder.AppendLine();
builder.AppendLine($"namespace {schema.Namespace};");
builder.AppendLine();
builder.AppendLine("/// <summary>");
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("/// </summary>");
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();
}
/// <summary>
/// 生成配置表包装源码。
/// </summary>
/// <param name="schema">已解析的 schema 模型。</param>
/// <returns>配置表包装源码。</returns>
private static string GenerateTableClass(SchemaFileSpec schema)
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("#nullable enable");
builder.AppendLine();
builder.AppendLine($"namespace {schema.Namespace};");
builder.AppendLine();
builder.AppendLine("/// <summary>");
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("/// </summary>");
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(" /// <summary>");
builder.AppendLine(" /// Creates a generated table wrapper around the runtime config table instance.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"inner\">The runtime config table instance.</param>");
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(" /// <inheritdoc />");
builder.AppendLine(" public global::System.Type KeyType => _inner.KeyType;");
builder.AppendLine();
builder.AppendLine(" /// <inheritdoc />");
builder.AppendLine(" public global::System.Type ValueType => _inner.ValueType;");
builder.AppendLine();
builder.AppendLine(" /// <inheritdoc />");
builder.AppendLine(" public int Count => _inner.Count;");
builder.AppendLine();
builder.AppendLine(" /// <inheritdoc />");
builder.AppendLine($" public {schema.ClassName} Get({schema.KeyClrType} key)");
builder.AppendLine(" {");
builder.AppendLine(" return _inner.Get(key);");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <inheritdoc />");
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(" /// <inheritdoc />");
builder.AppendLine($" public bool ContainsKey({schema.KeyClrType} key)");
builder.AppendLine(" {");
builder.AppendLine(" return _inner.ContainsKey(key);");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <inheritdoc />");
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();
}
/// <summary>
/// 从 schema 文件路径提取实体基础名。
/// </summary>
/// <param name="path">schema 文件路径。</param>
/// <returns>去掉扩展名和 `.schema` 后缀的实体基础名。</returns>
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);
}
/// <summary>
/// 将 schema 名称转换为 PascalCase 标识符。
/// </summary>
/// <param name="value">原始名称。</param>
/// <returns>PascalCase 标识符。</returns>
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);
}
/// <summary>
/// 为 AdditionalFiles 诊断创建文件位置。
/// </summary>
/// <param name="path">文件路径。</param>
/// <returns>指向文件开头的位置。</returns>
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<string>();
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<string>();
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(" /// <summary>");
builder.AppendLine(
$" /// {EscapeXmlDocumentation(property.Description ?? property.Title ?? $"Gets or sets the value mapped from schema property '{property.SchemaName}'.")}");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <remarks>");
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(" /// </remarks>");
}
private static string EscapeXmlDocumentation(string value)
{
return value
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;");
}
/// <summary>
/// 表示单个 schema 文件的解析结果。
/// </summary>
/// <param name="Schema">成功解析出的 schema 模型。</param>
/// <param name="Diagnostics">解析阶段产生的诊断。</param>
private sealed record SchemaParseResult(
SchemaFileSpec? Schema,
ImmutableArray<Diagnostic> Diagnostics)
{
/// <summary>
/// 从成功解析的 schema 模型创建结果。
/// </summary>
public static SchemaParseResult FromSchema(SchemaFileSpec schema)
{
return new SchemaParseResult(schema, ImmutableArray<Diagnostic>.Empty);
}
/// <summary>
/// 从单个诊断创建结果。
/// </summary>
public static SchemaParseResult FromDiagnostic(Diagnostic diagnostic)
{
return new SchemaParseResult(null, ImmutableArray.Create(diagnostic));
}
}
/// <summary>
/// 表示已解析的 schema 文件模型。
/// </summary>
private sealed record SchemaFileSpec(
string FileName,
string EntityName,
string ClassName,
string TableName,
string Namespace,
string KeyClrType,
string? Title,
string? Description,
IReadOnlyList<SchemaPropertySpec> Properties);
/// <summary>
/// 表示已解析的 schema 属性。
/// </summary>
private sealed record SchemaPropertySpec(
string SchemaName,
string PropertyName,
string SchemaType,
string ClrType,
bool IsRequired,
string? Initializer,
string? Title,
string? Description,
string? EnumDocumentation,
string? ReferenceTableName);
/// <summary>
/// 表示单个属性的解析结果。
/// </summary>
private sealed record ParsedPropertyResult(
SchemaPropertySpec? Property,
Diagnostic? Diagnostic)
{
/// <summary>
/// 从属性模型创建成功结果。
/// </summary>
public static ParsedPropertyResult FromProperty(SchemaPropertySpec property)
{
return new ParsedPropertyResult(property, null);
}
/// <summary>
/// 从诊断创建失败结果。
/// </summary>
public static ParsedPropertyResult FromDiagnostic(Diagnostic diagnostic)
{
return new ParsedPropertyResult(null, diagnostic);
}
}
}

View File

@ -0,0 +1,66 @@
using GFramework.SourceGenerators.Common.Constants;
namespace GFramework.SourceGenerators.Diagnostics;
/// <summary>
/// 提供配置 schema 代码生成相关诊断。
/// </summary>
public static class ConfigSchemaDiagnostics
{
private const string SourceGeneratorsConfigCategory = $"{PathContests.SourceGeneratorsPath}.Config";
/// <summary>
/// schema JSON 无法解析。
/// </summary>
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);
/// <summary>
/// schema 顶层必须是 object。
/// </summary>
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);
/// <summary>
/// schema 必须声明 id 字段作为主键。
/// </summary>
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);
/// <summary>
/// schema 包含暂不支持的字段类型。
/// </summary>
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);
/// <summary>
/// schema 的 id 字段类型不支持作为主键。
/// </summary>
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);
}

View File

@ -24,6 +24,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="System.Text.Json" Version="8.0.5" PrivateAssets="all"/>
</ItemGroup> </ItemGroup>
<!-- Generator 编译期引用 SourceGenerators.Abstractions / Common / Core.Abstractions但不打包 --> <!-- Generator 编译期引用 SourceGenerators.Abstractions / Common / Core.Abstractions但不打包 -->

View File

@ -3,14 +3,30 @@
<!-- This file is automatically generated by the NuGet package --> <!-- This file is automatically generated by the NuGet package -->
<!-- It ensures that the source generators are properly registered during build --> <!-- It ensures that the source generators are properly registered during build -->
<PropertyGroup>
<!--
消费项目默认从 schemas/ 目录收集配置 schema。
这样可以让配置生成器开箱即用,同时保留通过属性重定向目录的余地。
-->
<GFrameworkConfigSchemaDirectory Condition="'$(GFrameworkConfigSchemaDirectory)' == ''">schemas</GFrameworkConfigSchemaDirectory>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.dll"/> <Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.dll"/>
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Abstractions.dll"/> <Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Abstractions.dll"/>
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Common.dll"/> <Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Common.dll"/>
</ItemGroup> </ItemGroup>
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)/$(GFrameworkConfigSchemaDirectory)')">
<!--
将消费者项目中的 schema 文件自动暴露为 AdditionalFiles
避免每个游戏项目都手工维护同样的 MSBuild 配置。
-->
<AdditionalFiles Include="$(MSBuildProjectDirectory)/$(GFrameworkConfigSchemaDirectory)/**/*.schema.json"/>
</ItemGroup>
<!-- Ensure the analyzers are loaded --> <!-- Ensure the analyzers are loaded -->
<Target Name="EnsureGFrameworkAnalyzers" BeforeTargets="CoreCompile"> <Target Name="EnsureGFrameworkAnalyzers" BeforeTargets="CoreCompile">
<Message Text="Loading GFramework source generators" Importance="high"/> <Message Text="Loading GFramework source generators" Importance="high"/>
</Target> </Target>
</Project> </Project>

View File

@ -134,6 +134,8 @@
<AdditionalFiles Remove="AnalyzerReleases.Unshipped.md"/> <AdditionalFiles Remove="AnalyzerReleases.Unshipped.md"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="local-plan\docs\"/>
<Folder Include="local-plan\todos\"/>
<Folder Include="local-plan\评估\"/> <Folder Include="local-plan\评估\"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -197,6 +197,7 @@ export default defineConfig({
text: 'Game 游戏模块', text: 'Game 游戏模块',
items: [ items: [
{ text: '概览', link: '/zh-CN/game/' }, { text: '概览', link: '/zh-CN/game/' },
{ text: '内容配置系统', link: '/zh-CN/game/config-system' },
{ text: '数据管理', link: '/zh-CN/game/data' }, { text: '数据管理', link: '/zh-CN/game/data' },
{ text: '场景系统', link: '/zh-CN/game/scene' }, { text: '场景系统', link: '/zh-CN/game/scene' },
{ text: 'UI 系统', link: '/zh-CN/game/ui' }, { text: 'UI 系统', link: '/zh-CN/game/ui' },

View File

@ -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<int, MonsterConfig>(
"monster",
"monster",
"schemas/monster.schema.json",
static config => config.Id);
await loader.LoadAsync(registry);
var monsterTable = registry.GetTable<int, MonsterConfig>("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<int, MonsterConfig>(
"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 元数据约定,能够支撑单独桌面产品的长期维护

View File

@ -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 基础使用 ### AbstractModule 基础使用
@ -1395,4 +1414,4 @@ graph TD
- **.NET**: 6.0+ - **.NET**: 6.0+
- **Newtonsoft.Json**: 13.0.3+ - **Newtonsoft.Json**: 13.0.3+
- **GFramework.Core**: 与 Core 模块版本保持同步 - **GFramework.Core**: 与 Core 模块版本保持同步
--- ---

View File

@ -10,6 +10,7 @@ GFramework.SourceGenerators 是 GFramework 框架的源代码生成器包,通
- [核心特性](#核心特性) - [核心特性](#核心特性)
- [安装配置](#安装配置) - [安装配置](#安装配置)
- [Log 属性生成器](#log-属性生成器) - [Log 属性生成器](#log-属性生成器)
- [Config Schema 生成器](#config-schema-生成器)
- [ContextAware 属性生成器](#contextaware-属性生成器) - [ContextAware 属性生成器](#contextaware-属性生成器)
- [GenerateEnumExtensions 属性生成器](#generateenumextensions-属性生成器) - [GenerateEnumExtensions 属性生成器](#generateenumextensions-属性生成器)
- [Priority 属性生成器](#priority-属性生成器) - [Priority 属性生成器](#priority-属性生成器)
@ -38,6 +39,7 @@ GFramework.SourceGenerators 利用 Roslyn 源代码生成器技术,在编译
### 🎯 主要生成器 ### 🎯 主要生成器
- **[Log] 属性**:自动生成 ILogger 字段和日志方法 - **[Log] 属性**:自动生成 ILogger 字段和日志方法
- **Config Schema 生成器**:根据 `*.schema.json` 生成配置类型和表包装
- **[ContextAware] 属性**:自动实现 IContextAware 接口 - **[ContextAware] 属性**:自动实现 IContextAware 接口
- **[GenerateEnumExtensions] 属性**:自动生成枚举扩展方法 - **[GenerateEnumExtensions] 属性**:自动生成枚举扩展方法
- **[Priority] 属性**:自动实现 IPrioritized 接口,为类添加优先级标记 - **[Priority] 属性**:自动实现 IPrioritized 接口,为类添加优先级标记
@ -75,6 +77,45 @@ GFramework.SourceGenerators 利用 Roslyn 源代码生成器技术,在编译
</Project> </Project>
``` ```
### 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<TKey, TValue>` 对齐的表包装类型
这一生成器适合与 `GFramework.Game.Config.YamlConfigLoader` 配合使用,让 schema、运行时和工具链共享同一份结构约定。
当前支持的 schema 子集以内容配置系统文档中的说明为准,重点覆盖:
- `object` 根节点
- `required`
- `integer`
- `number`
- `boolean`
- `string`
- `array`
### 项目文件配置 ### 项目文件配置
```xml ```xml

View File

@ -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`

View File

@ -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."
}
}
}
}
}

View File

@ -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<string, {
* type: string,
* itemType?: string,
* title?: string,
* description?: string,
* defaultValue?: string,
* enumValues?: string[],
* itemEnumValues?: string[],
* refTable?: string
* }>}} 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<string, {
* type: string,
* itemType?: string,
* title?: string,
* description?: string,
* defaultValue?: string,
* enumValues?: string[],
* itemEnumValues?: string[],
* refTable?: string
* }>}} 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<string, {kind: string, value?: string, items?: Array<{raw: string, isComplex: boolean}>}>, keys: Set<string>}} 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<string, {
* type: string,
* itemType?: string,
* title?: string,
* description?: string,
* defaultValue?: string,
* enumValues?: string[],
* itemEnumValues?: string[],
* refTable?: string
* }>}} schemaInfo Parsed schema info.
* @param {{entries: Map<string, {kind: string, value?: string, items?: Array<{raw: string, isComplex: boolean}>}>, keys: Set<string>}} 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<string, string>, arrays?: Record<string, string[]>}} 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<string, string>} 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
};

File diff suppressed because it is too large Load Diff

View File

@ -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(""), []);
});