保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。 保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。 验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。 包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
231 lines
17 KiB
C#
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;}
|
|
}
|
|
}
|
|
}
|