Files
ft/CodexOutput/upserver/KVGameLoadTests/ServiceHost/C3StatisticsWriteTests.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

231 lines
17 KiB
C#

using System.Globalization;
using System.Net;
using System.Reflection;
using asap.playfab.async;
using game;
using game.PlayerDataStorage;
using GameCore;
using Newtonsoft.Json.Linq;
using PlayFab.ClientModels;
static partial class Probe
{
sealed class StatisticsStore : IPlayerSaveStore
{
internal readonly List<(string key,string value)> Writes=new();
internal readonly HashSet<string> Reject=new();
internal int Flushes;
internal Func<Task<PlayerSaveConfirmation>> Flush;
public bool IsCurrent=>true;
public PlayerSaveReadResult Read(string key)=>throw new NotSupportedException();
public PlayerSaveWriteResult Write(string key,string value,bool remoteStore=true)
{
Check(remoteStore,"statistics uses remote storage"); Writes.Add((key,value));
return Reject.Contains(key) ? new PlayerSaveWriteResult("fixture-rejected") : new PlayerSaveWriteResult(new PlayerSaveReceipt(this,null));
}
public Task<PlayerSaveConfirmation> FlushAsync(CancellationToken token=default)
{ Flushes++;return Flush?.Invoke() ?? Task.FromResult(new PlayerSaveConfirmation(PlayerSaveStatus.Confirmed)); }
public Task<PlayerSaveConfirmation> ConfirmAsync(PlayerSaveReceipt r,CancellationToken t=default)=>throw new NotSupportedException("No extra per-key flush.");
public Task<PlayerSaveConfirmation> WriteAndConfirmAsync(string k,string v,CancellationToken t=default)=>throw new NotSupportedException("No per-key blocking write.");
}
static KVUserService StatisticsUser(IPlayerSaveStore store,string region="AS")
{
var user=new KVUserService(PlayerKVRuntimeSelection.Capture("kv",PlayerKVRuntimeSelection.FormalApiUrl,false),
_=>new Handler(_=>throw new Exception("No network for contract fixture"))) {SaveStore=store};
typeof(KVUserService).GetProperty(nameof(KVUserService.ContinentCode)).SetValue(user,region);
return user;
}
static bool StatisticsRefreshing(LeadboardData board)=>(bool)typeof(LeadboardData).GetField("isRefreshing",BindingFlags.Instance|BindingFlags.NonPublic).GetValue(board);
static void StatisticsContext(IUserService user,int level,string building)
{
GContext.container.Values[typeof(IUserService)]=user;
GContext.container.Values[typeof(PlayerData)]=new PlayerData {lv=level,InfiniteBuildingLevel=building};
var tables=new cfg.Tables();tables.TbMapData.DataList.Add(new cfg.MapData {LevelRequired=100});
GContext.container.Values[typeof(cfg.Tables)]=tables;
}
static async Task StatisticsWriteTests()
{
var saved=new Dictionary<Type,object>(GContext.container.Values);
try
{
// Execute the unchanged provider branch against recorded SDK requests first.
foreach(IUserService pf in new IUserService[]{new IdentityPFUser(),new AggUserService()})
{
var board=new LeadboardData {userService=pf};
PlayFabClientAsyncAPI.StatisticsRequests.Clear();
var done=new TaskCompletionSource<UpdatePlayerStatisticsResult>();
PlayFabClientAsyncAPI.StatisticsOverride=()=>done.Task;
var pending=board.SetLeadboardData(new() {"LevelB","ASLevelB"},new() {1200034,1200034});
Check(!pending.IsCompleted && PlayFabClientAsyncAPI.StatisticsRequests.Count==1,"PF/AGG still await single SDK request");
var stats=PlayFabClientAsyncAPI.StatisticsRequests[0].Statistics;
Check(stats.Count==2 && stats[0].StatisticName=="LevelB" && stats[1].StatisticName=="ASLevelB" && stats.All(s=>s.Value==1200034),"PF/AGG original parameters");
UnityEngine.Debug.LogError("fixture original PF error callback");done.SetResult(null);await pending;
Check(pending.IsCompletedSuccessfully,"PF logged error/null completes normally");
}
PlayFabClientAsyncAPI.StatisticsOverride=null;
Pass("C3-statistics-original-PF-AGG-request-and-error-contract");
var store=new StatisticsStore();var user=StatisticsUser(store);var kv=new LeadboardData {userService=user};
Effects.Clear();
await kv.SetLeadboardData(new(){"LevelB","ASLevelB"},new(){1200034,1200034});
Check(Effects.SDK==0 && store.Writes.Count==2 && store.Flushes==1,"KV statistics must replace PF with existing store");
Check(store.Writes.SequenceEqual(new[]{("LevelB","1200034"),("ASLevelB","1200034")}),"two original statistic names and invariant decimal values");
foreach(var c in new[]{(12,"34",1200034,12),(12,"99999",1299999,12),(12,"100000",1299999,12),
(12,"bad",1200000,12),(120,"34",10000034,100),(-2,"-4",-200004,-2)})
{
store.Writes.Clear();StatisticsContext(user,c.Item1,c.Item2);kv.SetLvData();
Check(!StatisticsRefreshing(kv) && store.Writes.Count==2 && store.Writes.All(w=>w.value==c.Item3.ToString(CultureInfo.InvariantCulture)),"real SetLvData original formula "+c);
Check(GContext.container.Resolve<PlayerData>().lv==c.Item4,"original lv clamp/writeback");
}
foreach(var region in new string[]{null,"","AF","AN","AS","EU","NA","OC","SA"})
{
user=StatisticsUser(store,region);kv.userService=user;store.Writes.Clear();StatisticsContext(user,12,"34");kv.SetLvData();
Check(store.Writes.Count==(string.IsNullOrEmpty(region)?1:2) && store.Writes[0].key=="LevelB","missing/valid continent keeps global statistic");
if(!string.IsNullOrEmpty(region)) Check(store.Writes[1].key==region+"LevelB","continent prefix preserved");
}
store.Writes.Clear();var previous=store.Flushes;
try {await kv.SetLeadboardData(new(){"LevelB","ASLevelB"},new(){1});throw new Exception("Expected original length error");}
catch(ArgumentOutOfRangeException) {Check(store.Writes.Count==0 && store.Flushes==previous,"parameters fail before first mutation");}
Pass("C3-statistics-real-formula-regions-and-prewrite-parameter-error");
foreach(var status in Enum.GetValues<PlayerSaveStatus>())
{
var before=UnityEngine.Debug.Errors.Count;
store=new StatisticsStore {Flush=()=>Task.FromResult(new PlayerSaveConfirmation(status,"fixture"))};
kv.userService=user=StatisticsUser(store);StatisticsContext(user,12,"34");kv.SetLvData();
Check(!StatisticsRefreshing(kv) && store.Flushes==1,"all known statuses complete original async void "+status);
Check((UnityEngine.Debug.Errors.Count>before)==(status!=PlayerSaveStatus.Confirmed),"non-confirmed state is observable "+status);
}
foreach(var rejected in new[]{new[]{"LevelB"},new[]{"LevelB","ASLevelB"}})
{
store=new StatisticsStore();store.Reject.UnionWith(rejected);kv.userService=StatisticsUser(store);
var before=UnityEngine.Debug.Errors.Count;
await kv.SetLeadboardData(new(){"LevelB","ASLevelB"},new(){1,1});
Check(store.Writes.Count==2 && store.Flushes==(rejected.Length==2?0:1),"partial/all rejected flush only accepted work");
Check(UnityEngine.Debug.Errors.Skip(before).Count()==rejected.Length && rejected.All(k=>UnityEngine.Debug.Errors.Skip(before).Any(s=>s.Contains(k))),"each rejected key logged");
}
var release=new TaskCompletionSource<PlayerSaveConfirmation>();store=new StatisticsStore {Flush=()=>release.Task};
kv.userService=user=StatisticsUser(store);StatisticsContext(user,12,"34");kv.SetLvData();
Check(StatisticsRefreshing(kv),"original in-progress flag stays set through pending confirmation");
var replacement=new StatisticsStore();user.SaveStore=replacement;
release.SetResult(new(PlayerSaveStatus.Cancelled));await Task.Yield();await Task.Yield();
Check(!StatisticsRefreshing(kv) && replacement.Writes.Count==0 && replacement.Flushes==0,"pending write retains original store and resets after cancellation");
var timer=new TaskCompletionSource<bool>();int seconds=0;
asap.core.Awaiters.SecondsOverride=value=>{seconds=(int)value;return timer.Task;};
kv.AwaitSetLvData();kv.AwaitSetLvData();
Check(seconds==300 && StatisticsRefreshing(kv) && replacement.Writes.Count==0,"original 300-second timer and in-progress skip");
timer.SetResult(true);await Task.Yield();await Task.Yield();
Check(replacement.Writes.Count==2 && !StatisticsRefreshing(kv),"timer invokes original SetLvData once");
Check(Effects.SDK==0,"all KV contract branches have zero PF SDK calls");
Pass("C3-statistics-failure-partial-confirmation-and-original-timing");
await StatisticsRealStoreTests();
}
finally
{
PlayFabClientAsyncAPI.StatisticsOverride=null;asap.core.Awaiters.SecondsOverride=null;
GContext.container.Values.Clear();foreach(var pair in saved)GContext.container.Values[pair.Key]=pair.Value;
}
}
static async Task StatisticsRealStoreTests()
{
// Real account session, KV service, store, handle and sync coordinator; only HTTP is synthetic.
foreach(var scenario in new[]{"success","network","unrelated-failure","logout","rewrite-inflight","superseded"})
{
var remote=Raw();remote["ContinentCode"]="AS";var remote102=Raw();
var oldRoot=UnityEngine.Application.persistentDataPath;
UnityEngine.Application.persistentDataPath=Path.Combine(Path.GetTempPath(),"ft-statistics-"+Guid.NewGuid().ToString("N"));
var requests=new List<Dictionary<string,string>>();
var requestOwners=new List<string>();
var started=new TaskCompletionSource<bool>();var release=new TaskCompletionSource<bool>();
var selection=PlayerKVRuntimeSelection.Capture("kv",PlayerKVRuntimeSelection.FormalApiUrl,false);
PlayerKVGameplayBindings.SnapshotFactory=Snapshot;
PlayerKVGameplayBindings.BindFactory=receiver=>new PlayerKVGameLoadTransaction(
Enumerable.Range(1,31).Select(id=>(IPlayerKVLoadModule)new PlayerKVLoadModule(id,
()=>{if(id==31)receiver.ResetForKVLoad();},
decoded=>{if(id==31)receiver.ReplaceFromKVDecoded(decoded.Module<PlayerKVLoginState>(31),decoded.Epoch,decoded.Raw);})),
Environment.CurrentManagedThreadId,receiver.DiscardKVIntents);
async Task<HttpResponseMessage> Send(HttpRequestMessage request,string owner)
{
var accountData=owner=="101"?remote:remote102;
if(request.RequestUri.AbsolutePath=="/playerdata/Get")return await Json(new{data=accountData});
if(request.RequestUri.AbsolutePath=="/playerdata/Set")
{
var data=JObject.Parse(await request.Content.ReadAsStringAsync())["data"].ToObject<Dictionary<string,string>>();requests.Add(data);requestOwners.Add(owner);started.TrySetResult(true);
await release.Task;
if(scenario=="network")throw new HttpRequestException("synthetic network failure");
if(scenario=="unrelated-failure" && data.ContainsKey("Unrelated"))return await Json(new{},HttpStatusCode.InternalServerError);
foreach(var pair in data)accountData[pair.Key]=pair.Value;
return await Json(new{});
}
throw new Exception("Unexpected statistics route: "+request.RequestUri.AbsolutePath);
}
KVUserService Create(string id)
{
var config=Configuration();config.Set("EventApiURL",selection.Api.AbsoluteUri);
GContext.container.Values[typeof(IChatService)]=new KVChat();
GContext.container.Values[typeof(ISettingService)]=new DefaultSettingService();
GContext.container.Values[typeof(ILocalNotificationService)]=new LocalNotificationService();
GContext.container.Values[typeof(ClubService)]=new ClubService();
return new KVUserService(selection,jar=>new Handler(request=>
{
if(request.RequestUri.AbsolutePath!="/account/Login")return Send(request,id);
jar.Add(selection.Api,new Cookie("sid","statistics-"+id));
return Json(new{state=0,internalId=int.Parse(id),newCreate=false,createTime="2026-01-01T00:00:00Z",svrTime="2026-09-30T00:00:00Z"});
})) {CustomId=id,SaveStore=new KVPlayerSaveStore(),customServerMgr=new CustomServerMgr(config,_=>new Handler(_=>throw new Exception("PF transport forbidden")))};
}
var user=Create("101");KVUserService next=null;Task<ELoginStep> nextLogin=null;
try
{
Check(await user.Login(null)==ELoginStep.Done,"real statistics login "+scenario);
if(scenario=="unrelated-failure")Check(user.SaveStore.Write("Unrelated","pending").Accepted,"unrelated write queued");
Task<PlayerSaveConfirmation> blockingFlush=null;
if(scenario=="superseded")
{
Check(user.SaveStore.Write("DispatchBlock","pending").Accepted,"hold dispatch before statistics");
blockingFlush=user.SaveStore.FlushAsync();await started.Task;
}
var board=new LeadboardData {userService=user};Effects.Clear();var errors=UnityEngine.Debug.Errors.Count;
var pending=board.SetLeadboardData(new(){"LevelB","ASLevelB"},new(){1200034,1200034});
await started.Task;Check(!pending.IsCompleted,"real wire delays statistics completion");
if(scenario=="logout")
{
user.Logout();next=Create("102");nextLogin=next.Login(null);
Check(!nextLogin.IsCompleted && !next.IsAuthorized,"existing shutdown registry delays new login until old write drains");
board.userService=next;
}
if(scenario=="superseded" || scenario=="rewrite-inflight")Check(user.SaveStore.Write("LevelB","1300034").Accepted,"newer same-key value queued during flush");
release.TrySetResult(true);await pending;
if(blockingFlush!=null)await blockingFlush;
if(nextLogin!=null)Check(await nextLogin==ELoginStep.Done,"new account authenticates after previous close");
Check(Effects.SDK==0,"real KV storage never calls PF "+scenario);
Check(requestOwners.All(id=>id=="101"),"statistics transport retains original authenticated owner");
if(scenario=="success")
{
Check(remote["LevelB"]=="1200034" && remote["ASLevelB"]=="1200034","real KV protocol persists both statistics");
await user.CloseKVForValidationAsync();next=Create("101");Check(await next.Login(null)==ELoginStep.Done,"new login reads stored statistics");
Check(next.SaveStore.Read("LevelB").Value=="1200034" && next.SaveStore.Read("ASLevelB").Value=="1200034","new store sees both persisted keys");
}
else if(scenario=="rewrite-inflight")
{
Check(remote["LevelB"]=="1200034" && user.SaveStore.Read("LevelB").Value=="1300034","dispatched ticket confirms original while newer value remains local");
Check(UnityEngine.Debug.Errors.Count==errors,"already dispatched receipt is not falsely superseded");
Check((await user.SaveStore.FlushAsync()).Confirmed && remote["LevelB"]=="1300034","later flush still owns newer pending receipt");
}
else
{
Check(UnityEngine.Debug.Errors.Count>errors,"non-confirmed real write emits original error log "+scenario);
if(scenario=="superseded")Check(!remote.ContainsKey("LevelB") && remote["ASLevelB"]=="1200034" &&
UnityEngine.Debug.Errors.Skip(errors).Any(e=>e.Contains("Superseded")),"partial remote result is logged as superseded, never both-key success");
if(scenario=="unrelated-failure")Check(requests.Any(r=>r.ContainsKey("Unrelated")),"known all-pending flush difference exercised");
if(scenario=="logout")Check(next.IsAuthorized && next.SaveStore.Read("LevelB").Found==false,"old completion cannot acknowledge/write new account store");
}
Pass("C3-statistics-real-store-"+scenario);
}
finally {release.TrySetResult(true);await user.CloseKVForValidationAsync();if(next!=null)await next.CloseKVForValidationAsync();UnityEngine.Application.persistentDataPath=oldRoot;}
}
}
}