feat(upserver): 保存M2客户端KV迁移至本人统计写检查点
保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。 保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。 验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。 包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
using game;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Net;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
static partial class Probe
|
||||
{
|
||||
sealed class IdentityWireCapture
|
||||
{
|
||||
internal readonly List<(string Path, string Body)> Requests = new();
|
||||
internal HttpStatusCode NextStatus = HttpStatusCode.OK;
|
||||
internal string NextBody = "{\"Code\":1}";
|
||||
internal Task<HttpResponseMessage> Respond(string path, string body)
|
||||
{
|
||||
Requests.Add((path, body));
|
||||
var result = new HttpResponseMessage(NextStatus) { Content = new StringContent(NextBody) };
|
||||
NextStatus = HttpStatusCode.OK; NextBody = "{\"Code\":1}";
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
internal bool TryHandle(HttpRequestMessage request, out Task<HttpResponseMessage> response)
|
||||
{
|
||||
response = null;
|
||||
var path = request.RequestUri.AbsolutePath;
|
||||
if (!new[] { "/friend/AddFriend", "/friend/DeleteFriend", "/friend/SearchFriend", "/friend/AcceptFriend",
|
||||
"/friend/GetHomePage", "/friend/GetRecommendedFriends", "/friend/GetInvitedList",
|
||||
"/friend/IsFriendWaitingForAccept" }.Contains(path)) return false;
|
||||
Check(request.RequestUri.GetLeftPart(UriPartial.Authority) == "http://s101.local:18080" && request.Method == HttpMethod.Post,
|
||||
"original friend route uses fixed official origin through in-memory transport");
|
||||
response = Respond(path, request.Content.ReadAsStringAsync().GetAwaiter().GetResult());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class IdentityPFTransport : System.Reflection.DispatchProxy
|
||||
{
|
||||
public Func<string, string, Task<string>> Send;
|
||||
protected override object Invoke(System.Reflection.MethodInfo method, object[] args)
|
||||
{
|
||||
if (method.Name != "FriendRequest") throw new Exception("Unexpected PF fixture transport: " + method.Name);
|
||||
return Send("/friend/" + args[0], Newtonsoft.Json.JsonConvert.SerializeObject(args[1]));
|
||||
}
|
||||
}
|
||||
sealed class IdentityPFUser : PlayFabUserService { public IdentityPFUser() { UserId = "ABCD1234"; } }
|
||||
|
||||
static async Task IdentityCompatibility(IUserService user, ICustomServerMgr server, IdentityWireCapture wire, string other)
|
||||
{
|
||||
GContext.container.Values[typeof(PlayerData)] = new PlayerData { lv = 10 };
|
||||
var friend = new FriendService { userService = user, customServerMgr = server };
|
||||
friend.FriendList.Add(new LeadboardItemData { playFabId = other });
|
||||
var initialSDK = Effects.SDK;
|
||||
JObject Last(string action)
|
||||
{
|
||||
var last = wire.Requests[^1];
|
||||
Check(last.Path == "/friend/" + action, "original route retained: " + action);
|
||||
return JObject.Parse(last.Body);
|
||||
}
|
||||
void Fields(JObject value, params string[] fields) => Check(value.Properties().Select(p => p.Name).OrderBy(x=>x)
|
||||
.SequenceEqual(fields.OrderBy(x=>x)), "original field names retained, no userId alias added");
|
||||
|
||||
wire.NextBody = "{\"Code\":-3,\"Msg\":\"synthetic compatibility failure\"}";
|
||||
var add = JObject.Parse(await friend.AddFriend(other));
|
||||
var request = Last("AddFriend"); Fields(request, "MySelf", "MyFriendCount", "FriendPlayFabId");
|
||||
Fields((JObject)request["MySelf"], "PlayFabId", "DisplayName", "AvatarUrl", "Level");
|
||||
Check((string)request["MySelf"]["PlayFabId"] == user.UserId && (string)request["FriendPlayFabId"] == other,
|
||||
"self uses authenticated identity and friend retains the distinct target identity");
|
||||
Check((int)request["MySelf"]["Level"] == 10 && (int)request["MyFriendCount"] == 1 && (int)add["Code"] == -3,
|
||||
"original data and server business failure are returned unchanged");
|
||||
|
||||
wire.NextBody = "{\"Code\":1,\"Info\":{\"PlayFabId\":\"" + other + "\",\"Level\":12}}";
|
||||
var search = JObject.Parse(await friend.SearchFriend(other));
|
||||
request = Last("SearchFriend"); Fields(request, "PlayFabId");
|
||||
Check((string)request["PlayFabId"] == other && (string)search["Info"]["PlayFabId"] == other,
|
||||
"target UserID round trips in legacy PlayFabId field without substituting self");
|
||||
await friend.AcceptFriend(2, user.UserId, other);
|
||||
request = Last("AcceptFriend"); Fields(request, "Status", "PlayFabId", "FriendPlayFabId");
|
||||
Check((int)request["Status"] == 2 && (string)request["PlayFabId"] == user.UserId && (string)request["FriendPlayFabId"] == other,
|
||||
"original accept/ignore status and two identities stay distinct");
|
||||
await friend.GetHomePage(other, 3);
|
||||
request = Last("GetHomePage"); Fields(request, "PlayFabId", "PageNo");
|
||||
Check((string)request["PlayFabId"] == other && (int)request["PageNo"] == 3, "home-page target and page retained");
|
||||
await friend.GetRecommendedFriends(user.UserId, 4);
|
||||
request = Last("GetRecommendedFriends"); Fields(request, "PlayFabId", "PageNo");
|
||||
Check((string)request["PlayFabId"] == user.UserId && (int)request["PageNo"] == 4, "recommendation self identity and page retained");
|
||||
await friend.IsFriendWaitingForAccept(user.UserId, other);
|
||||
request = Last("IsFriendWaitingForAccept"); Fields(request, "PlayFabId", "FriendPlayFabId");
|
||||
Check((string)request["PlayFabId"] == user.UserId && (string)request["FriendPlayFabId"] == other,
|
||||
"invitation status uses self and target without conversion");
|
||||
await friend.GetInvitedList();
|
||||
Check(wire.Requests[^1] == ("/friend/GetInvitedList", "\"\""), "cookie-authenticated original empty body remains unchanged");
|
||||
|
||||
wire.NextBody = "{\"Code\":-1}";
|
||||
Check(!await friend.DeleteFriend(other) && friend.FriendList.Count == 1, "business failure retains original friend list");
|
||||
request = Last("DeleteFriend"); Fields(request, "FriendPlayFabId");
|
||||
Check((string)request["FriendPlayFabId"] == other, "delete targets the supplied UserID");
|
||||
Check(await friend.DeleteFriend(other) && friend.FriendList.Count == 0, "original successful delete updates list");
|
||||
foreach (var id in new[] { user.UserId, other })
|
||||
{
|
||||
var invitation = friend.GetInvitationCodeByPlayFabID(id).Replace("-", "");
|
||||
Check(friend.DecryptInvitationCodeToUlong(invitation).ToString("X") == id,
|
||||
"original invitation codec round trips identity string " + id);
|
||||
}
|
||||
Check(Effects.SDK == initialSDK, "ordinary friend requests do not call PlayFab SDK");
|
||||
if (user is KVUserService)
|
||||
{
|
||||
wire.NextStatus = HttpStatusCode.InternalServerError;
|
||||
Check(await friend.SearchFriend(other) == null, "HTTP failure retains original null result, no fake success");
|
||||
Check(user.IsAuthorized, "ordinary HTTP 500 does not invent logout");
|
||||
}
|
||||
Pass("identity-legacy-fields-real-friend-service-" + user.UserId);
|
||||
}
|
||||
|
||||
static async Task IdentityPFCompatibility()
|
||||
{
|
||||
var wire = new IdentityWireCapture();
|
||||
var server = System.Reflection.DispatchProxy.Create<ICustomServerMgr, IdentityPFTransport>();
|
||||
((IdentityPFTransport)(object)server).Send = async (path, body) =>
|
||||
await (await wire.Respond(path, body)).Content.ReadAsStringAsync();
|
||||
await IdentityCompatibility(new IdentityPFUser(), server, wire, "FE123456");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Only unused UI/config dependencies of the linked real FriendService are fixtures.
|
||||
namespace game
|
||||
{
|
||||
public class FriendAddItemData { }
|
||||
public class FriendAddPanelBackendOperation
|
||||
{
|
||||
public void StartInvite(string id, Action<FriendAddPanelBackendOperation, string, int> callback) =>
|
||||
throw new NotSupportedException("UI operation is outside this protocol test.");
|
||||
public void Clear() { }
|
||||
}
|
||||
public class LeadboardItemData { public string playFabId, displayName, avatarUrl; public DateTime? LastLogin; public int value, position, rank;
|
||||
public void SetData(PlayFab.ClientModels.PlayerLeaderboardEntry entry) => throw new NotSupportedException("Leaderboard reads are outside this statistics-write test."); }
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using game;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Net;
|
||||
|
||||
static partial class Probe
|
||||
{
|
||||
// Actual KV login, lease, CustomServerMgr and public consumer methods run above this transport.
|
||||
sealed class ProfileCacheWire
|
||||
{
|
||||
internal bool Active;
|
||||
internal readonly List<(string Path, string Body)> Requests = new();
|
||||
internal Task<HttpResponseMessage> Next;
|
||||
internal bool TryHandle(HttpRequestMessage request, out Task<HttpResponseMessage> response)
|
||||
{
|
||||
response = null;
|
||||
if (!Active) return false;
|
||||
Check(request.RequestUri.GetLeftPart(UriPartial.Authority) == "http://s101.local:18080" &&
|
||||
request.RequestUri.AbsolutePath == "/friend/SearchFriend" && request.Method == HttpMethod.Post,
|
||||
"profile read uses existing authenticated SearchFriend, no KV/SDK/private target read");
|
||||
Requests.Add((request.RequestUri.AbsolutePath, request.Content.ReadAsStringAsync().GetAwaiter().GetResult()));
|
||||
response = Next ?? throw new Exception("Unplanned profile request");
|
||||
Next = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task ProfileCachePFCompatibility()
|
||||
{
|
||||
// Concrete PF service supplies the independent original-contract reference, SDK is the existing sentinel.
|
||||
var pf = new IdentityPFUser();
|
||||
int events = 0;
|
||||
using var subscription = GContext.OnEvent<GetClubPlayInfoEvent>().Subscribe(_ => events++);
|
||||
Check(pf.GetPlayerLv("FE123456") == 1 && pf.GetPlayerLv("") == 1, "PF display cache misses return 1");
|
||||
foreach (var level in new[] { 0, -5, 62 })
|
||||
{
|
||||
pf.SetPlayerLv("FE123456", level);
|
||||
Check(pf.GetPlayerLv("FE123456") == level, "PF display cache preserves stored values");
|
||||
}
|
||||
Check(pf.GetPlayInfo(null) == null && pf.GetPlayInfo("") == null, "PF empty profile IDs return null");
|
||||
var own = pf.GetPlayInfo(pf.UserId);
|
||||
Check(ReferenceEquals(own, pf.GetPlayInfo(pf.UserId)) && events == 1,
|
||||
"PF own profile is one cached object and one synchronous event");
|
||||
pf.SetPlayInfo("FE123456", "Before", "avatar-before");
|
||||
var first = pf.GetPlayInfo("FE123456");
|
||||
pf.SetPlayInfo("FE123456", "After", "avatar-after");
|
||||
Check(!ReferenceEquals(first, pf.GetPlayInfo("FE123456")) && first.name == "Before" && events == 1,
|
||||
"PF explicit profile assignment replaces without mutating old reference or publishing");
|
||||
pf.ClearPlayInfos();
|
||||
int sdk = Effects.SDK;
|
||||
var remote = pf.GetPlayInfo("FE123456");
|
||||
Check(remote.name == "Angler#FE123" && ReferenceEquals(remote, pf.GetPlayInfo("FE123456")) &&
|
||||
events == 2 && Effects.SDK == sdk + 1, "PF remote profile still uses original SDK and caches one result");
|
||||
Check((await pf.GetPlayLv("FE123456")).Item2 == 1 && Effects.SDK == sdk + 2,
|
||||
"PF asynchronous miss queries SDK before original missing-Lv default 1");
|
||||
Pass("C3-profile-original-PF-cache-and-SDK-reference");
|
||||
}
|
||||
|
||||
static async Task ProfileCacheKVCompatibility(KVUserService user, ProfileCacheWire wire, string other)
|
||||
{
|
||||
var failures = new List<string>();
|
||||
void Expect(bool ok, string label)
|
||||
{
|
||||
assertions++;
|
||||
if (!ok) { failures.Add(label); Console.WriteLine("FAIL C3-profile " + label); }
|
||||
}
|
||||
async Task ExpectUnloaded(string id, string label)
|
||||
{
|
||||
try { await user.GetPlayLv(id); Expect(false, label); }
|
||||
catch (InvalidOperationException) { Expect(true, label); }
|
||||
}
|
||||
int sdk = Effects.SDK;
|
||||
var events = new List<GetClubPlayInfoEvent>();
|
||||
TaskCompletionSource<bool> updated = null;
|
||||
using var subscription = GContext.OnEvent<GetClubPlayInfoEvent>().Subscribe(evt =>
|
||||
{
|
||||
events.Add(evt);
|
||||
updated?.TrySetResult(true);
|
||||
});
|
||||
wire.Active = true;
|
||||
try
|
||||
{
|
||||
user.ClearPlayInfos();
|
||||
var player = (PlayerData)GContext.container.Values[typeof(PlayerData)];
|
||||
Expect(user.IsAuthorized && player.lv == 10, "fixture uses already authenticated real KV user and existing player");
|
||||
Expect(user.GetPlayerLv(other) == 1 && user.GetPlayerLv("") == 1,
|
||||
"unknown synchronous display level is original default 1");
|
||||
Expect(user.GetPlayerLv(user.UserId) == 1,
|
||||
"synchronous self display also follows original cache contract, not a new live shortcut");
|
||||
await ExpectUnloaded(other, "display default 1 must not make asynchronous unqueried level succeed");
|
||||
await ExpectUnloaded(null, "asynchronous null ID keeps prior unloaded failure");
|
||||
await ExpectUnloaded("", "asynchronous empty ID keeps prior unloaded failure");
|
||||
Expect((await user.GetPlayLv(user.UserId)).Item2 == 10,
|
||||
"asynchronous authenticated self still reads actual player level before cache population");
|
||||
try { user.GetPlayerLv(null); Expect(false, "synchronous null ID follows original Dictionary argument failure"); }
|
||||
catch (ArgumentNullException) { Expect(true, "synchronous null ID follows original Dictionary argument failure"); }
|
||||
foreach (var level in new[] { 0, -5, 62 })
|
||||
{
|
||||
user.SetPlayerLv(other, level);
|
||||
Expect(user.GetPlayerLv(other) == level, "synchronous cached level is not normalized: " + level);
|
||||
if (level < 0) await ExpectUnloaded(other, "asynchronous negative cached level keeps prior failure");
|
||||
else Expect((await user.GetPlayLv(other)).Item2 == level, "asynchronous nonnegative cache remains available");
|
||||
}
|
||||
user.SetPlayerLv(user.UserId, 999);
|
||||
Expect(user.GetPlayerLv(user.UserId) == 10, "original self SetPlayerLv uses PlayerData rather than caller value");
|
||||
user.ClearPlayInfos();
|
||||
Expect(user.GetPlayerLv(other) == 1 && (await user.GetPlayLv(user.UserId)).Item2 == 10,
|
||||
"clear removes display cache without changing asynchronous self contract");
|
||||
Expect(wire.Requests.Count == 0, "level methods do not introduce remote level queries");
|
||||
|
||||
Expect(user.GetPlayInfo(null) == null && user.GetPlayInfo("") == null, "null and empty profile IDs return null");
|
||||
var own = user.GetPlayInfo(user.UserId);
|
||||
Expect(own != null && own.name == user.DisplayName && own.avatarUrl == user.AvatarUrl,
|
||||
"own profile uses authenticated name and avatar");
|
||||
Expect(ReferenceEquals(own, user.GetPlayInfo(user.UserId)) && events.Count == 1 &&
|
||||
events[0].id == user.UserId && events[0].name == user.DisplayName,
|
||||
"own profile cached reference and exactly one synchronous success event");
|
||||
Expect(wire.Requests.Count == 0, "own and empty profile lookups send no request");
|
||||
|
||||
user.SetPlayInfo(other, "Before", "avatar-before");
|
||||
var before = user.GetPlayInfo(other);
|
||||
int beforeEvents = events.Count;
|
||||
user.SetPlayInfo(other, "After", "avatar-after");
|
||||
Expect(!ReferenceEquals(before, user.GetPlayInfo(other)) && before.name == "Before" && events.Count == beforeEvents,
|
||||
"explicit assignment replaces the cache object without mutating old reference or publishing");
|
||||
user.SetPlayInfo(other, "", null);
|
||||
Expect(user.GetPlayInfo(other).name == "Angler#" + other && user.GetPlayInfo(other).avatarUrl == null,
|
||||
"explicit empty nickname retains original default-name rule");
|
||||
|
||||
user.ClearPlayInfos();
|
||||
events.Clear();
|
||||
var release = new TaskCompletionSource<HttpResponseMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
wire.Next = release.Task;
|
||||
updated = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
int requestStart = wire.Requests.Count;
|
||||
var placeholder = user.GetPlayInfo(other);
|
||||
Expect(placeholder != null && placeholder.name == "Angler#" + other && placeholder.avatarUrl == null,
|
||||
"remote miss returns original default-name placeholder before HTTP completes");
|
||||
Expect(ReferenceEquals(placeholder, user.GetPlayInfo(other)) && events.Count == 0 && wire.Requests.Count == requestStart + 1,
|
||||
"pending remote read shares one object and one request without early event");
|
||||
if (wire.Requests.Count == requestStart + 1)
|
||||
{
|
||||
var body = JObject.Parse(wire.Requests[^1].Body);
|
||||
Expect(body.Properties().Count() == 1 && (string)body["PlayFabId"] == other,
|
||||
"existing request field carries target new UserID without extra identity aliases");
|
||||
release.SetResult(await Json(new { code = 1, info = new { playFabId = other, displayName = "Remote", avatarUrl = "remote-avatar", level = 1 } }));
|
||||
await updated.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Expect(ReferenceEquals(placeholder, user.GetPlayInfo(other)) && placeholder.name == "Remote" &&
|
||||
placeholder.avatarUrl == "remote-avatar" && events.Count == 1 && events[0].id == other,
|
||||
"successful HTTP updates the same consumer object and publishes one event");
|
||||
await ExpectUnloaded(other, "SearchFriend nickname success does not populate asynchronous level cache");
|
||||
updated = null;
|
||||
}
|
||||
else { release.SetResult(await Json(new { code = 0 })); updated = null; }
|
||||
|
||||
// Completed in-memory responses exercise the actual async-void consumer's synchronous-completion path.
|
||||
foreach (var sample in new[]
|
||||
{
|
||||
(Body: "{\"code\":-1,\"info\":null}", Status: HttpStatusCode.OK, Success: false),
|
||||
(Body: "{\"code\":1,\"info\":null}", Status: HttpStatusCode.OK, Success: false),
|
||||
(Body: "null", Status: HttpStatusCode.OK, Success: false),
|
||||
(Body: "", Status: HttpStatusCode.OK, Success: false),
|
||||
(Body: "{invalid", Status: HttpStatusCode.OK, Success: false),
|
||||
(Body: "{}", Status: HttpStatusCode.InternalServerError, Success: false),
|
||||
(Body: "{\"code\":2,\"info\":{\"displayName\":\"\",\"avatarUrl\":null}}", Status: HttpStatusCode.OK, Success: true)
|
||||
})
|
||||
{
|
||||
user.ClearPlayInfos(); events.Clear();
|
||||
wire.Next = Task.FromResult(new HttpResponseMessage(sample.Status) { Content = new StringContent(sample.Body) });
|
||||
int sent = wire.Requests.Count;
|
||||
var profile = user.GetPlayInfo(other);
|
||||
Expect(profile != null && profile.name == "Angler#" + other && profile.avatarUrl == null &&
|
||||
ReferenceEquals(profile, user.GetPlayInfo(other)), "missing nickname or failed response preserves the cached placeholder");
|
||||
Expect(wire.Requests.Count == sent + 1 && events.Count == (sample.Success ? 1 : 0),
|
||||
"code>0 and nonnull info alone determine successful profile publication: " + sample.Body);
|
||||
}
|
||||
Expect(Effects.SDK == sdk && user.IsAuthorized, "KV profiles and levels never invoke PF SDK or revoke on ordinary failure");
|
||||
user.ClearPlayInfos();
|
||||
}
|
||||
finally { wire.Active = false; wire.Next = null; }
|
||||
if (failures.Count != 0) throw new Exception("C3 profile migration gaps: " + string.Join("; ", failures));
|
||||
Pass("C3-real-KV-profile-cache-reference-event-and-level-boundary-" + user.UserId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using asap.playfab.async;
|
||||
using game;
|
||||
using game.PlayerDataStorage;
|
||||
using GameCore;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using PlayFab.ClientModels;
|
||||
|
||||
static partial class Probe
|
||||
{
|
||||
sealed class StatisticsStore : IPlayerSaveStore
|
||||
{
|
||||
internal readonly List<(string key,string value)> Writes=new();
|
||||
internal readonly HashSet<string> Reject=new();
|
||||
internal int Flushes;
|
||||
internal Func<Task<PlayerSaveConfirmation>> Flush;
|
||||
public bool IsCurrent=>true;
|
||||
public PlayerSaveReadResult Read(string key)=>throw new NotSupportedException();
|
||||
public PlayerSaveWriteResult Write(string key,string value,bool remoteStore=true)
|
||||
{
|
||||
Check(remoteStore,"statistics uses remote storage"); Writes.Add((key,value));
|
||||
return Reject.Contains(key) ? new PlayerSaveWriteResult("fixture-rejected") : new PlayerSaveWriteResult(new PlayerSaveReceipt(this,null));
|
||||
}
|
||||
public Task<PlayerSaveConfirmation> FlushAsync(CancellationToken token=default)
|
||||
{ Flushes++;return Flush?.Invoke() ?? Task.FromResult(new PlayerSaveConfirmation(PlayerSaveStatus.Confirmed)); }
|
||||
public Task<PlayerSaveConfirmation> ConfirmAsync(PlayerSaveReceipt r,CancellationToken t=default)=>throw new NotSupportedException("No extra per-key flush.");
|
||||
public Task<PlayerSaveConfirmation> WriteAndConfirmAsync(string k,string v,CancellationToken t=default)=>throw new NotSupportedException("No per-key blocking write.");
|
||||
}
|
||||
|
||||
static KVUserService StatisticsUser(IPlayerSaveStore store,string region="AS")
|
||||
{
|
||||
var user=new KVUserService(PlayerKVRuntimeSelection.Capture("kv",PlayerKVRuntimeSelection.FormalApiUrl,false),
|
||||
_=>new Handler(_=>throw new Exception("No network for contract fixture"))) {SaveStore=store};
|
||||
typeof(KVUserService).GetProperty(nameof(KVUserService.ContinentCode)).SetValue(user,region);
|
||||
return user;
|
||||
}
|
||||
static bool StatisticsRefreshing(LeadboardData board)=>(bool)typeof(LeadboardData).GetField("isRefreshing",BindingFlags.Instance|BindingFlags.NonPublic).GetValue(board);
|
||||
static void StatisticsContext(IUserService user,int level,string building)
|
||||
{
|
||||
GContext.container.Values[typeof(IUserService)]=user;
|
||||
GContext.container.Values[typeof(PlayerData)]=new PlayerData {lv=level,InfiniteBuildingLevel=building};
|
||||
var tables=new cfg.Tables();tables.TbMapData.DataList.Add(new cfg.MapData {LevelRequired=100});
|
||||
GContext.container.Values[typeof(cfg.Tables)]=tables;
|
||||
}
|
||||
static async Task StatisticsWriteTests()
|
||||
{
|
||||
var saved=new Dictionary<Type,object>(GContext.container.Values);
|
||||
try
|
||||
{
|
||||
// Execute the unchanged provider branch against recorded SDK requests first.
|
||||
foreach(IUserService pf in new IUserService[]{new IdentityPFUser(),new AggUserService()})
|
||||
{
|
||||
var board=new LeadboardData {userService=pf};
|
||||
PlayFabClientAsyncAPI.StatisticsRequests.Clear();
|
||||
var done=new TaskCompletionSource<UpdatePlayerStatisticsResult>();
|
||||
PlayFabClientAsyncAPI.StatisticsOverride=()=>done.Task;
|
||||
var pending=board.SetLeadboardData(new() {"LevelB","ASLevelB"},new() {1200034,1200034});
|
||||
Check(!pending.IsCompleted && PlayFabClientAsyncAPI.StatisticsRequests.Count==1,"PF/AGG still await single SDK request");
|
||||
var stats=PlayFabClientAsyncAPI.StatisticsRequests[0].Statistics;
|
||||
Check(stats.Count==2 && stats[0].StatisticName=="LevelB" && stats[1].StatisticName=="ASLevelB" && stats.All(s=>s.Value==1200034),"PF/AGG original parameters");
|
||||
UnityEngine.Debug.LogError("fixture original PF error callback");done.SetResult(null);await pending;
|
||||
Check(pending.IsCompletedSuccessfully,"PF logged error/null completes normally");
|
||||
}
|
||||
PlayFabClientAsyncAPI.StatisticsOverride=null;
|
||||
Pass("C3-statistics-original-PF-AGG-request-and-error-contract");
|
||||
|
||||
var store=new StatisticsStore();var user=StatisticsUser(store);var kv=new LeadboardData {userService=user};
|
||||
Effects.Clear();
|
||||
await kv.SetLeadboardData(new(){"LevelB","ASLevelB"},new(){1200034,1200034});
|
||||
Check(Effects.SDK==0 && store.Writes.Count==2 && store.Flushes==1,"KV statistics must replace PF with existing store");
|
||||
Check(store.Writes.SequenceEqual(new[]{("LevelB","1200034"),("ASLevelB","1200034")}),"two original statistic names and invariant decimal values");
|
||||
|
||||
foreach(var c in new[]{(12,"34",1200034,12),(12,"99999",1299999,12),(12,"100000",1299999,12),
|
||||
(12,"bad",1200000,12),(120,"34",10000034,100),(-2,"-4",-200004,-2)})
|
||||
{
|
||||
store.Writes.Clear();StatisticsContext(user,c.Item1,c.Item2);kv.SetLvData();
|
||||
Check(!StatisticsRefreshing(kv) && store.Writes.Count==2 && store.Writes.All(w=>w.value==c.Item3.ToString(CultureInfo.InvariantCulture)),"real SetLvData original formula "+c);
|
||||
Check(GContext.container.Resolve<PlayerData>().lv==c.Item4,"original lv clamp/writeback");
|
||||
}
|
||||
foreach(var region in new string[]{null,"","AF","AN","AS","EU","NA","OC","SA"})
|
||||
{
|
||||
user=StatisticsUser(store,region);kv.userService=user;store.Writes.Clear();StatisticsContext(user,12,"34");kv.SetLvData();
|
||||
Check(store.Writes.Count==(string.IsNullOrEmpty(region)?1:2) && store.Writes[0].key=="LevelB","missing/valid continent keeps global statistic");
|
||||
if(!string.IsNullOrEmpty(region)) Check(store.Writes[1].key==region+"LevelB","continent prefix preserved");
|
||||
}
|
||||
store.Writes.Clear();var previous=store.Flushes;
|
||||
try {await kv.SetLeadboardData(new(){"LevelB","ASLevelB"},new(){1});throw new Exception("Expected original length error");}
|
||||
catch(ArgumentOutOfRangeException) {Check(store.Writes.Count==0 && store.Flushes==previous,"parameters fail before first mutation");}
|
||||
Pass("C3-statistics-real-formula-regions-and-prewrite-parameter-error");
|
||||
|
||||
foreach(var status in Enum.GetValues<PlayerSaveStatus>())
|
||||
{
|
||||
var before=UnityEngine.Debug.Errors.Count;
|
||||
store=new StatisticsStore {Flush=()=>Task.FromResult(new PlayerSaveConfirmation(status,"fixture"))};
|
||||
kv.userService=user=StatisticsUser(store);StatisticsContext(user,12,"34");kv.SetLvData();
|
||||
Check(!StatisticsRefreshing(kv) && store.Flushes==1,"all known statuses complete original async void "+status);
|
||||
Check((UnityEngine.Debug.Errors.Count>before)==(status!=PlayerSaveStatus.Confirmed),"non-confirmed state is observable "+status);
|
||||
}
|
||||
foreach(var rejected in new[]{new[]{"LevelB"},new[]{"LevelB","ASLevelB"}})
|
||||
{
|
||||
store=new StatisticsStore();store.Reject.UnionWith(rejected);kv.userService=StatisticsUser(store);
|
||||
var before=UnityEngine.Debug.Errors.Count;
|
||||
await kv.SetLeadboardData(new(){"LevelB","ASLevelB"},new(){1,1});
|
||||
Check(store.Writes.Count==2 && store.Flushes==(rejected.Length==2?0:1),"partial/all rejected flush only accepted work");
|
||||
Check(UnityEngine.Debug.Errors.Skip(before).Count()==rejected.Length && rejected.All(k=>UnityEngine.Debug.Errors.Skip(before).Any(s=>s.Contains(k))),"each rejected key logged");
|
||||
}
|
||||
var release=new TaskCompletionSource<PlayerSaveConfirmation>();store=new StatisticsStore {Flush=()=>release.Task};
|
||||
kv.userService=user=StatisticsUser(store);StatisticsContext(user,12,"34");kv.SetLvData();
|
||||
Check(StatisticsRefreshing(kv),"original in-progress flag stays set through pending confirmation");
|
||||
var replacement=new StatisticsStore();user.SaveStore=replacement;
|
||||
release.SetResult(new(PlayerSaveStatus.Cancelled));await Task.Yield();await Task.Yield();
|
||||
Check(!StatisticsRefreshing(kv) && replacement.Writes.Count==0 && replacement.Flushes==0,"pending write retains original store and resets after cancellation");
|
||||
var timer=new TaskCompletionSource<bool>();int seconds=0;
|
||||
asap.core.Awaiters.SecondsOverride=value=>{seconds=(int)value;return timer.Task;};
|
||||
kv.AwaitSetLvData();kv.AwaitSetLvData();
|
||||
Check(seconds==300 && StatisticsRefreshing(kv) && replacement.Writes.Count==0,"original 300-second timer and in-progress skip");
|
||||
timer.SetResult(true);await Task.Yield();await Task.Yield();
|
||||
Check(replacement.Writes.Count==2 && !StatisticsRefreshing(kv),"timer invokes original SetLvData once");
|
||||
Check(Effects.SDK==0,"all KV contract branches have zero PF SDK calls");
|
||||
Pass("C3-statistics-failure-partial-confirmation-and-original-timing");
|
||||
await StatisticsRealStoreTests();
|
||||
}
|
||||
finally
|
||||
{
|
||||
PlayFabClientAsyncAPI.StatisticsOverride=null;asap.core.Awaiters.SecondsOverride=null;
|
||||
GContext.container.Values.Clear();foreach(var pair in saved)GContext.container.Values[pair.Key]=pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task StatisticsRealStoreTests()
|
||||
{
|
||||
// Real account session, KV service, store, handle and sync coordinator; only HTTP is synthetic.
|
||||
foreach(var scenario in new[]{"success","network","unrelated-failure","logout","rewrite-inflight","superseded"})
|
||||
{
|
||||
var remote=Raw();remote["ContinentCode"]="AS";var remote102=Raw();
|
||||
var oldRoot=UnityEngine.Application.persistentDataPath;
|
||||
UnityEngine.Application.persistentDataPath=Path.Combine(Path.GetTempPath(),"ft-statistics-"+Guid.NewGuid().ToString("N"));
|
||||
var requests=new List<Dictionary<string,string>>();
|
||||
var requestOwners=new List<string>();
|
||||
var started=new TaskCompletionSource<bool>();var release=new TaskCompletionSource<bool>();
|
||||
var selection=PlayerKVRuntimeSelection.Capture("kv",PlayerKVRuntimeSelection.FormalApiUrl,false);
|
||||
PlayerKVGameplayBindings.SnapshotFactory=Snapshot;
|
||||
PlayerKVGameplayBindings.BindFactory=receiver=>new PlayerKVGameLoadTransaction(
|
||||
Enumerable.Range(1,31).Select(id=>(IPlayerKVLoadModule)new PlayerKVLoadModule(id,
|
||||
()=>{if(id==31)receiver.ResetForKVLoad();},
|
||||
decoded=>{if(id==31)receiver.ReplaceFromKVDecoded(decoded.Module<PlayerKVLoginState>(31),decoded.Epoch,decoded.Raw);})),
|
||||
Environment.CurrentManagedThreadId,receiver.DiscardKVIntents);
|
||||
async Task<HttpResponseMessage> Send(HttpRequestMessage request,string owner)
|
||||
{
|
||||
var accountData=owner=="101"?remote:remote102;
|
||||
if(request.RequestUri.AbsolutePath=="/playerdata/Get")return await Json(new{data=accountData});
|
||||
if(request.RequestUri.AbsolutePath=="/playerdata/Set")
|
||||
{
|
||||
var data=JObject.Parse(await request.Content.ReadAsStringAsync())["data"].ToObject<Dictionary<string,string>>();requests.Add(data);requestOwners.Add(owner);started.TrySetResult(true);
|
||||
await release.Task;
|
||||
if(scenario=="network")throw new HttpRequestException("synthetic network failure");
|
||||
if(scenario=="unrelated-failure" && data.ContainsKey("Unrelated"))return await Json(new{},HttpStatusCode.InternalServerError);
|
||||
foreach(var pair in data)accountData[pair.Key]=pair.Value;
|
||||
return await Json(new{});
|
||||
}
|
||||
throw new Exception("Unexpected statistics route: "+request.RequestUri.AbsolutePath);
|
||||
}
|
||||
KVUserService Create(string id)
|
||||
{
|
||||
var config=Configuration();config.Set("EventApiURL",selection.Api.AbsoluteUri);
|
||||
GContext.container.Values[typeof(IChatService)]=new KVChat();
|
||||
GContext.container.Values[typeof(ISettingService)]=new DefaultSettingService();
|
||||
GContext.container.Values[typeof(ILocalNotificationService)]=new LocalNotificationService();
|
||||
GContext.container.Values[typeof(ClubService)]=new ClubService();
|
||||
return new KVUserService(selection,jar=>new Handler(request=>
|
||||
{
|
||||
if(request.RequestUri.AbsolutePath!="/account/Login")return Send(request,id);
|
||||
jar.Add(selection.Api,new Cookie("sid","statistics-"+id));
|
||||
return Json(new{state=0,internalId=int.Parse(id),newCreate=false,createTime="2026-01-01T00:00:00Z",svrTime="2026-09-30T00:00:00Z"});
|
||||
})) {CustomId=id,SaveStore=new KVPlayerSaveStore(),customServerMgr=new CustomServerMgr(config,_=>new Handler(_=>throw new Exception("PF transport forbidden")))};
|
||||
}
|
||||
var user=Create("101");KVUserService next=null;Task<ELoginStep> nextLogin=null;
|
||||
try
|
||||
{
|
||||
Check(await user.Login(null)==ELoginStep.Done,"real statistics login "+scenario);
|
||||
if(scenario=="unrelated-failure")Check(user.SaveStore.Write("Unrelated","pending").Accepted,"unrelated write queued");
|
||||
Task<PlayerSaveConfirmation> blockingFlush=null;
|
||||
if(scenario=="superseded")
|
||||
{
|
||||
Check(user.SaveStore.Write("DispatchBlock","pending").Accepted,"hold dispatch before statistics");
|
||||
blockingFlush=user.SaveStore.FlushAsync();await started.Task;
|
||||
}
|
||||
var board=new LeadboardData {userService=user};Effects.Clear();var errors=UnityEngine.Debug.Errors.Count;
|
||||
var pending=board.SetLeadboardData(new(){"LevelB","ASLevelB"},new(){1200034,1200034});
|
||||
await started.Task;Check(!pending.IsCompleted,"real wire delays statistics completion");
|
||||
if(scenario=="logout")
|
||||
{
|
||||
user.Logout();next=Create("102");nextLogin=next.Login(null);
|
||||
Check(!nextLogin.IsCompleted && !next.IsAuthorized,"existing shutdown registry delays new login until old write drains");
|
||||
board.userService=next;
|
||||
}
|
||||
if(scenario=="superseded" || scenario=="rewrite-inflight")Check(user.SaveStore.Write("LevelB","1300034").Accepted,"newer same-key value queued during flush");
|
||||
release.TrySetResult(true);await pending;
|
||||
if(blockingFlush!=null)await blockingFlush;
|
||||
if(nextLogin!=null)Check(await nextLogin==ELoginStep.Done,"new account authenticates after previous close");
|
||||
Check(Effects.SDK==0,"real KV storage never calls PF "+scenario);
|
||||
Check(requestOwners.All(id=>id=="101"),"statistics transport retains original authenticated owner");
|
||||
if(scenario=="success")
|
||||
{
|
||||
Check(remote["LevelB"]=="1200034" && remote["ASLevelB"]=="1200034","real KV protocol persists both statistics");
|
||||
await user.CloseKVForValidationAsync();next=Create("101");Check(await next.Login(null)==ELoginStep.Done,"new login reads stored statistics");
|
||||
Check(next.SaveStore.Read("LevelB").Value=="1200034" && next.SaveStore.Read("ASLevelB").Value=="1200034","new store sees both persisted keys");
|
||||
}
|
||||
else if(scenario=="rewrite-inflight")
|
||||
{
|
||||
Check(remote["LevelB"]=="1200034" && user.SaveStore.Read("LevelB").Value=="1300034","dispatched ticket confirms original while newer value remains local");
|
||||
Check(UnityEngine.Debug.Errors.Count==errors,"already dispatched receipt is not falsely superseded");
|
||||
Check((await user.SaveStore.FlushAsync()).Confirmed && remote["LevelB"]=="1300034","later flush still owns newer pending receipt");
|
||||
}
|
||||
else
|
||||
{
|
||||
Check(UnityEngine.Debug.Errors.Count>errors,"non-confirmed real write emits original error log "+scenario);
|
||||
if(scenario=="superseded")Check(!remote.ContainsKey("LevelB") && remote["ASLevelB"]=="1200034" &&
|
||||
UnityEngine.Debug.Errors.Skip(errors).Any(e=>e.Contains("Superseded")),"partial remote result is logged as superseded, never both-key success");
|
||||
if(scenario=="unrelated-failure")Check(requests.Any(r=>r.ContainsKey("Unrelated")),"known all-pending flush difference exercised");
|
||||
if(scenario=="logout")Check(next.IsAuthorized && next.SaveStore.Read("LevelB").Found==false,"old completion cannot acknowledge/write new account store");
|
||||
}
|
||||
Pass("C3-statistics-real-store-"+scenario);
|
||||
}
|
||||
finally {release.TrySetResult(true);await user.CloseKVForValidationAsync();if(next!=null)await next.CloseKVForValidationAsync();UnityEngine.Application.persistentDataPath=oldRoot;}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using game;
|
||||
using game.PlayerDataStorage;
|
||||
|
||||
static partial class Probe
|
||||
{
|
||||
static async Task MergedUser()
|
||||
{
|
||||
var selection = PlayerKVRuntimeSelection.Capture("kv", PlayerKVRuntimeSelection.FormalApiUrl, false);
|
||||
var user = new KVUserService(selection, _ => new Handler(_ => throw new Exception("unexpected HTTP")));
|
||||
Check(user is IUserService && user is IKVUserLoadReceiver && user is IKVUserLifecycle,
|
||||
"independent KV service implements actual consumer contracts");
|
||||
Check(!typeof(PlayFabUserService).IsAssignableFrom(user.GetType()), "KV does not inherit PF");
|
||||
Check(typeof(IUserService).GetProperty("entityKey") == null, "common identity has no PF entity DTO");
|
||||
Check(typeof(IUserService).GetProperty("CustomId").CanWrite, "login input uses common setter");
|
||||
Check(user.GetDefaultName("101") == "Angler#101" && user.GetDefaultName("123456") == "Angler#12345",
|
||||
"short and long identity names");
|
||||
Check(KVUserService.ParseLastLoginTime(null, false) == DateTime.MinValue, "missing login time sentinel");
|
||||
foreach (var value in new[] { "", "bad", "0001-01-01T00:00:00.0000000Z" })
|
||||
{
|
||||
try { KVUserService.ParseLastLoginTime(value, true); throw new Exception("bad time accepted"); }
|
||||
catch (FormatException) { Check(true, "present invalid time is not first login"); }
|
||||
}
|
||||
Check(KVUserService.ParseLastLoginTime("2026-09-28T01:00:00.0000000Z", true).Kind == DateTimeKind.Utc,
|
||||
"stored previous login is UTC");
|
||||
user.Logout();
|
||||
await user.CloseKVForValidationAsync();
|
||||
Pass("merged-KV-contract-and-login-time");
|
||||
await IdentityPFCompatibility();
|
||||
await ProfileCachePFCompatibility();
|
||||
await MergedLogin();
|
||||
MergedDailyRefresh();
|
||||
}
|
||||
|
||||
sealed class KVChat : IChatService
|
||||
{
|
||||
internal int Waits;
|
||||
public void WaitingForLogin() { Waits++; }
|
||||
}
|
||||
|
||||
static async Task MergedLogin()
|
||||
{
|
||||
foreach (var scenario in new[] { "success", "success-102", "save-unknown", "logout-during-save", "corrupt", "logout-during-load" })
|
||||
{
|
||||
var accountId = scenario == "success-102" ? "102" : "101";
|
||||
var identityWire = new IdentityWireCapture();
|
||||
var profileWire = new ProfileCacheWire();
|
||||
var badProfile = scenario == "corrupt";
|
||||
var selection = PlayerKVRuntimeSelection.Capture("kv", PlayerKVRuntimeSelection.FormalApiUrl, false);
|
||||
var config = Configuration(); config.Set("EventApiURL", PlayerKVRuntimeSelection.FormalApiUrl);
|
||||
var root = UnityEngine.Application.persistentDataPath;
|
||||
UnityEngine.Application.persistentDataPath = Path.Combine(Path.GetTempPath(), "ft-merged-" + Guid.NewGuid().ToString("N"));
|
||||
var raw = Raw();
|
||||
var remote = new Dictionary<string, string>(raw);
|
||||
if (badProfile) raw["LastLoginTimeUtc"] = "corrupt";
|
||||
int auth = 0, gets = 0, success = 0, applied = 0;
|
||||
var getStarted = new TaskCompletionSource<bool>();
|
||||
var releaseGet = new TaskCompletionSource<System.Net.Http.HttpResponseMessage>();
|
||||
var setStarted = new TaskCompletionSource<bool>();
|
||||
var releaseSet = new TaskCompletionSource<System.Net.Http.HttpResponseMessage>();
|
||||
Action beforeSet = null;
|
||||
var chat = new KVChat();
|
||||
GContext.container.Values[typeof(IChatService)] = chat;
|
||||
GContext.container.Values[typeof(ISettingService)] = new DefaultSettingService();
|
||||
GContext.container.Values[typeof(ILocalNotificationService)] = new LocalNotificationService();
|
||||
GContext.container.Values[typeof(ClubService)] = new ClubService();
|
||||
PlayerKVGameplayBindings.SnapshotFactory = Snapshot;
|
||||
PlayerKVGameplayBindings.BindFactory = receiver => new PlayerKVGameLoadTransaction(
|
||||
Enumerable.Range(1, 31).Select(id => (IPlayerKVLoadModule)new PlayerKVLoadModule(id,
|
||||
() => { if (id == 31) receiver.ResetForKVLoad(); },
|
||||
decoded => { applied++; if (id == 31) receiver.ReplaceFromKVDecoded(decoded.Module<PlayerKVLoginState>(31), decoded.Epoch, decoded.Raw); })),
|
||||
Environment.CurrentManagedThreadId, receiver.DiscardKVIntents);
|
||||
var server = new CustomServerMgr(config, _ => new Handler(_ => throw new Exception("PF transport forbidden")));
|
||||
var user = new KVUserService(selection, jar => new Handler(request =>
|
||||
{
|
||||
if (profileWire.TryHandle(request, out var profileResponse)) return profileResponse;
|
||||
if (identityWire.TryHandle(request, out var identityResponse)) return identityResponse;
|
||||
switch (request.RequestUri.AbsolutePath)
|
||||
{
|
||||
case "/account/Login":
|
||||
auth++; jar.Add(selection.Api, new System.Net.Cookie("sid", "synthetic"));
|
||||
return Json(new { state = 0, internalId = 17, newCreate = false,
|
||||
createTime = "2026-01-01T00:00:00Z", svrTime = "2026-09-29T00:00:00Z" });
|
||||
case "/playerdata/Get": gets++; getStarted.TrySetResult(true); return releaseGet.Task;
|
||||
case "/playerdata/Set":
|
||||
var onSet = beforeSet; beforeSet = null; onSet?.Invoke();
|
||||
setStarted.TrySetResult(true);
|
||||
if (scenario == "logout-during-save") return releaseSet.Task;
|
||||
if (scenario == "save-unknown") return Json(new { }, System.Net.HttpStatusCode.InternalServerError);
|
||||
var body = Newtonsoft.Json.Linq.JObject.Parse(request.Content.ReadAsStringAsync().GetAwaiter().GetResult());
|
||||
foreach (var entry in body["data"].ToObject<Dictionary<string, string>>()) remote[entry.Key] = entry.Value;
|
||||
return Json(new { });
|
||||
case "/friend/unauthorized": return Json(new { }, System.Net.HttpStatusCode.Unauthorized);
|
||||
default: throw new Exception("unplanned synthetic route " + request.RequestUri.AbsolutePath);
|
||||
}
|
||||
})) { CustomId = accountId, customServerMgr = server, SaveStore = new KVPlayerSaveStore() };
|
||||
using var subscription = GContext.OnEvent<EvtAPISvrLoginSuccess>().Subscribe(evt =>
|
||||
{
|
||||
success++;
|
||||
Check(applied == 31 && chat.Waits == 1 && user.IsAuthorized && user.KVHandoffReady,
|
||||
"identity event follows all modules, chat subscription and original handoff");
|
||||
Check(evt.playerId == accountId && evt.internalId == 17 && server.Cookie != null,
|
||||
"event carries authenticated ID and ready cookie");
|
||||
});
|
||||
Effects.Clear();
|
||||
try
|
||||
{
|
||||
var pending = user.Login(null);
|
||||
await getStarted.Task;
|
||||
Check(!user.IsLogin && !user.IsAuthorized && user.UserId == null && success == 0,
|
||||
"no public identity during remote load");
|
||||
Check(await user.Login(null) == ELoginStep.Api && auth == 1, "duplicate login never authenticates twice");
|
||||
if (scenario == "logout-during-load") user.Logout();
|
||||
releaseGet.SetResult(await Json(new { data = raw }));
|
||||
var result = await pending;
|
||||
if (scenario == "logout-during-load")
|
||||
{
|
||||
Check(result == ELoginStep.Api && !user.IsAuthorized && user.UserId == null && success == 0 && applied == 0,
|
||||
"logout during load discards delayed response before module apply or identity");
|
||||
}
|
||||
else if (badProfile)
|
||||
{
|
||||
Check(result == ELoginStep.Api && !user.IsAuthorized && success == 0 && chat.Waits == 0,
|
||||
"corrupt profile revokes before public event or chat subscription");
|
||||
}
|
||||
else
|
||||
{
|
||||
Check(result == ELoginStep.Done && user.UserId == accountId && user.ApiPlayerId == accountId && user.InternalId == 17,
|
||||
"real KV service publishes new authenticated identity");
|
||||
Check(user.LastLoginTime == DateTime.MinValue && user.LoginTime == new DateTime(2026, 9, 29, 0, 0, 0, DateTimeKind.Utc),
|
||||
"missing previous time and authenticated current time are distinct");
|
||||
Check(user.DisplayName == "Angler#" + accountId && user.ContinentCode == null && user.KVTapJoyBalance == 9,
|
||||
"profile defaults, unknown region and decoded platform balance");
|
||||
Check(await user.Login(null) == ELoginStep.Api && success == 1 && auth == 1 && gets == 1,
|
||||
"completed login cannot publish a second event");
|
||||
if (scenario == "logout-during-save")
|
||||
{
|
||||
var save = user.UpdateLanguage("de");
|
||||
await setStarted.Task;
|
||||
user.Logout();
|
||||
Check(!user.IsAuthorized && !user.SaveStore.IsCurrent, "close invalidates store before save response");
|
||||
releaseSet.SetResult(await Json(new { }));
|
||||
try { await save; throw new Exception("late profile was published"); }
|
||||
catch (InvalidOperationException) { Check(user.Language == null, "late save completion cannot republish a closed profile"); }
|
||||
}
|
||||
else
|
||||
{
|
||||
if (scenario == "success" || scenario == "success-102")
|
||||
{
|
||||
await IdentityCompatibility(user, server, identityWire, accountId == "101" ? "102" : "101");
|
||||
await ProfileCacheKVCompatibility(user, profileWire, accountId == "101" ? "102" : "101");
|
||||
}
|
||||
if (scenario != "success-102")
|
||||
await MergedSaveContract(user, remote, scenario == "save-unknown", action => beforeSet = action);
|
||||
Check(await server.FriendRequest("unauthorized", "") == null && !user.IsAuthorized && user.UserId == null,
|
||||
"current 401 synchronously revokes independent identity");
|
||||
var sentBeforeCloseProbe = identityWire.Requests.Count;
|
||||
try
|
||||
{
|
||||
await new FriendService { userService = user, customServerMgr = server }.SearchFriend("102");
|
||||
throw new Exception("revoked transport allowed a new friend request");
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
Check(identityWire.Requests.Count == sentBeforeCloseProbe,
|
||||
"original not-ready exception prevents a friend request after identity revocation");
|
||||
}
|
||||
}
|
||||
}
|
||||
Check(Effects.SDK == 0 && Effects.Helpers == 0 && Effects.Rewards == 0,
|
||||
"formal KV never invokes PF SDK or platform/reward initialization");
|
||||
await user.CloseKVForValidationAsync();
|
||||
var staleTokens = new Activity.Services.EventTokenService(user.SaveStore);
|
||||
Check(staleTokens.GetTokenCount(1) == 0, "original token service catches unavailable storage and retains its initial balance");
|
||||
Check(!user.SaveStore.IsCurrent && !user.SaveStore.Read("Language").Success &&
|
||||
!user.SaveStore.Write("Language", "stale").Accepted, "old store is revoked synchronously on close");
|
||||
}
|
||||
finally
|
||||
{
|
||||
user.Logout(); await user.CloseKVForValidationAsync();
|
||||
UnityEngine.Application.persistentDataPath = root;
|
||||
PlayerKVGameplayBindings.BindFactory = null; PlayerKVGameplayBindings.SnapshotFactory = null;
|
||||
}
|
||||
}
|
||||
Pass("merged-real-KV-login-publication-duplicate-401-and-corrupt-profile");
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ using Newtonsoft.Json;
|
||||
using PlayFab.ClientModels;
|
||||
using tysdk;
|
||||
|
||||
static class Probe
|
||||
static partial class Probe
|
||||
{
|
||||
static int assertions,groups;
|
||||
static void Check(bool condition,string label){assertions++;if(!condition)throw new Exception(label);}
|
||||
@@ -57,12 +57,33 @@ static class Probe
|
||||
}
|
||||
}
|
||||
Pass("public-backend-rejection-before-AGG-shortcut");
|
||||
SubBoostrap.RuntimeSelection=PlayerKVRuntimeSelection.Capture("kv",PlayerKVRuntimeSelection.FormalApiUrl,false);
|
||||
var formalConfig=Configuration(); formalConfig.Set("SaveBackend","kv");
|
||||
foreach(var disallowed in new[]{"100","103","-101","x101"})
|
||||
{
|
||||
var formalUser=new KVUserService{CustomId=disallowed};
|
||||
Effects.Clear(); PlayFab.PlayFabClientAPI.Logged=true;
|
||||
Check(await formalUser.Login(null)==ELoginStep.Api && formalUser.KVLastError==PlayerKVGameLoadError.Input,
|
||||
"formal cohort rejected before any account request");
|
||||
Check(!formalUser.IsLogin && !formalUser.IsAuthorized && Effects.Total==0,
|
||||
"formal rejection does not publish PF identity or cause side effects");
|
||||
formalUser.Logout();
|
||||
Check(Effects.SDK==0,"formal logout does not clear PF credentials");
|
||||
}
|
||||
var quittingUser=new KVUserService{CustomId="101"};
|
||||
var oldLifetime=quittingUser.LoginLifetime; Effects.Clear();
|
||||
UnityEngine.Application.QuitForTest();
|
||||
Check(oldLifetime.IsCancellationRequested && Effects.SDK==0,
|
||||
"application quitting revokes formal login lifetime without PF credential calls");
|
||||
SubBoostrap.RuntimeSelection=null;
|
||||
Pass("formal-cohort-rejection-and-PF-credential-isolation");
|
||||
var pf=new PlayFabMgr();Private(pf,"Awake");
|
||||
var data=new Dictionary<string,UserDataRecord>{{"MMTest",new(){Value="MMFailParam#4",LastUpdated=DateTime.UtcNow}},{"TapJoy_Diamond",new(){Value="7",LastUpdated=DateTime.UtcNow}}};
|
||||
pf.CompareDatas(data,"synthetic");pf.UpdateUserDataValue("local","value",false);
|
||||
Check(pf.GetLocalData("local")=="value","default PF local write unchanged");
|
||||
Check(await pf.SyncDataImmidiate("remote","value"),"default PF immediate result unchanged");
|
||||
Pass("default-PF-storage-passthrough");
|
||||
await MergedPFStore(pf);
|
||||
foreach(bool agg in new[]{false,true})
|
||||
{
|
||||
var config=Configuration();PlayFabUserService user=agg?new AggUserService():new PlayFabUserService();user.config=config;user.CustomId="101";
|
||||
@@ -122,6 +143,9 @@ static class Probe
|
||||
Check(Effects.Total==0 && fresh.IsUnstartedForKVLoad && guard.Violations==12,"all PF entrances reject before buffer/Prefs/SDK");
|
||||
}
|
||||
Pass("all-real-PF-guard-entrances");await Controlled();
|
||||
await FormalTransport();
|
||||
await MergedUser();
|
||||
await StatisticsWriteTests();
|
||||
}
|
||||
sealed class Chat:IChatService {public void WaitingForLogin(){} }
|
||||
sealed class Setting:ISettingService {public string Lang=>"en";}
|
||||
@@ -158,6 +182,87 @@ static class Probe
|
||||
Check(unauthorizedEvents==1 && auth==3 && Effects.Total==0 && user.KVIntents==null,"late 401 never reauthenticates or publishes another unauthorized event");
|
||||
Pass("real-Agg-User-CustomServer-coordinator-KV-chain");
|
||||
}
|
||||
static async Task FormalTransport()
|
||||
{
|
||||
var api=new Uri(PlayerKVRuntimeSelection.FormalApiUrl);
|
||||
var config=new Config();config.Set("EventApiURL",api.AbsoluteUri);
|
||||
int auth=0,kvGet=0,business=0,unauthorized=0;
|
||||
var ownerThread=Environment.CurrentManagedThreadId;
|
||||
CookieContainer jar=null;
|
||||
var context=new PlayerKVLoginContext("101",Path.Combine(Path.GetTempPath(),"ft-cp2b-t-"+Guid.NewGuid().ToString("N")),31,
|
||||
_=>true,api,
|
||||
()=>new S101AccountSession(api,cookies=>new Handler(r=>
|
||||
{
|
||||
auth++;jar=cookies;cookies.Add(api,new Cookie("sid","synthetic"));
|
||||
return Json(new{state=0,internalId=17,newCreate=false,createTime="2026-01-01T00:00:00Z",svrTime="2026-01-01T00:00:00Z"});
|
||||
})),
|
||||
()=>new PlayerKVGameSessionBridge(cookies=>new Handler(r=>
|
||||
{kvGet++;Check(ReferenceEquals(cookies,jar),"KV shares account jar");return Json(new{data=new Dictionary<string,string>()});})));
|
||||
Check((await context.PrepareAsync()).Prepared,"formal synthetic account prepared");
|
||||
var late=new TaskCompletionSource<HttpResponseMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
Check(context.TryAcquireAccountLease(PlayerKVControlledLoadCapability.CreateForGame(cookies=>new Handler(r=>
|
||||
{
|
||||
Check(ReferenceEquals(cookies,jar),"ordinary send shares account jar");business++;
|
||||
Check(Environment.CurrentManagedThreadId==ownerThread,"ordinary transport runs on owner thread");
|
||||
return r.RequestUri.AbsolutePath switch
|
||||
{
|
||||
"/friend/late"=>late.Task,
|
||||
"/friend/unauthorized"=>Json(new{},HttpStatusCode.Unauthorized),
|
||||
"/mail/missing"=>Json(new{},HttpStatusCode.NotFound),
|
||||
_=>Json(new{ok=true})
|
||||
};
|
||||
})),out var lease),"formal lease acquired");
|
||||
var server=new CustomServerMgr(config,_=>new Handler(r=>throw new Exception("PF transport forbidden")));
|
||||
server.KVUnauthorized+=_=>unauthorized++;
|
||||
server.BindKVForGame(lease);
|
||||
try { await server.CustomServerPost("mail/fetch","{}");throw new Exception("pre-ready send escaped"); }
|
||||
catch(InvalidOperationException) { Check(business==0,"pre-ready zero send"); }
|
||||
server.EnableKVBusiness(lease.Epoch);
|
||||
Check(ReferenceEquals(server.Cookie,jar),"RT sees same account cookie only after readiness");
|
||||
Check((await server.CustomServerPost("mail/fetch","{}"))!=null && business==1,"business POST uses lease");
|
||||
Check((await server.GetStringAsync("anti-cheat/GetConfigRequest"))!=null && business==2,"business GET uses lease");
|
||||
Check(await Task.Run(()=>server.GetStringAsync("friend/background"))!=null && business==3,
|
||||
"background caller dispatched to original service thread");
|
||||
Check(await server.GetStringAsync("mail/missing")==null && business==4 && unauthorized==0,
|
||||
"non-200 response returns empty without revoking identity");
|
||||
var old=server.GetStringAsync("friend/late");
|
||||
var version=server.SessionVersion;
|
||||
Check(await server.GetStringAsync("friend/unauthorized")==null && unauthorized==1 && server.SessionVersion>version,
|
||||
"current 401 disables transport and advances version");
|
||||
Check(business==6 && auth==1 && kvGet==1,"one authentication, one KV read, no PF transport");
|
||||
try { server.EnableKVBusiness(lease.Epoch);throw new Exception("revoked transport reopened"); }
|
||||
catch(InvalidOperationException) { Check(true,"single publication cannot reopen after 401"); }
|
||||
try { _=server.Cookie;throw new Exception("revoked cookie escaped"); }
|
||||
catch(NullReferenceException) { Check(true,"revoked cookie unavailable"); }
|
||||
var close=server.ClearKVForGame();Check(!close.IsCompleted,"inflight request drains before closing");
|
||||
late.SetResult(new HttpResponseMessage(HttpStatusCode.OK){Content=new StringContent("old-session-body")});
|
||||
Check(await old==null,"old 200 body withheld after unauthorized revocation");
|
||||
await close;await context.CloseAsync();
|
||||
CookieContainer nextJar=null;
|
||||
var nextContext=new PlayerKVLoginContext("101",Path.Combine(Path.GetTempPath(),"ft-cp2b-t-"+Guid.NewGuid().ToString("N")),32,
|
||||
_=>true,api,
|
||||
()=>new S101AccountSession(api,cookies=>new Handler(r=>
|
||||
{
|
||||
auth++;nextJar=cookies;cookies.Add(api,new Cookie("sid","next-synthetic"));
|
||||
return Json(new{state=0,internalId=17,newCreate=false,createTime="2026-01-01T00:00:00Z",svrTime="2026-01-01T00:00:00Z"});
|
||||
})),
|
||||
()=>new PlayerKVGameSessionBridge(cookies=>new Handler(r=>Json(new{data=new Dictionary<string,string>()}))));
|
||||
Check((await nextContext.PrepareAsync()).Prepared,"same account new epoch prepared");
|
||||
Check(nextContext.TryAcquireAccountLease(PlayerKVControlledLoadCapability.CreateForGame(cookies=>new Handler(r=>
|
||||
{
|
||||
Check(ReferenceEquals(cookies,nextJar) && !ReferenceEquals(cookies,jar),"new epoch owns new cookie jar");
|
||||
return Json(new{next=true});
|
||||
})),out var nextLease),"new epoch lease acquired");
|
||||
server.BindKVForGame(nextLease);
|
||||
Check(!ReferenceEquals(lease,nextLease),"new epoch has distinct lease");
|
||||
try { _=server.Cookie;throw new Exception("new epoch cookie published before readiness"); }
|
||||
catch(NullReferenceException) { Check(true,"new epoch cookie withheld before readiness"); }
|
||||
server.EnableKVBusiness(nextLease.Epoch);
|
||||
Check(ReferenceEquals(server.Cookie,nextJar) && await server.GetStringAsync("friend/next")!=null,
|
||||
"same account new epoch uses only its current lease");
|
||||
await server.ClearKVForGame();await nextContext.CloseAsync();
|
||||
Pass("formal-KV-business-transport-and-401-boundary");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings><Nullable>disable</Nullable>
|
||||
@@ -14,6 +14,28 @@
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/PlayerData/PlayerKV*.cs" Exclude="../../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/Account/S101AccountSession.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/UserService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/KVUserService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/PlayerData/IPlayerSaveStore.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/PlayerData/PlayerSaveStores.cs" />
|
||||
<Compile Include="../M2MergedContractTests.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/SurveyService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/FriendService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/DataCenter/DataCenter.LeadboardData.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/FriendModel.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/FootPrintService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/InGameMail.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/InGameMailHandler.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/UI/PartnerStatue/EventPartnerStatuePlayfabData.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Activity/Data/EventDataRepository.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Activity/Services/EventTokenService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Activity/Services/IEventTokenService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Activity/Data/IEventDataRepository.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/TimeManager/TimeRefreshManager.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/TimeManager/ITimeTickService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/TimeManager/Event/*.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/DataCenter/DateTimeExtensions.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/PlayerData/IKVUser*.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Core/KVMgr.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/UserService.KVLoad.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/AggUserService.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Core/PlayFabMgr.cs" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Only SDK/engine/business dependencies are replaced. Four service sources are linked unchanged.
|
||||
// Only SDK/engine/business dependencies are replaced. Four service sources are linked unchanged.
|
||||
using PlayFab.ClientModels;
|
||||
using Newtonsoft.Json;
|
||||
// Legacy desktop fallback names AndroidDeviceId on CustomID request (unreachable with validated CustomId).
|
||||
@@ -12,6 +12,7 @@ static class Effects
|
||||
}
|
||||
namespace UnityEngine
|
||||
{
|
||||
public static class Mathf { public static int Min(int a, int b) => Math.Min(a, b); }
|
||||
public class Object { public static implicit operator bool(Object value)=>value!=null; public static void DontDestroyOnLoad(Object o){} }
|
||||
public class GameObject:Object
|
||||
{
|
||||
@@ -20,14 +21,17 @@ namespace UnityEngine
|
||||
public T AddComponent<T>() where T:new(){Effects.Helpers++;return new T();}
|
||||
}
|
||||
public class MonoBehaviour:Object { public GameObject gameObject=new(); public T GetComponent<T>() where T:class=>null; }
|
||||
public static class Debug { public static void Log(object o){} public static void LogWarning(object o){} public static void LogError(object o){} }
|
||||
public static class Application { public static string version="synthetic",identifier="101",persistentDataPath=Path.GetTempPath(); }
|
||||
public static class Debug { internal static readonly List<string> Errors=new(); public static void Log(object o){} public static void LogWarning(object o){} public static void LogError(object o){Errors.Add(o?.ToString());} public static void LogException(Exception e){System.Console.WriteLine("fixture observed exception: " + e);} }
|
||||
public static class Application { public static string version="synthetic",identifier="101",persistentDataPath=Path.GetTempPath(); public static event Action quitting; public static void QuitForTest()=>quitting?.Invoke(); }
|
||||
public static class SystemInfo { public static string deviceUniqueIdentifier="101",deviceModel="stub"; }
|
||||
public static class PlayerPrefs
|
||||
{
|
||||
public static readonly Dictionary<string,string> Data=new();
|
||||
public static string GetString(string key,string fallback=""){Effects.Prefs++;return Data.GetValueOrDefault(key,fallback);}
|
||||
public static void SetString(string key,string value){Effects.Prefs++;Data[key]=value;}
|
||||
public static bool HasKey(string key) => Data.ContainsKey(key);
|
||||
public static int GetInt(string key,int fallback=0)=>throw new NotSupportedException("Activity preference is not under test.");
|
||||
public static void SetInt(string key,int value)=>throw new NotSupportedException("Activity preference is not under test.");
|
||||
public static void Save(){Effects.Prefs++;}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +39,7 @@ namespace asap.core
|
||||
{
|
||||
public class InjectAttribute:Attribute{}
|
||||
public interface IConfig { T Get<T>(string key,T fallback); void Set(string key,object value); }
|
||||
public static class Awaiters { public static Task NextFrame=>Task.CompletedTask; }
|
||||
public static class Awaiters { public static Task NextFrame=>Task.CompletedTask; internal static Func<float,Task> SecondsOverride; public static Task Seconds(float value)=>SecondsOverride?.Invoke(value) ?? throw new NotSupportedException("Unexpected timer."); }
|
||||
}
|
||||
sealed class Config:asap.core.IConfig
|
||||
{
|
||||
@@ -45,9 +49,51 @@ sealed class Config:asap.core.IConfig
|
||||
}
|
||||
namespace UniRx
|
||||
{
|
||||
public sealed class Stream<T>
|
||||
{ internal static Action<T> Handlers; public IDisposable Subscribe(Action<T> action){Effects.Subscriptions++;Handlers+=action;return new Subscription(()=>Handlers-=action);} }
|
||||
public sealed class Stream<T> : IObservable<T>
|
||||
{
|
||||
internal static Action<T> Handlers;
|
||||
public IDisposable Subscribe(Action<T> action){Effects.Subscriptions++;Handlers+=action;return new Subscription(()=>Handlers-=action);}
|
||||
public IDisposable Subscribe(IObserver<T> observer) => Subscribe(observer.OnNext);
|
||||
}
|
||||
public sealed class Subscription:IDisposable { readonly Action action; public Subscription(Action action)=>this.action=action;public void Dispose()=>action(); }
|
||||
public sealed class CompositeDisposable : IDisposable
|
||||
{
|
||||
readonly List<IDisposable> values = new();
|
||||
public void Add(IDisposable value) => values.Add(value);
|
||||
public void Dispose() { foreach (var value in values) value.Dispose(); values.Clear(); }
|
||||
}
|
||||
public static class ProbeRxExtensions
|
||||
{
|
||||
sealed class Observer<T> : IObserver<T>
|
||||
{
|
||||
readonly Action<T> next;
|
||||
internal Observer(Action<T> next) { this.next = next; }
|
||||
public void OnNext(T value) => next(value);
|
||||
public void OnCompleted() { }
|
||||
public void OnError(Exception error) => throw error;
|
||||
}
|
||||
public static IDisposable Subscribe<T>(this IObservable<T> source, Action<T> next) => source.Subscribe(new Observer<T>(next));
|
||||
public static void AddTo(this IDisposable subscription, CompositeDisposable subscriptions) => subscriptions.Add(subscription);
|
||||
}
|
||||
}
|
||||
namespace cfg
|
||||
{
|
||||
public sealed class Tables { public GlobalConfig TbGlobalConfig { get; } = new(); public TbSurveyData TbSurveyData { get; } = new(); public TbAutoDeliveryMail TbAutoDeliveryMail { get; } = new(); public TbInvite TbInvite { get; } = new();
|
||||
public TbMapData TbMapData {get;}=new(); public UnusedTable<FishingEvent> TbFishingEvent {get;}=new(); public UnusedTable<RankInit> TbRankInit {get;}=new(); public UnusedTable<RankTargets> TbRankTargets {get;}=new(); public UnusedTable<Robot> TbRobot {get;}=new(); }
|
||||
public sealed class MapData { public int LevelRequired; }
|
||||
public sealed class TbMapData { public List<MapData> DataList=new(); }
|
||||
public sealed class FishingEvent { public int ID,RedirectID; }
|
||||
public sealed class RankInit { public int UnlockPoint; public List<int> TargetList; }
|
||||
public sealed class RankTargets { public int NextTarget,TokenRequiredB; }
|
||||
public sealed class Robot { public string Name_l10n_key,Avatar; }
|
||||
public sealed class UnusedTable<T> { public T GetOrDefault(int id)=>throw new NotSupportedException("Activity leaderboard is not under test."); public Dictionary<int,T> DataMap=>throw new NotSupportedException("Activity leaderboard is not under test."); }
|
||||
public sealed class Invite { public int NumInvite, DropID; }
|
||||
public sealed class TbInvite { public List<Invite> DataList = new(); }
|
||||
public sealed class SurveyData { public int ID, LevelCondition, Reward; public string Hash; public List<int> TailNumberList = new(); }
|
||||
public sealed class TbSurveyData { public List<SurveyData> DataList = new(); }
|
||||
public sealed class AutoDeliveryMail { public int MailID, ExpireDay, DropID; public List<int> AccountLevelRange = new(); public string AccountCreateTimestamp, Title_l10n_key, Content_l10n_key, Inscribe_l10n_key; public List<string> LoginTimestampRange = new(); }
|
||||
public sealed class TbAutoDeliveryMail { public Dictionary<int, AutoDeliveryMail> DataMap = new(); public List<AutoDeliveryMail> DataList = new(); }
|
||||
public sealed class GlobalConfig { public int GlobalTimeOffset = 9; }
|
||||
}
|
||||
public static class GContext
|
||||
{
|
||||
@@ -55,13 +101,28 @@ public static class GContext
|
||||
public static UniRx.Stream<T> OnEvent<T>()=>new();
|
||||
public static void Publish<T>(T value){Effects.Events++;UniRx.Stream<T>.Handlers?.Invoke(value);}
|
||||
}
|
||||
public static class SubBoostrap
|
||||
{ internal static game.PlayerDataStorage.PlayerKVRuntimeSelection RuntimeSelection { get; set; } }
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// The service-host verifies service branch behavior; Unity resolves the real 31-module binding.
|
||||
internal static class PlayerKVGameplayBindings
|
||||
{
|
||||
internal static Func<IKVUserLoadReceiver, PlayerKVGameLoadTransaction> BindFactory;
|
||||
internal static Func<PlayerKVSchemaSnapshot> SnapshotFactory;
|
||||
internal static PlayerKVGameLoadTransaction Bind(IKVUserLoadReceiver user) => BindFactory?.Invoke(user) ?? throw new NotSupportedException("Unity module binding required.");
|
||||
internal static PlayerKVSchemaSnapshot Snapshot(DateTime nowUtc,string version="kv-loop") => SnapshotFactory?.Invoke() ?? throw new NotSupportedException("Unity configuration required.");
|
||||
}
|
||||
}
|
||||
public sealed class Container
|
||||
{ public readonly Dictionary<Type,object> Values=new();public T Resolve<T>()=> (T)Values[typeof(T)]; }
|
||||
{ public readonly Dictionary<Type,object> Values=new();public T Resolve<T>()=> (T)Values[typeof(T)]; public object ResolveStage(string name)=>throw new NotSupportedException("Stage is not under test."); }
|
||||
public static class GameDebug { public static void LogError(string s,params object[] a){} }
|
||||
public static class ZZTimeHelper { public static DateTime UtcNow()=>new(2026,9,23,0,0,0,DateTimeKind.Utc); public static bool Adjusted; public static void AdjustTime(DateTime t)=>Adjusted=true;public static void WathForcusChange(){} }
|
||||
public static class GConstant { public const string K_Event_API_URL="EventApiURL",K_AssetVersion="AssetVersion",Yidun_GooglePlayChannel="stub",Yidun_AndroidBusinessId="stub",K_PlayFab_Title="PFTitle",V_Debug_PF_Title="stub",K_PlayFab_TimeOut="PFTimeout"; }
|
||||
namespace LitJson { public static class JsonMapper { public static string ToJson(object o)=>JsonConvert.SerializeObject(o);public static T ToObject<T>(string s)=>JsonConvert.DeserializeObject<T>(s); } }
|
||||
namespace GameCore { }
|
||||
// The real repository only needs the event marker; production model behavior is verified in Unity separately.
|
||||
namespace Activity.Data { public interface IEventData { } }
|
||||
namespace YiDun.Protector { public class YiDunManager { } }
|
||||
namespace PlayFab
|
||||
{
|
||||
@@ -81,6 +142,10 @@ namespace asap.playfab.async
|
||||
{
|
||||
internal static LoginResult Result;
|
||||
internal static Func<Task<LoginResult>> LoginOverride;
|
||||
internal static readonly List<UpdatePlayerStatisticsRequest> StatisticsRequests=new();
|
||||
internal static Func<Task<UpdatePlayerStatisticsResult>> StatisticsOverride;
|
||||
public static Task<UpdatePlayerStatisticsResult> UpdatePlayerStatisticsAsync(UpdatePlayerStatisticsRequest r){Effects.SDK++;StatisticsRequests.Add(r);return StatisticsOverride?.Invoke() ?? Task.FromResult(new UpdatePlayerStatisticsResult());}
|
||||
public static Task<GetLeaderboardResult> GetLeaderboardAsync(GetLeaderboardRequest r){Effects.SDK++;throw new NotSupportedException("Unmigrated leaderboard read.");}
|
||||
public static Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> call)=>call();
|
||||
public static Task<LoginResult> LoginWithCustomIDAsync(LoginWithCustomIDRequest r){Effects.SDK++;PlayFab.PlayFabClientAPI.Logged=Result!=null;return LoginOverride?.Invoke() ?? Task.FromResult(Result);}
|
||||
public static Task<LoginResult> LoginWithAndroidDeviceIDAsync(LoginWithAndroidDeviceIDRequest r)=>LoginWithCustomIDAsync(new());
|
||||
@@ -88,6 +153,7 @@ namespace asap.playfab.async
|
||||
public static Task<UpdateUserTitleDisplayNameResult> UpdateUserTitleDisplayNameAsync(UpdateUserTitleDisplayNameRequest r){Effects.SDK++;return Task.FromResult(new UpdateUserTitleDisplayNameResult{DisplayName=r.DisplayName});}
|
||||
public static Task<EmptyResponse> UpdateAvatarUrlAsync(UpdateAvatarUrlRequest r){Effects.SDK++;return Task.FromResult(new EmptyResponse());}
|
||||
public static Task<GetUserDataResult> GetUserDataAsync(GetUserDataRequest r){Effects.SDK++;return Task.FromResult(new GetUserDataResult{Data=new()});}
|
||||
public static Task<GetFriendsListResult> GetFriendsListAsync(GetFriendsListRequest r){Effects.SDK++;throw new InvalidOperationException("Unmigrated PF friend-list SDK must not be exercised by the UserID compatibility test.");}
|
||||
}
|
||||
public static class PlayFabProfilesAsyncAPI
|
||||
{ public static Task<PlayFab.ProfilesModels.SetProfileLanguageResponse> SetProfileLanguageAsync(PlayFab.ProfilesModels.SetProfileLanguageRequest r){Effects.SDK++;return Task.FromResult(new PlayFab.ProfilesModels.SetProfileLanguageResponse());} }
|
||||
@@ -117,7 +183,7 @@ public static class GEvent
|
||||
public sealed class Event:IDisposable { public Event AddContent(string k,object v)=>this;public void Dispose(){} }
|
||||
public static Event GameEvent(string name){Effects.Events++;return new();}
|
||||
public static Event LoginEvent()=>GameEvent("login");public static Event CompleteRegistrationEvent()=>GameEvent("registration");
|
||||
public static void SetProp(string k,object v){Effects.Events++;}public static void SetUserProp(string k,object v,bool b){Effects.Events++;}
|
||||
public static void SetProp(string k,object v){Effects.Events++;}public static void SetUserProp(string k,object v,bool b=true){Effects.Events++;}
|
||||
public static void SetProps(Dictionary<string,object> p){Effects.Events++;}public static void SetUserProps(Dictionary<string,object> p){Effects.Events++;}
|
||||
public static void OnLogin(string a,string b){Effects.Events++;}
|
||||
}
|
||||
@@ -125,15 +191,65 @@ namespace game
|
||||
{
|
||||
public interface IChatService { void WaitingForLogin(); }
|
||||
public interface ISettingService { string Lang {get;} }
|
||||
// CP3A's real Setting/Club/Notification instances are exercised by the Unity CLI Loop fixed case.
|
||||
public interface ILocalNotificationService { }
|
||||
public sealed class DefaultSettingService : ISettingService
|
||||
{
|
||||
public string Lang => "en";
|
||||
public static InvalidOperationException PromptFailure(string message) => new(message);
|
||||
PlayerDataStorage.PlayerKVGameSessionHandle bound;
|
||||
public void PreflightFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) { if(bound!=null || owner==null) throw new InvalidOperationException(); }
|
||||
public void BindFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) { PreflightFormalPromptOwner(owner); bound=owner; }
|
||||
public bool HasFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) => ReferenceEquals(bound,owner);
|
||||
}
|
||||
public sealed class ClubService
|
||||
{
|
||||
PlayerDataStorage.PlayerKVGameSessionHandle bound;
|
||||
public void PreflightFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) { if(bound!=null || owner==null) throw new InvalidOperationException(); }
|
||||
public void BindFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) { PreflightFormalPromptOwner(owner); bound=owner; }
|
||||
public bool HasFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) => ReferenceEquals(bound,owner);
|
||||
}
|
||||
public sealed class LocalNotificationService : ILocalNotificationService
|
||||
{
|
||||
DefaultSettingService bound;
|
||||
public void PreflightFormalPromptSettings(DefaultSettingService settings) { if(bound!=null || settings==null) throw new InvalidOperationException(); }
|
||||
public void BindFormalPromptSettings(DefaultSettingService settings) { PreflightFormalPromptSettings(settings); bound=settings; }
|
||||
public bool HasFormalPromptSettings(DefaultSettingService settings) => ReferenceEquals(bound,settings);
|
||||
}
|
||||
public class MMTService { public string Init(int value){Effects.Events++;return value.ToString();} }
|
||||
public class PlayerData { public int lv=1;public int Loads;public void LoadPlayerData(IDictionary<string,string> data){if(!ZZTimeHelper.Adjusted)throw new Exception("PF time not adjusted");Loads++;} }
|
||||
public class PlayerData { public string InfiniteBuildingLevel; public Action Save; public void SavePlayerData() => Save?.Invoke(); public int lv=1;public int Loads;public void LoadPlayerData(IDictionary<string,string> data){if(!ZZTimeHelper.Adjusted)throw new Exception("PF time not adjusted");Loads++;} }
|
||||
public class ChangeAvatarEvent{}
|
||||
public class GetClubPlayInfoEvent { public string id,name,avatarUrl; }
|
||||
public class ItemData {public int id,count;public ulong curCount;}
|
||||
public class ItemData {public int id,count,origin;public ulong curCount;}
|
||||
public class FishingEventData { public Dictionary<int,int> RankTargetCount=>throw new NotSupportedException("Activity reward is not under test."); }
|
||||
public class FishingStage { public bool IsNextQueue; public string NextQueueKey; }
|
||||
public class ChangeSource { public ChangeSource(int id,string a,string b,string c)=>throw new NotSupportedException("Activity reward is not under test."); }
|
||||
public interface IFaceUIService { void AddGiftFaceUI(int id, UITypes type); }
|
||||
public enum UITypes { EventRankPopupPanel }
|
||||
public enum RewardType {NormalOne}
|
||||
public class ShowData {public ShowData(){}public ShowData(List<ItemData> items,RewardType t){Effects.UI++;}}
|
||||
public class PlayerItemData {public int GetItemCount(int id)=>0;public void AddItem(ItemData i,object o){Effects.Rewards++;}}
|
||||
public class PlayerItemData { public Action<int, bool> Drop;
|
||||
public void AddItemByDrop(int id, object change) => Drop?.Invoke(id, false);
|
||||
public List<ItemData> AddItemByDrop(int id, object change, bool value) => throw new NotSupportedException("Activity reward is not under test.");
|
||||
public void AddItemByDropList(List<int> ids, object change) { foreach (var id in ids) Drop?.Invoke(id, false); }
|
||||
public void AddItemByDropListLureInflation(float? inflation, List<int> ids, object change) { foreach (var id in ids) Drop?.Invoke(id, true); }
|
||||
public int GetItemCount(int id)=>0;public void AddItem(ItemData i,object o){Effects.Rewards++;}}
|
||||
public class TapjoyHelper:UnityEngine.MonoBehaviour {public void Init(int balance,Action<string,int> earn,Action<string,int> changed){Effects.Helpers++;}}
|
||||
}
|
||||
namespace TapjoyUnity.Internal { public class TapjoyComponent:UnityEngine.MonoBehaviour{} }
|
||||
|
||||
|
||||
namespace UnityEngine.Assertions { public static class Assert { public static void IsNotNull(object value) { if(value==null) throw new Exception("Unexpected null in real mail cache."); } } }
|
||||
public sealed class RedPointManager { public static RedPointManager Instance { get; } = new(); public void SetRedPointState(string key, bool active, int count = 0) { } }
|
||||
public static class RedPointName { public const string Unread_Mail = "mail", Home_Invite="invite", Home_Friend_Add="friend-add"; }
|
||||
public static class LocalizationMgr { public static string GetText(string key) => key; }
|
||||
public sealed class OnLvChangeEvent { public int lvl; }
|
||||
public interface IRTService { IDisposable OnEvent<T>(Func<T, Task> action); }
|
||||
namespace UniRx
|
||||
{
|
||||
public static class MailProbeRxExtensions
|
||||
{
|
||||
public static IObservable<T> Skip<T>(this IObservable<T> source, int count) => source;
|
||||
public static IObservable<T> Where<T>(this IObservable<T> source, Func<T,bool> predicate) => source;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user