Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,17 @@ bash scripts/validate-csharp-naming.sh
- The main documentation site lives under `docs/`, with Chinese content under `docs/zh-CN/`.
- Keep code samples, package names, and command examples aligned with the current repository state.
- Prefer documenting behavior and design intent, not only API surface.
- When a feature is added, removed, renamed, or substantially refactored, contributors MUST update or create the
corresponding user-facing integration documentation in `docs/zh-CN/` in the same change.
- For integration-oriented features such as the AI-First config system, documentation MUST cover:
- project directory layout and file conventions
- required project or package wiring
- minimal working usage example
- migration or compatibility notes when behavior changes
- If an existing documentation page no longer reflects the current implementation, fixing the code without fixing the
documentation is considered incomplete work.
- Do not rely on “the code is self-explanatory” for framework features that consumers need to adopt; write the
adoption path down so future users do not need to rediscover it from source.

### Documentation Preview

Expand All @@ -218,3 +229,4 @@ Before considering work complete, confirm:
- Relevant tests were added or updated
- Sensitive or unsafe behavior was not introduced
- User-facing documentation is updated when needed
- Feature adoption docs under `docs/zh-CN/` were added or updated when functionality was added, removed, or refactored
19 changes: 19 additions & 0 deletions GFramework.Game.Abstractions/Config/IConfigLoader.cs
Original file line number Diff line number Diff line change
@@ -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);
}
84 changes: 84 additions & 0 deletions GFramework.Game.Abstractions/Config/IConfigRegistry.cs
Original file line number Diff line number Diff line change
@@ -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();
}
65 changes: 65 additions & 0 deletions GFramework.Game.Abstractions/Config/IConfigTable.cs
Original file line number Diff line number Diff line change
@@ -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();
}
150 changes: 150 additions & 0 deletions GFramework.Game.Tests/Config/ConfigRegistryTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading