Files
ft/Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs

4912 lines
262 KiB
C#

#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<T>(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<string> 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<Exception> Errors = new List<Exception>();
private readonly System.Threading.SynchronizationContext original;
private TaskCompletionSource<bool> idle = new TaskCompletionSource<bool>();
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<bool>();
}
}
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<string> admitted;
private readonly List<int> statuses;
private readonly Dictionary<string, string> responses;
private readonly HashSet<string> failures;
private readonly System.Threading.CancellationToken lifetime;
internal FormalReadHttp(System.Net.Http.HttpMessageHandler inner, object gate, List<string> admitted,
List<int> statuses, Dictionary<string, string> responses, HashSet<string> 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<System.Net.Http.HttpResponseMessage> 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<string> 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<asap.core.GContext>().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<string>();
var statuses = new List<int>();
var responses = new Dictionary<string, string>();
var transportFailures = new HashSet<string>();
var errors = new List<string>();
var coverage = new Dictionary<string, object>();
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<Task>();
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<string>();
var codes = new List<int>();
var rejected = new HashSet<string>();
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<string, string>(), 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<ICustomServerMgr>(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<PlayFab.ClientModels.GetFriendsListResult>)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<PlayFab.ClientModels.GetTitleNewsResult>)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<Task> 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<System.Net.Http.HttpRequestMessage, string, System.Threading.CancellationToken> admit;
private readonly Action<string, int, string> receive;
internal FormalMatchHttp(System.Net.Http.HttpMessageHandler inner,
Func<System.Net.Http.HttpRequestMessage, string, System.Threading.CancellationToken> admit,
Action<string, int, string> receive)
{
InnerHandler = inner;
this.admit = admit;
this.receive = receive;
}
protected override async Task<System.Net.Http.HttpResponseMessage> 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<string> 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<asap.core.GContext>().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<string, (int owner, string status)>();
var unknown = new int[2];
var tasks = new List<Task>();
var requests = new List<object>();
var statuses = new List<int>();
var errors = new List<string>();
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<Newtonsoft.Json.Linq.JObject, PlayFab.MultiplayerModels.GetMatchResult>)Delegate.CreateDelegate(
typeof(Func<Newtonsoft.Json.Linq.JObject, PlayFab.MultiplayerModels.GetMatchResult>), 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<bool>();
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<string, (int owner, string status)>[] 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<T> Call<T>(int who, string action, object body, Func<Newtonsoft.Json.Linq.JObject, T> 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<T>();
var started = observed.Started;
System.Threading.SynchronizationContext.SetSynchronizationContext(observed);
try
{
requestMethod.MakeGenericMethod(typeof(T)).Invoke(null, new object[] { action, body, parse,
new Action<T>(value =>
{
result.Success++;
result.Value = value;
}),
new Action<string>(_ =>
{
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<string> Create(int who)
{
var result = await Call(who, "CreateMatchmakingTicket", new { queueName = queue, giveUpAfterSeconds = 60, attributes = Attributes(who) },
value => value.ToObject<PlayFab.MultiplayerModels.CreateMatchmakingTicketResult>());
Check(!string.IsNullOrEmpty(result.TicketId) && Snapshot().tickets.Any(item => item.Key == result.TicketId && item.Value.owner == who));
return result.TicketId;
}
async Task<PlayFab.MultiplayerModels.GetMatchmakingTicketResult> ReadTicket(string ticket)
{
var owner = Ticket(ticket).owner;
var result = await Call(owner, "GetMatchmakingTicket", new { queueName = queue, ticketId = ticket },
value => value.ToObject<PlayFab.MultiplayerModels.GetMatchmakingTicketResult>());
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<PlayFab.MultiplayerModels.CancelMatchmakingTicketResult>());
}
async Task Cleanup(Func<Task> 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<ICustomServerMgr>(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<Task>();
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<T>
{
internal int Success;
internal int Failure;
internal T Value;
}
private static async Task<string> 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<asap.core.GContext>().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<string>();
var expectedErrors = new HashSet<Exception>();
var releases = new List<TaskCompletionSource<System.Net.Http.HttpResponseMessage>>();
var holds = new Queue<TaskCompletionSource<System.Net.Http.HttpResponseMessage>>();
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<Newtonsoft.Json.Linq.JObject, PlayFab.MultiplayerModels.GetMatchResult>)
Delegate.CreateDelegate(typeof(Func<Newtonsoft.Json.Linq.JObject, PlayFab.MultiplayerModels.GetMatchResult>),
typeof(EventFishingDuelPanel).GetMethod("ReadKVMatch", flags));
running = true;
void Check(bool condition, string detail)
{
assertions++;
if (!condition)
{
throw new InvalidOperationException("Matchmaking: " + detail);
}
}
MatchCallback<T> Start<T>(string action, object request, Func<Newtonsoft.Json.Linq.JObject, T> parse,
Exception successError = null, Exception failureError = null)
{
var record = new MatchCallback<T>();
Action<T> success = value =>
{
record.Success++;
record.Value = value;
if (successError != null)
{
throw successError;
}
};
Action<string> 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<bool>();
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<string, string>() });
})));
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<IUserService>(isolated, user);
asap.core.ContainerExtensions.RegisterInstance<ICustomServerMgr>(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<Newtonsoft.Json.Linq.JObject, PlayFab.MultiplayerModels.CreateMatchmakingTicketResult> createParse = value => value.ToObject<PlayFab.MultiplayerModels.CreateMatchmakingTicketResult>();
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<PlayFab.MultiplayerModels.GetMatchmakingTicketResult>());
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<PlayFab.MultiplayerModels.CancelMatchmakingTicketResult>());
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<PlayFab.MultiplayerModels.CancelMatchmakingTicketResult>());
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<System.Net.Http.HttpResponseMessage>();
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<Task> 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<string> 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<asap.core.GContext>().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<string>();
var releases = new List<TaskCompletionSource<System.Net.Http.HttpResponseMessage>>();
var holds = new Queue<TaskCompletionSource<System.Net.Http.HttpResponseMessage>>();
PlayerKVLoginContext context = null;
CustomServerMgr server = null;
KVUserService user = null;
Task continuation = null;
TaskCompletionSource<PlayFab.ClientModels.TitleNewsItem> 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<string, string>() });
})));
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<IUserService>(isolated, user);
asap.core.ContainerExtensions.RegisterInstance<ICustomServerMgr>(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<System.Net.Http.HttpResponseMessage>();
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<Task> 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<string> 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<asap.core.GContext>().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<PlayFab.ClientModels.GetLeaderboardResult> pending = null;
TaskCompletionSource<System.Net.Http.HttpResponseMessage> held = null;
var errors = new List<string>();
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<string, string>() });
})));
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<GameCore.PFRankDataEvent>().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<System.Net.Http.HttpResponseMessage>();
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<Task> 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<bool> FetchMail()
{
Fetches++;
return Task.FromResult(true);
}
}
private static async Task<string> 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<asap.core.GContext>().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<TaskCompletionSource<System.Net.Http.HttpResponseMessage>>();
var holds = new Queue<TaskCompletionSource<System.Net.Http.HttpResponseMessage>>();
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<string, (bool Exists, string Value)>();
int orderCallbacks = 0;
var errors = new List<string>();
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<string, string> { ["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<IUserService>(isolated, user);
profileSubscription = asap.core.GContext.OnEvent<GetClubPlayInfoEvent>().Subscribe(_ => profileEvents++);
listSubscription = asap.core.GContext.OnEvent<SocialFriendListChangedData>().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<IRTService>(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<string, Action<string>>)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<FetchMailEvt>(_ =>
{
});
}
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<Dictionary<string, PlayInfo>>(user, "profiles");
var levelCache = UserField<Dictionary<string, int>>(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<object>())),
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<System.Net.Http.HttpResponseMessage>();
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<Task> 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<string> 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<asap.core.GContext>().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<bool>();
var entered = new TaskCompletionSource<bool>();
var remote = new Dictionary<string, string>();
var bodies = new List<Dictionary<string, string>>();
var receipts = new List<PlayerKVSyncTicket>();
var cadenceGate = new object();
var requestTimes = new List<long>();
var cadenceSamples = new List<object>();
string cadenceFile = null;
int assertions = 0;
string failure = null;
bool restored = false;
double intervalSeconds = 0;
var cleanupErrors = new List<string>();
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<Dictionary<string, string>>();
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<KVMgr>();
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<string, string> sentBody;
long sentAt;
int sends;
lock (cadenceGate)
{
sends = bodies.Count;
sentBody = new Dictionary<string, string>(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<KVMgr>(), "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<KVMgr>(), "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<bool> idle = new TaskCompletionSource<bool>();
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<bool>();
}
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<long> seconds = new Subject<long>();
private readonly Subject<long> minutes = new Subject<long>();
public IObservable<long> SecondTick
{
get
{
return seconds;
}
}
public IObservable<long> 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<bool> SyncDataImmidiate(string key, string value)
{
var error = new InvalidOperationException("P6A synthetic store fault");
if (synchronous)
{
throw error;
}
return Task.FromException<bool>(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<string> P6AInspectStart(Action<bool, string> 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<System.Reflection.MethodBase>();
var evidence = new List<string>();
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<string> RunInitializationLastLogin()
{
if (running || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || asap.core.GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll<asap.core.GContext>().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<IDisposable>();
var tasks = new List<Task>();
var releases = new List<TaskCompletionSource<bool>>();
var logs = new List<string>();
var errors = new List<string>();
var cases = new List<string>();
var il = new List<string>();
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<cfg.Tables>(isolated, tables);
user = new KVUserService();
var ticks = new P6ATicks();
resources.Add(ticks);
int daily = 0;
resources.Add(asap.core.GContext.OnEvent<TimeManager.Event.ActivityCrossDayRefreshEvent>().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<TimeManager.Event.ActivityCrossDayRefreshEvent>().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<string, string> { ["LastLoginTimeUtc"] = oldValue };
Dictionary<string, string> sent = null;
TaskCompletionSource<bool> 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<string, string> 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<Dictionary<string, string>>();
}
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<KVPlayerSaveStore> 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<bool>();
release = new TaskCompletionSource<bool>();
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<string, string> { ["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<IPlayerSaveStore>(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<bool> 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("<UnitySynchronizationContext>k__BackingField", RetryStatic);
private readonly System.Reflection.FieldInfo threadField = typeof(UnityAsyncAwaitUtil.SyncContextUtil).GetField("<UnityThreadId>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<string> 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<string> Errors { get; } = new List<string>();
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<UnityAsyncAwaitUtil.AsyncCoroutineRunner>().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<UnityAsyncAwaitUtil.AsyncCoroutineRunner>().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<UnityAsyncAwaitUtil.AsyncCoroutineRunner>().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<string, object> values = new Dictionary<string, object>();
internal RetryConfig(string id)
{
Set("CustomID", id);
}
public void Set(string key, object value)
{
values[key] = value;
}
public T Get<T>(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<ELoginStep> steps;
private bool ready;
internal List<bool> TokenCalls { get; } = new List<bool>();
internal List<string> Ids { get; } = new List<string>();
internal int Logouts { get; private set; }
internal bool HoldLogin
{
get;
set;
}
internal RetryUser(ELoginStep[] steps)
{
this.steps = new Queue<ELoginStep>(steps);
}
public override Task<ELoginStep> Login(Action<int> 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<ELoginStep> 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<PlayerKVShutdownStatus> 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<T>(Action<T> handler)
{
throw new InvalidOperationException("UI probe cannot subscribe to RT.");
}
public Task<string> Publish(string method, string value)
{
throw new InvalidOperationException("UI probe cannot publish.");
}
public Task<string> 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<string> Calls = new List<string>();
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<GameMail> GetAllMail()
{
throw new InvalidOperationException("Unexpected mail read in login UI validation.");
}
public Task<bool> MarkRead(GameMail mail)
{
throw new InvalidOperationException("Unexpected mail write in login UI validation.");
}
public Task<bool> MarkRead(GameMail[] mails = null)
{
throw new InvalidOperationException("Unexpected mail write in login UI validation.");
}
public Task<bool> DelMail(GameMail mail)
{
throw new InvalidOperationException("Unexpected mail deletion in login UI validation.");
}
public Task<bool> DelMail(GameMail[] mails = null)
{
throw new InvalidOperationException("Unexpected mail deletion in login UI validation.");
}
public Task<bool> TakeReward(GameMail mail)
{
throw new InvalidOperationException("Unexpected reward in login UI validation.");
}
public Task<bool> TakeReward(GameMail[] mails = null)
{
throw new InvalidOperationException("Unexpected reward in login UI validation.");
}
public Task<IList<tysdk.SKUDetail>> GetSKUs()
{
throw new InvalidOperationException("Unexpected SKU request in login UI validation.");
}
public Task<tysdk.PaymentInfo> Pay(Action<string> 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<Dictionary<string, string>> GetOrderUnreceived()
{
throw new InvalidOperationException("Unexpected order request in login UI validation.");
}
public Task<bool> ConfirmOrders(List<string> 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<UnityEngine.GameObject> objects = new List<UnityEngine.GameObject>();
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<PFLoginAct>();
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<SimpleDialog>();
var ok = Object("ok").AddComponent<UnityEngine.UI.Button>();
var close = Object("close").AddComponent<UnityEngine.UI.Button>();
Field(dialog, "btn_ok", ok);
Field(dialog, "btn_close", close);
input = Object("input");
inputField = input.AddComponent<UnityEngine.UI.InputField>();
inputOk = Object("input-ok").AddComponent<UnityEngine.UI.Button>();
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<string> RunLoginRetry()
{
if (running || failedRetryCleanup != null || EditorApplication.isPlayingOrWillChangePlaymode ||
EditorApplication.isCompiling || EditorApplication.isUpdating || PlayerKVGameplayLoop.Enabled ||
asap.core.GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll<asap.core.GContext>().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<object>();
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<RetryCase, Task> 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<string> 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<string> RunCustomId()
{
if (running || failedRetryCleanup != null || EditorApplication.isPlayingOrWillChangePlaymode ||
EditorApplication.isCompiling || EditorApplication.isUpdating || PlayerKVGameplayLoop.Enabled ||
asap.core.GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll<asap.core.GContext>().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<object>();
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<string> RunDestroyBoundary()
{
if (running || failedRetryCleanup != null || EditorApplication.isPlayingOrWillChangePlaymode ||
EditorApplication.isCompiling || EditorApplication.isUpdating || PlayerKVGameplayLoop.Enabled ||
asap.core.GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll<asap.core.GContext>().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<object>();
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<string> RunImmediateConsumers()
{
if (running || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || asap.core.GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll<asap.core.GContext>().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<IDisposable>();
var release = default(TaskCompletionSource<bool>);
var errors = new List<string>();
var messages = new List<string>();
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<string, string>();
Dictionary<string, string> sent = null;
var entered = default(TaskCompletionSource<bool>);
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<Dictionary<string, string>>();
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<cfg.Tables>(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<GameCore.PlayerData>(isolated, player);
asap.core.ContainerExtensions.RegisterInstance<GameCore.SmallGameData>(isolated, small);
asap.core.ContainerExtensions.RegisterInstance<GameCore.PlayerFishData>(isolated, fish);
asap.core.ContainerExtensions.RegisterInstance<AchievementDataManager>(isolated, achievements);
asap.core.ContainerExtensions.RegisterInstance<FishingBoxData>(isolated, box);
asap.core.ContainerExtensions.RegisterInstance<GameCore.FishingEventData>(isolated, eventData);
eventData.InitData(new Dictionary<string, string>());
typeof(GameCore.PlayerData).GetProperty("benefitsEnergyLimit").SetValue(player, 10000);
Set(player, "vipScoreDic", new Dictionary<int, int> { [0] = 1 });
subscriptions.Add(asap.core.GContext.OnEvent<ConditionTypeEvent>().Subscribe(_ => consumeEvents++));
shell = new UnityEngine.GameObject("M01 immediate consumers");
shell.SetActive(false);
shell.hideFlags = UnityEngine.HideFlags.HideAndDontSave;
var fishing = shell.AddComponent<FishingBehaviorB>();
var panel = shell.AddComponent<FishingTurntablePanel>();
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<bool>();
release = new TaskCompletionSource<bool>();
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<System.Net.Http.HttpRequestMessage,System.Net.Http.HttpResponseMessage> send;
private readonly Func<System.Net.Http.HttpRequestMessage, Task<System.Net.Http.HttpResponseMessage>> sendAsync;
internal ValidationHttp(Func<System.Net.Http.HttpRequestMessage,System.Net.Http.HttpResponseMessage> send,
Func<System.Net.Http.HttpRequestMessage, Task<System.Net.Http.HttpResponseMessage>> sendAsync = null)
{
this.send = send;
this.sendAsync = sendAsync;
}
protected override Task<System.Net.Http.HttpResponseMessage> 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<string, string> 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<string, string>
{
["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<string, string>();
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<int, string> { [fish.ID] = "0|-1|2|3|True" });
Put("InitRodAccessoriesData", new { GloveID = 1, RodToSkin = new Dictionary<int, int> { [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<int, object> { [12] = new { ItemID = 13, Discount = .5, hasBought = 1 } });
raw["BlackFridayCouponDate"] = "2026,9,23,1,2,3,14,15,1";
Put("LimitedTimeEventDic", new Dictionary<int, object> { [1] = new { id = 2, duration = 9999999d, startTime = day } });
Put("FixedTimeEventDic", new Dictionary<int, object> { [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<int, object> { [7] = buff });
Put("TargetBuffDataList", new[] { buff });
Put("FishDailyBuff", buff);
Put("GlobalBuffDataList", new Dictionary<int, object> { [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<int, int> { [9] = 10 }, dic_weeklyTasks = new Dictionary<int, int> { [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<string, object> { ["A"] = new { oldVipLevel = 1, curVipLevel = 2, timer = 3 } });
Put("FixedTimeShopDic", new Dictionary<int, object> { [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<int, object> { [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<int, object> { [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