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 Reject=new(); internal int Flushes; internal Func> 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 FlushAsync(CancellationToken token=default) { Flushes++;return Flush?.Invoke() ?? Task.FromResult(new PlayerSaveConfirmation(PlayerSaveStatus.Confirmed)); } public Task ConfirmAsync(PlayerSaveReceipt r,CancellationToken t=default)=>throw new NotSupportedException("No extra per-key flush."); public Task 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(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(); 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().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()) { 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();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();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>(); var requestOwners=new List(); var started=new TaskCompletionSource();var release=new TaskCompletionSource(); 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(31),decoded.Epoch,decoded.Raw);})), Environment.CurrentManagedThreadId,receiver.DiscardKVIntents); async Task 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>();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 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 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;} } } }