4912 lines
262 KiB
C#
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
|