Files
ft/CodexOutput/upserver/B3BatchMigration/M2/C3b修改前快照/M2MergedGameplayTests.cs.txt
Liubing\LB 6813da149a feat(upserver): 保存M2客户端KV迁移至本人统计写检查点
保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。
保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。

验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。
包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
2026-09-30 19:19:05 +08:00

163 lines
10 KiB
Plaintext

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 MergedLogin();
MergedDailyRefresh();
}
sealed class KVChat : IChatService
{
internal int Waits;
public void WaitingForLogin() { Waits++; }
}
static async Task MergedLogin()
{
foreach (var scenario in new[] { "success", "save-unknown", "logout-during-save", "corrupt", "logout-during-load" })
{
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 =>
{
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 = "101", 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 == "101" && 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 == "101" && user.ApiPlayerId == "101" && 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#101" && 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
{
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");
}
}
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");
}
}