feat(upserver): 保存M2客户端KV迁移至本人统计写检查点

保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。
保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。

验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。
包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
This commit is contained in:
Liubing\LB
2026-09-30 19:19:05 +08:00
parent 1c0cdf7a99
commit 6813da149a
511 changed files with 151858 additions and 2376 deletions

View 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(); }
}
}
}

View File

@@ -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;
}
}

View File

@@ -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();

View 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");
}));
}
}

View File

@@ -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" />

View 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

View File

@@ -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();

View File

@@ -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>

View File

@@ -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."); }
}

View File

@@ -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);
}
}

View File

@@ -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;}
}
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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" />

View File

@@ -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;
}
}