mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-15 15:24:29 +08:00
Compare commits
No commits in common. "f0064e31aaa418c17db587bf384c4e72f2159636" and "c4f5d502b31c38b63941800d6bb868b6d903061e" have entirely different histories.
f0064e31aa
...
c4f5d502b3
@ -1,18 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
|
||||
[*.sln]
|
||||
end_of_line = crlf
|
||||
|
||||
[*.bat]
|
||||
end_of_line = crlf
|
||||
|
||||
[*.cmd]
|
||||
end_of_line = crlf
|
||||
|
||||
[*.ps1]
|
||||
end_of_line = crlf
|
||||
35
.gitattributes
vendored
35
.gitattributes
vendored
@ -1,35 +0,0 @@
|
||||
# Keep repository text normalized to LF unless a file format is known to require CRLF.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Solution and Windows-native scripts are more interoperable when they keep CRLF in the working tree.
|
||||
*.sln text eol=crlf
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# Source, config, scripts, and documentation stay LF across WSL and Windows editors.
|
||||
*.sh text eol=lf
|
||||
*.cs text eol=lf
|
||||
*.csproj text eol=lf
|
||||
*.props text eol=lf
|
||||
*.targets text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.md text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.js text eol=lf
|
||||
*.mts text eol=lf
|
||||
*.vue text eol=lf
|
||||
*.css text eol=lf
|
||||
|
||||
# Common binary assets should never be line-normalized.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.zip binary
|
||||
*.dll binary
|
||||
*.so binary
|
||||
*.pdb binary
|
||||
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@ -156,11 +156,6 @@ jobs:
|
||||
--logger "trx;LogFileName=ecs-arch-$RANDOM.trx" \
|
||||
--results-directory TestResults &
|
||||
|
||||
dotnet test GFramework.Godot.Tests \
|
||||
-c Release \
|
||||
--no-build \
|
||||
--logger "trx;LogFileName=godot-$RANDOM.trx" \
|
||||
--results-directory TestResults &
|
||||
# 等待所有后台测试完成
|
||||
wait
|
||||
|
||||
|
||||
@ -193,14 +193,6 @@ bash scripts/validate-csharp-naming.sh
|
||||
- If a framework abstraction changes meaning or intended usage, update the explanatory comments in code as part of the
|
||||
same change.
|
||||
|
||||
### Task Tracking
|
||||
|
||||
- When working from a tracked implementation plan, contributors MUST update the corresponding tracking document under
|
||||
`local-plan/todos/` in the same change.
|
||||
- Tracking updates MUST reflect completed work, newly discovered issues, validation results, and the next recommended
|
||||
recovery point.
|
||||
- Completing code changes without updating the active tracking document is considered incomplete work.
|
||||
|
||||
### Repository Documentation
|
||||
|
||||
- Update the relevant `README.md` or `docs/` page when behavior, setup steps, architecture guidance, or user-facing
|
||||
|
||||
@ -74,9 +74,7 @@ public interface IArchitecture : IAsyncInitializable, IAsyncDestroyable, IInitia
|
||||
|
||||
/// <summary>
|
||||
/// 注册中介行为管道
|
||||
/// 用于配置Mediator框架的行为拦截和处理逻辑。
|
||||
/// 既支持实现 <c>IPipelineBehavior<,></c> 的开放泛型行为类型,
|
||||
/// 也支持绑定到单一请求/响应对的封闭行为类型。
|
||||
/// 用于配置Mediator框架的行为拦截和处理逻辑
|
||||
/// </summary>
|
||||
/// <typeparam name="TBehavior">行为类型,必须是引用类型</typeparam>
|
||||
void RegisterMediatorBehavior<TBehavior>()
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
namespace GFramework.Core.Abstractions.Coroutine;
|
||||
|
||||
/// <summary>
|
||||
/// 表示协程的最终完成结果。
|
||||
/// </summary>
|
||||
public enum CoroutineCompletionStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 调度器无法确认该句柄的最终结果。
|
||||
/// 这通常意味着句柄无效,或者句柄对应的历史结果已经不可用。
|
||||
/// </summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>
|
||||
/// 协程自然执行结束。
|
||||
/// </summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// 协程被外部终止、清空或取消令牌中断。
|
||||
/// </summary>
|
||||
Cancelled,
|
||||
|
||||
/// <summary>
|
||||
/// 协程在推进过程中抛出了异常。
|
||||
/// </summary>
|
||||
Faulted
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
namespace GFramework.Core.Abstractions.Coroutine;
|
||||
|
||||
/// <summary>
|
||||
/// 表示协程调度器当前所处的执行阶段。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 某些等待指令具有阶段语义,例如 <c>WaitForFixedUpdate</c> 和 <c>WaitForEndOfFrame</c>。
|
||||
/// 宿主应为这些语义提供匹配的调度器阶段,否则这类等待不会自然完成。
|
||||
/// </remarks>
|
||||
public enum CoroutineExecutionStage
|
||||
{
|
||||
/// <summary>
|
||||
/// 默认更新阶段。
|
||||
/// 普通时间等待、下一帧等待以及大多数条件等待都会在该阶段推进。
|
||||
/// </summary>
|
||||
Update,
|
||||
|
||||
/// <summary>
|
||||
/// 固定更新阶段。
|
||||
/// 仅与固定步相关的等待指令会在该阶段完成。
|
||||
/// </summary>
|
||||
FixedUpdate,
|
||||
|
||||
/// <summary>
|
||||
/// 帧结束阶段。
|
||||
/// 仅与帧尾或延迟执行相关的等待指令会在该阶段完成。
|
||||
/// </summary>
|
||||
EndOfFrame
|
||||
}
|
||||
@ -1,717 +0,0 @@
|
||||
using GFramework.Core.Abstractions.Architectures;
|
||||
using GFramework.Core.Abstractions.Enums;
|
||||
using GFramework.Core.Abstractions.Model;
|
||||
using GFramework.Core.Abstractions.Systems;
|
||||
using GFramework.Core.Abstractions.Utility;
|
||||
using GFramework.Core.Architectures;
|
||||
using GFramework.Core.Logging;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GFramework.Core.Tests.Architectures;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Architecture 通过 <c>ArchitectureComponentRegistry</c> 暴露出的组件注册行为。
|
||||
/// 这些测试覆盖实例注册、工厂注册、上下文注入、生命周期初始化和 Ready 后注册约束,
|
||||
/// 用于保护组件注册器在继续重构后的既有契约。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ArchitectureComponentRegistryBehaviorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化日志工厂和全局上下文状态。
|
||||
/// </summary>
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider();
|
||||
GameContext.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理测试过程中绑定到全局表的架构上下文。
|
||||
/// </summary>
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
GameContext.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证系统实例注册会注入上下文并参与生命周期初始化。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task RegisterSystem_Instance_Should_Set_Context_And_Initialize_System()
|
||||
{
|
||||
var system = new TrackingSystem();
|
||||
var architecture = new RegistryTestArchitecture(target => target.RegisterSystem(system));
|
||||
|
||||
await architecture.InitializeAsync();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(system.GetContext(), Is.SameAs(architecture.Context));
|
||||
Assert.That(system.InitializeCallCount, Is.EqualTo(1));
|
||||
Assert.That(architecture.Context.GetSystem<TrackingSystem>(), Is.SameAs(system));
|
||||
});
|
||||
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证模型实例注册会注入上下文并参与生命周期初始化。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task RegisterModel_Instance_Should_Set_Context_And_Initialize_Model()
|
||||
{
|
||||
var model = new TrackingModel();
|
||||
var architecture = new RegistryTestArchitecture(target => target.RegisterModel(model));
|
||||
|
||||
await architecture.InitializeAsync();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(model.GetContext(), Is.SameAs(architecture.Context));
|
||||
Assert.That(model.InitializeCallCount, Is.EqualTo(1));
|
||||
Assert.That(architecture.Context.GetModel<TrackingModel>(), Is.SameAs(model));
|
||||
});
|
||||
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证上下文工具注册会注入上下文并参与生命周期初始化。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task RegisterUtility_Instance_Should_Set_Context_For_ContextUtility()
|
||||
{
|
||||
var utility = new TrackingContextUtility();
|
||||
var architecture = new RegistryTestArchitecture(target => target.RegisterUtility(utility));
|
||||
|
||||
await architecture.InitializeAsync();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(utility.GetContext(), Is.SameAs(architecture.Context));
|
||||
Assert.That(utility.InitializeCallCount, Is.EqualTo(1));
|
||||
Assert.That(architecture.Context.GetUtility<TrackingContextUtility>(), Is.SameAs(utility));
|
||||
});
|
||||
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证普通工具的工厂注册会在首次解析时创建单例并执行创建回调。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task RegisterUtility_Type_Should_Create_Singleton_And_Invoke_Callback()
|
||||
{
|
||||
FactoryCreatedUtility? callbackInstance = null;
|
||||
var architecture = new RegistryTestArchitecture(target =>
|
||||
target.RegisterUtility<FactoryCreatedUtility>(created => callbackInstance = created));
|
||||
|
||||
await architecture.InitializeAsync();
|
||||
|
||||
var first = architecture.Context.GetUtility<FactoryCreatedUtility>();
|
||||
var second = architecture.Context.GetUtility<FactoryCreatedUtility>();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(callbackInstance, Is.SameAs(first));
|
||||
Assert.That(second, Is.SameAs(first));
|
||||
});
|
||||
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证系统类型注册会在初始化期间物化实例、注入构造函数依赖并执行创建回调。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task RegisterSystem_Type_Should_Create_Instance_During_Initialization()
|
||||
{
|
||||
var dependency = new ConstructorDependency("system-dependency");
|
||||
FactoryCreatedSystem? callbackInstance = null;
|
||||
var architecture = new RegistryTestArchitecture(
|
||||
target => target.RegisterSystem<FactoryCreatedSystem>(created => callbackInstance = created),
|
||||
services => services.AddSingleton(dependency));
|
||||
|
||||
await architecture.InitializeAsync();
|
||||
|
||||
var resolved = architecture.Context.GetSystem<FactoryCreatedSystem>();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(callbackInstance, Is.Not.Null);
|
||||
Assert.That(resolved, Is.SameAs(callbackInstance));
|
||||
Assert.That(resolved.Dependency, Is.SameAs(dependency));
|
||||
Assert.That(resolved.GetContext(), Is.SameAs(architecture.Context));
|
||||
Assert.That(resolved.InitializeCallCount, Is.EqualTo(1));
|
||||
});
|
||||
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证模型类型注册会在初始化期间物化实例、注入构造函数依赖并执行创建回调。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task RegisterModel_Type_Should_Create_Instance_During_Initialization()
|
||||
{
|
||||
var dependency = new ConstructorDependency("model-dependency");
|
||||
FactoryCreatedModel? callbackInstance = null;
|
||||
var architecture = new RegistryTestArchitecture(
|
||||
target => target.RegisterModel<FactoryCreatedModel>(created => callbackInstance = created),
|
||||
services => services.AddSingleton(dependency));
|
||||
|
||||
await architecture.InitializeAsync();
|
||||
|
||||
var resolved = architecture.Context.GetModel<FactoryCreatedModel>();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(callbackInstance, Is.Not.Null);
|
||||
Assert.That(resolved, Is.SameAs(callbackInstance));
|
||||
Assert.That(resolved.Dependency, Is.SameAs(dependency));
|
||||
Assert.That(resolved.GetContext(), Is.SameAs(architecture.Context));
|
||||
Assert.That(resolved.InitializeCallCount, Is.EqualTo(1));
|
||||
});
|
||||
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证预冻结阶段通过实现类型注册的单例依赖会在同一轮组件激活中复用同一个实例。
|
||||
/// 该回归测试用于保护 <see cref="ArchitectureComponentActivator" /> 的共享单例缓存,避免系统和模型分别创建重复单例。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task RegisterSystem_And_Model_Type_Should_Reuse_ImplementationType_Singleton_During_Activation()
|
||||
{
|
||||
var counter = new DependencyConstructionCounter();
|
||||
var architecture = new RegistryTestArchitecture(
|
||||
target =>
|
||||
{
|
||||
target.RegisterSystem<ImplementationTypeDependencySystem>();
|
||||
target.RegisterModel<ImplementationTypeDependencyModel>();
|
||||
},
|
||||
services =>
|
||||
{
|
||||
services.AddSingleton(counter);
|
||||
services.AddSingleton<ImplementationTypeSharedDependency>();
|
||||
});
|
||||
|
||||
await architecture.InitializeAsync();
|
||||
|
||||
var system = architecture.Context.GetSystem<ImplementationTypeDependencySystem>();
|
||||
var model = architecture.Context.GetModel<ImplementationTypeDependencyModel>();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(counter.CreationCount, Is.EqualTo(1));
|
||||
Assert.That(system.Dependency, Is.SameAs(model.Dependency));
|
||||
});
|
||||
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证预冻结阶段通过工厂注册的单例依赖会在同一轮组件激活中复用同一个实例。
|
||||
/// 该回归测试覆盖 <c>ImplementationFactory</c> 描述符路径,避免用户工厂在初始化时被重复调用。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task RegisterSystem_And_Model_Type_Should_Reuse_Factory_Singleton_During_Activation()
|
||||
{
|
||||
var creationCount = 0;
|
||||
var architecture = new RegistryTestArchitecture(
|
||||
target =>
|
||||
{
|
||||
target.RegisterSystem<FactoryDependencySystem>();
|
||||
target.RegisterModel<FactoryDependencyModel>();
|
||||
},
|
||||
services =>
|
||||
{
|
||||
services.AddSingleton(_ =>
|
||||
{
|
||||
creationCount++;
|
||||
return new FactorySharedDependency();
|
||||
});
|
||||
});
|
||||
|
||||
await architecture.InitializeAsync();
|
||||
|
||||
var system = architecture.Context.GetSystem<FactoryDependencySystem>();
|
||||
var model = architecture.Context.GetModel<FactoryDependencyModel>();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(creationCount, Is.EqualTo(1));
|
||||
Assert.That(system.Dependency, Is.SameAs(model.Dependency));
|
||||
});
|
||||
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Ready 阶段后不允许继续注册 Utility,保持与系统和模型一致的约束。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task RegisterUtility_After_Ready_Should_Throw_InvalidOperationException()
|
||||
{
|
||||
var architecture = new RegistryTestArchitecture(_ => { });
|
||||
await architecture.InitializeAsync();
|
||||
|
||||
Assert.That(
|
||||
() => architecture.RegisterUtility(new TrackingContextUtility()),
|
||||
Throws.InvalidOperationException.With.Message.EqualTo(
|
||||
"Cannot register utility after Architecture is Ready"));
|
||||
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于测试组件注册行为的最小架构实现。
|
||||
/// </summary>
|
||||
private sealed class RegistryTestArchitecture : Architecture
|
||||
{
|
||||
private readonly Action<IServiceCollection>? _configurator;
|
||||
private readonly Action<RegistryTestArchitecture> _registrationAction;
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个可选地附带服务配置逻辑的测试架构。
|
||||
/// </summary>
|
||||
/// <param name="registrationAction">初始化阶段执行的组件注册逻辑。</param>
|
||||
/// <param name="configurator">初始化前执行的服务配置逻辑。</param>
|
||||
public RegistryTestArchitecture(
|
||||
Action<RegistryTestArchitecture> registrationAction,
|
||||
Action<IServiceCollection>? configurator = null)
|
||||
{
|
||||
_registrationAction = registrationAction;
|
||||
_configurator = configurator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回测试注入的服务配置逻辑。
|
||||
/// </summary>
|
||||
public override Action<IServiceCollection>? Configurator => _configurator;
|
||||
|
||||
/// <summary>
|
||||
/// 在初始化阶段执行测试注入的注册逻辑。
|
||||
/// </summary>
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
_registrationAction(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录初始化与上下文注入情况的测试系统。
|
||||
/// </summary>
|
||||
private sealed class TrackingSystem : ISystem
|
||||
{
|
||||
private IArchitectureContext _context = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 获取系统初始化调用次数。
|
||||
/// </summary>
|
||||
public int InitializeCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录初始化调用。
|
||||
/// </summary>
|
||||
public void Initialize()
|
||||
{
|
||||
InitializeCallCount++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 该测试系统不关心阶段变更。
|
||||
/// </summary>
|
||||
/// <param name="phase">当前架构阶段。</param>
|
||||
public void OnArchitecturePhase(ArchitecturePhase phase)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 存储注入的架构上下文。
|
||||
/// </summary>
|
||||
/// <param name="context">架构上下文。</param>
|
||||
public void SetContext(IArchitectureContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回当前持有的架构上下文。
|
||||
/// </summary>
|
||||
public IArchitectureContext GetContext()
|
||||
{
|
||||
return _context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 该测试系统没有额外销毁逻辑。
|
||||
/// </summary>
|
||||
public void Destroy()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录初始化与上下文注入情况的测试模型。
|
||||
/// </summary>
|
||||
private sealed class TrackingModel : IModel
|
||||
{
|
||||
private IArchitectureContext _context = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 获取模型初始化调用次数。
|
||||
/// </summary>
|
||||
public int InitializeCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录初始化调用。
|
||||
/// </summary>
|
||||
public void Initialize()
|
||||
{
|
||||
InitializeCallCount++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 该测试模型不关心阶段变更。
|
||||
/// </summary>
|
||||
/// <param name="phase">当前架构阶段。</param>
|
||||
public void OnArchitecturePhase(ArchitecturePhase phase)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 存储注入的架构上下文。
|
||||
/// </summary>
|
||||
/// <param name="context">架构上下文。</param>
|
||||
public void SetContext(IArchitectureContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回当前持有的架构上下文。
|
||||
/// </summary>
|
||||
public IArchitectureContext GetContext()
|
||||
{
|
||||
return _context;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录初始化与上下文注入情况的测试上下文工具。
|
||||
/// </summary>
|
||||
private sealed class TrackingContextUtility : IContextUtility
|
||||
{
|
||||
private IArchitectureContext _context = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 获取工具初始化调用次数。
|
||||
/// </summary>
|
||||
public int InitializeCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录初始化调用。
|
||||
/// </summary>
|
||||
public void Initialize()
|
||||
{
|
||||
InitializeCallCount++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 存储注入的架构上下文。
|
||||
/// </summary>
|
||||
/// <param name="context">架构上下文。</param>
|
||||
public void SetContext(IArchitectureContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回当前持有的架构上下文。
|
||||
/// </summary>
|
||||
public IArchitectureContext GetContext()
|
||||
{
|
||||
return _context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 该测试工具没有额外销毁逻辑。
|
||||
/// </summary>
|
||||
public void Destroy()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于验证普通工厂注册路径的简单工具。
|
||||
/// </summary>
|
||||
private sealed class FactoryCreatedUtility : IUtility
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于验证构造函数依赖注入的简单依赖对象。
|
||||
/// </summary>
|
||||
private sealed class ConstructorDependency(string name)
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取依赖对象名称。
|
||||
/// </summary>
|
||||
public string Name { get; } = name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于验证系统类型注册路径的工厂创建系统。
|
||||
/// </summary>
|
||||
private sealed class FactoryCreatedSystem(ConstructorDependency dependency) : ISystem
|
||||
{
|
||||
private IArchitectureContext _context = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 获取构造函数注入的依赖对象。
|
||||
/// </summary>
|
||||
public ConstructorDependency Dependency { get; } = dependency;
|
||||
|
||||
/// <summary>
|
||||
/// 获取初始化调用次数。
|
||||
/// </summary>
|
||||
public int InitializeCallCount { get; private set; }
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
InitializeCallCount++;
|
||||
}
|
||||
|
||||
public void Destroy()
|
||||
{
|
||||
}
|
||||
|
||||
public void OnArchitecturePhase(ArchitecturePhase phase)
|
||||
{
|
||||
}
|
||||
|
||||
public void SetContext(IArchitectureContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public IArchitectureContext GetContext()
|
||||
{
|
||||
return _context;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于验证模型类型注册路径的工厂创建模型。
|
||||
/// </summary>
|
||||
private sealed class FactoryCreatedModel(ConstructorDependency dependency) : IModel
|
||||
{
|
||||
private IArchitectureContext _context = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 获取构造函数注入的依赖对象。
|
||||
/// </summary>
|
||||
public ConstructorDependency Dependency { get; } = dependency;
|
||||
|
||||
/// <summary>
|
||||
/// 获取初始化调用次数。
|
||||
/// </summary>
|
||||
public int InitializeCallCount { get; private set; }
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
InitializeCallCount++;
|
||||
}
|
||||
|
||||
public void OnArchitecturePhase(ArchitecturePhase phase)
|
||||
{
|
||||
}
|
||||
|
||||
public void SetContext(IArchitectureContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public IArchitectureContext GetContext()
|
||||
{
|
||||
return _context;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统计实现类型单例在预冻结激活阶段的构造次数。
|
||||
/// </summary>
|
||||
private sealed class DependencyConstructionCounter
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取共享依赖被构造的次数。
|
||||
/// </summary>
|
||||
public int CreationCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录一次新的依赖构造。
|
||||
/// </summary>
|
||||
public void RecordCreation()
|
||||
{
|
||||
CreationCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于覆盖 ImplementationType 单例描述符路径的共享依赖。
|
||||
/// </summary>
|
||||
private sealed class ImplementationTypeSharedDependency
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建共享依赖并记录构造次数。
|
||||
/// </summary>
|
||||
/// <param name="counter">用于统计构造次数的计数器。</param>
|
||||
public ImplementationTypeSharedDependency(DependencyConstructionCounter counter)
|
||||
{
|
||||
counter.RecordCreation();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于覆盖 ImplementationType 单例复用路径的测试系统。
|
||||
/// </summary>
|
||||
private sealed class ImplementationTypeDependencySystem(ImplementationTypeSharedDependency dependency) : ISystem
|
||||
{
|
||||
private IArchitectureContext _context = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 获取构造函数注入的共享依赖。
|
||||
/// </summary>
|
||||
public ImplementationTypeSharedDependency Dependency { get; } = dependency;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
public void Destroy()
|
||||
{
|
||||
}
|
||||
|
||||
public void OnArchitecturePhase(ArchitecturePhase phase)
|
||||
{
|
||||
}
|
||||
|
||||
public void SetContext(IArchitectureContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public IArchitectureContext GetContext()
|
||||
{
|
||||
return _context;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于覆盖 ImplementationType 单例复用路径的测试模型。
|
||||
/// </summary>
|
||||
private sealed class ImplementationTypeDependencyModel(ImplementationTypeSharedDependency dependency) : IModel
|
||||
{
|
||||
private IArchitectureContext _context = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 获取构造函数注入的共享依赖。
|
||||
/// </summary>
|
||||
public ImplementationTypeSharedDependency Dependency { get; } = dependency;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
public void OnArchitecturePhase(ArchitecturePhase phase)
|
||||
{
|
||||
}
|
||||
|
||||
public void SetContext(IArchitectureContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public IArchitectureContext GetContext()
|
||||
{
|
||||
return _context;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于覆盖 ImplementationFactory 单例描述符路径的共享依赖。
|
||||
/// </summary>
|
||||
private sealed class FactorySharedDependency
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于覆盖 ImplementationFactory 单例复用路径的测试系统。
|
||||
/// </summary>
|
||||
private sealed class FactoryDependencySystem(FactorySharedDependency dependency) : ISystem
|
||||
{
|
||||
private IArchitectureContext _context = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 获取构造函数注入的共享依赖。
|
||||
/// </summary>
|
||||
public FactorySharedDependency Dependency { get; } = dependency;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
public void Destroy()
|
||||
{
|
||||
}
|
||||
|
||||
public void OnArchitecturePhase(ArchitecturePhase phase)
|
||||
{
|
||||
}
|
||||
|
||||
public void SetContext(IArchitectureContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public IArchitectureContext GetContext()
|
||||
{
|
||||
return _context;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于覆盖 ImplementationFactory 单例复用路径的测试模型。
|
||||
/// </summary>
|
||||
private sealed class FactoryDependencyModel(FactorySharedDependency dependency) : IModel
|
||||
{
|
||||
private IArchitectureContext _context = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 获取构造函数注入的共享依赖。
|
||||
/// </summary>
|
||||
public FactorySharedDependency Dependency { get; } = dependency;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
public void OnArchitecturePhase(ArchitecturePhase phase)
|
||||
{
|
||||
}
|
||||
|
||||
public void SetContext(IArchitectureContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public IArchitectureContext GetContext()
|
||||
{
|
||||
return _context;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,188 +0,0 @@
|
||||
using GFramework.Core.Abstractions.Enums;
|
||||
using GFramework.Core.Abstractions.Events;
|
||||
using GFramework.Core.Architectures;
|
||||
using GFramework.Core.Environment;
|
||||
using GFramework.Core.Logging;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GFramework.Core.Tests.Architectures;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 <see cref="Architecture" /> 初始化编排流程的单元测试。
|
||||
/// 这些测试覆盖环境初始化、服务准备、上下文绑定和自定义服务配置的时序,
|
||||
/// 以确保核心协调器在拆分后仍保持既有行为。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ArchitectureInitializationPipelineTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 为每个测试准备独立的日志工厂和游戏上下文状态。
|
||||
/// </summary>
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider();
|
||||
GameContext.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理测试期间注册的全局游戏上下文,避免跨测试污染。
|
||||
/// </summary>
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
GameContext.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证异步初始化会在执行用户初始化逻辑之前准备环境、服务和上下文。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task InitializeAsync_Should_Prepare_Runtime_Before_OnInitialize()
|
||||
{
|
||||
var environment = new TrackingEnvironment();
|
||||
var marker = new BootstrapMarker();
|
||||
var architecture = new InitializationPipelineTestArchitecture(environment, marker);
|
||||
|
||||
await architecture.InitializeAsync();
|
||||
|
||||
AssertRuntimePrepared(architecture, environment, marker);
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证同步初始化路径复用同一套基础设施准备流程。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Initialize_Should_Prepare_Runtime_Before_OnInitialize()
|
||||
{
|
||||
var environment = new TrackingEnvironment();
|
||||
var marker = new BootstrapMarker();
|
||||
var architecture = new InitializationPipelineTestArchitecture(environment, marker);
|
||||
|
||||
architecture.Initialize();
|
||||
|
||||
AssertRuntimePrepared(architecture, environment, marker);
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 断言初始化阶段所需的运行时准备工作都已经完成。
|
||||
/// </summary>
|
||||
/// <param name="architecture">待验证的测试架构实例。</param>
|
||||
/// <param name="environment">测试使用的环境对象。</param>
|
||||
/// <param name="marker">通过服务配置委托注册的标记服务。</param>
|
||||
private static void AssertRuntimePrepared(
|
||||
InitializationPipelineTestArchitecture architecture,
|
||||
TrackingEnvironment environment,
|
||||
BootstrapMarker marker)
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(architecture.ObservedEnvironmentInitialized, Is.True);
|
||||
Assert.That(architecture.ObservedConfiguredServiceAvailable, Is.True);
|
||||
Assert.That(architecture.ObservedEventBusAvailable, Is.True);
|
||||
Assert.That(architecture.ObservedContextWasBound, Is.True);
|
||||
Assert.That(architecture.ObservedEnvironmentRegistered, Is.True);
|
||||
Assert.That(architecture.Context.GetEnvironment(), Is.SameAs(environment));
|
||||
Assert.That(architecture.Context.GetService<BootstrapMarker>(), Is.SameAs(marker));
|
||||
Assert.That(architecture.CurrentPhase, Is.EqualTo(ArchitecturePhase.Ready));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 跟踪初始化期间关键可观察状态的测试架构。
|
||||
/// </summary>
|
||||
private sealed class InitializationPipelineTestArchitecture : Architecture
|
||||
{
|
||||
private readonly TrackingEnvironment _environment;
|
||||
private readonly BootstrapMarker _marker;
|
||||
|
||||
/// <summary>
|
||||
/// 使用可观察环境和标记服务创建测试架构。
|
||||
/// </summary>
|
||||
/// <param name="environment">用于验证初始化时序的环境对象。</param>
|
||||
/// <param name="marker">用于验证服务钩子执行结果的标记服务。</param>
|
||||
public InitializationPipelineTestArchitecture(
|
||||
TrackingEnvironment environment,
|
||||
BootstrapMarker marker)
|
||||
: base(environment: environment)
|
||||
{
|
||||
_environment = environment;
|
||||
_marker = marker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录用户初始化逻辑执行时环境是否已经准备完成。
|
||||
/// </summary>
|
||||
public bool ObservedEnvironmentInitialized { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录自定义服务是否已在用户初始化前注册到容器。
|
||||
/// </summary>
|
||||
public bool ObservedConfiguredServiceAvailable { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录内置事件总线是否已在用户初始化前可用。
|
||||
/// </summary>
|
||||
public bool ObservedEventBusAvailable { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录当前上下文是否已在用户初始化前绑定到全局游戏上下文表。
|
||||
/// </summary>
|
||||
public bool ObservedContextWasBound { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录环境对象是否已在用户初始化前注册到架构上下文。
|
||||
/// </summary>
|
||||
public bool ObservedEnvironmentRegistered { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 为容器注册测试标记服务,用于验证初始化前的服务钩子是否执行。
|
||||
/// </summary>
|
||||
public override Action<IServiceCollection>? Configurator => services => services.AddSingleton(_marker);
|
||||
|
||||
/// <summary>
|
||||
/// 在用户初始化逻辑中采集运行时准备状态。
|
||||
/// </summary>
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
ObservedEnvironmentInitialized = _environment.InitializeCallCount == 1;
|
||||
ObservedConfiguredServiceAvailable = ReferenceEquals(Context.GetService<BootstrapMarker>(), _marker);
|
||||
ObservedEventBusAvailable = Context.GetService<IEventBus>() is not null;
|
||||
ObservedContextWasBound = ReferenceEquals(GameContext.GetByType(GetType()), Context);
|
||||
ObservedEnvironmentRegistered = ReferenceEquals(Context.GetEnvironment(), _environment);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于验证环境初始化是否发生的测试环境。
|
||||
/// </summary>
|
||||
private sealed class TrackingEnvironment : EnvironmentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取测试环境名称。
|
||||
/// </summary>
|
||||
public override string Name { get; } = "Tracking";
|
||||
|
||||
/// <summary>
|
||||
/// 获取环境初始化调用次数。
|
||||
/// </summary>
|
||||
public int InitializeCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录环境初始化次数。
|
||||
/// </summary>
|
||||
public override void Initialize()
|
||||
{
|
||||
InitializeCallCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过服务配置委托注册到容器的测试标记对象。
|
||||
/// </summary>
|
||||
private sealed class BootstrapMarker
|
||||
{
|
||||
}
|
||||
}
|
||||
@ -1,463 +0,0 @@
|
||||
using System.Reflection;
|
||||
using GFramework.Core.Abstractions.Architectures;
|
||||
using GFramework.Core.Abstractions.Enums;
|
||||
using GFramework.Core.Abstractions.Lifecycle;
|
||||
using GFramework.Core.Abstractions.Model;
|
||||
using GFramework.Core.Abstractions.Systems;
|
||||
using GFramework.Core.Abstractions.Utility;
|
||||
using GFramework.Core.Architectures;
|
||||
using GFramework.Core.Logging;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GFramework.Core.Tests.Architectures;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Architecture 生命周期行为的集成测试。
|
||||
/// 这些测试覆盖阶段流转、失败状态传播和逆序销毁规则,
|
||||
/// 用于保护拆分后的生命周期管理、阶段协调与销毁协调行为。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ArchitectureLifecycleBehaviorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 为每个测试准备独立的日志工厂和全局上下文状态。
|
||||
/// </summary>
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider();
|
||||
GameContext.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理测试注册到全局上下文表的架构上下文。
|
||||
/// </summary>
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
GameContext.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证初始化流程会按既定顺序推进所有生命周期阶段。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task InitializeAsync_Should_Enter_Expected_Phases_In_Order()
|
||||
{
|
||||
var architecture = new PhaseTrackingArchitecture();
|
||||
|
||||
await architecture.InitializeAsync();
|
||||
|
||||
Assert.That(architecture.PhaseHistory, Is.EqualTo(new[]
|
||||
{
|
||||
ArchitecturePhase.BeforeUtilityInit,
|
||||
ArchitecturePhase.AfterUtilityInit,
|
||||
ArchitecturePhase.BeforeModelInit,
|
||||
ArchitecturePhase.AfterModelInit,
|
||||
ArchitecturePhase.BeforeSystemInit,
|
||||
ArchitecturePhase.AfterSystemInit,
|
||||
ArchitecturePhase.Ready
|
||||
}));
|
||||
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证用户初始化失败时,等待 Ready 的任务会失败并进入 FailedInitialization 阶段。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task InitializeAsync_When_OnInitialize_Throws_Should_Mark_FailedInitialization()
|
||||
{
|
||||
var architecture = new PhaseTrackingArchitecture(() => throw new InvalidOperationException("boom"));
|
||||
|
||||
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await architecture.InitializeAsync());
|
||||
Assert.That(exception, Is.Not.Null);
|
||||
Assert.That(architecture.CurrentPhase, Is.EqualTo(ArchitecturePhase.FailedInitialization));
|
||||
Assert.ThrowsAsync<InvalidOperationException>(async () => await architecture.WaitUntilReadyAsync());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证销毁流程会按注册逆序释放组件,并推进 Destroying/Destroyed 阶段。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task DestroyAsync_Should_Destroy_Components_In_Reverse_Registration_Order()
|
||||
{
|
||||
var destroyOrder = new List<string>();
|
||||
var architecture = new DestroyOrderArchitecture(destroyOrder);
|
||||
|
||||
await architecture.InitializeAsync();
|
||||
await architecture.DestroyAsync();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(destroyOrder, Is.EqualTo(new[] { "system", "model", "utility" }));
|
||||
Assert.That(architecture.CurrentPhase, Is.EqualTo(ArchitecturePhase.Destroyed));
|
||||
Assert.That(architecture.PhaseHistory[^2..], Is.EqualTo(new[]
|
||||
{
|
||||
ArchitecturePhase.Destroying,
|
||||
ArchitecturePhase.Destroyed
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证初始化失败后仍然允许执行销毁流程。
|
||||
/// 该回归测试用于保护 FailedInitialization → Destroying 的合法迁移,避免失败路径上的组件与容器泄漏。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task DestroyAsync_After_FailedInitialization_Should_Cleanup_And_Enter_Destroyed()
|
||||
{
|
||||
var destroyOrder = new List<string>();
|
||||
var architecture = new FailingInitializationArchitecture(destroyOrder);
|
||||
|
||||
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await architecture.InitializeAsync());
|
||||
Assert.That(exception, Is.Not.Null);
|
||||
Assert.That(architecture.CurrentPhase, Is.EqualTo(ArchitecturePhase.FailedInitialization));
|
||||
|
||||
await architecture.DestroyAsync();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(destroyOrder, Is.EqualTo(new[] { "model", "utility" }));
|
||||
Assert.That(architecture.CurrentPhase, Is.EqualTo(ArchitecturePhase.Destroyed));
|
||||
Assert.That(architecture.PhaseHistory[^3..], Is.EqualTo(new[]
|
||||
{
|
||||
ArchitecturePhase.FailedInitialization,
|
||||
ArchitecturePhase.Destroying,
|
||||
ArchitecturePhase.Destroyed
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Destroyed 阶段会在容器清空前广播给容器内的阶段监听器。
|
||||
/// 该回归测试保护销毁尾声的阶段通知,确保依赖最终阶段信号的服务仍能收到 Destroyed。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task DestroyAsync_Should_Notify_Container_Phase_Listeners_About_Destroyed_Before_Clear()
|
||||
{
|
||||
var listener = new TrackingPhaseListener();
|
||||
var architecture = new ListenerTrackingArchitecture(listener);
|
||||
|
||||
await architecture.InitializeAsync();
|
||||
await architecture.DestroyAsync();
|
||||
|
||||
Assert.That(listener.ObservedPhases[^2..], Is.EqualTo(new[]
|
||||
{
|
||||
ArchitecturePhase.Destroying,
|
||||
ArchitecturePhase.Destroyed
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证启用 AllowLateRegistration 时,生命周期层会立即初始化后注册的组件,而不是继续沿用初始化期的拒绝策略。
|
||||
/// 由于公共架构 API 在 Ready 之后会先触发容器限制,此回归测试直接覆盖生命周期协作者的对齐逻辑。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task
|
||||
RegisterLifecycleComponent_After_Initialization_Should_Initialize_Immediately_When_LateRegistration_Is_Enabled()
|
||||
{
|
||||
var architecture = new AllowLateRegistrationArchitecture();
|
||||
await architecture.InitializeAsync();
|
||||
|
||||
var lateComponent = new LateRegisteredInitializableComponent();
|
||||
|
||||
architecture.RegisterLateComponentForTesting(lateComponent);
|
||||
|
||||
Assert.That(lateComponent.InitializeCallCount, Is.EqualTo(1));
|
||||
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录阶段流转的可配置测试架构。
|
||||
/// </summary>
|
||||
private sealed class PhaseTrackingArchitecture : Architecture
|
||||
{
|
||||
private readonly Action? _onInitializeAction;
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个可选地在用户初始化阶段执行自定义逻辑的测试架构。
|
||||
/// </summary>
|
||||
/// <param name="onInitializeAction">用户初始化时执行的测试回调。</param>
|
||||
public PhaseTrackingArchitecture(Action? onInitializeAction = null)
|
||||
{
|
||||
_onInitializeAction = onInitializeAction;
|
||||
PhaseChanged += phase => PhaseHistory.Add(phase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取架构经历过的阶段列表。
|
||||
/// </summary>
|
||||
public List<ArchitecturePhase> PhaseHistory { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 执行测试注入的初始化逻辑。
|
||||
/// </summary>
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
_onInitializeAction?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在初始化时注册可销毁组件的测试架构。
|
||||
/// </summary>
|
||||
private sealed class DestroyOrderArchitecture : Architecture
|
||||
{
|
||||
private readonly List<string> _destroyOrder;
|
||||
|
||||
/// <summary>
|
||||
/// 创建用于验证销毁顺序的测试架构。
|
||||
/// </summary>
|
||||
/// <param name="destroyOrder">记录组件销毁顺序的列表。</param>
|
||||
public DestroyOrderArchitecture(List<string> destroyOrder)
|
||||
{
|
||||
_destroyOrder = destroyOrder;
|
||||
PhaseChanged += phase => PhaseHistory.Add(phase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取架构经历过的阶段列表。
|
||||
/// </summary>
|
||||
public List<ArchitecturePhase> PhaseHistory { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 注册会记录销毁顺序的 Utility、Model 和 System。
|
||||
/// </summary>
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
RegisterUtility(new TrackingDestroyableUtility(_destroyOrder));
|
||||
RegisterModel(new TrackingDestroyableModel(_destroyOrder));
|
||||
RegisterSystem(new TrackingDestroyableSystem(_destroyOrder));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在初始化阶段注册可销毁组件并随后抛出异常的测试架构。
|
||||
/// </summary>
|
||||
private sealed class FailingInitializationArchitecture : Architecture
|
||||
{
|
||||
private readonly List<string> _destroyOrder;
|
||||
|
||||
/// <summary>
|
||||
/// 创建用于验证失败后销毁行为的测试架构。
|
||||
/// </summary>
|
||||
/// <param name="destroyOrder">记录失败后清理顺序的列表。</param>
|
||||
public FailingInitializationArchitecture(List<string> destroyOrder)
|
||||
{
|
||||
_destroyOrder = destroyOrder;
|
||||
PhaseChanged += phase => PhaseHistory.Add(phase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取架构经历过的阶段列表。
|
||||
/// </summary>
|
||||
public List<ArchitecturePhase> PhaseHistory { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 注册可销毁组件后故意抛出异常,模拟初始化失败场景。
|
||||
/// </summary>
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
RegisterUtility(new TrackingDestroyableUtility(_destroyOrder));
|
||||
RegisterModel(new TrackingDestroyableModel(_destroyOrder));
|
||||
throw new InvalidOperationException("boom");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过配置器把阶段监听器注册到容器中的测试架构。
|
||||
/// </summary>
|
||||
private sealed class ListenerTrackingArchitecture(TrackingPhaseListener listener) : Architecture
|
||||
{
|
||||
/// <summary>
|
||||
/// 保持对监听器的引用,以便配置器在初始化前把同一实例注册到容器。
|
||||
/// </summary>
|
||||
public override Action<IServiceCollection>? Configurator =>
|
||||
services => services.AddSingleton<IArchitecturePhaseListener>(listener);
|
||||
|
||||
/// <summary>
|
||||
/// 该测试不需要额外组件注册。
|
||||
/// </summary>
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启用 AllowLateRegistration 的测试架构。
|
||||
/// 该架构暴露生命周期协作者供回归测试验证内部注册策略对齐。
|
||||
/// </summary>
|
||||
private sealed class AllowLateRegistrationArchitecture : Architecture
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用允许后注册的配置创建测试架构。
|
||||
/// </summary>
|
||||
public AllowLateRegistrationArchitecture()
|
||||
: base(new ArchitectureConfiguration
|
||||
{
|
||||
ArchitectureProperties = new()
|
||||
{
|
||||
AllowLateRegistration = true,
|
||||
StrictPhaseValidation = true
|
||||
}
|
||||
})
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 该测试不需要初始组件。
|
||||
/// </summary>
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过反射调用内部生命周期协作者的注册逻辑,以便覆盖无法通过公共 API 直接到达的后注册初始化路径。
|
||||
/// </summary>
|
||||
/// <param name="component">要登记到生命周期中的后注册组件。</param>
|
||||
public void RegisterLateComponentForTesting(object component)
|
||||
{
|
||||
var field = typeof(Architecture).GetField(
|
||||
"_lifecycle",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var lifecycle = field?.GetValue(this) ??
|
||||
throw new InvalidOperationException("Architecture lifecycle field was not found.");
|
||||
var registerMethod = lifecycle.GetType().GetMethod(nameof(RegisterLateComponentForTesting)) ??
|
||||
lifecycle.GetType().GetMethod("RegisterLifecycleComponent") ??
|
||||
throw new InvalidOperationException(
|
||||
"Architecture lifecycle registration method was not found.");
|
||||
|
||||
registerMethod.Invoke(lifecycle, [component]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于验证逆序销毁的上下文工具。
|
||||
/// </summary>
|
||||
private sealed class TrackingDestroyableUtility(List<string> destroyOrder) : IContextUtility
|
||||
{
|
||||
private IArchitectureContext _context = null!;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
public void Destroy()
|
||||
{
|
||||
destroyOrder.Add("utility");
|
||||
}
|
||||
|
||||
public void SetContext(IArchitectureContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public IArchitectureContext GetContext()
|
||||
{
|
||||
return _context;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录容器阶段通知顺序的监听器。
|
||||
/// </summary>
|
||||
private sealed class TrackingPhaseListener : IArchitecturePhaseListener
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取监听到的阶段列表。
|
||||
/// </summary>
|
||||
public List<ArchitecturePhase> ObservedPhases { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 记录收到的阶段通知。
|
||||
/// </summary>
|
||||
/// <param name="phase">当前阶段。</param>
|
||||
public void OnArchitecturePhase(ArchitecturePhase phase)
|
||||
{
|
||||
ObservedPhases.Add(phase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录即时初始化次数的后注册测试组件。
|
||||
/// </summary>
|
||||
private sealed class LateRegisteredInitializableComponent : IInitializable
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取组件被即时初始化的次数。
|
||||
/// </summary>
|
||||
public int InitializeCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录一次初始化调用。
|
||||
/// </summary>
|
||||
public void Initialize()
|
||||
{
|
||||
InitializeCallCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于验证逆序销毁的模型。
|
||||
/// </summary>
|
||||
private sealed class TrackingDestroyableModel(List<string> destroyOrder) : IModel, IDestroyable
|
||||
{
|
||||
private IArchitectureContext _context = null!;
|
||||
|
||||
public void Destroy()
|
||||
{
|
||||
destroyOrder.Add("model");
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
public void OnArchitecturePhase(ArchitecturePhase phase)
|
||||
{
|
||||
}
|
||||
|
||||
public void SetContext(IArchitectureContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public IArchitectureContext GetContext()
|
||||
{
|
||||
return _context;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于验证逆序销毁的系统。
|
||||
/// </summary>
|
||||
private sealed class TrackingDestroyableSystem(List<string> destroyOrder) : ISystem
|
||||
{
|
||||
private IArchitectureContext _context = null!;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
public void Destroy()
|
||||
{
|
||||
destroyOrder.Add("system");
|
||||
}
|
||||
|
||||
public void OnArchitecturePhase(ArchitecturePhase phase)
|
||||
{
|
||||
}
|
||||
|
||||
public void SetContext(IArchitectureContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public IArchitectureContext GetContext()
|
||||
{
|
||||
return _context;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,188 +0,0 @@
|
||||
using GFramework.Core.Abstractions.Architectures;
|
||||
using GFramework.Core.Abstractions.Utility;
|
||||
using GFramework.Core.Architectures;
|
||||
using GFramework.Core.Logging;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GFramework.Core.Tests.Architectures;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Architecture 通过 <c>ArchitectureModules</c> 暴露出的模块安装与 Mediator 行为注册能力。
|
||||
/// 这些测试覆盖模块安装回调和中介管道行为接入,确保模块管理器仍然保持可观察行为不变。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ArchitectureModulesBehaviorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化日志工厂和全局上下文状态。
|
||||
/// </summary>
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider();
|
||||
GameContext.Clear();
|
||||
TrackingPipelineBehavior<ModuleBehaviorRequest, string>.InvocationCount = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理测试过程中写入的全局上下文状态。
|
||||
/// </summary>
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
GameContext.Clear();
|
||||
TrackingPipelineBehavior<ModuleBehaviorRequest, string>.InvocationCount = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证安装模块时会把当前架构实例传给模块,并允许模块在安装阶段注册组件。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task InstallModule_Should_Invoke_Module_Install_With_Current_Architecture()
|
||||
{
|
||||
var module = new TrackingArchitectureModule();
|
||||
var architecture = new ModuleTestArchitecture(target => target.InstallModule(module));
|
||||
|
||||
await architecture.InitializeAsync();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(module.InstalledArchitecture, Is.SameAs(architecture));
|
||||
Assert.That(module.InstallCallCount, Is.EqualTo(1));
|
||||
Assert.That(architecture.Context.GetUtility<InstalledByModuleUtility>(), Is.Not.Null);
|
||||
});
|
||||
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证注册的 Mediator 行为会参与请求管道执行。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task RegisterMediatorBehavior_Should_Apply_Pipeline_Behavior_To_Request()
|
||||
{
|
||||
var architecture = new ModuleTestArchitecture(target =>
|
||||
target.RegisterMediatorBehavior<TrackingPipelineBehavior<ModuleBehaviorRequest, string>>());
|
||||
|
||||
await architecture.InitializeAsync();
|
||||
|
||||
var response = await architecture.Context.SendRequestAsync(new ModuleBehaviorRequest());
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(response, Is.EqualTo("handled"));
|
||||
Assert.That(TrackingPipelineBehavior<ModuleBehaviorRequest, string>.InvocationCount, Is.EqualTo(1));
|
||||
});
|
||||
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于测试模块行为的最小架构实现。
|
||||
/// </summary>
|
||||
private sealed class ModuleTestArchitecture(Action<ModuleTestArchitecture> registrationAction) : Architecture
|
||||
{
|
||||
/// <summary>
|
||||
/// 打开 Mediator 服务注册,以便测试中介行为接入。
|
||||
/// </summary>
|
||||
public override Action<IServiceCollection>? Configurator =>
|
||||
services => services.AddMediator(options => { options.ServiceLifetime = ServiceLifetime.Singleton; });
|
||||
|
||||
/// <summary>
|
||||
/// 在初始化阶段执行测试注入的模块注册逻辑。
|
||||
/// </summary>
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
registrationAction(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录模块安装调用情况的测试模块。
|
||||
/// </summary>
|
||||
private sealed class TrackingArchitectureModule : IArchitectureModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取模块安装调用次数。
|
||||
/// </summary>
|
||||
public int InstallCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取最近一次接收到的架构实例。
|
||||
/// </summary>
|
||||
public IArchitecture? InstalledArchitecture { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录安装调用,并在安装阶段注册一个工具验证调用链可用。
|
||||
/// </summary>
|
||||
/// <param name="architecture">目标架构实例。</param>
|
||||
public void Install(IArchitecture architecture)
|
||||
{
|
||||
InstallCallCount++;
|
||||
InstalledArchitecture = architecture;
|
||||
architecture.RegisterUtility(new InstalledByModuleUtility());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 由测试模块安装时注册的简单工具。
|
||||
/// </summary>
|
||||
private sealed class InstalledByModuleUtility : IUtility
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于验证管道行为注册是否生效的测试请求。
|
||||
/// </summary>
|
||||
public sealed class ModuleBehaviorRequest : IRequest<string>
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理测试请求的处理器。
|
||||
/// </summary>
|
||||
public sealed class ModuleBehaviorRequestHandler : IRequestHandler<ModuleBehaviorRequest, string>
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回固定结果,便于聚焦验证管道行为是否执行。
|
||||
/// </summary>
|
||||
/// <param name="request">请求实例。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>固定响应内容。</returns>
|
||||
public ValueTask<string> Handle(ModuleBehaviorRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return ValueTask.FromResult("handled");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录请求通过管道次数的测试行为。
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">请求类型。</typeparam>
|
||||
/// <typeparam name="TResponse">响应类型。</typeparam>
|
||||
public sealed class TrackingPipelineBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取当前测试进程中该请求类型对应的行为触发次数。
|
||||
/// </summary>
|
||||
public static int InvocationCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录一次行为执行,然后继续执行下一个处理器。
|
||||
/// </summary>
|
||||
/// <param name="message">当前请求消息。</param>
|
||||
/// <param name="next">下一个处理委托。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>下游处理器的响应结果。</returns>
|
||||
public async ValueTask<TResponse> Handle(
|
||||
TRequest message,
|
||||
MessageHandlerDelegate<TRequest, TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
InvocationCount++;
|
||||
return await next(message, cancellationToken);
|
||||
}
|
||||
}
|
||||
@ -1,233 +0,0 @@
|
||||
using GFramework.Core.Abstractions.Coroutine;
|
||||
using GFramework.Core.Coroutine;
|
||||
using GFramework.Core.Coroutine.Instructions;
|
||||
|
||||
namespace GFramework.Core.Tests.Coroutine;
|
||||
|
||||
/// <summary>
|
||||
/// 协程调度器增强行为测试。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class CoroutineSchedulerAdvancedTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证 WaitForSecondsRealtime 使用独立的真实时间源推进。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void WaitForSecondsRealtime_Should_Use_Realtime_TimeSource_When_Provided()
|
||||
{
|
||||
var scaledTime = new FakeTimeSource();
|
||||
var realtimeTime = new FakeTimeSource();
|
||||
var scheduler = new CoroutineScheduler(
|
||||
scaledTime,
|
||||
realtimeTimeSource: realtimeTime);
|
||||
var completed = false;
|
||||
|
||||
IEnumerator<IYieldInstruction> Coroutine()
|
||||
{
|
||||
yield return new WaitForSecondsRealtime(1.0);
|
||||
completed = true;
|
||||
}
|
||||
|
||||
scheduler.Run(Coroutine());
|
||||
|
||||
scaledTime.Advance(0.1);
|
||||
realtimeTime.Advance(0.4);
|
||||
scheduler.Update();
|
||||
|
||||
Assert.That(completed, Is.False);
|
||||
Assert.That(scheduler.ActiveCoroutineCount, Is.EqualTo(1));
|
||||
|
||||
scaledTime.Advance(0.1);
|
||||
realtimeTime.Advance(0.6);
|
||||
scheduler.Update();
|
||||
|
||||
Assert.That(completed, Is.True);
|
||||
Assert.That(scheduler.ActiveCoroutineCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证固定更新等待指令仅在固定阶段调度器中推进。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void WaitForFixedUpdate_Should_Only_Advance_On_FixedUpdate_Scheduler()
|
||||
{
|
||||
var defaultTime = new FakeTimeSource();
|
||||
var fixedTime = new FakeTimeSource();
|
||||
var defaultScheduler = new CoroutineScheduler(defaultTime);
|
||||
var fixedScheduler = new CoroutineScheduler(
|
||||
fixedTime,
|
||||
executionStage: CoroutineExecutionStage.FixedUpdate);
|
||||
var defaultCompleted = false;
|
||||
var fixedCompleted = false;
|
||||
|
||||
IEnumerator<IYieldInstruction> DefaultCoroutine()
|
||||
{
|
||||
yield return new WaitForFixedUpdate();
|
||||
defaultCompleted = true;
|
||||
}
|
||||
|
||||
IEnumerator<IYieldInstruction> FixedCoroutine()
|
||||
{
|
||||
yield return new WaitForFixedUpdate();
|
||||
fixedCompleted = true;
|
||||
}
|
||||
|
||||
defaultScheduler.Run(DefaultCoroutine());
|
||||
fixedScheduler.Run(FixedCoroutine());
|
||||
|
||||
defaultTime.Advance(0.1);
|
||||
fixedTime.Advance(0.1);
|
||||
defaultScheduler.Update();
|
||||
fixedScheduler.Update();
|
||||
|
||||
Assert.That(defaultCompleted, Is.False);
|
||||
Assert.That(defaultScheduler.ActiveCoroutineCount, Is.EqualTo(1));
|
||||
Assert.That(fixedCompleted, Is.True);
|
||||
Assert.That(fixedScheduler.ActiveCoroutineCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证取消令牌会在下一次调度循环中终止协程并记录结果。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task CancellationToken_Should_Cancel_Coroutine_On_Next_Update()
|
||||
{
|
||||
var timeSource = new FakeTimeSource();
|
||||
var scheduler = new CoroutineScheduler(timeSource);
|
||||
using var cancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
IEnumerator<IYieldInstruction> Coroutine()
|
||||
{
|
||||
yield return new Delay(10);
|
||||
}
|
||||
|
||||
var handle = scheduler.Run(Coroutine(), cancellationToken: cancellationTokenSource.Token);
|
||||
cancellationTokenSource.Cancel();
|
||||
|
||||
timeSource.Advance(0.1);
|
||||
scheduler.Update();
|
||||
|
||||
var status = await scheduler.WaitForCompletionAsync(handle);
|
||||
|
||||
Assert.That(scheduler.IsCoroutineAlive(handle), Is.False);
|
||||
Assert.That(status, Is.EqualTo(CoroutineCompletionStatus.Cancelled));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证调度器可以暴露活跃协程快照。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void TryGetSnapshot_Should_Return_Current_Waiting_Instruction_And_Stage()
|
||||
{
|
||||
var timeSource = new FakeTimeSource();
|
||||
var scheduler = new CoroutineScheduler(
|
||||
timeSource,
|
||||
executionStage: CoroutineExecutionStage.EndOfFrame);
|
||||
|
||||
IEnumerator<IYieldInstruction> Coroutine()
|
||||
{
|
||||
yield return new WaitForEndOfFrame();
|
||||
}
|
||||
|
||||
var handle = scheduler.Run(Coroutine(), tag: "ui", group: "frame-end");
|
||||
|
||||
var found = scheduler.TryGetSnapshot(handle, out var snapshot);
|
||||
|
||||
Assert.That(found, Is.True);
|
||||
Assert.That(snapshot.Handle, Is.EqualTo(handle));
|
||||
Assert.That(snapshot.Tag, Is.EqualTo("ui"));
|
||||
Assert.That(snapshot.Group, Is.EqualTo("frame-end"));
|
||||
Assert.That(snapshot.IsWaiting, Is.True);
|
||||
Assert.That(snapshot.WaitingInstructionType, Is.EqualTo(typeof(WaitForEndOfFrame)));
|
||||
Assert.That(snapshot.ExecutionStage, Is.EqualTo(CoroutineExecutionStage.EndOfFrame));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证异常结束的协程会记录为 Faulted。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task WaitForCompletionAsync_Should_Return_Faulted_For_Failing_Coroutine()
|
||||
{
|
||||
var timeSource = new FakeTimeSource();
|
||||
var scheduler = new CoroutineScheduler(timeSource);
|
||||
|
||||
IEnumerator<IYieldInstruction> Coroutine()
|
||||
{
|
||||
yield return new WaitOneFrame();
|
||||
throw new InvalidOperationException("boom");
|
||||
#pragma warning disable CS0162
|
||||
yield break;
|
||||
#pragma warning restore CS0162
|
||||
}
|
||||
|
||||
var handle = scheduler.Run(Coroutine());
|
||||
timeSource.Advance(0.1);
|
||||
scheduler.Update();
|
||||
timeSource.Advance(0.1);
|
||||
scheduler.Update();
|
||||
|
||||
var status = await scheduler.WaitForCompletionAsync(handle);
|
||||
|
||||
Assert.That(status, Is.EqualTo(CoroutineCompletionStatus.Faulted));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证完成状态缓存有固定上限,避免无限增长。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void CompletionStatusHistory_Should_Be_Bounded()
|
||||
{
|
||||
var timeSource = new FakeTimeSource();
|
||||
var scheduler = new CoroutineScheduler(timeSource);
|
||||
var handles = new List<CoroutineHandle>();
|
||||
|
||||
IEnumerator<IYieldInstruction> ImmediateCoroutine()
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
for (var i = 0; i < 1100; i++)
|
||||
{
|
||||
handles.Add(scheduler.Run(ImmediateCoroutine()));
|
||||
}
|
||||
|
||||
Assert.That(scheduler.TryGetCompletionStatus(handles[0], out _), Is.False);
|
||||
Assert.That(scheduler.TryGetCompletionStatus(handles[^1], out var latestStatus), Is.True);
|
||||
Assert.That(latestStatus, Is.EqualTo(CoroutineCompletionStatus.Completed));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证作为首个等待指令的 WaitForCoroutine 会立即启动子协程,并沿用父协程取消令牌。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task WaitForCoroutine_Should_Start_Child_During_Prewarm_And_Propagate_Cancellation()
|
||||
{
|
||||
var timeSource = new FakeTimeSource();
|
||||
var scheduler = new CoroutineScheduler(timeSource);
|
||||
using var cancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
IEnumerator<IYieldInstruction> ChildCoroutine()
|
||||
{
|
||||
yield return new Delay(10);
|
||||
}
|
||||
|
||||
IEnumerator<IYieldInstruction> ParentCoroutine()
|
||||
{
|
||||
yield return new WaitForCoroutine(ChildCoroutine());
|
||||
}
|
||||
|
||||
var handle = scheduler.Run(ParentCoroutine(), cancellationToken: cancellationTokenSource.Token);
|
||||
|
||||
Assert.That(scheduler.ActiveCoroutineCount, Is.EqualTo(2));
|
||||
|
||||
cancellationTokenSource.Cancel();
|
||||
timeSource.Advance(0.1);
|
||||
scheduler.Update();
|
||||
|
||||
var status = await scheduler.WaitForCompletionAsync(handle);
|
||||
|
||||
Assert.That(status, Is.EqualTo(CoroutineCompletionStatus.Cancelled));
|
||||
Assert.That(scheduler.ActiveCoroutineCount, Is.EqualTo(0));
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,6 @@ namespace GFramework.Core.Architectures;
|
||||
/// 专注于生命周期管理、初始化流程控制和架构阶段转换。
|
||||
///
|
||||
/// 重构说明:此类已重构为协调器模式,将职责委托给专门的管理器:
|
||||
/// - ArchitectureBootstrapper: 初始化基础设施编排
|
||||
/// - ArchitectureLifecycle: 生命周期管理
|
||||
/// - ArchitectureComponentRegistry: 组件注册管理
|
||||
/// - ArchitectureModules: 模块管理
|
||||
@ -38,25 +37,19 @@ public abstract class Architecture : IArchitecture
|
||||
IArchitectureServices? services = null,
|
||||
IArchitectureContext? context = null)
|
||||
{
|
||||
var resolvedConfiguration = configuration ?? new ArchitectureConfiguration();
|
||||
var resolvedEnvironment = environment ?? new DefaultEnvironment();
|
||||
var resolvedServices = services ?? new ArchitectureServices();
|
||||
Configuration = configuration ?? new ArchitectureConfiguration();
|
||||
Environment = environment ?? new DefaultEnvironment();
|
||||
Services = services ?? new ArchitectureServices();
|
||||
_context = context;
|
||||
|
||||
// 初始化 Logger
|
||||
LoggerFactoryResolver.Provider = resolvedConfiguration.LoggerProperties.LoggerFactoryProvider;
|
||||
LoggerFactoryResolver.Provider = Configuration.LoggerProperties.LoggerFactoryProvider;
|
||||
_logger = LoggerFactoryResolver.Provider.CreateLogger(GetType().Name);
|
||||
|
||||
// 初始化管理器
|
||||
_bootstrapper = new ArchitectureBootstrapper(GetType(), resolvedEnvironment, resolvedServices, _logger);
|
||||
_lifecycle = new ArchitectureLifecycle(this, resolvedConfiguration, resolvedServices, _logger);
|
||||
_componentRegistry = new ArchitectureComponentRegistry(
|
||||
this,
|
||||
resolvedConfiguration,
|
||||
resolvedServices,
|
||||
_lifecycle,
|
||||
_logger);
|
||||
_modules = new ArchitectureModules(this, resolvedServices, _logger);
|
||||
_lifecycle = new ArchitectureLifecycle(this, Configuration, Services, _logger);
|
||||
_componentRegistry = new ArchitectureComponentRegistry(this, Configuration, Services, _lifecycle, _logger);
|
||||
_modules = new ArchitectureModules(this, Services, _logger);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -77,6 +70,21 @@ public abstract class Architecture : IArchitecture
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// 获取架构配置对象
|
||||
/// </summary>
|
||||
private IArchitectureConfiguration Configuration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取环境配置对象
|
||||
/// </summary>
|
||||
private IEnvironment Environment { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取服务管理器
|
||||
/// </summary>
|
||||
private IArchitectureServices Services { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前架构的阶段
|
||||
/// </summary>
|
||||
@ -121,11 +129,6 @@ public abstract class Architecture : IArchitecture
|
||||
/// </summary>
|
||||
private IArchitectureContext? _context;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化基础设施编排器
|
||||
/// </summary>
|
||||
private readonly ArchitectureBootstrapper _bootstrapper;
|
||||
|
||||
/// <summary>
|
||||
/// 生命周期管理器
|
||||
/// </summary>
|
||||
@ -147,8 +150,7 @@ public abstract class Architecture : IArchitecture
|
||||
|
||||
/// <summary>
|
||||
/// 注册中介行为管道
|
||||
/// 用于配置Mediator框架的行为拦截和处理逻辑。
|
||||
/// 可以传入开放泛型行为类型,也可以传入绑定到特定请求的封闭行为类型。
|
||||
/// 用于配置Mediator框架的行为拦截和处理逻辑
|
||||
/// </summary>
|
||||
/// <typeparam name="TBehavior">行为类型,必须是引用类型</typeparam>
|
||||
public void RegisterMediatorBehavior<TBehavior>() where TBehavior : class
|
||||
@ -182,7 +184,7 @@ public abstract class Architecture : IArchitecture
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册系统类型,由当前服务集合自动创建实例并接入本轮初始化
|
||||
/// 注册系统类型,由 DI 容器自动创建实例
|
||||
/// </summary>
|
||||
/// <typeparam name="T">系统类型</typeparam>
|
||||
/// <param name="onCreated">可选的实例创建后回调</param>
|
||||
@ -203,7 +205,7 @@ public abstract class Architecture : IArchitecture
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册模型类型,由当前服务集合自动创建实例并接入本轮初始化
|
||||
/// 注册模型类型,由 DI 容器自动创建实例
|
||||
/// </summary>
|
||||
/// <typeparam name="T">模型类型</typeparam>
|
||||
/// <param name="onCreated">可选的实例创建后回调</param>
|
||||
@ -282,7 +284,32 @@ public abstract class Architecture : IArchitecture
|
||||
/// <param name="asyncMode">是否启用异步模式</param>
|
||||
private async Task InitializeInternalAsync(bool asyncMode)
|
||||
{
|
||||
_context = await _bootstrapper.PrepareForInitializationAsync(_context, Configurator, asyncMode);
|
||||
// === 基础环境初始化 ===
|
||||
Environment.Initialize();
|
||||
|
||||
// 注册内置服务模块
|
||||
Services.ModuleManager.RegisterBuiltInModules(Services.Container);
|
||||
|
||||
// 将 Environment 注册到容器
|
||||
if (!Services.Container.Contains<IEnvironment>())
|
||||
Services.Container.RegisterPlurality(Environment);
|
||||
|
||||
// 初始化架构上下文
|
||||
_context ??= new ArchitectureContext(Services.Container);
|
||||
GameContext.Bind(GetType(), _context);
|
||||
|
||||
// 为服务设置上下文
|
||||
Services.SetContext(_context);
|
||||
if (Configurator is null)
|
||||
{
|
||||
_logger.Debug("Mediator-based cqrs will not take effect without the service setter configured!");
|
||||
}
|
||||
|
||||
// 执行服务钩子
|
||||
Services.Container.ExecuteServicesHook(Configurator);
|
||||
|
||||
// 初始化服务模块
|
||||
await Services.ModuleManager.InitializeAllAsync(asyncMode);
|
||||
|
||||
// === 用户 OnInitialize ===
|
||||
_logger.Debug("Calling user OnInitialize()");
|
||||
@ -293,7 +320,9 @@ public abstract class Architecture : IArchitecture
|
||||
await _lifecycle.InitializeAllComponentsAsync(asyncMode);
|
||||
|
||||
// === 初始化完成阶段 ===
|
||||
_bootstrapper.CompleteInitialization();
|
||||
Services.Container.Freeze();
|
||||
_logger.Info("IOC container frozen");
|
||||
|
||||
_lifecycle.MarkAsReady();
|
||||
_logger.Info($"Architecture {GetType().Name} is ready - all components initialized");
|
||||
}
|
||||
|
||||
@ -1,118 +0,0 @@
|
||||
using GFramework.Core.Abstractions.Architectures;
|
||||
using GFramework.Core.Abstractions.Environment;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GFramework.Core.Architectures;
|
||||
|
||||
/// <summary>
|
||||
/// 协调架构初始化期间的基础设施准备工作。
|
||||
/// 该类型将环境初始化、服务模块启动、上下文绑定和服务容器配置从 <see cref="Architecture" /> 中拆出,
|
||||
/// 使核心架构类只保留生命周期入口和公共 API 协调职责。
|
||||
/// </summary>
|
||||
internal sealed class ArchitectureBootstrapper(
|
||||
Type architectureType,
|
||||
IEnvironment environment,
|
||||
IArchitectureServices services,
|
||||
ILogger logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 在执行用户 <c>OnInitialize</c> 之前准备架构运行时。
|
||||
/// 该流程必须保证环境、内置服务、上下文和服务钩子已经可用,
|
||||
/// 因为用户初始化逻辑通常会立即访问事件总线、查询执行器或环境对象。
|
||||
/// </summary>
|
||||
/// <param name="existingContext">调用方已经提供的上下文;如果为空则创建默认上下文。</param>
|
||||
/// <param name="configurator">可选的容器配置委托,用于接入 Mediator 等扩展服务。</param>
|
||||
/// <param name="asyncMode">是否以异步模式初始化服务模块。</param>
|
||||
/// <returns>已绑定到当前架构类型的架构上下文。</returns>
|
||||
public async Task<IArchitectureContext> PrepareForInitializationAsync(
|
||||
IArchitectureContext? existingContext,
|
||||
Action<IServiceCollection>? configurator,
|
||||
bool asyncMode)
|
||||
{
|
||||
InitializeEnvironment();
|
||||
RegisterBuiltInModules();
|
||||
EnsureEnvironmentRegistered();
|
||||
|
||||
var context = EnsureContext(existingContext);
|
||||
ConfigureServices(context, configurator);
|
||||
await InitializeServiceModulesAsync(asyncMode);
|
||||
return context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 完成用户组件初始化之后的收尾工作。
|
||||
/// 冻结容器可以阻止 Ready 阶段之后的意外服务注册,保持运行时依赖图稳定。
|
||||
/// </summary>
|
||||
public void CompleteInitialization()
|
||||
{
|
||||
services.Container.Freeze();
|
||||
logger.Info("IOC container frozen");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化运行环境,使环境对象在后续服务构建和用户初始化前进入可用状态。
|
||||
/// </summary>
|
||||
private void InitializeEnvironment()
|
||||
{
|
||||
environment.Initialize();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册框架内置服务模块。
|
||||
/// 该步骤必须先于执行服务钩子,以便容器具备 CQRS 和事件总线等基础服务。
|
||||
/// </summary>
|
||||
private void RegisterBuiltInModules()
|
||||
{
|
||||
services.ModuleManager.RegisterBuiltInModules(services.Container);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保环境对象可以通过架构容器解析。
|
||||
/// 如果调用方已经预先注册了自定义环境实例,则保留现有绑定,避免覆盖外部配置。
|
||||
/// </summary>
|
||||
private void EnsureEnvironmentRegistered()
|
||||
{
|
||||
if (!services.Container.Contains<IEnvironment>())
|
||||
services.Container.RegisterPlurality(environment);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取本次初始化使用的架构上下文,并将其绑定到全局游戏上下文表。
|
||||
/// 绑定发生在用户初始化之前,确保组件在注册阶段即可通过架构类型解析上下文。
|
||||
/// </summary>
|
||||
/// <param name="existingContext">外部提供的上下文。</param>
|
||||
/// <returns>实际用于本次初始化的上下文实例。</returns>
|
||||
private IArchitectureContext EnsureContext(IArchitectureContext? existingContext)
|
||||
{
|
||||
var context = existingContext ?? new ArchitectureContext(services.Container);
|
||||
GameContext.Bind(architectureType, context);
|
||||
return context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为服务容器设置上下文并执行扩展配置钩子。
|
||||
/// 这一步统一承接 Mediator 等容器扩展的接入点,避免 <see cref="Architecture" /> 直接操作容器细节。
|
||||
/// </summary>
|
||||
/// <param name="context">当前架构上下文。</param>
|
||||
/// <param name="configurator">可选的服务集合配置委托。</param>
|
||||
private void ConfigureServices(IArchitectureContext context, Action<IServiceCollection>? configurator)
|
||||
{
|
||||
services.SetContext(context);
|
||||
|
||||
if (configurator is null)
|
||||
logger.Debug("Mediator-based cqrs will not take effect without the service setter configured!");
|
||||
|
||||
services.Container.ExecuteServicesHook(configurator);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化所有服务模块。
|
||||
/// 该过程在用户注册系统、模型和工具之前完成,避免组件在初始化期间访问未准备好的服务。
|
||||
/// </summary>
|
||||
/// <param name="asyncMode">是否允许异步初始化服务模块。</param>
|
||||
private async Task InitializeServiceModulesAsync(bool asyncMode)
|
||||
{
|
||||
await services.ModuleManager.InitializeAllAsync(asyncMode);
|
||||
}
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
using GFramework.Core.Abstractions.Ioc;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GFramework.Core.Architectures;
|
||||
|
||||
/// <summary>
|
||||
/// 为架构组件的类型注册路径提供实例创建能力。
|
||||
/// 该类型在容器冻结前基于当前服务集合和已注册实例进行激活,
|
||||
/// 使 <see cref="ArchitectureComponentRegistry" /> 可以在注册阶段就物化 System / Model,
|
||||
/// 避免它们在 Ready 之后首次解析时才参与生命周期而导致状态不一致。
|
||||
/// </summary>
|
||||
internal sealed class ArchitectureComponentActivator(
|
||||
IIocContainer container,
|
||||
ILogger logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 预冻结阶段的单例实例缓存。
|
||||
/// 该缓存跨越整个架构组件激活周期共享,确保多个组件在同一轮初始化中解析到同一个单例描述时不会重复创建实例。
|
||||
/// </summary>
|
||||
private readonly Dictionary<Type, object?> _singletonCache = [];
|
||||
|
||||
/// <summary>
|
||||
/// 根据当前容器状态创建组件实例。
|
||||
/// 激活过程优先复用已经注册到容器中的实例,再按服务描述解析实现类型或工厂方法,
|
||||
/// 以兼容构造函数依赖于框架服务、用户实例服务和先前注册组件的场景。
|
||||
/// </summary>
|
||||
/// <typeparam name="TComponent">要创建的组件类型。</typeparam>
|
||||
/// <returns>创建完成的组件实例。</returns>
|
||||
public TComponent CreateInstance<TComponent>() where TComponent : class
|
||||
{
|
||||
var activationProvider = new RegistrationServiceProvider(container, logger, _singletonCache);
|
||||
return ActivatorUtilities.CreateInstance<TComponent>(activationProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 面向组件注册的轻量级服务提供者。
|
||||
/// 该实现只覆盖预冻结阶段需要的解析能力,避免引入完整容器冻结过程。
|
||||
/// </summary>
|
||||
private sealed class RegistrationServiceProvider(
|
||||
IIocContainer container,
|
||||
ILogger logger,
|
||||
Dictionary<Type, object?> singletonCache) : IServiceProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// 共享的单例缓存。
|
||||
/// 该缓存由外层 activator 统一持有,从而把单例复用范围提升到整个组件注册批次,而不是单次实例创建调用。
|
||||
/// </summary>
|
||||
private readonly Dictionary<Type, object?> _singletonCache = singletonCache;
|
||||
|
||||
/// <summary>
|
||||
/// 从当前服务集合中解析指定类型的服务。
|
||||
/// 解析顺序为:已注册实例 → 服务描述实例/工厂/实现类型 → 可直接实例化的具体类型。
|
||||
/// </summary>
|
||||
/// <param name="serviceType">请求解析的服务类型。</param>
|
||||
/// <returns>解析到的服务实例;若无法解析则返回 null。</returns>
|
||||
public object? GetService(Type serviceType)
|
||||
{
|
||||
if (serviceType == typeof(IServiceProvider))
|
||||
return this;
|
||||
|
||||
var existingInstance = container.Get(serviceType);
|
||||
if (existingInstance != null)
|
||||
return existingInstance;
|
||||
|
||||
var descriptor =
|
||||
container.GetServicesUnsafe.LastOrDefault(candidate => candidate.ServiceType == serviceType);
|
||||
if (descriptor != null)
|
||||
return ResolveDescriptor(serviceType, descriptor);
|
||||
|
||||
if (!serviceType.IsAbstract && !serviceType.IsInterface)
|
||||
return ActivatorUtilities.CreateInstance(this, serviceType);
|
||||
|
||||
logger.Trace($"Activation provider could not resolve {serviceType.Name}");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据服务描述解析实例,并对单例描述进行缓存。
|
||||
/// 这样可以保证同一类型在一次组件注册流程中只创建一次依赖实例。
|
||||
/// </summary>
|
||||
/// <param name="requestedType">请求的服务类型。</param>
|
||||
/// <param name="descriptor">命中的服务描述。</param>
|
||||
/// <returns>解析到的实例。</returns>
|
||||
private object? ResolveDescriptor(Type requestedType, ServiceDescriptor descriptor)
|
||||
{
|
||||
if (descriptor.Lifetime == ServiceLifetime.Singleton &&
|
||||
_singletonCache.TryGetValue(requestedType, out var cached))
|
||||
return cached;
|
||||
|
||||
object? resolved = descriptor switch
|
||||
{
|
||||
{ ImplementationInstance: not null } => descriptor.ImplementationInstance,
|
||||
{ ImplementationFactory: not null } => descriptor.ImplementationFactory(this),
|
||||
{ ImplementationType: not null } => ActivatorUtilities.CreateInstance(this,
|
||||
descriptor.ImplementationType),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (descriptor.Lifetime == ServiceLifetime.Singleton && resolved != null)
|
||||
_singletonCache[requestedType] = resolved;
|
||||
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -20,8 +20,6 @@ internal sealed class ArchitectureComponentRegistry(
|
||||
ArchitectureLifecycle lifecycle,
|
||||
ILogger logger)
|
||||
{
|
||||
private readonly ArchitectureComponentActivator _activator = new(services.Container, logger);
|
||||
|
||||
#region Validation
|
||||
|
||||
/// <summary>
|
||||
@ -65,8 +63,7 @@ internal sealed class ArchitectureComponentRegistry(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册系统类型,并在注册阶段由当前服务集合立即创建实例。
|
||||
/// 这样可以确保该系统参与当前架构初始化批次,而不是等到 Ready 之后首次解析时才延迟创建。
|
||||
/// 注册系统类型,由 DI 容器自动创建实例
|
||||
/// </summary>
|
||||
/// <typeparam name="T">系统类型</typeparam>
|
||||
/// <param name="onCreated">可选的实例创建后回调</param>
|
||||
@ -75,12 +72,21 @@ internal sealed class ArchitectureComponentRegistry(
|
||||
ValidateRegistration("system");
|
||||
logger.Debug($"Registering system type: {typeof(T).Name}");
|
||||
|
||||
// 类型注册路径在注册阶段就物化实例,确保组件能参与当前初始化批次。
|
||||
var system = _activator.CreateInstance<T>();
|
||||
services.Container.RegisterFactory<T>(sp =>
|
||||
{
|
||||
// 1. DI 创建实例
|
||||
var system = ActivatorUtilities.CreateInstance<T>(sp);
|
||||
|
||||
// 2. 框架默认处理
|
||||
system.SetContext(architecture.Context);
|
||||
lifecycle.RegisterLifecycleComponent(system);
|
||||
|
||||
// 3. 用户自定义处理(钩子)
|
||||
onCreated?.Invoke(system);
|
||||
services.Container.RegisterPlurality(system);
|
||||
|
||||
logger.Debug($"System created: {typeof(T).Name}");
|
||||
return system;
|
||||
});
|
||||
|
||||
logger.Info($"System type registered: {typeof(T).Name}");
|
||||
}
|
||||
@ -112,8 +118,7 @@ internal sealed class ArchitectureComponentRegistry(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册模型类型,并在注册阶段由当前服务集合立即创建实例。
|
||||
/// 这样可以确保该模型参与当前架构初始化批次,而不是等到 Ready 之后首次解析时才延迟创建。
|
||||
/// 注册模型类型,由 DI 容器自动创建实例
|
||||
/// </summary>
|
||||
/// <typeparam name="T">模型类型</typeparam>
|
||||
/// <param name="onCreated">可选的实例创建后回调</param>
|
||||
@ -122,11 +127,18 @@ internal sealed class ArchitectureComponentRegistry(
|
||||
ValidateRegistration("model");
|
||||
logger.Debug($"Registering model type: {typeof(T).Name}");
|
||||
|
||||
var model = _activator.CreateInstance<T>();
|
||||
services.Container.RegisterFactory<T>(sp =>
|
||||
{
|
||||
var model = ActivatorUtilities.CreateInstance<T>(sp);
|
||||
model.SetContext(architecture.Context);
|
||||
lifecycle.RegisterLifecycleComponent(model);
|
||||
|
||||
// 用户自定义钩子
|
||||
onCreated?.Invoke(model);
|
||||
services.Container.RegisterPlurality(model);
|
||||
|
||||
logger.Debug($"Model created: {typeof(T).Name}");
|
||||
return model;
|
||||
});
|
||||
|
||||
logger.Info($"Model type registered: {typeof(T).Name}");
|
||||
}
|
||||
@ -143,7 +155,6 @@ internal sealed class ArchitectureComponentRegistry(
|
||||
/// <returns>注册成功的工具实例</returns>
|
||||
public TUtility RegisterUtility<TUtility>(TUtility utility) where TUtility : IUtility
|
||||
{
|
||||
ValidateRegistration("utility");
|
||||
logger.Debug($"Registering utility: {typeof(TUtility).Name}");
|
||||
|
||||
// 处理上下文工具类型的设置和生命周期管理
|
||||
@ -166,7 +177,6 @@ internal sealed class ArchitectureComponentRegistry(
|
||||
/// <param name="onCreated">可选的实例创建后回调</param>
|
||||
public void RegisterUtility<T>(Action<T>? onCreated = null) where T : class, IUtility
|
||||
{
|
||||
ValidateRegistration("utility");
|
||||
logger.Debug($"Registering utility type: {typeof(T).Name}");
|
||||
|
||||
services.Container.RegisterFactory<T>(sp =>
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
using GFramework.Core.Abstractions.Architectures;
|
||||
using GFramework.Core.Abstractions.Enums;
|
||||
using GFramework.Core.Abstractions.Lifecycle;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
|
||||
namespace GFramework.Core.Architectures;
|
||||
|
||||
/// <summary>
|
||||
/// 统一处理架构内可销毁对象的登记与释放。
|
||||
/// 该类型封装逆序销毁、异常隔离和服务模块清理规则,
|
||||
/// 让 <see cref="ArchitectureLifecycle" /> 可以专注于初始化流程本身。
|
||||
/// </summary>
|
||||
internal sealed class ArchitectureDisposer(
|
||||
IArchitectureServices services,
|
||||
ILogger logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 保留注册顺序的可销毁对象列表。
|
||||
/// 销毁时按逆序遍历,以尽量匹配组件间的依赖方向。
|
||||
/// </summary>
|
||||
private readonly List<object> _disposables = [];
|
||||
|
||||
/// <summary>
|
||||
/// 用于去重的可销毁对象集合。
|
||||
/// </summary>
|
||||
private readonly HashSet<object> _disposableSet = [];
|
||||
|
||||
/// <summary>
|
||||
/// 注册一个需要参与架构销毁流程的对象。
|
||||
/// 只有实现 <see cref="IDestroyable" /> 或 <see cref="IAsyncDestroyable" /> 的对象会被跟踪。
|
||||
/// </summary>
|
||||
/// <param name="component">待检查的组件实例。</param>
|
||||
public void Register(object component)
|
||||
{
|
||||
if (component is not (IDestroyable or IAsyncDestroyable))
|
||||
return;
|
||||
|
||||
if (!_disposableSet.Add(component))
|
||||
return;
|
||||
|
||||
_disposables.Add(component);
|
||||
logger.Trace($"Registered {component.GetType().Name} for destruction");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行架构销毁流程。
|
||||
/// 该方法会根据当前阶段决定是否进入 Destroying/Destroyed 阶段,并负责服务模块与容器清理。
|
||||
/// </summary>
|
||||
/// <param name="currentPhase">销毁开始前的架构阶段。</param>
|
||||
/// <param name="enterPhase">用于推进架构阶段的回调。</param>
|
||||
public async ValueTask DestroyAsync(ArchitecturePhase currentPhase, Action<ArchitecturePhase> enterPhase)
|
||||
{
|
||||
if (currentPhase is ArchitecturePhase.Destroying or ArchitecturePhase.Destroyed)
|
||||
{
|
||||
logger.Warn("Architecture destroy called but already in destroying/destroyed state");
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPhase == ArchitecturePhase.None)
|
||||
{
|
||||
logger.Debug("Architecture destroy called but never initialized, cleaning up registered components");
|
||||
await CleanupComponentsAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.Info("Starting architecture destruction");
|
||||
enterPhase(ArchitecturePhase.Destroying);
|
||||
|
||||
await CleanupComponentsAsync();
|
||||
await services.ModuleManager.DestroyAllAsync();
|
||||
|
||||
// Destroyed 广播依赖容器中的阶段监听器,必须在清空容器前完成。
|
||||
enterPhase(ArchitecturePhase.Destroyed);
|
||||
services.Container.Clear();
|
||||
logger.Info("Architecture destruction completed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 逆序销毁当前已注册的所有可销毁组件。
|
||||
/// 单个组件失败不会中断后续清理,避免在销毁阶段留下半清理状态。
|
||||
/// </summary>
|
||||
private async ValueTask CleanupComponentsAsync()
|
||||
{
|
||||
logger.Info($"Destroying {_disposables.Count} disposable components");
|
||||
|
||||
for (var i = _disposables.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var component = _disposables[i];
|
||||
|
||||
try
|
||||
{
|
||||
logger.Debug($"Destroying component: {component.GetType().Name}");
|
||||
|
||||
if (component is IAsyncDestroyable asyncDestroyable)
|
||||
{
|
||||
await asyncDestroyable.DestroyAsync();
|
||||
}
|
||||
else if (component is IDestroyable destroyable)
|
||||
{
|
||||
destroyable.Destroy();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error($"Error destroying {component.GetType().Name}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
_disposables.Clear();
|
||||
_disposableSet.Clear();
|
||||
}
|
||||
}
|
||||
@ -27,7 +27,11 @@ internal sealed class ArchitectureLifecycle(
|
||||
/// <returns>注册的钩子实例</returns>
|
||||
public IArchitectureLifecycleHook RegisterLifecycleHook(IArchitectureLifecycleHook hook)
|
||||
{
|
||||
return _phaseCoordinator.RegisterLifecycleHook(hook);
|
||||
if (CurrentPhase >= ArchitecturePhase.Ready && !configuration.ArchitectureProperties.AllowLateRegistration)
|
||||
throw new InvalidOperationException(
|
||||
"Cannot register lifecycle hook after architecture is Ready");
|
||||
_lifecycleHooks.Add(hook);
|
||||
return hook;
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -40,37 +44,31 @@ internal sealed class ArchitectureLifecycle(
|
||||
/// <param name="component">要注册的组件</param>
|
||||
public void RegisterLifecycleComponent(object component)
|
||||
{
|
||||
// 处理初始化
|
||||
if (component is IInitializable initializable)
|
||||
{
|
||||
if (_initialized)
|
||||
if (!_initialized)
|
||||
{
|
||||
if (!configuration.ArchitectureProperties.AllowLateRegistration)
|
||||
throw new InvalidOperationException("Cannot initialize component after Architecture is Ready");
|
||||
|
||||
InitializeLateRegisteredComponent(initializable);
|
||||
}
|
||||
|
||||
else if (_pendingInitializableSet.Add(initializable))
|
||||
// 原子去重:HashSet.Add 返回 true 表示添加成功(之前不存在)
|
||||
if (_pendingInitializableSet.Add(initializable))
|
||||
{
|
||||
_pendingInitializableList.Add(initializable);
|
||||
logger.Trace($"Added {component.GetType().Name} to pending initialization queue");
|
||||
}
|
||||
}
|
||||
|
||||
_disposer.Register(component);
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Cannot initialize component after Architecture is Ready");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Phase Management
|
||||
|
||||
/// <summary>
|
||||
/// 进入指定的架构阶段,并执行相应的生命周期管理操作
|
||||
/// </summary>
|
||||
/// <param name="next">要进入的下一个架构阶段</param>
|
||||
public void EnterPhase(ArchitecturePhase next)
|
||||
{
|
||||
_phaseCoordinator.EnterPhase(next);
|
||||
// 处理销毁(支持 IDestroyable 或 IAsyncDestroyable)
|
||||
if (component is not (IDestroyable or IAsyncDestroyable)) return;
|
||||
// 原子去重:HashSet.Add 返回 true 表示添加成功(之前不存在)
|
||||
if (!_disposableSet.Add(component)) return;
|
||||
_disposables.Add(component);
|
||||
logger.Trace($"Registered {component.GetType().Name} for destruction");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -90,15 +88,19 @@ internal sealed class ArchitectureLifecycle(
|
||||
private readonly List<IInitializable> _pendingInitializableList = [];
|
||||
|
||||
/// <summary>
|
||||
/// 架构阶段协调器
|
||||
/// 可销毁组件的去重集合(支持 IDestroyable 和 IAsyncDestroyable)
|
||||
/// </summary>
|
||||
private readonly ArchitecturePhaseCoordinator _phaseCoordinator =
|
||||
new(architecture, configuration, services, logger);
|
||||
private readonly HashSet<object> _disposableSet = [];
|
||||
|
||||
/// <summary>
|
||||
/// 架构销毁协调器
|
||||
/// 存储所有需要销毁的组件(统一管理,保持注册逆序销毁)
|
||||
/// </summary>
|
||||
private readonly ArchitectureDisposer _disposer = new(services, logger);
|
||||
private readonly List<object> _disposables = [];
|
||||
|
||||
/// <summary>
|
||||
/// 生命周期感知对象列表
|
||||
/// </summary>
|
||||
private readonly List<IArchitectureLifecycleHook> _lifecycleHooks = [];
|
||||
|
||||
/// <summary>
|
||||
/// 标记架构是否已初始化完成
|
||||
@ -112,7 +114,7 @@ internal sealed class ArchitectureLifecycle(
|
||||
/// <summary>
|
||||
/// 当前架构的阶段
|
||||
/// </summary>
|
||||
public ArchitecturePhase CurrentPhase => _phaseCoordinator.CurrentPhase;
|
||||
public ArchitecturePhase CurrentPhase { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取一个布尔值,指示当前架构是否处于就绪状态
|
||||
@ -127,10 +129,87 @@ internal sealed class ArchitectureLifecycle(
|
||||
/// <summary>
|
||||
/// 阶段变更事件(用于测试和扩展)
|
||||
/// </summary>
|
||||
public event Action<ArchitecturePhase>? PhaseChanged
|
||||
public event Action<ArchitecturePhase>? PhaseChanged;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Phase Management
|
||||
|
||||
/// <summary>
|
||||
/// 进入指定的架构阶段,并执行相应的生命周期管理操作
|
||||
/// </summary>
|
||||
/// <param name="next">要进入的下一个架构阶段</param>
|
||||
/// <exception cref="InvalidOperationException">当阶段转换不被允许时抛出异常</exception>
|
||||
public void EnterPhase(ArchitecturePhase next)
|
||||
{
|
||||
add => _phaseCoordinator.PhaseChanged += value;
|
||||
remove => _phaseCoordinator.PhaseChanged -= value;
|
||||
// 验证阶段转换
|
||||
ValidatePhaseTransition(next);
|
||||
|
||||
// 执行阶段转换
|
||||
var previousPhase = CurrentPhase;
|
||||
CurrentPhase = next;
|
||||
|
||||
if (previousPhase != next)
|
||||
logger.Info($"Architecture phase changed: {previousPhase} -> {next}");
|
||||
|
||||
// 通知阶段变更
|
||||
NotifyPhase(next);
|
||||
NotifyPhaseAwareObjects(next);
|
||||
|
||||
// 触发阶段变更事件(用于测试和扩展)
|
||||
PhaseChanged?.Invoke(next);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证阶段转换是否合法
|
||||
/// </summary>
|
||||
/// <param name="next">目标阶段</param>
|
||||
/// <exception cref="InvalidOperationException">当阶段转换不合法时抛出</exception>
|
||||
private void ValidatePhaseTransition(ArchitecturePhase next)
|
||||
{
|
||||
// 不需要严格验证,直接返回
|
||||
if (!configuration.ArchitectureProperties.StrictPhaseValidation)
|
||||
return;
|
||||
|
||||
// FailedInitialization 可以从任何阶段转换,直接返回
|
||||
if (next == ArchitecturePhase.FailedInitialization)
|
||||
return;
|
||||
|
||||
// 检查转换是否在允许列表中
|
||||
if (ArchitectureConstants.PhaseTransitions.TryGetValue(CurrentPhase, out var allowed) &&
|
||||
allowed.Contains(next))
|
||||
return;
|
||||
|
||||
// 转换不合法,抛出异常
|
||||
var errorMsg = $"Invalid phase transition: {CurrentPhase} -> {next}";
|
||||
logger.Fatal(errorMsg);
|
||||
throw new InvalidOperationException(errorMsg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通知所有架构阶段感知对象阶段变更
|
||||
/// </summary>
|
||||
/// <param name="phase">新阶段</param>
|
||||
private void NotifyPhaseAwareObjects(ArchitecturePhase phase)
|
||||
{
|
||||
foreach (var obj in services.Container.GetAll<IArchitecturePhaseListener>())
|
||||
{
|
||||
logger.Trace($"Notifying phase-aware object {obj.GetType().Name} of phase change to {phase}");
|
||||
obj.OnArchitecturePhase(phase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通知所有生命周期钩子当前阶段变更
|
||||
/// </summary>
|
||||
/// <param name="phase">当前架构阶段</param>
|
||||
private void NotifyPhase(ArchitecturePhase phase)
|
||||
{
|
||||
foreach (var hook in _lifecycleHooks)
|
||||
{
|
||||
hook.OnPhase(phase, architecture);
|
||||
logger.Trace($"Notifying lifecycle hook {hook.GetType().Name} of phase {phase}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -223,18 +302,6 @@ internal sealed class ArchitectureLifecycle(
|
||||
component.Initialize();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 立即初始化在常规初始化批次完成后新增的组件。
|
||||
/// 当启用 <c>AllowLateRegistration</c> 时,生命周期层需要和注册层保持一致,
|
||||
/// 让新增组件在注册当下完成同步初始化,而不是停留在未初始化状态。
|
||||
/// </summary>
|
||||
/// <param name="component">后注册的可初始化组件。</param>
|
||||
private void InitializeLateRegisteredComponent(IInitializable component)
|
||||
{
|
||||
logger.Debug($"Initializing late-registered component: {component.GetType().Name}");
|
||||
component.Initialize();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Destruction
|
||||
@ -244,7 +311,72 @@ internal sealed class ArchitectureLifecycle(
|
||||
/// </summary>
|
||||
public async ValueTask DestroyAsync()
|
||||
{
|
||||
await _disposer.DestroyAsync(CurrentPhase, EnterPhase);
|
||||
// 检查当前阶段,如果已经处于销毁或已销毁状态则直接返回
|
||||
if (CurrentPhase >= ArchitecturePhase.Destroying)
|
||||
{
|
||||
logger.Warn("Architecture destroy called but already in destroying/destroyed state");
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果从未初始化(None 阶段),只清理已注册的组件,不进行阶段转换
|
||||
if (CurrentPhase == ArchitecturePhase.None)
|
||||
{
|
||||
logger.Debug("Architecture destroy called but never initialized, cleaning up registered components");
|
||||
await CleanupComponentsAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
// 进入销毁阶段
|
||||
logger.Info("Starting architecture destruction");
|
||||
EnterPhase(ArchitecturePhase.Destroying);
|
||||
|
||||
// 清理所有组件
|
||||
await CleanupComponentsAsync();
|
||||
|
||||
// 销毁服务模块
|
||||
await services.ModuleManager.DestroyAllAsync();
|
||||
|
||||
services.Container.Clear();
|
||||
|
||||
// 进入已销毁阶段
|
||||
EnterPhase(ArchitecturePhase.Destroyed);
|
||||
logger.Info("Architecture destruction completed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有已注册的可销毁组件
|
||||
/// </summary>
|
||||
private async ValueTask CleanupComponentsAsync()
|
||||
{
|
||||
// 销毁所有实现了 IAsyncDestroyable 或 IDestroyable 的组件(按注册逆序销毁)
|
||||
logger.Info($"Destroying {_disposables.Count} disposable components");
|
||||
|
||||
for (var i = _disposables.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var component = _disposables[i];
|
||||
try
|
||||
{
|
||||
logger.Debug($"Destroying component: {component.GetType().Name}");
|
||||
|
||||
// 优先使用异步销毁
|
||||
if (component is IAsyncDestroyable asyncDestroyable)
|
||||
{
|
||||
await asyncDestroyable.DestroyAsync();
|
||||
}
|
||||
else if (component is IDestroyable destroyable)
|
||||
{
|
||||
destroyable.Destroy();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error($"Error destroying {component.GetType().Name}", ex);
|
||||
// 继续销毁其他组件,不会因为一个组件失败而中断
|
||||
}
|
||||
}
|
||||
|
||||
_disposables.Clear();
|
||||
_disposableSet.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -14,8 +14,7 @@ internal sealed class ArchitectureModules(
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册中介行为管道
|
||||
/// 用于配置Mediator框架的行为拦截和处理逻辑。
|
||||
/// 支持开放泛型行为类型和针对单一请求的封闭行为类型。
|
||||
/// 用于配置Mediator框架的行为拦截和处理逻辑
|
||||
/// </summary>
|
||||
/// <typeparam name="TBehavior">行为类型,必须是引用类型</typeparam>
|
||||
public void RegisterMediatorBehavior<TBehavior>() where TBehavior : class
|
||||
|
||||
@ -1,116 +0,0 @@
|
||||
using GFramework.Core.Abstractions.Architectures;
|
||||
using GFramework.Core.Abstractions.Enums;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
|
||||
namespace GFramework.Core.Architectures;
|
||||
|
||||
/// <summary>
|
||||
/// 负责架构阶段流转的验证与通知。
|
||||
/// 该类型集中管理阶段值、生命周期钩子和阶段监听器,避免 <see cref="ArchitectureLifecycle" />
|
||||
/// 同时承担阶段广播与组件初始化队列管理两类职责。
|
||||
/// </summary>
|
||||
internal sealed class ArchitecturePhaseCoordinator(
|
||||
IArchitecture architecture,
|
||||
IArchitectureConfiguration configuration,
|
||||
IArchitectureServices services,
|
||||
ILogger logger)
|
||||
{
|
||||
private readonly List<IArchitectureLifecycleHook> _lifecycleHooks = [];
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前架构阶段。
|
||||
/// </summary>
|
||||
public ArchitecturePhase CurrentPhase { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 在架构阶段变更时触发。
|
||||
/// 该事件用于测试和扩展场景,保持现有公共行为不变。
|
||||
/// </summary>
|
||||
public event Action<ArchitecturePhase>? PhaseChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 注册一个生命周期钩子。
|
||||
/// 就绪后是否允许追加注册由架构配置控制,以保证阶段回调的一致性。
|
||||
/// </summary>
|
||||
/// <param name="hook">要注册的生命周期钩子。</param>
|
||||
/// <returns>原样返回注册的钩子实例,便于链式调用或测试断言。</returns>
|
||||
public IArchitectureLifecycleHook RegisterLifecycleHook(IArchitectureLifecycleHook hook)
|
||||
{
|
||||
if (CurrentPhase >= ArchitecturePhase.Ready && !configuration.ArchitectureProperties.AllowLateRegistration)
|
||||
throw new InvalidOperationException("Cannot register lifecycle hook after architecture is Ready");
|
||||
|
||||
_lifecycleHooks.Add(hook);
|
||||
return hook;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 进入指定阶段并广播给所有阶段消费者。
|
||||
/// 顺序保持为“更新阶段值 → 生命周期钩子 → 容器中的阶段监听器 → 外部事件”,
|
||||
/// 以兼容既有调用约定。
|
||||
/// </summary>
|
||||
/// <param name="next">目标阶段。</param>
|
||||
public void EnterPhase(ArchitecturePhase next)
|
||||
{
|
||||
ValidatePhaseTransition(next);
|
||||
|
||||
var previousPhase = CurrentPhase;
|
||||
CurrentPhase = next;
|
||||
|
||||
if (previousPhase != next)
|
||||
logger.Info($"Architecture phase changed: {previousPhase} -> {next}");
|
||||
|
||||
NotifyLifecycleHooks(next);
|
||||
NotifyPhaseListeners(next);
|
||||
PhaseChanged?.Invoke(next);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据配置验证阶段迁移是否合法。
|
||||
/// 在关闭严格校验时直接放行,以兼容对阶段流转有特殊需求的宿主。
|
||||
/// </summary>
|
||||
/// <param name="next">目标阶段。</param>
|
||||
/// <exception cref="InvalidOperationException">当迁移不在允许集合中时抛出。</exception>
|
||||
private void ValidatePhaseTransition(ArchitecturePhase next)
|
||||
{
|
||||
if (!configuration.ArchitectureProperties.StrictPhaseValidation)
|
||||
return;
|
||||
|
||||
if (next == ArchitecturePhase.FailedInitialization)
|
||||
return;
|
||||
|
||||
if (ArchitectureConstants.PhaseTransitions.TryGetValue(CurrentPhase, out var allowed) && allowed.Contains(next))
|
||||
return;
|
||||
|
||||
var errorMessage = $"Invalid phase transition: {CurrentPhase} -> {next}";
|
||||
logger.Fatal(errorMessage);
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通知所有生命周期钩子当前阶段已变更。
|
||||
/// 生命周期钩子通常承载注册表装配等框架扩展逻辑,因此优先于普通阶段监听器执行。
|
||||
/// </summary>
|
||||
/// <param name="phase">当前阶段。</param>
|
||||
private void NotifyLifecycleHooks(ArchitecturePhase phase)
|
||||
{
|
||||
foreach (var hook in _lifecycleHooks)
|
||||
{
|
||||
hook.OnPhase(phase, architecture);
|
||||
logger.Trace($"Notifying lifecycle hook {hook.GetType().Name} of phase {phase}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通知容器中的阶段监听器当前阶段已变更。
|
||||
/// 这些对象通常是系统、模型或工具本身,依赖容器解析保证通知范围与运行时实例一致。
|
||||
/// </summary>
|
||||
/// <param name="phase">当前阶段。</param>
|
||||
private void NotifyPhaseListeners(ArchitecturePhase phase)
|
||||
{
|
||||
foreach (var listener in services.Container.GetAll<IArchitecturePhaseListener>())
|
||||
{
|
||||
logger.Trace($"Notifying phase-aware object {listener.GetType().Name} of phase change to {phase}");
|
||||
listener.OnArchitecturePhase(phase);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -7,12 +7,6 @@ namespace GFramework.Core.Coroutine;
|
||||
/// </summary>
|
||||
internal class CoroutineMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// 协程所属调度器的执行阶段。
|
||||
/// 该值用于诊断等待语义是否与当前宿主阶段匹配。
|
||||
/// </summary>
|
||||
public CoroutineExecutionStage ExecutionStage;
|
||||
|
||||
/// <summary>
|
||||
/// 协程的分组标识符,用于批量管理协程
|
||||
/// </summary>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -7,18 +7,6 @@ namespace GFramework.Core.Coroutine;
|
||||
/// </summary>
|
||||
internal sealed class CoroutineSlot
|
||||
{
|
||||
/// <summary>
|
||||
/// 由外部取消令牌创建的注册。
|
||||
/// 调度器在协程结束时必须释放该注册,避免泄漏取消回调。
|
||||
/// </summary>
|
||||
public CancellationTokenRegistration CancellationRegistration;
|
||||
|
||||
/// <summary>
|
||||
/// 创建该协程时传入的取消令牌。
|
||||
/// 当协程启动子协程时,会把同一个取消令牌继续传递下去,以保持父子协程的取消语义一致。
|
||||
/// </summary>
|
||||
public CancellationToken CancellationToken;
|
||||
|
||||
/// <summary>
|
||||
/// 协程枚举器,包含协程的执行逻辑
|
||||
/// </summary>
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
using GFramework.Core.Abstractions.Coroutine;
|
||||
|
||||
namespace GFramework.Core.Coroutine;
|
||||
|
||||
/// <summary>
|
||||
/// 表示某个活跃协程在调度器中的只读运行快照。
|
||||
/// </summary>
|
||||
/// <param name="Handle">协程句柄。</param>
|
||||
/// <param name="State">当前协程状态。</param>
|
||||
/// <param name="Priority">当前协程优先级。</param>
|
||||
/// <param name="Tag">可选标签。</param>
|
||||
/// <param name="Group">可选分组。</param>
|
||||
/// <param name="StartTimeMs">协程启动时间,单位为毫秒。</param>
|
||||
/// <param name="IsWaiting">当前是否正被等待指令阻塞。</param>
|
||||
/// <param name="WaitingInstructionType">
|
||||
/// 当前等待指令的具体类型。
|
||||
/// 若协程当前未处于等待状态,则该值为 <see langword="null" />。
|
||||
/// </param>
|
||||
/// <param name="ExecutionStage">所属调度器的执行阶段。</param>
|
||||
public readonly record struct CoroutineSnapshot(
|
||||
CoroutineHandle Handle,
|
||||
CoroutineState State,
|
||||
CoroutinePriority Priority,
|
||||
string? Tag,
|
||||
string? Group,
|
||||
double StartTimeMs,
|
||||
bool IsWaiting,
|
||||
Type? WaitingInstructionType,
|
||||
CoroutineExecutionStage ExecutionStage);
|
||||
@ -311,9 +311,7 @@ public class MicrosoftDiContainer(IServiceCollection? serviceCollection = null)
|
||||
|
||||
/// <summary>
|
||||
/// 注册中介行为管道
|
||||
/// 用于配置Mediator框架的行为拦截和处理逻辑。
|
||||
/// 同时支持开放泛型行为类型和已闭合的具体行为类型,
|
||||
/// 以兼容通用行为和针对单一请求的专用行为两种注册方式。
|
||||
/// 用于配置Mediator框架的行为拦截和处理逻辑
|
||||
/// </summary>
|
||||
/// <typeparam name="TBehavior">行为类型,必须是引用类型</typeparam>
|
||||
public void RegisterMediatorBehavior<TBehavior>() where TBehavior : class
|
||||
@ -323,35 +321,12 @@ public class MicrosoftDiContainer(IServiceCollection? serviceCollection = null)
|
||||
{
|
||||
ThrowIfFrozen();
|
||||
|
||||
var behaviorType = typeof(TBehavior);
|
||||
GetServicesUnsafe.AddSingleton(
|
||||
typeof(IPipelineBehavior<,>),
|
||||
typeof(TBehavior)
|
||||
);
|
||||
|
||||
if (behaviorType.IsGenericTypeDefinition)
|
||||
{
|
||||
GetServicesUnsafe.AddSingleton(typeof(IPipelineBehavior<,>), behaviorType);
|
||||
}
|
||||
else
|
||||
{
|
||||
var pipelineInterfaces = behaviorType
|
||||
.GetInterfaces()
|
||||
.Where(type => type.IsGenericType &&
|
||||
type.GetGenericTypeDefinition() == typeof(IPipelineBehavior<,>))
|
||||
.ToList();
|
||||
|
||||
if (pipelineInterfaces.Count == 0)
|
||||
{
|
||||
var errorMessage = $"{behaviorType.Name} does not implement IPipelineBehavior<,>";
|
||||
_logger.Error(errorMessage);
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
// 为每个已闭合的管道接口建立显式映射,支持针对特定请求/响应的专用行为。
|
||||
foreach (var pipelineInterface in pipelineInterfaces)
|
||||
{
|
||||
GetServicesUnsafe.AddSingleton(pipelineInterface, behaviorType);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Debug($"Mediator behavior registered: {behaviorType.Name}");
|
||||
_logger.Debug($"Mediator behavior registered: {typeof(TBehavior).Name}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@ -14,47 +14,22 @@
|
||||
namespace GFramework.Game.Abstractions.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 数据仓库配置选项。
|
||||
/// 数据仓库配置选项
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该选项描述的是仓库层的公开行为契约,而不是某一种固定的落盘格式。
|
||||
/// 因此不同实现可以分别使用“每项单文件”或“统一聚合文件”存储,只要对外遵守同一套备份与事件语义:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <see cref="AutoBackup" /> 在覆盖已有持久化数据前保留上一份可恢复快照。对于聚合型仓库,备份粒度是整个统一文件。
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <see cref="EnableEvents" /> 仅控制公开仓库操作产生的事件;内部缓存预热、迁移回写或批量保存中的子步骤不会额外发出单项事件。
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public class DataRepositoryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置仓库使用的基础存储路径。
|
||||
/// 存储基础路径(如 "user://data/")
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 具体实现会在该路径下组织自己的键空间。调用方应将其视为仓库级根目录,而不是具体文件名。
|
||||
/// </remarks>
|
||||
public string BasePath { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置是否在覆盖已有持久化数据前自动创建备份。
|
||||
/// 是否在保存时自动备份
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该选项只影响覆盖写入;首次写入不会生成备份。聚合型仓库会为统一文件创建单份备份,而不是为内部 section 分别备份。
|
||||
/// </remarks>
|
||||
public bool AutoBackup { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置是否启用仓库层加载、保存、删除与批量保存事件。
|
||||
/// 是否启用加载/保存事件
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 当该值为 <see langword="true" /> 时,<c>SaveAllAsync</c> 只会发出批量事件,不会重复发出每个条目的单项保存事件。
|
||||
/// </remarks>
|
||||
public bool EnableEvents { get; set; } = true;
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
// Copyright (c) 2026 GeWuYou
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System;
|
||||
|
||||
namespace GFramework.Game.Abstractions.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 定义存档数据迁移接口,用于将旧版本存档升级到较新的版本。
|
||||
/// </summary>
|
||||
/// <typeparam name="TSaveData">
|
||||
/// 存档数据类型。该类型通常需要实现 <see cref="IVersionedData" />,
|
||||
/// 以便仓库在加载时判断当前版本并串联迁移链。
|
||||
/// </typeparam>
|
||||
public interface ISaveMigration<TSaveData>
|
||||
where TSaveData : class, IData
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取迁移前的版本号。
|
||||
/// </summary>
|
||||
int FromVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取迁移后的目标版本号。
|
||||
/// </summary>
|
||||
int ToVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 将旧版本存档转换为新版本存档。
|
||||
/// </summary>
|
||||
/// <param name="oldData">待升级的旧版本存档数据。</param>
|
||||
/// <returns>迁移完成后的存档数据。</returns>
|
||||
TSaveData Migrate(TSaveData oldData);
|
||||
}
|
||||
@ -11,9 +11,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using GFramework.Core.Abstractions.Utility;
|
||||
|
||||
namespace GFramework.Game.Abstractions.Data;
|
||||
@ -25,20 +22,6 @@ namespace GFramework.Game.Abstractions.Data;
|
||||
public interface ISaveRepository<TSaveData> : IUtility
|
||||
where TSaveData : class, IData, new()
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册存档迁移器。
|
||||
/// </summary>
|
||||
/// <param name="migration">
|
||||
/// 负责将某个旧版本存档升级到新版本的迁移器。
|
||||
/// 仅当 <typeparamref name="TSaveData" /> 实现 <see cref="IVersionedData" /> 时该功能才有效。
|
||||
/// </param>
|
||||
/// <returns>当前存档仓库实例,便于链式注册多个迁移器。</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="migration" /> 为 <see langword="null" />。</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// <typeparamref name="TSaveData" /> 未实现 <see cref="IVersionedData" />,因此无法建立版本迁移管线。
|
||||
/// </exception>
|
||||
ISaveRepository<TSaveData> RegisterMigration(ISaveMigration<TSaveData> migration);
|
||||
|
||||
/// <summary>
|
||||
/// 检查指定槽位是否存在存档
|
||||
/// </summary>
|
||||
|
||||
@ -1,155 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using GFramework.Core.Architectures;
|
||||
using GFramework.Game.Config;
|
||||
using GFramework.Game.Config.Generated;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GFramework.Game.Tests.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 验证在 <see cref="Architecture" /> 初始化流程中可以注册配置注册表、执行加载并通过生成的表访问器读取数据。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ArchitectureConfigIntegrationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 架构初始化期间,通过 <see cref="YamlConfigLoader" /> 注册生成表,
|
||||
/// 并将 <see cref="ConfigRegistry" /> 作为 utility 暴露给架构上下文读取。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task ConfigLoaderCanRunDuringArchitectureInitialization()
|
||||
{
|
||||
var rootPath = CreateTempConfigRoot();
|
||||
ConsumerArchitecture? architecture = null;
|
||||
var initialized = false;
|
||||
try
|
||||
{
|
||||
architecture = new ConsumerArchitecture(rootPath);
|
||||
await architecture.InitializeAsync();
|
||||
initialized = true;
|
||||
|
||||
var table = architecture.MonsterTable;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
|
||||
Assert.That(table.Get(2).Hp, Is.EqualTo(30));
|
||||
Assert.That(table.FindByFaction("dungeon").Select(static config => config.Name),
|
||||
Is.EquivalentTo(new[] { "Slime", "Goblin" }));
|
||||
Assert.That(architecture.Registry.TryGetMonsterTable(out var retrieved), Is.True);
|
||||
Assert.That(retrieved, Is.Not.Null);
|
||||
Assert.That(retrieved!.Get(1).Name, Is.EqualTo("Slime"));
|
||||
Assert.That(architecture.Context.GetUtility<ConfigRegistry>(), Is.SameAs(architecture.Registry));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (architecture is not null && initialized)
|
||||
{
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
DeleteDirectoryIfExists(rootPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateTempConfigRoot()
|
||||
{
|
||||
var rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigArchitecture", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(rootPath);
|
||||
Directory.CreateDirectory(Path.Combine(rootPath, "schemas"));
|
||||
Directory.CreateDirectory(Path.Combine(rootPath, "monster"));
|
||||
File.WriteAllText(Path.Combine(rootPath, "schemas", "monster.schema.json"), MonsterSchemaJson);
|
||||
File.WriteAllText(Path.Combine(rootPath, "monster", "slime.yaml"), MonsterSlimeYaml);
|
||||
File.WriteAllText(Path.Combine(rootPath, "monster", "goblin.yaml"), MonsterGoblinYaml);
|
||||
return rootPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 最佳努力尝试删除临时目录。
|
||||
/// </summary>
|
||||
private static void DeleteDirectoryIfExists(string path)
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignored: cleanup is best effort and should not fail the test.
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Ignored: cleanup is best effort and can transiently fail when files are still being released.
|
||||
}
|
||||
}
|
||||
|
||||
private const string MonsterSchemaJson = @"{
|
||||
""title"": ""Monster Config"",
|
||||
""description"": ""Defines one monster entry for the generated consumer integration test."",
|
||||
""type"": ""object"",
|
||||
""required"": [
|
||||
""id"",
|
||||
""name"",
|
||||
""hp"",
|
||||
""faction""
|
||||
],
|
||||
""properties"": {
|
||||
""id"": {
|
||||
""type"": ""integer"",
|
||||
""description"": ""Monster identifier.""
|
||||
},
|
||||
""name"": {
|
||||
""type"": ""string"",
|
||||
""description"": ""Monster display name.""
|
||||
},
|
||||
""hp"": {
|
||||
""type"": ""integer"",
|
||||
""description"": ""Monster base health.""
|
||||
},
|
||||
""faction"": {
|
||||
""type"": ""string"",
|
||||
""description"": ""Used by the integration test to validate generated non-unique queries.""
|
||||
}
|
||||
}
|
||||
}";
|
||||
|
||||
private const string MonsterSlimeYaml =
|
||||
"id: 1\nname: Slime\nhp: 10\nfaction: dungeon\n";
|
||||
|
||||
private const string MonsterGoblinYaml =
|
||||
"id: 2\nname: Goblin\nhp: 30\nfaction: dungeon\n";
|
||||
|
||||
private sealed class ConsumerArchitecture : Architecture
|
||||
{
|
||||
private readonly string _configRoot;
|
||||
|
||||
public ConfigRegistry Registry { get; }
|
||||
|
||||
public MonsterTable MonsterTable { get; private set; } = null!;
|
||||
|
||||
public ConsumerArchitecture(string configRoot)
|
||||
{
|
||||
_configRoot = configRoot ?? throw new ArgumentNullException(nameof(configRoot));
|
||||
Registry = new ConfigRegistry();
|
||||
}
|
||||
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
RegisterUtility(Registry);
|
||||
|
||||
var loader = new YamlConfigLoader(_configRoot)
|
||||
.RegisterMonsterTable();
|
||||
loader.LoadAsync(Registry).GetAwaiter().GetResult();
|
||||
MonsterTable = Registry.GetMonsterTable();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,4 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using GFramework.Game.Config;
|
||||
using GFramework.Game.Config.Generated;
|
||||
|
||||
@ -8,7 +6,7 @@ namespace GFramework.Game.Tests.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 验证消费者项目通过 `schemas/**/*.schema.json` 自动拾取 schema 后,
|
||||
/// 可以直接编译并使用生成的注册辅助、强类型访问入口、查询辅助与运行时加载链路。
|
||||
/// 可以直接编译并使用生成的注册辅助、强类型访问入口与运行时加载链路。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class GeneratedConfigConsumerIntegrationTests
|
||||
@ -39,7 +37,7 @@ public class GeneratedConfigConsumerIntegrationTests
|
||||
|
||||
/// <summary>
|
||||
/// 验证生成器自动拾取消费者项目的 schema 后,
|
||||
/// 可以用生成的注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。
|
||||
/// 可以用生成的注册辅助完成加载,并通过强类型表包装访问运行时数据。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task LoadAsync_Should_Support_Generated_Bindings_In_Consumer_Project()
|
||||
@ -51,7 +49,7 @@ public class GeneratedConfigConsumerIntegrationTests
|
||||
"title": "Monster Config",
|
||||
"description": "Defines one monster entry for the end-to-end consumer integration test.",
|
||||
"type": "object",
|
||||
"required": ["id", "name", "hp", "faction"],
|
||||
"required": ["id", "name", "hp"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
@ -64,10 +62,6 @@ public class GeneratedConfigConsumerIntegrationTests
|
||||
"hp": {
|
||||
"type": "integer",
|
||||
"description": "Monster base health."
|
||||
},
|
||||
"faction": {
|
||||
"type": "string",
|
||||
"description": "Used by the integration test to validate generated non-unique queries."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -78,7 +72,6 @@ public class GeneratedConfigConsumerIntegrationTests
|
||||
id: 1
|
||||
name: Slime
|
||||
hp: 10
|
||||
faction: dungeon
|
||||
""");
|
||||
CreateFile(
|
||||
"monster/goblin.yaml",
|
||||
@ -86,7 +79,6 @@ public class GeneratedConfigConsumerIntegrationTests
|
||||
id: 2
|
||||
name: Goblin
|
||||
hp: 30
|
||||
faction: dungeon
|
||||
""");
|
||||
|
||||
var registry = new ConfigRegistry();
|
||||
@ -96,35 +88,15 @@ public class GeneratedConfigConsumerIntegrationTests
|
||||
await loader.LoadAsync(registry);
|
||||
|
||||
var table = registry.GetMonsterTable();
|
||||
var dungeonMonsters = table.FindByFaction("dungeon");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(MonsterConfigBindings.ConfigDomain, Is.EqualTo("monster"));
|
||||
Assert.That(MonsterConfigBindings.TableName, Is.EqualTo("monster"));
|
||||
Assert.That(MonsterConfigBindings.ConfigRelativePath, Is.EqualTo("monster"));
|
||||
Assert.That(MonsterConfigBindings.SchemaRelativePath, Is.EqualTo("schemas/monster.schema.json"));
|
||||
Assert.That(MonsterConfigBindings.Metadata.ConfigDomain, Is.EqualTo(MonsterConfigBindings.ConfigDomain));
|
||||
Assert.That(MonsterConfigBindings.Metadata.TableName, Is.EqualTo(MonsterConfigBindings.TableName));
|
||||
Assert.That(MonsterConfigBindings.Metadata.ConfigRelativePath,
|
||||
Is.EqualTo(MonsterConfigBindings.ConfigRelativePath));
|
||||
Assert.That(MonsterConfigBindings.Metadata.SchemaRelativePath,
|
||||
Is.EqualTo(MonsterConfigBindings.SchemaRelativePath));
|
||||
Assert.That(MonsterConfigBindings.References.All, Is.Empty);
|
||||
Assert.That(MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out _), Is.False);
|
||||
Assert.That(table.Count, Is.EqualTo(2));
|
||||
Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
|
||||
Assert.That(table.Get(2).Hp, Is.EqualTo(30));
|
||||
Assert.That(table.FindByName("Slime").Select(static config => config.Id), Is.EqualTo(new[] { 1 }));
|
||||
Assert.That(dungeonMonsters.Select(static config => config.Name), Is.EquivalentTo(new[] { "Slime", "Goblin" }));
|
||||
Assert.That(table.TryFindFirstByName("Goblin", out var goblin), Is.True);
|
||||
Assert.That(goblin, Is.Not.Null);
|
||||
Assert.That(goblin!.Id, Is.EqualTo(2));
|
||||
Assert.That(table.TryFindFirstByFaction("dungeon", out var firstDungeonMonster), Is.True);
|
||||
Assert.That(firstDungeonMonster, Is.Not.Null);
|
||||
Assert.That(firstDungeonMonster!.Name, Is.AnyOf("Slime", "Goblin"));
|
||||
Assert.That(table.TryFindFirstByFaction("forest", out var missingMonster), Is.False);
|
||||
Assert.That(missingMonster, Is.Null);
|
||||
Assert.That(registry.TryGetMonsterTable(out var generatedTable), Is.True);
|
||||
Assert.That(generatedTable, Is.Not.Null);
|
||||
Assert.That(generatedTable!.All().Select(static config => config.Name),
|
||||
|
||||
@ -71,68 +71,6 @@ public class YamlConfigLoaderTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证加载器支持通过选项对象注册带 schema 校验的配置表。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task RegisterTable_Should_Support_Options_Object()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
hp: 10
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"hp": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable(
|
||||
new YamlConfigTableRegistrationOptions<int, MonsterConfigStub>(
|
||||
"monster",
|
||||
"monster",
|
||||
static config => config.Id)
|
||||
{
|
||||
SchemaRelativePath = "schemas/monster.schema.json"
|
||||
});
|
||||
var registry = new ConfigRegistry();
|
||||
|
||||
await loader.LoadAsync(registry);
|
||||
|
||||
var table = registry.GetTable<int, MonsterConfigStub>("monster");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(table.Count, Is.EqualTo(1));
|
||||
Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
|
||||
Assert.That(table.Get(1).Hp, Is.EqualTo(10));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证加载器会拒绝空的配置表注册选项对象。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void RegisterTable_Should_Throw_When_Options_Are_Null()
|
||||
{
|
||||
var loader = new YamlConfigLoader(_rootPath);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
loader.RegisterTable<int, MonsterConfigStub>(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证注册的配置目录不存在时会抛出清晰错误。
|
||||
/// </summary>
|
||||
@ -1061,98 +999,6 @@ public class YamlConfigLoaderTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证热重载支持通过选项对象配置回调和防抖延迟。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task EnableHotReload_Should_Support_Options_Object()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
hp: 10
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"hp": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable(
|
||||
new YamlConfigTableRegistrationOptions<int, MonsterConfigStub>(
|
||||
"monster",
|
||||
"monster",
|
||||
static config => config.Id)
|
||||
{
|
||||
SchemaRelativePath = "schemas/monster.schema.json"
|
||||
});
|
||||
var registry = new ConfigRegistry();
|
||||
await loader.LoadAsync(registry);
|
||||
|
||||
var reloadTaskSource = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var hotReload = loader.EnableHotReload(
|
||||
registry,
|
||||
new YamlConfigHotReloadOptions
|
||||
{
|
||||
OnTableReloaded = tableName => reloadTaskSource.TrySetResult(tableName),
|
||||
DebounceDelay = TimeSpan.FromMilliseconds(150)
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
hp: 25
|
||||
""");
|
||||
|
||||
var tableName = await WaitForTaskWithinAsync(reloadTaskSource.Task, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(tableName, Is.EqualTo("monster"));
|
||||
Assert.That(registry.GetTable<int, MonsterConfigStub>("monster").Get(1).Hp, Is.EqualTo(25));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
hotReload.UnRegister();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证热重载会在启动前拒绝负的防抖延迟,避免后台延迟任务才暴露参数错误。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void EnableHotReload_Should_Throw_When_Debounce_Delay_Is_Negative()
|
||||
{
|
||||
var loader = new YamlConfigLoader(_rootPath);
|
||||
var registry = new ConfigRegistry();
|
||||
|
||||
var exception = Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
loader.EnableHotReload(
|
||||
registry,
|
||||
new YamlConfigHotReloadOptions
|
||||
{
|
||||
DebounceDelay = TimeSpan.FromMilliseconds(-1)
|
||||
}));
|
||||
|
||||
Assert.That(exception!.ParamName, Is.EqualTo("options"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。
|
||||
/// </summary>
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
using GFramework.Game.Config;
|
||||
|
||||
namespace GFramework.Game.Tests.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 YAML 配置表注册选项会在构造阶段建立最小不变量,避免非法路径状态继续向后传播。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class YamlConfigTableRegistrationOptionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证构造函数会拒绝空的或仅空白字符的表名。
|
||||
/// </summary>
|
||||
/// <param name="tableName">待验证的表名。</param>
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
public void Constructor_Should_Throw_When_Table_Name_Is_Null_Or_Whitespace(string? tableName)
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentException>(() =>
|
||||
_ = new YamlConfigTableRegistrationOptions<int, string>(
|
||||
tableName!,
|
||||
"monster",
|
||||
static config => config.Length));
|
||||
|
||||
Assert.That(exception!.ParamName, Is.EqualTo("tableName"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证构造函数会拒绝空的或仅空白字符的相对目录路径。
|
||||
/// </summary>
|
||||
/// <param name="relativePath">待验证的相对目录路径。</param>
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
public void Constructor_Should_Throw_When_Relative_Path_Is_Null_Or_Whitespace(string? relativePath)
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentException>(() =>
|
||||
_ = new YamlConfigTableRegistrationOptions<int, string>(
|
||||
"monster",
|
||||
relativePath!,
|
||||
static config => config.Length));
|
||||
|
||||
Assert.That(exception!.ParamName, Is.EqualTo("relativePath"));
|
||||
}
|
||||
}
|
||||
@ -1,114 +1,20 @@
|
||||
using System;
|
||||
using GFramework.Game.Abstractions.Data;
|
||||
using GFramework.Game.Abstractions.Enums;
|
||||
|
||||
namespace GFramework.Game.Tests.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 为持久化测试提供稳定的测试数据位置实现。
|
||||
/// </summary>
|
||||
internal sealed class TestDataLocation : IDataLocation
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化测试数据位置。
|
||||
/// </summary>
|
||||
/// <param name="key">测试使用的存储键。</param>
|
||||
/// <param name="kinds">测试使用的存储类型。</param>
|
||||
/// <param name="namespaceValue">测试使用的命名空间。</param>
|
||||
/// <param name="metadata">附加测试元数据。</param>
|
||||
public TestDataLocation(
|
||||
string key,
|
||||
StorageKinds kinds = StorageKinds.Local,
|
||||
string? namespaceValue = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
Key = key;
|
||||
Kinds = kinds;
|
||||
Namespace = namespaceValue;
|
||||
Metadata = metadata;
|
||||
}
|
||||
internal sealed record TestDataLocation(
|
||||
string Key,
|
||||
StorageKinds Kinds = StorageKinds.Local,
|
||||
string? Namespace = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null) : IDataLocation;
|
||||
|
||||
/// <summary>
|
||||
/// 获取测试数据对应的存储键。
|
||||
/// </summary>
|
||||
public string Key { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取测试数据使用的存储类型。
|
||||
/// </summary>
|
||||
public StorageKinds Kinds { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取测试数据使用的命名空间。
|
||||
/// </summary>
|
||||
public string? Namespace { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取附加到测试位置上的元数据。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为基础存档仓库测试提供的简单存档模型。
|
||||
/// </summary>
|
||||
internal sealed class TestSaveData : IData
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置测试存档中的名称字段。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为存档迁移测试提供的版本化存档模型。
|
||||
/// </summary>
|
||||
internal sealed class TestVersionedSaveData : IVersionedData
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置测试存档中的名称字段。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置测试存档中的等级字段。
|
||||
/// </summary>
|
||||
public int Level { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置测试存档中的经验字段。
|
||||
/// </summary>
|
||||
public int Experience { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置当前测试存档的版本号。
|
||||
/// </summary>
|
||||
public int Version { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置测试存档的最后修改时间。
|
||||
/// </summary>
|
||||
public DateTime LastModified { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为通用持久化测试提供的简单数据模型。
|
||||
/// </summary>
|
||||
internal sealed class TestSimpleData : IData
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置测试数据中的整数值。
|
||||
/// </summary>
|
||||
public int Value { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为批量持久化测试提供的另一种数据模型,用于验证运行时类型不会在接口路径上退化。
|
||||
/// </summary>
|
||||
internal sealed class TestNamedData : IData
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置测试数据中的名称值。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@ -1,21 +1,11 @@
|
||||
using System.IO;
|
||||
using GFramework.Core.Abstractions.Events;
|
||||
using GFramework.Core.Abstractions.Rule;
|
||||
using GFramework.Core.Abstractions.Storage;
|
||||
using GFramework.Core.Architectures;
|
||||
using GFramework.Core.Events;
|
||||
using GFramework.Core.Ioc;
|
||||
using GFramework.Game.Abstractions.Data;
|
||||
using GFramework.Game.Abstractions.Data.Events;
|
||||
using GFramework.Game.Data;
|
||||
using GFramework.Game.Serializer;
|
||||
using GFramework.Game.Storage;
|
||||
|
||||
namespace GFramework.Game.Tests.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖文件存储、槽位存档仓库和统一设置仓库的持久化行为测试。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class PersistenceTests
|
||||
{
|
||||
@ -26,10 +16,6 @@ public class PersistenceTests
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证文件存储能够持久化数据,并拒绝包含路径逃逸的非法键。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task FileStorage_PersistsDataAndRejectsIllegalKeys()
|
||||
{
|
||||
@ -45,10 +31,6 @@ public class PersistenceTests
|
||||
Assert.ThrowsAsync<ArgumentException>(async () => await storage.WriteAsync("../escape", new TestSimpleData()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证槽位存档仓库的保存、加载、列举和删除行为。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task SaveRepository_ManagesSlots()
|
||||
{
|
||||
@ -77,121 +59,6 @@ public class PersistenceTests
|
||||
Assert.That(await repository.ExistsAsync(1), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证存档仓库在加载旧版本数据时会执行迁移链并回写升级后的最新版本。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task SaveRepository_LoadAsync_Should_Apply_Migrations_And_Persist_Upgraded_Save()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
using var storage = new FileStorage(root, new JsonSerializer());
|
||||
var config = new SaveConfiguration
|
||||
{
|
||||
SaveRoot = "saves",
|
||||
SaveSlotPrefix = "slot_",
|
||||
SaveFileName = "save"
|
||||
};
|
||||
|
||||
var writer = new SaveRepository<TestVersionedSaveData>(storage, config);
|
||||
await writer.SaveAsync(1, new TestVersionedSaveData
|
||||
{
|
||||
Name = "hero",
|
||||
Level = 5,
|
||||
Experience = 0,
|
||||
Version = 1
|
||||
});
|
||||
|
||||
var repository = new SaveRepository<TestVersionedSaveData>(storage, config)
|
||||
.RegisterMigration(new TestSaveMigrationV1ToV2())
|
||||
.RegisterMigration(new TestSaveMigrationV2ToV3());
|
||||
|
||||
var loaded = await repository.LoadAsync(1);
|
||||
var persisted = await storage.ReadAsync<TestVersionedSaveData>("saves/slot_1/save");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(loaded.Version, Is.EqualTo(3));
|
||||
Assert.That(loaded.Experience, Is.EqualTo(500));
|
||||
Assert.That(loaded.Name, Is.EqualTo("hero-v2"));
|
||||
Assert.That(persisted.Version, Is.EqualTo(3));
|
||||
Assert.That(persisted.Experience, Is.EqualTo(500));
|
||||
Assert.That(persisted.Name, Is.EqualTo("hero-v2"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证非版本化存档类型不能注册迁移器,避免构建无效迁移管线。
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">当存档类型未实现 <see cref="IVersionedData" /> 时抛出。</exception>
|
||||
[Test]
|
||||
public void SaveRepository_RegisterMigration_For_NonVersioned_Save_Should_Throw()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
using var storage = new FileStorage(root, new JsonSerializer());
|
||||
var config = new SaveConfiguration();
|
||||
var repository = new SaveRepository<TestSaveData>(storage, config);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => repository.RegisterMigration(new TestNonVersionedMigration()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证同一源版本不能重复注册迁移器,避免迁移链配置被静默覆盖。
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">当同一源版本重复注册迁移器时抛出。</exception>
|
||||
[Test]
|
||||
public void SaveRepository_RegisterMigration_Should_Reject_Duplicate_FromVersion()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
using var storage = new FileStorage(root, new JsonSerializer());
|
||||
var config = new SaveConfiguration();
|
||||
var repository = new SaveRepository<TestVersionedSaveData>(storage, config);
|
||||
|
||||
repository.RegisterMigration(new TestSaveMigrationV1ToV2());
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(
|
||||
() => repository.RegisterMigration(new TestDuplicateSaveMigrationV1ToV2()));
|
||||
|
||||
Assert.That(exception!.Message, Does.Contain("Duplicate save migration registration"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当迁移链缺少中间版本时,加载旧存档会明确失败而不是静默返回不完整数据。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
/// <exception cref="InvalidOperationException">当从旧版本到当前版本的迁移链不完整时抛出。</exception>
|
||||
[Test]
|
||||
public async Task SaveRepository_LoadAsync_Should_Throw_When_Migration_Chain_Is_Incomplete()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
using var storage = new FileStorage(root, new JsonSerializer());
|
||||
var config = new SaveConfiguration
|
||||
{
|
||||
SaveRoot = "saves",
|
||||
SaveSlotPrefix = "slot_",
|
||||
SaveFileName = "save"
|
||||
};
|
||||
|
||||
var writer = new SaveRepository<TestVersionedSaveData>(storage, config);
|
||||
await writer.SaveAsync(1, new TestVersionedSaveData
|
||||
{
|
||||
Name = "legacy",
|
||||
Level = 3,
|
||||
Experience = 0,
|
||||
Version = 1
|
||||
});
|
||||
|
||||
var repository = new SaveRepository<TestVersionedSaveData>(storage, config)
|
||||
.RegisterMigration(new TestSaveMigrationV1ToV2());
|
||||
|
||||
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await repository.LoadAsync(1));
|
||||
Assert.That(exception!.Message, Does.Contain("from version 2"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证统一设置仓库能够保存、重新加载并批量读取已注册的设置数据。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task UnifiedSettingsDataRepository_RoundTripsDataAndLoadAll()
|
||||
{
|
||||
@ -201,7 +68,8 @@ public class PersistenceTests
|
||||
var repo = new UnifiedSettingsDataRepository(
|
||||
storage,
|
||||
serializer,
|
||||
new DataRepositoryOptions { EnableEvents = false });
|
||||
new DataRepositoryOptions { EnableEvents = false },
|
||||
"settings.json");
|
||||
|
||||
var location = new TestDataLocation("settings/choice");
|
||||
repo.RegisterDataType(location, typeof(TestSimpleData));
|
||||
@ -213,7 +81,8 @@ public class PersistenceTests
|
||||
var repo2 = new UnifiedSettingsDataRepository(
|
||||
storage2,
|
||||
serializer,
|
||||
new DataRepositoryOptions { EnableEvents = false });
|
||||
new DataRepositoryOptions { EnableEvents = false },
|
||||
"settings.json");
|
||||
repo2.RegisterDataType(location, typeof(TestSimpleData));
|
||||
|
||||
var loaded = await repo2.LoadAsync<TestSimpleData>(location);
|
||||
@ -223,605 +92,4 @@ public class PersistenceTests
|
||||
Assert.That(all.Keys, Contains.Item(location.Key));
|
||||
Assert.That(all[location.Key], Is.TypeOf<TestSimpleData>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证通用数据仓库在覆盖已有数据时会创建备份文件,并保留覆盖前的旧值。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task DataRepository_SaveAsync_Should_Create_Backup_When_Overwriting_Existing_Data()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
using var storage = new FileStorage(root, new JsonSerializer(), ".json");
|
||||
var repository = new DataRepository(
|
||||
storage,
|
||||
new DataRepositoryOptions
|
||||
{
|
||||
AutoBackup = true,
|
||||
EnableEvents = false
|
||||
});
|
||||
var location = new TestDataLocation("options", namespaceValue: "profile");
|
||||
|
||||
await repository.SaveAsync(location, new TestSimpleData { Value = 1 });
|
||||
await repository.SaveAsync(location, new TestSimpleData { Value = 2 });
|
||||
|
||||
var current = await repository.LoadAsync<TestSimpleData>(location);
|
||||
var backup = await storage.ReadAsync<TestSimpleData>("profile/options.backup");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(current.Value, Is.EqualTo(2));
|
||||
Assert.That(backup.Value, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证通用数据仓库的批量保存只发送批量事件,不重复发送单项保存事件。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task DataRepository_SaveAllAsync_Should_Emit_Only_Batch_Event()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
using var storage = new FileStorage(root, new JsonSerializer(), ".json");
|
||||
var repository = new DataRepository(
|
||||
storage,
|
||||
new DataRepositoryOptions
|
||||
{
|
||||
AutoBackup = false,
|
||||
EnableEvents = true
|
||||
});
|
||||
var context = CreateEventContext();
|
||||
((IContextAware)repository).SetContext(context);
|
||||
|
||||
var location1 = new TestDataLocation("graphics", namespaceValue: "settings");
|
||||
var location2 = new TestDataLocation("audio", namespaceValue: "settings");
|
||||
var savedEventCount = 0;
|
||||
var batchEventCount = 0;
|
||||
|
||||
context.RegisterEvent<DataSavedEvent<TestSimpleData>>(_ => savedEventCount++);
|
||||
context.RegisterEvent<DataBatchSavedEvent>(_ => batchEventCount++);
|
||||
|
||||
await repository.SaveAllAsync(
|
||||
[
|
||||
(location1, new TestSimpleData { Value = 10 }),
|
||||
(location2, new TestSimpleData { Value = 20 })
|
||||
]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(savedEventCount, Is.Zero);
|
||||
Assert.That(batchEventCount, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证批量覆盖已有数据时仍会按每个条目的运行时类型执行备份与回写,而不会退化为 <see cref="IData" />。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task DataRepository_SaveAllAsync_Should_Preserve_Runtime_Types_When_Overwriting_Existing_Data()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
using var storage = new FileStorage(root, new JsonSerializer(), ".json");
|
||||
var repository = new DataRepository(
|
||||
storage,
|
||||
new DataRepositoryOptions
|
||||
{
|
||||
AutoBackup = true,
|
||||
EnableEvents = false
|
||||
});
|
||||
var numberLocation = new TestDataLocation("graphics", namespaceValue: "settings");
|
||||
var textLocation = new TestDataLocation("profile", namespaceValue: "settings");
|
||||
|
||||
await repository.SaveAllAsync(
|
||||
[
|
||||
(numberLocation, new TestSimpleData { Value = 1 }),
|
||||
(textLocation, new TestNamedData { Name = "old-name" })
|
||||
]);
|
||||
|
||||
await repository.SaveAllAsync(
|
||||
[
|
||||
(numberLocation, new TestSimpleData { Value = 2 }),
|
||||
(textLocation, new TestNamedData { Name = "new-name" })
|
||||
]);
|
||||
|
||||
var currentNumber = await repository.LoadAsync<TestSimpleData>(numberLocation);
|
||||
var currentText = await repository.LoadAsync<TestNamedData>(textLocation);
|
||||
var backupNumber = await storage.ReadAsync<TestSimpleData>("settings/graphics.backup");
|
||||
var backupText = await storage.ReadAsync<TestNamedData>("settings/profile.backup");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(currentNumber.Value, Is.EqualTo(2));
|
||||
Assert.That(currentText.Name, Is.EqualTo("new-name"));
|
||||
Assert.That(backupNumber.Value, Is.EqualTo(1));
|
||||
Assert.That(backupText.Name, Is.EqualTo("old-name"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证统一设置仓库在批量覆盖时会为整个聚合文件创建备份,并只发送批量事件。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task UnifiedSettingsDataRepository_SaveAllAsync_Should_Create_Backup_And_Emit_Only_Batch_Event()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
var location1 = new TestDataLocation("settings/graphics");
|
||||
var location2 = new TestDataLocation("settings/audio");
|
||||
|
||||
using (var seedStorage = new FileStorage(root, new JsonSerializer(), ".json"))
|
||||
{
|
||||
var seedRepository = new UnifiedSettingsDataRepository(
|
||||
seedStorage,
|
||||
new JsonSerializer(),
|
||||
new DataRepositoryOptions
|
||||
{
|
||||
AutoBackup = true,
|
||||
EnableEvents = false
|
||||
});
|
||||
seedRepository.RegisterDataType(location1, typeof(TestSimpleData));
|
||||
seedRepository.RegisterDataType(location2, typeof(TestSimpleData));
|
||||
|
||||
await seedRepository.SaveAsync(location1, new TestSimpleData { Value = 1 });
|
||||
}
|
||||
|
||||
using var storage = new FileStorage(root, new JsonSerializer(), ".json");
|
||||
var repository = new UnifiedSettingsDataRepository(
|
||||
storage,
|
||||
new JsonSerializer(),
|
||||
new DataRepositoryOptions
|
||||
{
|
||||
AutoBackup = true,
|
||||
EnableEvents = true
|
||||
});
|
||||
repository.RegisterDataType(location1, typeof(TestSimpleData));
|
||||
repository.RegisterDataType(location2, typeof(TestSimpleData));
|
||||
|
||||
var context = CreateEventContext();
|
||||
((IContextAware)repository).SetContext(context);
|
||||
|
||||
var savedEventCount = 0;
|
||||
var batchEventCount = 0;
|
||||
|
||||
context.RegisterEvent<DataSavedEvent<TestSimpleData>>(_ => savedEventCount++);
|
||||
context.RegisterEvent<DataBatchSavedEvent>(_ => batchEventCount++);
|
||||
|
||||
await repository.SaveAllAsync(
|
||||
[
|
||||
(location1, new TestSimpleData { Value = 2 }),
|
||||
(location2, new TestSimpleData { Value = 3 })
|
||||
]);
|
||||
|
||||
var current = await repository.LoadAsync<TestSimpleData>(location1);
|
||||
var backupJson = await File.ReadAllTextAsync(Path.Combine(root, "settings.json.backup.json"));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(current.Value, Is.EqualTo(2));
|
||||
Assert.That(savedEventCount, Is.Zero);
|
||||
Assert.That(batchEventCount, Is.EqualTo(1));
|
||||
Assert.That(backupJson, Does.Contain("settings/graphics"));
|
||||
Assert.That(backupJson, Does.Contain("\\\"Value\\\":1"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证统一设置仓库在删除某个 section 时会回写聚合文件,并保留删除前的统一文件备份。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task UnifiedSettingsDataRepository_DeleteAsync_Should_Persist_Deletion_And_Create_Backup()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
var location1 = new TestDataLocation("settings/graphics");
|
||||
var location2 = new TestDataLocation("settings/audio");
|
||||
|
||||
using (var storage = new FileStorage(root, new JsonSerializer(), ".json"))
|
||||
{
|
||||
var repository = new UnifiedSettingsDataRepository(
|
||||
storage,
|
||||
new JsonSerializer(),
|
||||
new DataRepositoryOptions
|
||||
{
|
||||
AutoBackup = true,
|
||||
EnableEvents = false
|
||||
});
|
||||
repository.RegisterDataType(location1, typeof(TestSimpleData));
|
||||
repository.RegisterDataType(location2, typeof(TestSimpleData));
|
||||
|
||||
await repository.SaveAllAsync(
|
||||
[
|
||||
(location1, new TestSimpleData { Value = 7 }),
|
||||
(location2, new TestSimpleData { Value = 11 })
|
||||
]);
|
||||
}
|
||||
|
||||
using var verifyStorage = new FileStorage(root, new JsonSerializer(), ".json");
|
||||
var verifyRepository = new UnifiedSettingsDataRepository(
|
||||
verifyStorage,
|
||||
new JsonSerializer(),
|
||||
new DataRepositoryOptions
|
||||
{
|
||||
AutoBackup = true,
|
||||
EnableEvents = false
|
||||
});
|
||||
verifyRepository.RegisterDataType(location1, typeof(TestSimpleData));
|
||||
verifyRepository.RegisterDataType(location2, typeof(TestSimpleData));
|
||||
|
||||
await verifyRepository.DeleteAsync(location2);
|
||||
|
||||
var remaining = await verifyRepository.LoadAsync<TestSimpleData>(location1);
|
||||
var removedExists = await verifyRepository.ExistsAsync(location2);
|
||||
var backupJson = await File.ReadAllTextAsync(Path.Combine(root, "settings.json.backup.json"));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(remaining.Value, Is.EqualTo(7));
|
||||
Assert.That(removedExists, Is.False);
|
||||
Assert.That(backupJson, Does.Contain("settings/audio"));
|
||||
Assert.That(backupJson, Does.Contain("\\\"Value\\\":11"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证统一设置仓库在保存提交失败时不会污染内存缓存,并且失败修改不会泄漏到后续无关保存。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task UnifiedSettingsDataRepository_SaveAsync_When_Persist_Fails_Should_Keep_Cache_Consistent()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
var primaryLocation = new TestDataLocation("settings/graphics");
|
||||
var secondaryLocation = new TestDataLocation("settings/audio");
|
||||
|
||||
using (var seedStorage = new FileStorage(root, new JsonSerializer(), ".json"))
|
||||
{
|
||||
var seedRepository = new UnifiedSettingsDataRepository(
|
||||
seedStorage,
|
||||
new JsonSerializer(),
|
||||
new DataRepositoryOptions { EnableEvents = false });
|
||||
seedRepository.RegisterDataType(primaryLocation, typeof(TestSimpleData));
|
||||
seedRepository.RegisterDataType(secondaryLocation, typeof(TestSimpleData));
|
||||
await seedRepository.SaveAsync(primaryLocation, new TestSimpleData { Value = 1 });
|
||||
}
|
||||
|
||||
using var innerStorage = new FileStorage(root, new JsonSerializer(), ".json");
|
||||
var throwingStorage = new ToggleWriteFailureStorage(innerStorage, "settings.json");
|
||||
var repository = new UnifiedSettingsDataRepository(
|
||||
throwingStorage,
|
||||
new JsonSerializer(),
|
||||
new DataRepositoryOptions { EnableEvents = false });
|
||||
repository.RegisterDataType(primaryLocation, typeof(TestSimpleData));
|
||||
repository.RegisterDataType(secondaryLocation, typeof(TestSimpleData));
|
||||
|
||||
throwingStorage.ThrowOnWrite = true;
|
||||
Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await repository.SaveAsync(primaryLocation, new TestSimpleData { Value = 99 }));
|
||||
|
||||
var cachedAfterFailure = await repository.LoadAsync<TestSimpleData>(primaryLocation);
|
||||
Assert.That(cachedAfterFailure.Value, Is.EqualTo(1));
|
||||
|
||||
throwingStorage.ThrowOnWrite = false;
|
||||
await repository.SaveAsync(secondaryLocation, new TestSimpleData { Value = 7 });
|
||||
|
||||
using var verifyStorage = new FileStorage(root, new JsonSerializer(), ".json");
|
||||
var verifyRepository = new UnifiedSettingsDataRepository(
|
||||
verifyStorage,
|
||||
new JsonSerializer(),
|
||||
new DataRepositoryOptions { EnableEvents = false });
|
||||
verifyRepository.RegisterDataType(primaryLocation, typeof(TestSimpleData));
|
||||
verifyRepository.RegisterDataType(secondaryLocation, typeof(TestSimpleData));
|
||||
|
||||
var persistedPrimary = await verifyRepository.LoadAsync<TestSimpleData>(primaryLocation);
|
||||
var persistedSecondary = await verifyRepository.LoadAsync<TestSimpleData>(secondaryLocation);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(persistedPrimary.Value, Is.EqualTo(1));
|
||||
Assert.That(persistedSecondary.Value, Is.EqualTo(7));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证统一设置仓库在删除提交失败时不会把未提交删除留在缓存里,也不会泄漏到后续保存。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task UnifiedSettingsDataRepository_DeleteAsync_When_Persist_Fails_Should_Keep_Cache_Consistent()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
var primaryLocation = new TestDataLocation("settings/graphics");
|
||||
var secondaryLocation = new TestDataLocation("settings/audio");
|
||||
|
||||
using (var seedStorage = new FileStorage(root, new JsonSerializer(), ".json"))
|
||||
{
|
||||
var seedRepository = new UnifiedSettingsDataRepository(
|
||||
seedStorage,
|
||||
new JsonSerializer(),
|
||||
new DataRepositoryOptions { EnableEvents = false });
|
||||
seedRepository.RegisterDataType(primaryLocation, typeof(TestSimpleData));
|
||||
seedRepository.RegisterDataType(secondaryLocation, typeof(TestSimpleData));
|
||||
await seedRepository.SaveAllAsync(
|
||||
[
|
||||
(primaryLocation, new TestSimpleData { Value = 3 }),
|
||||
(secondaryLocation, new TestSimpleData { Value = 5 })
|
||||
]);
|
||||
}
|
||||
|
||||
using var innerStorage = new FileStorage(root, new JsonSerializer(), ".json");
|
||||
var throwingStorage = new ToggleWriteFailureStorage(innerStorage, "settings.json");
|
||||
var repository = new UnifiedSettingsDataRepository(
|
||||
throwingStorage,
|
||||
new JsonSerializer(),
|
||||
new DataRepositoryOptions { EnableEvents = false });
|
||||
repository.RegisterDataType(primaryLocation, typeof(TestSimpleData));
|
||||
repository.RegisterDataType(secondaryLocation, typeof(TestSimpleData));
|
||||
|
||||
throwingStorage.ThrowOnWrite = true;
|
||||
Assert.ThrowsAsync<InvalidOperationException>(async () => await repository.DeleteAsync(secondaryLocation));
|
||||
|
||||
Assert.That(await repository.ExistsAsync(secondaryLocation), Is.True);
|
||||
|
||||
throwingStorage.ThrowOnWrite = false;
|
||||
await repository.SaveAsync(primaryLocation, new TestSimpleData { Value = 9 });
|
||||
|
||||
using var verifyStorage = new FileStorage(root, new JsonSerializer(), ".json");
|
||||
var verifyRepository = new UnifiedSettingsDataRepository(
|
||||
verifyStorage,
|
||||
new JsonSerializer(),
|
||||
new DataRepositoryOptions { EnableEvents = false });
|
||||
verifyRepository.RegisterDataType(primaryLocation, typeof(TestSimpleData));
|
||||
verifyRepository.RegisterDataType(secondaryLocation, typeof(TestSimpleData));
|
||||
|
||||
var persistedPrimary = await verifyRepository.LoadAsync<TestSimpleData>(primaryLocation);
|
||||
var persistedSecondary = await verifyRepository.LoadAsync<TestSimpleData>(secondaryLocation);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(persistedPrimary.Value, Is.EqualTo(9));
|
||||
Assert.That(persistedSecondary.Value, Is.EqualTo(5));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证统一设置仓库在启用事件时,只为显式仓库操作发送加载、保存、批量保存和删除事件。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task UnifiedSettingsDataRepository_WithEvents_Should_Emit_Only_Public_Operation_Events()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
using var storage = new FileStorage(root, new JsonSerializer(), ".json");
|
||||
var repository = new UnifiedSettingsDataRepository(
|
||||
storage,
|
||||
new JsonSerializer(),
|
||||
new DataRepositoryOptions
|
||||
{
|
||||
AutoBackup = true,
|
||||
EnableEvents = true
|
||||
});
|
||||
var context = CreateEventContext();
|
||||
((IContextAware)repository).SetContext(context);
|
||||
|
||||
var location1 = new TestDataLocation("settings/graphics");
|
||||
var location2 = new TestDataLocation("settings/audio");
|
||||
repository.RegisterDataType(location1, typeof(TestSimpleData));
|
||||
repository.RegisterDataType(location2, typeof(TestSimpleData));
|
||||
|
||||
var loadedEventCount = 0;
|
||||
var savedEventCount = 0;
|
||||
var batchEventCount = 0;
|
||||
var deletedEventCount = 0;
|
||||
|
||||
context.RegisterEvent<DataLoadedEvent<TestSimpleData>>(_ => loadedEventCount++);
|
||||
context.RegisterEvent<DataSavedEvent<TestSimpleData>>(_ => savedEventCount++);
|
||||
context.RegisterEvent<DataBatchSavedEvent>(_ => batchEventCount++);
|
||||
context.RegisterEvent<DataDeletedEvent>(_ => deletedEventCount++);
|
||||
|
||||
_ = await repository.LoadAsync<TestSimpleData>(location1);
|
||||
await repository.SaveAsync(location1, new TestSimpleData { Value = 5 });
|
||||
await repository.SaveAllAsync(
|
||||
[
|
||||
(location1, new TestSimpleData { Value = 6 }),
|
||||
(location2, new TestSimpleData { Value = 7 })
|
||||
]);
|
||||
await repository.DeleteAsync(location2);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(loadedEventCount, Is.EqualTo(1));
|
||||
Assert.That(savedEventCount, Is.EqualTo(1));
|
||||
Assert.That(batchEventCount, Is.EqualTo(1));
|
||||
Assert.That(deletedEventCount, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建带事件总线的真实架构上下文,供上下文感知仓库测试使用。
|
||||
/// </summary>
|
||||
/// <returns>可用于发送和监听事件的架构上下文。</returns>
|
||||
private static ArchitectureContext CreateEventContext()
|
||||
{
|
||||
var container = new MicrosoftDiContainer();
|
||||
container.Register<IEventBus>(new EventBus());
|
||||
container.Freeze();
|
||||
return new ArchitectureContext(container);
|
||||
}
|
||||
|
||||
private sealed class TestSaveMigrationV1ToV2 : ISaveMigration<TestVersionedSaveData>
|
||||
{
|
||||
public int FromVersion => 1;
|
||||
|
||||
public int ToVersion => 2;
|
||||
|
||||
public TestVersionedSaveData Migrate(TestVersionedSaveData oldData)
|
||||
{
|
||||
return new TestVersionedSaveData
|
||||
{
|
||||
Name = $"{oldData.Name}-v2",
|
||||
Level = oldData.Level,
|
||||
Experience = oldData.Level * 100,
|
||||
Version = 2,
|
||||
LastModified = oldData.LastModified
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSaveMigrationV2ToV3 : ISaveMigration<TestVersionedSaveData>
|
||||
{
|
||||
public int FromVersion => 2;
|
||||
|
||||
public int ToVersion => 3;
|
||||
|
||||
public TestVersionedSaveData Migrate(TestVersionedSaveData oldData)
|
||||
{
|
||||
return new TestVersionedSaveData
|
||||
{
|
||||
Name = oldData.Name,
|
||||
Level = oldData.Level,
|
||||
Experience = oldData.Experience,
|
||||
Version = 3,
|
||||
LastModified = oldData.LastModified
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestDuplicateSaveMigrationV1ToV2 : ISaveMigration<TestVersionedSaveData>
|
||||
{
|
||||
public int FromVersion => 1;
|
||||
|
||||
public int ToVersion => 2;
|
||||
|
||||
public TestVersionedSaveData Migrate(TestVersionedSaveData oldData)
|
||||
{
|
||||
return new TestVersionedSaveData
|
||||
{
|
||||
Name = $"{oldData.Name}-duplicate",
|
||||
Level = oldData.Level,
|
||||
Experience = oldData.Experience,
|
||||
Version = 2,
|
||||
LastModified = oldData.LastModified
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestNonVersionedMigration : ISaveMigration<TestSaveData>
|
||||
{
|
||||
public int FromVersion => 1;
|
||||
|
||||
public int ToVersion => 2;
|
||||
|
||||
public TestSaveData Migrate(TestSaveData oldData)
|
||||
{
|
||||
return new TestSaveData
|
||||
{
|
||||
Name = oldData.Name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为统一设置仓库失败场景测试提供可切换的写入失败包装器。
|
||||
/// </summary>
|
||||
private sealed class ToggleWriteFailureStorage(IStorage innerStorage, string failingKey) : IStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置是否在目标键写入时主动抛出异常。
|
||||
/// </summary>
|
||||
public bool ThrowOnWrite { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Exists(string key)
|
||||
{
|
||||
return innerStorage.Exists(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsAsync(string key)
|
||||
{
|
||||
return innerStorage.ExistsAsync(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T Read<T>(string key)
|
||||
{
|
||||
return innerStorage.Read<T>(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T Read<T>(string key, T defaultValue)
|
||||
{
|
||||
return innerStorage.Read(key, defaultValue);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<T> ReadAsync<T>(string key)
|
||||
{
|
||||
return innerStorage.ReadAsync<T>(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Write<T>(string key, T value)
|
||||
{
|
||||
ThrowIfNeeded(key);
|
||||
innerStorage.Write(key, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync<T>(string key, T value)
|
||||
{
|
||||
ThrowIfNeeded(key);
|
||||
return innerStorage.WriteAsync(key, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Delete(string key)
|
||||
{
|
||||
innerStorage.Delete(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteAsync(string key)
|
||||
{
|
||||
return innerStorage.DeleteAsync(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> ListDirectoriesAsync(string path = "")
|
||||
{
|
||||
return innerStorage.ListDirectoriesAsync(path);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> ListFilesAsync(string path = "")
|
||||
{
|
||||
return innerStorage.ListFilesAsync(path);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> DirectoryExistsAsync(string path)
|
||||
{
|
||||
return innerStorage.DirectoryExistsAsync(path);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CreateDirectoryAsync(string path)
|
||||
{
|
||||
return innerStorage.CreateDirectoryAsync(path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在启用失败开关且命中目标键时抛出一致的写入失败异常。
|
||||
/// </summary>
|
||||
/// <param name="key">当前正在写入的存储键。</param>
|
||||
private void ThrowIfNeeded(string key)
|
||||
{
|
||||
if (ThrowOnWrite && string.Equals(key, failingKey, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Simulated unified settings write failure.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,351 +0,0 @@
|
||||
using GFramework.Core.Abstractions.Architectures;
|
||||
using GFramework.Core.Abstractions.Enums;
|
||||
using GFramework.Core.Abstractions.Events;
|
||||
using GFramework.Core.Abstractions.Rule;
|
||||
using GFramework.Core.Architectures;
|
||||
using GFramework.Core.Events;
|
||||
using GFramework.Core.Ioc;
|
||||
using GFramework.Game.Abstractions.Setting;
|
||||
using GFramework.Game.Setting;
|
||||
using GFramework.Game.Setting.Events;
|
||||
|
||||
namespace GFramework.Game.Tests.Setting;
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖 <see cref="SettingsSystem" /> 的系统层语义,确保系统对模型编排、事件发送和重置流程保持稳定。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class SettingsSystemTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证 <see cref="SettingsSystem.ApplyAll" /> 会尝试应用全部 applicator,并为成功与失败结果分别发送事件。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task ApplyAll_Should_Apply_All_Applicators_And_Publish_Result_Events()
|
||||
{
|
||||
var successfulApplicator = new PrimaryTestSettings();
|
||||
var failingApplicator = new SecondaryTestSettings(throwOnApply: true);
|
||||
var model = new FakeSettingsModel(successfulApplicator, failingApplicator);
|
||||
var context = CreateContext(model);
|
||||
var system = CreateSystem(context);
|
||||
|
||||
var applyingEventCount = 0;
|
||||
var appliedEventCount = 0;
|
||||
var failedEventCount = 0;
|
||||
|
||||
context.RegisterEvent<SettingsApplyingEvent<ISettingsSection>>(_ => applyingEventCount++);
|
||||
context.RegisterEvent<SettingsAppliedEvent<ISettingsSection>>(eventData =>
|
||||
{
|
||||
appliedEventCount++;
|
||||
if (!eventData.Success)
|
||||
{
|
||||
failedEventCount++;
|
||||
}
|
||||
});
|
||||
|
||||
await system.ApplyAll();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(successfulApplicator.ApplyCount, Is.EqualTo(1));
|
||||
Assert.That(failingApplicator.ApplyCount, Is.EqualTo(1));
|
||||
Assert.That(applyingEventCount, Is.EqualTo(2));
|
||||
Assert.That(appliedEventCount, Is.EqualTo(2));
|
||||
Assert.That(failedEventCount, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 <see cref="SettingsSystem.SaveAll" /> 会直接委托给模型层统一保存。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task SaveAll_Should_Delegate_To_Model()
|
||||
{
|
||||
var model = new FakeSettingsModel(new PrimaryTestSettings());
|
||||
var system = CreateSystem(CreateContext(model));
|
||||
|
||||
await system.SaveAll();
|
||||
|
||||
Assert.That(model.SaveAllCallCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 <see cref="SettingsSystem.ResetAll" /> 会先委托模型统一重置,再重新应用全部 applicator。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task ResetAll_Should_Reset_Model_And_Reapply_All_Applicators()
|
||||
{
|
||||
var primaryApplicator = new PrimaryTestSettings();
|
||||
var secondaryApplicator = new SecondaryTestSettings();
|
||||
var model = new FakeSettingsModel(primaryApplicator, secondaryApplicator);
|
||||
var system = CreateSystem(CreateContext(model));
|
||||
|
||||
await system.ResetAll();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(model.ResetAllCallCount, Is.EqualTo(1));
|
||||
Assert.That(primaryApplicator.ApplyCount, Is.EqualTo(1));
|
||||
Assert.That(secondaryApplicator.ApplyCount, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 <see cref="SettingsSystem.Reset{T}" /> 会重置目标数据类型,并只重新应用对应的 applicator。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task Reset_Should_Reset_Target_Data_And_Reapply_Target_Applicator()
|
||||
{
|
||||
var primaryApplicator = new PrimaryTestSettings();
|
||||
var secondaryApplicator = new SecondaryTestSettings();
|
||||
var model = new FakeSettingsModel(primaryApplicator, secondaryApplicator);
|
||||
var system = CreateSystem(CreateContext(model));
|
||||
|
||||
await system.Reset<PrimaryTestSettings>();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(model.ResetTypes, Is.EquivalentTo(new[] { typeof(PrimaryTestSettings) }));
|
||||
Assert.That(primaryApplicator.ApplyCount, Is.EqualTo(1));
|
||||
Assert.That(secondaryApplicator.ApplyCount, Is.Zero);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建带事件总线和设置模型的真实架构上下文。
|
||||
/// </summary>
|
||||
/// <param name="model">测试使用的设置模型。</param>
|
||||
/// <returns>可供系统解析依赖与发送事件的上下文。</returns>
|
||||
private static ArchitectureContext CreateContext(ISettingsModel model)
|
||||
{
|
||||
var container = new MicrosoftDiContainer();
|
||||
container.Register<IEventBus>(new EventBus());
|
||||
container.Register<ISettingsModel>(model);
|
||||
container.Freeze();
|
||||
return new ArchitectureContext(container);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建并初始化绑定到指定上下文的设置系统。
|
||||
/// </summary>
|
||||
/// <param name="context">系统运行所需的架构上下文。</param>
|
||||
/// <returns>已完成初始化的设置系统实例。</returns>
|
||||
private static SettingsSystem CreateSystem(IArchitectureContext context)
|
||||
{
|
||||
var system = new SettingsSystem();
|
||||
((IContextAware)system).SetContext(context);
|
||||
system.Initialize();
|
||||
return system;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于系统层测试的简化设置模型,记录系统对模型的调用行为。
|
||||
/// </summary>
|
||||
private sealed class FakeSettingsModel : ISettingsModel
|
||||
{
|
||||
private readonly IReadOnlyDictionary<Type, IResetApplyAbleSettings> _applicators;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化测试模型,并注册参与测试的 applicator 集合。
|
||||
/// </summary>
|
||||
/// <param name="applicators">测试使用的 applicator。</param>
|
||||
public FakeSettingsModel(params IResetApplyAbleSettings[] applicators)
|
||||
{
|
||||
_applicators = applicators.ToDictionary(applicator => applicator.GetType());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取保存全量设置的调用次数。
|
||||
/// </summary>
|
||||
public int SaveAllCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取重置全部设置的调用次数。
|
||||
/// </summary>
|
||||
public int ResetAllCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取被请求重置的设置数据类型列表。
|
||||
/// </summary>
|
||||
public List<Type> ResetTypes { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsInitialized => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetContext(IArchitectureContext context)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IArchitectureContext GetContext()
|
||||
{
|
||||
throw new NotSupportedException("Fake settings model does not expose a context.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnArchitecturePhase(ArchitecturePhase phase)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T GetData<T>() where T : class, ISettingsData, new()
|
||||
{
|
||||
return new T();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ISettingsData> AllData()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISettingsModel RegisterApplicator<T>(T applicator) where T : class, IResetApplyAbleSettings
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T? GetApplicator<T>() where T : class, IResetApplyAbleSettings
|
||||
{
|
||||
return _applicators.TryGetValue(typeof(T), out var applicator) ? (T)applicator : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<IResetApplyAbleSettings> AllApplicators()
|
||||
{
|
||||
return _applicators.Values;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISettingsModel RegisterMigration(ISettingsMigration migration)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveAllAsync()
|
||||
{
|
||||
SaveAllCallCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ApplyAllAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Reset<T>() where T : class, ISettingsData, new()
|
||||
{
|
||||
ResetTypes.Add(typeof(T));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetAll()
|
||||
{
|
||||
ResetAllCallCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为系统层测试提供的最小设置数据实现。
|
||||
/// </summary>
|
||||
private abstract class TestSettingsBase : ISettingsData, IResetApplyAbleSettings
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public int Version { get; set; } = 1;
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTime LastModified { get; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// 获取测试用的数值字段,用于确认重置与加载行为。
|
||||
/// </summary>
|
||||
public int Value { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取应用操作被调用的次数。
|
||||
/// </summary>
|
||||
public int ApplyCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISettingsData Data => this;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Type DataType => GetType();
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置是否在应用时抛出异常。
|
||||
/// </summary>
|
||||
protected bool ThrowOnApply { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Reset()
|
||||
{
|
||||
Value = 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void LoadFrom(ISettingsData source)
|
||||
{
|
||||
if (source is TestSettingsBase data)
|
||||
{
|
||||
Value = data.Value;
|
||||
Version = data.Version;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task Apply()
|
||||
{
|
||||
ApplyCount++;
|
||||
|
||||
await Task.Yield();
|
||||
|
||||
if (ThrowOnApply)
|
||||
{
|
||||
throw new InvalidOperationException("Test applicator failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 代表主设置分支的测试设置对象。
|
||||
/// </summary>
|
||||
private sealed class PrimaryTestSettings : TestSettingsBase
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 代表第二个设置分支的测试设置对象,可选择在应用时失败。
|
||||
/// </summary>
|
||||
private sealed class SecondaryTestSettings : TestSettingsBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化第二个测试设置对象。
|
||||
/// </summary>
|
||||
/// <param name="throwOnApply">是否在应用时抛出异常。</param>
|
||||
public SecondaryTestSettings(bool throwOnApply = false)
|
||||
{
|
||||
ThrowOnApply = throwOnApply;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,8 +5,7 @@
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"hp",
|
||||
"faction"
|
||||
"hp"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
@ -20,10 +19,6 @@
|
||||
"hp": {
|
||||
"type": "integer",
|
||||
"description": "Monster base health."
|
||||
},
|
||||
"faction": {
|
||||
"type": "string",
|
||||
"description": "Used by integration tests to validate generated non-unique queries."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
namespace GFramework.Game.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 描述开发期热重载的可选行为。
|
||||
/// 该选项对象集中承载回调和防抖等可扩展参数,
|
||||
/// 以避免后续继续在 <see cref="YamlConfigLoader.EnableHotReload(GFramework.Game.Abstractions.Config.IConfigRegistry,YamlConfigHotReloadOptions?)" />
|
||||
/// 上堆叠额外重载。
|
||||
/// </summary>
|
||||
public sealed class YamlConfigHotReloadOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置单个配置表重载成功后的可选回调。
|
||||
/// </summary>
|
||||
public Action<string>? OnTableReloaded { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置单个配置表重载失败后的可选回调。
|
||||
/// 当失败来自加载器本身时,异常通常为 <see cref="GFramework.Game.Abstractions.Config.ConfigLoadException" />。
|
||||
/// </summary>
|
||||
public Action<string, Exception>? OnTableReloadFailed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置文件系统事件的防抖延迟。
|
||||
/// 默认值为 200 毫秒,用于吸收编辑器保存时的短时间重复触发。
|
||||
/// </summary>
|
||||
public TimeSpan DebounceDelay { get; init; } = TimeSpan.FromMilliseconds(200);
|
||||
}
|
||||
@ -20,8 +20,6 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
private const string SchemaRelativePathCannotBeNullOrWhiteSpaceMessage =
|
||||
"Schema relative path cannot be null or whitespace.";
|
||||
|
||||
private static readonly TimeSpan DefaultHotReloadDebounceDelay = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
private readonly IDeserializer _deserializer;
|
||||
|
||||
private readonly Dictionary<string, IReadOnlyCollection<string>> _lastSuccessfulDependencies =
|
||||
@ -97,50 +95,13 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
/// <param name="debounceDelay">防抖延迟;为空时默认使用 200 毫秒。</param>
|
||||
/// <returns>用于停止热重载监听的注销句柄。</returns>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为空时抛出。</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// 当显式提供的 <paramref name="debounceDelay" /> 小于 <see cref="TimeSpan.Zero" /> 时抛出。
|
||||
/// </exception>
|
||||
public IUnRegister EnableHotReload(
|
||||
IConfigRegistry registry,
|
||||
Action<string>? onTableReloaded = null,
|
||||
Action<string, Exception>? onTableReloadFailed = null,
|
||||
TimeSpan? debounceDelay = null)
|
||||
{
|
||||
return EnableHotReload(
|
||||
registry,
|
||||
new YamlConfigHotReloadOptions
|
||||
{
|
||||
OnTableReloaded = onTableReloaded,
|
||||
OnTableReloadFailed = onTableReloadFailed,
|
||||
DebounceDelay = debounceDelay ?? DefaultHotReloadDebounceDelay
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启用开发期热重载,并通过选项对象集中配置回调和防抖行为。
|
||||
/// 该入口用于减少继续堆叠位置参数重载的需要,
|
||||
/// 也为未来扩展过滤策略或日志钩子预留稳定形态。
|
||||
/// </summary>
|
||||
/// <param name="registry">要被热重载更新的配置注册表。</param>
|
||||
/// <param name="options">热重载配置选项;为空时使用默认选项。</param>
|
||||
/// <returns>用于停止热重载监听的注销句柄。</returns>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为空时抛出。</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// 当 <paramref name="options" /> 的 <see cref="YamlConfigHotReloadOptions.DebounceDelay" /> 小于
|
||||
/// <see cref="TimeSpan.Zero" /> 时抛出。
|
||||
/// </exception>
|
||||
public IUnRegister EnableHotReload(
|
||||
IConfigRegistry registry,
|
||||
YamlConfigHotReloadOptions? options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
options ??= new YamlConfigHotReloadOptions();
|
||||
if (options.DebounceDelay < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(options),
|
||||
"DebounceDelay must be greater than or equal to zero.");
|
||||
}
|
||||
|
||||
return new HotReloadSession(
|
||||
_rootPath,
|
||||
@ -148,9 +109,9 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
registry,
|
||||
_registrations,
|
||||
_lastSuccessfulDependencies,
|
||||
options.OnTableReloaded,
|
||||
options.OnTableReloadFailed,
|
||||
options.DebounceDelay);
|
||||
onTableReloaded,
|
||||
onTableReloadFailed,
|
||||
debounceDelay ?? TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
private void UpdateLastSuccessfulDependencies(IEnumerable<YamlTableLoadResult> loadedTables)
|
||||
@ -174,10 +135,6 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
/// <param name="keySelector">配置项主键提取器。</param>
|
||||
/// <param name="comparer">可选主键比较器。</param>
|
||||
/// <returns>当前加载器实例,以便链式注册。</returns>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// 当 <paramref name="tableName" /> 或 <paramref name="relativePath" /> 为 null、空字符串或空白字符串时抛出。
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="keySelector" /> 为 null 时抛出。</exception>
|
||||
public YamlConfigLoader RegisterTable<TKey, TValue>(
|
||||
string tableName,
|
||||
string relativePath,
|
||||
@ -185,11 +142,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
IEqualityComparer<TKey>? comparer = null)
|
||||
where TKey : notnull
|
||||
{
|
||||
return RegisterTable(
|
||||
new YamlConfigTableRegistrationOptions<TKey, TValue>(tableName, relativePath, keySelector)
|
||||
{
|
||||
Comparer = comparer
|
||||
});
|
||||
return RegisterTableCore(tableName, relativePath, null, keySelector, comparer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -205,11 +158,6 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
/// <param name="keySelector">配置项主键提取器。</param>
|
||||
/// <param name="comparer">可选主键比较器。</param>
|
||||
/// <returns>当前加载器实例,以便链式注册。</returns>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// 当 <paramref name="tableName" />、<paramref name="relativePath" /> 或 <paramref name="schemaRelativePath" />
|
||||
/// 为 null、空字符串或空白字符串时抛出。
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="keySelector" /> 为 null 时抛出。</exception>
|
||||
public YamlConfigLoader RegisterTable<TKey, TValue>(
|
||||
string tableName,
|
||||
string relativePath,
|
||||
@ -218,40 +166,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
IEqualityComparer<TKey>? comparer = null)
|
||||
where TKey : notnull
|
||||
{
|
||||
return RegisterTable(
|
||||
new YamlConfigTableRegistrationOptions<TKey, TValue>(tableName, relativePath, keySelector)
|
||||
{
|
||||
SchemaRelativePath = schemaRelativePath,
|
||||
Comparer = comparer
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用选项对象注册一个 YAML 配置表定义。
|
||||
/// 该入口集中承载配置目录、schema 路径、主键提取器和比较器,
|
||||
/// 以避免未来继续为新增开关叠加更多重载。
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">配置主键类型。</typeparam>
|
||||
/// <typeparam name="TValue">配置值类型。</typeparam>
|
||||
/// <param name="options">配置表注册选项。</param>
|
||||
/// <returns>当前加载器实例,以便链式注册。</returns>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="options" /> 为空时抛出。</exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// 当 <paramref name="options" /> 内的 <see cref="YamlConfigTableRegistrationOptions{TKey, TValue}.TableName" />、
|
||||
/// <see cref="YamlConfigTableRegistrationOptions{TKey, TValue}.RelativePath" /> 或
|
||||
/// <see cref="YamlConfigTableRegistrationOptions{TKey, TValue}.SchemaRelativePath" /> 为 null、空字符串或空白字符串时抛出。
|
||||
/// </exception>
|
||||
public YamlConfigLoader RegisterTable<TKey, TValue>(YamlConfigTableRegistrationOptions<TKey, TValue> options)
|
||||
where TKey : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return RegisterTableCore(
|
||||
options.TableName,
|
||||
options.RelativePath,
|
||||
options.SchemaRelativePath,
|
||||
options.KeySelector,
|
||||
options.Comparer);
|
||||
return RegisterTableCore(tableName, relativePath, schemaRelativePath, keySelector, comparer);
|
||||
}
|
||||
|
||||
private YamlConfigLoader RegisterTableCore<TKey, TValue>(
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
namespace GFramework.Game.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 描述一个 YAML 配置表注册项的参数集合。
|
||||
/// 该选项对象用于替代不断增加的位置参数重载,
|
||||
/// 让消费者在启用 schema 校验、主键比较器或未来扩展项时仍能保持调用点可读。
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">配置主键类型。</typeparam>
|
||||
/// <typeparam name="TValue">配置值类型。</typeparam>
|
||||
public sealed class YamlConfigTableRegistrationOptions<TKey, TValue>
|
||||
where TKey : notnull
|
||||
{
|
||||
private const string TableNameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace.";
|
||||
private const string RelativePathCannotBeNullOrWhiteSpaceMessage = "Relative path cannot be null or whitespace.";
|
||||
|
||||
/// <summary>
|
||||
/// 使用最小必需参数创建配置表注册选项。
|
||||
/// </summary>
|
||||
/// <param name="tableName">运行时配置表名称。</param>
|
||||
/// <param name="relativePath">相对配置根目录的子目录。</param>
|
||||
/// <param name="keySelector">配置项主键提取器。</param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// 当 <paramref name="tableName" /> 或 <paramref name="relativePath" /> 为 null、空字符串或空白字符串时抛出。
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="keySelector" /> 为 null 时抛出。</exception>
|
||||
public YamlConfigTableRegistrationOptions(
|
||||
string tableName,
|
||||
string relativePath,
|
||||
Func<TValue, TKey> keySelector)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tableName))
|
||||
{
|
||||
throw new ArgumentException(TableNameCannotBeNullOrWhiteSpaceMessage, nameof(tableName));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
throw new ArgumentException(RelativePathCannotBeNullOrWhiteSpaceMessage, nameof(relativePath));
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(keySelector);
|
||||
|
||||
TableName = tableName;
|
||||
RelativePath = relativePath;
|
||||
KeySelector = keySelector;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取运行时配置表名称。
|
||||
/// </summary>
|
||||
public string TableName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取相对配置根目录的子目录。
|
||||
/// </summary>
|
||||
public string RelativePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取相对配置根目录的 schema 文件路径。
|
||||
/// 当该值为空时,当前注册项不会启用 schema 校验。
|
||||
/// </summary>
|
||||
public string? SchemaRelativePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取配置项主键提取器。
|
||||
/// </summary>
|
||||
public Func<TValue, TKey> KeySelector { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取可选的主键比较器。
|
||||
/// </summary>
|
||||
public IEqualityComparer<TKey>? Comparer { get; init; }
|
||||
}
|
||||
@ -11,7 +11,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System.Reflection;
|
||||
using GFramework.Core.Abstractions.Storage;
|
||||
using GFramework.Core.Extensions;
|
||||
using GFramework.Core.Utility;
|
||||
@ -22,17 +21,13 @@ using GFramework.Game.Extensions;
|
||||
namespace GFramework.Game.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 数据仓库类,用于管理游戏数据的存储和读取。
|
||||
/// 数据仓库类,用于管理游戏数据的存储和读取
|
||||
/// </summary>
|
||||
/// <param name="storage">存储接口实例</param>
|
||||
/// <param name="options">数据仓库配置选项</param>
|
||||
public class DataRepository(IStorage? storage, DataRepositoryOptions? options = null)
|
||||
: AbstractContextUtility, IDataRepository
|
||||
{
|
||||
private static readonly MethodInfo SaveCoreGenericMethod =
|
||||
typeof(DataRepository).GetMethod(nameof(SaveCoreAsync), BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException($"Method {nameof(SaveCoreAsync)} not found.");
|
||||
|
||||
private readonly DataRepositoryOptions _options = options ?? new DataRepositoryOptions();
|
||||
private IStorage? _storage = storage;
|
||||
|
||||
@ -70,7 +65,20 @@ public class DataRepository(IStorage? storage, DataRepositoryOptions? options =
|
||||
public async Task SaveAsync<T>(IDataLocation location, T data)
|
||||
where T : class, IData
|
||||
{
|
||||
await SaveCoreAsync(location, data, emitSavedEvent: true);
|
||||
var key = location.ToStorageKey();
|
||||
|
||||
// 自动备份
|
||||
if (_options.AutoBackup && await Storage.ExistsAsync(key))
|
||||
{
|
||||
var backupKey = $"{key}.backup";
|
||||
var existing = await Storage.ReadAsync<T>(key);
|
||||
await Storage.WriteAsync(backupKey, existing);
|
||||
}
|
||||
|
||||
await Storage.WriteAsync(key, data);
|
||||
|
||||
if (_options.EnableEvents)
|
||||
this.SendEvent(new DataSavedEvent<T>(data));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -90,12 +98,6 @@ public class DataRepository(IStorage? storage, DataRepositoryOptions? options =
|
||||
public async Task DeleteAsync(IDataLocation location)
|
||||
{
|
||||
var key = location.ToStorageKey();
|
||||
|
||||
if (!await Storage.ExistsAsync(key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Storage.DeleteAsync(key);
|
||||
if (_options.EnableEvents)
|
||||
this.SendEvent(new DataDeletedEvent(location));
|
||||
@ -108,13 +110,7 @@ public class DataRepository(IStorage? storage, DataRepositoryOptions? options =
|
||||
public async Task SaveAllAsync(IEnumerable<(IDataLocation location, IData data)> dataList)
|
||||
{
|
||||
var valueTuples = dataList.ToList();
|
||||
|
||||
// 批量保存对订阅者而言应视为一次显式提交,因此这里复用底层保存逻辑,
|
||||
// 但抑制逐项 DataSavedEvent,避免监听器对同一批次收到重复语义的事件。
|
||||
foreach (var (location, data) in valueTuples)
|
||||
{
|
||||
await SaveCoreUntypedAsync(location, data, emitSavedEvent: false);
|
||||
}
|
||||
foreach (var (location, data) in valueTuples) await SaveAsync(location, data);
|
||||
|
||||
if (_options.EnableEvents)
|
||||
this.SendEvent(new DataBatchSavedEvent(valueTuples));
|
||||
@ -127,56 +123,4 @@ public class DataRepository(IStorage? storage, DataRepositoryOptions? options =
|
||||
{
|
||||
_storage ??= this.GetUtility<IStorage>()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行单项保存的共享流程,并根据调用入口决定是否发送单项保存事件。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">数据类型。</typeparam>
|
||||
/// <param name="location">目标数据位置。</param>
|
||||
/// <param name="data">要保存的数据对象。</param>
|
||||
/// <param name="emitSavedEvent">是否在成功写入后发送单项保存事件。</param>
|
||||
private async Task SaveCoreAsync<T>(IDataLocation location, T data, bool emitSavedEvent)
|
||||
where T : class, IData
|
||||
{
|
||||
var key = location.ToStorageKey();
|
||||
|
||||
await BackupIfNeededAsync<T>(key);
|
||||
await Storage.WriteAsync(key, data);
|
||||
|
||||
if (emitSavedEvent && _options.EnableEvents)
|
||||
{
|
||||
this.SendEvent(new DataSavedEvent<T>(data));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在覆盖旧值前为当前存储键创建备份。
|
||||
/// </summary>
|
||||
/// <param name="key">即将被覆盖的存储键。</param>
|
||||
private async Task BackupIfNeededAsync<T>(string key)
|
||||
where T : class, IData
|
||||
{
|
||||
if (!_options.AutoBackup || !await Storage.ExistsAsync(key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var backupKey = $"{key}.backup";
|
||||
var existing = await Storage.ReadAsync<T>(key);
|
||||
await Storage.WriteAsync(backupKey, existing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用数据对象的运行时类型执行保存流程,避免批量保存时因为编译期类型退化为 <see cref="IData" /> 而破坏备份反序列化。
|
||||
/// </summary>
|
||||
/// <param name="location">目标数据位置。</param>
|
||||
/// <param name="data">要保存的数据对象。</param>
|
||||
/// <param name="emitSavedEvent">是否发送单项保存事件。</param>
|
||||
private Task SaveCoreUntypedAsync(IDataLocation location, IData data, bool emitSavedEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
var closedMethod = SaveCoreGenericMethod.MakeGenericMethod(data.GetType());
|
||||
return (Task)closedMethod.Invoke(this, [location, data, emitSavedEvent])!;
|
||||
}
|
||||
}
|
||||
@ -11,11 +11,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using GFramework.Core.Abstractions.Storage;
|
||||
using GFramework.Core.Utility;
|
||||
using GFramework.Game.Abstractions.Data;
|
||||
@ -31,8 +27,6 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
|
||||
where TSaveData : class, IData, new()
|
||||
{
|
||||
private readonly SaveConfiguration _config;
|
||||
private readonly Dictionary<int, ISaveMigration<TSaveData>> _migrations = new();
|
||||
private readonly object _migrationsLock = new();
|
||||
private readonly IStorage _rootStorage;
|
||||
|
||||
/// <summary>
|
||||
@ -49,47 +43,6 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
|
||||
_rootStorage = new ScopedStorage(storage, config.SaveRoot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册存档迁移器,使仓库在加载旧版本存档时自动执行升级。
|
||||
/// </summary>
|
||||
/// <param name="migration">要注册的存档迁移器。</param>
|
||||
/// <returns>当前存档仓库实例,支持链式调用。</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="migration" /> 为 <see langword="null" />。</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// <typeparamref name="TSaveData" /> 未实现 <see cref="IVersionedData" />,无法使用版本化迁移。
|
||||
/// 或者同一个源版本已经注册过迁移器,导致迁移链配置存在歧义。
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentException">迁移器的目标版本不大于源版本。</exception>
|
||||
/// <remarks>
|
||||
/// 迁移注册表是可变共享状态。注册与加载可以并发发生,因此所有访问都通过 <see cref="_migrationsLock" />
|
||||
/// 串行化,避免读写竞争和“部分可见”的迁移链。
|
||||
/// </remarks>
|
||||
public ISaveRepository<TSaveData> RegisterMigration(ISaveMigration<TSaveData> migration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migration);
|
||||
EnsureVersionedSaveType();
|
||||
|
||||
if (migration.ToVersion <= migration.FromVersion)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Migration for {typeof(TSaveData).Name} must advance the version number.",
|
||||
nameof(migration));
|
||||
}
|
||||
|
||||
lock (_migrationsLock)
|
||||
{
|
||||
if (_migrations.ContainsKey(migration.FromVersion))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Duplicate save migration registration for {typeof(TSaveData).Name} from version {migration.FromVersion}.");
|
||||
}
|
||||
|
||||
_migrations.Add(migration.FromVersion, migration);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查指定槽位是否存在存档
|
||||
/// </summary>
|
||||
@ -111,10 +64,7 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
|
||||
var storage = GetSlotStorage(slot);
|
||||
|
||||
if (await storage.ExistsAsync(_config.SaveFileName))
|
||||
{
|
||||
var loaded = await storage.ReadAsync<TSaveData>(_config.SaveFileName);
|
||||
return await MigrateIfNeededAsync(slot, storage, loaded);
|
||||
}
|
||||
return await storage.ReadAsync<TSaveData>(_config.SaveFileName);
|
||||
|
||||
return new TSaveData();
|
||||
}
|
||||
@ -187,117 +137,6 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
|
||||
return new ScopedStorage(_rootStorage, $"{_config.SaveSlotPrefix}{slot}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在加载旧版本存档时按注册顺序执行迁移,并在成功后自动回写升级结果。
|
||||
/// </summary>
|
||||
/// <param name="slot">当前加载的存档槽位。</param>
|
||||
/// <param name="storage">对应槽位的存储对象。</param>
|
||||
/// <param name="data">原始加载出来的存档数据。</param>
|
||||
/// <returns>迁移后的最新存档;如果无需迁移则返回原始对象。</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// 当前运行时缺少必要的迁移链、读取到更高版本的存档,或迁移器返回了非法版本。
|
||||
/// </exception>
|
||||
private async Task<TSaveData> MigrateIfNeededAsync(int slot, IStorage storage, TSaveData data)
|
||||
{
|
||||
if (data is not IVersionedData versionedData)
|
||||
{
|
||||
return data;
|
||||
}
|
||||
|
||||
var latestTemplate = new TSaveData();
|
||||
if (latestTemplate is not IVersionedData latestVersionedData)
|
||||
{
|
||||
return data;
|
||||
}
|
||||
|
||||
var currentVersion = versionedData.Version;
|
||||
var targetVersion = latestVersionedData.Version;
|
||||
|
||||
if (currentVersion > targetVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Save slot {slot} for {typeof(TSaveData).Name} is version {currentVersion}, " +
|
||||
$"which is newer than the current runtime version {targetVersion}.");
|
||||
}
|
||||
|
||||
if (currentVersion == targetVersion)
|
||||
{
|
||||
return data;
|
||||
}
|
||||
|
||||
EnsureVersionedSaveType();
|
||||
|
||||
var migrated = data;
|
||||
|
||||
// 迁移链按“当前版本 -> 下一个已注册迁移器”推进;任何缺口都表示运行时无法安全解释旧存档。
|
||||
// 读取迁移表时使用同一把锁,保证并发注册不会让加载线程看到不一致的链路状态。
|
||||
while (currentVersion < targetVersion)
|
||||
{
|
||||
ISaveMigration<TSaveData>? migration;
|
||||
lock (_migrationsLock)
|
||||
{
|
||||
_migrations.TryGetValue(currentVersion, out migration);
|
||||
}
|
||||
|
||||
if (migration is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"No save migration is registered for {typeof(TSaveData).Name} from version {currentVersion}.");
|
||||
}
|
||||
|
||||
migrated = migration.Migrate(migrated) ??
|
||||
throw new InvalidOperationException(
|
||||
$"Save migration for {typeof(TSaveData).Name} from version {currentVersion} returned null.");
|
||||
|
||||
if (migrated is not IVersionedData migratedVersionedData)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Save migration for {typeof(TSaveData).Name} must return data implementing {nameof(IVersionedData)}.");
|
||||
}
|
||||
|
||||
// 显式校验迁移器声明与实际结果,避免版本号不前进导致死循环或把旧数据错误回写为“已升级”。
|
||||
if (migration.ToVersion != migratedVersionedData.Version)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Save migration for {typeof(TSaveData).Name} declared target version {migration.ToVersion} " +
|
||||
$"but returned version {migratedVersionedData.Version}.");
|
||||
}
|
||||
|
||||
if (migratedVersionedData.Version <= currentVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Save migration for {typeof(TSaveData).Name} must advance beyond version {currentVersion}.");
|
||||
}
|
||||
|
||||
if (migratedVersionedData.Version > targetVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Save migration for {typeof(TSaveData).Name} produced version {migratedVersionedData.Version}, " +
|
||||
$"which exceeds the current runtime version {targetVersion}.");
|
||||
}
|
||||
|
||||
currentVersion = migratedVersionedData.Version;
|
||||
}
|
||||
|
||||
await storage.WriteAsync(_config.SaveFileName, migrated);
|
||||
return migrated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当前存档类型支持基于版本号的迁移流程。
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// <typeparamref name="TSaveData" /> 未实现 <see cref="IVersionedData" />。
|
||||
/// </exception>
|
||||
private static void EnsureVersionedSaveType()
|
||||
{
|
||||
if (!typeof(IVersionedData).IsAssignableFrom(typeof(TSaveData)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{typeof(TSaveData).Name} must implement {nameof(IVersionedData)} to use save migrations.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化逻辑
|
||||
/// </summary>
|
||||
|
||||
@ -21,13 +21,8 @@ using GFramework.Game.Abstractions.Data.Events;
|
||||
namespace GFramework.Game.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 使用单一文件存储所有设置数据的仓库实现。
|
||||
/// 使用单一文件存储所有设置数据的仓库实现
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该仓库通过内存缓存聚合所有设置 section,并在公开的保存或删除操作发生时整文件回写。
|
||||
/// 虽然底层不是“一项一个文件”,但它仍遵循 <see cref="DataRepositoryOptions" /> 定义的统一契约:
|
||||
/// 启用自动备份时,覆盖写入前会为整个统一文件创建单份备份;批量保存只发出批量事件,不重复发出单项保存事件。
|
||||
/// </remarks>
|
||||
public class UnifiedSettingsDataRepository(
|
||||
IStorage? storage,
|
||||
IRuntimeTypeSerializer? serializer,
|
||||
@ -71,7 +66,7 @@ public class UnifiedSettingsDataRepository(
|
||||
var key = location.Key;
|
||||
var result = _file!.Sections.TryGetValue(key, out var raw) ? Serializer.Deserialize<T>(raw) : new T();
|
||||
if (_options.EnableEvents)
|
||||
this.SendEvent(new DataLoadedEvent<T>(result));
|
||||
this.SendEvent(new DataLoadedEvent<IData>(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -86,12 +81,22 @@ public class UnifiedSettingsDataRepository(
|
||||
where T : class, IData
|
||||
{
|
||||
await EnsureLoadedAsync();
|
||||
await MutateAndPersistAsync(file => file.Sections[location.Key] = Serializer.Serialize(data));
|
||||
|
||||
if (_options.EnableEvents)
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var key = location.Key;
|
||||
var serialized = Serializer.Serialize(data);
|
||||
|
||||
_file!.Sections[key] = serialized;
|
||||
|
||||
await Storage.WriteAsync(UnifiedKey, _file);
|
||||
if (_options.EnableEvents)
|
||||
this.SendEvent(new DataSavedEvent<T>(data));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -113,29 +118,12 @@ public class UnifiedSettingsDataRepository(
|
||||
public async Task DeleteAsync(IDataLocation location)
|
||||
{
|
||||
await EnsureLoadedAsync();
|
||||
var removed = false;
|
||||
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
if (File.Sections.Remove(location.Key))
|
||||
{
|
||||
var currentFile = File;
|
||||
var nextFile = CloneFile(currentFile);
|
||||
removed = nextFile.Sections.Remove(location.Key);
|
||||
if (!removed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
await SaveUnifiedFileAsync();
|
||||
|
||||
await WriteUnifiedFileCoreAsync(currentFile, nextFile);
|
||||
_file = nextFile;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
|
||||
if (removed && _options.EnableEvents)
|
||||
{
|
||||
if (_options.EnableEvents)
|
||||
this.SendEvent(new DataDeletedEvent(location));
|
||||
}
|
||||
}
|
||||
@ -151,17 +139,16 @@ public class UnifiedSettingsDataRepository(
|
||||
await EnsureLoadedAsync();
|
||||
|
||||
var valueTuples = dataList.ToList();
|
||||
|
||||
await MutateAndPersistAsync(file =>
|
||||
{
|
||||
foreach (var (location, data) in valueTuples)
|
||||
{
|
||||
file.Sections[location.Key] = Serializer.Serialize(data);
|
||||
var serialized = Serializer.Serialize(data);
|
||||
File.Sections[location.Key] = serialized;
|
||||
}
|
||||
});
|
||||
|
||||
await SaveUnifiedFileAsync();
|
||||
|
||||
if (_options.EnableEvents)
|
||||
this.SendEvent(new DataBatchSavedEvent(valueTuples));
|
||||
this.SendEvent(new DataBatchSavedEvent(valueTuples.ToList()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -239,19 +226,12 @@ public class UnifiedSettingsDataRepository(
|
||||
/// <summary>
|
||||
/// 将缓存中的所有数据保存到统一文件
|
||||
/// </summary>
|
||||
private async Task MutateAndPersistAsync(Action<UnifiedSettingsFile> mutation)
|
||||
private async Task SaveUnifiedFileAsync()
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var currentFile = File;
|
||||
var nextFile = CloneFile(currentFile);
|
||||
|
||||
// 先在副本上计算“下一份已提交状态”,只有底层持久化成功后才交换缓存,
|
||||
// 这样即使备份或写入失败,也不会把未提交修改留在内存快照里。
|
||||
mutation(nextFile);
|
||||
await WriteUnifiedFileCoreAsync(currentFile, nextFile);
|
||||
_file = nextFile;
|
||||
await Storage.WriteAsync(UnifiedKey, _file);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@ -260,45 +240,9 @@ public class UnifiedSettingsDataRepository(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将当前缓存快照写回底层存储,并在需要时创建整个文件的备份。
|
||||
/// 获取统一文件的存储键名
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该方法要求调用方已经持有 <see cref="_lock" />,以保证“读取当前快照 -> 写入备份 -> 提交新快照”的原子提交顺序。
|
||||
/// 只有在该方法成功返回后,调用方才应交换内存中的 <see cref="_file" /> 引用。
|
||||
/// </remarks>
|
||||
/// <param name="currentFile">当前已提交的统一文件快照。</param>
|
||||
/// <param name="nextFile">即将提交的新统一文件快照。</param>
|
||||
private async Task WriteUnifiedFileCoreAsync(UnifiedSettingsFile currentFile, UnifiedSettingsFile nextFile)
|
||||
{
|
||||
if (_options.AutoBackup && await Storage.ExistsAsync(UnifiedKey))
|
||||
{
|
||||
var backupKey = $"{UnifiedKey}.backup";
|
||||
await Storage.WriteAsync(backupKey, currentFile);
|
||||
}
|
||||
|
||||
await Storage.WriteAsync(UnifiedKey, nextFile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制当前统一文件快照,确保未提交修改不会污染内存中的已提交状态。
|
||||
/// </summary>
|
||||
/// <param name="source">要复制的统一文件快照。</param>
|
||||
/// <returns>包含独立 section 字典的新快照。</returns>
|
||||
private static UnifiedSettingsFile CloneFile(UnifiedSettingsFile source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return new UnifiedSettingsFile
|
||||
{
|
||||
Version = source.Version,
|
||||
Sections = new Dictionary<string, string>(source.Sections, source.Sections.Comparer)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取统一文件的存储键名。
|
||||
/// </summary>
|
||||
/// <returns>完整的存储键名。</returns>
|
||||
/// <returns>完整的存储键名</returns>
|
||||
protected virtual string GetUnifiedKey()
|
||||
{
|
||||
return string.IsNullOrEmpty(_options.BasePath) ? fileName : $"{_options.BasePath}/{fileName}";
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using GFramework.Godot.Coroutine;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GFramework.Godot.Tests.Coroutine;
|
||||
|
||||
/// <summary>
|
||||
/// GodotTimeSource 的单元测试。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class GodotTimeSourceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证增量模式会直接累加传入的 delta。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Update_Should_Accumulate_Delta_When_Using_Delta_Mode()
|
||||
{
|
||||
var values = new Queue<double>([0.1, 0.2]);
|
||||
var timeSource = new GodotTimeSource(() => values.Dequeue());
|
||||
|
||||
timeSource.Update();
|
||||
Assert.That(timeSource.DeltaTime, Is.EqualTo(0.1).Within(0.0001));
|
||||
Assert.That(timeSource.CurrentTime, Is.EqualTo(0.1).Within(0.0001));
|
||||
|
||||
timeSource.Update();
|
||||
Assert.That(timeSource.DeltaTime, Is.EqualTo(0.2).Within(0.0001));
|
||||
Assert.That(timeSource.CurrentTime, Is.EqualTo(0.3).Within(0.0001));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证绝对时间模式会根据前后两次采样计算 delta。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Update_Should_Calculate_Delta_When_Using_Absolute_Time_Mode()
|
||||
{
|
||||
var values = new Queue<double>([1.0, 1.25, 2.0]);
|
||||
var timeSource = new GodotTimeSource(() => values.Dequeue(), useAbsoluteTime: true);
|
||||
|
||||
timeSource.Update();
|
||||
Assert.That(timeSource.DeltaTime, Is.EqualTo(0).Within(0.0001));
|
||||
Assert.That(timeSource.CurrentTime, Is.EqualTo(1.0).Within(0.0001));
|
||||
|
||||
timeSource.Update();
|
||||
Assert.That(timeSource.DeltaTime, Is.EqualTo(0.25).Within(0.0001));
|
||||
Assert.That(timeSource.CurrentTime, Is.EqualTo(1.25).Within(0.0001));
|
||||
|
||||
timeSource.Update();
|
||||
Assert.That(timeSource.DeltaTime, Is.EqualTo(0.75).Within(0.0001));
|
||||
Assert.That(timeSource.CurrentTime, Is.EqualTo(2.0).Within(0.0001));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证绝对时间源在回拨时仍保持单调,不会把 CurrentTime 拉回去。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Update_Should_Keep_Absolute_Time_Monotonic_When_Provider_Goes_Backwards()
|
||||
{
|
||||
var values = new Queue<double>([5.0, 4.0, 6.5]);
|
||||
var timeSource = new GodotTimeSource(() => values.Dequeue(), useAbsoluteTime: true);
|
||||
|
||||
timeSource.Update();
|
||||
timeSource.Update();
|
||||
|
||||
Assert.That(timeSource.DeltaTime, Is.EqualTo(0).Within(0.0001));
|
||||
Assert.That(timeSource.CurrentTime, Is.EqualTo(5.0).Within(0.0001));
|
||||
|
||||
timeSource.Update();
|
||||
|
||||
Assert.That(timeSource.DeltaTime, Is.EqualTo(1.5).Within(0.0001));
|
||||
Assert.That(timeSource.CurrentTime, Is.EqualTo(6.5).Within(0.0001));
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TestTargetFrameworks Condition="'$(TestTargetFrameworks)' == ''">net10.0</TestTargetFrameworks>
|
||||
<TargetFrameworks>$(TestTargetFrameworks)</TargetFrameworks>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<WarningLevel>0</WarningLevel>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0"/>
|
||||
<PackageReference Include="NUnit" Version="4.5.1"/>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GFramework.Godot\GFramework.Godot.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@ -35,25 +35,6 @@ public static class CoroutineNodeExtensions
|
||||
return Timing.RunCoroutine(coroutine, segment, tag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 以指定节点作为生命周期所有者运行协程。
|
||||
/// </summary>
|
||||
/// <param name="owner">拥有该协程生命周期的节点。</param>
|
||||
/// <param name="coroutine">要启动的协程枚举器。</param>
|
||||
/// <param name="segment">协程运行的时间段。</param>
|
||||
/// <param name="tag">协程标签。</param>
|
||||
/// <param name="cancellationToken">可选取消令牌。</param>
|
||||
/// <returns>返回协程句柄。</returns>
|
||||
public static CoroutineHandle RunCoroutine(
|
||||
this Node owner,
|
||||
IEnumerator<IYieldInstruction> coroutine,
|
||||
Segment segment = Segment.Process,
|
||||
string? tag = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Timing.RunOwnedCoroutine(owner, coroutine, segment, tag, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 让协程在指定节点被销毁时自动取消。
|
||||
/// </summary>
|
||||
|
||||
@ -3,80 +3,40 @@
|
||||
namespace GFramework.Godot.Coroutine;
|
||||
|
||||
/// <summary>
|
||||
/// Godot 时间源实现,用于为协程调度器提供缩放时间或真实时间数据。
|
||||
/// Godot时间源实现,用于提供基于Godot引擎的时间信息
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">
|
||||
/// 时间提供函数。
|
||||
/// 在默认模式下该函数返回“本帧增量”;在绝对时间模式下该函数返回“当前绝对时间(秒)”。
|
||||
/// </param>
|
||||
/// <param name="useAbsoluteTime">
|
||||
/// 是否把 <paramref name="timeProvider" /> 返回值解释为绝对时间。
|
||||
/// 启用后,<see cref="Update" /> 会通过相邻两次读数计算 <see cref="DeltaTime" />。
|
||||
/// </param>
|
||||
public sealed class GodotTimeSource(Func<double> timeProvider, bool useAbsoluteTime = false) : ITimeSource
|
||||
/// <param name="getDeltaFunc">获取增量时间的函数委托</param>
|
||||
public class GodotTimeSource(Func<double> getDeltaFunc) : ITimeSource
|
||||
{
|
||||
private readonly Func<double> _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
private bool _initialized;
|
||||
private double _lastAbsoluteTime;
|
||||
private readonly Func<double> _getDeltaFunc = getDeltaFunc ?? throw new ArgumentNullException(nameof(getDeltaFunc));
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前累计时间。
|
||||
/// 获取当前累计时间
|
||||
/// </summary>
|
||||
public double CurrentTime { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取上一帧的时间增量。
|
||||
/// 获取上一帧的时间增量
|
||||
/// </summary>
|
||||
public double DeltaTime { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间源,计算新的时间增量与累计时间。
|
||||
/// 更新时间源,计算新的增量时间和累计时间
|
||||
/// </summary>
|
||||
public void Update()
|
||||
{
|
||||
var value = _timeProvider();
|
||||
if (useAbsoluteTime)
|
||||
{
|
||||
if (!_initialized)
|
||||
{
|
||||
_initialized = true;
|
||||
_lastAbsoluteTime = value;
|
||||
CurrentTime = value;
|
||||
DeltaTime = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 对绝对时间源做单调钳制,避免 provider 回拨后把 CurrentTime 也拉回去。
|
||||
var nextTime = Math.Max(value, _lastAbsoluteTime);
|
||||
DeltaTime = nextTime - _lastAbsoluteTime;
|
||||
_lastAbsoluteTime = nextTime;
|
||||
CurrentTime = nextTime;
|
||||
return;
|
||||
}
|
||||
|
||||
DeltaTime = value;
|
||||
// 调用外部提供的函数获取当前帧的时间增量
|
||||
DeltaTime = _getDeltaFunc();
|
||||
// 累加到总时间中
|
||||
CurrentTime += DeltaTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建基于 Godot 单调时钟的真实时间源。
|
||||
/// </summary>
|
||||
/// <returns>返回一个不受场景暂停与时间缩放影响的时间源实例。</returns>
|
||||
public static GodotTimeSource CreateRealtime()
|
||||
{
|
||||
return new GodotTimeSource(
|
||||
() => Time.GetTicksUsec() / 1_000_000.0,
|
||||
useAbsoluteTime: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置时间源到初始状态。
|
||||
/// 重置时间源到初始状态
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
CurrentTime = 0;
|
||||
DeltaTime = 0;
|
||||
_initialized = false;
|
||||
_lastAbsoluteTime = 0;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -132,8 +132,7 @@ public class SchemaConfigGeneratorSnapshotTests
|
||||
"type": "string",
|
||||
"description": "Monster reference id.",
|
||||
"minLength": 2,
|
||||
"maxLength": 32,
|
||||
"x-gframework-ref-table": "monster"
|
||||
"maxLength": 32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,264 +91,4 @@ public class SchemaConfigGeneratorTests
|
||||
Assert.That(diagnostic.GetMessage(), Does.Contain("array<array>"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 schema 字段名无法映射为合法 C# 标识符时会直接给出诊断,而不是生成不可编译代码。
|
||||
/// </summary>
|
||||
/// <param name="schemaKey">会映射为非法 C# 标识符的 schema key。</param>
|
||||
/// <param name="generatedIdentifier">当前命名规范化逻辑生成出的非法标识符。</param>
|
||||
[TestCase("drop$item", "Drop$item")]
|
||||
[TestCase("1-phase", "1Phase")]
|
||||
public void Run_Should_Report_Diagnostic_When_Schema_Key_Maps_To_Invalid_CSharp_Identifier(
|
||||
string schemaKey,
|
||||
string generatedIdentifier)
|
||||
{
|
||||
const string source = """
|
||||
namespace TestApp
|
||||
{
|
||||
public sealed class Dummy
|
||||
{
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var schema = $$"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "{{schemaKey}}"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"{{schemaKey}}": { "type": "string" }
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = SchemaGeneratorTestDriver.Run(
|
||||
source,
|
||||
("monster.schema.json", schema));
|
||||
|
||||
var diagnostic = result.Results.Single().Diagnostics.Single();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_006"));
|
||||
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
|
||||
Assert.That(diagnostic.GetMessage(), Does.Contain(schemaKey));
|
||||
Assert.That(diagnostic.GetMessage(), Does.Contain(generatedIdentifier));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证引用元数据成员名在不同路径规范化后发生碰撞时,生成器仍会分配全局唯一的成员名。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Run_Should_Assign_Globally_Unique_Reference_Metadata_Member_Names()
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
public interface IConfigRegistry
|
||||
{
|
||||
IConfigTable<TKey, TValue> GetTable<TKey, TValue>(string name)
|
||||
where TKey : notnull;
|
||||
|
||||
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
|
||||
where TKey : notnull;
|
||||
}
|
||||
}
|
||||
|
||||
namespace GFramework.Game.Config
|
||||
{
|
||||
public sealed class YamlConfigLoader
|
||||
{
|
||||
public YamlConfigLoader RegisterTable<TKey, TValue>(
|
||||
string tableName,
|
||||
string relativePath,
|
||||
string schemaRelativePath,
|
||||
Func<TValue, TKey> keySelector,
|
||||
IEqualityComparer<TKey>? comparer = null)
|
||||
where TKey : notnull
|
||||
{
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
const string schema = """
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"drop-items": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"x-gframework-ref-table": "item"
|
||||
},
|
||||
"drop_items": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"x-gframework-ref-table": "item"
|
||||
},
|
||||
"dropItems1": {
|
||||
"type": "string",
|
||||
"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);
|
||||
|
||||
Assert.That(result.Results.Single().Diagnostics, Is.Empty);
|
||||
Assert.That(generatedSources.TryGetValue("MonsterConfigBindings.g.cs", out var bindingsSource), Is.True);
|
||||
Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems ="));
|
||||
Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems1 ="));
|
||||
Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems11 ="));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证生成器只为顶层非主键标量字段生成轻量查询辅助,
|
||||
/// 避免把数组、对象和引用字段误生成为查询 API。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Run_Should_Generate_Query_Helpers_Only_For_Top_Level_Scalar_Properties()
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
public interface IConfigRegistry
|
||||
{
|
||||
IConfigTable<TKey, TValue> GetTable<TKey, TValue>(string name)
|
||||
where TKey : notnull;
|
||||
|
||||
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
|
||||
where TKey : notnull;
|
||||
}
|
||||
}
|
||||
|
||||
namespace GFramework.Game.Config
|
||||
{
|
||||
public sealed class YamlConfigLoader
|
||||
{
|
||||
public YamlConfigLoader RegisterTable<TKey, TValue>(
|
||||
string tableName,
|
||||
string relativePath,
|
||||
string schemaRelativePath,
|
||||
Func<TValue, TKey> keySelector,
|
||||
IEqualityComparer<TKey>? comparer = null)
|
||||
where TKey : notnull
|
||||
{
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
const string schema = """
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"hp": { "type": "integer" },
|
||||
"dropItems": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"targetId": {
|
||||
"type": "string",
|
||||
"x-gframework-ref-table": "monster"
|
||||
},
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"gold": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
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);
|
||||
|
||||
Assert.That(result.Results.Single().Diagnostics, Is.Empty);
|
||||
Assert.That(generatedSources.TryGetValue("MonsterTable.g.cs", out var tableSource), Is.True);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(tableSource, Does.Contain("FindByName(string value)"));
|
||||
Assert.That(tableSource, Does.Contain("TryFindFirstByName(string value, out MonsterConfig? result)"));
|
||||
Assert.That(tableSource, Does.Contain("FindByHp(int? value)"));
|
||||
Assert.That(tableSource, Does.Contain("TryFindFirstByHp(int? value, out MonsterConfig? result)"));
|
||||
Assert.That(tableSource, Does.Not.Contain("FindById("));
|
||||
Assert.That(tableSource, Does.Not.Contain("FindByDropItems("));
|
||||
Assert.That(tableSource, Does.Not.Contain("FindByTargetId("));
|
||||
Assert.That(tableSource, Does.Not.Contain("FindByReward("));
|
||||
Assert.That(tableSource, Does.Not.Contain("TryFindFirstById("));
|
||||
Assert.That(tableSource, Does.Not.Contain("TryFindFirstByDropItems("));
|
||||
Assert.That(tableSource, Does.Not.Contain("TryFindFirstByTargetId("));
|
||||
Assert.That(tableSource, Does.Not.Contain("TryFindFirstByReward("));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -117,7 +117,6 @@ public sealed partial class MonsterConfig
|
||||
/// <remarks>
|
||||
/// Schema property path: 'phases[].monsterId'.
|
||||
/// Constraints: minLength = 2, maxLength = 32.
|
||||
/// References config table: 'monster'.
|
||||
/// Generated default initializer: = string.Empty;
|
||||
/// </remarks>
|
||||
public string MonsterId { get; set; } = string.Empty;
|
||||
|
||||
@ -9,61 +9,6 @@ namespace GFramework.Game.Config.Generated;
|
||||
/// </summary>
|
||||
public static class MonsterConfigBindings
|
||||
{
|
||||
/// <summary>
|
||||
/// Describes one schema property that declares <c>x-gframework-ref-table</c> metadata.
|
||||
/// </summary>
|
||||
public readonly struct ReferenceMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes one generated cross-table reference descriptor.
|
||||
/// </summary>
|
||||
/// <param name="displayPath">Schema property path.</param>
|
||||
/// <param name="referencedTableName">Referenced runtime table name.</param>
|
||||
/// <param name="valueSchemaType">Schema scalar type used by the reference value.</param>
|
||||
/// <param name="isCollection">Whether the property stores multiple reference keys.</param>
|
||||
public ReferenceMetadata(
|
||||
string displayPath,
|
||||
string referencedTableName,
|
||||
string valueSchemaType,
|
||||
bool isCollection)
|
||||
{
|
||||
DisplayPath = displayPath ?? throw new global::System.ArgumentNullException(nameof(displayPath));
|
||||
ReferencedTableName = referencedTableName ?? throw new global::System.ArgumentNullException(nameof(referencedTableName));
|
||||
ValueSchemaType = valueSchemaType ?? throw new global::System.ArgumentNullException(nameof(valueSchemaType));
|
||||
IsCollection = isCollection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema property path such as <c>dropItems</c> or <c>phases[].monsterId</c>.
|
||||
/// </summary>
|
||||
public string DisplayPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the runtime registration name of the referenced config table.
|
||||
/// </summary>
|
||||
public string ReferencedTableName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema scalar type used by the referenced key value.
|
||||
/// </summary>
|
||||
public string ValueSchemaType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the property stores multiple reference keys.
|
||||
/// </summary>
|
||||
public bool IsCollection { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups the schema-derived metadata constants so consumer code can reuse one stable entry point.
|
||||
/// </summary>
|
||||
public static class Metadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the logical config domain derived from the schema base name. The current runtime convention keeps this value aligned with the generated table name.
|
||||
/// </summary>
|
||||
public const string ConfigDomain = "monster";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the runtime registration name of the generated config table.
|
||||
/// </summary>
|
||||
@ -78,88 +23,6 @@ public static class MonsterConfigBindings
|
||||
/// Gets the schema file path expected by the generated registration helper.
|
||||
/// </summary>
|
||||
public const string SchemaRelativePath = "schemas/monster.schema.json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the logical config domain derived from the schema base name. The current runtime convention keeps this value aligned with the generated table name.
|
||||
/// </summary>
|
||||
public const string ConfigDomain = Metadata.ConfigDomain;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the runtime registration name of the generated config table.
|
||||
/// </summary>
|
||||
public const string TableName = Metadata.TableName;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the config directory path expected by the generated registration helper.
|
||||
/// </summary>
|
||||
public const string ConfigRelativePath = Metadata.ConfigRelativePath;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema file path expected by the generated registration helper.
|
||||
/// </summary>
|
||||
public const string SchemaRelativePath = Metadata.SchemaRelativePath;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes generated metadata for schema properties that declare <c>x-gframework-ref-table</c>.
|
||||
/// </summary>
|
||||
public static class References
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets generated reference metadata for schema property path 'dropItems'.
|
||||
/// </summary>
|
||||
public static readonly ReferenceMetadata DropItems = new(
|
||||
"dropItems",
|
||||
"item",
|
||||
"string",
|
||||
true);
|
||||
|
||||
/// <summary>
|
||||
/// Gets generated reference metadata for schema property path 'phases[].monsterId'.
|
||||
/// </summary>
|
||||
public static readonly ReferenceMetadata PhasesItemsMonsterId = new(
|
||||
"phases[].monsterId",
|
||||
"monster",
|
||||
"string",
|
||||
false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all generated cross-table reference descriptors for the current schema.
|
||||
/// </summary>
|
||||
public static global::System.Collections.Generic.IReadOnlyList<ReferenceMetadata> All { get; } = global::System.Array.AsReadOnly(new ReferenceMetadata[]
|
||||
{
|
||||
DropItems,
|
||||
PhasesItemsMonsterId,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Tries to resolve generated reference metadata by schema property path.
|
||||
/// </summary>
|
||||
/// <param name="displayPath">Schema property path.</param>
|
||||
/// <param name="metadata">Resolved generated reference metadata when the path is known; otherwise the default value.</param>
|
||||
/// <returns>True when the schema property path has generated cross-table metadata; otherwise false.</returns>
|
||||
public static bool TryGetByDisplayPath(string displayPath, out ReferenceMetadata metadata)
|
||||
{
|
||||
if (displayPath is null)
|
||||
{
|
||||
throw new global::System.ArgumentNullException(nameof(displayPath));
|
||||
}
|
||||
|
||||
if (string.Equals(displayPath, "dropItems", global::System.StringComparison.Ordinal))
|
||||
{
|
||||
metadata = DropItems;
|
||||
return true;
|
||||
}
|
||||
if (string.Equals(displayPath, "phases[].monsterId", global::System.StringComparison.Ordinal))
|
||||
{
|
||||
metadata = PhasesItemsMonsterId;
|
||||
return true;
|
||||
}
|
||||
|
||||
metadata = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the generated config table using the schema-derived runtime conventions.
|
||||
@ -177,9 +40,9 @@ public static class MonsterConfigBindings
|
||||
}
|
||||
|
||||
return loader.RegisterTable<int, MonsterConfig>(
|
||||
Metadata.TableName,
|
||||
Metadata.ConfigRelativePath,
|
||||
Metadata.SchemaRelativePath,
|
||||
TableName,
|
||||
ConfigRelativePath,
|
||||
SchemaRelativePath,
|
||||
static config => config.Id,
|
||||
comparer);
|
||||
}
|
||||
@ -197,7 +60,7 @@ public static class MonsterConfigBindings
|
||||
throw new global::System.ArgumentNullException(nameof(registry));
|
||||
}
|
||||
|
||||
return new MonsterTable(registry.GetTable<int, MonsterConfig>(Metadata.TableName));
|
||||
return new MonsterTable(registry.GetTable<int, MonsterConfig>(TableName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -214,7 +77,7 @@ public static class MonsterConfigBindings
|
||||
throw new global::System.ArgumentNullException(nameof(registry));
|
||||
}
|
||||
|
||||
if (registry.TryGetTable<int, MonsterConfig>(Metadata.TableName, out var innerTable) && innerTable is not null)
|
||||
if (registry.TryGetTable<int, MonsterConfig>(TableName, out var innerTable) && innerTable is not null)
|
||||
{
|
||||
table = new MonsterTable(innerTable);
|
||||
return true;
|
||||
|
||||
@ -52,102 +52,4 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.
|
||||
{
|
||||
return _inner.All();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds all config entries whose property 'name' equals the supplied value.
|
||||
/// </summary>
|
||||
/// <param name="value">The property value to match.</param>
|
||||
/// <returns>A read-only snapshot containing every matching config entry.</returns>
|
||||
/// <remarks>
|
||||
/// The generated helper performs a deterministic linear scan over <see cref="All"/> so it stays compatible with runtime hot reload and does not require secondary index infrastructure.
|
||||
/// </remarks>
|
||||
public global::System.Collections.Generic.IReadOnlyList<MonsterConfig> FindByName(string value)
|
||||
{
|
||||
var matches = new global::System.Collections.Generic.List<MonsterConfig>();
|
||||
|
||||
// Scan the current table snapshot on demand so generated helpers stay aligned with reloadable runtime data.
|
||||
foreach (var candidate in All())
|
||||
{
|
||||
if (global::System.Collections.Generic.EqualityComparer<string>.Default.Equals(candidate.Name, value))
|
||||
{
|
||||
matches.Add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return matches.Count == 0 ? global::System.Array.Empty<MonsterConfig>() : matches.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to find the first config entry whose property 'name' equals the supplied value.
|
||||
/// </summary>
|
||||
/// <param name="value">The property value to match.</param>
|
||||
/// <param name="result">The first matching config entry when lookup succeeds; otherwise <see langword="null" />.</param>
|
||||
/// <returns><see langword="true" /> when a matching config entry is found; otherwise <see langword="false" />.</returns>
|
||||
/// <remarks>
|
||||
/// The generated helper walks the same snapshot exposed by <see cref="All"/> and returns the first match in iteration order.
|
||||
/// </remarks>
|
||||
public bool TryFindFirstByName(string value, out MonsterConfig? result)
|
||||
{
|
||||
// Keep the search path allocation-free for the first-match case by exiting as soon as one entry matches.
|
||||
foreach (var candidate in All())
|
||||
{
|
||||
if (global::System.Collections.Generic.EqualityComparer<string>.Default.Equals(candidate.Name, value))
|
||||
{
|
||||
result = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
result = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds all config entries whose property 'hp' equals the supplied value.
|
||||
/// </summary>
|
||||
/// <param name="value">The property value to match.</param>
|
||||
/// <returns>A read-only snapshot containing every matching config entry.</returns>
|
||||
/// <remarks>
|
||||
/// The generated helper performs a deterministic linear scan over <see cref="All"/> so it stays compatible with runtime hot reload and does not require secondary index infrastructure.
|
||||
/// </remarks>
|
||||
public global::System.Collections.Generic.IReadOnlyList<MonsterConfig> FindByHp(int? value)
|
||||
{
|
||||
var matches = new global::System.Collections.Generic.List<MonsterConfig>();
|
||||
|
||||
// Scan the current table snapshot on demand so generated helpers stay aligned with reloadable runtime data.
|
||||
foreach (var candidate in All())
|
||||
{
|
||||
if (global::System.Collections.Generic.EqualityComparer<int?>.Default.Equals(candidate.Hp, value))
|
||||
{
|
||||
matches.Add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return matches.Count == 0 ? global::System.Array.Empty<MonsterConfig>() : matches.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to find the first config entry whose property 'hp' equals the supplied value.
|
||||
/// </summary>
|
||||
/// <param name="value">The property value to match.</param>
|
||||
/// <param name="result">The first matching config entry when lookup succeeds; otherwise <see langword="null" />.</param>
|
||||
/// <returns><see langword="true" /> when a matching config entry is found; otherwise <see langword="false" />.</returns>
|
||||
/// <remarks>
|
||||
/// The generated helper walks the same snapshot exposed by <see cref="All"/> and returns the first match in iteration order.
|
||||
/// </remarks>
|
||||
public bool TryFindFirstByHp(int? value, out MonsterConfig? result)
|
||||
{
|
||||
// Keep the search path allocation-free for the first-match case by exiting as soon as one entry matches.
|
||||
foreach (var candidate in All())
|
||||
{
|
||||
if (global::System.Collections.Generic.EqualityComparer<int?>.Default.Equals(candidate.Hp, value))
|
||||
{
|
||||
result = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
result = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,7 +23,6 @@
|
||||
GF_ConfigSchema_003 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
|
||||
GF_ConfigSchema_004 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
|
||||
GF_ConfigSchema_005 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
|
||||
GF_ConfigSchema_006 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
|
||||
GF_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic
|
||||
GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic
|
||||
GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic
|
||||
|
||||
@ -253,10 +253,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
var title = TryGetMetadataString(property.Value, "title");
|
||||
var description = TryGetMetadataString(property.Value, "description");
|
||||
var refTableName = TryGetMetadataString(property.Value, "x-gframework-ref-table");
|
||||
if (!TryBuildPropertyIdentifier(filePath, displayPath, property.Name, out var propertyName, out var diagnostic))
|
||||
{
|
||||
return ParsedPropertyResult.FromDiagnostic(diagnostic!);
|
||||
}
|
||||
var propertyName = ToPascalCase(property.Name);
|
||||
|
||||
switch (schemaType)
|
||||
{
|
||||
@ -560,7 +557,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
private static string GenerateTableClass(SchemaFileSpec schema)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
var queryableProperties = CollectQueryableProperties(schema).ToArray();
|
||||
builder.AppendLine("// <auto-generated />");
|
||||
builder.AppendLine("#nullable enable");
|
||||
builder.AppendLine();
|
||||
@ -621,15 +617,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" return _inner.All();");
|
||||
builder.AppendLine(" }");
|
||||
|
||||
foreach (var property in queryableProperties)
|
||||
{
|
||||
builder.AppendLine();
|
||||
AppendFindByPropertyMethod(builder, schema, property);
|
||||
builder.AppendLine();
|
||||
AppendTryFindFirstByPropertyMethod(builder, schema, property);
|
||||
}
|
||||
|
||||
builder.AppendLine("}");
|
||||
return builder.ToString().TrimEnd();
|
||||
}
|
||||
@ -647,7 +634,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
var getMethodName = $"Get{schema.EntityName}Table";
|
||||
var tryGetMethodName = $"TryGet{schema.EntityName}Table";
|
||||
var bindingsClassName = $"{schema.EntityName}ConfigBindings";
|
||||
var referenceSpecs = CollectReferenceSpecs(schema.RootObject).ToArray();
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("// <auto-generated />");
|
||||
@ -664,80 +650,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
builder.AppendLine($"public static class {bindingsClassName}");
|
||||
builder.AppendLine("{");
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Describes one schema property that declares <c>x-gframework-ref-table</c> metadata.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public readonly struct ReferenceMetadata");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(" /// Initializes one generated cross-table reference descriptor.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" /// <param name=\"displayPath\">Schema property path.</param>");
|
||||
builder.AppendLine(" /// <param name=\"referencedTableName\">Referenced runtime table name.</param>");
|
||||
builder.AppendLine(
|
||||
" /// <param name=\"valueSchemaType\">Schema scalar type used by the reference value.</param>");
|
||||
builder.AppendLine(
|
||||
" /// <param name=\"isCollection\">Whether the property stores multiple reference keys.</param>");
|
||||
builder.AppendLine(" public ReferenceMetadata(");
|
||||
builder.AppendLine(" string displayPath,");
|
||||
builder.AppendLine(" string referencedTableName,");
|
||||
builder.AppendLine(" string valueSchemaType,");
|
||||
builder.AppendLine(" bool isCollection)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(
|
||||
" DisplayPath = displayPath ?? throw new global::System.ArgumentNullException(nameof(displayPath));");
|
||||
builder.AppendLine(
|
||||
" ReferencedTableName = referencedTableName ?? throw new global::System.ArgumentNullException(nameof(referencedTableName));");
|
||||
builder.AppendLine(
|
||||
" ValueSchemaType = valueSchemaType ?? throw new global::System.ArgumentNullException(nameof(valueSchemaType));");
|
||||
builder.AppendLine(" IsCollection = isCollection;");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Gets the schema property path such as <c>dropItems</c> or <c>phases[].monsterId</c>.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public string DisplayPath { get; }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(" /// Gets the runtime registration name of the referenced config table.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public string ReferencedTableName { get; }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(" /// Gets the schema scalar type used by the referenced key value.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public string ValueSchemaType { get; }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Gets a value indicating whether the property stores multiple reference keys.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public bool IsCollection { get; }");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Groups the schema-derived metadata constants so consumer code can reuse one stable entry point.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public static class Metadata");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Gets the logical config domain derived from the schema base name. The current runtime convention keeps this value aligned with the generated table name.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(
|
||||
$" public const string ConfigDomain = {SymbolDisplay.FormatLiteral(schema.TableRegistrationName, true)};");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(" /// Gets the runtime registration name of the generated config table.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(
|
||||
$" public const string TableName = {SymbolDisplay.FormatLiteral(schema.TableRegistrationName, true)};");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Gets the config directory path expected by the generated registration helper.");
|
||||
builder.AppendLine(" /// Gets the config directory path expected by the generated registration helper.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(
|
||||
$" public const string ConfigRelativePath = {SymbolDisplay.FormatLiteral(schema.ConfigRelativePath, true)};");
|
||||
@ -747,119 +666,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(
|
||||
$" public const string SchemaRelativePath = {SymbolDisplay.FormatLiteral(schema.SchemaRelativePath, true)};");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Gets the logical config domain derived from the schema base name. The current runtime convention keeps this value aligned with the generated table name.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public const string ConfigDomain = Metadata.ConfigDomain;");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(" /// Gets the runtime registration name of the generated config table.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public const string TableName = Metadata.TableName;");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(" /// Gets the config directory path expected by the generated registration helper.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public const string ConfigRelativePath = Metadata.ConfigRelativePath;");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(" /// Gets the schema file path expected by the generated registration helper.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public const string SchemaRelativePath = Metadata.SchemaRelativePath;");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Exposes generated metadata for schema properties that declare <c>x-gframework-ref-table</c>.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public static class References");
|
||||
builder.AppendLine(" {");
|
||||
|
||||
foreach (var referenceSpec in referenceSpecs)
|
||||
{
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
$" /// Gets generated reference metadata for schema property path '{EscapeXmlDocumentation(referenceSpec.DisplayPath)}'.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(
|
||||
$" public static readonly ReferenceMetadata {referenceSpec.MemberName} = new(");
|
||||
builder.AppendLine(
|
||||
$" {SymbolDisplay.FormatLiteral(referenceSpec.DisplayPath, true)},");
|
||||
builder.AppendLine(
|
||||
$" {SymbolDisplay.FormatLiteral(referenceSpec.ReferencedTableName, true)},");
|
||||
builder.AppendLine(
|
||||
$" {SymbolDisplay.FormatLiteral(referenceSpec.ValueSchemaType, true)},");
|
||||
builder.AppendLine(
|
||||
$" {(referenceSpec.IsCollection ? "true" : "false")});");
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Gets all generated cross-table reference descriptors for the current schema.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
if (referenceSpecs.Length == 0)
|
||||
{
|
||||
builder.AppendLine(
|
||||
" public static global::System.Collections.Generic.IReadOnlyList<ReferenceMetadata> All { get; } = global::System.Array.Empty<ReferenceMetadata>();");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.AppendLine(
|
||||
" public static global::System.Collections.Generic.IReadOnlyList<ReferenceMetadata> All { get; } = global::System.Array.AsReadOnly(new ReferenceMetadata[]");
|
||||
builder.AppendLine(" {");
|
||||
foreach (var referenceSpec in referenceSpecs)
|
||||
{
|
||||
builder.AppendLine($" {referenceSpec.MemberName},");
|
||||
}
|
||||
|
||||
builder.AppendLine(" });");
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(" /// Tries to resolve generated reference metadata by schema property path.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" /// <param name=\"displayPath\">Schema property path.</param>");
|
||||
builder.AppendLine(
|
||||
" /// <param name=\"metadata\">Resolved generated reference metadata when the path is known; otherwise the default value.</param>");
|
||||
builder.AppendLine(
|
||||
" /// <returns>True when the schema property path has generated cross-table metadata; otherwise false.</returns>");
|
||||
builder.AppendLine(
|
||||
" public static bool TryGetByDisplayPath(string displayPath, out ReferenceMetadata metadata)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" if (displayPath is null)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(displayPath));");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
|
||||
if (referenceSpecs.Length == 0)
|
||||
{
|
||||
builder.AppendLine(" metadata = default;");
|
||||
builder.AppendLine(" return false;");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var referenceSpec in referenceSpecs)
|
||||
{
|
||||
builder.AppendLine(
|
||||
$" if (string.Equals(displayPath, {SymbolDisplay.FormatLiteral(referenceSpec.DisplayPath, true)}, global::System.StringComparison.Ordinal))");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine($" metadata = {referenceSpec.MemberName};");
|
||||
builder.AppendLine(" return true;");
|
||||
builder.AppendLine(" }");
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" metadata = default;");
|
||||
builder.AppendLine(" return false;");
|
||||
}
|
||||
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
@ -882,9 +688,9 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(
|
||||
$" return loader.RegisterTable<{schema.KeyClrType}, {schema.ClassName}>(");
|
||||
builder.AppendLine(" Metadata.TableName,");
|
||||
builder.AppendLine(" Metadata.ConfigRelativePath,");
|
||||
builder.AppendLine(" Metadata.SchemaRelativePath,");
|
||||
builder.AppendLine(" TableName,");
|
||||
builder.AppendLine(" ConfigRelativePath,");
|
||||
builder.AppendLine(" SchemaRelativePath,");
|
||||
builder.AppendLine($" static config => config.{schema.KeyPropertyName},");
|
||||
builder.AppendLine(" comparer);");
|
||||
builder.AppendLine(" }");
|
||||
@ -905,7 +711,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(
|
||||
$" return new {schema.TableName}(registry.GetTable<{schema.KeyClrType}, {schema.ClassName}>(Metadata.TableName));");
|
||||
$" return new {schema.TableName}(registry.GetTable<{schema.KeyClrType}, {schema.ClassName}>(TableName));");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
@ -927,7 +733,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(
|
||||
$" if (registry.TryGetTable<{schema.KeyClrType}, {schema.ClassName}>(Metadata.TableName, out var innerTable) && innerTable is not null)");
|
||||
$" if (registry.TryGetTable<{schema.KeyClrType}, {schema.ClassName}>(TableName, out var innerTable) && innerTable is not null)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine($" table = new {schema.TableName}(innerTable);");
|
||||
builder.AppendLine(" return true;");
|
||||
@ -940,206 +746,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
return builder.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收集 schema 中声明的跨表引用元数据,并为生成代码分配稳定成员名。
|
||||
/// </summary>
|
||||
/// <param name="rootObject">根对象模型。</param>
|
||||
/// <returns>生成期引用元数据集合。</returns>
|
||||
private static IEnumerable<GeneratedReferenceSpec> CollectReferenceSpecs(SchemaObjectSpec rootObject)
|
||||
{
|
||||
var nextSuffixByBaseMemberName = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var allocatedMemberNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var referenceSeed in EnumerateReferenceSeeds(rootObject.Properties))
|
||||
{
|
||||
var baseMemberName = BuildReferenceMemberName(referenceSeed.DisplayPath);
|
||||
var memberName = baseMemberName;
|
||||
if (!allocatedMemberNames.Add(memberName))
|
||||
{
|
||||
// Track globally allocated member names because a suffixed duplicate from one path can collide
|
||||
// with the unsuffixed base name produced by a later, different path.
|
||||
var duplicateCount = nextSuffixByBaseMemberName.TryGetValue(baseMemberName, out var nextSuffix)
|
||||
? nextSuffix + 1
|
||||
: 1;
|
||||
|
||||
memberName = $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}";
|
||||
while (!allocatedMemberNames.Add(memberName))
|
||||
{
|
||||
duplicateCount++;
|
||||
memberName = $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}";
|
||||
}
|
||||
|
||||
nextSuffixByBaseMemberName[baseMemberName] = duplicateCount;
|
||||
}
|
||||
else
|
||||
{
|
||||
nextSuffixByBaseMemberName[baseMemberName] = 0;
|
||||
}
|
||||
|
||||
yield return new GeneratedReferenceSpec(
|
||||
memberName,
|
||||
referenceSeed.DisplayPath,
|
||||
referenceSeed.ReferencedTableName,
|
||||
referenceSeed.ValueSchemaType,
|
||||
referenceSeed.IsCollection);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收集适合生成轻量查询辅助的根级标量字段。
|
||||
/// 当前实现故意限定在顶层非主键标量字段,避免把嵌套结构、数组或引用语义提前固化为运行时契约。
|
||||
/// </summary>
|
||||
/// <param name="schema">生成器级 schema 模型。</param>
|
||||
/// <returns>可生成查询辅助的属性集合。</returns>
|
||||
private static IEnumerable<SchemaPropertySpec> CollectQueryableProperties(SchemaFileSpec schema)
|
||||
{
|
||||
foreach (var property in schema.RootObject.Properties)
|
||||
{
|
||||
if (property.TypeSpec.Kind != SchemaNodeKind.Scalar)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(property.TypeSpec.RefTableName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(property.PropertyName, schema.KeyPropertyName, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return property;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成按字段匹配全部结果的轻量查询辅助。
|
||||
/// </summary>
|
||||
/// <param name="builder">输出缓冲区。</param>
|
||||
/// <param name="schema">生成器级 schema 模型。</param>
|
||||
/// <param name="property">要生成查询辅助的字段模型。</param>
|
||||
private static void AppendFindByPropertyMethod(
|
||||
StringBuilder builder,
|
||||
SchemaFileSpec schema,
|
||||
SchemaPropertySpec property)
|
||||
{
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
$" /// Finds all config entries whose property '{EscapeXmlDocumentation(property.DisplayPath)}' equals the supplied value.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" /// <param name=\"value\">The property value to match.</param>");
|
||||
builder.AppendLine(" /// <returns>A read-only snapshot containing every matching config entry.</returns>");
|
||||
builder.AppendLine(" /// <remarks>");
|
||||
builder.AppendLine(
|
||||
" /// The generated helper performs a deterministic linear scan over <see cref=\"All\"/> so it stays compatible with runtime hot reload and does not require secondary index infrastructure.");
|
||||
builder.AppendLine(" /// </remarks>");
|
||||
builder.AppendLine(
|
||||
$" public global::System.Collections.Generic.IReadOnlyList<{schema.ClassName}> FindBy{property.PropertyName}({property.TypeSpec.ClrType} value)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(
|
||||
$" var matches = new global::System.Collections.Generic.List<{schema.ClassName}>();");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(
|
||||
" // Scan the current table snapshot on demand so generated helpers stay aligned with reloadable runtime data.");
|
||||
builder.AppendLine(" foreach (var candidate in All())");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(
|
||||
$" if (global::System.Collections.Generic.EqualityComparer<{property.TypeSpec.ClrType}>.Default.Equals(candidate.{property.PropertyName}, value))");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" matches.Add(candidate);");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(
|
||||
$" return matches.Count == 0 ? global::System.Array.Empty<{schema.ClassName}>() : matches.AsReadOnly();");
|
||||
builder.AppendLine(" }");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成按字段匹配首个结果的轻量查询辅助。
|
||||
/// </summary>
|
||||
/// <param name="builder">输出缓冲区。</param>
|
||||
/// <param name="schema">生成器级 schema 模型。</param>
|
||||
/// <param name="property">要生成查询辅助的字段模型。</param>
|
||||
private static void AppendTryFindFirstByPropertyMethod(
|
||||
StringBuilder builder,
|
||||
SchemaFileSpec schema,
|
||||
SchemaPropertySpec property)
|
||||
{
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
$" /// Tries to find the first config entry whose property '{EscapeXmlDocumentation(property.DisplayPath)}' equals the supplied value.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" /// <param name=\"value\">The property value to match.</param>");
|
||||
builder.AppendLine(
|
||||
" /// <param name=\"result\">The first matching config entry when lookup succeeds; otherwise <see langword=\"null\" />.</param>");
|
||||
builder.AppendLine(" /// <returns><see langword=\"true\" /> when a matching config entry is found; otherwise <see langword=\"false\" />.</returns>");
|
||||
builder.AppendLine(" /// <remarks>");
|
||||
builder.AppendLine(
|
||||
" /// The generated helper walks the same snapshot exposed by <see cref=\"All\"/> and returns the first match in iteration order.");
|
||||
builder.AppendLine(" /// </remarks>");
|
||||
builder.AppendLine(
|
||||
$" public bool TryFindFirstBy{property.PropertyName}({property.TypeSpec.ClrType} value, out {schema.ClassName}? result)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(
|
||||
" // Keep the search path allocation-free for the first-match case by exiting as soon as one entry matches.");
|
||||
builder.AppendLine(" foreach (var candidate in All())");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(
|
||||
$" if (global::System.Collections.Generic.EqualityComparer<{property.TypeSpec.ClrType}>.Default.Equals(candidate.{property.PropertyName}, value))");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" result = candidate;");
|
||||
builder.AppendLine(" return true;");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" result = null;");
|
||||
builder.AppendLine(" return false;");
|
||||
builder.AppendLine(" }");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归枚举对象树中所有带 ref-table 元数据的字段。
|
||||
/// </summary>
|
||||
/// <param name="properties">对象属性集合。</param>
|
||||
/// <returns>原始引用字段信息。</returns>
|
||||
private static IEnumerable<GeneratedReferenceSeed> EnumerateReferenceSeeds(
|
||||
IEnumerable<SchemaPropertySpec> properties)
|
||||
{
|
||||
foreach (var property in properties)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(property.TypeSpec.RefTableName))
|
||||
{
|
||||
yield return new GeneratedReferenceSeed(
|
||||
property.DisplayPath,
|
||||
property.TypeSpec.RefTableName!,
|
||||
property.TypeSpec.Kind == SchemaNodeKind.Array
|
||||
? property.TypeSpec.ItemTypeSpec?.SchemaType ?? property.TypeSpec.SchemaType
|
||||
: property.TypeSpec.SchemaType,
|
||||
property.TypeSpec.Kind == SchemaNodeKind.Array);
|
||||
}
|
||||
|
||||
if (property.TypeSpec.NestedObject is not null)
|
||||
{
|
||||
foreach (var nestedReference in EnumerateReferenceSeeds(property.TypeSpec.NestedObject.Properties))
|
||||
{
|
||||
yield return nestedReference;
|
||||
}
|
||||
}
|
||||
|
||||
if (property.TypeSpec.ItemTypeSpec?.NestedObject is not null)
|
||||
{
|
||||
foreach (var nestedReference in EnumerateReferenceSeeds(property.TypeSpec.ItemTypeSpec.NestedObject
|
||||
.Properties))
|
||||
{
|
||||
yield return nestedReference;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归生成配置对象类型。
|
||||
/// </summary>
|
||||
@ -1304,40 +910,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
builder.AppendLine($"{indent}/// </remarks>");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 schema 字段名转换并验证为生成代码可直接使用的属性标识符。
|
||||
/// 生成器会在这里拒绝无法映射为合法 C# 标识符的外部输入,避免生成源码后才在编译阶段失败。
|
||||
/// </summary>
|
||||
/// <param name="filePath">Schema 文件路径。</param>
|
||||
/// <param name="displayPath">逻辑字段路径。</param>
|
||||
/// <param name="schemaName">Schema 原始字段名。</param>
|
||||
/// <param name="propertyName">生成后的属性名。</param>
|
||||
/// <param name="diagnostic">字段名非法时生成的诊断。</param>
|
||||
/// <returns>是否成功生成合法属性标识符。</returns>
|
||||
private static bool TryBuildPropertyIdentifier(
|
||||
string filePath,
|
||||
string displayPath,
|
||||
string schemaName,
|
||||
out string propertyName,
|
||||
out Diagnostic? diagnostic)
|
||||
{
|
||||
propertyName = ToPascalCase(schemaName);
|
||||
if (SyntaxFacts.IsValidIdentifier(propertyName))
|
||||
{
|
||||
diagnostic = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
diagnostic = Diagnostic.Create(
|
||||
ConfigSchemaDiagnostics.InvalidGeneratedIdentifier,
|
||||
CreateFileLocation(filePath),
|
||||
Path.GetFileName(filePath),
|
||||
displayPath,
|
||||
schemaName,
|
||||
propertyName);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 schema 文件路径提取实体基础名。
|
||||
/// </summary>
|
||||
@ -1396,28 +968,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
return tokens.Length == 0 ? "Config" : string.Concat(tokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 schema 字段路径转换为可用于生成引用元数据成员的 PascalCase 标识符。
|
||||
/// </summary>
|
||||
/// <param name="displayPath">Schema 字段路径。</param>
|
||||
/// <returns>稳定的成员名。</returns>
|
||||
private static string BuildReferenceMemberName(string displayPath)
|
||||
{
|
||||
var segments = displayPath.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var builder = new StringBuilder();
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var normalizedSegment = segment
|
||||
.Replace("[]", "Items")
|
||||
.Replace("[", " ")
|
||||
.Replace("]", " ");
|
||||
builder.Append(ToPascalCase(normalizedSegment));
|
||||
}
|
||||
|
||||
return builder.Length == 0 ? "Reference" : builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 AdditionalFiles 诊断创建文件位置。
|
||||
/// </summary>
|
||||
@ -1785,34 +1335,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
SchemaObjectSpec? NestedObject,
|
||||
SchemaTypeSpec? ItemTypeSpec);
|
||||
|
||||
/// <summary>
|
||||
/// 生成代码前的跨表引用字段种子信息。
|
||||
/// </summary>
|
||||
/// <param name="DisplayPath">Schema 字段路径。</param>
|
||||
/// <param name="ReferencedTableName">目标表名称。</param>
|
||||
/// <param name="ValueSchemaType">引用值的标量 schema 类型。</param>
|
||||
/// <param name="IsCollection">是否为数组引用。</param>
|
||||
private sealed record GeneratedReferenceSeed(
|
||||
string DisplayPath,
|
||||
string ReferencedTableName,
|
||||
string ValueSchemaType,
|
||||
bool IsCollection);
|
||||
|
||||
/// <summary>
|
||||
/// 已分配稳定成员名的生成期跨表引用信息。
|
||||
/// </summary>
|
||||
/// <param name="MemberName">生成到绑定类中的成员名。</param>
|
||||
/// <param name="DisplayPath">Schema 字段路径。</param>
|
||||
/// <param name="ReferencedTableName">目标表名称。</param>
|
||||
/// <param name="ValueSchemaType">引用值的标量 schema 类型。</param>
|
||||
/// <param name="IsCollection">是否为数组引用。</param>
|
||||
private sealed record GeneratedReferenceSpec(
|
||||
string MemberName,
|
||||
string DisplayPath,
|
||||
string ReferencedTableName,
|
||||
string ValueSchemaType,
|
||||
bool IsCollection);
|
||||
|
||||
/// <summary>
|
||||
/// 属性解析结果包装。
|
||||
/// </summary>
|
||||
|
||||
@ -63,15 +63,4 @@ public static class ConfigSchemaDiagnostics
|
||||
SourceGeneratorsConfigCategory,
|
||||
DiagnosticSeverity.Error,
|
||||
true);
|
||||
|
||||
/// <summary>
|
||||
/// schema 字段名无法安全映射为 C# 标识符。
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor InvalidGeneratedIdentifier = new(
|
||||
"GF_ConfigSchema_006",
|
||||
"Config schema property name cannot be converted to a valid C# identifier",
|
||||
"Property '{1}' in schema file '{0}' uses schema key '{2}', which generates invalid C# identifier '{3}'",
|
||||
SourceGeneratorsConfigCategory,
|
||||
DiagnosticSeverity.Error,
|
||||
true);
|
||||
}
|
||||
@ -62,7 +62,6 @@
|
||||
<None Remove="GFramework.SourceGenerators.Attributes\**"/>
|
||||
<None Remove="Godot\**"/>
|
||||
<None Remove="GFramework.Game.Tests\**"/>
|
||||
<None Remove="GFramework.Godot.Tests\**"/>
|
||||
</ItemGroup>
|
||||
<!-- 聚合核心模块 -->
|
||||
<ItemGroup>
|
||||
@ -103,7 +102,6 @@
|
||||
<Compile Remove="GFramework.SourceGenerators.Attributes\**"/>
|
||||
<Compile Remove="Godot\**"/>
|
||||
<Compile Remove="GFramework.Game.Tests\**"/>
|
||||
<Compile Remove="GFramework.Godot.Tests\**"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Remove="GFramework.Core\**"/>
|
||||
@ -130,10 +128,14 @@
|
||||
<EmbeddedResource Remove="GFramework.SourceGenerators.Attributes\**"/>
|
||||
<EmbeddedResource Remove="Godot\**"/>
|
||||
<EmbeddedResource Remove="GFramework.Game.Tests\**"/>
|
||||
<EmbeddedResource Remove="GFramework.Godot.Tests\**"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Remove="AnalyzerReleases.Shipped.md"/>
|
||||
<AdditionalFiles Remove="AnalyzerReleases.Unshipped.md"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="local-plan\docs\"/>
|
||||
<Folder Include="local-plan\todos\"/>
|
||||
<Folder Include="local-plan\评估\"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@ -36,8 +36,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Game.Tests", "GF
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Godot.SourceGenerators.Tests", "GFramework.Godot.SourceGenerators.Tests\GFramework.Godot.SourceGenerators.Tests.csproj", "{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Godot.Tests", "GFramework.Godot.Tests\GFramework.Godot.Tests.csproj", "{576119E2-13D0-4ACF-A012-D01C320E8BF3}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@ -264,18 +262,6 @@ Global
|
||||
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x86.Build.0 = Release|Any CPU
|
||||
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{576119E2-13D0-4ACF-A012-D01C320E8BF3}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@ -15,12 +15,7 @@
|
||||
## 概述
|
||||
|
||||
Architecture 是 GFramework 的核心类,负责管理整个应用的生命周期、组件注册和模块管理。从 v1.1.0 开始,Architecture
|
||||
采用模块化设计,将职责分离到专门的协作者中。
|
||||
|
||||
> 命名约定:
|
||||
> - `ArchitectureServices` 是公开的基础服务入口,负责容器、事件总线、命令执行器、查询执行器和服务模块管理
|
||||
> - `ArchitectureComponentRegistry` 是内部组件注册器,专门负责 System / Model / Utility 的注册与生命周期接入
|
||||
> - 两者不是同一层职责,不要混用
|
||||
采用模块化设计,将职责分离到专门的管理器中。
|
||||
|
||||
### 设计目标
|
||||
|
||||
@ -34,7 +29,6 @@ Architecture 是 GFramework 的核心类,负责管理整个应用的生命周期
|
||||
|
||||
```
|
||||
Architecture (核心协调器)
|
||||
├── ArchitectureBootstrapper (初始化基础设施编排)
|
||||
├── ArchitectureLifecycle (生命周期管理)
|
||||
├── ArchitectureComponentRegistry (组件注册)
|
||||
└── ArchitectureModules (模块管理)
|
||||
@ -46,7 +40,7 @@ Architecture (核心协调器)
|
||||
|
||||
Architecture 采用以下设计模式:
|
||||
|
||||
1. **组合模式 (Composition)**: Architecture 组合多个内部协作者
|
||||
1. **组合模式 (Composition)**: Architecture 组合三个管理器
|
||||
2. **委托模式 (Delegation)**: 方法调用委托给专门的管理器
|
||||
3. **协调器模式 (Coordinator)**: Architecture 作为协调器统一对外接口
|
||||
|
||||
@ -55,7 +49,6 @@ Architecture 采用以下设计模式:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Architecture │
|
||||
│ - _bootstrapper: ArchitectureBootstrapper │
|
||||
│ - _lifecycle: ArchitectureLifecycle │
|
||||
│ - _componentRegistry: ArchitectureComponentRegistry│
|
||||
│ - _modules: ArchitectureModules │
|
||||
@ -69,17 +62,17 @@ Architecture 采用以下设计模式:
|
||||
│ + DestroyAsync() │
|
||||
│ + event PhaseChanged │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Bootstrapper │ │ Lifecycle │ │ComponentReg. │ │ Modules │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ - 环境初始化 │ │ - 阶段管理 │ │ - System 注册│ │ - 模块安装 │
|
||||
│ - 服务准备 │ │ - 钩子管理 │ │ - Model 注册 │ │ - 行为注册 │
|
||||
│ - 上下文绑定 │ │ - 组件初始化 │ │ - Utility 注册│ │ │
|
||||
│ - 容器冻结 │ │ - 就绪/销毁协调 │ │ - 生命周期接入│ │ │
|
||||
└──────────────┘ └──────────────────┘ └──────────────┘ └──────────────┘
|
||||
│ │ │
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
|
||||
│ Lifecycle │ │ ComponentRegistry│ │ Modules │
|
||||
│ │ │ │ │ │
|
||||
│ - 阶段管理 │ │ - System 注册 │ │ - 模块安装 │
|
||||
│ - 钩子管理 │ │ - Model 注册 │ │ - 行为注册 │
|
||||
│ - 初始化 │ │ - Utility 注册 │ │ │
|
||||
│ - 销毁 │ │ - 生命周期注册 │ │ │
|
||||
└──────────────┘ └──────────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### 构造函数初始化
|
||||
@ -93,20 +86,19 @@ protected Architecture(
|
||||
IArchitectureServices? services = null,
|
||||
IArchitectureContext? context = null)
|
||||
{
|
||||
var resolvedConfiguration = configuration ?? new ArchitectureConfiguration();
|
||||
var resolvedEnvironment = environment ?? new DefaultEnvironment();
|
||||
var resolvedServices = services ?? new ArchitectureServices();
|
||||
Configuration = configuration ?? new ArchitectureConfiguration();
|
||||
Environment = environment ?? new DefaultEnvironment();
|
||||
Services = services ?? new ArchitectureServices();
|
||||
_context = context;
|
||||
|
||||
// 初始化 Logger
|
||||
LoggerFactoryResolver.Provider = resolvedConfiguration.LoggerProperties.LoggerFactoryProvider;
|
||||
LoggerFactoryResolver.Provider = Configuration.LoggerProperties.LoggerFactoryProvider;
|
||||
_logger = LoggerFactoryResolver.Provider.CreateLogger(GetType().Name);
|
||||
|
||||
// 初始化协作者
|
||||
_bootstrapper = new ArchitectureBootstrapper(GetType(), resolvedEnvironment, resolvedServices, _logger);
|
||||
_lifecycle = new ArchitectureLifecycle(this, resolvedConfiguration, resolvedServices, _logger);
|
||||
_componentRegistry = new ArchitectureComponentRegistry(this, resolvedConfiguration, resolvedServices, _lifecycle, _logger);
|
||||
_modules = new ArchitectureModules(this, resolvedServices, _logger);
|
||||
// 初始化管理器
|
||||
_lifecycle = new ArchitectureLifecycle(this, Configuration, Services, _logger);
|
||||
_componentRegistry = new ArchitectureComponentRegistry(this, Configuration, Services, _lifecycle, _logger);
|
||||
_modules = new ArchitectureModules(this, Services, _logger);
|
||||
}
|
||||
```
|
||||
|
||||
@ -198,18 +190,17 @@ architecture.RegisterLifecycleHook(new MyLifecycleHook());
|
||||
└─> 构造函数初始化管理器
|
||||
|
||||
2. 调用 InitializeAsync() 或 Initialize()
|
||||
├─> ArchitectureBootstrapper 准备基础设施
|
||||
│ ├─> 初始化环境 (Environment.Initialize())
|
||||
│ ├─> 注册内置服务模块
|
||||
│ ├─> 初始化架构上下文并绑定 GameContext
|
||||
│ ├─> 执行服务钩子
|
||||
│ └─> 初始化服务模块
|
||||
├─> 初始化环境 (Environment.Initialize())
|
||||
├─> 注册内置服务模块
|
||||
├─> 初始化架构上下文
|
||||
├─> 执行服务钩子
|
||||
├─> 初始化服务模块
|
||||
├─> 调用 OnInitialize() (用户注册组件)
|
||||
├─> 初始化所有组件
|
||||
│ ├─> BeforeUtilityInit → 初始化 Utility → AfterUtilityInit
|
||||
│ ├─> BeforeModelInit → 初始化 Model → AfterModelInit
|
||||
│ └─> BeforeSystemInit → 初始化 System → AfterSystemInit
|
||||
├─> CompleteInitialization() 冻结 IoC 容器
|
||||
├─> 冻结 IoC 容器
|
||||
└─> 进入 Ready 阶段
|
||||
|
||||
3. 等待就绪 (可选)
|
||||
@ -226,8 +217,8 @@ architecture.RegisterLifecycleHook(new MyLifecycleHook());
|
||||
│ ├─> 优先调用 IAsyncDestroyable.DestroyAsync()
|
||||
│ └─> 否则调用 IDestroyable.Destroy()
|
||||
├─> 销毁服务模块
|
||||
├─> 进入 Destroyed 阶段
|
||||
└─> 清空 IoC 容器
|
||||
├─> 清空 IoC 容器
|
||||
└─> 进入 Destroyed 阶段
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -1,95 +1,61 @@
|
||||
---
|
||||
title: 协程系统
|
||||
description: 基于 IEnumerator<IYieldInstruction> 的协程调度系统,支持时间等待、阶段等待、Task 桥接、事件等待与运行时快照查询。
|
||||
description: 协程系统提供基于 IEnumerator<IYieldInstruction> 的调度、等待和组合能力,可与事件、Task、命令与查询集成。
|
||||
---
|
||||
|
||||
# 协程系统
|
||||
|
||||
## 概述
|
||||
|
||||
`GFramework.Core.Coroutine` 提供一个宿主无关的协程内核。它围绕 `CoroutineScheduler` 工作,统一处理:
|
||||
GFramework 的 Core 协程系统基于 `IEnumerator<IYieldInstruction>` 构建,通过 `CoroutineScheduler`
|
||||
统一推进协程执行。它适合处理分帧逻辑、时间等待、条件等待、Task 桥接,以及事件驱动的异步流程。
|
||||
|
||||
- `IEnumerator<IYieldInstruction>` 形式的协程推进
|
||||
- 时间等待、条件等待、Task 等待与事件等待
|
||||
- 标签、分组、暂停、恢复与终止
|
||||
- 取消令牌、完成状态查询与运行快照
|
||||
- 调度阶段语义,例如默认更新、固定更新和帧结束
|
||||
协程系统主要由以下部分组成:
|
||||
|
||||
Core 协程本身不依赖任何具体引擎;阶段语义是否真实成立,取决于宿主是否为调度器提供了匹配的执行阶段。
|
||||
- `CoroutineScheduler`:负责运行、更新和控制协程
|
||||
- `CoroutineHandle`:用于标识协程实例并控制其状态
|
||||
- `IYieldInstruction`:定义等待行为的统一接口
|
||||
- `Instructions`:内置等待指令集合
|
||||
- `CoroutineHelper`:提供常用等待与生成器辅助方法
|
||||
- `Extensions`:提供 Task、组合、命令、查询和 Mediator 场景下的扩展方法
|
||||
|
||||
## CoroutineScheduler
|
||||
## 核心概念
|
||||
|
||||
### 基础创建
|
||||
### CoroutineScheduler
|
||||
|
||||
`CoroutineScheduler` 是协程系统的核心调度器。构造时需要提供 `ITimeSource`,调度器会在每次 `Update()` 时读取时间增量并推进所有活跃协程。
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Coroutine;
|
||||
using GFramework.Core.Coroutine;
|
||||
|
||||
ITimeSource scaledTimeSource = /* 游戏时间 */;
|
||||
ITimeSource realtimeTimeSource = /* 真实时间,可选 */;
|
||||
ITimeSource timeSource = /* 你的时间源实现 */;
|
||||
var scheduler = new CoroutineScheduler(timeSource);
|
||||
|
||||
var scheduler = new CoroutineScheduler(
|
||||
scaledTimeSource,
|
||||
realtimeTimeSource: realtimeTimeSource,
|
||||
executionStage: CoroutineExecutionStage.Update);
|
||||
var handle = scheduler.Run(MyCoroutine());
|
||||
|
||||
var handle = scheduler.Run(MyCoroutine(), tag: "bootstrap", group: "loading");
|
||||
|
||||
// 在宿主主循环中推进协程
|
||||
// 在你的主循环中推进协程
|
||||
scheduler.Update();
|
||||
```
|
||||
|
||||
构造参数中最重要的两个语义是:
|
||||
如果需要统计信息,可以启用构造函数的 `enableStatistics` 参数。
|
||||
|
||||
- `realtimeTimeSource`
|
||||
- 如果提供,`WaitForSecondsRealtime` 会使用它的 `DeltaTime`
|
||||
- 如果不提供,实时等待会退化为使用默认时间源
|
||||
- `executionStage`
|
||||
- `Update`:默认阶段
|
||||
- `FixedUpdate`:固定步阶段
|
||||
- `EndOfFrame`:帧结束阶段
|
||||
### CoroutineHandle
|
||||
|
||||
### 控制与完成状态
|
||||
`CoroutineHandle` 用于引用具体协程,并配合调度器进行控制:
|
||||
|
||||
```csharp
|
||||
using var cts = new CancellationTokenSource();
|
||||
var handle = scheduler.Run(MyCoroutine(), tag: "gameplay", group: "battle");
|
||||
|
||||
var handle = scheduler.Run(
|
||||
LoadResources(),
|
||||
tag: "loading",
|
||||
group: "bootstrap",
|
||||
cancellationToken: cts.Token);
|
||||
|
||||
scheduler.Pause(handle);
|
||||
scheduler.Resume(handle);
|
||||
scheduler.Kill(handle);
|
||||
|
||||
var completionStatus = await scheduler.WaitForCompletionAsync(handle);
|
||||
```
|
||||
|
||||
协程的最终结果由 `CoroutineCompletionStatus` 表示:
|
||||
|
||||
- `Completed`
|
||||
- `Cancelled`
|
||||
- `Faulted`
|
||||
- `Unknown`
|
||||
|
||||
### 快照与可观测性
|
||||
|
||||
```csharp
|
||||
if (scheduler.TryGetSnapshot(handle, out var snapshot))
|
||||
if (scheduler.IsCoroutineAlive(handle))
|
||||
{
|
||||
Console.WriteLine(snapshot.State);
|
||||
Console.WriteLine(snapshot.WaitingInstructionType);
|
||||
Console.WriteLine(snapshot.ExecutionStage);
|
||||
scheduler.Pause(handle);
|
||||
scheduler.Resume(handle);
|
||||
scheduler.Kill(handle);
|
||||
}
|
||||
|
||||
var allSnapshots = scheduler.GetActiveSnapshots();
|
||||
```
|
||||
|
||||
快照适合做诊断、调试面板和运行中状态检查。
|
||||
|
||||
## IYieldInstruction
|
||||
### IYieldInstruction
|
||||
|
||||
协程通过 `yield return IYieldInstruction` 表达等待逻辑:
|
||||
|
||||
@ -101,40 +67,91 @@ public interface IYieldInstruction
|
||||
}
|
||||
```
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 创建简单协程
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Coroutine;
|
||||
using GFramework.Core.Coroutine.Instructions;
|
||||
|
||||
public IEnumerator<IYieldInstruction> SimpleCoroutine()
|
||||
{
|
||||
Console.WriteLine("开始");
|
||||
|
||||
yield return new Delay(2.0);
|
||||
Console.WriteLine("2 秒后");
|
||||
|
||||
yield return new WaitOneFrame();
|
||||
Console.WriteLine("下一帧");
|
||||
}
|
||||
```
|
||||
|
||||
### 使用 CoroutineHelper
|
||||
|
||||
`CoroutineHelper` 提供了一组常用等待和生成器辅助方法:
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Coroutine;
|
||||
|
||||
public IEnumerator<IYieldInstruction> HelperCoroutine()
|
||||
{
|
||||
yield return CoroutineHelper.WaitForSeconds(1.5);
|
||||
yield return CoroutineHelper.WaitForOneFrame();
|
||||
yield return CoroutineHelper.WaitForFrames(10);
|
||||
yield return CoroutineHelper.WaitUntil(() => isReady);
|
||||
yield return CoroutineHelper.WaitWhile(() => isLoading);
|
||||
}
|
||||
```
|
||||
|
||||
除了直接返回等待指令,`CoroutineHelper` 也可以直接生成可运行的协程枚举器:
|
||||
|
||||
```csharp
|
||||
scheduler.Run(CoroutineHelper.DelayedCall(2.0, () => Console.WriteLine("延迟执行")));
|
||||
scheduler.Run(CoroutineHelper.RepeatCall(1.0, 5, () => Console.WriteLine("重复执行")));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
scheduler.Run(CoroutineHelper.RepeatCallForever(1.0, () => Console.WriteLine("持续执行"), cts.Token));
|
||||
```
|
||||
|
||||
### 控制协程状态
|
||||
|
||||
```csharp
|
||||
var handle = scheduler.Run(LoadResources(), tag: "loading", group: "bootstrap");
|
||||
|
||||
scheduler.Pause(handle);
|
||||
scheduler.Resume(handle);
|
||||
scheduler.Kill(handle);
|
||||
|
||||
scheduler.KillByTag("loading");
|
||||
scheduler.PauseGroup("bootstrap");
|
||||
scheduler.ResumeGroup("bootstrap");
|
||||
scheduler.KillGroup("bootstrap");
|
||||
|
||||
var cleared = scheduler.Clear();
|
||||
```
|
||||
|
||||
## 常用等待指令
|
||||
|
||||
### 时间与帧
|
||||
|
||||
```csharp
|
||||
yield return new Delay(1.0);
|
||||
yield return new WaitForSecondsScaled(1.0);
|
||||
yield return new WaitForSecondsRealtime(1.0);
|
||||
yield return new WaitOneFrame();
|
||||
yield return new WaitForNextFrame();
|
||||
yield return new WaitForFrames(5);
|
||||
yield return new WaitForFixedUpdate();
|
||||
yield return new WaitForEndOfFrame();
|
||||
yield return new WaitForFixedUpdate();
|
||||
```
|
||||
|
||||
语义说明:
|
||||
|
||||
- `Delay` 与 `WaitForSecondsScaled`
|
||||
- 使用调度器默认时间源推进
|
||||
- `WaitForSecondsRealtime`
|
||||
- 优先使用调度器的 `realtimeTimeSource`
|
||||
- `WaitForFixedUpdate`
|
||||
- 仅在 `CoroutineExecutionStage.FixedUpdate` 调度器中推进
|
||||
- `WaitForEndOfFrame`
|
||||
- 仅在 `CoroutineExecutionStage.EndOfFrame` 调度器中推进
|
||||
|
||||
如果宿主没有提供匹配阶段,这类阶段型等待不会自然完成。
|
||||
|
||||
### 条件等待
|
||||
|
||||
```csharp
|
||||
yield return new WaitUntil(() => health > 0);
|
||||
yield return new WaitWhile(() => isLoading);
|
||||
yield return new WaitForPredicate(() => hp >= maxHp);
|
||||
yield return new WaitForPredicate(() => isBusy, waitForTrue: false);
|
||||
yield return new WaitUntilOrTimeout(() => connected, timeoutSeconds: 5.0);
|
||||
yield return new WaitForConditionChange(() => isPaused, waitForTransitionTo: true);
|
||||
```
|
||||
@ -142,14 +159,27 @@ yield return new WaitForConditionChange(() => isPaused, waitForTransitionTo: tru
|
||||
### Task 桥接
|
||||
|
||||
```csharp
|
||||
using System.Threading.Tasks;
|
||||
using GFramework.Core.Coroutine.Extensions;
|
||||
|
||||
Task loadTask = LoadDataAsync();
|
||||
yield return loadTask.AsCoroutineInstruction();
|
||||
|
||||
var handle = scheduler.StartTaskAsCoroutine(LoadDataAsync());
|
||||
```
|
||||
|
||||
也可以将 `Task` 转成协程枚举器后直接交给调度器:
|
||||
|
||||
```csharp
|
||||
var coroutine = LoadDataAsync().ToCoroutineEnumerator();
|
||||
var handle1 = scheduler.Run(coroutine);
|
||||
|
||||
var handle2 = scheduler.StartTaskAsCoroutine(LoadDataAsync());
|
||||
```
|
||||
|
||||
- `AsCoroutineInstruction()` 适合已经处在某个协程内部,只需要在当前位置等待 `Task` 完成的场景。
|
||||
- `ToCoroutineEnumerator()` 适合需要把 `Task` 先转换成 `IEnumerator<IYieldInstruction>`,再传给 `scheduler.Run(...)`、
|
||||
`Sequence(...)` 或其他只接受协程枚举器的 API。
|
||||
- `StartTaskAsCoroutine()` 适合已经持有 `CoroutineScheduler`,并希望把 `Task` 直接作为一个顶层协程启动的场景。
|
||||
|
||||
### 等待事件
|
||||
|
||||
```csharp
|
||||
@ -158,52 +188,236 @@ using GFramework.Core.Coroutine.Instructions;
|
||||
|
||||
public IEnumerator<IYieldInstruction> WaitForEventExample(IEventBus eventBus)
|
||||
{
|
||||
using var wait = new WaitForEvent<PlayerJoinedEvent>(eventBus);
|
||||
yield return wait;
|
||||
using var waitEvent = new WaitForEvent<PlayerDiedEvent>(eventBus);
|
||||
yield return waitEvent;
|
||||
|
||||
Console.WriteLine(wait.EventData?.PlayerName);
|
||||
var eventData = waitEvent.EventData;
|
||||
Console.WriteLine($"玩家 {eventData!.PlayerId} 死亡");
|
||||
}
|
||||
```
|
||||
|
||||
## CoroutineHelper
|
||||
|
||||
`CoroutineHelper` 提供一组常用简写:
|
||||
为事件等待附加超时:
|
||||
|
||||
```csharp
|
||||
yield return CoroutineHelper.WaitForSeconds(1.5);
|
||||
yield return CoroutineHelper.WaitForOneFrame();
|
||||
yield return CoroutineHelper.WaitForFrames(10);
|
||||
yield return CoroutineHelper.WaitUntil(() => isReady);
|
||||
yield return CoroutineHelper.WaitWhile(() => isLoading);
|
||||
public IEnumerator<IYieldInstruction> WaitForEventWithTimeoutExample(IEventBus eventBus)
|
||||
{
|
||||
using var waitEvent = new WaitForEvent<PlayerJoinedEvent>(eventBus);
|
||||
var timeoutWait = new WaitForEventWithTimeout<PlayerJoinedEvent>(waitEvent, 5.0f);
|
||||
|
||||
yield return timeoutWait;
|
||||
|
||||
if (timeoutWait.IsTimeout)
|
||||
Console.WriteLine("等待超时");
|
||||
else
|
||||
Console.WriteLine($"玩家加入: {timeoutWait.EventData!.PlayerName}");
|
||||
}
|
||||
```
|
||||
|
||||
也可以直接生成可运行的协程枚举器:
|
||||
等待两个事件中的任意一个:
|
||||
|
||||
```csharp
|
||||
scheduler.Run(CoroutineHelper.DelayedCall(2.0, () => Console.WriteLine("延迟执行")));
|
||||
scheduler.Run(CoroutineHelper.RepeatCall(1.0, 5, () => Console.WriteLine("重复执行")));
|
||||
public IEnumerator<IYieldInstruction> WaitForEitherEvent(IEventBus eventBus)
|
||||
{
|
||||
using var wait = new WaitForMultipleEvents<PlayerReadyEvent, PlayerQuitEvent>(eventBus);
|
||||
yield return wait;
|
||||
|
||||
if (wait.TriggeredBy == 1)
|
||||
Console.WriteLine($"Ready: {wait.FirstEventData}");
|
||||
else
|
||||
Console.WriteLine($"Quit: {wait.SecondEventData}");
|
||||
}
|
||||
```
|
||||
|
||||
## 协程组合
|
||||
### 协程组合
|
||||
|
||||
等待子协程完成:
|
||||
|
||||
```csharp
|
||||
public IEnumerator<IYieldInstruction> ParentCoroutine()
|
||||
{
|
||||
Console.WriteLine("父协程开始");
|
||||
|
||||
yield return new WaitForCoroutine(ChildCoroutine());
|
||||
|
||||
Console.WriteLine("子协程完成");
|
||||
}
|
||||
|
||||
private IEnumerator<IYieldInstruction> ChildCoroutine()
|
||||
{
|
||||
yield return new Delay(1.0);
|
||||
yield return CoroutineHelper.WaitForSeconds(1.0);
|
||||
Console.WriteLine("子协程执行");
|
||||
}
|
||||
```
|
||||
|
||||
如果需要等待多个顶层协程句柄,可以结合 `WaitForAllCoroutines` 或 `ParallelCoroutines(...)` 使用。
|
||||
等待多个句柄全部完成:
|
||||
|
||||
## 建议
|
||||
```csharp
|
||||
public IEnumerator<IYieldInstruction> WaitForMultipleCoroutines(CoroutineScheduler scheduler)
|
||||
{
|
||||
var handles = new List<CoroutineHandle>
|
||||
{
|
||||
scheduler.Run(LoadTexture()),
|
||||
scheduler.Run(LoadAudio()),
|
||||
scheduler.Run(LoadModel())
|
||||
};
|
||||
|
||||
- 普通游戏时间等待优先使用 `Delay` 或 `WaitForSecondsScaled`
|
||||
- 只有宿主提供真实时间源时再使用 `WaitForSecondsRealtime`
|
||||
- 只有宿主显式区分阶段时才使用 `WaitForFixedUpdate` 与 `WaitForEndOfFrame`
|
||||
- 需要对接生命周期或外部取消时,优先传入 `CancellationToken`
|
||||
- 需要诊断线上状态时,优先使用 `TryGetSnapshot(...)` 和 `GetActiveSnapshots()`
|
||||
yield return new WaitForAllCoroutines(scheduler, handles);
|
||||
|
||||
Console.WriteLine("所有资源加载完成");
|
||||
}
|
||||
```
|
||||
|
||||
### 进度等待
|
||||
|
||||
```csharp
|
||||
public IEnumerator<IYieldInstruction> LoadingWithProgress()
|
||||
{
|
||||
yield return CoroutineHelper.WaitForProgress(
|
||||
duration: 3.0,
|
||||
onProgress: progress => Console.WriteLine($"加载进度: {progress * 100:F0}%"));
|
||||
}
|
||||
```
|
||||
|
||||
## 扩展方法
|
||||
|
||||
### 组合扩展
|
||||
|
||||
`CoroutineComposeExtensions` 提供链式顺序组合能力:
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Coroutine.Extensions;
|
||||
|
||||
var chained =
|
||||
LoadConfig()
|
||||
.Then(() => Console.WriteLine("配置加载完成"))
|
||||
.Then(StartBattle());
|
||||
|
||||
scheduler.Run(chained);
|
||||
```
|
||||
|
||||
### 协程生成扩展
|
||||
|
||||
`CoroutineExtensions` 提供了一些常用的协程生成器:
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Coroutine.Extensions;
|
||||
|
||||
var delayed = CoroutineExtensions.ExecuteAfter(2.0, () => Console.WriteLine("延迟执行"));
|
||||
var repeated = CoroutineExtensions.RepeatEvery(1.0, () => Console.WriteLine("tick"), count: 5);
|
||||
var progress = CoroutineExtensions.WaitForSecondsWithProgress(3.0, p => Console.WriteLine(p));
|
||||
|
||||
scheduler.Run(delayed);
|
||||
scheduler.Run(repeated);
|
||||
scheduler.Run(progress);
|
||||
```
|
||||
|
||||
顺序或并行组合多个协程:
|
||||
|
||||
```csharp
|
||||
var sequence = CoroutineExtensions.Sequence(LoadConfig(), LoadScene(), StartBattle());
|
||||
scheduler.Run(sequence);
|
||||
|
||||
var parallel = scheduler.ParallelCoroutines(LoadTexture(), LoadAudio(), LoadModel());
|
||||
scheduler.Run(parallel);
|
||||
```
|
||||
|
||||
### Task 扩展
|
||||
|
||||
`TaskCoroutineExtensions` 提供了三类扩展:
|
||||
|
||||
- `AsCoroutineInstruction()`:把 `Task` / `Task<T>` 包装成等待指令
|
||||
- `ToCoroutineEnumerator()`:把 `Task` / `Task<T>` 转成协程枚举器
|
||||
- `StartTaskAsCoroutine()`:直接通过调度器启动 Task 协程
|
||||
|
||||
### 命令、查询与 Mediator 扩展
|
||||
|
||||
这些扩展都定义在 `GFramework.Core.Coroutine.Extensions` 命名空间中。
|
||||
|
||||
### 命令协程
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Coroutine.Extensions;
|
||||
|
||||
public IEnumerator<IYieldInstruction> ExecuteCommand(IContextAware contextAware)
|
||||
{
|
||||
yield return contextAware.SendCommandCoroutineWithErrorHandler(
|
||||
new LoadSceneCommand(),
|
||||
ex => Console.WriteLine(ex.Message));
|
||||
}
|
||||
```
|
||||
|
||||
如果命令执行后需要等待事件:
|
||||
|
||||
```csharp
|
||||
public IEnumerator<IYieldInstruction> ExecuteCommandAndWaitEvent(IContextAware contextAware)
|
||||
{
|
||||
yield return contextAware.SendCommandAndWaitEventCoroutine<LoadSceneCommand, SceneLoadedEvent>(
|
||||
new LoadSceneCommand(),
|
||||
evt => Console.WriteLine($"场景加载完成: {evt.SceneName}"),
|
||||
timeout: 5.0f);
|
||||
}
|
||||
```
|
||||
|
||||
### 查询协程
|
||||
|
||||
`SendQueryCoroutine` 会同步执行查询,并通过回调返回结果:
|
||||
|
||||
```csharp
|
||||
public IEnumerator<IYieldInstruction> QueryPlayer(IContextAware contextAware)
|
||||
{
|
||||
yield return contextAware.SendQueryCoroutine<GetPlayerDataQuery, PlayerData>(
|
||||
new GetPlayerDataQuery { PlayerId = 1 },
|
||||
playerData => Console.WriteLine($"玩家名称: {playerData.Name}"));
|
||||
}
|
||||
```
|
||||
|
||||
### Mediator 协程
|
||||
|
||||
如果项目使用 `Mediator.IMediator`,还可以使用 `MediatorCoroutineExtensions`:
|
||||
|
||||
```csharp
|
||||
public IEnumerator<IYieldInstruction> ExecuteMediatorCommand(IContextAware contextAware)
|
||||
{
|
||||
yield return contextAware.SendCommandCoroutine(
|
||||
new SaveArchiveCommand(),
|
||||
ex => Console.WriteLine(ex.Message));
|
||||
}
|
||||
```
|
||||
|
||||
## 异常处理
|
||||
|
||||
调度器会在协程抛出未捕获异常时触发 `OnCoroutineException`:
|
||||
|
||||
```csharp
|
||||
scheduler.OnCoroutineException += (handle, exception) =>
|
||||
{
|
||||
Console.WriteLine($"协程 {handle} 异常: {exception.Message}");
|
||||
};
|
||||
```
|
||||
|
||||
如果协程等待的是 `Task`,也可以通过 `WaitForTask` / `WaitForTask<T>` 检查任务异常。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 协程什么时候执行?
|
||||
|
||||
协程在调度器的 `Update()` 中推进。调度器每次更新都会先更新 `ITimeSource`,再推进所有活跃协程。
|
||||
|
||||
### 协程是多线程的吗?
|
||||
|
||||
不是。协程本身仍由调用 `Update()` 的线程推进,通常用于主线程上的分帧流程控制。
|
||||
|
||||
### `Delay` 和 `CoroutineHelper.WaitForSeconds()` 有什么区别?
|
||||
|
||||
两者表达的是同一类等待语义。`CoroutineHelper.WaitForSeconds()` 只是 `Delay` 的辅助构造方法。
|
||||
|
||||
### 如何等待异步方法?
|
||||
|
||||
在现有协程里等待 `Task` 时,优先使用 `yield return task.AsCoroutineInstruction()`;如果要把 `Task` 单独交给调度器启动,使用
|
||||
`scheduler.StartTaskAsCoroutine(task)`;如果中间还需要传给只接受协程枚举器的 API,则先调用 `task.ToCoroutineEnumerator()`。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [事件系统](/zh-CN/core/events)
|
||||
- [CQRS](/zh-CN/core/cqrs)
|
||||
- [协程系统教程](/zh-CN/tutorials/coroutine-tutorial)
|
||||
|
||||
@ -216,20 +216,15 @@ public class GameArchitecture : Architecture
|
||||
{
|
||||
protected override void Init()
|
||||
{
|
||||
// 注册通用开放泛型行为
|
||||
RegisterMediatorBehavior<LoggingBehavior<,>>();
|
||||
RegisterMediatorBehavior<PerformanceBehavior<,>>();
|
||||
// 注册 Mediator 行为
|
||||
RegisterMediatorBehavior<LoggingBehavior>();
|
||||
RegisterMediatorBehavior<PerformanceBehavior>();
|
||||
|
||||
// 处理器会自动通过依赖注入注册
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`RegisterMediatorBehavior<TBehavior>()` 同时支持两种形式:
|
||||
|
||||
- 开放泛型行为,例如 `LoggingBehavior<,>`,用于匹配所有请求
|
||||
- 封闭行为类型,例如某个只服务于单一请求的 `SpecialBehavior`
|
||||
|
||||
## 高级用法
|
||||
|
||||
### Request(请求)
|
||||
|
||||
@ -319,7 +319,7 @@ public class PlayerController : IController
|
||||
|
||||
### Architecture 内部结构 (v1.1.0+)
|
||||
|
||||
从 v1.1.0 开始,Architecture 类采用模块化设计,将原本 708 行的单一类拆分为多个职责清晰的协作者:
|
||||
从 v1.1.0 开始,Architecture 类采用模块化设计,将原本 708 行的单一类拆分为 4 个职责清晰的类:
|
||||
|
||||
#### 1. Architecture (核心协调器)
|
||||
|
||||
@ -338,19 +338,7 @@ public class PlayerController : IController
|
||||
|
||||
- `PhaseChanged` - 阶段变更事件
|
||||
|
||||
#### 2. ArchitectureBootstrapper (初始化基础设施编排器)
|
||||
|
||||
**职责**: 在用户 `OnInitialize()` 执行前准备环境、服务和上下文,并在组件初始化完成后执行初始化收尾
|
||||
|
||||
**核心功能**:
|
||||
|
||||
- 初始化环境对象
|
||||
- 注册内置服务模块
|
||||
- 绑定架构上下文到 `GameContext`
|
||||
- 执行服务钩子
|
||||
- 在 `InitializeAllComponentsAsync()` 完成后通过 `CompleteInitialization()` 冻结 IoC 容器
|
||||
|
||||
#### 3. ArchitectureLifecycle (生命周期管理器)
|
||||
#### 2. ArchitectureLifecycle (生命周期管理器)
|
||||
|
||||
**职责**: 管理架构的生命周期和阶段转换
|
||||
|
||||
@ -369,7 +357,7 @@ public class PlayerController : IController
|
||||
- `InitializeAllComponentsAsync()` - 初始化所有组件
|
||||
- `DestroyAsync()` - 异步销毁
|
||||
|
||||
#### 4. ArchitectureComponentRegistry (组件注册管理器)
|
||||
#### 3. ArchitectureComponentRegistry (组件注册管理器)
|
||||
|
||||
**职责**: 管理 System、Model、Utility 的注册
|
||||
|
||||
@ -386,10 +374,7 @@ public class PlayerController : IController
|
||||
- `RegisterModel<T>()` - 注册模型
|
||||
- `RegisterUtility<T>()` - 注册工具
|
||||
|
||||
> 命名提醒: 公开的 `ArchitectureServices` 负责容器和基础服务,并不承担组件注册职责。
|
||||
> `ArchitectureComponentRegistry` 才是内部的 System / Model / Utility 注册器。
|
||||
|
||||
#### 5. ArchitectureModules (模块管理器)
|
||||
#### 4. ArchitectureModules (模块管理器)
|
||||
|
||||
**职责**: 管理架构模块和中介行为
|
||||
|
||||
@ -447,22 +432,19 @@ public class PlayerController : IController
|
||||
创建 Architecture 实例
|
||||
└─> 构造函数
|
||||
├─> 初始化 Logger
|
||||
├─> 创建 ArchitectureBootstrapper
|
||||
├─> 创建 ArchitectureLifecycle
|
||||
├─> 创建 ArchitectureComponentRegistry
|
||||
└─> 创建 ArchitectureModules
|
||||
└─> InitializeAsync()
|
||||
├─> Bootstrapper 准备环境/服务/上下文
|
||||
├─> OnInitialize() (用户注册组件)
|
||||
│ ├─> RegisterModel → Model.SetContext()
|
||||
│ ├─> RegisterSystem → System.SetContext()
|
||||
│ └─> RegisterUtility → 注册到容器
|
||||
├─> InitializeAllComponentsAsync()
|
||||
│ ├─> BeforeUtilityInit → Utility.Initialize()
|
||||
│ ├─> BeforeModelInit → Model.Initialize()
|
||||
│ └─> BeforeSystemInit → System.Initialize()
|
||||
├─> CompleteInitialization() → 冻结 IoC 容器
|
||||
└─> 进入 Ready
|
||||
└─> InitializeAllComponentsAsync()
|
||||
├─> BeforeUtilityInit → Utility.Initialize()
|
||||
├─> BeforeModelInit → Model.Initialize()
|
||||
├─> BeforeSystemInit → System.Initialize()
|
||||
└─> Ready
|
||||
```
|
||||
|
||||
**重要变更 (v1.1.0)**: 管理器现在在构造函数中初始化,而不是在 InitializeAsync 中。这消除了 `null!` 断言,提高了代码安全性。
|
||||
|
||||
@ -82,321 +82,6 @@ dropItems:
|
||||
- slime_gel
|
||||
```
|
||||
|
||||
## 推荐接入模板
|
||||
|
||||
如果你准备在一个真实游戏项目里首次接入这套配置系统,建议直接采用下面这套目录与启动模板,而不是零散拼装。
|
||||
|
||||
### 目录模板
|
||||
|
||||
```text
|
||||
GameProject/
|
||||
├─ GameProject.csproj
|
||||
├─ Config/
|
||||
│ ├─ GameConfigBootstrap.cs
|
||||
│ └─ GameConfigRuntime.cs
|
||||
├─ config/
|
||||
│ ├─ monster/
|
||||
│ │ ├─ slime.yaml
|
||||
│ │ └─ goblin.yaml
|
||||
│ └─ item/
|
||||
│ └─ potion.yaml
|
||||
└─ schemas/
|
||||
├─ monster.schema.json
|
||||
└─ item.schema.json
|
||||
```
|
||||
|
||||
推荐约定如下:
|
||||
|
||||
- `schemas/` 放所有 `*.schema.json`,由 Source Generator 自动拾取
|
||||
- `config/` 放运行时加载的 YAML 数据,一对象一文件
|
||||
- `Config/` 放你自己的接入代码,例如启动注册、热重载句柄和对外读取入口
|
||||
|
||||
### `csproj` 模板
|
||||
|
||||
如果你在仓库内直接用项目引用,最小模板可以写成下面这样:
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GFramework.Game\GFramework.Game.csproj" />
|
||||
<ProjectReference Include="..\GFramework.SourceGenerators.Abstractions\GFramework.SourceGenerators.Abstractions.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Include="..\GFramework.SourceGenerators.Common\GFramework.SourceGenerators.Common.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Include="..\GFramework.SourceGenerators\GFramework.SourceGenerators.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="..\GFramework.SourceGenerators\GeWuYou.GFramework.SourceGenerators.targets" />
|
||||
</Project>
|
||||
```
|
||||
|
||||
这段配置的作用:
|
||||
|
||||
- `GFramework.Game` 提供运行时 `YamlConfigLoader`、`ConfigRegistry` 和只读表实现
|
||||
- 三个 `ProjectReference(... OutputItemType="Analyzer")` 把生成器接进当前消费者项目
|
||||
- `GeWuYou.GFramework.SourceGenerators.targets` 自动把 `schemas/**/*.schema.json` 加入 `AdditionalFiles`
|
||||
|
||||
如果你使用打包后的 NuGet,而不是仓库内项目引用,原则保持不变:
|
||||
|
||||
- 运行时项目需要引用 `GeWuYou.GFramework.Game`
|
||||
- 生成器项目需要引用 `GeWuYou.GFramework.SourceGenerators`
|
||||
- schema 目录默认仍然是 `schemas/`
|
||||
|
||||
如果你的 schema 不放在默认目录,可以在项目文件里覆盖:
|
||||
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
<GFrameworkConfigSchemaDirectory>GameSchemas</GFrameworkConfigSchemaDirectory>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
### 启动引导模板
|
||||
|
||||
推荐把配置系统的初始化收敛到一个单独入口,避免把 `YamlConfigLoader` 注册逻辑散落到多个启动脚本中:
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Events;
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
using GFramework.Game.Config;
|
||||
using GFramework.Game.Config.Generated;
|
||||
|
||||
namespace GameProject.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 负责初始化游戏内容配置运行时入口。
|
||||
/// </summary>
|
||||
public sealed class GameConfigBootstrap : IDisposable
|
||||
{
|
||||
private readonly ConfigRegistry _registry = new();
|
||||
private IUnRegister? _hotReload;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前游戏进程共享的配置注册表。
|
||||
/// </summary>
|
||||
public IConfigRegistry Registry => _registry;
|
||||
|
||||
/// <summary>
|
||||
/// 从指定配置根目录加载所有已注册配置表。
|
||||
/// </summary>
|
||||
/// <param name="configRootPath">配置根目录。</param>
|
||||
/// <param name="enableHotReload">是否启用开发期热重载。</param>
|
||||
public async Task InitializeAsync(string configRootPath, bool enableHotReload = false)
|
||||
{
|
||||
var loader = new YamlConfigLoader(configRootPath)
|
||||
.RegisterMonsterTable()
|
||||
.RegisterItemTable();
|
||||
|
||||
await loader.LoadAsync(_registry);
|
||||
|
||||
if (enableHotReload)
|
||||
{
|
||||
_hotReload = loader.EnableHotReload(
|
||||
_registry,
|
||||
onTableReloaded: tableName => Console.WriteLine($"Reloaded config table: {tableName}"),
|
||||
onTableReloadFailed: static (_, exception) =>
|
||||
{
|
||||
var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
|
||||
Console.WriteLine($"Config reload failed: {diagnostic?.FailureKind}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止开发期热重载并释放相关资源。
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_hotReload?.UnRegister();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这段模板刻意遵循几个约定:
|
||||
|
||||
- 优先使用生成器产出的 `Register*Table()`,避免手写表名、路径和 key selector
|
||||
- 由一个长生命周期对象持有 `ConfigRegistry`
|
||||
- 热重载句柄和配置生命周期绑在一起,避免监听器泄漏
|
||||
|
||||
### 运行时读取模板
|
||||
|
||||
推荐不要在业务代码里直接散落字符串表名查询,而是统一依赖生成的强类型入口:
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Config.Generated;
|
||||
|
||||
namespace GameProject.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 封装游戏内容配置读取入口。
|
||||
/// </summary>
|
||||
public sealed class GameConfigRuntime
|
||||
{
|
||||
private readonly IConfigRegistry _registry;
|
||||
|
||||
/// <summary>
|
||||
/// 使用已初始化的配置注册表创建读取入口。
|
||||
/// </summary>
|
||||
/// <param name="registry">配置注册表。</param>
|
||||
public GameConfigRuntime(IConfigRegistry registry)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定怪物配置。
|
||||
/// </summary>
|
||||
/// <param name="monsterId">怪物主键。</param>
|
||||
/// <returns>强类型怪物配置。</returns>
|
||||
public MonsterConfig GetMonster(int monsterId)
|
||||
{
|
||||
return _registry.GetMonsterTable().Get(monsterId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取怪物配置表。
|
||||
/// </summary>
|
||||
/// <returns>生成的强类型表包装。</returns>
|
||||
public MonsterTable GetMonsterTable()
|
||||
{
|
||||
return _registry.GetMonsterTable();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这样做的收益:
|
||||
|
||||
- 配置系统对业务层暴露的是强类型表,而不是 `"monster"` 这类 magic string
|
||||
- 后续如果你要复用配置域、schema 路径或引用元数据,可以继续依赖 `MonsterConfigBindings.Metadata` 和
|
||||
`MonsterConfigBindings.References`
|
||||
- 如果未来把配置初始化接入 `Architecture` 或 `Module`,迁移成本也更低
|
||||
|
||||
### 生成查询辅助
|
||||
|
||||
从当前阶段开始,生成的 `*Table` 包装会为“顶层、非主键、非引用的标量字段”额外产出轻量查询辅助。
|
||||
|
||||
如果 `monster.schema.json` 包含顶层标量字段 `name`、`faction`,则可以直接这样使用:
|
||||
|
||||
```csharp
|
||||
var monsterTable = registry.GetMonsterTable();
|
||||
|
||||
var slime = monsterTable.FindByName("Slime");
|
||||
|
||||
if (monsterTable.TryFindFirstByFaction("dungeon", out var firstDungeonMonster))
|
||||
{
|
||||
Console.WriteLine(firstDungeonMonster.Name);
|
||||
}
|
||||
```
|
||||
|
||||
当前生成规则刻意保持保守:
|
||||
|
||||
- 只为顶层标量字段生成 `FindBy*` 与 `TryFindFirstBy*`
|
||||
- 主键字段继续只走 `Get / TryGet`
|
||||
- 嵌套对象、对象数组、标量数组和 `x-gframework-ref-table` 字段暂不生成查询辅助
|
||||
- 查询实现基于 `All()` 做线性扫描,不引入运行时索引或缓存
|
||||
|
||||
这意味着它的定位是“减少业务层手写过滤样板”,而不是“替代专门索引结构”。
|
||||
|
||||
如果你依赖 `TryFindFirstBy*`,应当把它理解为“返回当前表快照遍历顺序下的第一个匹配项”,而不是固定排序语义。
|
||||
|
||||
### Architecture 推荐接入模板
|
||||
|
||||
如果你的项目已经基于 `GFramework.Core.Architectures.Architecture` 组织初始化流程,推荐把配置系统接到 `OnInitialize()` 阶段,并把 `ConfigRegistry` 注册为 utility:
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Architectures;
|
||||
using GFramework.Game.Config;
|
||||
using GFramework.Game.Config.Generated;
|
||||
|
||||
public sealed class GameArchitecture : Architecture
|
||||
{
|
||||
private readonly string _configRootPath;
|
||||
|
||||
public GameArchitecture(string configRootPath)
|
||||
{
|
||||
_configRootPath = configRootPath ?? throw new ArgumentNullException(nameof(configRootPath));
|
||||
}
|
||||
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
var registry = RegisterUtility(new ConfigRegistry());
|
||||
|
||||
var loader = new YamlConfigLoader(_configRootPath)
|
||||
.RegisterMonsterTable()
|
||||
.RegisterItemTable();
|
||||
|
||||
loader.LoadAsync(registry).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
初始化完成后,业务组件可以继续通过架构上下文读取 utility,再走生成的强类型入口:
|
||||
|
||||
```csharp
|
||||
var registry = Context.GetUtility<ConfigRegistry>();
|
||||
var monsterTable = registry.GetMonsterTable();
|
||||
var slime = monsterTable.Get(1);
|
||||
```
|
||||
|
||||
推荐遵循以下顺序:
|
||||
|
||||
- 先注册 `ConfigRegistry`
|
||||
- 再构造并配置 `YamlConfigLoader`
|
||||
- 在 `OnInitialize()` 内完成首次 `LoadAsync`
|
||||
- 初始化完成后只通过注册表和生成表包装访问配置
|
||||
|
||||
当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + ConfigRegistry + YamlConfigLoader + Register*Table()` 组合已经足够作为官方推荐接入路径。
|
||||
|
||||
### 热重载模板
|
||||
|
||||
如果你希望把开发期热重载显式收敛为一个可选能力,建议把失败诊断一起写进模板,而不是只打印异常文本:
|
||||
|
||||
```csharp
|
||||
var hotReload = loader.EnableHotReload(
|
||||
registry,
|
||||
onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"),
|
||||
onTableReloadFailed: (tableName, exception) =>
|
||||
{
|
||||
var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
|
||||
Console.WriteLine($"Reload failed: {tableName}");
|
||||
Console.WriteLine($"Failure kind: {diagnostic?.FailureKind}");
|
||||
Console.WriteLine($"Yaml path: {diagnostic?.YamlPath}");
|
||||
Console.WriteLine($"Display path: {diagnostic?.DisplayPath}");
|
||||
});
|
||||
```
|
||||
|
||||
建议只在开发期启用这项能力:
|
||||
|
||||
- 生产环境默认更适合静态加载和固定生命周期
|
||||
- 热重载失败时应优先依赖 `ConfigLoadException.Diagnostic` 做稳定日志或 UI 提示
|
||||
- 如果你的项目已经有统一日志系统,建议在这里把诊断字段转成结构化日志,而不是拼接一整段字符串
|
||||
|
||||
如果你后续还需要为热重载增加更多开关,推荐优先使用选项对象入口,而不是继续叠加位置参数:
|
||||
|
||||
```csharp
|
||||
var hotReload = loader.EnableHotReload(
|
||||
registry,
|
||||
new YamlConfigHotReloadOptions
|
||||
{
|
||||
OnTableReloaded = tableName => Console.WriteLine($"Reloaded: {tableName}"),
|
||||
OnTableReloadFailed = (tableName, exception) =>
|
||||
{
|
||||
var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
|
||||
Console.WriteLine($"{tableName}: {diagnostic?.FailureKind}");
|
||||
},
|
||||
DebounceDelay = TimeSpan.FromMilliseconds(150)
|
||||
});
|
||||
```
|
||||
|
||||
## 运行时接入
|
||||
|
||||
当你希望加载后的配置在运行时以只读表形式暴露时,优先使用生成器产出的注册与访问辅助:
|
||||
@ -418,37 +103,13 @@ var slime = monsterTable.Get(1);
|
||||
|
||||
这组辅助会把以下约定固化到生成代码里:
|
||||
|
||||
- 配置域常量,例如 `MonsterConfigBindings.ConfigDomain`
|
||||
- 表注册名,例如 `monster`
|
||||
- 配置目录相对路径,例如 `monster`
|
||||
- schema 相对路径,例如 `schemas/monster.schema.json`
|
||||
- 主键提取逻辑,例如 `config => config.Id`
|
||||
|
||||
如果你希望把这些约定作为一个统一入口传递或复用,也可以优先读取 `MonsterConfigBindings.Metadata` 下的常量:
|
||||
|
||||
```csharp
|
||||
var domain = MonsterConfigBindings.Metadata.ConfigDomain;
|
||||
var tableName = MonsterConfigBindings.Metadata.TableName;
|
||||
var configPath = MonsterConfigBindings.Metadata.ConfigRelativePath;
|
||||
var schemaPath = MonsterConfigBindings.Metadata.SchemaRelativePath;
|
||||
```
|
||||
|
||||
如果你需要自定义目录、表名或 key selector,仍然可以直接调用 `YamlConfigLoader.RegisterTable(...)` 原始重载。
|
||||
|
||||
如果你希望把 schema 路径、比较器以及未来扩展开关集中到一个对象里,推荐改用选项对象入口:
|
||||
|
||||
```csharp
|
||||
var loader = new YamlConfigLoader("config-root")
|
||||
.RegisterTable(
|
||||
new YamlConfigTableRegistrationOptions<int, MonsterConfig>(
|
||||
"monster",
|
||||
"monster",
|
||||
static config => config.Id)
|
||||
{
|
||||
SchemaRelativePath = "schemas/monster.schema.json"
|
||||
});
|
||||
```
|
||||
|
||||
## 运行时校验行为
|
||||
|
||||
绑定 schema 的表在加载时会拒绝以下问题:
|
||||
@ -487,21 +148,6 @@ var loader = new YamlConfigLoader("config-root")
|
||||
- 引用目标表需要由同一个 `YamlConfigLoader` 注册,或已存在于当前 `IConfigRegistry`
|
||||
- 热重载中若目标表变更导致依赖表引用失效,会整体回滚受影响表,避免注册表进入不一致状态
|
||||
|
||||
如果你希望在消费者代码里复用这些跨表约定,而不是继续手写字段路径或目标表名,生成的 `*ConfigBindings` 还会暴露引用元数据:
|
||||
|
||||
```csharp
|
||||
var allReferences = MonsterConfigBindings.References.All;
|
||||
|
||||
if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var reference))
|
||||
{
|
||||
Console.WriteLine(reference.ReferencedTableName);
|
||||
Console.WriteLine(reference.ValueSchemaType);
|
||||
Console.WriteLine(reference.IsCollection);
|
||||
}
|
||||
```
|
||||
|
||||
当 schema 中存在具体引用字段时,还可以直接通过生成成员访问,例如 `MonsterConfigBindings.References.DropItems`。
|
||||
|
||||
当前还支持以下“轻量元数据”:
|
||||
|
||||
- `title`:供 VS Code 插件表单和批量编辑入口显示更友好的字段标题
|
||||
@ -549,11 +195,14 @@ catch (ConfigLoadException exception)
|
||||
```csharp
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
using GFramework.Game.Config;
|
||||
using GFramework.Game.Config.Generated;
|
||||
|
||||
var registry = new ConfigRegistry();
|
||||
var loader = new YamlConfigLoader("config-root")
|
||||
.RegisterMonsterTable();
|
||||
.RegisterTable<int, MonsterConfig>(
|
||||
"monster",
|
||||
"monster",
|
||||
"schemas/monster.schema.json",
|
||||
static config => config.Id);
|
||||
|
||||
await loader.LoadAsync(registry);
|
||||
|
||||
|
||||
@ -48,20 +48,6 @@ public interface IDataRepository : IUtility
|
||||
}
|
||||
```
|
||||
|
||||
`IDataRepository` 描述的是“仓库语义”,不是固定的落盘格式。实现可以选择每个数据项独立成文件,也可以把多个 section 聚合到同一个文件里。
|
||||
|
||||
当前内建实现里:
|
||||
|
||||
- `DataRepository` 采用“每个 location 一份持久化对象”的模型
|
||||
- `UnifiedSettingsDataRepository` 采用“所有设置聚合到一个统一文件”的模型
|
||||
|
||||
两者对外遵守同一套约定:
|
||||
|
||||
- `SaveAllAsync(...)` 视为一次批量提交,只发送 `DataBatchSavedEvent`,不会再为每个条目重复发送 `DataSavedEvent<T>`
|
||||
- `DeleteAsync(...)` 只有在目标数据真实存在并被删除时才会发送删除事件
|
||||
- 当 `DataRepositoryOptions.AutoBackup = true` 时,覆盖已有数据前会先保留上一份快照
|
||||
- 对 `UnifiedSettingsDataRepository` 来说,备份粒度是整个统一文件,而不是单个设置 section
|
||||
|
||||
### 存档仓库
|
||||
|
||||
`ISaveRepository<T>` 专门用于管理游戏存档:
|
||||
@ -70,7 +56,6 @@ public interface IDataRepository : IUtility
|
||||
public interface ISaveRepository<TSaveData> : IUtility
|
||||
where TSaveData : class, IData, new()
|
||||
{
|
||||
ISaveRepository<TSaveData> RegisterMigration(ISaveMigration<TSaveData> migration);
|
||||
Task<bool> ExistsAsync(int slot);
|
||||
Task<TSaveData> LoadAsync(int slot);
|
||||
Task SaveAsync(int slot, TSaveData data);
|
||||
@ -79,18 +64,6 @@ public interface ISaveRepository<TSaveData> : IUtility
|
||||
}
|
||||
```
|
||||
|
||||
`ISaveMigration<TSaveData>` 定义单步迁移:
|
||||
|
||||
```csharp
|
||||
public interface ISaveMigration<TSaveData>
|
||||
where TSaveData : class, IData
|
||||
{
|
||||
int FromVersion { get; }
|
||||
int ToVersion { get; }
|
||||
TSaveData Migrate(TSaveData oldData);
|
||||
}
|
||||
```
|
||||
|
||||
### 版本化数据
|
||||
|
||||
`IVersionedData` 支持数据版本管理:
|
||||
@ -292,77 +265,67 @@ public partial class AutoSaveController : IController
|
||||
|
||||
### 数据版本迁移
|
||||
|
||||
`SaveRepository<TSaveData>` 现在支持注册正式的迁移器,并在 `LoadAsync(slot)` 时自动升级旧版本存档。
|
||||
`SaveRepository<TSaveData>` 当前负责槽位存档的读取、写入、删除和列举,并没有内建“注册迁移器后自动升级存档”的统一迁移管线。
|
||||
|
||||
迁移规则如下:
|
||||
|
||||
- `TSaveData` 需要实现 `IVersionedData`
|
||||
- 仓库以 `new TSaveData().Version` 作为当前运行时目标版本
|
||||
- 每个迁移器负责一个 `FromVersion -> ToVersion` 跳转
|
||||
- 加载时仓库会按链路连续执行迁移,并在成功后自动回写升级后的存档
|
||||
- 如果缺少中间迁移器,或者读到了比当前运行时更高的版本,`LoadAsync` 会抛出异常,避免静默加载错误数据
|
||||
下面示例展示的是应用层迁移策略:加载后检查版本,调用你自己的迁移逻辑,再决定是否回写新版本数据。
|
||||
|
||||
```csharp
|
||||
public sealed class SaveData : IVersionedData
|
||||
// 版本 1 的数据
|
||||
public class SaveDataV1 : IVersionedData
|
||||
{
|
||||
public int Version { get; set; } = 1;
|
||||
public string PlayerName { get; set; }
|
||||
public int Level { get; set; }
|
||||
}
|
||||
|
||||
// 版本 2 的数据(添加了新字段)
|
||||
public class SaveDataV2 : IVersionedData
|
||||
{
|
||||
// 当前运行时代码支持的最新版本
|
||||
public int Version { get; set; } = 2;
|
||||
public string PlayerName { get; set; }
|
||||
public int Level { get; set; }
|
||||
public int Experience { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
public int Experience { get; set; } // 新增字段
|
||||
public DateTime LastPlayTime { get; set; } // 新增字段
|
||||
}
|
||||
|
||||
public sealed class SaveDataMigrationV1ToV2 : ISaveMigration<SaveData>
|
||||
// 数据迁移器
|
||||
public class SaveDataMigrator
|
||||
{
|
||||
public int FromVersion => 1;
|
||||
|
||||
public int ToVersion => 2;
|
||||
|
||||
public SaveData Migrate(SaveData oldData)
|
||||
public SaveDataV2 Migrate(SaveDataV1 oldData)
|
||||
{
|
||||
return new SaveData
|
||||
return new SaveDataV2
|
||||
{
|
||||
Version = 2,
|
||||
PlayerName = oldData.PlayerName,
|
||||
Level = oldData.Level,
|
||||
Experience = oldData.Level * 100,
|
||||
LastModified = DateTime.UtcNow
|
||||
Experience = oldData.Level * 100, // 根据等级计算经验
|
||||
LastPlayTime = DateTime.Now
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SaveModule : AbstractModule
|
||||
// 加载后由应用层决定是否迁移
|
||||
public async Task<SaveDataV2> LoadWithMigration(int slot)
|
||||
{
|
||||
public override void Install(IArchitecture architecture)
|
||||
{
|
||||
var storage = architecture.GetUtility<IStorage>();
|
||||
var saveConfig = new SaveConfiguration
|
||||
{
|
||||
SaveRoot = "saves",
|
||||
SaveSlotPrefix = "slot_",
|
||||
SaveFileName = "save"
|
||||
};
|
||||
var saveRepo = this.GetUtility<ISaveRepository<SaveDataV2>>();
|
||||
var data = await saveRepo.LoadAsync(slot);
|
||||
|
||||
var saveRepo = new SaveRepository<SaveData>(storage, saveConfig)
|
||||
.RegisterMigration(new SaveDataMigrationV1ToV2());
|
||||
if (data.Version < 2)
|
||||
{
|
||||
// 需要迁移:此处调用应用层迁移器
|
||||
var oldData = data as SaveDataV1;
|
||||
var migrator = new SaveDataMigrator();
|
||||
var newData = migrator.Migrate(oldData);
|
||||
|
||||
architecture.RegisterUtility<ISaveRepository<SaveData>>(saveRepo);
|
||||
// 保存迁移后的数据
|
||||
await saveRepo.SaveAsync(slot, newData);
|
||||
return newData;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SaveData> LoadGame(int slot)
|
||||
{
|
||||
var saveRepo = this.GetUtility<ISaveRepository<SaveData>>();
|
||||
|
||||
// 如果槽位里是 v1,仓库会自动迁移到 v2,并把新版本重新写回存储。
|
||||
return await saveRepo.LoadAsync(slot);
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
`ISaveMigration<TSaveData>` 接收和返回的是同一个存档类型。也就是说,框架提供的是“当前类型内的版本升级管线”,
|
||||
而不是跨 CLR 类型的双模型反序列化系统。如果旧版本缺失了新字段,反序列化会先使用当前类型的默认值,再由迁移器补齐。
|
||||
|
||||
### 使用数据仓库
|
||||
|
||||
```csharp
|
||||
@ -427,74 +390,6 @@ public async Task SaveAllGameData()
|
||||
}
|
||||
```
|
||||
|
||||
`SaveAllAsync(...)` 的事件语义和逐项调用 `SaveAsync(...)` 不同。它代表一次显式的批量提交,因此适合让监听器在收到 `DataBatchSavedEvent` 时统一刷新 UI、缓存或元数据,而不是对每个条目单独响应。
|
||||
|
||||
### 聚合设置仓库
|
||||
|
||||
如果你希望把设置统一保存到单个文件中,可以使用 `UnifiedSettingsDataRepository`:
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Data;
|
||||
using GFramework.Game.Serializer;
|
||||
|
||||
public sealed class GameArchitecture : Architecture
|
||||
{
|
||||
protected override void Init()
|
||||
{
|
||||
var storage = this.GetUtility<IStorage>();
|
||||
var serializer = new JsonSerializer();
|
||||
|
||||
var settingsRepo = new UnifiedSettingsDataRepository(
|
||||
storage,
|
||||
serializer,
|
||||
new DataRepositoryOptions
|
||||
{
|
||||
AutoBackup = true,
|
||||
EnableEvents = true
|
||||
},
|
||||
"settings.json");
|
||||
|
||||
settingsRepo.RegisterDataType(new DataLocation("settings", "graphics"), typeof(GraphicsSettings));
|
||||
settingsRepo.RegisterDataType(new DataLocation("settings", "audio"), typeof(AudioSettings));
|
||||
|
||||
RegisterUtility<ISettingsDataRepository>(settingsRepo);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个实现依然满足 `IDataRepository` 的通用契约,但有两个实现层面的差异需要明确:
|
||||
|
||||
- 它把所有设置 section 缓存在内存中,并在保存或删除时整文件回写
|
||||
- 开启自动备份时,备份的是整个 `settings.json` 文件,因此适合做“上一次完整设置快照”的恢复,而不是 section 级别回滚
|
||||
|
||||
如果你需要 `LoadAllAsync()`,或者希望在同一个统一文件里混合反序列化多个 section,必须先为每个 section 注册类型:
|
||||
|
||||
```csharp
|
||||
public async Task PrintSettingsSnapshot()
|
||||
{
|
||||
var repo = this.GetUtility<ISettingsDataRepository>();
|
||||
|
||||
var all = await repo.LoadAllAsync();
|
||||
|
||||
var graphics = (GraphicsSettings)all["graphics"];
|
||||
var audio = (AudioSettings)all["audio"];
|
||||
|
||||
Console.WriteLine($"Resolution: {graphics.ResolutionWidth}x{graphics.ResolutionHeight}");
|
||||
Console.WriteLine($"MasterVolume: {audio.MasterVolume}");
|
||||
}
|
||||
```
|
||||
|
||||
最小采用要求:
|
||||
|
||||
- 项目需要可用的 `IStorage`
|
||||
- 项目需要一个可用的序列化器实例,例如 `GFramework.Game.Serializer.JsonSerializer`
|
||||
- 在注册仓库时,把所有需要参与 `LoadAllAsync()` 或混合 section 反序列化的 location/type 对显式调用一次 `RegisterDataType(...)`
|
||||
|
||||
兼容性说明:
|
||||
|
||||
- 现在 `UnifiedSettingsDataRepository.LoadAsync<T>()` 发送的是 `DataLoadedEvent<T>`,而不是 `DataLoadedEvent<IData>`
|
||||
- 如果你之前监听的是 `DataLoadedEvent<IData>`,需要改成订阅具体类型,例如 `DataLoadedEvent<GraphicsSettings>` 或 `DataLoadedEvent<AudioSettings>`
|
||||
|
||||
### 存档备份
|
||||
|
||||
```csharp
|
||||
@ -619,14 +514,15 @@ await saveRepo.SaveAsync(3, saveData); // 槽位 3
|
||||
### 问题:如何处理数据版本升级?
|
||||
|
||||
**解答**:
|
||||
实现 `IVersionedData`,并在仓库初始化阶段注册 `ISaveMigration<TSaveData>`。之后 `LoadAsync(slot)` 会自动执行迁移并回写:
|
||||
实现 `IVersionedData` 并在加载后检查版本。当前框架不会自动为 `ISaveRepository<T>` 执行迁移,需要由业务层决定迁移规则与回写时机:
|
||||
|
||||
```csharp
|
||||
var saveRepo = new SaveRepository<SaveData>(storage, saveConfig)
|
||||
.RegisterMigration(new SaveDataMigrationV1ToV2())
|
||||
.RegisterMigration(new SaveDataMigrationV2ToV3());
|
||||
|
||||
var data = await saveRepo.LoadAsync(slot);
|
||||
if (data.Version < CurrentVersion)
|
||||
{
|
||||
data = MigrateData(data);
|
||||
await saveRepo.SaveAsync(slot, data);
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:存档数据保存在哪里?
|
||||
|
||||
@ -196,4 +196,4 @@ public interface ISettingsMigration
|
||||
- 设置迁移是内建能力
|
||||
- 设置持久化是内建能力
|
||||
- 设置如何应用到具体引擎由 applicator 决定
|
||||
- 存档系统也支持内建版本迁移,但入口位于 `ISaveRepository<T>.RegisterMigration(...)`,语义是槽位存档升级而不是设置节初始化
|
||||
- 存档系统的迁移能力不等同于设置系统;`ISaveRepository<T>` 当前仍需要业务层自己实现迁移策略
|
||||
|
||||
@ -2,30 +2,41 @@
|
||||
|
||||
## 概述
|
||||
|
||||
`GFramework.Godot.Coroutine` 在 Core 协程内核之上提供 Godot 宿主集成,负责把 Godot 的不同更新循环映射为真实的协程阶段语义:
|
||||
GFramework 的协程系统由两层组成:
|
||||
|
||||
- `Segment.Process`
|
||||
- `Segment.ProcessIgnorePause`
|
||||
- `Segment.PhysicsProcess`
|
||||
- `Segment.DeferredProcess`
|
||||
- `GFramework.Core.Coroutine` 提供通用调度器、`IYieldInstruction` 和一组等待指令。
|
||||
- `GFramework.Godot.Coroutine` 提供 Godot 环境下的运行入口、分段调度以及节点生命周期辅助方法。
|
||||
|
||||
它同时补充了以下宿主能力:
|
||||
Godot 集成层的核心入口包括:
|
||||
|
||||
- 节点归属协程运行入口
|
||||
- 节点退树自动终止
|
||||
- Godot 真实时间源
|
||||
- 句柄控制与快照查询
|
||||
- `RunCoroutine(...)`
|
||||
- `Timing.RunGameCoroutine(...)`
|
||||
- `Timing.RunUiCoroutine(...)`
|
||||
- `Timing.CallDelayed(...)`
|
||||
- `CancelWith(...)`
|
||||
|
||||
## 启动协程
|
||||
协程本身使用 `IEnumerator<IYieldInstruction>`。
|
||||
|
||||
### 直接运行枚举器
|
||||
## 主要能力
|
||||
|
||||
- 在 Godot 中按不同更新阶段运行协程
|
||||
- 等待时间、帧、条件、Task 和事件总线事件
|
||||
- 显式将协程与一个或多个 `Node` 的生命周期绑定
|
||||
- 通过 `CoroutineHandle` 暂停、恢复、终止协程
|
||||
- 将命令、查询、发布操作直接包装为协程运行
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 启动协程
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using GFramework.Core.Abstractions.Coroutine;
|
||||
using GFramework.Core.Coroutine.Instructions;
|
||||
using GFramework.Godot.Coroutine;
|
||||
using Godot;
|
||||
|
||||
public partial class DemoNode : Node
|
||||
public partial class MyNode : Node
|
||||
{
|
||||
public override void _Ready()
|
||||
{
|
||||
@ -34,131 +45,242 @@ public partial class DemoNode : Node
|
||||
|
||||
private IEnumerator<IYieldInstruction> Demo()
|
||||
{
|
||||
GD.Print("start");
|
||||
yield return new Delay(1.0);
|
||||
GD.Print("开始执行");
|
||||
|
||||
yield return new Delay(2.0);
|
||||
GD.Print("2 秒后继续执行");
|
||||
|
||||
yield return new WaitForEndOfFrame();
|
||||
GD.Print("done");
|
||||
GD.Print("当前帧结束后继续执行");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
默认情况下,`RunCoroutine()` 会在 `Segment.Process` 上运行。
|
||||
`RunCoroutine()` 默认在 `Segment.Process` 上运行,也就是普通帧更新阶段。
|
||||
|
||||
### 以 Node 作为生命周期所有者运行
|
||||
|
||||
更推荐的方式是以节点为入口运行协程:
|
||||
除了枚举器扩展方法,也可以直接使用 `Timing` 的静态入口:
|
||||
|
||||
```csharp
|
||||
public override void _Ready()
|
||||
Timing.RunCoroutine(Demo());
|
||||
Timing.RunGameCoroutine(GameLoop());
|
||||
Timing.RunUiCoroutine(MenuAnimation());
|
||||
```
|
||||
|
||||
### 显式绑定节点生命周期
|
||||
|
||||
可以使用 `CancelWith(...)` 将协程与一个或多个节点的生命周期关联。
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using GFramework.Core.Abstractions.Coroutine;
|
||||
using GFramework.Core.Coroutine.Instructions;
|
||||
using GFramework.Godot.Coroutine;
|
||||
using Godot;
|
||||
|
||||
public partial class MyNode : Node
|
||||
{
|
||||
this.RunCoroutine(LongRunningTask(), Segment.Process, tag: "ui-blink");
|
||||
public override void _Ready()
|
||||
{
|
||||
LongRunningTask()
|
||||
.CancelWith(this)
|
||||
.RunCoroutine();
|
||||
}
|
||||
|
||||
private IEnumerator<IYieldInstruction> LongRunningTask()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
GD.Print("tick");
|
||||
yield return new Delay(1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这会自动把协程登记为该节点归属协程,并在节点退出场景树时终止它。
|
||||
`CancelWith` 目前有三种重载:
|
||||
|
||||
你仍然可以继续使用 `CancelWith(...)` 包装已有枚举器;它适合把一个协程显式绑定到多个节点生命周期。
|
||||
- `CancelWith(Node node)`
|
||||
- `CancelWith(Node node1, Node node2)`
|
||||
- `CancelWith(params Node[] nodes)`
|
||||
|
||||
## Segment 与阶段语义
|
||||
`CancelWith(...)` 内部通过 `Timing.IsNodeAlive(...)` 判断节点是否仍然有效。只要任一被监视的节点出现以下任一情况,包装后的协程就会停止继续枚举:
|
||||
|
||||
Godot 层会把不同 segment 映射为不同的 `CoroutineExecutionStage`:
|
||||
- 节点引用为 `null`
|
||||
- Godot 实例已经失效或已被释放
|
||||
- 节点已进入 `queue_free` / `IsQueuedForDeletion()`
|
||||
- 节点已退出场景树,`IsInsideTree()` 返回 `false`
|
||||
|
||||
- `Segment.Process`
|
||||
- 对应默认更新阶段
|
||||
- 场景树暂停时不会推进
|
||||
- `Segment.ProcessIgnorePause`
|
||||
- 同样对应默认更新阶段
|
||||
- 场景树暂停时仍会推进
|
||||
- `Segment.PhysicsProcess`
|
||||
- 对应固定更新阶段
|
||||
- `WaitForFixedUpdate` 会在这里真实完成
|
||||
- `Segment.DeferredProcess`
|
||||
- 对应帧结束阶段
|
||||
- `WaitForEndOfFrame` 会在这里真实完成
|
||||
这意味着协程不只会在节点真正释放时停止;节点一旦退出场景树,下一次推进时也会停止。
|
||||
|
||||
## Segment 分段
|
||||
|
||||
Godot 层通过 `Segment` 决定协程挂在哪个调度器上:
|
||||
|
||||
```csharp
|
||||
public enum Segment
|
||||
{
|
||||
Process,
|
||||
ProcessIgnorePause,
|
||||
PhysicsProcess,
|
||||
DeferredProcess
|
||||
}
|
||||
```
|
||||
|
||||
- `Process`:普通 `_Process` 段,场景树暂停时不会推进。
|
||||
- `ProcessIgnorePause`:同样使用 process delta,但即使场景树暂停也会推进。
|
||||
- `PhysicsProcess`:在 `_PhysicsProcess` 段推进。
|
||||
- `DeferredProcess`:通过 `CallDeferred` 在当前帧之后推进,场景树暂停时不会推进。
|
||||
|
||||
示例:
|
||||
|
||||
```csharp
|
||||
this.RunCoroutine(PhysicsRoutine(), Segment.PhysicsProcess);
|
||||
this.RunCoroutine(UiAnimation(), Segment.ProcessIgnorePause);
|
||||
UiAnimation().RunCoroutine(Segment.ProcessIgnorePause);
|
||||
PhysicsRoutine().RunCoroutine(Segment.PhysicsProcess);
|
||||
```
|
||||
|
||||
## 时间等待语义
|
||||
|
||||
Godot 集成层为每个调度器同时提供了两套时间源:
|
||||
|
||||
- 缩放时间
|
||||
- 来自 `_Process` / `_PhysicsProcess` 的帧增量
|
||||
- 真实时间
|
||||
- 来自 Godot 单调时钟,不受时间缩放和暂停影响
|
||||
|
||||
因此:
|
||||
|
||||
- `Delay` / `WaitForSecondsScaled` 使用宿主帧增量
|
||||
- `WaitForSecondsRealtime` 使用真实时间
|
||||
|
||||
这意味着 UI 或暂停菜单中的协程可以安全使用 `WaitForSecondsRealtime` 保持真实计时。
|
||||
|
||||
## 生命周期管理
|
||||
|
||||
### 自动归属
|
||||
如果你更偏向语义化入口,也可以直接使用:
|
||||
|
||||
```csharp
|
||||
var handle = this.RunCoroutine(LoadAvatar(), tag: "avatar");
|
||||
Timing.RunGameCoroutine(GameLoop());
|
||||
Timing.RunUiCoroutine(MenuAnimation());
|
||||
```
|
||||
|
||||
### 手动绑定多个节点
|
||||
### 延迟调用
|
||||
|
||||
```csharp
|
||||
LongRunningTask()
|
||||
.CancelWith(this, panelNode)
|
||||
.RunCoroutine();
|
||||
```
|
||||
|
||||
### 主动清理
|
||||
|
||||
```csharp
|
||||
Timing.KillCoroutine(handle);
|
||||
Timing.KillCoroutines(this);
|
||||
Timing.KillCoroutines("avatar");
|
||||
Timing.KillAllCoroutines();
|
||||
```
|
||||
|
||||
## 调试与查询
|
||||
|
||||
```csharp
|
||||
if (Timing.TryGetCoroutineSnapshot(handle, out var snapshot))
|
||||
{
|
||||
GD.Print(snapshot.ExecutionStage);
|
||||
GD.Print(snapshot.WaitingInstructionType);
|
||||
}
|
||||
|
||||
var ownedCount = Timing.GetOwnedCoroutineCount(this);
|
||||
```
|
||||
|
||||
实例级计数器:
|
||||
|
||||
- `Timing.Instance.ProcessCoroutines`
|
||||
- `Timing.Instance.ProcessIgnorePauseCoroutines`
|
||||
- `Timing.Instance.PhysicsCoroutines`
|
||||
- `Timing.Instance.DeferredCoroutines`
|
||||
|
||||
## 延迟调用
|
||||
`Timing` 还提供了两个延迟调用快捷方法:
|
||||
|
||||
```csharp
|
||||
Timing.CallDelayed(1.0, () => GD.Print("1 秒后执行"));
|
||||
Timing.CallDelayed(1.0, () => GD.Print("节点仍然有效时执行"), this);
|
||||
```
|
||||
|
||||
第二个重载内部使用节点归属语义,因此节点退树后不会再触发动作。
|
||||
第二个重载会在执行前检查传入节点是否仍然存活。
|
||||
|
||||
## 常用等待指令
|
||||
|
||||
以下类型可直接用于 `yield return`:
|
||||
|
||||
### 时间与帧
|
||||
|
||||
```csharp
|
||||
yield return new Delay(1.0);
|
||||
yield return new WaitForSecondsRealtime(1.0);
|
||||
yield return new WaitOneFrame();
|
||||
yield return new WaitForNextFrame();
|
||||
yield return new WaitForFrames(5);
|
||||
yield return new WaitForEndOfFrame();
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `Delay` 是最直接的秒级等待。
|
||||
- `WaitForSecondsRealtime` 常用于需要独立计时语义的协程场景。
|
||||
- `WaitOneFrame`、`WaitForNextFrame`、`WaitForEndOfFrame` 用于帧级调度控制。
|
||||
|
||||
### 条件等待
|
||||
|
||||
```csharp
|
||||
yield return new WaitUntil(() => health > 0);
|
||||
yield return new WaitWhile(() => isLoading);
|
||||
```
|
||||
|
||||
### Task 等待
|
||||
|
||||
```csharp
|
||||
using System.Threading.Tasks;
|
||||
using GFramework.Core.Coroutine.Extensions;
|
||||
|
||||
Task loadTask = LoadSomethingAsync();
|
||||
yield return loadTask.AsCoroutineInstruction();
|
||||
```
|
||||
|
||||
也可以先把 `Task` 转成协程枚举器,再直接运行:
|
||||
|
||||
```csharp
|
||||
LoadSomethingAsync()
|
||||
.ToCoroutineEnumerator()
|
||||
.RunCoroutine();
|
||||
```
|
||||
|
||||
- 已经在一个协程内部时,优先使用 `yield return task.AsCoroutineInstruction()`,这样可以直接把 `Task` 嵌入当前协程流程。
|
||||
- 如果要把一个现成的 `Task` 当作独立协程入口交给 Godot 协程系统运行,再使用
|
||||
`task.ToCoroutineEnumerator().RunCoroutine()`。
|
||||
|
||||
### 等待事件总线事件
|
||||
|
||||
可以通过事件总线等待业务事件:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using GFramework.Core.Abstractions.Coroutine;
|
||||
using GFramework.Core.Abstractions.Events;
|
||||
using GFramework.Core.Coroutine.Instructions;
|
||||
|
||||
private IEnumerator<IYieldInstruction> WaitForGameEvent(IEventBus eventBus)
|
||||
{
|
||||
using var wait = new WaitForEvent<PlayerSpawnedEvent>(eventBus);
|
||||
yield return wait;
|
||||
|
||||
var evt = wait.EventData;
|
||||
}
|
||||
```
|
||||
|
||||
如需为事件等待附加超时控制,可结合 `WaitForEventWithTimeout<TEvent>`。
|
||||
|
||||
## 协程控制
|
||||
|
||||
协程启动后会返回 `CoroutineHandle`,可用于控制运行状态:
|
||||
|
||||
```csharp
|
||||
var handle = Demo().RunCoroutine(tag: "demo");
|
||||
|
||||
Timing.PauseCoroutine(handle);
|
||||
Timing.ResumeCoroutine(handle);
|
||||
Timing.KillCoroutine(handle);
|
||||
|
||||
Timing.KillCoroutines("demo");
|
||||
Timing.KillAllCoroutines();
|
||||
```
|
||||
|
||||
如果希望在场景初始化阶段主动确保调度器存在,也可以调用:
|
||||
|
||||
```csharp
|
||||
Timing.Prewarm();
|
||||
```
|
||||
|
||||
## 与 IContextAware 集成
|
||||
|
||||
Godot 层还提供以下扩展方法,用于把命令、查询和通知直接包装成协程并交给 Timing 调度:
|
||||
`GFramework.Godot.Coroutine` 还提供了一组扩展方法,用于把命令、查询和通知直接包装成协程:
|
||||
|
||||
- `RunCommandCoroutine(...)`
|
||||
- `RunCommandCoroutine<TResponse>(...)`
|
||||
- `RunQueryCoroutine<TResponse>(...)`
|
||||
- `RunPublishCoroutine(...)`
|
||||
|
||||
这些 API 仍然可以与 `Segment`、节点归属和标签控制一起使用。
|
||||
这些方法会把异步操作转换为协程,并交给 `RunCoroutine(...)` 调度执行。
|
||||
|
||||
例如:
|
||||
|
||||
```csharp
|
||||
public void StartCoroutines(IContextAware contextAware)
|
||||
{
|
||||
contextAware.RunCommandCoroutine(
|
||||
new EnterBattleCommand(),
|
||||
Segment.Process,
|
||||
tag: "battle");
|
||||
|
||||
contextAware.RunQueryCoroutine(
|
||||
new LoadPlayerQuery(),
|
||||
Segment.ProcessIgnorePause,
|
||||
tag: "ui");
|
||||
}
|
||||
```
|
||||
|
||||
这些扩展适合在 Godot 节点或控制器中直接启动和跟踪业务协程。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Godot 概述](./index.md)
|
||||
- [Godot 扩展方法](./extensions.md)
|
||||
- [信号扩展](./signal.md)
|
||||
- [事件系统](../core/events.md)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 使用协程系统
|
||||
description: 学习如何在 GFramework 中创建调度器、运行协程,并结合时间、阶段、Task 与生命周期管理实现常见异步流程。
|
||||
description: 学习如何使用协程系统实现异步操作和时间控制
|
||||
---
|
||||
|
||||
# 使用协程系统
|
||||
@ -9,184 +9,590 @@ description: 学习如何在 GFramework 中创建调度器、运行协程,并
|
||||
|
||||
完成本教程后,你将能够:
|
||||
|
||||
- 创建并驱动 `CoroutineScheduler`
|
||||
- 编写 `IEnumerator<IYieldInstruction>` 协程
|
||||
- 区分缩放时间、真实时间与阶段等待
|
||||
- 使用句柄、取消令牌和快照查询控制协程
|
||||
- 在 Godot 中把协程绑定到节点生命周期
|
||||
- 理解协程的基本概念和执行机制
|
||||
- 创建和启动协程
|
||||
- 使用各种等待指令控制协程执行
|
||||
- 在架构组件中使用协程
|
||||
- 实现常见的游戏逻辑(延迟执行、循环任务、事件等待)
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 已安装 GFramework.Core NuGet 包
|
||||
- 了解 C# 基础语法和迭代器(IEnumerator)
|
||||
- 阅读过[快速开始](/zh-CN/getting-started/quick-start)
|
||||
- 了解[生命周期管理](/zh-CN/core/lifecycle)
|
||||
|
||||
## 步骤 1:创建第一个协程
|
||||
|
||||
首先,让我们创建一个简单的协程来理解基本概念。
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Coroutine;
|
||||
using GFramework.Core.Coroutine;
|
||||
using GFramework.Core.Coroutine.Instructions;
|
||||
|
||||
public sealed class TutorialLoop
|
||||
namespace MyGame.Systems
|
||||
{
|
||||
private readonly CoroutineScheduler _scheduler;
|
||||
|
||||
public TutorialLoop(ITimeSource timeSource)
|
||||
public class TutorialSystem : AbstractSystem
|
||||
{
|
||||
_scheduler = new CoroutineScheduler(timeSource);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_scheduler.Run(MyFirstCoroutine(), tag: "tutorial");
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
_scheduler.Update();
|
||||
protected override void OnInit()
|
||||
{
|
||||
// 启动协程
|
||||
this.StartCoroutine(MyFirstCoroutine());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 第一个协程示例
|
||||
/// </summary>
|
||||
private IEnumerator<IYieldInstruction> MyFirstCoroutine()
|
||||
{
|
||||
Console.WriteLine("协程开始");
|
||||
Console.WriteLine("协程开始执行");
|
||||
|
||||
yield return new Delay(1.0);
|
||||
Console.WriteLine("1 秒后");
|
||||
// 等待 1 秒
|
||||
yield return CoroutineHelper.WaitForSeconds(1.0);
|
||||
|
||||
yield return new WaitOneFrame();
|
||||
Console.WriteLine("下一帧");
|
||||
Console.WriteLine("1 秒后执行");
|
||||
|
||||
yield return new WaitForFrames(3);
|
||||
Console.WriteLine("3 帧后");
|
||||
// 等待 1 帧
|
||||
yield return CoroutineHelper.WaitForOneFrame();
|
||||
|
||||
Console.WriteLine("下一帧执行");
|
||||
|
||||
// 等待 5 帧
|
||||
yield return CoroutineHelper.WaitForFrames(5);
|
||||
|
||||
Console.WriteLine("5 帧后执行");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关键点:
|
||||
**代码说明**:
|
||||
|
||||
- 协程返回类型必须是 `IEnumerator<IYieldInstruction>`
|
||||
- 调度器不会自动运行,你必须在宿主主循环中调用 `Update()`
|
||||
- `Run(...)` 返回 `CoroutineHandle`,后续控制都依赖这个句柄
|
||||
- 协程方法返回 `IEnumerator<IYieldInstruction>`
|
||||
- 使用 `yield return` 返回等待指令
|
||||
- `this.StartCoroutine()` 扩展方法启动协程
|
||||
- `WaitForSeconds` 等待指定秒数
|
||||
- `WaitForOneFrame` 等待一帧
|
||||
- `WaitForFrames` 等待多帧
|
||||
|
||||
## 步骤 2:控制协程生命周期
|
||||
## 步骤 2:实现生命值自动恢复
|
||||
|
||||
让我们实现一个实用的功能:玩家生命值自动恢复。
|
||||
|
||||
```csharp
|
||||
using var cts = new CancellationTokenSource();
|
||||
using GFramework.Core.Abstractions.Model;
|
||||
using GFramework.Core.Abstractions.Property;
|
||||
using GFramework.Core.Model;
|
||||
|
||||
var handle = _scheduler.Run(
|
||||
HealthRegenerationCoroutine(),
|
||||
tag: "regen",
|
||||
group: "player",
|
||||
cancellationToken: cts.Token);
|
||||
|
||||
_scheduler.Pause(handle);
|
||||
_scheduler.Resume(handle);
|
||||
|
||||
// 外部取消会在下一次 Update 时生效
|
||||
cts.Cancel();
|
||||
|
||||
var status = await _scheduler.WaitForCompletionAsync(handle);
|
||||
Console.WriteLine(status);
|
||||
```
|
||||
|
||||
如果你需要观察运行中状态:
|
||||
|
||||
```csharp
|
||||
if (_scheduler.TryGetSnapshot(handle, out var snapshot))
|
||||
namespace MyGame.Models
|
||||
{
|
||||
Console.WriteLine(snapshot.State);
|
||||
Console.WriteLine(snapshot.WaitingInstructionType);
|
||||
}
|
||||
```
|
||||
|
||||
## 步骤 3:区分时间等待
|
||||
|
||||
```csharp
|
||||
private IEnumerator<IYieldInstruction> CooldownCoroutine()
|
||||
{
|
||||
// 使用宿主默认时间
|
||||
yield return new Delay(2.0);
|
||||
|
||||
// 使用真实时间,需要调度器提供 realtimeTimeSource
|
||||
yield return new WaitForSecondsRealtime(2.0);
|
||||
}
|
||||
```
|
||||
|
||||
建议:
|
||||
|
||||
- 普通游戏逻辑优先使用 `Delay`
|
||||
- 暂停菜单、真实倒计时、网络超时等场景使用 `WaitForSecondsRealtime`
|
||||
|
||||
## 步骤 4:使用阶段等待
|
||||
|
||||
只有宿主为调度器提供了匹配阶段时,阶段等待才会真实生效:
|
||||
|
||||
```csharp
|
||||
var fixedScheduler = new CoroutineScheduler(
|
||||
fixedTimeSource,
|
||||
executionStage: CoroutineExecutionStage.FixedUpdate);
|
||||
|
||||
private IEnumerator<IYieldInstruction> PhysicsCoroutine()
|
||||
{
|
||||
yield return new WaitForFixedUpdate();
|
||||
Console.WriteLine("下一次固定步到达");
|
||||
}
|
||||
```
|
||||
|
||||
同理,`WaitForEndOfFrame` 需要运行在 `CoroutineExecutionStage.EndOfFrame` 的调度器上。
|
||||
|
||||
## 步骤 5:等待 Task
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Coroutine.Extensions;
|
||||
|
||||
private IEnumerator<IYieldInstruction> LoadCoroutine()
|
||||
{
|
||||
var task = LoadDataAsync();
|
||||
yield return task.AsCoroutineInstruction();
|
||||
Console.WriteLine("Task 已完成");
|
||||
}
|
||||
```
|
||||
|
||||
如果你已经持有调度器,也可以直接把 `Task` 作为顶层协程启动:
|
||||
|
||||
```csharp
|
||||
var handle = _scheduler.StartTaskAsCoroutine(LoadDataAsync());
|
||||
```
|
||||
|
||||
## 步骤 6:在 Godot 中绑定 Node 生命周期
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Coroutine;
|
||||
using GFramework.Core.Coroutine.Instructions;
|
||||
using GFramework.Godot.Coroutine;
|
||||
using Godot;
|
||||
|
||||
public partial class DemoNode : Node
|
||||
{
|
||||
public override void _Ready()
|
||||
public class PlayerModel : AbstractModel
|
||||
{
|
||||
// 推荐:节点作为所有者运行协程
|
||||
this.RunCoroutine(BlinkCoroutine(), Segment.ProcessIgnorePause, tag: "blink");
|
||||
// 当前生命值
|
||||
public BindableProperty<int> Health { get; } = new(100);
|
||||
|
||||
// 最大生命值
|
||||
public BindableProperty<int> MaxHealth { get; } = new(100);
|
||||
|
||||
// 是否启用自动恢复
|
||||
public BindableProperty<bool> AutoRegenEnabled { get; } = new(true);
|
||||
|
||||
private CoroutineHandle? _regenHandle;
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
// 启动生命值恢复协程
|
||||
StartHealthRegeneration();
|
||||
}
|
||||
|
||||
private IEnumerator<IYieldInstruction> BlinkCoroutine()
|
||||
/// <summary>
|
||||
/// 启动生命值恢复
|
||||
/// </summary>
|
||||
public void StartHealthRegeneration()
|
||||
{
|
||||
// 如果已经在运行,先停止
|
||||
if (_regenHandle.HasValue)
|
||||
{
|
||||
this.StopCoroutine(_regenHandle.Value);
|
||||
}
|
||||
|
||||
// 启动新的恢复协程
|
||||
_regenHandle = this.StartCoroutine(HealthRegenerationCoroutine());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止生命值恢复
|
||||
/// </summary>
|
||||
public void StopHealthRegeneration()
|
||||
{
|
||||
if (_regenHandle.HasValue)
|
||||
{
|
||||
this.StopCoroutine(_regenHandle.Value);
|
||||
_regenHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生命值恢复协程
|
||||
/// </summary>
|
||||
private IEnumerator<IYieldInstruction> HealthRegenerationCoroutine()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Visible = !Visible;
|
||||
yield return new WaitForSecondsRealtime(0.5);
|
||||
// 等待 1 秒
|
||||
yield return CoroutineHelper.WaitForSeconds(1.0);
|
||||
|
||||
// 检查是否启用自动恢复
|
||||
if (!AutoRegenEnabled.Value)
|
||||
continue;
|
||||
|
||||
// 如果生命值未满,恢复 5 点
|
||||
if (Health.Value < MaxHealth.Value)
|
||||
{
|
||||
Health.Value = Math.Min(Health.Value + 5, MaxHealth.Value);
|
||||
Console.WriteLine($"生命值恢复: {Health.Value}/{MaxHealth.Value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当 `DemoNode` 退出场景树时,上面的协程会被自动终止。
|
||||
**代码说明**:
|
||||
|
||||
如果你需要绑定多个节点,可以继续使用:
|
||||
- 使用 `while (true)` 创建无限循环协程
|
||||
- 保存协程句柄以便后续控制
|
||||
- 使用 `StopCoroutine` 停止协程
|
||||
- 协程中可以访问类成员变量
|
||||
|
||||
## 步骤 3:实现技能冷却系统
|
||||
|
||||
接下来实现一个技能冷却系统,展示如何使用协程管理时间相关的游戏逻辑。
|
||||
|
||||
```csharp
|
||||
BlinkCoroutine()
|
||||
.CancelWith(this, anotherNode)
|
||||
.RunCoroutine();
|
||||
using GFramework.Core.System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace MyGame.Systems
|
||||
{
|
||||
public class SkillSystem : AbstractSystem
|
||||
{
|
||||
// 技能冷却状态
|
||||
private readonly Dictionary<string, bool> _skillCooldowns = new();
|
||||
|
||||
/// <summary>
|
||||
/// 使用技能
|
||||
/// </summary>
|
||||
public bool UseSkill(string skillName, double cooldownTime)
|
||||
{
|
||||
// 检查是否在冷却中
|
||||
if (_skillCooldowns.TryGetValue(skillName, out var isOnCooldown) && isOnCooldown)
|
||||
{
|
||||
Console.WriteLine($"技能 {skillName} 冷却中...");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 执行技能
|
||||
Console.WriteLine($"使用技能: {skillName}");
|
||||
|
||||
// 启动冷却协程
|
||||
this.StartCoroutine(SkillCooldownCoroutine(skillName, cooldownTime));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 技能冷却协程
|
||||
/// </summary>
|
||||
private IEnumerator<IYieldInstruction> SkillCooldownCoroutine(string skillName, double cooldownTime)
|
||||
{
|
||||
// 标记为冷却中
|
||||
_skillCooldowns[skillName] = true;
|
||||
|
||||
Console.WriteLine($"技能 {skillName} 开始冷却 {cooldownTime} 秒");
|
||||
|
||||
// 等待冷却时间
|
||||
yield return CoroutineHelper.WaitForSeconds(cooldownTime);
|
||||
|
||||
// 冷却结束
|
||||
_skillCooldowns[skillName] = false;
|
||||
Console.WriteLine($"技能 {skillName} 冷却完成");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 带进度显示的技能冷却
|
||||
/// </summary>
|
||||
private IEnumerator<IYieldInstruction> SkillCooldownWithProgressCoroutine(
|
||||
string skillName,
|
||||
double cooldownTime)
|
||||
{
|
||||
_skillCooldowns[skillName] = true;
|
||||
|
||||
// 使用 WaitForProgress 显示冷却进度
|
||||
yield return CoroutineHelper.WaitForProgress(
|
||||
duration: cooldownTime,
|
||||
onProgress: progress =>
|
||||
{
|
||||
Console.WriteLine($"技能 {skillName} 冷却进度: {progress * 100:F0}%");
|
||||
}
|
||||
);
|
||||
|
||||
_skillCooldowns[skillName] = false;
|
||||
Console.WriteLine($"技能 {skillName} 冷却完成");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**代码说明**:
|
||||
|
||||
- 使用字典管理多个技能的冷却状态
|
||||
- 每个技能使用独立的协程管理冷却
|
||||
- `WaitForProgress` 可以在等待期间执行回调
|
||||
- 协程结束后自动清理冷却状态
|
||||
|
||||
## 步骤 4:等待事件触发
|
||||
|
||||
实现一个等待玩家完成任务的系统,展示如何在协程中等待事件。
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Events;
|
||||
using GFramework.Core.Coroutine.Instructions;
|
||||
|
||||
namespace MyGame.Systems
|
||||
{
|
||||
// 任务完成事件
|
||||
public record QuestCompletedEvent(int QuestId, string QuestName) : IEvent;
|
||||
|
||||
public class QuestSystem : AbstractSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// 开始任务并等待完成
|
||||
/// </summary>
|
||||
public void StartQuest(int questId, string questName)
|
||||
{
|
||||
this.StartCoroutine(QuestCoroutine(questId, questName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 任务协程
|
||||
/// </summary>
|
||||
private IEnumerator<IYieldInstruction> QuestCoroutine(int questId, string questName)
|
||||
{
|
||||
Console.WriteLine($"任务开始: {questName}");
|
||||
|
||||
// 获取事件总线
|
||||
var eventBus = this.GetService<IEventBus>();
|
||||
|
||||
// 等待任务完成事件
|
||||
var waitEvent = new WaitForEvent<QuestCompletedEvent>(
|
||||
eventBus,
|
||||
evt => evt.QuestId == questId // 过滤条件
|
||||
);
|
||||
|
||||
yield return waitEvent;
|
||||
|
||||
// 获取事件数据
|
||||
var completedEvent = waitEvent.EventData;
|
||||
Console.WriteLine($"任务完成: {completedEvent.QuestName}");
|
||||
|
||||
// 发放奖励
|
||||
GiveReward(questId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 带超时的任务
|
||||
/// </summary>
|
||||
private IEnumerator<IYieldInstruction> TimedQuestCoroutine(
|
||||
int questId,
|
||||
string questName,
|
||||
double timeLimit)
|
||||
{
|
||||
Console.WriteLine($"限时任务开始: {questName} (时限: {timeLimit}秒)");
|
||||
|
||||
var eventBus = this.GetService<IEventBus>();
|
||||
|
||||
// 等待事件,带超时
|
||||
var waitEvent = new WaitForEventWithTimeout<QuestCompletedEvent>(
|
||||
eventBus,
|
||||
timeout: timeLimit,
|
||||
predicate: evt => evt.QuestId == questId
|
||||
);
|
||||
|
||||
yield return waitEvent;
|
||||
|
||||
if (waitEvent.IsTimeout)
|
||||
{
|
||||
Console.WriteLine($"任务超时失败: {questName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"任务完成: {questName}");
|
||||
GiveReward(questId);
|
||||
}
|
||||
}
|
||||
|
||||
private void GiveReward(int questId)
|
||||
{
|
||||
Console.WriteLine($"发放任务 {questId} 的奖励");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**代码说明**:
|
||||
|
||||
- `WaitForEvent` 等待特定事件触发
|
||||
- 可以使用 `predicate` 参数过滤事件
|
||||
- `WaitForEventWithTimeout` 支持超时机制
|
||||
- 通过 `EventData` 属性获取事件数据
|
||||
|
||||
## 步骤 5:协程组合与嵌套
|
||||
|
||||
实现一个复杂的游戏流程,展示如何组合多个协程。
|
||||
|
||||
```csharp
|
||||
namespace MyGame.Systems
|
||||
{
|
||||
public class GameFlowSystem : AbstractSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// 游戏开始流程
|
||||
/// </summary>
|
||||
public void StartGame()
|
||||
{
|
||||
this.StartCoroutine(GameStartSequence());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 游戏开始序列
|
||||
/// </summary>
|
||||
private IEnumerator<IYieldInstruction> GameStartSequence()
|
||||
{
|
||||
Console.WriteLine("=== 游戏开始 ===");
|
||||
|
||||
// 1. 显示标题
|
||||
yield return ShowTitle();
|
||||
|
||||
// 2. 加载资源
|
||||
yield return LoadResources();
|
||||
|
||||
// 3. 初始化玩家
|
||||
yield return InitializePlayer();
|
||||
|
||||
// 4. 播放开场动画
|
||||
yield return PlayOpeningAnimation();
|
||||
|
||||
Console.WriteLine("=== 游戏准备完成 ===");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示标题
|
||||
/// </summary>
|
||||
private IEnumerator<IYieldInstruction> ShowTitle()
|
||||
{
|
||||
Console.WriteLine("显示游戏标题...");
|
||||
yield return CoroutineHelper.WaitForSeconds(2.0);
|
||||
Console.WriteLine("标题显示完成");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载资源
|
||||
/// </summary>
|
||||
private IEnumerator<IYieldInstruction> LoadResources()
|
||||
{
|
||||
Console.WriteLine("开始加载资源...");
|
||||
|
||||
// 并行加载多个资源
|
||||
var loadTextures = LoadTexturesCoroutine();
|
||||
var loadAudio = LoadAudioCoroutine();
|
||||
var loadModels = LoadModelsCoroutine();
|
||||
|
||||
// 等待所有资源加载完成
|
||||
yield return new WaitForAllCoroutines(
|
||||
this.GetCoroutineScheduler(),
|
||||
loadTextures,
|
||||
loadAudio,
|
||||
loadModels
|
||||
);
|
||||
|
||||
Console.WriteLine("所有资源加载完成");
|
||||
}
|
||||
|
||||
private IEnumerator<IYieldInstruction> LoadTexturesCoroutine()
|
||||
{
|
||||
Console.WriteLine(" 加载纹理...");
|
||||
yield return CoroutineHelper.WaitForSeconds(1.0);
|
||||
Console.WriteLine(" 纹理加载完成");
|
||||
}
|
||||
|
||||
private IEnumerator<IYieldInstruction> LoadAudioCoroutine()
|
||||
{
|
||||
Console.WriteLine(" 加载音频...");
|
||||
yield return CoroutineHelper.WaitForSeconds(1.5);
|
||||
Console.WriteLine(" 音频加载完成");
|
||||
}
|
||||
|
||||
private IEnumerator<IYieldInstruction> LoadModelsCoroutine()
|
||||
{
|
||||
Console.WriteLine(" 加载模型...");
|
||||
yield return CoroutineHelper.WaitForSeconds(0.8);
|
||||
Console.WriteLine(" 模型加载完成");
|
||||
}
|
||||
|
||||
private IEnumerator<IYieldInstruction> InitializePlayer()
|
||||
{
|
||||
Console.WriteLine("初始化玩家...");
|
||||
yield return CoroutineHelper.WaitForSeconds(0.5);
|
||||
Console.WriteLine("玩家初始化完成");
|
||||
}
|
||||
|
||||
private IEnumerator<IYieldInstruction> PlayOpeningAnimation()
|
||||
{
|
||||
Console.WriteLine("播放开场动画...");
|
||||
yield return CoroutineHelper.WaitForSeconds(3.0);
|
||||
Console.WriteLine("开场动画播放完成");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取协程调度器
|
||||
/// </summary>
|
||||
private CoroutineScheduler GetCoroutineScheduler()
|
||||
{
|
||||
// 从架构服务中获取
|
||||
return this.GetService<CoroutineScheduler>();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**代码说明**:
|
||||
|
||||
- 使用 `yield return` 调用其他协程实现嵌套
|
||||
- `WaitForAllCoroutines` 并行执行多个协程
|
||||
- 协程可以像函数一样组合和复用
|
||||
- 清晰的流程控制,避免回调嵌套
|
||||
|
||||
## 完整代码
|
||||
|
||||
### GameArchitecture.cs
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Architecture;
|
||||
|
||||
namespace MyGame
|
||||
{
|
||||
public class GameArchitecture : Architecture
|
||||
{
|
||||
public static IArchitecture Interface { get; private set; }
|
||||
|
||||
protected override void Init()
|
||||
{
|
||||
Interface = this;
|
||||
|
||||
// 注册 Model
|
||||
RegisterModel(new PlayerModel());
|
||||
|
||||
// 注册 System
|
||||
RegisterSystem(new TutorialSystem());
|
||||
RegisterSystem(new SkillSystem());
|
||||
RegisterSystem(new QuestSystem());
|
||||
RegisterSystem(new GameFlowSystem());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 测试代码
|
||||
|
||||
```csharp
|
||||
using MyGame;
|
||||
using MyGame.Systems;
|
||||
|
||||
// 初始化架构
|
||||
var architecture = new GameArchitecture();
|
||||
architecture.Initialize();
|
||||
await architecture.WaitUntilReadyAsync();
|
||||
|
||||
// 测试技能系统
|
||||
var skillSystem = architecture.GetSystem<SkillSystem>();
|
||||
skillSystem.UseSkill("火球术", 3.0);
|
||||
await Task.Delay(1000);
|
||||
skillSystem.UseSkill("火球术", 3.0); // 冷却中
|
||||
await Task.Delay(3000);
|
||||
skillSystem.UseSkill("火球术", 3.0); // 冷却完成
|
||||
|
||||
// 测试任务系统
|
||||
var questSystem = architecture.GetSystem<QuestSystem>();
|
||||
questSystem.StartQuest(1, "击败史莱姆");
|
||||
|
||||
// 模拟任务完成
|
||||
await Task.Delay(2000);
|
||||
var eventBus = architecture.GetService<IEventBus>();
|
||||
eventBus.Publish(new QuestCompletedEvent(1, "击败史莱姆"));
|
||||
|
||||
// 测试游戏流程
|
||||
var gameFlowSystem = architecture.GetSystem<GameFlowSystem>();
|
||||
gameFlowSystem.StartGame();
|
||||
```
|
||||
|
||||
## 运行结果
|
||||
|
||||
运行程序后,你将看到类似以下的输出:
|
||||
|
||||
```
|
||||
协程开始执行
|
||||
1 秒后执行
|
||||
下一帧执行
|
||||
5 帧后执行
|
||||
|
||||
使用技能: 火球术
|
||||
技能 火球术 开始冷却 3.0 秒
|
||||
技能 火球术 冷却中...
|
||||
技能 火球术 冷却完成
|
||||
使用技能: 火球术
|
||||
|
||||
任务开始: 击败史莱姆
|
||||
任务完成: 击败史莱姆
|
||||
发放任务 1 的奖励
|
||||
|
||||
=== 游戏开始 ===
|
||||
显示游戏标题...
|
||||
标题显示完成
|
||||
开始加载资源...
|
||||
加载纹理...
|
||||
加载音频...
|
||||
加载模型...
|
||||
模型加载完成
|
||||
纹理加载完成
|
||||
音频加载完成
|
||||
所有资源加载完成
|
||||
初始化玩家...
|
||||
玩家初始化完成
|
||||
播放开场动画...
|
||||
开场动画播放完成
|
||||
=== 游戏准备完成 ===
|
||||
```
|
||||
|
||||
**验证步骤**:
|
||||
|
||||
1. 协程按预期顺序执行
|
||||
2. 技能冷却系统正常工作
|
||||
3. 事件等待功能正确
|
||||
4. 并行加载资源成功
|
||||
|
||||
## 下一步
|
||||
|
||||
- Core 侧更完整的 API 说明见 [Core 协程系统](/zh-CN/core/coroutine)
|
||||
- Godot 集成细节见 [Godot 协程系统](/zh-CN/godot/coroutine)
|
||||
恭喜!你已经掌握了协程系统的基本用法。接下来可以学习:
|
||||
|
||||
- [实现状态机](/zh-CN/tutorials/state-machine-tutorial) - 使用协程实现状态转换
|
||||
- [资源管理最佳实践](/zh-CN/tutorials/resource-management) - 在协程中加载资源
|
||||
- [使用事件系统](/zh-CN/core/events) - 协程与事件系统集成
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [协程系统](/zh-CN/core/coroutine) - 协程系统详细说明
|
||||
- [事件系统](/zh-CN/core/events) - 事件系统详解
|
||||
- [生命周期管理](/zh-CN/core/lifecycle) - 组件生命周期
|
||||
- [System 层](/zh-CN/core/system) - System 详细说明
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user