#if UNITY_EDITOR using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading.Tasks; using Newtonsoft.Json; using UnityEditor; using UniRx; namespace game.PlayerDataStorage { // Fixed validation entry; business coverage uses original methods, resource tests remain in their own hosts. internal static class PlayerKVEditorValidationHost { private static T UserField(KVUserService user, string name) { return (T)typeof(KVUserService).GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).GetValue(user); } private static void SetUserField(KVUserService user, string name, object value) { typeof(KVUserService).GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(user, value); } internal static Task RunAsync(string caseId) { if (failedFormalMatchCleanup != null) { throw new InvalidOperationException("Previous formal matching cleanup is incomplete."); } if (caseId == "P15-FormalMatchState") { return RunFormalMatchmaking(true); } if (caseId == "P15-FormalMatchmaking") { return RunFormalMatchmaking(); } if (caseId == "P12C-StartupSubscriptions") { return RunFriends(true); } if (caseId == "P14-FormalRealtime") { return RunFormalReadAdapters(true); } if (caseId == "P12B-FormalReadAdapters") { return RunFormalReadAdapters(); } if (caseId == "P12A-OrdinaryCadence") { return RunOrdinarySave(false, true); } if (caseId == "P8D-Matchmaking") { return RunMatchmaking(); } if (caseId == "P8C-TitleNews-Cleanup") { return RunTitleNews(true); } if (caseId == "P8C-TitleNews") { return RunTitleNews(); } if (caseId == "P8B-Friends") { return RunFriends(); } if (caseId == "P8A-Leaderboard") { return RunLeaderboard(); } if (caseId == "P6B-OrdinarySave-Cleanup") { return RunOrdinarySave(true); } if (caseId == "P6B-OrdinarySave") { return RunOrdinarySave(); } if (caseId == "P6A-InitializationLastLogin") { return RunInitializationLastLogin(); } if (caseId == "M03B-LoginRetry") { return RunLoginRetry(); } if (caseId == "M03B-LoginRetry-Cleanup") { return CleanupLoginRetry(); } if (caseId == "M03C-CustomId") { return RunCustomId(); } if (caseId == "M03D-Destroy") { return RunDestroyBoundary(); } if (caseId == "M01-Immediate") { return RunImmediateConsumers(); } return Module02OriginalLoadTests.RunAsync(caseId); } private sealed class LeaderboardContext : System.Threading.SynchronizationContext { internal readonly List Errors = new List(); private readonly System.Threading.SynchronizationContext original; private TaskCompletionSource idle = new TaskCompletionSource(); internal int Active { get; private set; } internal int Started { get; private set; } internal int Posted { get; private set; } internal LeaderboardContext(System.Threading.SynchronizationContext original) { this.original = original ?? throw new InvalidOperationException("Editor context required."); idle.SetResult(true); } public override void OperationStarted() { Started++; if (Active++ == 0) { idle = new TaskCompletionSource(); } } public override void OperationCompleted() { if (--Active == 0) { idle.TrySetResult(true); } } public override void Post(System.Threading.SendOrPostCallback callback, object state) { Posted++; original.Post(_ => { var previous = Current; SetSynchronizationContext(this); try { callback(state); } catch (Exception ex) { Errors.Add(ex); } finally { SetSynchronizationContext(previous); } }, null); } internal Task WaitForIdle() { return idle.Task; } } private static object failedFormalReadCleanup; private sealed class FormalReadHttp : System.Net.Http.DelegatingHandler { internal static readonly string[] Routes = { "account/Login", "playerdata/Get", "leaderboard/GetLeaderboard", "friend/GetFriendsList", "commondata/GetTitleNews", "friend/SearchFriend" }; internal static readonly string[] Bodies = { "{\"playerId\":\"101\"}", "{}", "{\"statisticName\":\"LevelB\",\"startPosition\":0,\"maxResultsCount\":2}", "{}", "{}", "{\"PlayFabId\":\"102\"}" }; private readonly object gate; private readonly List admitted; private readonly List statuses; private readonly Dictionary responses; private readonly HashSet failures; private readonly System.Threading.CancellationToken lifetime; internal FormalReadHttp(System.Net.Http.HttpMessageHandler inner, object gate, List admitted, List statuses, Dictionary responses, HashSet failures, System.Threading.CancellationToken lifetime) : base(inner) { this.gate = gate; this.admitted = admitted; this.statuses = statuses; this.responses = responses; this.failures = failures; this.lifetime = lifetime; } protected override async Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken token) { try { var body = Newtonsoft.Json.Linq.JToken.Parse(await request.Content.ReadAsStringAsync()); int index; lock (gate) { index = admitted.Count; if (failures.Count != 0 || index >= Routes.Length || statuses.Count != index || request.Method != System.Net.Http.HttpMethod.Post || request.RequestUri.AbsoluteUri != PlayerKVRuntimeSelection.FormalApiUrl + Routes[index] || !(body is Newtonsoft.Json.Linq.JObject) || !Newtonsoft.Json.Linq.JToken.DeepEquals(body, Newtonsoft.Json.Linq.JToken.Parse(Bodies[index]))) { throw new InvalidOperationException("Formal read request rejected."); } admitted.Add(Routes[index]); } using (var linked = System.Threading.CancellationTokenSource.CreateLinkedTokenSource(token, lifetime)) { var response = await base.SendAsync(request, linked.Token).ConfigureAwait(false); try { var text = index >= 2 ? await response.Content.ReadAsStringAsync().ConfigureAwait(false) : null; lock (gate) { statuses.Add((int)response.StatusCode); if (response.StatusCode != System.Net.HttpStatusCode.OK) { failures.Add("HTTP" + (int)response.StatusCode); } if (text != null) { responses[Routes[index]] = text; } } return response; } catch { response.Dispose(); throw; } } } catch (Exception ex) { lock (gate) { failures.Add(ex.GetType().Name); } throw; } } } private static async Task RunFormalReadAdapters(bool verifyRealtime = false) { var instanceField = typeof(KVMgr).GetField("instance", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); void RequireIdle() { if (EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || asap.core.GContext.Ready || PlayerKVGameplayLoop.Enabled || instanceField.GetValue(null) != null || UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0 || PlayerKVShutdownRegistry.Process.Status != PlayerKVShutdownStatus.Ready) { throw new InvalidOperationException("Formal read validation requires an idle Editor."); } } RequireIdle(); if (running || failedFormalReadCleanup != null) { throw new InvalidOperationException("Previous validation has not closed."); } running = true; var originalContainer = asap.core.GContext.container; var originalEvents = ValidationContextAccess.Events; var originalContext = System.Threading.SynchronizationContext.Current; var gate = new object(); var admitted = new List(); var statuses = new List(); var responses = new Dictionary(); var transportFailures = new HashSet(); var errors = new List(); var coverage = new Dictionary(); var lifetime = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(120)); PlayerKVLoginContext context = null; PlayerKVAccountLease lease = null; CustomServerMgr server = null; KVUserService user = null; SignalRService realtime = null; var realtimeTasks = new List(); string realtimeStep = "not-started"; int realtimeReplies = 0; string failure = null; bool restored = false; int assertions = 0, offlineAssertions = 0, pfTransport = 0; EditorApplication.CallbackFunction observePlay = () => { if (EditorApplication.isPlayingOrWillChangePlaymode) { lifetime.Cancel(); } }; EditorApplication.update += observePlay; failedFormalReadCleanup = lifetime; void Check(bool value) { assertions++; if (!value) { throw new InvalidOperationException("Formal read assertion failed."); } } string ErrorType(Exception error) { return (error is System.Reflection.TargetInvocationException reflection && reflection.InnerException != null ? reflection.InnerException : error).GetType().Name; } async Task ObserveRealtime(Task task, bool cleanup = false) { realtimeTasks.Add(task); _ = task.ContinueWith(completed => { var observed = completed.Exception; }, System.Threading.CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); var timeout = Task.Delay(cleanup ? 10000 : 30000, cleanup ? System.Threading.CancellationToken.None : lifetime.Token); if (await Task.WhenAny(task, timeout) != task) { throw new TimeoutException("Realtime observation timed out."); } await task; } System.Net.Http.HttpRequestMessage Request(int index, string route = null, string body = null) { return new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Post, route ?? PlayerKVRuntimeSelection.FormalApiUrl + FormalReadHttp.Routes[index]) { Content = new System.Net.Http.StringContent(body ?? FormalReadHttp.Bodies[index], System.Text.Encoding.UTF8, "application/json") }; } async Task Offline(int scenario) { var calls = 0; var paths = new List(); var codes = new List(); var rejected = new HashSet(); using (var probe = new System.Net.Http.HttpMessageInvoker(new FormalReadHttp(new ValidationHttp(_ => { calls++; return new System.Net.Http.HttpResponseMessage(scenario == 6 ? System.Net.HttpStatusCode.Unauthorized : System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent("{}") }; }), new object(), paths, codes, new Dictionary(), rejected, lifetime.Token))) { if (scenario == 0) { for (int i = 0; i < 6; i++) { using (var request = Request(i)) using (var response = await probe.SendAsync(request, lifetime.Token)) { Check(response.StatusCode == System.Net.HttpStatusCode.OK); } } Check(calls == 6 && rejected.Count == 0); return; } if (scenario == 4 || scenario == 6) { using (var first = Request(0)) using (await probe.SendAsync(first, lifetime.Token)) { } } var blocked = false; using (var wrong = scenario == 1 ? Request(0, "http://example.invalid/account/Login") : scenario == 2 ? Request(0, PlayerKVRuntimeSelection.FormalApiUrl + "playerdata/Set") : scenario == 3 ? Request(0, body: "{\"playerId\":101}") : scenario == 5 || scenario == 6 ? Request(1) : Request(0)) { try { using (await probe.SendAsync(wrong, lifetime.Token)) { } } catch (InvalidOperationException) { blocked = true; } } Check(blocked && rejected.Count > 0 && calls == (scenario == 4 || scenario == 6 ? 1 : 0)); } } Newtonsoft.Json.Linq.JToken Field(Newtonsoft.Json.Linq.JToken item, string name) { return (item as Newtonsoft.Json.Linq.JObject)?.GetValue(name, StringComparison.OrdinalIgnoreCase); } System.Net.Http.HttpMessageHandler Transport(System.Net.CookieContainer cookies) { return new FormalReadHttp(new System.Net.Http.HttpClientHandler { CookieContainer = cookies, UseCookies = true, AllowAutoRedirect = false }, gate, admitted, statuses, responses, transportFailures, lifetime.Token); } void ReadyForRead() { RequireIdle(); lifetime.Token.ThrowIfCancellationRequested(); Check(context.IsCurrent && lease.IsCurrent && transportFailures.Count == 0); } try { for (int i = 0; i < 7; i++) { await Offline(i); } offlineAssertions = assertions; RequireIdle(); var api = new Uri(PlayerKVRuntimeSelection.FormalApiUrl); var root = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../CodexOutput/upserver/B3BatchMigration/M2/P6-2026-10-09/unity-formal-read-cache", Guid.NewGuid().ToString("N"))); context = new PlayerKVLoginContext("101", root, 1202, _ => !lifetime.IsCancellationRequested, api, () => new game.Account.S101AccountSession(api, Transport), () => new PlayerKVGameSessionBridge(Transport)); failedFormalReadCleanup = context; var prepared = await context.PrepareAsync(lifetime.Token); Check(prepared.Prepared && context.IsCurrent && context.AccountDescriptor?.NewlyCreated == false && prepared.Data.Count > 0); Check(context.TryAcquireAccountLease(PlayerKVControlledLoadCapability.CreateForGame(Transport), out lease)); var config = new RetryConfig("101"); config.Set("EventApiURL", api.AbsoluteUri); server = new CustomServerMgr(config, _ => new ValidationHttp(request => { pfTransport++; throw new InvalidOperationException("PF transport forbidden."); })); server.BindKVForGame(lease); server.EnableKVBusiness(lease.Epoch); user = new KVUserService { customServerMgr = server }; failedFormalReadCleanup = new object[] { context, server, user, lifetime }; var isolated = new asap.core.Container(); ValidationContextAccess.Use(isolated); ValidationContextAccess.Events = new asap.core.EventAggregator(); asap.core.ContainerExtensions.RegisterInstance(isolated, server); var flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic; if (verifyRealtime) { ReadyForRead(); realtime = new SignalRService(server, config); failedFormalReadCleanup = new object[] { context, server, user, lifetime, realtime, realtimeTasks }; var connectionField = typeof(SignalRService).GetField("_hubConnection", flags); realtimeStep = "connect"; await ObserveRealtime(realtime.Connect()); Check(realtime.IsConnected); var firstConnection = connectionField.GetValue(realtime); ReadyForRead(); realtimeStep = "duplicate-connect"; await ObserveRealtime(realtime.Connect()); Check(realtime.IsConnected && ReferenceEquals(firstConnection, connectionField.GetValue(realtime))); ReadyForRead(); realtimeStep = "first-test"; var firstReply = realtime.Publish("Test", "kv-migration", "101-first"); await ObserveRealtime(firstReply); Check(firstReply.Result == "OK"); realtimeReplies++; ReadyForRead(); realtimeStep = "disconnect"; await ObserveRealtime(realtime.Disconnect()); Check(!realtime.IsConnected); ReadyForRead(); realtimeStep = "reconnect"; await ObserveRealtime(realtime.Connect()); Check(realtime.IsConnected && !ReferenceEquals(firstConnection, connectionField.GetValue(realtime))); ReadyForRead(); realtimeStep = "second-test"; var secondReply = realtime.Publish("Test", "kv-migration", "101-second"); await ObserveRealtime(secondReply); Check(secondReply.Result == "OK"); realtimeReplies++; ReadyForRead(); realtimeStep = "release"; await ObserveRealtime(realtime.ReleaseAsync()); Check(!realtime.IsConnected); ReadyForRead(); await ObserveRealtime(realtime.Connect()); Check(!realtime.IsConnected && connectionField.GetValue(realtime) == null); ReadyForRead(); var afterRelease = realtime.Publish("Test", "kv-migration", "101-after-release"); await ObserveRealtime(afterRelease); Check(afterRelease.Result == null); ReadyForRead(); await ObserveRealtime(realtime.ReleaseAsync()); Check(admitted.Count == 2 && statuses.Count == 2 && statuses.All(value => value == 200) && pfTransport == 0 && transportFailures.Count == 0 && !realtime.IsConnected); realtimeStep = "completed"; } else { ReadyForRead(); var board = await new GameCore.LeadboardData { userService = user, customServerMgr = server }.GetLeadboardData("LevelB", 2); var boardRaw = Field(Newtonsoft.Json.Linq.JObject.Parse(responses[FormalReadHttp.Routes[2]]), "leaderboard") as Newtonsoft.Json.Linq.JArray; Check(board?.Leaderboard != null && boardRaw != null && board.Leaderboard.Count == boardRaw.Count); for (int i = 0; i < boardRaw.Count; i++) { var item = board.Leaderboard[i]; var raw = boardRaw[i]; var profile = Field(raw, "profile"); Check(item.PlayFabId == (string)Field(raw, "playFabId") && item.Position == (int)Field(raw, "position") && item.StatValue == (int)Field(raw, "statValue")); Check(item.Profile.DisplayName == (string)Field(profile, "displayName") && item.Profile.AvatarUrl == (string)Field(profile, "avatarUrl") && item.Profile.LastLogin == (DateTime?)Field(profile, "lastLogin")); } coverage["leaderboardCount"] = boardRaw.Count; coverage["leaderboardNonempty"] = boardRaw.Count > 0; ReadyForRead(); var friends = new FriendService { customServerMgr = server, userService = user }; var friendResult = await (Task)typeof(FriendService).GetMethod("GetKVFriendsListAsync", flags).Invoke(friends, null); var friendRaw = Field(Newtonsoft.Json.Linq.JObject.Parse(responses[FormalReadHttp.Routes[3]]), "friends") as Newtonsoft.Json.Linq.JArray; Check(friendResult?.Friends != null && friendRaw != null && friendResult.Friends.Count == friendRaw.Count); for (int i = 0; i < friendRaw.Count; i++) { var raw = Newtonsoft.Json.Linq.JObject.Parse((string)friendRaw[i]); var item = friendResult.Friends[i]; var profile = Field(raw, "profile"); Check(item.FriendPlayFabId == (string)Field(raw, "playFabId")); if (profile == null || profile.Type == Newtonsoft.Json.Linq.JTokenType.Null) { Check(item.Profile == null); } else { Check(item.Profile != null && item.Profile.DisplayName == (string)Field(profile, "displayName") && item.Profile.AvatarUrl == (string)Field(profile, "avatarUrl") && item.Profile.LastLogin == (DateTime?)Field(profile, "lastLogin")); var statistics = Field(profile, "statistics") as Newtonsoft.Json.Linq.JArray; Check(statistics == null ? item.Profile.Statistics == null : item.Profile.Statistics?.Count == statistics.Count); if (statistics != null) { for (int j = 0; j < statistics.Count; j++) { if (statistics[j].Type == Newtonsoft.Json.Linq.JTokenType.Null) { Check(item.Profile.Statistics[j] == null); } else { Check(item.Profile.Statistics[j].Name == (string)Field(statistics[j], "name") && item.Profile.Statistics[j].Value == (int)Field(statistics[j], "value")); } } } } } coverage["friendCount"] = friendRaw.Count; coverage["friendNonempty"] = friendRaw.Count > 0; ReadyForRead(); var news = new TileNewsManager(); var newsResult = await (Task)typeof(TileNewsManager).GetMethod("GetKVTitleNewsAsync", flags).Invoke(news, null); var newsRaw = Field(Newtonsoft.Json.Linq.JObject.Parse(responses[FormalReadHttp.Routes[4]]), "news") as Newtonsoft.Json.Linq.JArray; Check(newsResult?.News != null && newsRaw != null && newsResult.News.Count == newsRaw.Count); for (int i = 0; i < newsRaw.Count; i++) { var item = newsResult.News[i]; var raw = newsRaw[i]; if (raw.Type == Newtonsoft.Json.Linq.JTokenType.Null) { Check(item == null); } else { Check(item.NewsId == (string)Field(raw, "newsId") && item.Title == (string)Field(raw, "title") && item.Body == (string)Field(raw, "body") && item.Timestamp == (DateTime)Field(raw, "timestamp")); } } coverage["newsCount"] = newsRaw.Count; coverage["newsNonempty"] = newsRaw.Count > 0; ReadyForRead(); var level = await user.GetPlayLv("102"); var levelRaw = Newtonsoft.Json.Linq.JObject.Parse(responses[FormalReadHttp.Routes[5]]); var publicProfile = Field(levelRaw, "kvProfile") as Newtonsoft.Json.Linq.JObject; Check((int)Field(levelRaw, "code") > 0 && publicProfile != null); var levelToken = Field(publicProfile, "level"); var explicitLevel = levelToken != null && levelToken.Type != Newtonsoft.Json.Linq.JTokenType.Null; Check(!explicitLevel || levelToken.Type == Newtonsoft.Json.Linq.JTokenType.Integer); var expectedLevel = explicitLevel ? (int)levelToken : 1; Check(level.Item1 == "102" && level.Item2 == expectedLevel && expectedLevel >= 0 && user.GetPlayerLv("102") == expectedLevel); var cached = await user.GetPlayLv("102"); Check(cached.Equals(level) && admitted.Count == 6 && statuses.Count == 6 && statuses.All(value => value == 200) && pfTransport == 0 && transportFailures.Count == 0); coverage["publicLevelExplicit"] = explicitLevel; coverage["publicLevelDefaultApplied"] = !explicitLevel; } } catch (Exception ex) { failure = ErrorType(ex); } finally { async Task Cleanup(Func action) { try { await action(); } catch (Exception ex) { errors.Add(ErrorType(ex)); } } if (realtime != null) { await Cleanup(() => ObserveRealtime(realtime.ReleaseAsync(), true)); await Cleanup(async () => { var all = Task.WhenAll(realtimeTasks.ToArray()); try { await ObserveRealtime(all, true); } catch { if (!all.IsCompleted) { throw; } } }); } await Cleanup(() => { lifetime.Cancel(); return Task.CompletedTask; }); await Cleanup(() => { server?.DisableKVBusiness(); return Task.CompletedTask; }); await Cleanup(() => context?.CloseAsync() ?? Task.CompletedTask); await Cleanup(() => server?.ClearKVForGame() ?? Task.CompletedTask); await Cleanup(() => user?.CloseKVForValidationAsync() ?? Task.CompletedTask); await Cleanup(() => { ValidationContextAccess.Use(originalContainer); return Task.CompletedTask; }); await Cleanup(() => { ValidationContextAccess.Events = originalEvents; return Task.CompletedTask; }); await Cleanup(() => { System.Threading.SynchronizationContext.SetSynchronizationContext(originalContext); return Task.CompletedTask; }); await Cleanup(() => { EditorApplication.update -= observePlay; return Task.CompletedTask; }); await Cleanup(() => { lifetime.Dispose(); return Task.CompletedTask; }); restored = errors.Count == 0 && (context == null || !context.IsCurrent) && (lease == null || !lease.IsCurrent) && ReferenceEquals(asap.core.GContext.container, originalContainer) && ReferenceEquals(ValidationContextAccess.Events, originalEvents) && ReferenceEquals(System.Threading.SynchronizationContext.Current, originalContext) && instanceField.GetValue(null) == null; restored = restored && (realtime == null || !realtime.IsConnected) && realtimeTasks.All(task => task.IsCompleted); if (restored) { failedFormalReadCleanup = null; } running = false; } if (verifyRealtime) { return JsonConvert.SerializeObject(new { success = failure == null && restored, caseId = "P14-FormalRealtime", assertions, offlineAssertions, routes = admitted, statuses, realtimeStep, realtimeReplies, pfTransport, restored, failure, cleanupErrors = errors, transportFailures, formalRequests = admitted.Count, businessWrites = 0, remainingRealtimeTasks = realtimeTasks.Count(task => !task.IsCompleted), actualSignalRService = true, automaticReconnectVerified = false, naturalUIVerified = false }); } return JsonConvert.SerializeObject(new { success = failure == null && restored, caseId = "P12B-FormalReadAdapters", assertions, offlineAssertions, routes = admitted, statuses, coverage, pfTransport, restored, failure, cleanupErrors = errors, transportFailures, formalRequests = admitted.Count, businessWrites = 0, actualTaskAdapters = true, naturalUIVerified = false }); } private static object failedFormalMatchCleanup; private sealed class FormalMatchHttp : System.Net.Http.DelegatingHandler { private readonly Func admit; private readonly Action receive; internal FormalMatchHttp(System.Net.Http.HttpMessageHandler inner, Func admit, Action receive) { InnerHandler = inner; this.admit = admit; this.receive = receive; } protected override async Task SendAsync( System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken token) { var body = await request.Content.ReadAsStringAsync(); var admittedToken = admit(request, body); using (var linked = System.Threading.CancellationTokenSource.CreateLinkedTokenSource(token, admittedToken)) { var response = await base.SendAsync(request, linked.Token); try { var text = await response.Content.ReadAsStringAsync(); receive(request.RequestUri.AbsolutePath.TrimStart('/'), (int)response.StatusCode, text); return response; } catch { response.Dispose(); throw; } } } } private static async Task RunFormalMatchmaking(bool stateOnly = false) { var began = DateTime.UtcNow; var mainDeadline = began.AddSeconds(100); var finalDeadline = began.AddSeconds(170); var instance = typeof(KVMgr).GetField("instance", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); void Idle() { if (EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || asap.core.GContext.Ready || PlayerKVGameplayLoop.Enabled || instance.GetValue(null) != null || UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0 || PlayerKVShutdownRegistry.Process.Status != PlayerKVShutdownStatus.Ready) { throw new InvalidOperationException("Formal matchmaking requires an idle Editor."); } } Idle(); if (running || failedFormalReadCleanup != null || failedMatchmakingCleanup != null || failedFormalMatchCleanup != null) { throw new InvalidOperationException("Previous validation has not closed."); } running = true; var originalContainer = asap.core.GContext.container; var originalEvents = ValidationContextAccess.Events; var originalContext = System.Threading.SynchronizationContext.Current; var observed = new LeaderboardContext(originalContext); var identity = new System.Threading.CancellationTokenSource(); var main = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(100)); System.Threading.CancellationTokenSource cleanupToken = null; var stateGate = new object(); var phase = main.Token; var phaseDeadline = mainDeadline; var contexts = new PlayerKVLoginContext[2]; var leases = new PlayerKVAccountLease[2]; var servers = new CustomServerMgr[2]; var containers = new asap.core.Container[2]; var expected = new[] { new Queue<(string route, Newtonsoft.Json.Linq.JToken body)>(), new Queue<(string route, Newtonsoft.Json.Linq.JToken body)>() }; var tickets = new Dictionary(); var unknown = new int[2]; var tasks = new List(); var requests = new List(); var statuses = new List(); var errors = new List(); var queue = "P15_" + Guid.NewGuid().ToString("N"); var api = new Uri(PlayerKVRuntimeSelection.FormalApiUrl); var flags = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static; var requestMethod = typeof(EventFishingDuelPanel).GetMethod("RequestKVMatch", flags); var readMatch = (Func)Delegate.CreateDelegate( typeof(Func), typeof(EventFishingDuelPanel).GetMethod("ReadKVMatch", flags)); int assertions = 0, offlineAssertions = 0, pfTransport = 0, matchesRead = 0; string failure = null; bool restored = false; EditorApplication.CallbackFunction watch = () => { if (EditorApplication.isPlayingOrWillChangePlaymode) { main.Cancel(); } }; EditorApplication.update += watch; failedFormalMatchCleanup = new object[] { contexts, leases, servers, containers, observed, tasks, identity, main, tickets, unknown, queue, stateGate, expected, requests, statuses, errors }; void Check(bool value) { assertions++; if (!value) { throw new InvalidOperationException("Formal matchmaking assertion failed."); } } void Track(Task task) { tasks.Add(task); _ = task.ContinueWith(done => { var seen = done.Exception; }, System.Threading.CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } async Task Until(Task task, DateTime deadline) { Track(task); var remaining = deadline - DateTime.UtcNow; if (remaining <= TimeSpan.Zero || await Task.WhenAny(task, Task.Delay(remaining)) != task) { throw new TimeoutException("Formal matchmaking observation timed out."); } await task; } async Task Drain(DateTime deadline) { await Until(observed.WaitForIdle(), deadline); var posted = new TaskCompletionSource(); observed.Post(_ => posted.TrySetResult(true), null); await Until(posted.Task, deadline); } void Permit(int who, string route, object body) { var value = Newtonsoft.Json.Linq.JToken.FromObject(body); lock (stateGate) { expected[who].Enqueue((route, value)); } } int Pending(int who) { lock (stateGate) { return expected[who].Count; } } void ClearPermits(int who) { lock (stateGate) { expected[who].Clear(); } } System.Threading.CancellationToken Admit(int who, System.Net.Http.HttpRequestMessage request, string body) { var value = Newtonsoft.Json.Linq.JToken.Parse(body); lock (stateGate) { phase.ThrowIfCancellationRequested(); if (expected[who].Count == 0) { throw new InvalidOperationException("Unplanned formal request."); } var next = expected[who].Dequeue(); if (request.Method != System.Net.Http.HttpMethod.Post || request.RequestUri.AbsoluteUri != api.AbsoluteUri + next.route || !Newtonsoft.Json.Linq.JToken.DeepEquals(next.body, value)) { throw new InvalidOperationException("Formal request does not match its permit."); } if (next.route == "matchmaking/CreateMatchmakingTicket") { unknown[who]++; } return phase; } } void Receive(int who, string route, int status, string text) { string ticket = null; if (route == "matchmaking/CreateMatchmakingTicket" && status == 200) { var value = Newtonsoft.Json.Linq.JObject.Parse(text); if ((int?)value["code"] == 1) { ticket = (string)value["ticketId"]; } } lock (stateGate) { requests.Add(new { account = 101 + who, route, status }); statuses.Add(status); if (!string.IsNullOrEmpty(ticket) && !tickets.ContainsKey(ticket)) { tickets.Add(ticket, (who, "unconfirmed")); unknown[who]--; } } } (int owner, string status) Ticket(string id) { lock (stateGate) { return tickets[id]; } } (object[] requests, KeyValuePair[] tickets, int unknown, int[] statuses) Snapshot() { lock (stateGate) { return (requests.ToArray(), tickets.ToArray(), unknown.Sum(), statuses.ToArray()); } } System.Net.Http.HttpMessageHandler Transport(int who, System.Net.CookieContainer cookies) { return new FormalMatchHttp(new System.Net.Http.HttpClientHandler { CookieContainer = cookies, UseCookies = true, AllowAutoRedirect = false }, (request, body) => Admit(who, request, body), (route, status, text) => Receive(who, route, status, text)); } async Task Call(int who, string action, object body, Func parse) { Idle(); phase.ThrowIfCancellationRequested(); Check(DateTime.UtcNow < phaseDeadline && observed.Active == 0 && contexts[who].IsCurrent && leases[who].IsCurrent && Pending(who) == 0); ValidationContextAccess.Use(containers[who]); Permit(who, "matchmaking/" + action, body); var result = new MatchCallback(); var started = observed.Started; System.Threading.SynchronizationContext.SetSynchronizationContext(observed); try { requestMethod.MakeGenericMethod(typeof(T)).Invoke(null, new object[] { action, body, parse, new Action(value => { result.Success++; result.Value = value; }), new Action(_ => { result.Failure++; }) }); } finally { System.Threading.SynchronizationContext.SetSynchronizationContext(originalContext); } Check(observed.Started == started + 1); await Drain(phaseDeadline); Check(result.Success == 1 && result.Failure == 0 && observed.Errors.Count == 0 && Pending(who) == 0); return result.Value; } object Attributes(int who) { return new { Tier = new[] { "1" }, Map = new[] { "p15-common-map" }, Group = new[] { "p15" }, Details = JsonConvert.SerializeObject(new { ID = (101 + who).ToString(CultureInfo.InvariantCulture), Name = "KV migration test", Avatar = "", RandScore = 0, Mag = 1 }) }; } async Task Create(int who) { var result = await Call(who, "CreateMatchmakingTicket", new { queueName = queue, giveUpAfterSeconds = 60, attributes = Attributes(who) }, value => value.ToObject()); Check(!string.IsNullOrEmpty(result.TicketId) && Snapshot().tickets.Any(item => item.Key == result.TicketId && item.Value.owner == who)); return result.TicketId; } async Task ReadTicket(string ticket) { var owner = Ticket(ticket).owner; var result = await Call(owner, "GetMatchmakingTicket", new { queueName = queue, ticketId = ticket }, value => value.ToObject()); lock (stateGate) { tickets[ticket] = (owner, result.Status); } return result; } async Task Cancel(string ticket) { await Call(Ticket(ticket).owner, "CancelMatchmakingTicket", new { queueName = queue, ticketId = ticket }, value => value.ToObject()); } async Task Cleanup(Func action) { try { await action(); } catch (Exception error) { errors.Add(error.GetType().Name); } } try { for (int scenario = 0; scenario < 5; scenario++) { ClearPermits(0); Permit(0, "account/Login", new { playerId = "101" }); int sent = 0; using (var probe = new System.Net.Http.HttpMessageInvoker(new FormalMatchHttp(new ValidationHttp(_ => { sent++; return ValidationJson(new { }); }), (request, body) => Admit(0, request, body), (_, __, ___) => { }))) { var route = scenario == 0 ? "http://example.invalid/account/Login" : api.AbsoluteUri + (scenario == 1 ? "playerdata/Set" : "account/Login"); var body = scenario == 2 ? "{\"playerId\":\"102\"}" : "{\"playerId\":\"101\"}"; bool blocked = false; using (var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Post, route) { Content = new System.Net.Http.StringContent(body) }) { try { using (await probe.SendAsync(request, phase)) { } } catch (InvalidOperationException) { blocked = true; } } if (scenario == 4) { using (var duplicate = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Post, route) { Content = new System.Net.Http.StringContent(body) }) { try { using (await probe.SendAsync(duplicate, phase)) { } } catch (InvalidOperationException) { blocked = true; } } } Check(scenario < 3 ? blocked && sent == 0 : scenario == 3 ? !blocked && sent == 1 : blocked && sent == 1); } } ClearPermits(0); // Exercise the same receive/snapshot functions while a background response arrives. Receive(0, "playerdata/Get", 200, "{}"); var held = Snapshot(); var late = Task.Run(() => { for (int i = 0; i < 200; i++) { Receive(1, "playerdata/Get", 200, "{}"); } }); for (int i = 0; i < 200; i++) { var copy = Snapshot(); Check(copy.requests.Length == copy.statuses.Length); JsonConvert.SerializeObject(copy.requests); } await Until(late, phaseDeadline); Check(held.requests.Length == 1 && Snapshot().requests.Length == 201); Check(held.tickets.Length == 0 && held.unknown == 0); // A permit captures the original phase token even after the phase changes. Permit(0, "account/Login", new { playerId = "101" }); using (var probe = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Post, api.AbsoluteUri + "account/Login")) { var captured = Admit(0, probe, "{\"playerId\":\"101\"}"); using (var nextPhase = new System.Threading.CancellationTokenSource()) { lock (stateGate) { phase = nextPhase.Token; } nextPhase.Cancel(); Check(captured == main.Token && !captured.IsCancellationRequested); lock (stateGate) { phase = main.Token; } } } lock (stateGate) { requests.Clear(); statuses.Clear(); } offlineAssertions = assertions; if (!stateOnly) { ValidationContextAccess.Events = new asap.core.EventAggregator(); for (int who = 0; who < 2; who++) { int owner = who; string id = (101 + who).ToString(CultureInfo.InvariantCulture); Permit(who, "account/Login", new { playerId = id }); Permit(who, "playerdata/Get", new { }); var root = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../CodexOutput/upserver/B3BatchMigration/M2/P6-2026-10-09/unity-formal-match-cache", Guid.NewGuid().ToString("N"))); contexts[who] = new PlayerKVLoginContext(id, root, 1500 + who, _ => !identity.IsCancellationRequested, api, () => new game.Account.S101AccountSession(api, cookies => Transport(owner, cookies)), () => new PlayerKVGameSessionBridge(cookies => Transport(owner, cookies))); var prepare = contexts[who].PrepareAsync(identity.Token); await Until(prepare, phaseDeadline); Check(prepare.Result.Prepared && contexts[who].IsCurrent && contexts[who].AccountDescriptor?.NewlyCreated == false && prepare.Result.Data.Count > 0 && Pending(who) == 0); Check(contexts[who].TryAcquireAccountLease(PlayerKVControlledLoadCapability.CreateForGame(cookies => Transport(owner, cookies)), out leases[who])); var config = new RetryConfig(id); config.Set("EventApiURL", api.AbsoluteUri); servers[who] = new CustomServerMgr(config, _ => new ValidationHttp(request => { pfTransport++; throw new InvalidOperationException("PF transport forbidden."); })); servers[who].BindKVForGame(leases[who]); servers[who].EnableKVBusiness(leases[who].Epoch); containers[who] = new asap.core.Container(); asap.core.ContainerExtensions.RegisterInstance(containers[who], servers[who]); } var a = await Create(0); Check((await ReadTicket(a)).Status == "WaitingForMatch"); await Cancel(a); var canceled = await ReadTicket(a); Check(canceled.Status == "Canceled" && canceled.CancellationReasonString == "Requested"); var b = await Create(0); var c = await Create(1); var first = await ReadTicket(b); var second = await ReadTicket(c); Check(first.Status == "Matched" && second.Status == "Matched" && !string.IsNullOrEmpty(first.MatchId) && first.MatchId == second.MatchId); for (int who = 0; who < 2; who++) { var match = await Call(who, "GetMatch", new { queueName = queue, matchId = first.MatchId }, readMatch); Check(match.MatchId == first.MatchId && match.Members?.Count == 2); Check(match.Members.Select(member => member.Entity.Id).OrderBy(value => value).SequenceEqual(new[] { "101", "102" })); foreach (var member in match.Members) { int owner = member.Entity.Id == "101" ? 0 : 1; Check(Newtonsoft.Json.Linq.JToken.DeepEquals(Newtonsoft.Json.Linq.JToken.FromObject(member.Attributes.DataObject), Newtonsoft.Json.Linq.JToken.FromObject(Attributes(owner)))); } matchesRead++; } var complete = Snapshot(); Check(complete.requests.Length == 14 && complete.statuses.All(status => status == 200) && pfTransport == 0 && complete.unknown == 0); } } catch (Exception error) { failure = error.GetType().Name; } finally { main.Cancel(); var drainDeadline = DateTime.UtcNow.AddSeconds(20); if (drainDeadline > finalDeadline) { drainDeadline = finalDeadline; } await Cleanup(() => Drain(drainDeadline)); if (observed.Active == 0) { cleanupToken = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(40)); lock (stateGate) { phase = cleanupToken.Token; } phaseDeadline = DateTime.UtcNow.AddSeconds(40); if (phaseDeadline > finalDeadline.AddSeconds(-10)) { phaseDeadline = finalDeadline.AddSeconds(-10); } for (int who = 0; who < 2; who++) { ClearPermits(who); } foreach (var ticket in Snapshot().tickets.Select(item => item.Key)) { if (Ticket(ticket).status == "Canceled" || Ticket(ticket).status == "Matched") { continue; } await Cleanup(async () => { var current = await ReadTicket(ticket); if (current.Status == "WaitingForMatch") { await Cancel(ticket); current = await ReadTicket(ticket); } Check(current.Status == "Canceled" || current.Status == "Matched"); }); if (observed.Active != 0 || DateTime.UtcNow >= phaseDeadline) { break; } } } cleanupToken?.Cancel(); identity.Cancel(); var closing = new List(); for (int who = 0; who < 2; who++) { int owner = who; var close = Cleanup(async () => { await Cleanup(() => { servers[owner]?.DisableKVBusiness(); return Task.CompletedTask; }); await Cleanup(() => contexts[owner]?.CloseAsync() ?? Task.CompletedTask); await Cleanup(() => servers[owner]?.ClearKVForGame() ?? Task.CompletedTask); }); Track(close); closing.Add(close); } var end = DateTime.UtcNow.AddSeconds(10); if (end > finalDeadline) { end = finalDeadline; } await Cleanup(() => Until(Task.WhenAll(closing), end)); await Cleanup(() => Drain(end)); if (observed.Active == 0 && tasks.All(task => task.IsCompleted)) { ValidationContextAccess.Use(originalContainer); ValidationContextAccess.Events = originalEvents; System.Threading.SynchronizationContext.SetSynchronizationContext(originalContext); restored = errors.Count == 0 && observed.Errors.Count == 0 && contexts.All(value => value == null || !value.IsCurrent) && leases.All(value => value == null || !value.IsCurrent) && instance.GetValue(null) == null; } EditorApplication.update -= watch; if (restored) { failedFormalMatchCleanup = null; identity.Dispose(); main.Dispose(); cleanupToken?.Dispose(); } running = false; } var final = Snapshot(); var errorSnapshot = errors.ToArray(); var taskSnapshot = tasks.ToArray(); var callbackSnapshot = observed.Errors.Select(value => value.GetType().Name).ToArray(); var waiting = final.tickets.Count(item => item.Value.status != "Canceled" && item.Value.status != "Matched"); var serverCleanupComplete = final.unknown == 0 && waiting == 0; return JsonConvert.SerializeObject(new { success = failure == null && errorSnapshot.Length == 0 && restored && serverCleanupComplete, caseId = stateOnly ? "P15-FormalMatchState" : "P15-FormalMatchmaking", assertions, offlineAssertions, requests = final.requests, pfTransport, businessWrites = 0, stateOnly, matchesRead, unknownCreations = final.unknown, waitingTickets = waiting, retainedTerminalTickets = final.tickets.Length - waiting, retainedMatchRecords = final.tickets.Any(item => item.Value.status == "Matched") ? 1 : 0, serverCleanupComplete, restored, failure, cleanupErrors = errorSnapshot, callbackErrors = callbackSnapshot, remainingCallbacks = observed.Active, remainingTasks = taskSnapshot.Count(task => !task.IsCompleted), isolatedQueue = queue, tickets = final.tickets.Select(item => new { id = item.Key, account = 101 + item.Value.owner, status = item.Value.status }).ToArray(), controlledAttributes = true, naturalUIVerified = false, globalMapRuleVerified = false }); } private static object failedMatchmakingCleanup; private sealed class MatchCallback { internal int Success; internal int Failure; internal T Value; } private static async Task RunMatchmaking() { const System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic; var instanceField = typeof(KVMgr).GetField("instance", flags); if (running || failedMatchmakingCleanup != null || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || asap.core.GContext.Ready || PlayerKVGameplayLoop.Enabled || instanceField.GetValue(null) != null || UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0) { throw new InvalidOperationException("Matchmaking validation requires an idle isolated Editor."); } var originalContainer = asap.core.GContext.container; var originalEvents = ValidationContextAccess.Events; var originalContext = System.Threading.SynchronizationContext.Current; var observed = new LeaderboardContext(originalContext); var errors = new List(); var expectedErrors = new HashSet(); var releases = new List>(); var holds = new Queue>(); PlayerKVLoginContext context = null; CustomServerMgr server = null; KVUserService user = null; string failure = null; bool restored = false; int assertions = 0, sends = 0, auth = 0, gets = 0, pfTransport = 0; var requestMethod = typeof(EventFishingDuelPanel).GetMethod("RequestKVMatch", flags); var readMatch = (Func) Delegate.CreateDelegate(typeof(Func), typeof(EventFishingDuelPanel).GetMethod("ReadKVMatch", flags)); running = true; void Check(bool condition, string detail) { assertions++; if (!condition) { throw new InvalidOperationException("Matchmaking: " + detail); } } MatchCallback Start(string action, object request, Func parse, Exception successError = null, Exception failureError = null) { var record = new MatchCallback(); Action success = value => { record.Success++; record.Value = value; if (successError != null) { throw successError; } }; Action failed = error => { record.Failure++; Check(!string.IsNullOrEmpty(error), "failure is explained"); if (failureError != null) { throw failureError; } }; var started = observed.Started; System.Threading.SynchronizationContext.SetSynchronizationContext(observed); try { requestMethod.MakeGenericMethod(typeof(T)).Invoke(null, new object[] { action, request, parse, success, failed }); } finally { System.Threading.SynchronizationContext.SetSynchronizationContext(originalContext); } Check(observed.Started == started + 1, "real callback transport async void entered once"); return record; } async Task Drain() { await observed.WaitForIdle(); // Queue after async-void exception posts, so their observer has also run before assertions/restoration. var drained = new TaskCompletionSource(); observed.Post(_ => drained.TrySetResult(true), null); await drained.Task; } try { var api = new Uri(PlayerKVRuntimeSelection.FormalApiUrl); var config = new RetryConfig("101"); config.Set("EventApiURL", api.AbsoluteUri); string body = "{\"code\":1,\"ticketId\":\"ticket-101\"}"; var status = System.Net.HttpStatusCode.OK; string lastRoute = null; Newtonsoft.Json.Linq.JObject sent = null; var root = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../CodexOutput/upserver/B3BatchMigration/M2/P6-2026-10-09/unity-matchmaking-cache", Guid.NewGuid().ToString("N"))); context = new PlayerKVLoginContext("101", root, 804, _ => true, api, () => new game.Account.S101AccountSession(api, cookies => new ValidationHttp(request => { Check(request.RequestUri == new Uri(api, "account/Login"), "fake authentication route"); auth++; cookies.Add(api, new System.Net.Cookie("sid", "synthetic")); return ValidationJson(new { state = 0, internalId = 101, newCreate = false, createTime = "2026-01-01T00:00:00Z", svrTime = "2026-10-09T00:00:00Z" }); })), () => new PlayerKVGameSessionBridge(_ => new ValidationHttp(request => { Check(request.RequestUri == new Uri(api, "playerdata/Get"), "fake own KV route"); gets++; return ValidationJson(new { data = new Dictionary() }); }))); Check((await context.PrepareAsync()).Prepared, "real context prepared"); Check(context.TryAcquireAccountLease(PlayerKVControlledLoadCapability.CreateForGame(_ => new ValidationHttp(null, async request => { Check(request.Method == System.Net.Http.HttpMethod.Post && request.RequestUri.GetLeftPart(UriPartial.Authority) == "http://s101.local:18080", "fixed POST origin"); lastRoute = request.RequestUri.AbsolutePath; Check(new[] { "/matchmaking/CreateMatchmakingTicket", "/matchmaking/GetMatchmakingTicket", "/matchmaking/CancelMatchmakingTicket", "/matchmaking/GetMatch" }.Contains(lastRoute), "only four published routes"); sends++; sent = Newtonsoft.Json.Linq.JObject.Parse(await request.Content.ReadAsStringAsync()); if (holds.Count != 0) { return await holds.Dequeue().Task; } return new System.Net.Http.HttpResponseMessage(status) { Content = new System.Net.Http.StringContent(body) }; })), out var lease), "real account lease"); System.Threading.SynchronizationContext.SetSynchronizationContext(observed); try { server = new CustomServerMgr(config, _ => new ValidationHttp(request => { pfTransport++; throw new InvalidOperationException("PF transport forbidden"); })); } finally { System.Threading.SynchronizationContext.SetSynchronizationContext(originalContext); } server.BindKVForGame(lease); server.EnableKVBusiness(lease.Epoch); user = new KVUserService { customServerMgr = server }; var isolated = new asap.core.Container(); ValidationContextAccess.Use(isolated); ValidationContextAccess.Events = new asap.core.EventAggregator(); asap.core.ContainerExtensions.RegisterInstance(isolated, user); asap.core.ContainerExtensions.RegisterInstance(isolated, server); var details = "{\"ID\":\"101\",\"Name\":\"name\",\"Avatar\":\"avatar\",\"RandScore\":1,\"Mag\":2}"; var createRequest = new { queueName = "DuelPVP", giveUpAfterSeconds = 30, attributes = new { Tier = new[] { "1" }, Map = new[] { "10", "11" }, Group = new[] { "0" }, Details = details } }; var ticketRequest = new { queueName = "DuelPVP", ticketId = "ticket-101" }; var matchRequest = new { queueName = "DuelPVP", matchId = "match-101-102" }; Func createParse = value => value.ToObject(); var created = Start("CreateMatchmakingTicket", createRequest, createParse); await Drain(); Check(created.Success == 1 && created.Failure == 0 && created.Value.TicketId == "ticket-101", "create callback exact ticket"); Check(lastRoute.EndsWith("CreateMatchmakingTicket") && sent.Count == 3 && (string)sent["queueName"] == "DuelPVP" && (int)sent["giveUpAfterSeconds"] == 30 && (string)sent["attributes"]["Details"] == details && sent["attributes"]["Tier"] is Newtonsoft.Json.Linq.JArray && (string)sent["attributes"]["Map"][1] == "11" && (string)sent["attributes"]["Group"][0] == "0", "original attributes and Details string"); foreach (var ticketStatus in new[] { "WaitingForMatch", "Matched", "Canceled", "UnknownStatus" }) { body = JsonConvert.SerializeObject(new { code = 1, status = ticketStatus, matchId = "match-101-102", cancellationReasonString = "Requested" }); var ticket = Start("GetMatchmakingTicket", ticketRequest, value => value.ToObject()); await Drain(); Check(ticket.Success == 1 && ticket.Failure == 0 && ticket.Value.Status == ticketStatus && ticket.Value.MatchId == "match-101-102" && ticket.Value.CancellationReasonString == "Requested", "status/reason preserved, no new state rules"); Check(sent.Count == 2 && (string)sent["ticketId"] == "ticket-101", "ticket request fields"); } body = "{\"code\":1}"; var canceled = Start("CancelMatchmakingTicket", ticketRequest, value => value.ToObject()); await Drain(); Check(canceled.Success == 1 && canceled.Failure == 0 && lastRoute.EndsWith("CancelMatchmakingTicket"), "cancel callback and route"); body = JsonConvert.SerializeObject(new { code = 1, matchId = "match-101-102", members = new[] { new { playerId = "101", attributes = new { Tier = new[] { "1" }, Map = new[] { "10", "11" }, Group = new[] { "0" }, Details = details } }, new { playerId = "102", attributes = new { Tier = new[] { "1" }, Map = new[] { "11" }, Group = new[] { "0" }, Details = "{\"ID\":\"102\"}" } } } }); var validMatch = body; var matched = Start("GetMatch", matchRequest, readMatch); await Drain(); Check(matched.Success == 1 && matched.Failure == 0 && matched.Value.Members.Count == 2 && matched.Value.Members[0].Entity.Id == "101" && matched.Value.Members[1].Entity.Id == "102", "new UserID mapped without PF credentials"); var attributes = (Newtonsoft.Json.Linq.JObject)matched.Value.Members[1].Attributes.DataObject; Check(attributes["Details"].Type == Newtonsoft.Json.Linq.JTokenType.String && (string)attributes["Details"] == "{\"ID\":\"102\"}" && (string)attributes["Map"][0] == "11" && attributes["map"] == null && (string)sent["matchId"] == "match-101-102", "exact member attributes and requested match"); body = validMatch.Replace("[\"11\"]", "[\"99\"]"); matched = Start("GetMatch", matchRequest, readMatch); await Drain(); Check((string)((Newtonsoft.Json.Linq.JObject)matched.Value.Members[1].Attributes.DataObject)["Map"][0] == "99", "disjoint maps not silently repaired by client mapping"); foreach (var members in new[] { "", ",\"members\":null", ",\"members\":[]" }) { body = "{\"code\":1,\"matchId\":\"m\"" + members + "}"; matched = Start("GetMatch", matchRequest, readMatch); await Drain(); Check(matched.Success == 1 && matched.Failure == 0 && (members.EndsWith("[]") ? matched.Value.Members.Count == 0 : matched.Value.Members == null), "missing/null/empty original members behavior"); } foreach (var invalid in new[] { "", "null", "[]", "{", "{\"code\":0}", "{}", "{\"code\":\"bad\"}", "{\"code\":1,\"members\":{}}", "{\"code\":1,\"members\":[null]}", "{\"code\":1,\"members\":[{\"playerId\":\"102\"}]}", "{\"code\":1,\"members\":[{\"attributes\":{}}]}" }) { body = invalid; matched = Start("GetMatch", matchRequest, readMatch); await Drain(); Check(matched.Success == 0 && matched.Failure == 1, "invalid response yields exactly one failure"); } body = validMatch; foreach (var http in new[] { System.Net.HttpStatusCode.InternalServerError, System.Net.HttpStatusCode.Conflict }) { status = http; canceled = Start("CancelMatchmakingTicket", ticketRequest, value => value.ToObject()); await Drain(); Check(canceled.Success == 0 && canceled.Failure == 1, "HTTP failure/409 stays failure, no retry"); } status = System.Net.HttpStatusCode.OK; var successError = new InvalidOperationException("Expected P8D success callback exception"); expectedErrors.Add(successError); matched = Start("GetMatch", matchRequest, readMatch, successError); await Drain(); Check(matched.Success == 1 && matched.Failure == 0 && observed.Errors.Contains(successError), "success callback error is not converted into second failure"); body = "{\"code\":0}"; var failureError = new InvalidOperationException("Expected P8D failure callback exception"); expectedErrors.Add(failureError); matched = Start("GetMatch", matchRequest, readMatch, null, failureError); await Drain(); Check(matched.Success == 0 && matched.Failure == 1 && observed.Errors.Contains(failureError), "failure callback error is not called twice"); var held = new TaskCompletionSource(); releases.Add(held); holds.Enqueue(held); var late = Start("GetMatch", matchRequest, readMatch); Check(observed.Active == 1 && late.Success == 0 && late.Failure == 0, "delayed operation is active"); status = System.Net.HttpStatusCode.Unauthorized; matched = Start("GetMatch", matchRequest, readMatch); Check(matched.Success == 0 && matched.Failure == 1, "401 yields original failure callback"); held.SetResult(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(validMatch) }); await Drain(); Check(late.Success == 0 && late.Failure == 1, "late success after real lease revocation is rejected once"); Check(pfTransport == 0 && auth == 1 && gets == 1 && !user.IsAuthorized, "fake requests only without published gameplay authorization"); } catch (Exception ex) { failure = ex.ToString(); } finally { System.Threading.SynchronizationContext.SetSynchronizationContext(originalContext); foreach (var held in releases) { held.TrySetResult(ValidationJson(new { code = 0 })); } async Task Cleanup(Func action) { try { await action(); } catch (Exception ex) { errors.Add(ex.ToString()); } } await Cleanup(Drain); if (server != null) { await Cleanup(() => server.ClearKVForGame()); } if (context != null) { await Cleanup(() => context.CloseAsync()); } if (user != null) { await Cleanup(() => user.CloseKVForValidationAsync()); } await Cleanup(() => { ValidationContextAccess.Use(originalContainer); ValidationContextAccess.Events = originalEvents; System.Threading.SynchronizationContext.SetSynchronizationContext(originalContext); return Task.CompletedTask; }); foreach (var error in observed.Errors) { if (!expectedErrors.Contains(error)) { errors.Add(error.ToString()); } } restored = errors.Count == 0 && observed.Active == 0 && ReferenceEquals(asap.core.GContext.container, originalContainer) && ReferenceEquals(ValidationContextAccess.Events, originalEvents) && ReferenceEquals(System.Threading.SynchronizationContext.Current, originalContext); if (!restored) { failedMatchmakingCleanup = context ?? (object)observed; } running = false; } return JsonConvert.SerializeObject(new { success = failure == null && restored, caseId = "P8D-Matchmaking", assertions, auth, gets, sends, pfTransport, restored, failure, errors, expectedCallbackErrors = observed.Errors.Count(expectedErrors.Contains), temporaryContainerIsolation = true, formalRequests = 0, playing = EditorApplication.isPlaying, naturalPanelCovered = false }); } private static object failedTitleNewsCleanup; private sealed class NewsRedPoint : IUIRedPoint { internal bool Value; public void SetRedPointState(bool state) { Value = state; } } private static async Task RunTitleNews(bool failEarly = false) { const string key = "TitleNewsEvent"; const System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic; var instanceField = typeof(KVMgr).GetField("instance", flags); var redField = typeof(RedPointManager).GetField("instance", flags); if (running || failedTitleNewsCleanup != null || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || asap.core.GContext.Ready || PlayerKVGameplayLoop.Enabled || instanceField.GetValue(null) != null || UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0) { throw new InvalidOperationException("Title news validation requires an idle isolated Editor."); } var originalContainer = asap.core.GContext.container; var originalEvents = ValidationContextAccess.Events; var originalContext = System.Threading.SynchronizationContext.Current; var originalRed = redField.GetValue(null); var hadKey = UnityEngine.PlayerPrefs.HasKey(key); var oldValue = UnityEngine.PlayerPrefs.GetString(key); running = true; var observed = new LeaderboardContext(originalContext); var errors = new List(); var releases = new List>(); var holds = new Queue>(); PlayerKVLoginContext context = null; CustomServerMgr server = null; KVUserService user = null; Task continuation = null; TaskCompletionSource orderedCompletion = null; string failure = null; bool restored = false; bool earlyFailureObserved = false; bool cleanupCanceled = false; int assertions = 0, sends = 0, auth = 0, gets = 0, pfTransport = 0; void Check(bool condition, string detail) { assertions++; if (!condition) { throw new InvalidOperationException("Title news: " + detail); } } void Publish(TitleNewsEvent evt, bool complete = true) { var active = observed.Active; var started = observed.Started; var posted = observed.Posted; System.Threading.SynchronizationContext.SetSynchronizationContext(observed); try { asap.core.GContext.Publish(evt); } finally { System.Threading.SynchronizationContext.SetSynchronizationContext(originalContext); } Check(observed.Started == started + 1, "actual async void entered"); if (complete) { Check(observed.Active == active && observed.Posted == posted && observed.Errors.Count == 0, "actual full consumer completed before inspecting globals"); } } try { var api = new Uri(PlayerKVRuntimeSelection.FormalApiUrl); var config = new RetryConfig("101"); config.Set("EventApiURL", api.AbsoluteUri); var timestamp = new DateTime(2026, 10, 9, 1, 2, 3, DateTimeKind.Utc); var body = "{\"code\":1,\"news\":[{\"newsId\":\"first\",\"title\":\"title\",\"body\":\"body\",\"timestamp\":\"2026-10-09T01:02:03Z\"},{\"newsId\":\"later\",\"timestamp\":\"2026-10-10T00:00:00Z\"}]}"; var validBody = body; var status = System.Net.HttpStatusCode.OK; var root = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../CodexOutput/upserver/B3BatchMigration/M2/P6-2026-10-09/unity-news-cache", Guid.NewGuid().ToString("N"))); context = new PlayerKVLoginContext("101", root, 803, _ => true, api, () => new game.Account.S101AccountSession(api, cookies => new ValidationHttp(request => { Check(request.RequestUri == new Uri(api, "account/Login"), "fake login route"); auth++; cookies.Add(api, new System.Net.Cookie("sid", "synthetic")); return ValidationJson(new { state = 0, internalId = 101, newCreate = false, createTime = "2026-01-01T00:00:00Z", svrTime = "2026-10-09T00:00:00Z" }); })), () => new PlayerKVGameSessionBridge(_ => new ValidationHttp(request => { Check(request.RequestUri == new Uri(api, "playerdata/Get"), "fake KV route"); gets++; return ValidationJson(new { data = new Dictionary() }); }))); Check((await context.PrepareAsync()).Prepared, "real prepared context"); Check(context.TryAcquireAccountLease(PlayerKVControlledLoadCapability.CreateForGame(_ => new ValidationHttp(null, async request => { Check(request.Method == System.Net.Http.HttpMethod.Post && request.RequestUri == new Uri(api, "commondata/GetTitleNews"), "actual published route"); Check(Newtonsoft.Json.Linq.JObject.Parse(await request.Content.ReadAsStringAsync()).Count == 0, "original empty request"); sends++; if (holds.Count > 0) { return await holds.Dequeue().Task; } return new System.Net.Http.HttpResponseMessage(status) { Content = new System.Net.Http.StringContent(body) }; })), out var lease), "real lease"); System.Threading.SynchronizationContext.SetSynchronizationContext(observed); try { server = new CustomServerMgr(config, _ => new ValidationHttp(request => { pfTransport++; throw new InvalidOperationException("PF transport forbidden"); })); } finally { System.Threading.SynchronizationContext.SetSynchronizationContext(originalContext); } server.BindKVForGame(lease); server.EnableKVBusiness(lease.Epoch); user = new KVUserService { customServerMgr = server }; void Created(DateTime value) { typeof(KVUserService).GetProperty("CreateTime").SetValue(user, value); } Created(context.AccountDescriptor.CreateTimeUtc); var isolated = new asap.core.Container(); ValidationContextAccess.Use(isolated); ValidationContextAccess.Events = new asap.core.EventAggregator(); asap.core.ContainerExtensions.RegisterInstance(isolated, user); asap.core.ContainerExtensions.RegisterInstance(isolated, server); var red = new NewsRedPoint(); var redManager = new RedPointManager(); redManager.AddRedPoint(RedPointName.Announcement, red); redField.SetValue(null, redManager); var manager = new TileNewsManager(); var oldNews = new PlayFab.ClientModels.TitleNewsItem { NewsId = "old" }; manager.News = oldNews; UnityEngine.PlayerPrefs.SetString(key, "unread"); var evt = new TitleNewsEvent(); Publish(evt); Check(ReferenceEquals(evt.titleNewsItem, oldNews), "event retains original old News"); Check(ReferenceEquals(await evt.titleNewsItemAwait.Task, manager.News) && manager.News.NewsId == "first" && manager.News.Title == "title" && manager.News.Body == "body" && manager.News.Timestamp == timestamp, "original first selection and exact returned fields"); Check(red.Value && UnityEngine.PlayerPrefs.GetString(key) == "unread", "unread old account red without marking read"); UnityEngine.PlayerPrefs.SetString(key, timestamp.ToString()); Publish(new TitleNewsEvent()); Check(!red.Value, "original read timestamp comparison"); UnityEngine.PlayerPrefs.SetString(key, "unread"); foreach (var created in new[] { timestamp, timestamp.AddDays(1) }) { Created(created); Publish(new TitleNewsEvent()); Check(!red.Value, "equal or newer account does not receive red"); } Created(context.AccountDescriptor.CreateTimeUtc); redManager.SetRedPointState(RedPointName.Announcement, true); var prior = manager.News; evt = new TitleNewsEvent { show = true }; orderedCompletion = evt.titleNewsItemAwait; bool atCompletion = false; continuation = evt.titleNewsItemAwait.Task.ContinueWith(_ => { atCompletion = true; Check(UnityEngine.PlayerPrefs.GetString(key) == "unread" && red.Value, "TCS completes before Prefs and red changes"); }, System.Threading.CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default); if (failEarly) { throw new InvalidOperationException("Expected P8C validation failure before publication."); } Publish(evt); await continuation; Check(atCompletion && ReferenceEquals(evt.titleNewsItem, prior) && !red.Value && UnityEngine.PlayerPrefs.GetString(key) == timestamp.ToString(), "show marks original string after TCS and clears red"); foreach (var invalid in new[] { "{\"code\":1,\"news\":[]}", "{\"code\":0,\"news\":[]}", "{\"code\":1,\"news\":null}", "{", "", "{\"code\":1,\"news\":[null]}", "{\"code\":1,\"news\":[{\"timestamp\":\"bad-time\"}]}" }) { body = invalid; manager.News = oldNews; redManager.SetRedPointState(RedPointName.Announcement, true); UnityEngine.PlayerPrefs.SetString(key, "unchanged"); evt = new TitleNewsEvent { show = true }; Publish(evt); Check(ReferenceEquals(evt.titleNewsItem, oldNews) && await evt.titleNewsItemAwait.Task == null && manager.News == null && !red.Value && UnityEngine.PlayerPrefs.GetString(key) == "unchanged", "failure/empty follows original clear and no read write"); } body = validBody; status = System.Net.HttpStatusCode.InternalServerError; evt = new TitleNewsEvent { show = true }; Publish(evt); Check(await evt.titleNewsItemAwait.Task == null && manager.News == null && !red.Value && UnityEngine.PlayerPrefs.GetString(key) == "unchanged", "HTTP500 original failure result"); status = System.Net.HttpStatusCode.OK; var held = new TaskCompletionSource(); releases.Add(held); holds.Enqueue(held); var late = new TitleNewsEvent { show = true }; Publish(late, false); Check(observed.Active == 1 && !late.titleNewsItemAwait.Task.IsCompleted, "real delayed consumer remains active"); status = System.Net.HttpStatusCode.Unauthorized; evt = new TitleNewsEvent { show = true }; Publish(evt); Check(await evt.titleNewsItemAwait.Task == null, "401 failure consumed"); held.SetResult(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(validBody) }); await observed.WaitForIdle(); Check(await late.titleNewsItemAwait.Task == null && manager.News == null && !red.Value && UnityEngine.PlayerPrefs.GetString(key) == "unchanged", "late 200 after revocation cannot publish news or read marker"); Check(pfTransport == 0 && auth == 1 && gets == 1 && !user.IsAuthorized, "fake transports only, no published game authorization"); } catch (Exception ex) { failure = ex.ToString(); earlyFailureObserved = failEarly && ex is InvalidOperationException && ex.Message == "Expected P8C validation failure before publication."; } finally { System.Threading.SynchronizationContext.SetSynchronizationContext(originalContext); foreach (var held in releases) { held.TrySetResult(ValidationJson(new { code = 0 })); } async Task Cleanup(Func action) { try { await action(); } catch (Exception ex) { errors.Add(ex.ToString()); } } await Cleanup(() => observed.WaitForIdle()); if (observed.Active == 0 && orderedCompletion != null && !orderedCompletion.Task.IsCompleted) { cleanupCanceled = orderedCompletion.TrySetCanceled(); } if (continuation != null) { await Cleanup(async () => { try { await continuation; } catch (OperationCanceledException) when (cleanupCanceled && continuation.IsCanceled) { // Only our cleanup cancellation is expected; no successful news result is invented. } }); } if (server != null) { await Cleanup(() => server.ClearKVForGame()); } if (context != null) { await Cleanup(() => context.CloseAsync()); } if (user != null) { await Cleanup(() => user.CloseKVForValidationAsync()); } await Cleanup(() => { if (hadKey) { UnityEngine.PlayerPrefs.SetString(key, oldValue); } else { UnityEngine.PlayerPrefs.DeleteKey(key); } Check(UnityEngine.PlayerPrefs.HasKey(key) == hadKey && UnityEngine.PlayerPrefs.GetString(key) == oldValue, "actual Prefs restoration"); return Task.CompletedTask; }); await Cleanup(() => { redField.SetValue(null, originalRed); return Task.CompletedTask; }); await Cleanup(() => { ValidationContextAccess.Use(originalContainer); ValidationContextAccess.Events = originalEvents; System.Threading.SynchronizationContext.SetSynchronizationContext(originalContext); return Task.CompletedTask; }); foreach (var error in observed.Errors) { errors.Add(error.ToString()); } restored = errors.Count == 0 && observed.Active == 0 && ReferenceEquals(redField.GetValue(null), originalRed) && ReferenceEquals(asap.core.GContext.container, originalContainer) && ReferenceEquals(ValidationContextAccess.Events, originalEvents) && ReferenceEquals(System.Threading.SynchronizationContext.Current, originalContext); if (!restored) { failedTitleNewsCleanup = context ?? (object)observed; } running = false; } return JsonConvert.SerializeObject(new { success = (failEarly ? earlyFailureObserved && cleanupCanceled : failure == null) && restored, caseId = failEarly ? "P8C-TitleNews-Cleanup" : "P8C-TitleNews", assertions, earlyFailureObserved, cleanupCanceled, auth, gets, sends, pfTransport, restored, failure, errors, temporaryPrefsKey = key, temporaryContainerIsolation = true, formalRequests = 0, playing = EditorApplication.isPlaying }); } private static object failedLeaderboardCleanup; private static async Task RunLeaderboard() { var instanceField = typeof(KVMgr).GetField("instance", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); if (running || failedLeaderboardCleanup != null || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || asap.core.GContext.Ready || PlayerKVGameplayLoop.Enabled || instanceField.GetValue(null) != null || UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0) { throw new InvalidOperationException("Leaderboard validation requires an idle isolated Editor."); } running = true; var previousContext = System.Threading.SynchronizationContext.Current; var observed = new LeaderboardContext(previousContext); PlayerKVLoginContext context = null; CustomServerMgr server = null; KVUserService user = null; IDisposable subscription = null; Task pending = null; TaskCompletionSource held = null; var errors = new List(); string failure = null; int assertions = 0, sends = 0, pfTransport = 0, events = 0, logins = 0, gets = 0; bool restored = false; void Check(bool condition, string detail) { assertions++; if (!condition) { throw new InvalidOperationException("Leaderboard: " + detail); } } try { var api = new Uri(PlayerKVRuntimeSelection.FormalApiUrl); var config = new RetryConfig("101"); config.Set("EventApiURL", api.AbsoluteUri); var status = System.Net.HttpStatusCode.OK; var body = "{\"code\":1,\"leaderboard\":[{\"playFabId\":\"102\",\"position\":4,\"statValue\":500002,\"profile\":{\"displayName\":null,\"avatarUrl\":\"avatar102\",\"lastLogin\":\"2026-10-09T01:02:03Z\"}},{\"playFabId\":\"101\",\"position\":2,\"statValue\":500002,\"profile\":{\"displayName\":\"first\",\"avatarUrl\":null,\"lastLogin\":null}}]}"; var validBody = body; Newtonsoft.Json.Linq.JObject sent = null; var delayNext = false; var root = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../CodexOutput/upserver/B3BatchMigration/M2/P6-2026-10-09/unity-leaderboard-cache", Guid.NewGuid().ToString("N"))); context = new PlayerKVLoginContext("101", root, 801, _ => true, api, () => new game.Account.S101AccountSession(api, cookies => new ValidationHttp(request => { Check(request.RequestUri == new Uri(api, "account/Login"), "fake login route"); logins++; cookies.Add(api, new System.Net.Cookie("sid", "synthetic")); return ValidationJson(new { state = 0, internalId = 101, newCreate = false, createTime = "2026-01-01T00:00:00Z", svrTime = "2026-10-09T00:00:00Z" }); })), () => new PlayerKVGameSessionBridge(_ => new ValidationHttp(request => { Check(request.RequestUri == new Uri(api, "playerdata/Get"), "fake KV route"); gets++; return ValidationJson(new { data = new Dictionary() }); }))); Check((await context.PrepareAsync()).Prepared, "real prepared context"); Check(context.TryAcquireAccountLease(PlayerKVControlledLoadCapability.CreateForGame(_ => new ValidationHttp(null, async request => { Check(request.Method == System.Net.Http.HttpMethod.Post && request.RequestUri == new Uri(api, "leaderboard/GetLeaderboard"), "real route and method"); sends++; sent = Newtonsoft.Json.Linq.JObject.Parse(await request.Content.ReadAsStringAsync()); if (delayNext) { delayNext = false; return await held.Task; } return new System.Net.Http.HttpResponseMessage(status) { Content = new System.Net.Http.StringContent(body) }; })), out var lease), "real account lease"); System.Threading.SynchronizationContext.SetSynchronizationContext(observed); try { server = new CustomServerMgr(config, _ => new ValidationHttp(request => { pfTransport++; throw new InvalidOperationException("PF transport forbidden"); })); } finally { System.Threading.SynchronizationContext.SetSynchronizationContext(previousContext); } server.BindKVForGame(lease); server.EnableKVBusiness(lease.Epoch); user = new KVUserService { customServerMgr = server }; var board = new GameCore.LeadboardData { userService = user, customServerMgr = server }; var result = await board.GetLeadboardData("LevelB"); Check(result?.Leaderboard.Count == 2 && result.Leaderboard[0].PlayFabId == "102", "real UserID mapping"); Check((string)sent["statisticName"] == "LevelB" && (int)sent["maxResultsCount"] == 50 && (int)sent["startPosition"] == 0 && sent.Count == 3, "original default request"); Check(result.Leaderboard[0].Profile.LastLogin == new DateTime(2026, 10, 9, 1, 2, 3, DateTimeKind.Utc) && result.Leaderboard[1].Profile.LastLogin == null, "real DateTime and null"); await board.GetLeadboardData("EULevelB", 17); Check((string)sent["statisticName"] == "EULevelB" && (int)sent["maxResultsCount"] == 17, "original region/count"); subscription = asap.core.GContext.OnEvent().Subscribe(evt => { events++; Check(!evt.isLocal && board.LeadboardDataList.Count == 2 && board.LeadboardDataList[0].playFabId == "102", "event follows real list publication"); }); void Consume() { var started = observed.Started; var posted = observed.Posted; var previous = System.Threading.SynchronizationContext.Current; System.Threading.SynchronizationContext.SetSynchronizationContext(observed); try { board.GetLevelLeadboardData(); } finally { System.Threading.SynchronizationContext.SetSynchronizationContext(previous); } Check(observed.Started == started + 1 && observed.Active == 0 && observed.Posted == posted, "real async void operation completed synchronously without pending continuation"); } Consume(); var originalList = board.LeadboardDataList; Check(events == 1 && originalList[0].position == 4 && originalList[0].rank == 5 && originalList[0].value == 500002 && originalList[1].playFabId == "101" && originalList[1].position == 2, "original order and Position+1 without reranking"); Check(originalList[0].displayName == user.GetDefaultName("102") && originalList[0].avatarUrl == "avatar102" && originalList[0].LastLogin == result.Leaderboard[0].Profile.LastLogin, "real item/default name/time mapping"); Check(user.GetPlayInfo("102").name == originalList[0].displayName && user.GetPlayInfo("101").name == "first", "original profile cache consumer"); foreach (var invalid in new[] { "{\"code\":1,\"leaderboard\":[]}", "", "null", "{", "[]", "{}", "{\"code\":0,\"leaderboard\":[]}", "{\"code\":1,\"leaderboard\":null}", "{\"code\":1,\"leaderboard\":[null]}", "{\"code\":1,\"leaderboard\":[{\"profile\":null}]}", "{\"code\":1,\"leaderboard\":[{\"profile\":{\"lastLogin\":\"bad-time\"}}]}" }) { body = invalid; var empty = invalid == "{\"code\":1,\"leaderboard\":[]}"; result = await board.GetLeadboardData("LevelB"); Check(empty ? result?.Leaderboard.Count == 0 : result == null, "empty success differs from failure"); Consume(); Check(ReferenceEquals(originalList, board.LeadboardDataList) && events == 1, "empty/failure retains old list without event after real completion"); } body = validBody; status = System.Net.HttpStatusCode.InternalServerError; Check(await board.GetLeadboardData("LevelB") == null, "real HTTP500 remains failure"); Consume(); Check(ReferenceEquals(originalList, board.LeadboardDataList) && events == 1, "HTTP500 preserves original list"); status = System.Net.HttpStatusCode.OK; held = new TaskCompletionSource(); delayNext = true; pending = board.GetLeadboardData("LevelB"); await RetryWait(() => !delayNext); Check(!pending.IsCompleted, "real delayed response pending"); status = System.Net.HttpStatusCode.Unauthorized; Check(await board.GetLeadboardData("LevelB") == null, "401 is a failure"); held.SetResult(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(validBody) }); Check(await pending == null, "late old-owner success discarded"); var sendsBefore = sends; var rejected = false; try { await board.GetLeadboardData("LevelB"); } catch (InvalidOperationException) { rejected = true; } Check(rejected && sendsBefore == sends, "revoked owner cannot send"); Check(pfTransport == 0 && logins == 1 && gets == 1 && observed.Active == 0, "no PF transport/relogin or pending async void"); } catch (Exception ex) { failure = ex.ToString(); } finally { System.Threading.SynchronizationContext.SetSynchronizationContext(previousContext); held?.TrySetResult(ValidationJson(new { })); async Task Cleanup(Func action) { try { await action(); } catch (Exception ex) { errors.Add(ex.ToString()); } } if (pending != null) { await Cleanup(async () => { await pending; }); } await Cleanup(() => observed.WaitForIdle()); await Cleanup(() => { subscription?.Dispose(); return Task.CompletedTask; }); if (server != null) { await Cleanup(() => server.ClearKVForGame()); } if (context != null) { await Cleanup(() => context.CloseAsync()); } if (user != null) { await Cleanup(() => user.CloseKVForValidationAsync()); } foreach (var error in observed.Errors) { errors.Add(error.ToString()); } restored = errors.Count == 0 && observed.Active == 0 && ReferenceEquals(System.Threading.SynchronizationContext.Current, previousContext); if (!restored) { failedLeaderboardCleanup = context ?? (object)observed; } running = false; } return JsonConvert.SerializeObject(new { success = failure == null && restored, caseId = "P8A-Leaderboard", assertions, sends, logins, gets, pfTransport, events, restored, failure, errors, formalRequests = 0, playing = EditorApplication.isPlaying, globalContainerChanged = false }); } private static object failedFriendCleanup; private sealed class StartupMailHandler : BaseMailHandler { internal int Fetches { get; private set; } internal StartupMailHandler(MailCache cache, ICustomServerMgr server) : base(cache, server) { } public override Task FetchMail() { Fetches++; return Task.FromResult(true); } } private static async Task RunFriends(bool verifyStartup = false) { #if AGG const bool realChannelCounters = true; #else const bool realChannelCounters = false; #endif var instanceField = typeof(KVMgr).GetField("instance", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); if (realChannelCounters || running || failedFriendCleanup != null || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || asap.core.GContext.Ready || PlayerKVGameplayLoop.Enabled || instanceField.GetValue(null) != null || UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0) { throw new InvalidOperationException("Friends validation requires idle isolated Editor without live channel counters."); } running = true; var originalContainer = asap.core.GContext.container; var originalEvents = ValidationContextAccess.Events; var originalContext = System.Threading.SynchronizationContext.Current; var observed = new LeaderboardContext(originalContext); var isolated = new asap.core.Container(); CustomServerMgr server = null; PlayerKVGameLoadCoordinator coordinator = null; KVUserService user = null; IDisposable listSubscription = null, profileSubscription = null; var releases = new List>(); var holds = new Queue>(); Task<(string, int)> pendingLevel = null; InGameMailService startupMail = null; SignalRService startupRT = null; DevIAPService startupIAP = null; StartupMailHandler startupHandler = null; UnityEngine.Application.LogCallback startupLog = null; var startupPrefs = new Dictionary(); int orderCallbacks = 0; var errors = new List(); string failure = null; bool restored = false; int assertions = 0, sends = 0, auth = 0, gets = 0, pfTransport = 0, listEvents = 0, profileEvents = 0; void Check(bool condition, string detail) { assertions++; if (!condition) { throw new InvalidOperationException("Friends: " + detail); } } void Invoke(Action action, bool complete = true) { var started = observed.Started; var posted = observed.Posted; var previous = System.Threading.SynchronizationContext.Current; System.Threading.SynchronizationContext.SetSynchronizationContext(observed); try { action(); } finally { System.Threading.SynchronizationContext.SetSynchronizationContext(previous); } if (complete) { Check(observed.Started == started + 1 && observed.Active == 0 && observed.Posted == posted && observed.Errors.Count == 0, "original async void truly completed synchronously"); } } async Task LevelFails(string id) { var failed = false; try { await user.GetPlayLv(id); } catch (InvalidOperationException) { failed = true; } Check(failed, "ordinary level remains a failed Task"); } try { var api = new Uri(PlayerKVRuntimeSelection.FormalApiUrl); var config = new RetryConfig("101"); config.Set("EventApiURL", api.AbsoluteUri); var profileBody = "{\"code\":1,\"info\":{\"displayName\":\"legacy\",\"avatarUrl\":\"legacy\",\"level\":999},\"kvProfile\":{\"displayName\":\"KV name\",\"avatarUrl\":\"KV avatar\",\"level\":37}}"; var friendBody = "{\"code\":1,\"friends\":[]}"; var status = System.Net.HttpStatusCode.OK; Newtonsoft.Json.Linq.JObject sent = null; System.Threading.SynchronizationContext.SetSynchronizationContext(observed); try { server = new CustomServerMgr(config, _ => new ValidationHttp(request => { pfTransport++; throw new InvalidOperationException("PF transport forbidden"); })); } finally { System.Threading.SynchronizationContext.SetSynchronizationContext(originalContext); } user = new KVUserService { customServerMgr = server }; long boundEpoch = 0; coordinator = new PlayerKVGameLoadCoordinator(() => true, lease => { server.BindKVForGame(lease); boundEpoch = lease.Epoch; }, server.ClearKVForGame); var root = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../CodexOutput/upserver/B3BatchMigration/M2/P6-2026-10-09/unity-friends-cache", Guid.NewGuid().ToString("N"))); var capability = PlayerKVGameLoadCapability.CreateForGame(api, root, cookies => new ValidationHttp(request => { Check(request.RequestUri == new Uri(api, "account/Login"), "fake login route"); auth++; cookies.Add(api, new System.Net.Cookie("sid", "synthetic")); return ValidationJson(new { state = 0, internalId = 101, newCreate = false, createTime = "2026-01-01T00:00:00Z", svrTime = "2026-10-09T00:00:00Z" }); }), _ => new ValidationHttp(request => { Check(request.RequestUri == new Uri(api, "playerdata/Get"), "fake own archive read"); gets++; return ValidationJson(new { data = new Dictionary { ["Fixture"] = "1" } }); }), _ => new ValidationHttp(null, async request => { Check(request.Method == System.Net.Http.HttpMethod.Post && request.RequestUri.GetLeftPart(UriPartial.Authority) == "http://s101.local:18080", "fixed authenticated POST"); var route = request.RequestUri.AbsolutePath; Check(route == "/friend/GetFriendsList" || route == "/friend/SearchFriend", "only published friend routes"); sends++; sent = Newtonsoft.Json.Linq.JObject.Parse(await request.Content.ReadAsStringAsync()); Check(route.EndsWith("SearchFriend") ? sent.Count == 1 && sent["PlayFabId"] != null : sent.Count == 0, "old identity field or empty list request, no target KV shortcut"); if (holds.Count != 0) { return await holds.Dequeue().Task; } return new System.Net.Http.HttpResponseMessage(status) { Content = new System.Net.Http.StringContent(route.EndsWith("SearchFriend") ? profileBody : friendBody) }; })); var prepared = await coordinator.LoadForGameAsync("101", capability, user.LoginLifetime); Check(prepared.State == PlayerKVGameLoadState.PreparedForGame && coordinator.TryGetCurrentGameHandle(prepared.Epoch, out _), "actual coordinator prepared original handle"); coordinator.TryGetCurrentGameHandle(prepared.Epoch, out var handle); SetUserField(user, "coordinator", coordinator); SetUserField(user, "handle", handle); SetUserField(user, "epoch", prepared.Epoch); SetUserField(user, "playerId", "101"); server.EnableKVBusiness(boundEpoch); Check(user.UserId == "101" && !user.IsAuthorized, "real handoff identity, not published gameplay authorization"); ValidationContextAccess.Use(isolated); ValidationContextAccess.Events = new asap.core.EventAggregator(); asap.core.ContainerExtensions.RegisterInstance(isolated, user); profileSubscription = asap.core.GContext.OnEvent().Subscribe(_ => profileEvents++); listSubscription = asap.core.GContext.OnEvent().Subscribe(_ => listEvents++); if (verifyStartup) { const System.Reflection.BindingFlags fields = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic; foreach (var key in new[] { "mail_cache__101", "mail_sync_Time_101" }) { startupPrefs[key] = (UnityEngine.PlayerPrefs.HasKey(key), UnityEngine.PlayerPrefs.GetString(key)); } var directory = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../cfgData")); var tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(System.IO.File.ReadAllText(System.IO.Path.Combine(directory, name + ".json")))); Check(tables.TbAutoDeliveryMail.DataList.Count > 0, "original mail Min input is populated"); startupRT = new SignalRService(server, config); asap.core.ContainerExtensions.RegisterInstance(isolated, startupRT); Check(!startupRT.IsConnected && await startupRT.Publish("Test", "synthetic") == null && await startupRT.Publish("Test", "synthetic", "synthetic") == null, "unconnected publish cannot send"); startupMail = new InGameMailService(server, tables); var cache = (MailCache)typeof(InGameMailService).GetField("_mailCache", fields).GetValue(startupMail); startupHandler = new StartupMailHandler(cache, server); typeof(InGameMailService).GetField("_handler", fields).SetValue(startupMail, startupHandler); var cacheUser = typeof(MailCache).GetField("_userId", fields); var handlers = (Dictionary>)typeof(SignalRService).GetField("handlers", fields).GetValue(startupRT); startupMail.Init(); Check((string)cacheUser.GetValue(cache) == "101" && startupHandler.Fetches == 0, "original identity cache and deferred mail branch"); asap.core.GContext.Publish(new EvtAPISvrLoginSuccess()); Check(startupHandler.Fetches == 1, "original login subscription calls Fetch boundary"); Invoke(() => handlers[nameof(FetchMailEvt)]("{}")); await P6AWait(observed.WaitForIdle()); Check(startupHandler.Fetches == 2, "original RT subscription calls Fetch boundary"); startupMail.Reset(); Check(cacheUser.GetValue(cache) == null, "original Reset unloads mail cache"); asap.core.GContext.Publish(new EvtAPISvrLoginSuccess()); handlers[nameof(FetchMailEvt)]("{}"); Check(startupHandler.Fetches == 2 && observed.Active == 0, "Reset removes both subscriptions"); startupMail.Init(); asap.core.GContext.Publish(new EvtAPISvrLoginSuccess()); Invoke(() => handlers[nameof(FetchMailEvt)]("{}")); await P6AWait(observed.WaitForIdle()); Check(startupHandler.Fetches == 4 && (string)cacheUser.GetValue(cache) == "101", "Init after Reset has one subscription each"); startupMail.Reset(); asap.core.GContext.Publish(new EvtAPISvrLoginSuccess()); handlers[nameof(FetchMailEvt)]("{}"); Check(startupHandler.Fetches == 4, "second Reset removes subscribers"); startupLog = (message, stack, type) => { if (message.StartsWith("[AggIAPService] OnOrderCompleted", StringComparison.Ordinal)) { orderCallbacks++; } }; UnityEngine.Application.logMessageReceived += startupLog; startupIAP = new DevIAPService { rts = startupRT }; startupIAP.Init(); handlers[nameof(SC_OrderConfirmEvt)]("{}"); Check(orderCallbacks == 1, "original Dev order subscription entered once"); startupIAP.Init(); handlers[nameof(SC_OrderConfirmEvt)]("{}"); Check(orderCallbacks == 2, "original Init releases previous order subscription"); startupIAP.Release(); handlers[nameof(SC_OrderConfirmEvt)]("{}"); Check(orderCallbacks == 2, "original Release removes order subscription"); var loginField = typeof(SignalRService).GetField("loginSubscription", fields); startupRT.WaitForAPISvrLogin(); var loginSubscription = loginField.GetValue(startupRT); startupRT.WaitForAPISvrLogin(); Check(loginSubscription != null && ReferenceEquals(loginSubscription, loginField.GetValue(startupRT)), "RT does not subscribe twice"); await startupRT.ReleaseAsync(); await startupRT.ReleaseAsync(); var sessionChanged = (Delegate)typeof(CustomServerMgr).GetField("SessionChanged", fields).GetValue(server); Check(loginField.GetValue(startupRT) == null && handlers.Count == 0 && !startupRT.IsConnected && (sessionChanged == null || sessionChanged.GetInvocationList().All(item => !ReferenceEquals(item.Target, startupRT))), "RT release clears registrations"); var rejected = false; try { startupRT.OnEvent(_ => { }); } catch (ObjectDisposedException) { rejected = true; } Check(rejected, "released RT rejects new subscription"); Check(auth == 1 && gets == 1 && sends == 0 && pfTransport == 0 && observed.Active == 0 && observed.Errors.Count == 0, "only synthetic account preparation, no business transport"); } else { var profileCache = UserField>(user, "profiles"); var levelCache = UserField>(user, "levels"); var cacheEvents = profileEvents; var cacheSends = sends; var nullRejected = false; try { user.SetPlayInfo(null, "Explicit name", "avatar"); } catch (ArgumentNullException) { nullRejected = true; } Check(nullRejected, "explicit null cache key keeps original dictionary failure"); user.SetPlayInfo("", "Empty key", "empty-avatar"); Check(profileCache.TryGetValue("", out var emptyProfile) && emptyProfile.name == "Empty key" && emptyProfile.avatarUrl == "empty-avatar" && user.GetPlayInfo("") == null, "empty key write and empty lookup keep distinct original contracts"); user.SetPlayInfo("101", "Before", "before-avatar"); var oldProfile = user.GetPlayInfo("101"); user.SetPlayInfo("101", "After", "after-avatar"); Check(!ReferenceEquals(oldProfile, user.GetPlayInfo("101")) && oldProfile.name == "Before" && user.GetPlayInfo("101").avatarUrl == "after-avatar", "explicit cache assignment replaces only the dictionary object"); user.SetPlayInfo("101", "", null); Check(user.GetPlayInfo("101").name == "Angler#101", "approved short UserID default name remains"); user.SetPlayerLv("109", 7); user.ClearPlayInfos(); Check(profileCache.Count == 0 && levelCache.Count == 0 && profileEvents == cacheEvents && sends == cacheSends, "pure cache writes and clear add no event or transport"); Check((await user.GetPlayLv("109")).Item2 == 37 && (string)sent["PlayFabId"] == "109", "ordinary remote level uses kvProfile rather than legacy info"); var before = sends; Check((await user.GetPlayLv("109")).Item2 == 37 && user.GetPlayerLv("109") == 37 && sends == before, "successful level uses original cache"); user.SetPlayerLv("109", -3); await LevelFails("109"); await LevelFails(null); await LevelFails(""); await LevelFails("101"); Check(sends == before && user.GetPlayerLv("109") == -3, "negative cache/null/empty/unpublished self do not query"); foreach (var sample in new[] { (Body: "{\"code\":1,\"kvProfile\":{}}", Level: (int?)1), (Body: "{\"code\":1,\"kvProfile\":{\"level\":0}}", Level: (int?)0), (Body: "{\"code\":1,\"kvProfile\":{\"level\":-5}}", Level: (int?)null), (Body: "{\"code\":1,\"info\":{\"level\":99},\"kvProfile\":null}", Level: (int?)null), (Body: "{\"code\":0,\"kvProfile\":{\"level\":99}}", Level: (int?)null), (Body: "{\"code\":1,\"kvProfile\":{\"level\":\"bad\"}}", Level: (int?)null), (Body: "{", Level: (int?)null), (Body: "", Level: (int?)null) }) { user.ClearPlayInfos(); profileBody = sample.Body; before = sends; Check(user.GetPlayerLv("109") == 1 && sends == before, "synchronous display default does not query"); if (sample.Level.HasValue) { Check((await user.GetPlayLv("109")).Item2 == sample.Level.Value, "entity-present missing-level default or actual level"); } else { await LevelFails("109"); Check(user.GetPlayerLv("109") == 1, "failed level did not fill cache"); } Check(sends == before + 1, "one query without retry"); } foreach (var sample in new[] { (Body: "{\"code\":1,\"info\":{\"displayName\":\"legacy\",\"avatarUrl\":\"legacy\"},\"kvProfile\":{\"displayName\":\"KV name\",\"avatarUrl\":\"KV avatar\",\"level\":37}}", Success: true), (Body: "{\"code\":1,\"kvProfile\":{\"displayName\":\"\",\"avatarUrl\":null}}", Success: true), (Body: "{\"code\":1,\"info\":{\"displayName\":\"legacy\"},\"kvProfile\":null}", Success: false), (Body: "{invalid", Success: false), (Body: "", Success: false) }) { user.ClearPlayInfos(); profileBody = sample.Body; before = profileEvents; PlayInfo profile = null; Invoke(() => profile = user.GetPlayInfo("110")); Check(profileEvents == before + (sample.Success ? 1 : 0) && ReferenceEquals(profile, user.GetPlayInfo("110")), "root profile success/failure and original object identity"); Check(profile.name == (sample.Body.Contains("KV name") ? "KV name" : "Angler#110"), "KV name or original placeholder, never legacy fallback"); Check(user.GetPlayerLv("110") == 1, "profile fill never populates levels"); } string Item(string id, object profile) => JsonConvert.SerializeObject(new { PlayFabId = id, DisplayName = "legacy", Level = 999, Profile = profile }); object Profile(string name, object stats, string last = null) => new { DisplayName = name, AvatarUrl = "avatar", Statistics = stats, LastLogin = last }; var low = new object[] { null, new { Name = "Else", Value = 9999999 }, new { Name = "LevelB", Value = 900001 } }; friendBody = JsonConvert.SerializeObject(new { code = 1, friends = new[] { Item("101", Profile("self", low)), Item("102", Profile(null, low, "2026-10-09T01:02:03Z")), Item("103", Profile("high", new[] { new { Name = "LevelB", Value = 1900001 } })), Item("104", null), Item("105", Profile("null stats", null)), Item("106", Profile("empty stats", Array.Empty())), Item("107", Profile("missing LevelB", new[] { new { Name = "Else", Value = 6 } })), Item("108", Profile("negative", new[] { new { Name = "LevelB", Value = -5 } })) }}); var friends = new FriendService { userService = user, customServerMgr = server }; Invoke(friends.GetFriendList); Check(friends.FriendList.Count == 5 && friends.FriendList[0].playFabId == "103" && friends.FriendList[1].playFabId == "102" && friends.FriendList[4].value == -5, "original self/profile/stat filtering and LevelB sort"); Check(friends.FriendList.Select((item, index) => item.position == index && item.rank == index + 1).All(value => value) && !friends.IsFriendListRefreshing && listEvents == 1, "real list ranks/refresh/event"); Check(friends.FriendList[1].displayName == "Angler#102" && friends.FriendList[1].LastLogin == new DateTime(2026, 10, 9, 1, 2, 3, DateTimeKind.Utc) && friends.FriendList[0].LastLogin == null && user.GetPlayInfo("102").name == "Angler#102", "real item time/default name/profile cache"); var oldList = friends.FriendList; foreach (var invalid in new[] { "", "null", "{", "{\"code\":0,\"friends\":[]}", "{\"code\":1,\"friends\":null}", "{\"code\":1,\"friends\":[null]}", "{\"code\":1,\"friends\":[\"\"]}", JsonConvert.SerializeObject(new { code = 1, friends = new[] { Item("102", Profile("bad", low, "bad-time")) } }) }) { friendBody = invalid; Invoke(friends.GetFriendList); Check(ReferenceEquals(oldList, friends.FriendList) && listEvents == 1, "bad payload retains original list after actual completion"); } friendBody = "{\"code\":1,\"friends\":[]}"; status = System.Net.HttpStatusCode.InternalServerError; Invoke(friends.GetFriendList); Check(ReferenceEquals(oldList, friends.FriendList) && listEvents == 1, "real HTTP500 preserves list"); user.ClearPlayInfos(); await LevelFails("109"); Check(user.GetPlayerLv("109") == 1, "real level HTTP500 does not fake default success"); status = System.Net.HttpStatusCode.OK; Invoke(friends.GetFriendList); Check(friends.FriendList.Count == 0 && !ReferenceEquals(oldList, friends.FriendList) && listEvents == 2, "successful empty clears list and publishes unlike leaderboard"); profileBody = "{\"code\":1,\"kvProfile\":{\"displayName\":\"late\",\"level\":37}}"; for (var i = 0; i < 2; i++) { var release = new TaskCompletionSource(); releases.Add(release); holds.Enqueue(release); } PlayInfo delayedProfile = null; before = profileEvents; Invoke(() => delayedProfile = user.GetPlayInfo("1099"), false); Invoke(() => pendingLevel = user.GetPlayLv("1098"), false); Check(holds.Count == 0 && observed.Active == 1 && !pendingLevel.IsCompleted, "real profile and level operations held on fixed owner"); status = System.Net.HttpStatusCode.Unauthorized; await LevelFails("1097"); foreach (var release in releases) { release.SetResult(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(profileBody) }); } var levelRejected = false; try { await pendingLevel; } catch (InvalidOperationException) { levelRejected = true; } await P6AWait(observed.WaitForIdle()); Check(levelRejected && delayedProfile.name == "Angler#1099" && profileEvents == before && user.GetPlayerLv("1098") == 1, "late old-owner responses cannot fill caches or publish"); Check(auth == 1 && gets == 1 && pfTransport == 0 && observed.Errors.Count == 0, "no relogin/PF transport/unobserved async error"); } } catch (Exception ex) { failure = verifyStartup ? ex.GetType().Name : ex.ToString(); } finally { foreach (var release in releases) { release.TrySetResult(ValidationJson(new { })); } async Task Cleanup(Func action) { try { await action(); } catch (Exception ex) { errors.Add(verifyStartup ? ex.GetType().Name : ex.ToString()); } } if (verifyStartup) { await Cleanup(() => { startupMail?.Reset(); return Task.CompletedTask; }); await Cleanup(() => { startupIAP?.Release(); return Task.CompletedTask; }); await Cleanup(() => startupRT?.ReleaseAsync() ?? Task.CompletedTask); await Cleanup(() => { if (startupLog != null) { UnityEngine.Application.logMessageReceived -= startupLog; } return Task.CompletedTask; }); await Cleanup(() => { foreach (var pair in startupPrefs) { Check(UnityEngine.PlayerPrefs.HasKey(pair.Key) == pair.Value.Exists && UnityEngine.PlayerPrefs.GetString(pair.Key) == pair.Value.Value, "original mail Prefs untouched"); } return Task.CompletedTask; }); } if (pendingLevel != null) { await Cleanup(async () => { try { await pendingLevel; } catch (InvalidOperationException) { // Rejection is expected after revocation and asserted by the scenario. } }); } await Cleanup(() => observed.WaitForIdle()); await Cleanup(() => { listSubscription?.Dispose(); return Task.CompletedTask; }); await Cleanup(() => { profileSubscription?.Dispose(); return Task.CompletedTask; }); if (server != null) { await Cleanup(() => server.ClearKVForGame()); } if (coordinator != null) { await Cleanup(() => coordinator.Revoke()); } if (user != null) { await Cleanup(() => user.CloseKVForValidationAsync()); } await Cleanup(() => { ValidationContextAccess.Use(originalContainer); ValidationContextAccess.Events = originalEvents; System.Threading.SynchronizationContext.SetSynchronizationContext(originalContext); return Task.CompletedTask; }); foreach (var error in observed.Errors) { errors.Add(error.ToString()); } restored = errors.Count == 0 && observed.Active == 0 && ReferenceEquals(asap.core.GContext.container, originalContainer) && ReferenceEquals(ValidationContextAccess.Events, originalEvents) && ReferenceEquals(System.Threading.SynchronizationContext.Current, originalContext); if (!restored) { failedFriendCleanup = coordinator ?? (object)observed; } running = false; } if (verifyStartup) { return JsonConvert.SerializeObject(new { success = failure == null && restored, caseId = "P12C-StartupSubscriptions", assertions, auth, gets, sends, pfTransport, restored, failure, errors, fetchBoundaryCalls = startupHandler?.Fetches ?? 0, orderCallbacks, formalRequests = 0, actualSubscriptionMethods = true, mailFetchStubbed = true, hubConnected = false, naturalUIVerified = false }); } return JsonConvert.SerializeObject(new { success = failure == null && restored, caseId = "P8B-Friends", assertions, auth, gets, sends, pfTransport, listEvents, profileEvents, restored, failure, errors, temporaryContainerIsolation = true, publishedGameAuthorization = false, formalRequests = 0, playing = EditorApplication.isPlaying }); } private static async Task RunOrdinarySave(bool failEarly = false, bool verifyCadence = false) { const System.Reflection.BindingFlags fields = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; var instanceField = typeof(KVMgr).GetField("instance", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); if (running || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || asap.core.GContext.Ready || PlayerKVGameplayLoop.Enabled || instanceField.GetValue(null) != null || UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0) { throw new InvalidOperationException("Ordinary save validation requires an idle isolated Editor."); } running = true; var bridge = default(PlayerKVGameSessionBridge); var session = default(game.Account.S101AccountSession); var shell = default(UnityEngine.GameObject); var store = default(KVPlayerSaveStore); var release = new TaskCompletionSource(); var entered = new TaskCompletionSource(); var remote = new Dictionary(); var bodies = new List>(); var receipts = new List(); var cadenceGate = new object(); var requestTimes = new List(); var cadenceSamples = new List(); string cadenceFile = null; int assertions = 0; string failure = null; bool restored = false; double intervalSeconds = 0; var cleanupErrors = new List(); var loopContext = new P6BLoopContext(System.Threading.SynchronizationContext.Current); bool earlyFailureObserved = false; var timer = System.Diagnostics.Stopwatch.StartNew(); void Check(bool condition, string detail) { assertions++; if (!condition) { throw new InvalidOperationException("Ordinary save: " + detail); } } void Call(KVMgr manager, string name, params object[] args) { var previous = System.Threading.SynchronizationContext.Current; System.Threading.SynchronizationContext.SetSynchronizationContext(loopContext); try { typeof(KVMgr).GetMethod(name, fields).Invoke(manager, args); } finally { System.Threading.SynchronizationContext.SetSynchronizationContext(previous); } } try { // The user can be constructed before the root component or before any store is available. var early = new KVUserService(); typeof(KVUserService).GetMethod("OnApplicationQuitting", fields).Invoke(early, null); Check(early.LoginLifetime.IsCancellationRequested, "early user still logs out without a component/store"); var api = new Uri("http://synthetic.invalid/"); session = new game.Account.S101AccountSession(api, jar => new ValidationHttp(_ => { jar.Add(api, new System.Net.Cookie("sid", "ordinary")); return ValidationJson(new { state = 0, internalId = 17, newCreate = false, createTime = "2026-01-01T00:00:00Z", svrTime = "2026-10-09T00:00:00Z" }); })); Check((await session.LoginAsync("101")).Success, "synthetic login"); bridge = new PlayerKVGameSessionBridge(_ => new ValidationHttp(null, async request => { if (request.RequestUri.AbsolutePath.EndsWith("/Get", StringComparison.Ordinal)) { return ValidationJson(new { data = remote }); } var requestTime = verifyCadence ? System.Diagnostics.Stopwatch.GetTimestamp() : 0; var body = Newtonsoft.Json.Linq.JObject.Parse(await request.Content.ReadAsStringAsync())["data"].ToObject>(); if (verifyCadence) { lock (cadenceGate) { bodies.Add(body); requestTimes.Add(requestTime); } } else { bodies.Add(body); } if (!verifyCadence && bodies.Count == 1) { entered.TrySetResult(true); await release.Task; } foreach (var pair in body) { remote[pair.Key] = pair.Value; } return ValidationJson(new { }); })); var root = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../CodexOutput/upserver/B3BatchMigration/M2/P6-2026-10-09/unity-ordinary-cache", Guid.NewGuid().ToString("N"))); var opened = await bridge.OpenAsync(session, root, "101", api, 1, _ => true, default); Check(opened.Success, "original handle"); store = new KVPlayerSaveStore(); store.Bind(opened.Handle, default); shell = new UnityEngine.GameObject("P6B ordinary lifecycle fixture"); shell.SetActive(false); shell.hideFlags = UnityEngine.HideFlags.HideAndDontSave; var manager = shell.AddComponent(); var phaseTimestamp = verifyCadence ? System.Diagnostics.Stopwatch.GetTimestamp() : 0; Call(manager, "Awake"); // Fixed callback in idle Editor, not a claim of Play Mode frames. if (failEarly) { throw new InvalidOperationException("P6B expected early fixture failure"); } var loop = typeof(KVMgr).GetField("tokenSource", fields).GetValue(manager); Check(bodies.Count == 0, "unbound first tick has no send"); KVMgr.Bind(store); Check(ReferenceEquals(loop, typeof(KVMgr).GetField("tokenSource", fields).GetValue(manager)) && bodies.Count == 0, "bind does not restart phase or send"); if (verifyCadence) { var owner = (PlayerKVSessionOwner)typeof(PlayerKVGameSessionHandle).GetProperty("Owner", fields).GetValue(opened.Handle); var state = (PlayerKVSyncState)typeof(PlayerKVSessionOwner).GetField("state", fields).GetValue(owner); var coordinator = typeof(PlayerKVSessionOwner).GetField("coordinator", fields).GetValue(owner); var ticketGate = typeof(PlayerKVSyncCoordinator).GetField("ticketGate", fields).GetValue(coordinator); var tickets = (System.Collections.IDictionary)typeof(PlayerKVSyncCoordinator).GetField("tickets", fields).GetValue(coordinator); var admission = typeof(PlayerKVSessionOwner).GetField("admission", fields).GetValue(owner); var activeOperations = typeof(PlayerKVSessionOwner).GetField("activeOperations", fields); int TicketCount() { lock (ticketGate) { return tickets.Count; } } int LeaseCount() { lock (admission) { return (int)activeOperations.GetValue(owner); } } int SendCount() { lock (cadenceGate) { return bodies.Count; } } void RequireIdleEditor() { if (EditorApplication.isPlayingOrWillChangePlaymode) { throw new InvalidOperationException("Cadence validation interrupted by Play mode."); } } var localWrite = store.Write("ticks", "local", false); Check(localWrite.Accepted && localWrite.Receipt.Ticket == null && state.Inspect().LocalUpdateTicks.ContainsKey("ticks"), "cadence local-only timestamp is accepted without a remote ticket"); var initialTickets = TicketCount(); var initialStarted = loopContext.Started; var previousRequest = phaseTimestamp; cadenceFile = System.IO.Path.Combine(root, "cadence-samples.json"); for (int cycle = 1; cycle <= 2; cycle++) { RequireIdleEditor(); var sentBefore = SendCount(); var expected = cycle.ToString(CultureInfo.InvariantCulture); var written = store.Write("P12A-Cadence", expected); Check(written.Accepted && written.Receipt.Ticket is PlayerKVSyncTicket, "cadence original remote ticket " + cycle); Check(SendCount() == sentBefore, "ordinary acceptance never forces a send " + cycle); var waiting = System.Diagnostics.Stopwatch.StartNew(); while (SendCount() == sentBefore) { RequireIdleEditor(); if (waiting.Elapsed.TotalSeconds >= 45) { throw new TimeoutException("Cadence original timer did not send within 45 seconds."); } await Task.Delay(100); } var observation = opened.Handle.WaitForTicketAsync((PlayerKVSyncTicket)written.Receipt.Ticket); await P6AWait(observation); Check(observation.Result.Status == PlayerKVTicketStatus.Confirmed, "cadence actual ticket confirmed " + cycle); waiting.Restart(); while (state.Inspect().ActiveBatch != null || LeaseCount() != 0) { RequireIdleEditor(); if (waiting.Elapsed.TotalSeconds >= 5) { throw new TimeoutException("Cadence send did not release its actual operation lease."); } await Task.Delay(10); } RequireIdleEditor(); Dictionary sentBody; long sentAt; int sends; lock (cadenceGate) { sends = bodies.Count; sentBody = new Dictionary(bodies[sentBefore]); sentAt = requestTimes[sentBefore]; Check(requestTimes.Count == sends, "cadence request/body samples paired"); } var seconds = (sentAt - previousRequest) / (double)System.Diagnostics.Stopwatch.Frequency; previousRequest = sentAt; Check(seconds >= 39, "cadence real entry interval " + cycle + ": " + seconds); Check(sends == sentBefore + 1 && sentBody.Count == 1 && sentBody.TryGetValue("P12A-Cadence", out var sentValue) && sentValue == expected, "cadence exactly one original single-key payload " + cycle); Check(remote["P12A-Cadence"] == expected && store.Read("P12A-Cadence").ValueOrThrow() == expected && !remote.ContainsKey("ticks"), "cadence actual remote/local payload and local-only separation " + cycle); var snapshot = state.Inspect(); var queued = snapshot.OrdinaryBuffers.Sum(bucket => bucket.Count); Check(snapshot.OrdinaryBuffers.Count == 5 && queued == 0 && snapshot.PreparedOrdinaryBatches.Count == 0 && snapshot.OrdinaryUnknownBatches.Count == 0 && snapshot.UnknownBatch == null && snapshot.ActiveBatch == null && snapshot.LegacyPending.Count == 0 && snapshot.ExplicitDeletes.Count == 0, "cadence confirmed queues empty " + cycle); Check(ReferenceEquals(loop, typeof(KVMgr).GetField("tokenSource", fields).GetValue(manager)), "cadence original timer identity retained " + cycle); Check(loopContext.Started == initialStarted + cycle, "cadence only one ObserveSync started per original tick " + cycle); var ticketCount = TicketCount(); var leaseCount = LeaseCount(); Check(ticketCount == initialTickets + cycle && leaseCount == 0, "cadence tickets retained and leases released " + cycle); var storageBytes = System.IO.Directory.EnumerateFiles(root, "*.json", System.IO.SearchOption.AllDirectories) .Where(path => !string.Equals(path, cadenceFile, StringComparison.OrdinalIgnoreCase)) .Sum(path => new System.IO.FileInfo(path).Length); cadenceSamples.Add(new { cycle, seconds, sends, requestTimestamp = sentAt, ticketCount, leaseCount, queued, prepared = snapshot.PreparedOrdinaryBatches.Count, unknown = snapshot.OrdinaryUnknownBatches.Count, revision = snapshot.Revision, startedDelta = loopContext.Started - initialStarted, managedBytes = GC.GetTotalMemory(false), storageBytes }); System.IO.File.WriteAllText(cadenceFile, JsonConvert.SerializeObject(cadenceSamples, Formatting.Indented)); } } else { for (int i = 0; i < 21; i++) { var write = store.Write("k" + i, "v" + i); Check(write.Accepted, "local acceptance " + i); receipts.Add((PlayerKVSyncTicket)write.Receipt.Ticket); } Call(manager, "OnApplicationPause", true); await P6AWait(entered.Task); Check(bodies.Count == 1 && bodies[0].Count == 10, "pause captures one head without waiting HTTP"); Call(manager, "OnApplicationPause", false); var owner = (PlayerKVSessionOwner)typeof(PlayerKVGameSessionHandle).GetProperty("Owner", fields).GetValue(opened.Handle); var state = (PlayerKVSyncState)typeof(PlayerKVSessionOwner).GetField("state", fields).GetValue(owner); Check(state.Inspect().PreparedOrdinaryBatches.Count == 1 && bodies.Count == 1, "resume immediately captures next head while prior HTTP remains pending"); timer.Restart(); while (state.Inspect().PreparedOrdinaryBatches.Count < 2 && timer.Elapsed.TotalSeconds < 45) { await Task.Delay(100); } intervalSeconds = timer.Elapsed.TotalSeconds; Check(intervalSeconds >= 39 && state.Inspect().PreparedOrdinaryBatches.Count == 2 && bodies.Count == 1, "real forty-second loop captures third head without awaiting HTTP"); Call(manager, "OnApplicationPause", true); var closedLoop = typeof(KVMgr).GetField("tokenSource", fields).GetValue(manager); Check(closedLoop == null && state.Inspect().PreparedOrdinaryBatches.Count == 2, "pause stops and empty head never rotates"); release.TrySetResult(true); foreach (var ticket in receipts) { var observation = opened.Handle.WaitForTicketAsync(ticket); await P6AWait(observation); Check(observation.Result.Status == PlayerKVTicketStatus.Confirmed, "actual original ticket confirms"); } Check(bodies.Select(body => body.Count).SequenceEqual(new[] { 10, 10, 1 }) && remote.Count == 21, "three captured bodies delivered once in order"); var localWrite = store.Write("ticks", "local", false); Check(localWrite.Receipt.Ticket == null && state.Inspect().LocalUpdateTicks.ContainsKey("ticks"), "high-layer real timestamp and local-only receipt"); } var before = bodies.Count; Call(manager, "OnApplicationQuit"); Check(bodies.Count == before, "component quit only stops; user callback owns final attempt"); await bridge.CloseAsync(); Call(manager, "OnDestroy"); Check(instanceField.GetValue(null) == null && bodies.Count == before, "destroy after revoke cannot send or revive identity"); } catch (Exception ex) { failure = ex.ToString(); earlyFailureObserved = failEarly && ex.Message == "P6B expected early fixture failure"; } finally { // Independent cleanup steps retain the first failure and still execute later releases. void Cleanup(Action action) { try { action(); } catch (Exception ex) { cleanupErrors.Add(ex.ToString()); } } release.TrySetResult(true); Cleanup(() => { if (shell != null) { Call(shell.GetComponent(), "OnApplicationQuit"); } }); try { if (bridge != null) { await bridge.CloseAsync(); } } catch (Exception ex) { cleanupErrors.Add(ex.ToString()); } Cleanup(() => KVMgr.Unbind(store)); Cleanup(() => store?.Dispose()); Cleanup(() => { if (shell != null) { Call(shell.GetComponent(), "OnDestroy"); } }); Cleanup(() => { if (shell != null) { UnityEngine.Object.DestroyImmediate(shell); } }); Cleanup(() => session?.Dispose()); try { // Observe the real async-void lifecycle operations, including their uncancellable Delay. var idle = loopContext.Idle; if (await Task.WhenAny(idle, Task.Delay(45000)) != idle) { throw new TimeoutException("P6B original lifecycle Delay did not retire."); } await idle; } catch (Exception ex) { cleanupErrors.Add(ex.ToString()); } finally { running = false; restored = instanceField.GetValue(null) == null && shell == null && loopContext.Active == 0 && cleanupErrors.Count == 0; } } if (verifyCadence) { return JsonConvert.SerializeObject(new { success = failure == null && restored, caseId = "P12A-OrdinaryCadence", assertions, cadenceSamples, cadenceFile, sends = bodies.Count, restored, failure, cleanupErrors, remainingOperations = loopContext.Active, lifecycleOperations = loopContext.Started, formalRequests = 0, editorLifecycleDriven = true, runtimeFramesVerified = false, realTimerCycles = cadenceSamples.Count }); } return JsonConvert.SerializeObject(new { success = (failure == null || earlyFailureObserved) && restored, caseId = failEarly ? "P6B-OrdinarySave-Cleanup" : "P6B-OrdinarySave", assertions, intervalSeconds, sends = bodies.Count, restored, failure, earlyFailureObserved, cleanupErrors, remainingOperations = loopContext.Active, lifecycleOperations = loopContext.Started, formalRequests = 0, editorLifecycleDriven = true, runtimeFramesVerified = false }); } private sealed class P6BLoopContext : System.Threading.SynchronizationContext { private readonly System.Threading.SynchronizationContext original; private TaskCompletionSource idle = new TaskCompletionSource(); internal int Active { get; private set; } internal int Started { get; private set; } internal Task Idle { get { return Active == 0 ? Task.CompletedTask : idle.Task; } } internal P6BLoopContext(System.Threading.SynchronizationContext original) { this.original = original; } public override void OperationStarted() { if (Active++ == 0) { idle = new TaskCompletionSource(); } Started++; original.OperationStarted(); } public override void OperationCompleted() { original.OperationCompleted(); if (--Active == 0) { idle.TrySetResult(true); } } public override void Post(System.Threading.SendOrPostCallback callback, object state) { original.Post(value => { var previous = Current; SetSynchronizationContext(this); try { callback(value); } finally { SetSynchronizationContext(previous); } }, state); } } private static async Task P6AWait(Task task) { if (await Task.WhenAny(task, Task.Delay(5000)) != task) { throw new TimeoutException("P6A observation exceeded five seconds."); } await task; } private sealed class P6ATicks : TimeManager.ITimeTickService, IDisposable { private readonly Subject seconds = new Subject(); private readonly Subject minutes = new Subject(); public IObservable SecondTick { get { return seconds; } } public IObservable MinuteTick { get { return minutes; } } public void Start() { } public void Stop() { } internal void Minute() { minutes.OnNext(1); } public void Dispose() { seconds.Dispose(); minutes.Dispose(); } } private sealed class P6AFaultStore : IPlayerSaveStore { private readonly bool synchronous; internal P6AFaultStore(bool synchronous) { this.synchronous = synchronous; } public bool IsCurrent { get { return true; } } public Task SyncDataImmidiate(string key, string value) { var error = new InvalidOperationException("P6A synthetic store fault"); if (synchronous) { throw error; } return Task.FromException(error); } public PlayerSaveReadResult Read(string key) { throw new NotSupportedException(); } public PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true) { throw new NotSupportedException(); } } private static List P6AInspectStart(Action check) { var start = typeof(FishingStage).GetMethod("StartAsync", RetryInstance); var attribute = (System.Runtime.CompilerServices.AsyncStateMachineAttribute)Attribute.GetCustomAttribute(start, typeof(System.Runtime.CompilerServices.AsyncStateMachineAttribute)); check(attribute != null, "actual StartAsync state machine exists"); var move = attribute.StateMachineType.GetMethod("MoveNext", RetryInstance); var bytes = move.GetMethodBody().GetILAsByteArray(); var codes = typeof(System.Reflection.Emit.OpCodes).GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static) .Where(field => field.FieldType == typeof(System.Reflection.Emit.OpCode)) .Select(field => (System.Reflection.Emit.OpCode)field.GetValue(null)).ToDictionary(code => unchecked((ushort)code.Value)); var calls = new List(); var evidence = new List(); bool helperDiscarded = false; for (int cursor = 0; cursor < bytes.Length;) { int offset = cursor; ushort value = bytes[cursor++]; if (value == 0xfe) { value = (ushort)(0xfe00 | bytes[cursor++]); } var code = codes[value]; int width; switch (code.OperandType) { case System.Reflection.Emit.OperandType.InlineNone: width = 0; break; case System.Reflection.Emit.OperandType.ShortInlineBrTarget: case System.Reflection.Emit.OperandType.ShortInlineI: case System.Reflection.Emit.OperandType.ShortInlineVar: width = 1; break; case System.Reflection.Emit.OperandType.InlineVar: width = 2; break; case System.Reflection.Emit.OperandType.InlineI8: case System.Reflection.Emit.OperandType.InlineR: width = 8; break; case System.Reflection.Emit.OperandType.InlineSwitch: width = 4 + 4 * BitConverter.ToInt32(bytes, cursor); break; default: width = 4; break; } if (code == System.Reflection.Emit.OpCodes.Call || code == System.Reflection.Emit.OpCodes.Callvirt) { var method = move.Module.ResolveMethod(BitConverter.ToInt32(bytes, cursor), attribute.StateMachineType.GetGenericArguments(), Type.EmptyTypes); calls.Add(method); evidence.Add(offset.ToString("X4") + " " + method.DeclaringType.FullName + "." + method); if (method.DeclaringType == typeof(FishingStage) && method.Name == "ObserveLastLoginSaveAsync") { var after = cursor + width; while (after < bytes.Length && bytes[after] == System.Reflection.Emit.OpCodes.Nop.Value) { after++; } helperDiscarded = after < bytes.Length && bytes[after] == System.Reflection.Emit.OpCodes.Pop.Value; } } cursor += width; } int Position(string name) { check(calls.Count(method => method.DeclaringType == typeof(FishingStage) && method.Name == name) == 1, "unique actual " + name); return calls.FindIndex(method => method.DeclaringType == typeof(FishingStage) && method.Name == name); } var data = Position("InitData"); var manager = Position("InitManager"); var helper = Position("ObserveLastLoginSaveAsync"); check(data < manager && manager < helper && helperDiscarded, "original initialization order and helper Task pop"); check(calls.Skip(helper + 1).Any(method => method.Name == "RegisterInstance") && calls.Skip(helper + 1).Any(method => method.Name == "Subscribe") && calls.Skip(helper + 1).Any(method => method.Name == "LoadAtlas"), "original registration subscription and atlas remain after helper"); check(!calls.Any(method => method.Name == "SavePlayerData" || method.Name == "FlushAsync" || method.Name == "WriteAndConfirmAsync"), "no additional full save or confirmation call in actual MoveNext"); return evidence; } private static async Task RunInitializationLastLogin() { if (running || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || asap.core.GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0 || PlayerKVGameplayLoop.Enabled) { throw new InvalidOperationException("P6A requires an idle isolated Editor."); } running = true; var originalContainer = asap.core.GContext.container; var originalEvents = ValidationContextAccess.Events; var isolated = new asap.core.Container(); var resources = new List(); var tasks = new List(); var releases = new List>(); var logs = new List(); var errors = new List(); var cases = new List(); var il = new List(); KVUserService user = null; PlayerKVGameSessionBridge bridge = null; int assertions = 0, sends = 0, expectedErrors = 0; bool restored = false; string failure = null; void Check(bool condition, string message) { assertions++; if (!condition) { throw new InvalidOperationException("P6A " + message); } } void Log(string message, string stack, UnityEngine.LogType type) { if (type == UnityEngine.LogType.Error || type == UnityEngine.LogType.Exception || type == UnityEngine.LogType.Assert) { logs.Add(message); } } UnityEngine.Application.logMessageReceived += Log; try { ValidationContextAccess.Use(isolated); ValidationContextAccess.Events = new asap.core.EventAggregator(); var directory = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../cfgData")); var tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(System.IO.File.ReadAllText(System.IO.Path.Combine(directory, name + ".json")))); asap.core.ContainerExtensions.RegisterInstance(isolated, tables); user = new KVUserService(); var ticks = new P6ATicks(); resources.Add(ticks); int daily = 0; resources.Add(asap.core.GContext.OnEvent().Subscribe(_ => daily++)); using (var first = new TimeManager.TimeRefreshManager(ticks, user)) { Check(first.GetLastLoginTime() == null && daily == 1, "missing old login publishes once"); } cases.Add("missing-login"); var oldProperty = typeof(KVUserService).GetProperty("LastLoginTime"); var today = ZZTimeHelper.UtcNow().UtcNowOffset(); oldProperty.SetValue(user, today); using (var same = new TimeManager.TimeRefreshManager(ticks, user)) { Check(daily == 1 && same.GetLastLoginTime() == today, "same day preserves old time without refresh"); } oldProperty.SetValue(user, today.AddDays(-1)); using (var previous = new TimeManager.TimeRefreshManager(ticks, user)) { Check(daily == 2 && user.LastLoginTime == today.AddDays(-1), "previous day consumes without replacing old time"); } cases.Add("same-and-previous-day"); var logStart = logs.Count; using (var bad = asap.core.GContext.OnEvent().Subscribe(_ => throw new InvalidOperationException("P6A daily subscriber fault"))) using (var manager = new TimeManager.TimeRefreshManager(ticks, user)) { Check(logs.Skip(logStart).Count(message => message.StartsWith("[TimeRefreshManager] Error during cross day refresh: P6A daily subscriber fault")) == 1, "daily failure caught and construction continues"); expectedErrors++; bad.Dispose(); var sessionStart = typeof(TimeManager.TimeRefreshManager).GetField("_sessionStartTime", RetryInstance); sessionStart.SetValue(manager, today.AddDays(-1)); var before = daily; ticks.Minute(); Check(daily == before + 1, "minute subscription established after caught failure"); manager.Dispose(); sessionStart.SetValue(manager, today.AddDays(-1)); ticks.Minute(); Check(daily == before + 1, "disposed manager no longer consumes ticks"); } cases.Add("caught-daily-error-and-dispose"); var method = typeof(FishingStage).GetMethod("ObserveLastLoginSaveAsync", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic, null, new[] { typeof(IPlayerSaveStore), typeof(string) }, null); Check(method != null && method.ReturnType == typeof(Task), "actual private helper signature"); Task Observe(IPlayerSaveStore target, string value) { var task = (Task)method.Invoke(null, new object[] { target, value }); tasks.Add(task); return task; } foreach (var synchronous in new[] { true, false }) { logStart = logs.Count; await P6AWait(Observe(new P6AFaultStore(synchronous), "unused")); Check(logs.Skip(logStart).Count(message => message.StartsWith("[FishingStage] LastLogin save failed:")) == 1, "one observed helper fault without faulting caller"); expectedErrors++; } cases.Add("synchronous-and-task-faults"); var api = new Uri("http://synthetic.invalid/"); var session = new game.Account.S101AccountSession(api, jar => new ValidationHttp(_ => { jar.Add(api, new System.Net.Cookie("sid", "p6a")); return ValidationJson(new { state = 0, internalId = 17, newCreate = false, createTime = "2026-01-01T00:00:00Z", svrTime = "2026-10-09T00:00:00Z" }); })); resources.Add(session); var login = session.LoginAsync("101"); tasks.Add(login); await P6AWait(login); Check(login.Result.Success, "synthetic session authenticated"); var oldValue = "2026-10-08T00:00:00.0000000Z"; typeof(KVUserService).GetProperty("LoginTime").SetValue(user, session.ServerTimeUtc); var captured = user.LoginTime.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); var remote = new Dictionary { ["LastLoginTimeUtc"] = oldValue }; Dictionary sent = null; TaskCompletionSource entered = null, release = null; bool fail = false; bridge = new PlayerKVGameSessionBridge(_ => { var ownerRemote = remote; return new ValidationHttp(null, async request => { if (request.RequestUri.AbsolutePath.EndsWith("/Get", StringComparison.Ordinal)) { return ValidationJson(new { data = ownerRemote }); } sends++; Dictionary body; using (var reader = new Newtonsoft.Json.JsonTextReader(new System.IO.StringReader(await request.Content.ReadAsStringAsync()))) { reader.DateParseHandling = DateParseHandling.None; body = Newtonsoft.Json.Linq.JObject.Load(reader)["data"].ToObject>(); } sent = body; var requestRelease = release; var requestFail = fail; entered.TrySetResult(true); await requestRelease.Task; if (!requestFail) { foreach (var pair in body) { ownerRemote[pair.Key] = pair.Value; } } var response = ValidationJson(new { }); response.StatusCode = requestFail ? System.Net.HttpStatusCode.InternalServerError : System.Net.HttpStatusCode.OK; return response; }); }); var root = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../CodexOutput/upserver/B3BatchMigration/M2/P6-2026-10-09/unity-cache", Guid.NewGuid().ToString("N"))); async Task Open(string identity, long epoch) { var opening = bridge.OpenAsync(session, root, identity, api, epoch, _ => true, default); tasks.Add(opening); await P6AWait(opening); Check(opening.Result.Success, "real owner opened " + identity); var result = new KVPlayerSaveStore(); result.Bind(opening.Result.Handle, default); resources.Add(result); return result; } void Gate() { entered = new TaskCompletionSource(); release = new TaskCompletionSource(); releases.Add(release); } var store = await Open("101", 1); Check(store.Write("P6A-Unrelated", "pending").Accepted, "unrelated ordinary write accepted"); Gate(); var save = Observe(store, captured); await P6AWait(entered.Task); bool callerContinued = true; Check(callerContinued && !save.IsCompleted && sends == 1, "delayed real helper returns pending Task to caller"); Check(sent.Count == 1 && sent["LastLoginTimeUtc"] == captured && !remote.ContainsKey("P6A-Unrelated"), "one captured key excludes unrelated pending"); Check(store.Read("LastLoginTimeUtc").Value == oldValue && user.LastLoginTime == today.AddDays(-1), "old visible times retained before response"); release.TrySetResult(true); await P6AWait(save); Check(remote["LastLoginTimeUtc"] == captured && store.Read("LastLoginTimeUtc").Value == oldValue && !remote.ContainsKey("P6A-Unrelated"), "confirmed single write preserves local old value and unrelated pending"); var firstClose = bridge.CloseAsync(); tasks.Add(firstClose); await P6AWait(firstClose); store = await Open("101", 2); Check(store.Read("LastLoginTimeUtc").Value == captured, "new owner Get sees saved timestamp"); cases.Add("single-key-delayed-success-and-reopen"); fail = true; Gate(); logStart = logs.Count; save = Observe(store, "2026-10-10T00:00:00.0000000Z"); await P6AWait(entered.Task); release.TrySetResult(true); await P6AWait(save); Check(logs.Skip(logStart).Count(message => message == "[KVMgr::SyncDataImmidiate] Save failed.") == 1 && sends == 2 && remote["LastLoginTimeUtc"] == captured, "500 logged once without retry or false success"); expectedErrors++; cases.Add("http-500"); fail = false; Gate(); save = Observe(store, captured); await P6AWait(entered.Task); var oldStore = store; logStart = logs.Count; var close = bridge.CloseAsync(); tasks.Add(close); var replacementContainer = new asap.core.Container(); ValidationContextAccess.Use(replacementContainer); release.TrySetResult(true); await P6AWait(save); await P6AWait(close); var closeErrors = logs.Skip(logStart).ToArray(); Check(closeErrors.All(message => message == "[KVMgr::SyncDataImmidiate] Save failed.") && closeErrors.Length <= 1, "revoked in-flight helper uses existing failure semantics"); expectedErrors += closeErrors.Length; remote = new Dictionary { ["LastLoginTimeUtc"] = "other-owner" }; var otherLogin = session.LoginAsync("102"); tasks.Add(otherLogin); await P6AWait(otherLogin); Check(otherLogin.Result.Success && session.PlayerId == "102", "replacement account authenticated with separate cookie jar"); store = await Open("102", 3); asap.core.ContainerExtensions.RegisterInstance(replacementContainer, store); var sendBefore = sends; await P6AWait(Observe(oldStore, "must-not-reach-new-owner")); Check(sends == sendBefore && remote["LastLoginTimeUtc"] == "other-owner" && store.Read("LastLoginTimeUtc").Value == "other-owner", "stale captured store never resolves replacement account"); cases.Add("revoked-owner-isolation"); il = P6AInspectStart(Check); cases.Add("actual-start-movenext-il"); Check(logs.Count == expectedErrors, "all error logs accounted for per case"); } catch (Exception exception) { failure = exception.ToString(); } finally { foreach (var release in releases) { release.TrySetResult(true); } foreach (var task in tasks) { try { await P6AWait(task); } catch (Exception exception) { errors.Add("task cleanup: " + exception); } } try { if (bridge != null) { var finalClose = bridge.CloseAsync(); tasks.Add(finalClose); await P6AWait(finalClose); } } catch (Exception exception) { errors.Add("owner cleanup: " + exception); } foreach (var resource in resources.AsEnumerable().Reverse()) { try { resource.Dispose(); } catch (Exception exception) { errors.Add("resource cleanup: " + exception); } } try { user?.Logout(); } catch (Exception exception) { errors.Add("user cleanup: " + exception); } finally { ValidationContextAccess.Use(originalContainer); ValidationContextAccess.Events = originalEvents; restored = ReferenceEquals(asap.core.GContext.container, originalContainer) && ReferenceEquals(ValidationContextAccess.Events, originalEvents); UnityEngine.Application.logMessageReceived -= Log; running = false; } } return JsonConvert.SerializeObject(new { success = failure == null && errors.Count == 0 && restored && logs.Count == expectedErrors, caseId = "P6A-InitializationLastLogin", assertions, cases, sends, expectedErrors, logs, il, restored, failure, errors, cleanupPending = tasks.Any(task => !task.IsCompleted), fullFishingStageExecuted = false, runtimeFramesVerified = false, formalRequests = 0 }); } private const System.Reflection.BindingFlags RetryInstance = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public; private const System.Reflection.BindingFlags RetryStatic = System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public; private static RetryRun failedRetryCleanup; private static async Task RetryWait(Func ready) { var deadline = DateTime.UtcNow.AddSeconds(8); while (!ready()) { if (DateTime.UtcNow >= deadline) { throw new TimeoutException("Login UI observation timed out."); } await Task.Delay(10); } } private static async Task RetryComplete(Task work) { if (await Task.WhenAny(work, Task.Delay(8000)) != work) { throw new TimeoutException("Login UI task did not close."); } await work; } // Retains the original snapshots if a UI task cannot be closed; Editor validation only. private sealed class RetryRun { private readonly System.Reflection.PropertyInfo selectionProperty = typeof(SubBoostrap).GetProperty("RuntimeSelection", RetryStatic); private readonly System.Reflection.FieldInfo tableField = typeof(GameCore.LocalizationMgr).GetField("table", RetryStatic); private readonly System.Reflection.FieldInfo syncField = typeof(UnityAsyncAwaitUtil.SyncContextUtil).GetField("k__BackingField", RetryStatic); private readonly System.Reflection.FieldInfo threadField = typeof(UnityAsyncAwaitUtil.SyncContextUtil).GetField("k__BackingField", RetryStatic); private readonly System.Reflection.FieldInfo runnerField = typeof(UnityAsyncAwaitUtil.AsyncCoroutineRunner).GetField("_instance", RetryStatic); private readonly object previousSelection; private readonly object previousTable; private readonly List previousLanguages; private readonly bool hadId; private readonly string previousId; private readonly object previousSync; private readonly object previousThread; private readonly object previousRunner; private readonly System.Threading.SynchronizationContext context; private readonly int thread; private UnityAsyncAwaitUtil.AsyncCoroutineRunner ownedRunner; private bool subscribed; internal RetryCase Pending { get; set; } internal List Errors { get; } = new List(); internal int StoppedRunners { get; private set; } internal bool Restored { get; private set; } internal RetryRun() { context = System.Threading.SynchronizationContext.Current; thread = System.Threading.Thread.CurrentThread.ManagedThreadId; previousRunner = runnerField.GetValue(null); if (context == null || context.GetType().FullName != "UnityEngine.UnitySynchronizationContext" || previousRunner != null || UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0) { throw new InvalidOperationException("Login UI validation requires the Unity context and no existing coroutine runner."); } previousSelection = SubBoostrap.RuntimeSelection; previousTable = tableField.GetValue(null); previousLanguages = GameCore.LocalizationMgr.enumLanguage; hadId = UnityEngine.PlayerPrefs.HasKey("CustomID"); previousId = UnityEngine.PlayerPrefs.GetString("CustomID", string.Empty); previousSync = syncField.GetValue(null); previousThread = threadField.GetValue(null); } internal void Install() { UnityEngine.Application.logMessageReceived += Log; subscribed = true; syncField.SetValue(null, context); threadField.SetValue(null, thread); selectionProperty.SetValue(null, PlayerKVRuntimeSelection.Capture("kv", PlayerKVRuntimeSelection.FormalApiUrl, false)); var path = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../cfgData/tbtext.json")); tableField.SetValue(null, new cfg.TbText(SimpleJSON.JSON.Parse(System.IO.File.ReadAllText(path)))); GameCore.LocalizationMgr.enumLanguage = Enumerable.Repeat("en", Math.Max(1, GameCore.LocalizationMgr.LanguageIndex + 1)).ToList(); } private void Log(string message, string stack, UnityEngine.LogType type) { if (type == UnityEngine.LogType.Error || type == UnityEngine.LogType.Exception) { Errors.Add(message + stack); } } private void CheckContext() { if (!ReferenceEquals(System.Threading.SynchronizationContext.Current, context) || System.Threading.Thread.CurrentThread.ManagedThreadId != thread) { throw new InvalidOperationException("Login UI validation left its original Unity context."); } } internal void SetBackend(bool kv) { CheckContext(); selectionProperty.SetValue(null, PlayerKVRuntimeSelection.Capture(kv ? "kv" : "playfab", PlayerKVRuntimeSelection.FormalApiUrl, false)); } internal void OpenRunner() { CheckContext(); if (runnerField.GetValue(null) != null || UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0) { throw new InvalidOperationException("An existing coroutine runner is protected."); } ownedRunner = UnityAsyncAwaitUtil.AsyncCoroutineRunner.Instance; ownedRunner.gameObject.hideFlags = UnityEngine.HideFlags.HideAndDontSave; } internal void StopRunner() { CheckContext(); if (!ReferenceEquals(runnerField.GetValue(null), ownedRunner)) { throw new InvalidOperationException("Login UI coroutine runner ownership changed."); } if (ownedRunner != null) { ownedRunner.StopAllCoroutines(); UnityEngine.Object.DestroyImmediate(ownedRunner.gameObject); runnerField.SetValue(null, previousRunner); ownedRunner = null; StoppedRunners++; } if (UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0) { throw new InvalidOperationException("Login UI coroutine runner was not removed."); } } internal bool Restore() { StopRunner(); selectionProperty.SetValue(null, previousSelection); tableField.SetValue(null, previousTable); GameCore.LocalizationMgr.enumLanguage = previousLanguages; if (hadId) { UnityEngine.PlayerPrefs.SetString("CustomID", previousId); } else { UnityEngine.PlayerPrefs.DeleteKey("CustomID"); } syncField.SetValue(null, previousSync); threadField.SetValue(null, previousThread); Restored = ReferenceEquals(SubBoostrap.RuntimeSelection, previousSelection) && ReferenceEquals(tableField.GetValue(null), previousTable) && ReferenceEquals(GameCore.LocalizationMgr.enumLanguage, previousLanguages) && UnityEngine.PlayerPrefs.HasKey("CustomID") == hadId && UnityEngine.PlayerPrefs.GetString("CustomID", string.Empty) == previousId && ReferenceEquals(syncField.GetValue(null), previousSync) && Equals(threadField.GetValue(null), previousThread) && ReferenceEquals(runnerField.GetValue(null), previousRunner) && Pending == null; if (Restored && subscribed) { UnityEngine.Application.logMessageReceived -= Log; subscribed = false; } return Restored; } } private sealed class RetryConfig : asap.core.IConfig { private readonly Dictionary values = new Dictionary(); internal RetryConfig(string id) { Set("CustomID", id); } public void Set(string key, object value) { values[key] = value; } public T Get(string key, T fallback) { return values.TryGetValue(key, out var value) ? (T)value : fallback; } public string this[string key] { get { return Get(key, string.Empty); } } public float GetFloat(string key, float fallback) { return Get(key, fallback); } } // UI responses only; the overridden Login never creates an HTTP handler or calls base.Login. private sealed class RetryUser : KVUserService, IKVUserLifecycle { private readonly Queue steps; private bool ready; internal List TokenCalls { get; } = new List(); internal List Ids { get; } = new List(); internal int Logouts { get; private set; } internal bool HoldLogin { get; set; } internal RetryUser(ELoginStep[] steps) { this.steps = new Queue(steps); } public override Task Login(Action progress, bool tyrLoginByToken = true) { LoginLifetime.ThrowIfCancellationRequested(); TokenCalls.Add(tyrLoginByToken); Ids.Add(CustomId); var step = steps.Dequeue(); ready = step == ELoginStep.Done; progress?.Invoke(2); if (HoldLogin) { return HoldLoginAsync(step); } return Task.FromResult(step); } private async Task HoldLoginAsync(ELoginStep step) { await Task.Delay(System.Threading.Timeout.Infinite, LoginLifetime); return step; } public override void Logout() { Logouts++; base.Logout(); } bool IKVUserLifecycle.KVHandoffReady => ready && !LoginLifetime.IsCancellationRequested; PlayerKVGameLoadError IKVUserLifecycle.KVLastError => PlayerKVGameLoadError.Prepare; Task IKVUserLifecycle.CloseKVForValidationAsync() { return CloseKVForValidationAsync(); } Task IKVUserLifecycle.ObserveKVQuitAsync() { return ObserveKVQuitAsync(); } } private sealed class RetryRT : IRTService { internal int Waits { get; private set; } public bool IsConnected => false; public void WaitForAPISvrLogin() { Waits++; } public Task Connect() { throw new InvalidOperationException("UI probe cannot connect."); } public Task Disconnect() { throw new InvalidOperationException("UI probe cannot disconnect."); } public IDisposable OnEvent(Action handler) { throw new InvalidOperationException("UI probe cannot subscribe to RT."); } public Task Publish(string method, string value) { throw new InvalidOperationException("UI probe cannot publish."); } public Task Publish(string method, string value, string other) { throw new InvalidOperationException("UI probe cannot publish."); } public void Release() { } public Task ReleaseAsync() { return Task.CompletedTask; } } private sealed class RetrySuccessServices : IInGameMailService, IIAPService { internal readonly List Calls = new List(); void IInGameMailService.Init() { Calls.Add("mail"); } void IIAPService.Init() { Calls.Add("iap"); } public bool IsUnlock { get { throw new InvalidOperationException("Unexpected mail access in login UI validation."); } set { throw new InvalidOperationException("Unexpected mail access in login UI validation."); } } public void Reset() { throw new InvalidOperationException("Unexpected mail reset in login UI validation."); } public Task FetchMail() { throw new InvalidOperationException("Unexpected mail fetch in login UI validation."); } public IList GetAllMail() { throw new InvalidOperationException("Unexpected mail read in login UI validation."); } public Task MarkRead(GameMail mail) { throw new InvalidOperationException("Unexpected mail write in login UI validation."); } public Task MarkRead(GameMail[] mails = null) { throw new InvalidOperationException("Unexpected mail write in login UI validation."); } public Task DelMail(GameMail mail) { throw new InvalidOperationException("Unexpected mail deletion in login UI validation."); } public Task DelMail(GameMail[] mails = null) { throw new InvalidOperationException("Unexpected mail deletion in login UI validation."); } public Task TakeReward(GameMail mail) { throw new InvalidOperationException("Unexpected reward in login UI validation."); } public Task TakeReward(GameMail[] mails = null) { throw new InvalidOperationException("Unexpected reward in login UI validation."); } public Task> GetSKUs() { throw new InvalidOperationException("Unexpected SKU request in login UI validation."); } public Task Pay(Action callback, string data, tysdk.SKUDetail sku, int count, float usdPrice, Newtonsoft.Json.Linq.JObject extra) { throw new InvalidOperationException("Unexpected payment in login UI validation."); } public Task> GetOrderUnreceived() { throw new InvalidOperationException("Unexpected order request in login UI validation."); } public Task ConfirmOrders(List ids) { throw new InvalidOperationException("Unexpected order confirmation in login UI validation."); } public void Release() { throw new InvalidOperationException("Unexpected IAP release in login UI validation."); } } private sealed class RetryCase { private readonly List objects = new List(); private SimpleDialog dialog; private UnityEngine.GameObject input; private UnityEngine.UI.InputField inputField; private UnityEngine.UI.Button inputOk; private System.Threading.CancellationTokenSource lifetime; internal PFLoginAct Act { get; private set; } internal RetryUser User { get; private set; } internal RetryRT RT { get; private set; } internal Task Work { get; private set; } internal RetrySuccessServices SuccessServices { get; private set; } internal int RemainingObjects => objects.Count(value => value != null); internal bool InputOpen => input.activeSelf; private UnityEngine.GameObject Object(string name) { var value = new UnityEngine.GameObject("M03B-" + name, typeof(UnityEngine.RectTransform)); value.hideFlags = UnityEngine.HideFlags.HideAndDontSave; value.SetActive(false); objects.Add(value); return value; } private static void Field(object target, string name, object value) { target.GetType().GetField(name, RetryInstance).SetValue(target, value); } internal void Setup(string id, ELoginStep[] steps) { User = new RetryUser(steps); RT = new RetryRT(); Act = Object("act").AddComponent(); Act.userService = User; Act.config = new RetryConfig(id); Act.rtService = RT; SuccessServices = new RetrySuccessServices(); Act.MailService = SuccessServices; Act.iapService = SuccessServices; // Stage remains null: the real Unload returns without changing an Editor scene. dialog = Object("dialog").AddComponent(); var ok = Object("ok").AddComponent(); var close = Object("close").AddComponent(); Field(dialog, "btn_ok", ok); Field(dialog, "btn_close", close); input = Object("input"); inputField = input.AddComponent(); inputOk = Object("input-ok").AddComponent(); Field(Act, "dialog", dialog); Field(Act, "customInput", input); Field(Act, "customIdInput", inputField); Field(Act, "inputOkBtn", inputOk); Field(Act, "loadingInfos", new[] { "synthetic loading" }); lifetime = System.Threading.CancellationTokenSource.CreateLinkedTokenSource(User.LoginLifetime); Field(Act, "loginLifetime", lifetime); Field(Act, "loginToken", lifetime.Token); } internal void Start() { Work = (Task)typeof(PFLoginAct).GetMethod("LoginAsync", RetryInstance).Invoke(Act, null); } internal void StartThroughDoLogin() { typeof(PFLoginAct).GetMethod("DoLogin", RetryInstance).Invoke(Act, null); Work = (Task)typeof(PFLoginAct).GetField("loginWork", RetryInstance).GetValue(Act); if (Work == null) { throw new InvalidOperationException("Actual DoLogin did not retain its LoginAsync task."); } } internal void DestroyLoginAct() { typeof(PFLoginAct).GetMethod("OnDestroy", RetryInstance).Invoke(Act, null); lifetime = null; } internal Action Callback(bool close) { return dialog.gameObject.activeSelf ? (Action)typeof(SimpleDialog).GetField(close ? "onClose" : "onOk", RetryInstance).GetValue(dialog) : null; } internal void Click(bool close) { // Idle Editor lifecycle is fixture-driven; invoke the actual component methods. typeof(SimpleDialog).GetMethod("OnDisable", RetryInstance).Invoke(dialog, null); typeof(SimpleDialog).GetMethod("OnEnable", RetryInstance).Invoke(dialog, null); var button = (UnityEngine.UI.Button)typeof(SimpleDialog).GetField(close ? "btn_close" : "btn_ok", RetryInstance).GetValue(dialog); button.onClick.Invoke(); } internal void Input(string id) { inputField.text = id; inputOk.onClick.Invoke(); } internal async Task Close(RetryRun run) { User?.Logout(); lifetime?.Cancel(); if (Act != null) { typeof(PFLoginAct).GetMethod("StopLoadingInfo", RetryInstance).Invoke(Act, null); } if (Work != null) { try { await RetryComplete(Work); } catch (Exception) when (Work.IsCompleted) { // The case evaluates its task; cleanup only observes a closed failure/cancellation. } } // The original runtime frame does not advance in this idle Editor. // Stop only this fixture's coroutine source, never complete the awaiter by hand. run.StopRunner(); foreach (var value in objects) { UnityEngine.Object.DestroyImmediate(value); } lifetime?.Dispose(); if (RemainingObjects != 0) { throw new InvalidOperationException("Login UI objects were not removed."); } } } private static async Task RunLoginRetry() { if (running || failedRetryCleanup != null || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || PlayerKVGameplayLoop.Enabled || asap.core.GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0 || PlayFabMgr.HasLiveInstanceForKVLoad || typeof(LoadingScreen).GetField("_instance", RetryStatic).GetValue(null) != null) { throw new InvalidOperationException("Login UI validation requires an idle Editor with no live loading screen."); } var run = new RetryRun(); failedRetryCleanup = run; running = true; var cases = new List(); string failure = null; var assertions = 0; var restored = false; void Check(bool value, string label) { assertions++; if (!value) { throw new InvalidOperationException("Login UI: " + label); } } async Task Case(string name, string id, ELoginStep[] steps, Func body) { var probe = new RetryCase(); run.Pending = probe; try { run.OpenRunner(); probe.Setup(id, steps); probe.Start(); await body(probe); Check(probe.Work.Status == TaskStatus.RanToCompletion ? probe.SuccessServices.Calls.SequenceEqual(new[] { "mail", "iap" }) : probe.SuccessServices.Calls.Count == 0, "only successful login initializes mail then IAP exactly once"); cases.Add(new { name, calls = probe.User.TokenCalls.ToArray(), ids = probe.User.Ids.ToArray(), logoutsBeforeDestroy = probe.User.Logouts }); } finally { await probe.Close(run); run.Pending = null; } } try { run.Install(); await Case("first-done", "101", new[] { ELoginStep.Done }, async probe => { await RetryComplete(probe.Work); Check(probe.User.TokenCalls.Count == 1 && probe.User.Logouts == 0 && probe.Callback(false) == null, "first success completes without failure UI or logout"); }); foreach (var close in new[] { false, true }) { await Case(close ? "retry-close" : "retry-ok", "101", new[] { ELoginStep.Api, ELoginStep.Done }, async probe => { await RetryWait(() => probe.Callback(close) != null); Check(probe.User.TokenCalls.Count == 1 && probe.User.Logouts == 0 && !probe.Work.IsCompleted, "failure reaches the original dialog without ending the service"); var old = probe.Callback(close); probe.Click(close); old(); await RetryComplete(probe.Work); Check(probe.User.TokenCalls.Count == 2 && probe.User.TokenCalls.All(value => value) && probe.User.Logouts == 0, "both original buttons retry with true once and complete login"); }); } await Case("retry-repeated", "101", new[] { ELoginStep.Api, ELoginStep.Api, ELoginStep.Done }, async probe => { await RetryWait(() => probe.Callback(false) != null); var old = probe.Callback(false); probe.Click(false); await RetryWait(() => probe.Callback(true) != null && !ReferenceEquals(probe.Callback(true), old)); Check(probe.User.TokenCalls.Count == 2 && probe.User.Logouts == 0, "another failure reopens the same original retry loop"); probe.Click(true); await RetryComplete(probe.Work); Check(probe.User.TokenCalls.Count == 3 && probe.User.TokenCalls.All(value => value) && probe.RT.Waits == 1, "repeated failures retain the original option and single RT preparation"); }); await Case("cancel-dialog-old-click", "101", new[] { ELoginStep.Api }, async probe => { await RetryWait(() => probe.Callback(false) != null); var old = probe.Callback(false); probe.User.Logout(); try { await RetryComplete(probe.Work); throw new InvalidOperationException("Cancelled retry completed successfully."); } catch (OperationCanceledException) { Check(probe.Work.IsCanceled, "actual retry task is cancelled"); } old(); Check(probe.User.TokenCalls.Count == 1 && probe.User.Logouts == 1, "old dialog callback cannot re-enter login after logout"); }); await Case("input-and-retry-input", "{{input}}", new[] { ELoginStep.Api, ELoginStep.Done }, async probe => { await RetryWait(() => probe.InputOpen); probe.Input("101"); await RetryWait(() => probe.Callback(false) != null); Check(probe.User.CustomId == "101" && UnityEngine.PlayerPrefs.GetString("CustomID") == "101", "original input stores first identity"); probe.Click(false); await RetryWait(() => probe.InputOpen); probe.Input("102"); await RetryComplete(probe.Work); Check(probe.User.Ids.SequenceEqual(new[] { "101", "102" }) && probe.User.TokenCalls.All(value => value) && probe.User.Logouts == 0 && UnityEngine.PlayerPrefs.GetString("CustomID") == "102", "original retry input uses the new ID without restarting"); }); await Case("cancel-input-old-click", "{{input}}", new[] { ELoginStep.Done }, async probe => { await RetryWait(() => probe.InputOpen); var unchangedId = probe.User.CustomId; var unchangedPref = UnityEngine.PlayerPrefs.GetString("CustomID", string.Empty); probe.User.Logout(); try { await RetryComplete(probe.Work); throw new InvalidOperationException("Cancelled input completed successfully."); } catch (OperationCanceledException) { Check(probe.Work.IsCanceled, "actual input task is cancelled"); } probe.Input("101"); Check(probe.User.TokenCalls.Count == 0 && probe.User.CustomId == unchangedId && UnityEngine.PlayerPrefs.GetString("CustomID", string.Empty) == unchangedPref, "old input click cannot alter identity or enter login"); }); } catch (Exception ex) { failure = ex.ToString(); } finally { if (run.Pending == null) { try { restored = run.Restore(); if (restored) { failedRetryCleanup = null; running = false; } } catch (Exception ex) { failure = failure ?? ex.ToString(); } } } return JsonConvert.SerializeObject(new { success = failure == null && run.Errors.Count == 0 && restored && cases.Count == 7, caseId = "M03B-LoginRetry", assertions, cases, stoppedOwnedRunners = run.StoppedRunners, remainingOwnedObjects = run.Pending?.RemainingObjects ?? 0, restored, failure, errors = run.Errors, runtimeFramesVerified = false, cleanupMode = "owned-runner-stopped", editorLifecycleDriven = true, formalRequests = 0, realKVLoginExecuted = false, cleanupPending = failedRetryCleanup != null }); } private static async Task CleanupLoginRetry() { var run = failedRetryCleanup; if (run == null || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating) { throw new InvalidOperationException("No pending login UI owner can be cleaned in this Editor state."); } string failure = null; try { if (run.Pending != null) { await run.Pending.Close(run); run.Pending = null; } if (run.Restore()) { failedRetryCleanup = null; running = false; } } catch (Exception ex) { failure = ex.ToString(); } return JsonConvert.SerializeObject(new { success = run.Restored, caseId = "M03B-LoginRetry-Cleanup", cleanupOnly = true, restored = run.Restored, failure, errors = run.Errors, cleanupPending = failedRetryCleanup != null, remainingOwnedObjects = run.Pending?.RemainingObjects ?? 0, runtimeFramesVerified = false, formalRequests = 0 }); } private static async Task RunCustomId() { if (running || failedRetryCleanup != null || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || PlayerKVGameplayLoop.Enabled || asap.core.GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0 || PlayFabMgr.HasLiveInstanceForKVLoad || typeof(LoadingScreen).GetField("_instance", RetryStatic).GetValue(null) != null) { throw new InvalidOperationException("Custom ID validation requires an idle Editor."); } var run = new RetryRun(); failedRetryCleanup = run; running = true; var cases = new List(); var assertions = 0; string failure = null; var restored = false; void Check(bool value, string label) { assertions++; if (!value) { throw new InvalidOperationException("Custom ID: " + label); } } try { run.Install(); foreach (var input in new[] { new { kv = true, config = "101", expected = "101" }, new { kv = false, config = "101", expected = "102" }, new { kv = false, config = string.Empty, expected = string.Empty } }) { var probe = new RetryCase(); run.Pending = probe; try { run.SetBackend(input.kv); run.OpenRunner(); probe.Setup(input.config, new[] { ELoginStep.Api }); probe.User.CustomId = "102"; probe.Start(); await RetryWait(() => probe.Callback(false) != null); Check(probe.User.Ids.SequenceEqual(new[] { input.expected }) && probe.User.CustomId == input.expected, "actual Login receives the original backend-specific identity"); Check(probe.User.TokenCalls.SequenceEqual(new[] { true }) && probe.User.Logouts == 0 && !probe.Work.IsCompleted, "one real UI Login call reaches the original retry dialog"); probe.User.Logout(); var cancelled = false; try { await RetryComplete(probe.Work); } catch (OperationCanceledException) { cancelled = probe.Work.IsCanceled; } Check(cancelled, "actual Login task is cancelled before fixture cleanup"); Check(probe.SuccessServices.Calls.Count == 0, "cancelled identity probe does not initialize mail or IAP"); cases.Add(new { backend = input.kv ? "kv" : "playfab", input.config, ids = probe.User.Ids.ToArray(), calls = probe.User.TokenCalls.ToArray(), cancelled }); } finally { await probe.Close(run); run.Pending = null; } } } catch (Exception ex) { failure = ex.ToString(); } finally { if (run.Pending == null) { try { restored = run.Restore(); if (restored) { failedRetryCleanup = null; running = false; } } catch (Exception ex) { failure = failure ?? ex.ToString(); } } } return JsonConvert.SerializeObject(new { success = failure == null && run.Errors.Count == 0 && restored && cases.Count == 3, caseId = "M03C-CustomId", assertions, cases, stoppedOwnedRunners = run.StoppedRunners, remainingOwnedObjects = run.Pending?.RemainingObjects ?? 0, restored, failure, errors = run.Errors, runtimeFramesVerified = false, cleanupMode = "owned-runner-stopped", editorLifecycleDriven = true, formalRequests = 0, realKVLoginExecuted = false, cleanupPending = failedRetryCleanup != null }); } private static async Task RunDestroyBoundary() { if (running || failedRetryCleanup != null || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || PlayerKVGameplayLoop.Enabled || asap.core.GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0 || PlayFabMgr.HasLiveInstanceForKVLoad || typeof(LoadingScreen).GetField("_instance", RetryStatic).GetValue(null) != null) { throw new InvalidOperationException("Login destruction validation requires an idle Editor."); } var run = new RetryRun(); failedRetryCleanup = run; running = true; var cases = new List(); var assertions = 0; string failure = null; var restored = false; void Check(bool value, string label) { assertions++; if (!value) { throw new InvalidOperationException("Login destruction: " + label); } } try { run.Install(); var names = new[] { "done-kept", "retry-cancelled", "input-cancelled", "ready-but-pending", "pf-kept", "not-started-kept" }; for (var kind = 0; kind < names.Length; kind++) { var probe = new RetryCase(); run.Pending = probe; try { run.SetBackend(kind != 4); run.OpenRunner(); probe.Setup(kind == 2 ? "{{input}}" : "101", new[] { kind == 0 || kind == 3 || kind == 5 ? ELoginStep.Done : ELoginStep.Api }); probe.User.CustomId = "102"; probe.User.HoldLogin = kind == 3; Action oldCallback = null; if (kind == 5) { await probe.User.Login(null); Check(probe.Work == null && typeof(PFLoginAct).GetField("loginWork", RetryInstance).GetValue(probe.Act) == null, "unstarted Act has no account work"); } else { probe.StartThroughDoLogin(); Check(ReferenceEquals(probe.Work, typeof(PFLoginAct).GetField("loginWork", RetryInstance).GetValue(probe.Act)), "actual DoLogin awaits the retained LoginAsync task"); if (kind == 0) { await RetryComplete(probe.Work); Check(probe.Work.Status == TaskStatus.RanToCompletion, "actual login completed before destruction"); } else if (kind == 1 || kind == 4) { await RetryWait(() => probe.Callback(false) != null); oldCallback = probe.Callback(false); Check(!probe.Work.IsCompleted && probe.User.TokenCalls.SequenceEqual(new[] { true }), "actual retry is waiting"); } else if (kind == 2) { await RetryWait(() => probe.InputOpen); Check(!probe.Work.IsCompleted && probe.User.TokenCalls.Count == 0, "actual input is waiting before Login"); } else { await RetryWait(() => probe.User.TokenCalls.Count == 1); Check(!probe.Work.IsCompleted && ((IKVUserLifecycle)probe.User).KVHandoffReady, "ready alone cannot finish the actual login work"); } } var beforeTask = probe.Work?.Status.ToString() ?? "not-started"; Check(kind == 0 ? probe.SuccessServices.Calls.SequenceEqual(new[] { "mail", "iap" }) : probe.SuccessServices.Calls.Count == 0, "only completed login initializes mail then IAP exactly once"); var beforeServices = probe.SuccessServices.Calls.ToArray(); var beforeCalls = probe.User.TokenCalls.Count; var beforeId = probe.User.CustomId; var readyBefore = ((IKVUserLifecycle)probe.User).KVHandoffReady; Check(!probe.User.LoginLifetime.IsCancellationRequested && probe.User.Logouts == 0, "no fixture Logout before destruction"); probe.DestroyLoginAct(); var actToken = (System.Threading.CancellationToken)typeof(PFLoginAct).GetField("loginToken", RetryInstance).GetValue(probe.Act); var revoke = kind == 1 || kind == 2 || kind == 3; Check(actToken.IsCancellationRequested, "actual OnDestroy cancels its child token"); Check(probe.User.Logouts == (revoke ? 1 : 0) && probe.User.LoginLifetime.IsCancellationRequested == revoke, "only unfinished KV work revokes its parent lifetime"); if (probe.Work != null && kind != 0) { var cancelled = false; try { await RetryComplete(probe.Work); } catch (OperationCanceledException) { cancelled = probe.Work.IsCanceled; } Check(cancelled, "actual work ends cancelled before fixture cleanup"); } else { Check(probe.Work == null || probe.Work.Status == TaskStatus.RanToCompletion, "successful or unstarted work remains intact"); } oldCallback?.Invoke(); if (kind == 2) { probe.Input("101"); } Check(probe.User.TokenCalls.Count == beforeCalls && probe.User.CustomId == beforeId, "old UI callback cannot login or change identity"); Check(((IKVUserLifecycle)probe.User).KVHandoffReady == (!revoke && readyBefore), "completed valid handoff is retained"); Check(probe.SuccessServices.Calls.SequenceEqual(beforeServices), "destruction and old callbacks do not initialize services"); cases.Add(new { name = names[kind], taskBefore = beforeTask, taskAfter = probe.Work?.Status.ToString() ?? "not-started", actCancelled = actToken.IsCancellationRequested, parentCancelled = probe.User.LoginLifetime.IsCancellationRequested, logouts = probe.User.Logouts, callsBefore = beforeCalls, callsAfter = probe.User.TokenCalls.Count, readyBefore, readyAfter = ((IKVUserLifecycle)probe.User).KVHandoffReady }); } finally { await probe.Close(run); run.Pending = null; } } } catch (Exception ex) { failure = ex.ToString(); } finally { if (run.Pending == null) { try { restored = run.Restore(); if (restored) { failedRetryCleanup = null; running = false; } } catch (Exception ex) { failure = failure ?? ex.ToString(); } } } return JsonConvert.SerializeObject(new { success = failure == null && run.Errors.Count == 0 && restored && cases.Count == 6, caseId = "M03D-Destroy", assertions, cases, stoppedOwnedRunners = run.StoppedRunners, remainingOwnedObjects = run.Pending?.RemainingObjects ?? 0, restored, failure, errors = run.Errors, runtimeFramesVerified = false, cleanupMode = "owned-runner-stopped", editorLifecycleDriven = true, formalRequests = 0, realKVLoginExecuted = false, cleanupPending = failedRetryCleanup != null }); } private static async Task RunImmediateConsumers() { if (running || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || asap.core.GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0 || PlayerKVGameplayLoop.Enabled) { throw new InvalidOperationException("Immediate validation requires an idle isolated Editor."); } running = true; var originalContainer = asap.core.GContext.container; var originalEvents = ValidationContextAccess.Events; var isolated = new asap.core.Container(); var bridge = default(PlayerKVGameSessionBridge); var session = default(game.Account.S101AccountSession); var shell = default(UnityEngine.GameObject); var store = default(KVPlayerSaveStore); var subscriptions = new List(); var release = default(TaskCompletionSource); var errors = new List(); var messages = new List(); int assertions = 0, sends = 0, consumeEvents = 0; string failure = null; bool restored = false; void Check(bool condition) { assertions++; if (!condition) { throw new InvalidOperationException("Immediate consumer assertion " + assertions); } } void Set(object target, string name, object value) { target.GetType().GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(target, value); } void Log(string message, string stack, UnityEngine.LogType type) { messages.Add(message); if (type == UnityEngine.LogType.Exception) { errors.Add(message + stack); } } UnityEngine.Application.logMessageReceived += Log; try { var directory = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../cfgData")); var tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(System.IO.File.ReadAllText(System.IO.Path.Combine(directory, name + ".json")))); var api = new Uri("http://synthetic.invalid/"); session = new game.Account.S101AccountSession(api, jar => new ValidationHttp(_ => { jar.Add(api, new System.Net.Cookie("sid", "module01")); return ValidationJson(new { state = 0, internalId = 17, newCreate = false, createTime = "2026-01-01T00:00:00Z", svrTime = "2026-10-09T00:00:00Z" }); })); Check((await session.LoginAsync("101")).Success); var remote = new Dictionary(); Dictionary sent = null; var entered = default(TaskCompletionSource); bool fail = false; bridge = new PlayerKVGameSessionBridge(_ => new ValidationHttp(null, async request => { if (request.RequestUri.AbsolutePath.EndsWith("/Get", StringComparison.Ordinal)) { return ValidationJson(new { data = remote }); } sends++; sent = Newtonsoft.Json.Linq.JObject.Parse(await request.Content.ReadAsStringAsync())["data"].ToObject>(); entered.TrySetResult(true); await release.Task; var response = ValidationJson(new { }); response.StatusCode = fail ? System.Net.HttpStatusCode.InternalServerError : System.Net.HttpStatusCode.OK; return response; })); var root = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../CodexOutput/upserver/B3BatchMigration/M2/M01-2026-10-09/unity-cache", Guid.NewGuid().ToString("N"))); var opened = await bridge.OpenAsync(session, root, "101", api, 1, _ => true, default); Check(opened.Success); store = new KVPlayerSaveStore(); store.Bind(opened.Handle, default); ValidationContextAccess.Use(isolated); ValidationContextAccess.Events = new asap.core.EventAggregator(); asap.core.ContainerExtensions.RegisterInstance(isolated, tables); var player = new GameCore.PlayerData(tables, null) { playerSaveStore = store }; var small = new GameCore.SmallGameData(tables, null, null); var fish = new GameCore.PlayerFishData(tables, null); var achievements = new AchievementDataManager(tables); var box = new FishingBoxData(); var eventData = new GameCore.FishingEventData(tables); asap.core.ContainerExtensions.RegisterInstance(isolated, player); asap.core.ContainerExtensions.RegisterInstance(isolated, small); asap.core.ContainerExtensions.RegisterInstance(isolated, fish); asap.core.ContainerExtensions.RegisterInstance(isolated, achievements); asap.core.ContainerExtensions.RegisterInstance(isolated, box); asap.core.ContainerExtensions.RegisterInstance(isolated, eventData); eventData.InitData(new Dictionary()); typeof(GameCore.PlayerData).GetProperty("benefitsEnergyLimit").SetValue(player, 10000); Set(player, "vipScoreDic", new Dictionary { [0] = 1 }); subscriptions.Add(asap.core.GContext.OnEvent().Subscribe(_ => consumeEvents++)); shell = new UnityEngine.GameObject("M01 immediate consumers"); shell.SetActive(false); shell.hideFlags = UnityEngine.HideFlags.HideAndDontSave; var fishing = shell.AddComponent(); var panel = shell.AddComponent(); Set(fishing, "saveStore", store); Set(fishing, "_tables", tables); Set(fishing, "fishingData", new FishingData { fishItem = tables.TbItem.DataList.First(item => item.SubType != 21 && item.SubType != 22) }); Set(panel, "saveStore", store); Set(panel, "_fishingEventData", eventData); panel.wheelLevel = tables.TbWheelLevel.DataList.First(); foreach (var shouldFail in new[] { false, true } ) { fail = shouldFail; foreach (var isFishing in new[] { true, false } ) { player.SetEnergy(100, false); player.magnification = 0; Set(panel, "isDataSynced", false); var beforeEnergy = (int)player.Energy; var beforeEvents = consumeEvents; var beforeSends = sends; var logStart = messages.Count; entered = new TaskCompletionSource(); release = new TaskCompletionSource(); var target = isFishing ? (object)fishing : panel; var method = isFishing ? "ConsumeBaitOnPlayfab" : "ConsumeOnPlayfab"; target.GetType().GetMethod(method, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).Invoke(target, null); Check(await Task.WhenAny(entered.Task, Task.Delay(5000)) == entered.Task); var key = isFishing ? "NewPlayerDataDic" : "TurntableRoll"; Check(sent.Count == 1 && sent.ContainsKey(key)); var localBeforeCompletion = store.Read(key); if (isFishing) { Check((int)player.Energy == beforeEnergy - tables.TbEnergyDef.DataList[0].Mag && consumeEvents == beforeEvents + 1); Check(Newtonsoft.Json.Linq.JObject.Parse(sent[key])["energy"].ToString() == ((int)player.Energy).ToString()); } else { Check(!(bool)typeof(FishingTurntablePanel).GetField("isDataSynced", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).GetValue(panel)); Check(sent[key] == (eventData.TurntableCoupon - panel.wheelLevel.CostNumber).ToString()); } release.SetResult(true); var prefix = isFishing ? "[FishBehaviourB::ConsumeBaitOnPlayfab] " : "[FishingTurntablePanel::ConsumeOnPlayfab] "; var deadline = DateTime.UtcNow.AddSeconds(5); while (!messages.Skip(logStart).Contains(prefix + (!fail).ToString()) && errors.Count == 0 && DateTime.UtcNow < deadline) { await Task.Delay(10); } Check(messages.Skip(logStart).Contains(prefix + (!fail).ToString()) && errors.Count == 0); var localAfter = store.Read(key); Check(localBeforeCompletion.Found == localAfter.Found && localBeforeCompletion.Value == localAfter.Value && sends == beforeSends + 1); if (!isFishing) { Check((bool)typeof(FishingTurntablePanel).GetField("isDataSynced", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).GetValue(panel)); } } } } catch (Exception ex) { failure = ex.ToString(); } finally { release?.TrySetResult(true); if (bridge != null) { await bridge.CloseAsync(); } store?.Dispose(); session?.Dispose(); foreach (var subscription in subscriptions) { subscription.Dispose(); } if (shell != null) { UnityEngine.Object.DestroyImmediate(shell); } ValidationContextAccess.Use(originalContainer); ValidationContextAccess.Events = originalEvents; restored = ReferenceEquals(asap.core.GContext.container, originalContainer) && ReferenceEquals(ValidationContextAccess.Events, originalEvents); UnityEngine.Application.logMessageReceived -= Log; running = false; } return JsonConvert.SerializeObject(new { success = failure == null && errors.Count == 0 && restored, caseId = "M01-Immediate", assertions, sends, consumeEvents, restored, failure, errors, formalRequests = 0 }); } sealed class ValidationHttp : System.Net.Http.HttpMessageHandler { private readonly Func send; private readonly Func> sendAsync; internal ValidationHttp(Func send, Func> sendAsync = null) { this.send = send; this.sendAsync = sendAsync; } protected override Task SendAsync(System.Net.Http.HttpRequestMessage request,System.Threading.CancellationToken token) => sendAsync != null ? sendAsync(request) : Task.FromResult(send(request)); } static System.Net.Http.HttpResponseMessage ValidationJson(object value) => new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content=new System.Net.Http.StringContent(JsonConvert.SerializeObject(value),System.Text.Encoding.UTF8,"application/json") }; internal static Dictionary FullRaw(cfg.Tables tables, bool optional) { var now = new DateTime(2026, 9, 23, 0, 0, 0, DateTimeKind.Utc); string day = now.ToString("O", CultureInfo.InvariantCulture), end = now.AddDays(1).ToString("O", CultureInfo.InvariantCulture); var modern = new Dictionary { ["newData"] = string.Join(",", new[] { "3", "999", "4", "5", "6", "7", tables.TbRodData.DataList[0].ID.ToString(CultureInfo.InvariantCulture), day, "-1", "8", "77", "9", "0", "0", "10", "11", "12", "13", "14" }) }; var raw = new Dictionary(); void Put(string key, object value) { raw[key] = JsonConvert.SerializeObject(value); } if (optional) { modern["vipL"] = "1,2"; modern["ld"] = "1.25|2.5|3"; modern["bd"] = "1,2"; modern["ct"] = "1:2"; modern["rt"] = "3:4"; int condition = (int)Enum.GetValues(typeof(cfg.ConditionType)).GetValue(0); modern["ad"] = condition + ":1|2|3"; Put("NewRodList", new[] { 7, 8 }); raw["SeasonId"] = "9"; raw["IsOpenAppraise"] = "1"; raw["InfiniteBuildingData"] = "synthetic"; foreach (var key in new[] { "RodData", "RodLevelData", "FishSwimToMap", "FishSwimToMapCount", "MapProgress", "HonorLevelData" } ) raw[key] = "{\"-1\":7}"; var fish = tables.TbFishData.DataList.First(f => tables.TbMapData.DataMap.ContainsKey(f.AreaID)); Put("Map_5_" + fish.AreaID, new Dictionary { [fish.ID] = "0|-1|2|3|True" }); Put("InitRodAccessoriesData", new { GloveID = 1, RodToSkin = new Dictionary { [2] = 3 }, GloveIds = new[] { 4 }, SkinIds = new[] { 5 }, GloveNewIds = new[] { 6 }, SkinNewIds = new[] { 7 } }); raw["ShopPackBuyCount"] = "{\"x\":1}"; raw["ShopAllPackBuyCount"] = "{\"2\":3}"; raw["ShopGiftBuyCount"] = "{\"4\":5}"; raw["Voucher"] = "6"; var daily = new { ID = 1, index = 2, lastID = 3, time = "raw" }; Put("ChainPackDataNew", daily); Put("TurntableAdData", daily); Put("VIPAdPackData", new { ID = 7, index = 8, day = 9, cdTime = "raw", isOpen = true }); Put("BuyData", new[] { 1.5, 2.5 }); raw["AdvertAllCount"] = "{\"-2\":10}"; raw["DiamondShopRefreshCount"] = "11"; raw["DiamondShopRefreshTime"] = day; Put("DiamondShopItemRecords", new Dictionary { [12] = new { ItemID = 13, Discount = .5, hasBought = 1 } }); raw["BlackFridayCouponDate"] = "2026,9,23,1,2,3,14,15,1"; Put("LimitedTimeEventDic", new Dictionary { [1] = new { id = 2, duration = 9999999d, startTime = day } }); Put("FixedTimeEventDic", new Dictionary { [3] = new { id = 4, endTime = end } }); raw["WheelLevelCount"] = "{\"5\":6}"; foreach (var key in new[] { "TurntableRoll", "WheelOperateCount", "AllWheelOperateCount", "WheelLevelFreeCount", "PVPToken" } ) raw[key] = "7"; var buff = new { SortID = 1, sourceID = 2, buffID = 3, isEnd = false, buffEndTime = end }; Put("BuffDataList", new Dictionary { [7] = buff }); Put("TargetBuffDataList", new[] { buff }); Put("FishDailyBuff", buff); Put("GlobalBuffDataList", new Dictionary { [7] = buff }); raw["IsFix"] = "true"; raw["IsOpenSkyscraper"] = "1"; raw["CampDataDic"] = "{\"CampId\":205}"; raw["GuideSaveDataNew"] = "A|3|1;B|4|0"; Put("BattlePassRecord", new { ID = 1, Level = 2, Exp = 3, BuyVipToastIndex = 4, IsUnlockVip = 1, NormalRewardGet = new[] { 5, 6 }, VipRewardGet = new[] { 7, 8 }, LastDropIDs = new[] { 9 }, InflationRate = 2.5f }); Put("BattleTask", new { dic_dailyTasks = new Dictionary { [9] = 10 }, dic_weeklyTasks = new Dictionary { [11] = 12 }, loginDays = new[] { 13, 14 }, DailyTaskEndTime = now, WeeklyTaskEndTime = now.AddDays(1) }); Put("MonthlySignEvent", new { index = 4, dailySignEvent = daily }); Put("TriggerPackDataList", new[] { new { ID = 1, index = 2, lastID = 3, vipLevel = 4, mapID = 5, time = "raw" } }); Put("GiftVIPDData", new Dictionary { ["A"] = new { oldVipLevel = 1, curVipLevel = 2, timer = 3 } }); Put("FixedTimeShopDic", new Dictionary { [7] = new { id = 8, endTime = end } }); raw["ClubSignin"] = day; raw["IsOpenClubRed"] = "1"; Put("LureDonateData", new { DonateCountTime = day, ClubLureDonateCount = 7, ClubLureRequestCDEnd = end }); Put("FishingChallengeData", new { FirstOpen = true, EventId = 11, RankTierLevel = 11, Finished = 11, RedirectID = 11, Score = 11, ProcessedScore = 11, step = 11, uiStep = 11, index = 11, StartTime = day, EndTime = end, eliminateNumber = new[] { 7, 9 }, robotAccountList = new[] { 7, 9 }, InflationRate = 1.5f }); var offer = new { FirstOpen = true, EventId = 7, StartTime = day, EndTime = end, ItemId = -1, LotteryNum = 5, Items = new[] { new { Id = 1, Item = 2, Number = 3, Status = 2 } }, RewardList = new[] { 8, 9 }, Finished = true, ChestReward = 10 }; Put("OfferChainsChestData", offer); Put("OfferDiscoPartyManager", offer); Put("EventPartnerGatherData", new { FirstOpen = false, EventId = 18, StartTime = day, EndTime = end, CurrentPoints = 1, CurrentMiningProgress = 2, Token = 3, InvitedCount = 4, TicketCount = 5, CompletedNodesList = new[] { new[] { 6, 7 } }, BuilderPartnerScore = new[] { new { Item1 = 8, Item2 = 9 } }, PartnerBonus = new { Item1 = 1.25f, Item2 = end }, FreeSpinNum = 10, Bots = new Dictionary { [11] = new { Id = "synthetic-bot", ScorePlayer = 12, ScoreBot = 13, Grade = 14, TargetScore = 15, WakeStartTime = day, WakingSeconds = 16 } }, IsRecvFinalReward = true, UIFinishedPartners = new[] { true, false }, IsFinished = true, SpinState = 2, InflationRate = 2.5f, IsEnableAutoMatch = true, LastAutoMatchTime = day }); Put("EventCubeMeltData", new { TicketCount = 1, FirstOpen = true, IsFinished = true, EventId = 2, IsInGuide = true, StartTime = day, EndTime = end, CurrentStagetId = 3, CollectItems = new[] { new { ItemId = 4, Count = 5, MaxCount = 6, ItemTrueId = 7 } }, BoardData = new[] { 8, 9 }, BoardViewSize = new { x = 10, y = 11 }, CollectRewardsId = new[] { 12, 13 }, IsFinalRewardGet = true, PieceDatas = new[] { new[] { 14, 15 }, new[] { 16, 17 } }, Cycle = 18, InflationRate = 1.5f }); Put("EventPackData", new { currentEventID = -1, purchaseCount = 2, VIPLvlWhenEventActivate = 3, redirectID = 4 }); Put("PiggyBankPackData", new { currentEventID = 11, purchaseCount = 11, VIPLvlWhenEventActivate = 11, savingProgress = 11, redirectID = 0, doNeedTrigger = true, isTriggered = true, inflation = 1.5f }); Put("GeneralEventPackData", new { currentEventID = 11, redirectID = tables.TbEventPackManager.DataList.First(p => p.PackType == 3).ID, VIPLvlWhenEventActivate = 11, purchaseCount = 11, savingProgress = 11, discountLvl = 11, visualProgress = 11, visualDiscountLvl = 11, rodID = 11, lastPackIDBought = 11, packID = 11, doNeedTrigger = true, isTriggered = true, inflation = 1.5f, selections = new[] { 7, 9 } }); raw["FootPrint"] = "18446744073709551615"; Put("EventShootingRangeData", new { EventId = 11, CycleId = 11, RedirectId = 11, AmmoCount = 11, CurrentStageIdx = 11, ChainProgress = 11, RewardFlag = long.MaxValue, TargetFlag = long.MinValue, HasBeenOpened = true, Inflation = 1.5f }); var item = new { name = "synthetic", index = 3, isOver = true, DataType = 2, ColorTypeList = new[] { 7, 9 }, ShelfTypeList = new[] { 0, 1 }, GridStateList = new[] { 0, 5 }, FromDisplayAreaIndexList = new[] { 1, 2 } }; Put("epdk", new { EventID = 11, PotionID = 11, StageID = 11, Stage1TotalLevel = 11, Stage2TotalLevel = 11, LevelID = 11, LevelIndex = 11, CorrectNum = 11, SubmitNum = 11, Token = 11, ConsumedToken = 11, Rule = 11, PreEventID = 11, PrePotionID = 11, PreStageID = 11, PreLevelID = 11, Round = 11, FirstPlayedEventId = 11, IsOver = true, Name = "synthetic", InflationRate = 1.5f, LevelTargetData = item, LevelDisplayData = item, LevelProcessedList = new[] { item } }); Put("epbdk", new { EventID = 11, PinballMainID = 11, SpinPoint = 11, Scores = 11, Multiple = 11, Token = 11, ConsumedToken = 11, RewardIndex = 11, Force = 1.5f, LuckyValue = 1.5f, InflationRate = 1.5f }); Put("estdk", new { EventID = 11, ScratchMainID = 11, ChainPackID = 11, Token = 11, ConsumedToken = 11, RewardTaskConsumedToken = 11, EventScratchRewardTaskID = 11, EventScratchCardID = 11, TotalConsumedToken = 11, ChainPackProgress = 11, magIndex = 11, IsOver = true, LuckyValue = 1.5f, InflationRate = 1.5f, StampRewardList = new[] { 7, 9 }, StampRewardDict = new Dictionary { [7] = new { EventScratchCardID = 8, Count = 9 } } }); raw["ThanksGivingPackData"] = "1,2,3,4"; raw["ThanksGivingPackMigrationData"] = "5,6,7,8"; Put("EventPinataPackData", new { EventID = 7, PinataMainID = 8, PurchasedCount = 9 }); raw["EAccoutUrl"] = "{\"synthetic\":\"avatar\"}"; raw["hwGoogle"] = "1"; raw["Apple"] = "1"; raw["TapJoy_Diamond"] = "12"; } Put("NewPlayerDataDic", modern); return raw; } // A static access shim only; never added as a component or instantiated. sealed class ValidationContextAccess : asap.core.GContext { protected override Task SetupAsync() => throw new InvalidOperationException("Not a gameplay context."); internal static void Use(asap.core.Container value) { container = value; } internal static asap.core.IEventAggregator Events { get => eventAggregator; set => eventAggregator = value; } } static bool running; } } #endif