feat(upserver): 保存M2客户端KV迁移至本人统计写检查点
保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。 保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。 验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。 包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
This commit is contained in:
136
CodexOutput/upserver/KVGameLoadTests/CP2AExistingArchiveTests.cs
Normal file
136
CodexOutput/upserver/KVGameLoadTests/CP2AExistingArchiveTests.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using game.PlayerDataStorage;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
static partial class Tests
|
||||
{
|
||||
static async Task RunCP2AExistingArchiveTests()
|
||||
{
|
||||
await Run("CP2A-formal-load-rejects-new-or-empty-archive-before-apply", () => OnMain(CP2AExistingArchive));
|
||||
await Run("CP2A-formal-transport-rejects-shared-inner-before-login", () => OnMain(CP2ASharedInner));
|
||||
await Run("CP2A-formal-close-failure-revokes-handle-and-blocks-next", () => OnMain(CP2ACloseFailure));
|
||||
await Run("CP2A-formal-sockets-without-cookie-contract-rejected", () =>
|
||||
{
|
||||
var capability=PlayerKVControlledLoadCapability.CreateForGame(_ => new SocketsHttpHandler());
|
||||
bool rejected=false;
|
||||
try { using var handler=capability.CreateHandler(new CookieContainer()); }
|
||||
catch(InvalidOperationException) { rejected=true; }
|
||||
Check(rejected,"formal factory must not accept an unverified SocketsHttpHandler cookie policy");
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
static async Task CP2ACloseFailure()
|
||||
{
|
||||
var f = new LoadFixture();
|
||||
var formal = new Uri(PlayerKVRuntimeSelection.FormalApiUrl);
|
||||
var capability = PlayerKVGameLoadCapability.CreateForGame(
|
||||
new PlayerKVIdentitySnapshot(null, false, PlayerKVIdentityChannel.none, Array.Empty<PlayerKVIdentityChannel>()),
|
||||
SchemaSnapshot(), formal, Path.Combine(Root, "cp2a", Guid.NewGuid().ToString("N")),
|
||||
jar => new Handler((_, _) =>
|
||||
{
|
||||
jar.Add(formal, new Cookie("sid", "synthetic"));
|
||||
return Task.FromResult(Json(JsonConvert.SerializeObject(new {
|
||||
state = 0, internalId = 17, newCreate = false,
|
||||
createTime = "2026-01-01T00:00:00Z", svrTime = "2026-01-02T00:00:00Z"
|
||||
})));
|
||||
}),
|
||||
_ => new Handler((_, _) => Task.FromResult(Json(JsonConvert.SerializeObject(new { data = ModernRaw() })))),
|
||||
_ => new Handler((_, _) => Task.FromResult(Json("{}")), () => throw new InvalidOperationException("synthetic close")));
|
||||
var result = await f.Coordinator.LoadForGameAsync("101", capability);
|
||||
var hasOriginal=f.Coordinator.TryGetCurrentGameHandle(result.Epoch, out var original);
|
||||
Check(result.State == PlayerKVGameLoadState.GameLoaded && hasOriginal, "formal handle published");
|
||||
var closing = f.Coordinator.Revoke();
|
||||
Check(!f.Coordinator.TryGetCurrentGameHandle(result.Epoch, out _) &&
|
||||
!original.ReadLocalSnapshot().Success, "close invalidates handle synchronously");
|
||||
try { await closing; throw new Exception("close fault was ignored"); }
|
||||
catch(InvalidOperationException ex) { Check(ex.Message.Contains("synthetic close"), "close failure retained"); }
|
||||
var next = new LoadFixture(f.Registry);
|
||||
var denied = await next.Coordinator.LoadForGameAsync("102", next.Capability());
|
||||
Check(denied.Error == PlayerKVGameLoadError.PreviousCloseFailed && next.Auth == 0,
|
||||
"failed close prevents next context and sends no authentication");
|
||||
}
|
||||
|
||||
sealed class CP2AWrapper : DelegatingHandler
|
||||
{ internal CP2AWrapper(HttpMessageHandler inner) { InnerHandler = inner; } }
|
||||
|
||||
static async Task CP2ASharedInner()
|
||||
{
|
||||
var f = new LoadFixture();
|
||||
int sent = 0;
|
||||
var shared = new Handler((_, _) => { sent++; return Task.FromResult(Json("{}")); });
|
||||
var capability = PlayerKVGameLoadCapability.CreateForGame(
|
||||
new PlayerKVIdentitySnapshot(null, false, PlayerKVIdentityChannel.none, Array.Empty<PlayerKVIdentityChannel>()),
|
||||
SchemaSnapshot(), new Uri(PlayerKVRuntimeSelection.FormalApiUrl),
|
||||
Path.Combine(Root, "cp2a", Guid.NewGuid().ToString("N")),
|
||||
_ => new CP2AWrapper(shared), _ => new CP2AWrapper(shared),
|
||||
_ => new Handler((_, _) => Task.FromResult(Json("{}"))));
|
||||
var result = await f.Coordinator.LoadForGameAsync("101", capability);
|
||||
Check(result.State == PlayerKVGameLoadState.Rejected && sent == 0,
|
||||
"shared inner handler graph must fail before account/Login");
|
||||
await f.Coordinator.Revoke();
|
||||
}
|
||||
|
||||
static async Task CP2AExistingArchive()
|
||||
{
|
||||
var method = typeof(PlayerKVGameLoadCapability).GetMethod("CreateForGame", BindingFlags.Static | BindingFlags.NonPublic);
|
||||
Check(method != null, "formal factory must exist without using editor validation factory");
|
||||
var formal = new Uri("http://s101.local:18080/");
|
||||
foreach (var (newlyCreated, empty, accepted) in new[] { (true, false, false), (false, true, false), (false, false, true) })
|
||||
{
|
||||
var f = new LoadFixture();
|
||||
CookieContainer jar = null;
|
||||
Func<CookieContainer, HttpMessageHandler> auth = cookies =>
|
||||
{
|
||||
jar = cookies;
|
||||
return new Handler((request, _) =>
|
||||
{
|
||||
Check(request.RequestUri.AbsolutePath == "/account/Login", "exactly one account login route");
|
||||
f.Auth++;
|
||||
cookies.Add(formal, new Cookie("sid", "synthetic"));
|
||||
return Task.FromResult(Json(JsonConvert.SerializeObject(new {
|
||||
state = 0, internalId = 17, newCreate = newlyCreated,
|
||||
createTime = "2026-01-01T00:00:00Z", svrTime = "2026-01-02T00:00:00Z"
|
||||
})));
|
||||
});
|
||||
};
|
||||
Func<CookieContainer, HttpMessageHandler> kv = cookies =>
|
||||
{
|
||||
Check(ReferenceEquals(cookies, jar), "KV uses authentication cookie jar");
|
||||
return new Handler((request, _) =>
|
||||
{
|
||||
Check(request.RequestUri.AbsolutePath == "/playerdata/Get", "read-only KV fetch");
|
||||
f.Gets++;
|
||||
return Task.FromResult(Json(JsonConvert.SerializeObject(new {
|
||||
data = empty ? new Dictionary<string, string>() : ModernRaw()
|
||||
})));
|
||||
});
|
||||
};
|
||||
Func<CookieContainer, HttpMessageHandler> lease = cookies =>
|
||||
{
|
||||
Check(ReferenceEquals(cookies, jar), "account lease uses authentication cookie jar");
|
||||
return new Handler((_, _) => throw new InvalidOperationException("CP2A must send no business API request"));
|
||||
};
|
||||
var capability = (PlayerKVGameLoadCapability)method.Invoke(null, new object[] {
|
||||
new PlayerKVIdentitySnapshot(null, false, PlayerKVIdentityChannel.none, Array.Empty<PlayerKVIdentityChannel>()),
|
||||
SchemaSnapshot(), formal, Path.Combine(Root, "cp2a", Guid.NewGuid().ToString("N")), auth, kv, lease
|
||||
});
|
||||
try
|
||||
{
|
||||
var result = await f.Coordinator.LoadForGameAsync("101", capability);
|
||||
Check((result.State == PlayerKVGameLoadState.GameLoaded) == accepted,
|
||||
"only a pre-existing complete archive becomes game-loaded");
|
||||
Check(f.Auth == 1 && f.Gets == 1, "no second login or KV fetch");
|
||||
Check(f.Notify == (accepted ? 1 : 0), "ineligible archive never reaches Apply notification");
|
||||
if (accepted)
|
||||
Check(f.Coordinator.TryGetCurrentGameHandle(result.Epoch, out var owner) && owner.PlayerId == "101",
|
||||
"eligible archive yields original fixed account handle");
|
||||
else
|
||||
Check(!f.Coordinator.TryGetCurrentGameHandle(result.Epoch, out _), "rejected archive has no usable handle");
|
||||
}
|
||||
finally { await f.Coordinator.Revoke(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Reflection;
|
||||
|
||||
static partial class Tests
|
||||
{
|
||||
static async Task RunCP2ARuntimeSelectionTests()
|
||||
{
|
||||
await Run("CP2A-backend-capture-and-approved-existing-cohort", CP2ASelection);
|
||||
}
|
||||
|
||||
static Task CP2ASelection()
|
||||
{
|
||||
// A wrong branch, a widened cohort, or a mutable backend would break this boundary.
|
||||
var selectionType = typeof(Tests).Assembly.GetType("game.PlayerDataStorage.PlayerKVRuntimeSelection");
|
||||
Check(selectionType != null, "formal runtime selection must exist in the real production assembly");
|
||||
var capture = selectionType.GetMethod("Capture", BindingFlags.Static | BindingFlags.NonPublic);
|
||||
Check(capture != null, "capture API");
|
||||
object Take(string backend, string api, bool agg) => capture.Invoke(null, new object[] { backend, api, agg });
|
||||
var pf = Take(null, "http://unused.invalid/", false);
|
||||
Check((bool)selectionType.GetProperty("IsKV", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(pf) == false,
|
||||
"missing backend keeps PF default without applying KV URL rules");
|
||||
var kv = Take("kv", "http://s101.local:18080/", false);
|
||||
Check((bool)selectionType.GetProperty("IsKV", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(kv),
|
||||
"exact KV backend and address admitted");
|
||||
var admit = selectionType.GetMethod("TryAdmitPlayer", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Check(admit != null, "cohort admission API");
|
||||
bool Allowed(string id, out string canonical)
|
||||
{
|
||||
var args = new object[] { id, null };
|
||||
var accepted = (bool)admit.Invoke(kv, args);
|
||||
canonical = (string)args[1];
|
||||
return accepted;
|
||||
}
|
||||
Check(Allowed("00101", out var first) && first == "101", "101 uses canonical decimal identity");
|
||||
Check(Allowed("102", out var second) && second == "102", "102 admitted");
|
||||
foreach (var id in new[] { "100", "103", "0", "-101", "+101", " 101", "", "9223372036854775808" })
|
||||
Check(!Allowed(id, out _), "unapproved or malformed account denied: " + id);
|
||||
foreach (var bad in new[] {
|
||||
new object[] { "KV", "http://s101.local:18080/", false },
|
||||
new object[] { "other", "http://s101.local:18080/", false },
|
||||
new object[] { "kv", "http://s101.local:18080", false },
|
||||
new object[] { "kv", "http://other.local:18080/", false },
|
||||
new object[] { "kv", "http://s101.local:18080/", true }
|
||||
})
|
||||
{
|
||||
try { capture.Invoke(null, bad); throw new InvalidOperationException("invalid capture accepted"); }
|
||||
catch (TargetInvocationException ex) when (ex.InnerException is ArgumentException or InvalidOperationException)
|
||||
{ Check(true, "invalid capture rejected"); }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,19 @@ static partial class Tests
|
||||
}
|
||||
static async Task RunCoordinatorTests()
|
||||
{
|
||||
await Run("M2-CP1-game-load-releases-guard-after-notification",()=>OnMain(async()=>
|
||||
{
|
||||
var f=new LoadFixture();
|
||||
try
|
||||
{
|
||||
var result=await f.Coordinator.LoadForGameAsync("101",f.Capability());
|
||||
Check(result.Error==PlayerKVGameLoadError.None && f.Notify==1,"complete apply and notification");
|
||||
Check(!PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.ReadLocal),"game success must release its load guard");
|
||||
Check(result.State.ToString()=="GameLoaded" && !f.Coordinator.LoadedForValidation,"game success differs from validation");
|
||||
}
|
||||
finally { await f.Coordinator.Revoke(); }
|
||||
}));
|
||||
await RunGameHandoffTests();
|
||||
await Run("coordinator-success-reload-revoke-and-one-auth",()=>OnMain(async()=>
|
||||
{
|
||||
var f=new LoadFixture();
|
||||
|
||||
141
CodexOutput/upserver/KVGameLoadTests/GameHandoffTests.cs
Normal file
141
CodexOutput/upserver/KVGameLoadTests/GameHandoffTests.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
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");
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
<RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="../../../Client/Assets/Scripts/Core/KVMgr.cs" Link="Production/KVMgr.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVRuntimeSelection.cs" Link="Production/PlayerKVRuntimeSelection.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVKeyReadResult.cs" Link="Production/PlayerKVKeyReadResult.cs" />
|
||||
<Compile Remove="ServiceHost/**/*.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVInitializationIntents.cs" Link="Production/PlayerKVInitializationIntents.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadTransaction.cs" Link="Production/PlayerKVGameLoadTransaction.cs" />
|
||||
|
||||
257
CodexOutput/upserver/KVGameLoadTests/M2MergedContractTests.cs
Normal file
257
CodexOutput/upserver/KVGameLoadTests/M2MergedContractTests.cs
Normal file
@@ -0,0 +1,257 @@
|
||||
#if SERVICE_HOST
|
||||
using game.PlayerDataStorage;
|
||||
|
||||
static partial class Probe
|
||||
{
|
||||
public sealed class M2EventFixture : Activity.Data.IEventData { public int Count { get; set; } }
|
||||
sealed class DailyTicks : TimeManager.ITimeTickService
|
||||
{
|
||||
public IObservable<long> SecondTick { get; } = new UniRx.Stream<long>();
|
||||
public IObservable<long> MinuteTick { get; } = new UniRx.Stream<long>();
|
||||
public void Start() { }
|
||||
public void Stop() { }
|
||||
}
|
||||
|
||||
static void MergedDailyRefresh()
|
||||
{
|
||||
GContext.container.Values[typeof(cfg.Tables)] = new cfg.Tables();
|
||||
var user = new game.KVUserService();
|
||||
int refreshes = 0;
|
||||
using var events = GContext.OnEvent<TimeManager.Event.ActivityCrossDayRefreshEvent>().Subscribe(_ => refreshes++);
|
||||
using (var manager = new TimeManager.TimeRefreshManager(new DailyTicks(), user))
|
||||
Check(manager.GetLastLoginTime() == null && manager.InitialRefreshSucceeded && refreshes == 1,
|
||||
"actual daily manager consumes missing-login sentinel and records successful initial refresh");
|
||||
using (var failure = GContext.OnEvent<TimeManager.Event.ActivityCrossDayRefreshEvent>().Subscribe(_ => throw new InvalidOperationException("synthetic refresh failure")))
|
||||
using (var manager = new TimeManager.TimeRefreshManager(new DailyTicks(), user))
|
||||
Check(!manager.InitialRefreshSucceeded && user.LastLoginTime == DateTime.MinValue,
|
||||
"caught daily subscriber failure is visible to the timestamp commit gate");
|
||||
typeof(game.KVUserService).GetProperty(nameof(user.LastLoginTime)).SetValue(user, ZZTimeHelper.UtcNow().UtcNowOffset());
|
||||
var before = refreshes;
|
||||
using (var manager = new TimeManager.TimeRefreshManager(new DailyTicks(), user))
|
||||
Check(manager.InitialRefreshSucceeded && refreshes == before, "same configured game day needs no initial refresh");
|
||||
user.Logout();
|
||||
Pass("merged-real-daily-refresh-success-failure-and-login-sentinel");
|
||||
}
|
||||
static async Task MergedSaveContract(game.KVUserService user, Dictionary<string, string> remote, bool unknown, Action<Action> onNextSet)
|
||||
{
|
||||
var store = user.SaveStore;
|
||||
var original = (PlayerKVGameSessionHandle)typeof(game.KVUserService).GetField("handle",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).GetValue(user);
|
||||
try { ((KVPlayerSaveStore)store).Bind(original, user.LoginLifetime); throw new Exception("store rebound"); }
|
||||
catch (InvalidOperationException) { Check(true, "bound store cannot be rebound even to the same handle"); }
|
||||
Check(PlayerKVGameLoadGuard.TryEnter(out var guard), "save contract guard lease acquired");
|
||||
using (guard)
|
||||
{
|
||||
Check(!store.Read("Language").Success && !store.Write("M2Guarded", "x").Accepted && guard.Violations == 2,
|
||||
"common store preserves original read/write guard enforcement");
|
||||
}
|
||||
var absent = store.Read("M2Missing");
|
||||
Check(absent.Success && absent.Found == false && absent.Value == null, "successful missing key is explicit");
|
||||
var invalid = store.Read(null);
|
||||
Check(!invalid.Success && invalid.Found == null, "invalid read is never a missing key");
|
||||
try { invalid.ValueOrThrow(); throw new Exception("failed read swallowed"); }
|
||||
catch (InvalidOperationException) { Check(true, "failed read cannot enter a default-value branch"); }
|
||||
var local = store.Write("M2LocalOnly", "", false);
|
||||
Check(local.Accepted && store.Read("M2LocalOnly").Found == true && store.Read("M2LocalOnly").Value == "",
|
||||
"local-only empty string remains present: " + local.Error + "/" + original.Status);
|
||||
Check((await store.ConfirmAsync(local.Receipt)).Status == PlayerSaveStatus.LocalOnly && !remote.ContainsKey("M2LocalOnly"),
|
||||
"local-only acceptance never becomes remote confirmation");
|
||||
if (unknown)
|
||||
{
|
||||
Check((await store.WriteAndConfirmAsync("M2UnknownProbe", "value")).Status == PlayerSaveStatus.Unknown, "lost response retains original unknown state");
|
||||
var before = user.Language;
|
||||
try { await user.UpdateLanguage("de"); throw new Exception("unknown profile published"); }
|
||||
catch (InvalidOperationException) { Check(user.Language == before, "unknown profile save does not update public language"); }
|
||||
Check((await store.FlushAsync()).Status == PlayerSaveStatus.Unknown, "unknown outcome remains explicit");
|
||||
Check(!store.Write("M2BlockedAfterUnknown", "x").Accepted, "unknown outcome blocks further writes");
|
||||
var read = store.Read("M2Missing");
|
||||
Check(!read.Success && read.Found == null, "unreadable owner cannot impersonate an empty account");
|
||||
}
|
||||
else
|
||||
{
|
||||
await MergedActivityTokens(user, remote);
|
||||
await MergedOriginalSurveyAndMail(user, remote);
|
||||
var repository = new Activity.Data.EventDataRepository(store);
|
||||
Check(!repository.Exists<M2EventFixture>() && repository.Load<M2EventFixture>().Count == 0,
|
||||
"real activity repository preserves original missing-key default");
|
||||
foreach (var malformed in new[] { "", "null", "{broken" })
|
||||
{
|
||||
store.Write(nameof(M2EventFixture), malformed, false).RequireAccepted();
|
||||
Check(repository.Load<M2EventFixture>().Count == 0, "original activity fallback for empty/null/malformed JSON is preserved");
|
||||
}
|
||||
repository.Save(new M2EventFixture { Count = 19 });
|
||||
Check(repository.Load<M2EventFixture>().Count == 19, "real activity repository preserves generic key and JSON");
|
||||
var first = store.Write("M2Replace", "first");
|
||||
var last = store.Write("M2Replace", "last");
|
||||
Check(first.Accepted && last.Accepted && !remote.ContainsKey("M2Replace"), "buffered writes are not sent immediately");
|
||||
Check((await store.ConfirmAsync(first.Receipt)).Status == PlayerSaveStatus.Superseded,
|
||||
"overwritten standalone receipt is not confirmed");
|
||||
Check((await store.ConfirmAsync(last.Receipt)).Confirmed && remote["M2Replace"] == "last",
|
||||
"final original receipt confirms its remote value");
|
||||
Check(!remote.ContainsKey("M2LocalOnly"), "flush never sends local-only keys");
|
||||
Check(Newtonsoft.Json.JsonConvert.DeserializeObject<M2EventFixture>(remote[nameof(M2EventFixture)]).Count == 19,
|
||||
"real repository data reaches the original KV transport");
|
||||
var other = new KVPlayerSaveStore();
|
||||
Check((await store.ConfirmAsync(new PlayerSaveReceipt(other, null))).Status == PlayerSaveStatus.Rejected,
|
||||
"a receipt from another store cannot cross accounts");
|
||||
await user.UpdateLanguage("de");
|
||||
Check(user.Language == "de" && remote["Language"] == "de", "real profile consumer publishes only after confirmation");
|
||||
for (int i = 0; i < 11; i++) store.Write("M2Watermark" + i, "initial").RequireAccepted();
|
||||
onNextSet(() => store.Write("M2Watermark10", "later").RequireAccepted());
|
||||
Check((await store.FlushAsync()).Status == PlayerSaveStatus.Superseded && !remote.ContainsKey("M2Watermark10"),
|
||||
"aggregate flush cannot confirm an unsent receipt replaced beyond its captured watermark");
|
||||
Check((await store.FlushAsync()).Confirmed && remote["M2Watermark10"] == "later",
|
||||
"later replacement requires its own flush and confirmation");
|
||||
using var cancelled = new CancellationTokenSource(); cancelled.Cancel();
|
||||
Check((await store.WriteAndConfirmAsync("M2Cancelled", "x", cancelled.Token)).Status == PlayerSaveStatus.Cancelled &&
|
||||
!store.Read("M2Cancelled").Found.Value, "pre-cancelled consumption does not enqueue a debit");
|
||||
}
|
||||
Pass(unknown ? "merged-save-unknown-is-not-missing-or-profile-success" : "merged-save-local-buffered-superseded-confirmed-and-foreign-receipt");
|
||||
}
|
||||
|
||||
sealed class PFSurveyUser : game.PlayFabUserService { public PFSurveyUser() { UserId = "101"; } }
|
||||
public class MailServerProbe : System.Reflection.DispatchProxy
|
||||
{
|
||||
public Func<string,string,Task<string>> Post;
|
||||
protected override object Invoke(System.Reflection.MethodInfo method, object[] args)
|
||||
{
|
||||
if (method.Name != "CustomServerPost") throw new Exception("Unexpected mail transport method: " + method.Name);
|
||||
return Post((string)args[0], (string)args[1]);
|
||||
}
|
||||
}
|
||||
static async Task MergedOriginalSurveyAndMail(game.KVUserService user, Dictionary<string,string> remote)
|
||||
{
|
||||
var store = user.SaveStore;
|
||||
GContext.container.Values[typeof(IPlayerSaveStore)] = store;
|
||||
GContext.container.Values[typeof(game.PlayerData)] = new game.PlayerData { lv = 10 };
|
||||
var drops = new List<(int id, bool inflated)>();
|
||||
GContext.container.Values[typeof(game.PlayerItemData)] = new game.PlayerItemData { Drop = (id, inflated) =>
|
||||
{
|
||||
drops.Add((id, inflated));
|
||||
store.Write("M2OriginalDrop" + id, drops.Count.ToString()).RequireAccepted();
|
||||
GContext.Publish(new game.ShowData());
|
||||
}};
|
||||
var footprint = new FootPrintService { SaveStore = store };
|
||||
footprint.LoadData((1UL << 40).ToString());
|
||||
footprint.UpdateFootPrint(EFootPrint.IsFaceBookRewardLink);
|
||||
Check(store.Read("FootPrint").ValueOrThrow() == ((1UL << 40) | (uint)EFootPrint.IsFaceBookRewardLink).ToString(),
|
||||
"KV footprint retains the loaded full-width archive");
|
||||
var statue = new EventPartnerStatuePlayfabData { EventId=42, TicketCount=7, DisplayScoreMap=new(), RobotScoreMap=new() };
|
||||
statue.Save();
|
||||
var reloaded = EventPartnerStatuePlayfabData.Load();
|
||||
Check(reloaded.EventId==42 && reloaded.TicketCount==7, "activity archive retains original wire format");
|
||||
var tables = new cfg.Tables();
|
||||
var definition = new cfg.SurveyData { ID=11, Hash="test", Reward=501, TailNumberList=new List<int>{1} };
|
||||
tables.TbSurveyData.DataList.Add(definition);
|
||||
tables.TbSurveyData.DataList.Add(new cfg.SurveyData { ID=22, Reward=502, TailNumberList=new List<int>{7} });
|
||||
int presentations=0;
|
||||
using var subscription=GContext.OnEvent<game.ShowData>().Subscribe(_ => presentations++);
|
||||
var survey=new SurveyService(user,tables,store);
|
||||
survey.GetSurveyMatchlist();
|
||||
Check(survey.tableData==definition,"new KV identity uses decimal tail; survey selection otherwise follows original logic");
|
||||
Check(survey.GetSurveyAnswer()=="https://wesurvey.com/s2/11/test/?&uid=101","original survey URL and CustomId retained");
|
||||
survey.GetSurveyCheckAnswer();
|
||||
Check(drops.Count==1 && presentations==2 && store.Read("SurveyData").ValueOrThrow()=="[\"11\"]",
|
||||
"original synchronous award, presentation and claim-marker order retained");
|
||||
Check(!remote.ContainsKey("SurveyData") && !remote.ContainsKey("M2OriginalDrop501"),
|
||||
"ordinary survey flow does not wait for or force a remote save");
|
||||
survey.GetSurveyCheckAnswer();
|
||||
Check(drops.Count==1,"original claimed-survey check retained without a new action lock");
|
||||
Check((await store.FlushAsync()).Confirmed && remote["SurveyData"]=="[\"11\"]",
|
||||
"existing storage flush persists original ordinary writes");
|
||||
|
||||
var cache=new MailCache(tables.TbAutoDeliveryMail);cache.Load(user.UserId);
|
||||
var first=new GameMail { Id="original-single", Type=EMailType.Personal, DropIds=new[]{601}, ExpireTime=ZZTimeHelper.UtcNow().AddDays(1) };
|
||||
var second=new GameMail { Id="original-batch", Type=EMailType.Global, DropIds=new[]{602}, ExpireTime=first.ExpireTime };
|
||||
cache.Cache(new[]{first,second});
|
||||
var server=System.Reflection.DispatchProxy.Create<ICustomServerMgr,MailServerProbe>();
|
||||
var probe=(MailServerProbe)(object)server;
|
||||
Newtonsoft.Json.Linq.JObject sent=null;
|
||||
bool succeeds=true;
|
||||
probe.Post=(route,json) => {
|
||||
Check(route==MailUrls.UpdateState,"mail retains existing route");
|
||||
sent=Newtonsoft.Json.Linq.JObject.Parse(json);
|
||||
return Task.FromResult(succeeds ? "{\"success\":true}" : "{\"success\":false}");
|
||||
};
|
||||
var handler=new BaseMailHandler(cache,server);
|
||||
Check(await handler.TakeReward(first) && first.HasReward && drops.Contains((601,true)),
|
||||
"original single-mail state and personal inflation retained");
|
||||
Check(((int)sent["mailStates"][0]["state"] & (int)EMailState.RewardTaken)!=0,
|
||||
"single-mail original RewardTaken request retained");
|
||||
Check(!remote.ContainsKey("M2OriginalDrop601"),"single-mail returns without adding a KV cloud wait");
|
||||
var before=drops.Count;
|
||||
Check(await handler.TakeReward(new[]{second}) && second.IsRead && !second.HasReward,
|
||||
"batch retains original HasRead state; legacy business repair is deferred");
|
||||
Check(((int)sent["mailStates"][0]["state"] & (int)EMailState.HasRead)!=0 && drops.Count==before,
|
||||
"batch keeps original post-response HasReward filter rather than inventing new awards");
|
||||
succeeds=false;
|
||||
var failed=new GameMail { Id="original-failed", DropIds=new[]{603}, ExpireTime=first.ExpireTime };cache.Cache(failed);
|
||||
Check(!await handler.TakeReward(failed) && !failed.HasReward && drops.Count==before,
|
||||
"original failed state request returns false without issuing items");
|
||||
cache.Unload();
|
||||
Pass("merged-original-survey-and-mail-flow-with-KV-storage-substitution");
|
||||
}
|
||||
|
||||
static async Task MergedActivityTokens(game.KVUserService user, Dictionary<string, string> remote)
|
||||
{
|
||||
var store = user.SaveStore;
|
||||
foreach (var malformed in new[] { "", "null", "{bad", "[]" })
|
||||
{
|
||||
store.Write("TransitionData", malformed, false).RequireAccepted();
|
||||
var bad = new Activity.Services.EventTokenService(store);
|
||||
Check(bad.GetTokenCount(1) == 0, "original token loader logs malformed JSON and retains its initial balance");
|
||||
}
|
||||
store.Write("TransitionData", "{\"1\":10,\"2\":7}", false).RequireAccepted();
|
||||
var tokens = new Activity.Services.EventTokenService(store);
|
||||
int notifications = 0;
|
||||
using var subscription = GContext.OnEvent<Activity.ActivityEventTokenBalanceChanged>().Subscribe(_ => notifications++);
|
||||
Check(tokens.GetTokenCount(1) == 10 && tokens.GetTokenCount(3) == 0, "real token service preserves existing balances");
|
||||
Check(PlayerKVGameLoadGuard.TryEnter(out var guard), "token mutation rejection guard acquired");
|
||||
using (guard)
|
||||
{
|
||||
try { tokens.AddToken(1, 5); throw new Exception("guarded token mutation accepted"); }
|
||||
catch (InvalidOperationException) { Check(tokens.GetTokenCount(1) == 15 && notifications == 0, "original token mutation precedes save and notification follows accepted save"); }
|
||||
}
|
||||
tokens.AddToken(1, 5);
|
||||
Check(tokens.GetTokenCount(1) == 20 && notifications == 1, "accepted token credit publishes its original notification");
|
||||
Check(tokens.RemoveToken(1, 25) == 20 && tokens.GetTokenCount(1) == 0 && notifications == 2, "token debit preserves original clamp and removes empty entry");
|
||||
tokens.SetTokens(new Dictionary<int, int> { [3] = 8, [4] = 9, [-1] = 10 });
|
||||
tokens.ClearTokens(new[] { 3, 4 });
|
||||
Check(tokens.GetAllTokens().Count == 0, "batch clear changes the complete candidate, not a per-key copy");
|
||||
tokens.SetToken(5, 17);
|
||||
tokens.ClearToken(999);
|
||||
Check((await store.FlushAsync()).Confirmed, "token data confirms through original KV store");
|
||||
var saved = Newtonsoft.Json.JsonConvert.DeserializeObject<Dictionary<int, int>>(remote["TransitionData"]);
|
||||
Check(saved.Count == 1 && saved[5] == 17, "token wire JSON retains original key and dictionary format");
|
||||
Pass("merged-real-activity-tokens-malformed-rejected-credit-debit-and-batch-save");
|
||||
}
|
||||
|
||||
static async Task MergedPFStore(PlayFabMgr manager)
|
||||
{
|
||||
using var lifetime = new CancellationTokenSource();
|
||||
var store = new PlayFabPlayerSaveStore(manager, lifetime.Token);
|
||||
var pfTables = new cfg.Tables();
|
||||
var pfDefinition = new cfg.SurveyData { ID=22, TailNumberList=new List<int>{7} };
|
||||
pfTables.TbSurveyData.DataList.Add(pfDefinition);
|
||||
GContext.container.Values[typeof(game.PlayerData)] = new game.PlayerData { lv=10 };
|
||||
var pfSurvey = new SurveyService(new PFSurveyUser(), pfTables, store);
|
||||
pfSurvey.GetSurveyMatchlist();
|
||||
Check(pfSurvey.tableData == pfDefinition, "actual PF survey preserves hexadecimal identity tail");
|
||||
var tokens = new Activity.Services.EventTokenService(store);
|
||||
Check(tokens.GetTokenCount(91) == 0, "missing PF token key remains a valid empty balance");
|
||||
tokens.AddToken(91, 4);
|
||||
Check(Newtonsoft.Json.JsonConvert.DeserializeObject<Dictionary<int, int>>(manager.GetLocalData("TransitionData"))[91] == 4,
|
||||
"same real token consumer preserves the PF storage route");
|
||||
var write = store.Write("M2PF", "unchanged");
|
||||
Check(write.Accepted && store.Read("M2PF").Value == "unchanged", "PF adapter uses original local buffer");
|
||||
Check((await store.ConfirmAsync(write.Receipt)).Status == PlayerSaveStatus.Buffered, "PF buffered receipt is not a cloud confirmation");
|
||||
Check((await store.WriteAndConfirmAsync("M2PFImmediate", "value")).Confirmed, "PF immediate operation keeps original acknowledgement");
|
||||
lifetime.Cancel();
|
||||
Check(tokens.GetTokenCount(91) == 4, "original initialized token service can read its own cached balance after storage closes");
|
||||
Check(!store.IsCurrent && !store.Write("M2PF", "stale").Accepted && !store.Read("M2PF").Success,
|
||||
"captured PF lifetime does not follow replacement login");
|
||||
Pass("merged-PF-original-provider-and-frozen-lifetime");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -20,6 +20,7 @@ static partial class Tests
|
||||
await Run("B2-C1-metadata-one-auth-shared-cookie", SingleAuthentication);
|
||||
await Run("B2-C1-lease-once-before-after-close", Admission);
|
||||
await Run("B2-C1-denied-routes-zero-send", Routes);
|
||||
await Run("CP2B-T-formal-lease-route-and-cookie-isolation", FormalBusinessLease);
|
||||
await Run("B2-C1-real-handler-rejected", RealHandlerRejected);
|
||||
await Run("B2-C1-cyclic-handler-rejected-without-recursive-dispose", CyclicHandlerRejected);
|
||||
await Run("B2-C1-active-send-drains-before-session", Drain);
|
||||
@@ -56,6 +57,8 @@ static partial class Tests
|
||||
await RunLoginSchemaTests();
|
||||
await RunAggregateTests();
|
||||
await RunCoordinatorTests();
|
||||
await RunCP2ARuntimeSelectionTests();
|
||||
await RunCP2AExistingArchiveTests();
|
||||
var report = new { runId = RunId, checkpoint = "4-validation", failures, assertions, accessedFormalServer = false, tests = Results };
|
||||
await File.WriteAllTextAsync(Path.Combine(Root, "result.json"), JsonConvert.SerializeObject(report, Formatting.Indented));
|
||||
Console.WriteLine($"RESULT tests={Results.Count} failed={failures} assertions={assertions} run={RunId}");
|
||||
@@ -140,6 +143,12 @@ static partial class Tests
|
||||
return new Handler((r, t) => { BorrowedSends++; return Borrowed(r, t); }, () =>
|
||||
{ LeaseDisposes++; Record("lease"); if (FailLeaseDispose) throw new IOException("synthetic cleanup fault"); });
|
||||
});
|
||||
internal PlayerKVControlledLoadCapability FormalCapability() => PlayerKVControlledLoadCapability.CreateForGame(jar =>
|
||||
{
|
||||
Check(ReferenceEquals(Jar, jar), "formal lease shares exact cookie identity");
|
||||
return new Handler((r, t) => { BorrowedSends++; return Borrowed(r, t); }, () =>
|
||||
{ LeaseDisposes++; Record("lease"); if (FailLeaseDispose) throw new IOException("synthetic cleanup fault"); });
|
||||
});
|
||||
internal async Task<PlayerKVAccountLease> PreparedLease()
|
||||
{
|
||||
Check((await Context.PrepareAsync()).Prepared, "prepared");
|
||||
@@ -176,6 +185,42 @@ static partial class Tests
|
||||
await Throws<ArgumentException>(() => l.SendAsync(HttpMethod.Get, route));
|
||||
Check(f.BorrowedSends == 0, "rejected routes never sent"); await f.Context.CloseAsync();
|
||||
}
|
||||
static async Task FormalBusinessLease()
|
||||
{
|
||||
var f = new Fixture(); Check((await f.Context.PrepareAsync()).Prepared, "formal prepared");
|
||||
Check(f.Context.TryAcquireAccountLease(f.FormalCapability(), out var lease), "formal lease admitted");
|
||||
Check(lease.IsFormal && ReferenceEquals(lease.FormalCookies, f.Jar), "immutable formal mode and shared jar");
|
||||
var formalClient=(HttpClient)typeof(PlayerKVAccountLease).GetField("client", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(lease)!;
|
||||
Check(formalClient.Timeout == TimeSpan.FromSeconds(15), "formal transport retains old 15 second timeout");
|
||||
f.Borrowed = async (request, _) =>
|
||||
{
|
||||
Check(request.RequestUri.AbsolutePath == "/mail/fetch", "canonical business path");
|
||||
Check(request.Method == HttpMethod.Post, "POST method retained");
|
||||
Check(request.Content.Headers.ContentType.MediaType == "application/json", "JSON content type retained");
|
||||
Check(await request.Content.ReadAsStringAsync() == "{}", "body retained");
|
||||
return Json("business");
|
||||
};
|
||||
var response = await lease.SendAsync(HttpMethod.Post, "mail/fetch", "{}");
|
||||
Check(response.IsCurrent && response.StatusCode == 200 && response.Body == "business", "formal response retained");
|
||||
foreach (var route in new[] { "__kv_validation/ok", "__kv_validation/unauthorized", "account/Login", "playerdata/Get", "/mail/fetch", "//other/x", "http://other/x", "mail/fetch?x=1", "mail/%66etch", "mail/../fetch", "mail\\fetch", "mail//fetch" })
|
||||
await Throws<ArgumentException>(() => lease.SendAsync(HttpMethod.Get, route));
|
||||
Check(f.BorrowedSends == 1, "invalid formal routes zero send");
|
||||
await f.Context.CloseAsync();
|
||||
var v = new Fixture(); var validation = await v.PreparedLease();
|
||||
Check(!validation.IsFormal && validation.FormalCookies == null, "validation never exposes formal cookie");
|
||||
var validationClient=(HttpClient)typeof(PlayerKVAccountLease).GetField("client", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(validation)!;
|
||||
Check(validationClient.Timeout == TimeSpan.FromSeconds(100), "validation timeout unchanged");
|
||||
await Throws<ArgumentException>(() => validation.SendAsync(HttpMethod.Get, "mail/fetch"));
|
||||
Check(v.BorrowedSends == 0, "validation ordinary path zero send");
|
||||
await v.Context.CloseAsync();
|
||||
var broken = new Fixture { FailLeaseDispose = true };
|
||||
Check((await broken.Context.PrepareAsync()).Prepared, "failure case prepared");
|
||||
Check(broken.Context.TryAcquireAccountLease(broken.FormalCapability(), out var failedLease), "failure case formal lease");
|
||||
var failedClose = broken.Context.CloseAsync();
|
||||
await Throws<IOException>(() => failedClose);
|
||||
Check(!failedLease.IsCurrent && failedLease.FormalCookies == null &&
|
||||
ReferenceEquals(failedClose, broken.Context.CloseAsync()), "formal close failure remains closed");
|
||||
}
|
||||
static async Task RealHandlerRejected()
|
||||
{
|
||||
var f = new Fixture(); await f.Context.PrepareAsync();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project>
|
||||
<!-- Add approved dependencies at invocation time; historical projects and test sources stay unchanged. -->
|
||||
<ItemGroup>
|
||||
<Compile Include="$(MSBuildThisFileDirectory)../../../../Client/Assets/Scripts/Services/PlayerData/PlayerKV*.cs"
|
||||
Exclude="$(MSBuildThisFileDirectory)../../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs" />
|
||||
<Reference Include="Newtonsoft.Json"><HintPath>$(MSBuildThisFileDirectory)../../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath></Reference>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Condition="'$(MSBuildProjectName)' == 'ExitTests'">
|
||||
<DefineConstants>$(DefineConstants);KV_EXIT_REGRESSION</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition="'$(MSBuildProjectName)' == 'ExitTests'">
|
||||
<Compile Include="$(MSBuildThisFileDirectory)../../../../Client/Assets/Scripts/Services/Account/S101AccountSession.cs" />
|
||||
<Compile Include="$(MSBuildThisFileDirectory)ExitProbe.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,135 @@
|
||||
using game;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Net;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
static partial class Probe
|
||||
{
|
||||
sealed class IdentityWireCapture
|
||||
{
|
||||
internal readonly List<(string Path, string Body)> Requests = new();
|
||||
internal HttpStatusCode NextStatus = HttpStatusCode.OK;
|
||||
internal string NextBody = "{\"Code\":1}";
|
||||
internal Task<HttpResponseMessage> Respond(string path, string body)
|
||||
{
|
||||
Requests.Add((path, body));
|
||||
var result = new HttpResponseMessage(NextStatus) { Content = new StringContent(NextBody) };
|
||||
NextStatus = HttpStatusCode.OK; NextBody = "{\"Code\":1}";
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
internal bool TryHandle(HttpRequestMessage request, out Task<HttpResponseMessage> response)
|
||||
{
|
||||
response = null;
|
||||
var path = request.RequestUri.AbsolutePath;
|
||||
if (!new[] { "/friend/AddFriend", "/friend/DeleteFriend", "/friend/SearchFriend", "/friend/AcceptFriend",
|
||||
"/friend/GetHomePage", "/friend/GetRecommendedFriends", "/friend/GetInvitedList",
|
||||
"/friend/IsFriendWaitingForAccept" }.Contains(path)) return false;
|
||||
Check(request.RequestUri.GetLeftPart(UriPartial.Authority) == "http://s101.local:18080" && request.Method == HttpMethod.Post,
|
||||
"original friend route uses fixed official origin through in-memory transport");
|
||||
response = Respond(path, request.Content.ReadAsStringAsync().GetAwaiter().GetResult());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class IdentityPFTransport : System.Reflection.DispatchProxy
|
||||
{
|
||||
public Func<string, string, Task<string>> Send;
|
||||
protected override object Invoke(System.Reflection.MethodInfo method, object[] args)
|
||||
{
|
||||
if (method.Name != "FriendRequest") throw new Exception("Unexpected PF fixture transport: " + method.Name);
|
||||
return Send("/friend/" + args[0], Newtonsoft.Json.JsonConvert.SerializeObject(args[1]));
|
||||
}
|
||||
}
|
||||
sealed class IdentityPFUser : PlayFabUserService { public IdentityPFUser() { UserId = "ABCD1234"; } }
|
||||
|
||||
static async Task IdentityCompatibility(IUserService user, ICustomServerMgr server, IdentityWireCapture wire, string other)
|
||||
{
|
||||
GContext.container.Values[typeof(PlayerData)] = new PlayerData { lv = 10 };
|
||||
var friend = new FriendService { userService = user, customServerMgr = server };
|
||||
friend.FriendList.Add(new LeadboardItemData { playFabId = other });
|
||||
var initialSDK = Effects.SDK;
|
||||
JObject Last(string action)
|
||||
{
|
||||
var last = wire.Requests[^1];
|
||||
Check(last.Path == "/friend/" + action, "original route retained: " + action);
|
||||
return JObject.Parse(last.Body);
|
||||
}
|
||||
void Fields(JObject value, params string[] fields) => Check(value.Properties().Select(p => p.Name).OrderBy(x=>x)
|
||||
.SequenceEqual(fields.OrderBy(x=>x)), "original field names retained, no userId alias added");
|
||||
|
||||
wire.NextBody = "{\"Code\":-3,\"Msg\":\"synthetic compatibility failure\"}";
|
||||
var add = JObject.Parse(await friend.AddFriend(other));
|
||||
var request = Last("AddFriend"); Fields(request, "MySelf", "MyFriendCount", "FriendPlayFabId");
|
||||
Fields((JObject)request["MySelf"], "PlayFabId", "DisplayName", "AvatarUrl", "Level");
|
||||
Check((string)request["MySelf"]["PlayFabId"] == user.UserId && (string)request["FriendPlayFabId"] == other,
|
||||
"self uses authenticated identity and friend retains the distinct target identity");
|
||||
Check((int)request["MySelf"]["Level"] == 10 && (int)request["MyFriendCount"] == 1 && (int)add["Code"] == -3,
|
||||
"original data and server business failure are returned unchanged");
|
||||
|
||||
wire.NextBody = "{\"Code\":1,\"Info\":{\"PlayFabId\":\"" + other + "\",\"Level\":12}}";
|
||||
var search = JObject.Parse(await friend.SearchFriend(other));
|
||||
request = Last("SearchFriend"); Fields(request, "PlayFabId");
|
||||
Check((string)request["PlayFabId"] == other && (string)search["Info"]["PlayFabId"] == other,
|
||||
"target UserID round trips in legacy PlayFabId field without substituting self");
|
||||
await friend.AcceptFriend(2, user.UserId, other);
|
||||
request = Last("AcceptFriend"); Fields(request, "Status", "PlayFabId", "FriendPlayFabId");
|
||||
Check((int)request["Status"] == 2 && (string)request["PlayFabId"] == user.UserId && (string)request["FriendPlayFabId"] == other,
|
||||
"original accept/ignore status and two identities stay distinct");
|
||||
await friend.GetHomePage(other, 3);
|
||||
request = Last("GetHomePage"); Fields(request, "PlayFabId", "PageNo");
|
||||
Check((string)request["PlayFabId"] == other && (int)request["PageNo"] == 3, "home-page target and page retained");
|
||||
await friend.GetRecommendedFriends(user.UserId, 4);
|
||||
request = Last("GetRecommendedFriends"); Fields(request, "PlayFabId", "PageNo");
|
||||
Check((string)request["PlayFabId"] == user.UserId && (int)request["PageNo"] == 4, "recommendation self identity and page retained");
|
||||
await friend.IsFriendWaitingForAccept(user.UserId, other);
|
||||
request = Last("IsFriendWaitingForAccept"); Fields(request, "PlayFabId", "FriendPlayFabId");
|
||||
Check((string)request["PlayFabId"] == user.UserId && (string)request["FriendPlayFabId"] == other,
|
||||
"invitation status uses self and target without conversion");
|
||||
await friend.GetInvitedList();
|
||||
Check(wire.Requests[^1] == ("/friend/GetInvitedList", "\"\""), "cookie-authenticated original empty body remains unchanged");
|
||||
|
||||
wire.NextBody = "{\"Code\":-1}";
|
||||
Check(!await friend.DeleteFriend(other) && friend.FriendList.Count == 1, "business failure retains original friend list");
|
||||
request = Last("DeleteFriend"); Fields(request, "FriendPlayFabId");
|
||||
Check((string)request["FriendPlayFabId"] == other, "delete targets the supplied UserID");
|
||||
Check(await friend.DeleteFriend(other) && friend.FriendList.Count == 0, "original successful delete updates list");
|
||||
foreach (var id in new[] { user.UserId, other })
|
||||
{
|
||||
var invitation = friend.GetInvitationCodeByPlayFabID(id).Replace("-", "");
|
||||
Check(friend.DecryptInvitationCodeToUlong(invitation).ToString("X") == id,
|
||||
"original invitation codec round trips identity string " + id);
|
||||
}
|
||||
Check(Effects.SDK == initialSDK, "ordinary friend requests do not call PlayFab SDK");
|
||||
if (user is KVUserService)
|
||||
{
|
||||
wire.NextStatus = HttpStatusCode.InternalServerError;
|
||||
Check(await friend.SearchFriend(other) == null, "HTTP failure retains original null result, no fake success");
|
||||
Check(user.IsAuthorized, "ordinary HTTP 500 does not invent logout");
|
||||
}
|
||||
Pass("identity-legacy-fields-real-friend-service-" + user.UserId);
|
||||
}
|
||||
|
||||
static async Task IdentityPFCompatibility()
|
||||
{
|
||||
var wire = new IdentityWireCapture();
|
||||
var server = System.Reflection.DispatchProxy.Create<ICustomServerMgr, IdentityPFTransport>();
|
||||
((IdentityPFTransport)(object)server).Send = async (path, body) =>
|
||||
await (await wire.Respond(path, body)).Content.ReadAsStringAsync();
|
||||
await IdentityCompatibility(new IdentityPFUser(), server, wire, "FE123456");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Only unused UI/config dependencies of the linked real FriendService are fixtures.
|
||||
namespace game
|
||||
{
|
||||
public class FriendAddItemData { }
|
||||
public class FriendAddPanelBackendOperation
|
||||
{
|
||||
public void StartInvite(string id, Action<FriendAddPanelBackendOperation, string, int> callback) =>
|
||||
throw new NotSupportedException("UI operation is outside this protocol test.");
|
||||
public void Clear() { }
|
||||
}
|
||||
public class LeadboardItemData { public string playFabId, displayName, avatarUrl; public DateTime? LastLogin; public int value, position, rank;
|
||||
public void SetData(PlayFab.ClientModels.PlayerLeaderboardEntry entry) => throw new NotSupportedException("Leaderboard reads are outside this statistics-write test."); }
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using game;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Net;
|
||||
|
||||
static partial class Probe
|
||||
{
|
||||
// Actual KV login, lease, CustomServerMgr and public consumer methods run above this transport.
|
||||
sealed class ProfileCacheWire
|
||||
{
|
||||
internal bool Active;
|
||||
internal readonly List<(string Path, string Body)> Requests = new();
|
||||
internal Task<HttpResponseMessage> Next;
|
||||
internal bool TryHandle(HttpRequestMessage request, out Task<HttpResponseMessage> response)
|
||||
{
|
||||
response = null;
|
||||
if (!Active) return false;
|
||||
Check(request.RequestUri.GetLeftPart(UriPartial.Authority) == "http://s101.local:18080" &&
|
||||
request.RequestUri.AbsolutePath == "/friend/SearchFriend" && request.Method == HttpMethod.Post,
|
||||
"profile read uses existing authenticated SearchFriend, no KV/SDK/private target read");
|
||||
Requests.Add((request.RequestUri.AbsolutePath, request.Content.ReadAsStringAsync().GetAwaiter().GetResult()));
|
||||
response = Next ?? throw new Exception("Unplanned profile request");
|
||||
Next = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task ProfileCachePFCompatibility()
|
||||
{
|
||||
// Concrete PF service supplies the independent original-contract reference, SDK is the existing sentinel.
|
||||
var pf = new IdentityPFUser();
|
||||
int events = 0;
|
||||
using var subscription = GContext.OnEvent<GetClubPlayInfoEvent>().Subscribe(_ => events++);
|
||||
Check(pf.GetPlayerLv("FE123456") == 1 && pf.GetPlayerLv("") == 1, "PF display cache misses return 1");
|
||||
foreach (var level in new[] { 0, -5, 62 })
|
||||
{
|
||||
pf.SetPlayerLv("FE123456", level);
|
||||
Check(pf.GetPlayerLv("FE123456") == level, "PF display cache preserves stored values");
|
||||
}
|
||||
Check(pf.GetPlayInfo(null) == null && pf.GetPlayInfo("") == null, "PF empty profile IDs return null");
|
||||
var own = pf.GetPlayInfo(pf.UserId);
|
||||
Check(ReferenceEquals(own, pf.GetPlayInfo(pf.UserId)) && events == 1,
|
||||
"PF own profile is one cached object and one synchronous event");
|
||||
pf.SetPlayInfo("FE123456", "Before", "avatar-before");
|
||||
var first = pf.GetPlayInfo("FE123456");
|
||||
pf.SetPlayInfo("FE123456", "After", "avatar-after");
|
||||
Check(!ReferenceEquals(first, pf.GetPlayInfo("FE123456")) && first.name == "Before" && events == 1,
|
||||
"PF explicit profile assignment replaces without mutating old reference or publishing");
|
||||
pf.ClearPlayInfos();
|
||||
int sdk = Effects.SDK;
|
||||
var remote = pf.GetPlayInfo("FE123456");
|
||||
Check(remote.name == "Angler#FE123" && ReferenceEquals(remote, pf.GetPlayInfo("FE123456")) &&
|
||||
events == 2 && Effects.SDK == sdk + 1, "PF remote profile still uses original SDK and caches one result");
|
||||
Check((await pf.GetPlayLv("FE123456")).Item2 == 1 && Effects.SDK == sdk + 2,
|
||||
"PF asynchronous miss queries SDK before original missing-Lv default 1");
|
||||
Pass("C3-profile-original-PF-cache-and-SDK-reference");
|
||||
}
|
||||
|
||||
static async Task ProfileCacheKVCompatibility(KVUserService user, ProfileCacheWire wire, string other)
|
||||
{
|
||||
var failures = new List<string>();
|
||||
void Expect(bool ok, string label)
|
||||
{
|
||||
assertions++;
|
||||
if (!ok) { failures.Add(label); Console.WriteLine("FAIL C3-profile " + label); }
|
||||
}
|
||||
async Task ExpectUnloaded(string id, string label)
|
||||
{
|
||||
try { await user.GetPlayLv(id); Expect(false, label); }
|
||||
catch (InvalidOperationException) { Expect(true, label); }
|
||||
}
|
||||
int sdk = Effects.SDK;
|
||||
var events = new List<GetClubPlayInfoEvent>();
|
||||
TaskCompletionSource<bool> updated = null;
|
||||
using var subscription = GContext.OnEvent<GetClubPlayInfoEvent>().Subscribe(evt =>
|
||||
{
|
||||
events.Add(evt);
|
||||
updated?.TrySetResult(true);
|
||||
});
|
||||
wire.Active = true;
|
||||
try
|
||||
{
|
||||
user.ClearPlayInfos();
|
||||
var player = (PlayerData)GContext.container.Values[typeof(PlayerData)];
|
||||
Expect(user.IsAuthorized && player.lv == 10, "fixture uses already authenticated real KV user and existing player");
|
||||
Expect(user.GetPlayerLv(other) == 1 && user.GetPlayerLv("") == 1,
|
||||
"unknown synchronous display level is original default 1");
|
||||
Expect(user.GetPlayerLv(user.UserId) == 1,
|
||||
"synchronous self display also follows original cache contract, not a new live shortcut");
|
||||
await ExpectUnloaded(other, "display default 1 must not make asynchronous unqueried level succeed");
|
||||
await ExpectUnloaded(null, "asynchronous null ID keeps prior unloaded failure");
|
||||
await ExpectUnloaded("", "asynchronous empty ID keeps prior unloaded failure");
|
||||
Expect((await user.GetPlayLv(user.UserId)).Item2 == 10,
|
||||
"asynchronous authenticated self still reads actual player level before cache population");
|
||||
try { user.GetPlayerLv(null); Expect(false, "synchronous null ID follows original Dictionary argument failure"); }
|
||||
catch (ArgumentNullException) { Expect(true, "synchronous null ID follows original Dictionary argument failure"); }
|
||||
foreach (var level in new[] { 0, -5, 62 })
|
||||
{
|
||||
user.SetPlayerLv(other, level);
|
||||
Expect(user.GetPlayerLv(other) == level, "synchronous cached level is not normalized: " + level);
|
||||
if (level < 0) await ExpectUnloaded(other, "asynchronous negative cached level keeps prior failure");
|
||||
else Expect((await user.GetPlayLv(other)).Item2 == level, "asynchronous nonnegative cache remains available");
|
||||
}
|
||||
user.SetPlayerLv(user.UserId, 999);
|
||||
Expect(user.GetPlayerLv(user.UserId) == 10, "original self SetPlayerLv uses PlayerData rather than caller value");
|
||||
user.ClearPlayInfos();
|
||||
Expect(user.GetPlayerLv(other) == 1 && (await user.GetPlayLv(user.UserId)).Item2 == 10,
|
||||
"clear removes display cache without changing asynchronous self contract");
|
||||
Expect(wire.Requests.Count == 0, "level methods do not introduce remote level queries");
|
||||
|
||||
Expect(user.GetPlayInfo(null) == null && user.GetPlayInfo("") == null, "null and empty profile IDs return null");
|
||||
var own = user.GetPlayInfo(user.UserId);
|
||||
Expect(own != null && own.name == user.DisplayName && own.avatarUrl == user.AvatarUrl,
|
||||
"own profile uses authenticated name and avatar");
|
||||
Expect(ReferenceEquals(own, user.GetPlayInfo(user.UserId)) && events.Count == 1 &&
|
||||
events[0].id == user.UserId && events[0].name == user.DisplayName,
|
||||
"own profile cached reference and exactly one synchronous success event");
|
||||
Expect(wire.Requests.Count == 0, "own and empty profile lookups send no request");
|
||||
|
||||
user.SetPlayInfo(other, "Before", "avatar-before");
|
||||
var before = user.GetPlayInfo(other);
|
||||
int beforeEvents = events.Count;
|
||||
user.SetPlayInfo(other, "After", "avatar-after");
|
||||
Expect(!ReferenceEquals(before, user.GetPlayInfo(other)) && before.name == "Before" && events.Count == beforeEvents,
|
||||
"explicit assignment replaces the cache object without mutating old reference or publishing");
|
||||
user.SetPlayInfo(other, "", null);
|
||||
Expect(user.GetPlayInfo(other).name == "Angler#" + other && user.GetPlayInfo(other).avatarUrl == null,
|
||||
"explicit empty nickname retains original default-name rule");
|
||||
|
||||
user.ClearPlayInfos();
|
||||
events.Clear();
|
||||
var release = new TaskCompletionSource<HttpResponseMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
wire.Next = release.Task;
|
||||
updated = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
int requestStart = wire.Requests.Count;
|
||||
var placeholder = user.GetPlayInfo(other);
|
||||
Expect(placeholder != null && placeholder.name == "Angler#" + other && placeholder.avatarUrl == null,
|
||||
"remote miss returns original default-name placeholder before HTTP completes");
|
||||
Expect(ReferenceEquals(placeholder, user.GetPlayInfo(other)) && events.Count == 0 && wire.Requests.Count == requestStart + 1,
|
||||
"pending remote read shares one object and one request without early event");
|
||||
if (wire.Requests.Count == requestStart + 1)
|
||||
{
|
||||
var body = JObject.Parse(wire.Requests[^1].Body);
|
||||
Expect(body.Properties().Count() == 1 && (string)body["PlayFabId"] == other,
|
||||
"existing request field carries target new UserID without extra identity aliases");
|
||||
release.SetResult(await Json(new { code = 1, info = new { playFabId = other, displayName = "Remote", avatarUrl = "remote-avatar", level = 1 } }));
|
||||
await updated.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Expect(ReferenceEquals(placeholder, user.GetPlayInfo(other)) && placeholder.name == "Remote" &&
|
||||
placeholder.avatarUrl == "remote-avatar" && events.Count == 1 && events[0].id == other,
|
||||
"successful HTTP updates the same consumer object and publishes one event");
|
||||
await ExpectUnloaded(other, "SearchFriend nickname success does not populate asynchronous level cache");
|
||||
updated = null;
|
||||
}
|
||||
else { release.SetResult(await Json(new { code = 0 })); updated = null; }
|
||||
|
||||
// Completed in-memory responses exercise the actual async-void consumer's synchronous-completion path.
|
||||
foreach (var sample in new[]
|
||||
{
|
||||
(Body: "{\"code\":-1,\"info\":null}", Status: HttpStatusCode.OK, Success: false),
|
||||
(Body: "{\"code\":1,\"info\":null}", Status: HttpStatusCode.OK, Success: false),
|
||||
(Body: "null", Status: HttpStatusCode.OK, Success: false),
|
||||
(Body: "", Status: HttpStatusCode.OK, Success: false),
|
||||
(Body: "{invalid", Status: HttpStatusCode.OK, Success: false),
|
||||
(Body: "{}", Status: HttpStatusCode.InternalServerError, Success: false),
|
||||
(Body: "{\"code\":2,\"info\":{\"displayName\":\"\",\"avatarUrl\":null}}", Status: HttpStatusCode.OK, Success: true)
|
||||
})
|
||||
{
|
||||
user.ClearPlayInfos(); events.Clear();
|
||||
wire.Next = Task.FromResult(new HttpResponseMessage(sample.Status) { Content = new StringContent(sample.Body) });
|
||||
int sent = wire.Requests.Count;
|
||||
var profile = user.GetPlayInfo(other);
|
||||
Expect(profile != null && profile.name == "Angler#" + other && profile.avatarUrl == null &&
|
||||
ReferenceEquals(profile, user.GetPlayInfo(other)), "missing nickname or failed response preserves the cached placeholder");
|
||||
Expect(wire.Requests.Count == sent + 1 && events.Count == (sample.Success ? 1 : 0),
|
||||
"code>0 and nonnull info alone determine successful profile publication: " + sample.Body);
|
||||
}
|
||||
Expect(Effects.SDK == sdk && user.IsAuthorized, "KV profiles and levels never invoke PF SDK or revoke on ordinary failure");
|
||||
user.ClearPlayInfos();
|
||||
}
|
||||
finally { wire.Active = false; wire.Next = null; }
|
||||
if (failures.Count != 0) throw new Exception("C3 profile migration gaps: " + string.Join("; ", failures));
|
||||
Pass("C3-real-KV-profile-cache-reference-event-and-level-boundary-" + user.UserId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
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;}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using game;
|
||||
using game.PlayerDataStorage;
|
||||
|
||||
static partial class Probe
|
||||
{
|
||||
static async Task MergedUser()
|
||||
{
|
||||
var selection = PlayerKVRuntimeSelection.Capture("kv", PlayerKVRuntimeSelection.FormalApiUrl, false);
|
||||
var user = new KVUserService(selection, _ => new Handler(_ => throw new Exception("unexpected HTTP")));
|
||||
Check(user is IUserService && user is IKVUserLoadReceiver && user is IKVUserLifecycle,
|
||||
"independent KV service implements actual consumer contracts");
|
||||
Check(!typeof(PlayFabUserService).IsAssignableFrom(user.GetType()), "KV does not inherit PF");
|
||||
Check(typeof(IUserService).GetProperty("entityKey") == null, "common identity has no PF entity DTO");
|
||||
Check(typeof(IUserService).GetProperty("CustomId").CanWrite, "login input uses common setter");
|
||||
Check(user.GetDefaultName("101") == "Angler#101" && user.GetDefaultName("123456") == "Angler#12345",
|
||||
"short and long identity names");
|
||||
Check(KVUserService.ParseLastLoginTime(null, false) == DateTime.MinValue, "missing login time sentinel");
|
||||
foreach (var value in new[] { "", "bad", "0001-01-01T00:00:00.0000000Z" })
|
||||
{
|
||||
try { KVUserService.ParseLastLoginTime(value, true); throw new Exception("bad time accepted"); }
|
||||
catch (FormatException) { Check(true, "present invalid time is not first login"); }
|
||||
}
|
||||
Check(KVUserService.ParseLastLoginTime("2026-09-28T01:00:00.0000000Z", true).Kind == DateTimeKind.Utc,
|
||||
"stored previous login is UTC");
|
||||
user.Logout();
|
||||
await user.CloseKVForValidationAsync();
|
||||
Pass("merged-KV-contract-and-login-time");
|
||||
await IdentityPFCompatibility();
|
||||
await ProfileCachePFCompatibility();
|
||||
await MergedLogin();
|
||||
MergedDailyRefresh();
|
||||
}
|
||||
|
||||
sealed class KVChat : IChatService
|
||||
{
|
||||
internal int Waits;
|
||||
public void WaitingForLogin() { Waits++; }
|
||||
}
|
||||
|
||||
static async Task MergedLogin()
|
||||
{
|
||||
foreach (var scenario in new[] { "success", "success-102", "save-unknown", "logout-during-save", "corrupt", "logout-during-load" })
|
||||
{
|
||||
var accountId = scenario == "success-102" ? "102" : "101";
|
||||
var identityWire = new IdentityWireCapture();
|
||||
var profileWire = new ProfileCacheWire();
|
||||
var badProfile = scenario == "corrupt";
|
||||
var selection = PlayerKVRuntimeSelection.Capture("kv", PlayerKVRuntimeSelection.FormalApiUrl, false);
|
||||
var config = Configuration(); config.Set("EventApiURL", PlayerKVRuntimeSelection.FormalApiUrl);
|
||||
var root = UnityEngine.Application.persistentDataPath;
|
||||
UnityEngine.Application.persistentDataPath = Path.Combine(Path.GetTempPath(), "ft-merged-" + Guid.NewGuid().ToString("N"));
|
||||
var raw = Raw();
|
||||
var remote = new Dictionary<string, string>(raw);
|
||||
if (badProfile) raw["LastLoginTimeUtc"] = "corrupt";
|
||||
int auth = 0, gets = 0, success = 0, applied = 0;
|
||||
var getStarted = new TaskCompletionSource<bool>();
|
||||
var releaseGet = new TaskCompletionSource<System.Net.Http.HttpResponseMessage>();
|
||||
var setStarted = new TaskCompletionSource<bool>();
|
||||
var releaseSet = new TaskCompletionSource<System.Net.Http.HttpResponseMessage>();
|
||||
Action beforeSet = null;
|
||||
var chat = new KVChat();
|
||||
GContext.container.Values[typeof(IChatService)] = chat;
|
||||
GContext.container.Values[typeof(ISettingService)] = new DefaultSettingService();
|
||||
GContext.container.Values[typeof(ILocalNotificationService)] = new LocalNotificationService();
|
||||
GContext.container.Values[typeof(ClubService)] = new ClubService();
|
||||
PlayerKVGameplayBindings.SnapshotFactory = Snapshot;
|
||||
PlayerKVGameplayBindings.BindFactory = receiver => new PlayerKVGameLoadTransaction(
|
||||
Enumerable.Range(1, 31).Select(id => (IPlayerKVLoadModule)new PlayerKVLoadModule(id,
|
||||
() => { if (id == 31) receiver.ResetForKVLoad(); },
|
||||
decoded => { applied++; if (id == 31) receiver.ReplaceFromKVDecoded(decoded.Module<PlayerKVLoginState>(31), decoded.Epoch, decoded.Raw); })),
|
||||
Environment.CurrentManagedThreadId, receiver.DiscardKVIntents);
|
||||
var server = new CustomServerMgr(config, _ => new Handler(_ => throw new Exception("PF transport forbidden")));
|
||||
var user = new KVUserService(selection, jar => new Handler(request =>
|
||||
{
|
||||
if (profileWire.TryHandle(request, out var profileResponse)) return profileResponse;
|
||||
if (identityWire.TryHandle(request, out var identityResponse)) return identityResponse;
|
||||
switch (request.RequestUri.AbsolutePath)
|
||||
{
|
||||
case "/account/Login":
|
||||
auth++; jar.Add(selection.Api, new System.Net.Cookie("sid", "synthetic"));
|
||||
return Json(new { state = 0, internalId = 17, newCreate = false,
|
||||
createTime = "2026-01-01T00:00:00Z", svrTime = "2026-09-29T00:00:00Z" });
|
||||
case "/playerdata/Get": gets++; getStarted.TrySetResult(true); return releaseGet.Task;
|
||||
case "/playerdata/Set":
|
||||
var onSet = beforeSet; beforeSet = null; onSet?.Invoke();
|
||||
setStarted.TrySetResult(true);
|
||||
if (scenario == "logout-during-save") return releaseSet.Task;
|
||||
if (scenario == "save-unknown") return Json(new { }, System.Net.HttpStatusCode.InternalServerError);
|
||||
var body = Newtonsoft.Json.Linq.JObject.Parse(request.Content.ReadAsStringAsync().GetAwaiter().GetResult());
|
||||
foreach (var entry in body["data"].ToObject<Dictionary<string, string>>()) remote[entry.Key] = entry.Value;
|
||||
return Json(new { });
|
||||
case "/friend/unauthorized": return Json(new { }, System.Net.HttpStatusCode.Unauthorized);
|
||||
default: throw new Exception("unplanned synthetic route " + request.RequestUri.AbsolutePath);
|
||||
}
|
||||
})) { CustomId = accountId, customServerMgr = server, SaveStore = new KVPlayerSaveStore() };
|
||||
using var subscription = GContext.OnEvent<EvtAPISvrLoginSuccess>().Subscribe(evt =>
|
||||
{
|
||||
success++;
|
||||
Check(applied == 31 && chat.Waits == 1 && user.IsAuthorized && user.KVHandoffReady,
|
||||
"identity event follows all modules, chat subscription and original handoff");
|
||||
Check(evt.playerId == accountId && evt.internalId == 17 && server.Cookie != null,
|
||||
"event carries authenticated ID and ready cookie");
|
||||
});
|
||||
Effects.Clear();
|
||||
try
|
||||
{
|
||||
var pending = user.Login(null);
|
||||
await getStarted.Task;
|
||||
Check(!user.IsLogin && !user.IsAuthorized && user.UserId == null && success == 0,
|
||||
"no public identity during remote load");
|
||||
Check(await user.Login(null) == ELoginStep.Api && auth == 1, "duplicate login never authenticates twice");
|
||||
if (scenario == "logout-during-load") user.Logout();
|
||||
releaseGet.SetResult(await Json(new { data = raw }));
|
||||
var result = await pending;
|
||||
if (scenario == "logout-during-load")
|
||||
{
|
||||
Check(result == ELoginStep.Api && !user.IsAuthorized && user.UserId == null && success == 0 && applied == 0,
|
||||
"logout during load discards delayed response before module apply or identity");
|
||||
}
|
||||
else if (badProfile)
|
||||
{
|
||||
Check(result == ELoginStep.Api && !user.IsAuthorized && success == 0 && chat.Waits == 0,
|
||||
"corrupt profile revokes before public event or chat subscription");
|
||||
}
|
||||
else
|
||||
{
|
||||
Check(result == ELoginStep.Done && user.UserId == accountId && user.ApiPlayerId == accountId && user.InternalId == 17,
|
||||
"real KV service publishes new authenticated identity");
|
||||
Check(user.LastLoginTime == DateTime.MinValue && user.LoginTime == new DateTime(2026, 9, 29, 0, 0, 0, DateTimeKind.Utc),
|
||||
"missing previous time and authenticated current time are distinct");
|
||||
Check(user.DisplayName == "Angler#" + accountId && user.ContinentCode == null && user.KVTapJoyBalance == 9,
|
||||
"profile defaults, unknown region and decoded platform balance");
|
||||
Check(await user.Login(null) == ELoginStep.Api && success == 1 && auth == 1 && gets == 1,
|
||||
"completed login cannot publish a second event");
|
||||
if (scenario == "logout-during-save")
|
||||
{
|
||||
var save = user.UpdateLanguage("de");
|
||||
await setStarted.Task;
|
||||
user.Logout();
|
||||
Check(!user.IsAuthorized && !user.SaveStore.IsCurrent, "close invalidates store before save response");
|
||||
releaseSet.SetResult(await Json(new { }));
|
||||
try { await save; throw new Exception("late profile was published"); }
|
||||
catch (InvalidOperationException) { Check(user.Language == null, "late save completion cannot republish a closed profile"); }
|
||||
}
|
||||
else
|
||||
{
|
||||
if (scenario == "success" || scenario == "success-102")
|
||||
{
|
||||
await IdentityCompatibility(user, server, identityWire, accountId == "101" ? "102" : "101");
|
||||
await ProfileCacheKVCompatibility(user, profileWire, accountId == "101" ? "102" : "101");
|
||||
}
|
||||
if (scenario != "success-102")
|
||||
await MergedSaveContract(user, remote, scenario == "save-unknown", action => beforeSet = action);
|
||||
Check(await server.FriendRequest("unauthorized", "") == null && !user.IsAuthorized && user.UserId == null,
|
||||
"current 401 synchronously revokes independent identity");
|
||||
var sentBeforeCloseProbe = identityWire.Requests.Count;
|
||||
try
|
||||
{
|
||||
await new FriendService { userService = user, customServerMgr = server }.SearchFriend("102");
|
||||
throw new Exception("revoked transport allowed a new friend request");
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
Check(identityWire.Requests.Count == sentBeforeCloseProbe,
|
||||
"original not-ready exception prevents a friend request after identity revocation");
|
||||
}
|
||||
}
|
||||
}
|
||||
Check(Effects.SDK == 0 && Effects.Helpers == 0 && Effects.Rewards == 0,
|
||||
"formal KV never invokes PF SDK or platform/reward initialization");
|
||||
await user.CloseKVForValidationAsync();
|
||||
var staleTokens = new Activity.Services.EventTokenService(user.SaveStore);
|
||||
Check(staleTokens.GetTokenCount(1) == 0, "original token service catches unavailable storage and retains its initial balance");
|
||||
Check(!user.SaveStore.IsCurrent && !user.SaveStore.Read("Language").Success &&
|
||||
!user.SaveStore.Write("Language", "stale").Accepted, "old store is revoked synchronously on close");
|
||||
}
|
||||
finally
|
||||
{
|
||||
user.Logout(); await user.CloseKVForValidationAsync();
|
||||
UnityEngine.Application.persistentDataPath = root;
|
||||
PlayerKVGameplayBindings.BindFactory = null; PlayerKVGameplayBindings.SnapshotFactory = null;
|
||||
}
|
||||
}
|
||||
Pass("merged-real-KV-login-publication-duplicate-401-and-corrupt-profile");
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ using Newtonsoft.Json;
|
||||
using PlayFab.ClientModels;
|
||||
using tysdk;
|
||||
|
||||
static class Probe
|
||||
static partial class Probe
|
||||
{
|
||||
static int assertions,groups;
|
||||
static void Check(bool condition,string label){assertions++;if(!condition)throw new Exception(label);}
|
||||
@@ -57,12 +57,33 @@ static class Probe
|
||||
}
|
||||
}
|
||||
Pass("public-backend-rejection-before-AGG-shortcut");
|
||||
SubBoostrap.RuntimeSelection=PlayerKVRuntimeSelection.Capture("kv",PlayerKVRuntimeSelection.FormalApiUrl,false);
|
||||
var formalConfig=Configuration(); formalConfig.Set("SaveBackend","kv");
|
||||
foreach(var disallowed in new[]{"100","103","-101","x101"})
|
||||
{
|
||||
var formalUser=new KVUserService{CustomId=disallowed};
|
||||
Effects.Clear(); PlayFab.PlayFabClientAPI.Logged=true;
|
||||
Check(await formalUser.Login(null)==ELoginStep.Api && formalUser.KVLastError==PlayerKVGameLoadError.Input,
|
||||
"formal cohort rejected before any account request");
|
||||
Check(!formalUser.IsLogin && !formalUser.IsAuthorized && Effects.Total==0,
|
||||
"formal rejection does not publish PF identity or cause side effects");
|
||||
formalUser.Logout();
|
||||
Check(Effects.SDK==0,"formal logout does not clear PF credentials");
|
||||
}
|
||||
var quittingUser=new KVUserService{CustomId="101"};
|
||||
var oldLifetime=quittingUser.LoginLifetime; Effects.Clear();
|
||||
UnityEngine.Application.QuitForTest();
|
||||
Check(oldLifetime.IsCancellationRequested && Effects.SDK==0,
|
||||
"application quitting revokes formal login lifetime without PF credential calls");
|
||||
SubBoostrap.RuntimeSelection=null;
|
||||
Pass("formal-cohort-rejection-and-PF-credential-isolation");
|
||||
var pf=new PlayFabMgr();Private(pf,"Awake");
|
||||
var data=new Dictionary<string,UserDataRecord>{{"MMTest",new(){Value="MMFailParam#4",LastUpdated=DateTime.UtcNow}},{"TapJoy_Diamond",new(){Value="7",LastUpdated=DateTime.UtcNow}}};
|
||||
pf.CompareDatas(data,"synthetic");pf.UpdateUserDataValue("local","value",false);
|
||||
Check(pf.GetLocalData("local")=="value","default PF local write unchanged");
|
||||
Check(await pf.SyncDataImmidiate("remote","value"),"default PF immediate result unchanged");
|
||||
Pass("default-PF-storage-passthrough");
|
||||
await MergedPFStore(pf);
|
||||
foreach(bool agg in new[]{false,true})
|
||||
{
|
||||
var config=Configuration();PlayFabUserService user=agg?new AggUserService():new PlayFabUserService();user.config=config;user.CustomId="101";
|
||||
@@ -122,6 +143,9 @@ static class Probe
|
||||
Check(Effects.Total==0 && fresh.IsUnstartedForKVLoad && guard.Violations==12,"all PF entrances reject before buffer/Prefs/SDK");
|
||||
}
|
||||
Pass("all-real-PF-guard-entrances");await Controlled();
|
||||
await FormalTransport();
|
||||
await MergedUser();
|
||||
await StatisticsWriteTests();
|
||||
}
|
||||
sealed class Chat:IChatService {public void WaitingForLogin(){} }
|
||||
sealed class Setting:ISettingService {public string Lang=>"en";}
|
||||
@@ -158,6 +182,87 @@ static class Probe
|
||||
Check(unauthorizedEvents==1 && auth==3 && Effects.Total==0 && user.KVIntents==null,"late 401 never reauthenticates or publishes another unauthorized event");
|
||||
Pass("real-Agg-User-CustomServer-coordinator-KV-chain");
|
||||
}
|
||||
static async Task FormalTransport()
|
||||
{
|
||||
var api=new Uri(PlayerKVRuntimeSelection.FormalApiUrl);
|
||||
var config=new Config();config.Set("EventApiURL",api.AbsoluteUri);
|
||||
int auth=0,kvGet=0,business=0,unauthorized=0;
|
||||
var ownerThread=Environment.CurrentManagedThreadId;
|
||||
CookieContainer jar=null;
|
||||
var context=new PlayerKVLoginContext("101",Path.Combine(Path.GetTempPath(),"ft-cp2b-t-"+Guid.NewGuid().ToString("N")),31,
|
||||
_=>true,api,
|
||||
()=>new S101AccountSession(api,cookies=>new Handler(r=>
|
||||
{
|
||||
auth++;jar=cookies;cookies.Add(api,new Cookie("sid","synthetic"));
|
||||
return Json(new{state=0,internalId=17,newCreate=false,createTime="2026-01-01T00:00:00Z",svrTime="2026-01-01T00:00:00Z"});
|
||||
})),
|
||||
()=>new PlayerKVGameSessionBridge(cookies=>new Handler(r=>
|
||||
{kvGet++;Check(ReferenceEquals(cookies,jar),"KV shares account jar");return Json(new{data=new Dictionary<string,string>()});})));
|
||||
Check((await context.PrepareAsync()).Prepared,"formal synthetic account prepared");
|
||||
var late=new TaskCompletionSource<HttpResponseMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
Check(context.TryAcquireAccountLease(PlayerKVControlledLoadCapability.CreateForGame(cookies=>new Handler(r=>
|
||||
{
|
||||
Check(ReferenceEquals(cookies,jar),"ordinary send shares account jar");business++;
|
||||
Check(Environment.CurrentManagedThreadId==ownerThread,"ordinary transport runs on owner thread");
|
||||
return r.RequestUri.AbsolutePath switch
|
||||
{
|
||||
"/friend/late"=>late.Task,
|
||||
"/friend/unauthorized"=>Json(new{},HttpStatusCode.Unauthorized),
|
||||
"/mail/missing"=>Json(new{},HttpStatusCode.NotFound),
|
||||
_=>Json(new{ok=true})
|
||||
};
|
||||
})),out var lease),"formal lease acquired");
|
||||
var server=new CustomServerMgr(config,_=>new Handler(r=>throw new Exception("PF transport forbidden")));
|
||||
server.KVUnauthorized+=_=>unauthorized++;
|
||||
server.BindKVForGame(lease);
|
||||
try { await server.CustomServerPost("mail/fetch","{}");throw new Exception("pre-ready send escaped"); }
|
||||
catch(InvalidOperationException) { Check(business==0,"pre-ready zero send"); }
|
||||
server.EnableKVBusiness(lease.Epoch);
|
||||
Check(ReferenceEquals(server.Cookie,jar),"RT sees same account cookie only after readiness");
|
||||
Check((await server.CustomServerPost("mail/fetch","{}"))!=null && business==1,"business POST uses lease");
|
||||
Check((await server.GetStringAsync("anti-cheat/GetConfigRequest"))!=null && business==2,"business GET uses lease");
|
||||
Check(await Task.Run(()=>server.GetStringAsync("friend/background"))!=null && business==3,
|
||||
"background caller dispatched to original service thread");
|
||||
Check(await server.GetStringAsync("mail/missing")==null && business==4 && unauthorized==0,
|
||||
"non-200 response returns empty without revoking identity");
|
||||
var old=server.GetStringAsync("friend/late");
|
||||
var version=server.SessionVersion;
|
||||
Check(await server.GetStringAsync("friend/unauthorized")==null && unauthorized==1 && server.SessionVersion>version,
|
||||
"current 401 disables transport and advances version");
|
||||
Check(business==6 && auth==1 && kvGet==1,"one authentication, one KV read, no PF transport");
|
||||
try { server.EnableKVBusiness(lease.Epoch);throw new Exception("revoked transport reopened"); }
|
||||
catch(InvalidOperationException) { Check(true,"single publication cannot reopen after 401"); }
|
||||
try { _=server.Cookie;throw new Exception("revoked cookie escaped"); }
|
||||
catch(NullReferenceException) { Check(true,"revoked cookie unavailable"); }
|
||||
var close=server.ClearKVForGame();Check(!close.IsCompleted,"inflight request drains before closing");
|
||||
late.SetResult(new HttpResponseMessage(HttpStatusCode.OK){Content=new StringContent("old-session-body")});
|
||||
Check(await old==null,"old 200 body withheld after unauthorized revocation");
|
||||
await close;await context.CloseAsync();
|
||||
CookieContainer nextJar=null;
|
||||
var nextContext=new PlayerKVLoginContext("101",Path.Combine(Path.GetTempPath(),"ft-cp2b-t-"+Guid.NewGuid().ToString("N")),32,
|
||||
_=>true,api,
|
||||
()=>new S101AccountSession(api,cookies=>new Handler(r=>
|
||||
{
|
||||
auth++;nextJar=cookies;cookies.Add(api,new Cookie("sid","next-synthetic"));
|
||||
return Json(new{state=0,internalId=17,newCreate=false,createTime="2026-01-01T00:00:00Z",svrTime="2026-01-01T00:00:00Z"});
|
||||
})),
|
||||
()=>new PlayerKVGameSessionBridge(cookies=>new Handler(r=>Json(new{data=new Dictionary<string,string>()}))));
|
||||
Check((await nextContext.PrepareAsync()).Prepared,"same account new epoch prepared");
|
||||
Check(nextContext.TryAcquireAccountLease(PlayerKVControlledLoadCapability.CreateForGame(cookies=>new Handler(r=>
|
||||
{
|
||||
Check(ReferenceEquals(cookies,nextJar) && !ReferenceEquals(cookies,jar),"new epoch owns new cookie jar");
|
||||
return Json(new{next=true});
|
||||
})),out var nextLease),"new epoch lease acquired");
|
||||
server.BindKVForGame(nextLease);
|
||||
Check(!ReferenceEquals(lease,nextLease),"new epoch has distinct lease");
|
||||
try { _=server.Cookie;throw new Exception("new epoch cookie published before readiness"); }
|
||||
catch(NullReferenceException) { Check(true,"new epoch cookie withheld before readiness"); }
|
||||
server.EnableKVBusiness(nextLease.Epoch);
|
||||
Check(ReferenceEquals(server.Cookie,nextJar) && await server.GetStringAsync("friend/next")!=null,
|
||||
"same account new epoch uses only its current lease");
|
||||
await server.ClearKVForGame();await nextContext.CloseAsync();
|
||||
Pass("formal-KV-business-transport-and-401-boundary");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings><Nullable>disable</Nullable>
|
||||
@@ -14,6 +14,28 @@
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/PlayerData/PlayerKV*.cs" Exclude="../../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/Account/S101AccountSession.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/UserService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/KVUserService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/PlayerData/IPlayerSaveStore.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/PlayerData/PlayerSaveStores.cs" />
|
||||
<Compile Include="../M2MergedContractTests.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/SurveyService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/FriendService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/DataCenter/DataCenter.LeadboardData.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/FriendModel.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/FootPrintService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/InGameMail.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/InGameMailHandler.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/UI/PartnerStatue/EventPartnerStatuePlayfabData.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Activity/Data/EventDataRepository.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Activity/Services/EventTokenService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Activity/Services/IEventTokenService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Activity/Data/IEventDataRepository.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/TimeManager/TimeRefreshManager.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/TimeManager/ITimeTickService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/TimeManager/Event/*.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/DataCenter/DateTimeExtensions.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/PlayerData/IKVUser*.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Core/KVMgr.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/UserService.KVLoad.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/AggUserService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Core/PlayFabMgr.cs" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Only SDK/engine/business dependencies are replaced. Four service sources are linked unchanged.
|
||||
// Only SDK/engine/business dependencies are replaced. Four service sources are linked unchanged.
|
||||
using PlayFab.ClientModels;
|
||||
using Newtonsoft.Json;
|
||||
// Legacy desktop fallback names AndroidDeviceId on CustomID request (unreachable with validated CustomId).
|
||||
@@ -12,6 +12,7 @@ static class Effects
|
||||
}
|
||||
namespace UnityEngine
|
||||
{
|
||||
public static class Mathf { public static int Min(int a, int b) => Math.Min(a, b); }
|
||||
public class Object { public static implicit operator bool(Object value)=>value!=null; public static void DontDestroyOnLoad(Object o){} }
|
||||
public class GameObject:Object
|
||||
{
|
||||
@@ -20,14 +21,17 @@ namespace UnityEngine
|
||||
public T AddComponent<T>() where T:new(){Effects.Helpers++;return new T();}
|
||||
}
|
||||
public class MonoBehaviour:Object { public GameObject gameObject=new(); public T GetComponent<T>() where T:class=>null; }
|
||||
public static class Debug { public static void Log(object o){} public static void LogWarning(object o){} public static void LogError(object o){} }
|
||||
public static class Application { public static string version="synthetic",identifier="101",persistentDataPath=Path.GetTempPath(); }
|
||||
public static class Debug { internal static readonly List<string> Errors=new(); public static void Log(object o){} public static void LogWarning(object o){} public static void LogError(object o){Errors.Add(o?.ToString());} public static void LogException(Exception e){System.Console.WriteLine("fixture observed exception: " + e);} }
|
||||
public static class Application { public static string version="synthetic",identifier="101",persistentDataPath=Path.GetTempPath(); public static event Action quitting; public static void QuitForTest()=>quitting?.Invoke(); }
|
||||
public static class SystemInfo { public static string deviceUniqueIdentifier="101",deviceModel="stub"; }
|
||||
public static class PlayerPrefs
|
||||
{
|
||||
public static readonly Dictionary<string,string> Data=new();
|
||||
public static string GetString(string key,string fallback=""){Effects.Prefs++;return Data.GetValueOrDefault(key,fallback);}
|
||||
public static void SetString(string key,string value){Effects.Prefs++;Data[key]=value;}
|
||||
public static bool HasKey(string key) => Data.ContainsKey(key);
|
||||
public static int GetInt(string key,int fallback=0)=>throw new NotSupportedException("Activity preference is not under test.");
|
||||
public static void SetInt(string key,int value)=>throw new NotSupportedException("Activity preference is not under test.");
|
||||
public static void Save(){Effects.Prefs++;}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +39,7 @@ namespace asap.core
|
||||
{
|
||||
public class InjectAttribute:Attribute{}
|
||||
public interface IConfig { T Get<T>(string key,T fallback); void Set(string key,object value); }
|
||||
public static class Awaiters { public static Task NextFrame=>Task.CompletedTask; }
|
||||
public static class Awaiters { public static Task NextFrame=>Task.CompletedTask; internal static Func<float,Task> SecondsOverride; public static Task Seconds(float value)=>SecondsOverride?.Invoke(value) ?? throw new NotSupportedException("Unexpected timer."); }
|
||||
}
|
||||
sealed class Config:asap.core.IConfig
|
||||
{
|
||||
@@ -45,9 +49,51 @@ sealed class Config:asap.core.IConfig
|
||||
}
|
||||
namespace UniRx
|
||||
{
|
||||
public sealed class Stream<T>
|
||||
{ internal static Action<T> Handlers; public IDisposable Subscribe(Action<T> action){Effects.Subscriptions++;Handlers+=action;return new Subscription(()=>Handlers-=action);} }
|
||||
public sealed class Stream<T> : IObservable<T>
|
||||
{
|
||||
internal static Action<T> Handlers;
|
||||
public IDisposable Subscribe(Action<T> action){Effects.Subscriptions++;Handlers+=action;return new Subscription(()=>Handlers-=action);}
|
||||
public IDisposable Subscribe(IObserver<T> observer) => Subscribe(observer.OnNext);
|
||||
}
|
||||
public sealed class Subscription:IDisposable { readonly Action action; public Subscription(Action action)=>this.action=action;public void Dispose()=>action(); }
|
||||
public sealed class CompositeDisposable : IDisposable
|
||||
{
|
||||
readonly List<IDisposable> values = new();
|
||||
public void Add(IDisposable value) => values.Add(value);
|
||||
public void Dispose() { foreach (var value in values) value.Dispose(); values.Clear(); }
|
||||
}
|
||||
public static class ProbeRxExtensions
|
||||
{
|
||||
sealed class Observer<T> : IObserver<T>
|
||||
{
|
||||
readonly Action<T> next;
|
||||
internal Observer(Action<T> next) { this.next = next; }
|
||||
public void OnNext(T value) => next(value);
|
||||
public void OnCompleted() { }
|
||||
public void OnError(Exception error) => throw error;
|
||||
}
|
||||
public static IDisposable Subscribe<T>(this IObservable<T> source, Action<T> next) => source.Subscribe(new Observer<T>(next));
|
||||
public static void AddTo(this IDisposable subscription, CompositeDisposable subscriptions) => subscriptions.Add(subscription);
|
||||
}
|
||||
}
|
||||
namespace cfg
|
||||
{
|
||||
public sealed class Tables { public GlobalConfig TbGlobalConfig { get; } = new(); public TbSurveyData TbSurveyData { get; } = new(); public TbAutoDeliveryMail TbAutoDeliveryMail { get; } = new(); public TbInvite TbInvite { get; } = new();
|
||||
public TbMapData TbMapData {get;}=new(); public UnusedTable<FishingEvent> TbFishingEvent {get;}=new(); public UnusedTable<RankInit> TbRankInit {get;}=new(); public UnusedTable<RankTargets> TbRankTargets {get;}=new(); public UnusedTable<Robot> TbRobot {get;}=new(); }
|
||||
public sealed class MapData { public int LevelRequired; }
|
||||
public sealed class TbMapData { public List<MapData> DataList=new(); }
|
||||
public sealed class FishingEvent { public int ID,RedirectID; }
|
||||
public sealed class RankInit { public int UnlockPoint; public List<int> TargetList; }
|
||||
public sealed class RankTargets { public int NextTarget,TokenRequiredB; }
|
||||
public sealed class Robot { public string Name_l10n_key,Avatar; }
|
||||
public sealed class UnusedTable<T> { public T GetOrDefault(int id)=>throw new NotSupportedException("Activity leaderboard is not under test."); public Dictionary<int,T> DataMap=>throw new NotSupportedException("Activity leaderboard is not under test."); }
|
||||
public sealed class Invite { public int NumInvite, DropID; }
|
||||
public sealed class TbInvite { public List<Invite> DataList = new(); }
|
||||
public sealed class SurveyData { public int ID, LevelCondition, Reward; public string Hash; public List<int> TailNumberList = new(); }
|
||||
public sealed class TbSurveyData { public List<SurveyData> DataList = new(); }
|
||||
public sealed class AutoDeliveryMail { public int MailID, ExpireDay, DropID; public List<int> AccountLevelRange = new(); public string AccountCreateTimestamp, Title_l10n_key, Content_l10n_key, Inscribe_l10n_key; public List<string> LoginTimestampRange = new(); }
|
||||
public sealed class TbAutoDeliveryMail { public Dictionary<int, AutoDeliveryMail> DataMap = new(); public List<AutoDeliveryMail> DataList = new(); }
|
||||
public sealed class GlobalConfig { public int GlobalTimeOffset = 9; }
|
||||
}
|
||||
public static class GContext
|
||||
{
|
||||
@@ -55,13 +101,28 @@ public static class GContext
|
||||
public static UniRx.Stream<T> OnEvent<T>()=>new();
|
||||
public static void Publish<T>(T value){Effects.Events++;UniRx.Stream<T>.Handlers?.Invoke(value);}
|
||||
}
|
||||
public static class SubBoostrap
|
||||
{ internal static game.PlayerDataStorage.PlayerKVRuntimeSelection RuntimeSelection { get; set; } }
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// The service-host verifies service branch behavior; Unity resolves the real 31-module binding.
|
||||
internal static class PlayerKVGameplayBindings
|
||||
{
|
||||
internal static Func<IKVUserLoadReceiver, PlayerKVGameLoadTransaction> BindFactory;
|
||||
internal static Func<PlayerKVSchemaSnapshot> SnapshotFactory;
|
||||
internal static PlayerKVGameLoadTransaction Bind(IKVUserLoadReceiver user) => BindFactory?.Invoke(user) ?? throw new NotSupportedException("Unity module binding required.");
|
||||
internal static PlayerKVSchemaSnapshot Snapshot(DateTime nowUtc,string version="kv-loop") => SnapshotFactory?.Invoke() ?? throw new NotSupportedException("Unity configuration required.");
|
||||
}
|
||||
}
|
||||
public sealed class Container
|
||||
{ public readonly Dictionary<Type,object> Values=new();public T Resolve<T>()=> (T)Values[typeof(T)]; }
|
||||
{ public readonly Dictionary<Type,object> Values=new();public T Resolve<T>()=> (T)Values[typeof(T)]; public object ResolveStage(string name)=>throw new NotSupportedException("Stage is not under test."); }
|
||||
public static class GameDebug { public static void LogError(string s,params object[] a){} }
|
||||
public static class ZZTimeHelper { public static DateTime UtcNow()=>new(2026,9,23,0,0,0,DateTimeKind.Utc); public static bool Adjusted; public static void AdjustTime(DateTime t)=>Adjusted=true;public static void WathForcusChange(){} }
|
||||
public static class GConstant { public const string K_Event_API_URL="EventApiURL",K_AssetVersion="AssetVersion",Yidun_GooglePlayChannel="stub",Yidun_AndroidBusinessId="stub",K_PlayFab_Title="PFTitle",V_Debug_PF_Title="stub",K_PlayFab_TimeOut="PFTimeout"; }
|
||||
namespace LitJson { public static class JsonMapper { public static string ToJson(object o)=>JsonConvert.SerializeObject(o);public static T ToObject<T>(string s)=>JsonConvert.DeserializeObject<T>(s); } }
|
||||
namespace GameCore { }
|
||||
// The real repository only needs the event marker; production model behavior is verified in Unity separately.
|
||||
namespace Activity.Data { public interface IEventData { } }
|
||||
namespace YiDun.Protector { public class YiDunManager { } }
|
||||
namespace PlayFab
|
||||
{
|
||||
@@ -81,6 +142,10 @@ namespace asap.playfab.async
|
||||
{
|
||||
internal static LoginResult Result;
|
||||
internal static Func<Task<LoginResult>> LoginOverride;
|
||||
internal static readonly List<UpdatePlayerStatisticsRequest> StatisticsRequests=new();
|
||||
internal static Func<Task<UpdatePlayerStatisticsResult>> StatisticsOverride;
|
||||
public static Task<UpdatePlayerStatisticsResult> UpdatePlayerStatisticsAsync(UpdatePlayerStatisticsRequest r){Effects.SDK++;StatisticsRequests.Add(r);return StatisticsOverride?.Invoke() ?? Task.FromResult(new UpdatePlayerStatisticsResult());}
|
||||
public static Task<GetLeaderboardResult> GetLeaderboardAsync(GetLeaderboardRequest r){Effects.SDK++;throw new NotSupportedException("Unmigrated leaderboard read.");}
|
||||
public static Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> call)=>call();
|
||||
public static Task<LoginResult> LoginWithCustomIDAsync(LoginWithCustomIDRequest r){Effects.SDK++;PlayFab.PlayFabClientAPI.Logged=Result!=null;return LoginOverride?.Invoke() ?? Task.FromResult(Result);}
|
||||
public static Task<LoginResult> LoginWithAndroidDeviceIDAsync(LoginWithAndroidDeviceIDRequest r)=>LoginWithCustomIDAsync(new());
|
||||
@@ -88,6 +153,7 @@ namespace asap.playfab.async
|
||||
public static Task<UpdateUserTitleDisplayNameResult> UpdateUserTitleDisplayNameAsync(UpdateUserTitleDisplayNameRequest r){Effects.SDK++;return Task.FromResult(new UpdateUserTitleDisplayNameResult{DisplayName=r.DisplayName});}
|
||||
public static Task<EmptyResponse> UpdateAvatarUrlAsync(UpdateAvatarUrlRequest r){Effects.SDK++;return Task.FromResult(new EmptyResponse());}
|
||||
public static Task<GetUserDataResult> GetUserDataAsync(GetUserDataRequest r){Effects.SDK++;return Task.FromResult(new GetUserDataResult{Data=new()});}
|
||||
public static Task<GetFriendsListResult> GetFriendsListAsync(GetFriendsListRequest r){Effects.SDK++;throw new InvalidOperationException("Unmigrated PF friend-list SDK must not be exercised by the UserID compatibility test.");}
|
||||
}
|
||||
public static class PlayFabProfilesAsyncAPI
|
||||
{ public static Task<PlayFab.ProfilesModels.SetProfileLanguageResponse> SetProfileLanguageAsync(PlayFab.ProfilesModels.SetProfileLanguageRequest r){Effects.SDK++;return Task.FromResult(new PlayFab.ProfilesModels.SetProfileLanguageResponse());} }
|
||||
@@ -117,7 +183,7 @@ public static class GEvent
|
||||
public sealed class Event:IDisposable { public Event AddContent(string k,object v)=>this;public void Dispose(){} }
|
||||
public static Event GameEvent(string name){Effects.Events++;return new();}
|
||||
public static Event LoginEvent()=>GameEvent("login");public static Event CompleteRegistrationEvent()=>GameEvent("registration");
|
||||
public static void SetProp(string k,object v){Effects.Events++;}public static void SetUserProp(string k,object v,bool b){Effects.Events++;}
|
||||
public static void SetProp(string k,object v){Effects.Events++;}public static void SetUserProp(string k,object v,bool b=true){Effects.Events++;}
|
||||
public static void SetProps(Dictionary<string,object> p){Effects.Events++;}public static void SetUserProps(Dictionary<string,object> p){Effects.Events++;}
|
||||
public static void OnLogin(string a,string b){Effects.Events++;}
|
||||
}
|
||||
@@ -125,15 +191,65 @@ namespace game
|
||||
{
|
||||
public interface IChatService { void WaitingForLogin(); }
|
||||
public interface ISettingService { string Lang {get;} }
|
||||
// CP3A's real Setting/Club/Notification instances are exercised by the Unity CLI Loop fixed case.
|
||||
public interface ILocalNotificationService { }
|
||||
public sealed class DefaultSettingService : ISettingService
|
||||
{
|
||||
public string Lang => "en";
|
||||
public static InvalidOperationException PromptFailure(string message) => new(message);
|
||||
PlayerDataStorage.PlayerKVGameSessionHandle bound;
|
||||
public void PreflightFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) { if(bound!=null || owner==null) throw new InvalidOperationException(); }
|
||||
public void BindFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) { PreflightFormalPromptOwner(owner); bound=owner; }
|
||||
public bool HasFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) => ReferenceEquals(bound,owner);
|
||||
}
|
||||
public sealed class ClubService
|
||||
{
|
||||
PlayerDataStorage.PlayerKVGameSessionHandle bound;
|
||||
public void PreflightFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) { if(bound!=null || owner==null) throw new InvalidOperationException(); }
|
||||
public void BindFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) { PreflightFormalPromptOwner(owner); bound=owner; }
|
||||
public bool HasFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) => ReferenceEquals(bound,owner);
|
||||
}
|
||||
public sealed class LocalNotificationService : ILocalNotificationService
|
||||
{
|
||||
DefaultSettingService bound;
|
||||
public void PreflightFormalPromptSettings(DefaultSettingService settings) { if(bound!=null || settings==null) throw new InvalidOperationException(); }
|
||||
public void BindFormalPromptSettings(DefaultSettingService settings) { PreflightFormalPromptSettings(settings); bound=settings; }
|
||||
public bool HasFormalPromptSettings(DefaultSettingService settings) => ReferenceEquals(bound,settings);
|
||||
}
|
||||
public class MMTService { public string Init(int value){Effects.Events++;return value.ToString();} }
|
||||
public class PlayerData { public int lv=1;public int Loads;public void LoadPlayerData(IDictionary<string,string> data){if(!ZZTimeHelper.Adjusted)throw new Exception("PF time not adjusted");Loads++;} }
|
||||
public class PlayerData { public string InfiniteBuildingLevel; public Action Save; public void SavePlayerData() => Save?.Invoke(); public int lv=1;public int Loads;public void LoadPlayerData(IDictionary<string,string> data){if(!ZZTimeHelper.Adjusted)throw new Exception("PF time not adjusted");Loads++;} }
|
||||
public class ChangeAvatarEvent{}
|
||||
public class GetClubPlayInfoEvent { public string id,name,avatarUrl; }
|
||||
public class ItemData {public int id,count;public ulong curCount;}
|
||||
public class ItemData {public int id,count,origin;public ulong curCount;}
|
||||
public class FishingEventData { public Dictionary<int,int> RankTargetCount=>throw new NotSupportedException("Activity reward is not under test."); }
|
||||
public class FishingStage { public bool IsNextQueue; public string NextQueueKey; }
|
||||
public class ChangeSource { public ChangeSource(int id,string a,string b,string c)=>throw new NotSupportedException("Activity reward is not under test."); }
|
||||
public interface IFaceUIService { void AddGiftFaceUI(int id, UITypes type); }
|
||||
public enum UITypes { EventRankPopupPanel }
|
||||
public enum RewardType {NormalOne}
|
||||
public class ShowData {public ShowData(){}public ShowData(List<ItemData> items,RewardType t){Effects.UI++;}}
|
||||
public class PlayerItemData {public int GetItemCount(int id)=>0;public void AddItem(ItemData i,object o){Effects.Rewards++;}}
|
||||
public class PlayerItemData { public Action<int, bool> Drop;
|
||||
public void AddItemByDrop(int id, object change) => Drop?.Invoke(id, false);
|
||||
public List<ItemData> AddItemByDrop(int id, object change, bool value) => throw new NotSupportedException("Activity reward is not under test.");
|
||||
public void AddItemByDropList(List<int> ids, object change) { foreach (var id in ids) Drop?.Invoke(id, false); }
|
||||
public void AddItemByDropListLureInflation(float? inflation, List<int> ids, object change) { foreach (var id in ids) Drop?.Invoke(id, true); }
|
||||
public int GetItemCount(int id)=>0;public void AddItem(ItemData i,object o){Effects.Rewards++;}}
|
||||
public class TapjoyHelper:UnityEngine.MonoBehaviour {public void Init(int balance,Action<string,int> earn,Action<string,int> changed){Effects.Helpers++;}}
|
||||
}
|
||||
namespace TapjoyUnity.Internal { public class TapjoyComponent:UnityEngine.MonoBehaviour{} }
|
||||
|
||||
|
||||
namespace UnityEngine.Assertions { public static class Assert { public static void IsNotNull(object value) { if(value==null) throw new Exception("Unexpected null in real mail cache."); } } }
|
||||
public sealed class RedPointManager { public static RedPointManager Instance { get; } = new(); public void SetRedPointState(string key, bool active, int count = 0) { } }
|
||||
public static class RedPointName { public const string Unread_Mail = "mail", Home_Invite="invite", Home_Friend_Add="friend-add"; }
|
||||
public static class LocalizationMgr { public static string GetText(string key) => key; }
|
||||
public sealed class OnLvChangeEvent { public int lvl; }
|
||||
public interface IRTService { IDisposable OnEvent<T>(Func<T, Task> action); }
|
||||
namespace UniRx
|
||||
{
|
||||
public static class MailProbeRxExtensions
|
||||
{
|
||||
public static IObservable<T> Skip<T>(this IObservable<T> source, int count) => source;
|
||||
public static IObservable<T> Where<T>(this IObservable<T> source, Func<T,bool> predicate) => source;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user