Files
ft/CodexOutput/upserver/KVGameLoadTests/GameHandoffTests.cs
Liubing\LB 6813da149a feat(upserver): 保存M2客户端KV迁移至本人统计写检查点
保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。
保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。

验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。
包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
2026-09-30 19:19:05 +08:00

142 lines
9.6 KiB
C#

using System.Reflection;
using game.PlayerDataStorage;
using Newtonsoft.Json;
static partial class Tests
{
// Inspect identity only; all loading, storage operations and shutdown use real production code.
static PlayerKVLoginContext LoadContext(LoadFixture fixture) => (PlayerKVLoginContext)
typeof(PlayerKVGameLoadCoordinator).GetField("context",BindingFlags.Instance|BindingFlags.NonPublic).GetValue(fixture.Coordinator);
static async Task RunGameHandoffTests()
{
await Run("M2-CP1-original-handle-KVMgr-local-write-and-synchronous-revoke",()=>OnMain(async()=>
{
var f=new LoadFixture();
Check(!f.Coordinator.TryGetCurrentGameHandle(1,out _),"no handle before load");
f.OnNotify=()=>Check(!f.Coordinator.TryGetCurrentGameHandle(1,out _),"notify cannot observe published handle");
try
{
var result=await f.Coordinator.LoadForGameAsync("101",f.Capability());
Check(result.State==PlayerKVGameLoadState.GameLoaded && result.ModuleCount==31,"game state after full apply");
Check(f.Coordinator.TryGetCurrentGameHandle(result.Epoch,out var handle),"current game handle");
var context=LoadContext(f);
Check(context.TryAcquireGameHandle(result.Epoch,out var original) && ReferenceEquals(handle,original),"same prepared object, not another owner");
Check(f.Coordinator.TryGetCurrentGameHandle(result.Epoch,out var again) && ReferenceEquals(handle,again),"stable object on repeated access");
Check(!context.TryAcquireGameHandle(result.Epoch+1,out _) && !f.Coordinator.TryGetCurrentGameHandle(result.Epoch+1,out _),"wrong epoch denied");
Check(KVMgr.WriteKV(handle,"m2-handoff","local-only",false).Accepted,"released guard permits synthetic local edit");
Check(KVMgr.ReadKV(handle,"m2-handoff").Value=="local-only","read same owner cache");
Check(f.Auth==1 && f.Gets==1 && f.Notify==1,"handoff does not authenticate or fetch again");
var closing=f.Coordinator.Revoke();
Check(!f.Coordinator.TryGetCurrentGameHandle(result.Epoch,out _) && !context.TryAcquireGameHandle(result.Epoch,out _),"synchronous unpublication before awaiting drain");
Check(!KVMgr.WriteKV(handle,"m2-handoff","stale",false).Accepted && !handle.ReadLocalSnapshot().Success,"captured old object loses authority immediately");
await closing;
Check(f.Values.All(v=>v==0) && f.AuthDisposed==1 && f.LeaseDisposed==1,"modules and ownership drained");
}
finally { await f.Coordinator.Revoke(); }
}));
await Run("M2-CP1-failures-never-publish-and-release-after-cleanup",()=>OnMain(async()=>
{
foreach(var mode in new[]{"schema","apply","notify-throw","notify-violation","notify-revoke","notify-cancel","identity"})
{
using var cancel=new CancellationTokenSource();
var f=new LoadFixture{FailApply=mode=="apply",NotifyViolation=mode=="notify-violation"};
f.OnNotify=()=>{if(mode=="notify-throw")throw new InvalidOperationException("notify failure");if(mode=="notify-revoke")_=f.Coordinator.Revoke();if(mode=="notify-cancel")cancel.Cancel();};
var result=await f.Coordinator.LoadForGameAsync("101",f.Capability(mode=="schema" ? new Dictionary<string,string>() : null),cancel.Token,()=>mode!="identity");
Check(result.State==PlayerKVGameLoadState.Rejected && result.ModuleCount==0 && result.IntentCount==0,"failure cannot claim applied game: "+mode);
Check(!f.Coordinator.TryGetCurrentGameHandle(result.Epoch,out _) && !f.Coordinator.LoadedForValidation,"no alternate success state: "+mode);
Check(f.Values.All(v=>v==0),"failure clears containers: "+mode);
await f.Coordinator.Revoke();
Check(PlayerKVGameLoadGuard.TryEnter(out var guard),"cleanup releases guard: "+mode);guard.Dispose();
}
}));
await Run("M2-CP1-same-account-new-epoch-and-validation-isolation",()=>OnMain(async()=>
{
var f=new LoadFixture();
try
{
var first=await f.Coordinator.LoadForGameAsync("101",f.Capability());
Check(f.Coordinator.TryGetCurrentGameHandle(first.Epoch,out var old),"first handle");
var second=await f.Coordinator.LoadForGameAsync("101",f.Capability());
Check(second.Epoch>first.Epoch && f.Coordinator.TryGetCurrentGameHandle(second.Epoch,out _),"same account new epoch");
f.Coordinator.TryGetCurrentGameHandle(second.Epoch,out var current);
Check(!ReferenceEquals(old,current) && old.OwnerInstanceId!=current.OwnerInstanceId,"new load has distinct fixed owner");
Check(!f.Coordinator.TryGetCurrentGameHandle(first.Epoch,out _) && !old.Update("old","no",false).Accepted,"old callback cannot reuse new account");
var validation=await f.Coordinator.LoadAsync("101",f.Capability());
Check(validation.State==PlayerKVGameLoadState.LoadedForValidation && f.Coordinator.LoadedForValidation,"legacy validation state preserved");
Check(!f.Coordinator.TryGetCurrentGameHandle(validation.Epoch,out _) && !f.Coordinator.TryGetCurrentGameHandle(second.Epoch,out _),"validation never exposes a game handle");
Check(KVMgr.ReadKV(current,"old").Error==PlayerKVKeyReadError.LoadGuardActive,"validation still holds guard");
}
finally { await f.Coordinator.Revoke(); }
}));
await Run("M2-CP1-context-unprepared-and-reentrant-close-no-monitor-deadlock",()=>OnMain(async()=>
{
var f=new LoadFixture();bool closeOnCheck=false;Task closing=null;PlayerKVLoginContext context=null;
var cap=f.Capability();
context=cap.CreateContext("101",7,_=>
{
if(closeOnCheck)
{
closeOnCheck=false;
// Only wait for synchronous Close admission, never for async drainage.
var admission=Task.Run(()=>{closing=context.CloseAsync();});
Check(admission.Wait(TimeSpan.FromSeconds(3)),"identity callback outside context monitor");
}
return true;
});
try
{
Check(!context.TryAcquireGameHandle(7,out _),"unprepared context cannot issue");
Check((await context.PrepareAsync()).Prepared,"prepared context");
Check(context.TryAcquireGameHandle(7,out var original),"prepared context original");
closeOnCheck=true;
Check(!context.TryAcquireGameHandle(7,out _),"reentrant close invalidates issuance");
Check(!original.Update("closed","no",false).Accepted,"captured original revoked");
await closing;
}
finally { await context.CloseAsync();cap.CloseUnused(); }
}));
await Run("M2-CP1-revoke-at-publication-and-during-getter",()=>OnMain(async()=>
{
foreach(var when in new[]{"publication","getter"})
{
var f=new LoadFixture();bool armed=false;int afterNotify=0;Task closing=null;
bool Identity()
{
if((when=="publication" && f.Notify>0 && ++afterNotify==3) || (when=="getter" && armed))
{
armed=false;
var admission=Task.Run(()=>{closing=f.Coordinator.Revoke();});
Check(admission.Wait(TimeSpan.FromSeconds(3)),"external identity callback outside coordinator publication monitor");
}
return true;
}
try
{
var result=await f.Coordinator.LoadForGameAsync("101",f.Capability(),identityCurrent:Identity);
if(when=="getter")
{
Check(result.State==PlayerKVGameLoadState.GameLoaded,"success before getter revocation");armed=true;
Check(!f.Coordinator.TryGetCurrentGameHandle(result.Epoch,out _),"getter must recheck after callback");
}
else Check(result.State==PlayerKVGameLoadState.Rejected,"revocation before publication cannot return success");
Check(closing!=null,"race barrier actually reached");await closing;
Check(!f.Coordinator.TryGetCurrentGameHandle(result.Epoch,out _) && f.Values.All(v=>v==0),"revoked state stays unpublished");
}
finally { await f.Coordinator.Revoke(); }
}
}));
await Run("M2-CP1-background-revoke-during-prepare-discards-late-response",()=>OnMain(async()=>
{
var started=Signal();var response=new TaskCompletionSource<HttpResponseMessage>(TaskCreationOptions.RunContinuationsAsynchronously);var f=new LoadFixture();
f.GetOverride=()=>{started.TrySetResult(true);return response.Task;};
var load=f.Coordinator.LoadForGameAsync("101",f.Capability());await started.Task;
Task close=null;await Task.Run(()=>{close=f.Coordinator.Revoke();});
Check(!f.Coordinator.TryGetCurrentGameHandle(1,out _),"no handle while close drains late transport");
response.SetResult(Json(JsonConvert.SerializeObject(new{data=ModernRaw()})));
var result=await load;await close;
Check(result.State==PlayerKVGameLoadState.Rejected && f.Notify==0 && f.Values.All(v=>v==0),"late prepare cannot publish or apply");
}));
}
}