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(raw); if (badProfile) raw["LastLoginTimeUtc"] = "corrupt"; int auth = 0, gets = 0, success = 0, applied = 0; var getStarted = new TaskCompletionSource(); var releaseGet = new TaskCompletionSource(); var setStarted = new TaskCompletionSource(); var releaseSet = new TaskCompletionSource(); 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(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>()) 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().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"); } }