Files
ft/CodexOutput/upserver/KVGameLoadTests/M2MergedContractTests.cs
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

258 lines
18 KiB
C#

#if SERVICE_HOST
using game.PlayerDataStorage;
static partial class Probe
{
public sealed class M2EventFixture : Activity.Data.IEventData { public int Count { get; set; } }
sealed class DailyTicks : TimeManager.ITimeTickService
{
public IObservable<long> SecondTick { get; } = new UniRx.Stream<long>();
public IObservable<long> MinuteTick { get; } = new UniRx.Stream<long>();
public void Start() { }
public void Stop() { }
}
static void MergedDailyRefresh()
{
GContext.container.Values[typeof(cfg.Tables)] = new cfg.Tables();
var user = new game.KVUserService();
int refreshes = 0;
using var events = GContext.OnEvent<TimeManager.Event.ActivityCrossDayRefreshEvent>().Subscribe(_ => refreshes++);
using (var manager = new TimeManager.TimeRefreshManager(new DailyTicks(), user))
Check(manager.GetLastLoginTime() == null && manager.InitialRefreshSucceeded && refreshes == 1,
"actual daily manager consumes missing-login sentinel and records successful initial refresh");
using (var failure = GContext.OnEvent<TimeManager.Event.ActivityCrossDayRefreshEvent>().Subscribe(_ => throw new InvalidOperationException("synthetic refresh failure")))
using (var manager = new TimeManager.TimeRefreshManager(new DailyTicks(), user))
Check(!manager.InitialRefreshSucceeded && user.LastLoginTime == DateTime.MinValue,
"caught daily subscriber failure is visible to the timestamp commit gate");
typeof(game.KVUserService).GetProperty(nameof(user.LastLoginTime)).SetValue(user, ZZTimeHelper.UtcNow().UtcNowOffset());
var before = refreshes;
using (var manager = new TimeManager.TimeRefreshManager(new DailyTicks(), user))
Check(manager.InitialRefreshSucceeded && refreshes == before, "same configured game day needs no initial refresh");
user.Logout();
Pass("merged-real-daily-refresh-success-failure-and-login-sentinel");
}
static async Task MergedSaveContract(game.KVUserService user, Dictionary<string, string> remote, bool unknown, Action<Action> onNextSet)
{
var store = user.SaveStore;
var original = (PlayerKVGameSessionHandle)typeof(game.KVUserService).GetField("handle",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).GetValue(user);
try { ((KVPlayerSaveStore)store).Bind(original, user.LoginLifetime); throw new Exception("store rebound"); }
catch (InvalidOperationException) { Check(true, "bound store cannot be rebound even to the same handle"); }
Check(PlayerKVGameLoadGuard.TryEnter(out var guard), "save contract guard lease acquired");
using (guard)
{
Check(!store.Read("Language").Success && !store.Write("M2Guarded", "x").Accepted && guard.Violations == 2,
"common store preserves original read/write guard enforcement");
}
var absent = store.Read("M2Missing");
Check(absent.Success && absent.Found == false && absent.Value == null, "successful missing key is explicit");
var invalid = store.Read(null);
Check(!invalid.Success && invalid.Found == null, "invalid read is never a missing key");
try { invalid.ValueOrThrow(); throw new Exception("failed read swallowed"); }
catch (InvalidOperationException) { Check(true, "failed read cannot enter a default-value branch"); }
var local = store.Write("M2LocalOnly", "", false);
Check(local.Accepted && store.Read("M2LocalOnly").Found == true && store.Read("M2LocalOnly").Value == "",
"local-only empty string remains present: " + local.Error + "/" + original.Status);
Check((await store.ConfirmAsync(local.Receipt)).Status == PlayerSaveStatus.LocalOnly && !remote.ContainsKey("M2LocalOnly"),
"local-only acceptance never becomes remote confirmation");
if (unknown)
{
Check((await store.WriteAndConfirmAsync("M2UnknownProbe", "value")).Status == PlayerSaveStatus.Unknown, "lost response retains original unknown state");
var before = user.Language;
try { await user.UpdateLanguage("de"); throw new Exception("unknown profile published"); }
catch (InvalidOperationException) { Check(user.Language == before, "unknown profile save does not update public language"); }
Check((await store.FlushAsync()).Status == PlayerSaveStatus.Unknown, "unknown outcome remains explicit");
Check(!store.Write("M2BlockedAfterUnknown", "x").Accepted, "unknown outcome blocks further writes");
var read = store.Read("M2Missing");
Check(!read.Success && read.Found == null, "unreadable owner cannot impersonate an empty account");
}
else
{
await MergedActivityTokens(user, remote);
await MergedOriginalSurveyAndMail(user, remote);
var repository = new Activity.Data.EventDataRepository(store);
Check(!repository.Exists<M2EventFixture>() && repository.Load<M2EventFixture>().Count == 0,
"real activity repository preserves original missing-key default");
foreach (var malformed in new[] { "", "null", "{broken" })
{
store.Write(nameof(M2EventFixture), malformed, false).RequireAccepted();
Check(repository.Load<M2EventFixture>().Count == 0, "original activity fallback for empty/null/malformed JSON is preserved");
}
repository.Save(new M2EventFixture { Count = 19 });
Check(repository.Load<M2EventFixture>().Count == 19, "real activity repository preserves generic key and JSON");
var first = store.Write("M2Replace", "first");
var last = store.Write("M2Replace", "last");
Check(first.Accepted && last.Accepted && !remote.ContainsKey("M2Replace"), "buffered writes are not sent immediately");
Check((await store.ConfirmAsync(first.Receipt)).Status == PlayerSaveStatus.Superseded,
"overwritten standalone receipt is not confirmed");
Check((await store.ConfirmAsync(last.Receipt)).Confirmed && remote["M2Replace"] == "last",
"final original receipt confirms its remote value");
Check(!remote.ContainsKey("M2LocalOnly"), "flush never sends local-only keys");
Check(Newtonsoft.Json.JsonConvert.DeserializeObject<M2EventFixture>(remote[nameof(M2EventFixture)]).Count == 19,
"real repository data reaches the original KV transport");
var other = new KVPlayerSaveStore();
Check((await store.ConfirmAsync(new PlayerSaveReceipt(other, null))).Status == PlayerSaveStatus.Rejected,
"a receipt from another store cannot cross accounts");
await user.UpdateLanguage("de");
Check(user.Language == "de" && remote["Language"] == "de", "real profile consumer publishes only after confirmation");
for (int i = 0; i < 11; i++) store.Write("M2Watermark" + i, "initial").RequireAccepted();
onNextSet(() => store.Write("M2Watermark10", "later").RequireAccepted());
Check((await store.FlushAsync()).Status == PlayerSaveStatus.Superseded && !remote.ContainsKey("M2Watermark10"),
"aggregate flush cannot confirm an unsent receipt replaced beyond its captured watermark");
Check((await store.FlushAsync()).Confirmed && remote["M2Watermark10"] == "later",
"later replacement requires its own flush and confirmation");
using var cancelled = new CancellationTokenSource(); cancelled.Cancel();
Check((await store.WriteAndConfirmAsync("M2Cancelled", "x", cancelled.Token)).Status == PlayerSaveStatus.Cancelled &&
!store.Read("M2Cancelled").Found.Value, "pre-cancelled consumption does not enqueue a debit");
}
Pass(unknown ? "merged-save-unknown-is-not-missing-or-profile-success" : "merged-save-local-buffered-superseded-confirmed-and-foreign-receipt");
}
sealed class PFSurveyUser : game.PlayFabUserService { public PFSurveyUser() { UserId = "101"; } }
public class MailServerProbe : System.Reflection.DispatchProxy
{
public Func<string,string,Task<string>> Post;
protected override object Invoke(System.Reflection.MethodInfo method, object[] args)
{
if (method.Name != "CustomServerPost") throw new Exception("Unexpected mail transport method: " + method.Name);
return Post((string)args[0], (string)args[1]);
}
}
static async Task MergedOriginalSurveyAndMail(game.KVUserService user, Dictionary<string,string> remote)
{
var store = user.SaveStore;
GContext.container.Values[typeof(IPlayerSaveStore)] = store;
GContext.container.Values[typeof(game.PlayerData)] = new game.PlayerData { lv = 10 };
var drops = new List<(int id, bool inflated)>();
GContext.container.Values[typeof(game.PlayerItemData)] = new game.PlayerItemData { Drop = (id, inflated) =>
{
drops.Add((id, inflated));
store.Write("M2OriginalDrop" + id, drops.Count.ToString()).RequireAccepted();
GContext.Publish(new game.ShowData());
}};
var footprint = new FootPrintService { SaveStore = store };
footprint.LoadData((1UL << 40).ToString());
footprint.UpdateFootPrint(EFootPrint.IsFaceBookRewardLink);
Check(store.Read("FootPrint").ValueOrThrow() == ((1UL << 40) | (uint)EFootPrint.IsFaceBookRewardLink).ToString(),
"KV footprint retains the loaded full-width archive");
var statue = new EventPartnerStatuePlayfabData { EventId=42, TicketCount=7, DisplayScoreMap=new(), RobotScoreMap=new() };
statue.Save();
var reloaded = EventPartnerStatuePlayfabData.Load();
Check(reloaded.EventId==42 && reloaded.TicketCount==7, "activity archive retains original wire format");
var tables = new cfg.Tables();
var definition = new cfg.SurveyData { ID=11, Hash="test", Reward=501, TailNumberList=new List<int>{1} };
tables.TbSurveyData.DataList.Add(definition);
tables.TbSurveyData.DataList.Add(new cfg.SurveyData { ID=22, Reward=502, TailNumberList=new List<int>{7} });
int presentations=0;
using var subscription=GContext.OnEvent<game.ShowData>().Subscribe(_ => presentations++);
var survey=new SurveyService(user,tables,store);
survey.GetSurveyMatchlist();
Check(survey.tableData==definition,"new KV identity uses decimal tail; survey selection otherwise follows original logic");
Check(survey.GetSurveyAnswer()=="https://wesurvey.com/s2/11/test/?&uid=101","original survey URL and CustomId retained");
survey.GetSurveyCheckAnswer();
Check(drops.Count==1 && presentations==2 && store.Read("SurveyData").ValueOrThrow()=="[\"11\"]",
"original synchronous award, presentation and claim-marker order retained");
Check(!remote.ContainsKey("SurveyData") && !remote.ContainsKey("M2OriginalDrop501"),
"ordinary survey flow does not wait for or force a remote save");
survey.GetSurveyCheckAnswer();
Check(drops.Count==1,"original claimed-survey check retained without a new action lock");
Check((await store.FlushAsync()).Confirmed && remote["SurveyData"]=="[\"11\"]",
"existing storage flush persists original ordinary writes");
var cache=new MailCache(tables.TbAutoDeliveryMail);cache.Load(user.UserId);
var first=new GameMail { Id="original-single", Type=EMailType.Personal, DropIds=new[]{601}, ExpireTime=ZZTimeHelper.UtcNow().AddDays(1) };
var second=new GameMail { Id="original-batch", Type=EMailType.Global, DropIds=new[]{602}, ExpireTime=first.ExpireTime };
cache.Cache(new[]{first,second});
var server=System.Reflection.DispatchProxy.Create<ICustomServerMgr,MailServerProbe>();
var probe=(MailServerProbe)(object)server;
Newtonsoft.Json.Linq.JObject sent=null;
bool succeeds=true;
probe.Post=(route,json) => {
Check(route==MailUrls.UpdateState,"mail retains existing route");
sent=Newtonsoft.Json.Linq.JObject.Parse(json);
return Task.FromResult(succeeds ? "{\"success\":true}" : "{\"success\":false}");
};
var handler=new BaseMailHandler(cache,server);
Check(await handler.TakeReward(first) && first.HasReward && drops.Contains((601,true)),
"original single-mail state and personal inflation retained");
Check(((int)sent["mailStates"][0]["state"] & (int)EMailState.RewardTaken)!=0,
"single-mail original RewardTaken request retained");
Check(!remote.ContainsKey("M2OriginalDrop601"),"single-mail returns without adding a KV cloud wait");
var before=drops.Count;
Check(await handler.TakeReward(new[]{second}) && second.IsRead && !second.HasReward,
"batch retains original HasRead state; legacy business repair is deferred");
Check(((int)sent["mailStates"][0]["state"] & (int)EMailState.HasRead)!=0 && drops.Count==before,
"batch keeps original post-response HasReward filter rather than inventing new awards");
succeeds=false;
var failed=new GameMail { Id="original-failed", DropIds=new[]{603}, ExpireTime=first.ExpireTime };cache.Cache(failed);
Check(!await handler.TakeReward(failed) && !failed.HasReward && drops.Count==before,
"original failed state request returns false without issuing items");
cache.Unload();
Pass("merged-original-survey-and-mail-flow-with-KV-storage-substitution");
}
static async Task MergedActivityTokens(game.KVUserService user, Dictionary<string, string> remote)
{
var store = user.SaveStore;
foreach (var malformed in new[] { "", "null", "{bad", "[]" })
{
store.Write("TransitionData", malformed, false).RequireAccepted();
var bad = new Activity.Services.EventTokenService(store);
Check(bad.GetTokenCount(1) == 0, "original token loader logs malformed JSON and retains its initial balance");
}
store.Write("TransitionData", "{\"1\":10,\"2\":7}", false).RequireAccepted();
var tokens = new Activity.Services.EventTokenService(store);
int notifications = 0;
using var subscription = GContext.OnEvent<Activity.ActivityEventTokenBalanceChanged>().Subscribe(_ => notifications++);
Check(tokens.GetTokenCount(1) == 10 && tokens.GetTokenCount(3) == 0, "real token service preserves existing balances");
Check(PlayerKVGameLoadGuard.TryEnter(out var guard), "token mutation rejection guard acquired");
using (guard)
{
try { tokens.AddToken(1, 5); throw new Exception("guarded token mutation accepted"); }
catch (InvalidOperationException) { Check(tokens.GetTokenCount(1) == 15 && notifications == 0, "original token mutation precedes save and notification follows accepted save"); }
}
tokens.AddToken(1, 5);
Check(tokens.GetTokenCount(1) == 20 && notifications == 1, "accepted token credit publishes its original notification");
Check(tokens.RemoveToken(1, 25) == 20 && tokens.GetTokenCount(1) == 0 && notifications == 2, "token debit preserves original clamp and removes empty entry");
tokens.SetTokens(new Dictionary<int, int> { [3] = 8, [4] = 9, [-1] = 10 });
tokens.ClearTokens(new[] { 3, 4 });
Check(tokens.GetAllTokens().Count == 0, "batch clear changes the complete candidate, not a per-key copy");
tokens.SetToken(5, 17);
tokens.ClearToken(999);
Check((await store.FlushAsync()).Confirmed, "token data confirms through original KV store");
var saved = Newtonsoft.Json.JsonConvert.DeserializeObject<Dictionary<int, int>>(remote["TransitionData"]);
Check(saved.Count == 1 && saved[5] == 17, "token wire JSON retains original key and dictionary format");
Pass("merged-real-activity-tokens-malformed-rejected-credit-debit-and-batch-save");
}
static async Task MergedPFStore(PlayFabMgr manager)
{
using var lifetime = new CancellationTokenSource();
var store = new PlayFabPlayerSaveStore(manager, lifetime.Token);
var pfTables = new cfg.Tables();
var pfDefinition = new cfg.SurveyData { ID=22, TailNumberList=new List<int>{7} };
pfTables.TbSurveyData.DataList.Add(pfDefinition);
GContext.container.Values[typeof(game.PlayerData)] = new game.PlayerData { lv=10 };
var pfSurvey = new SurveyService(new PFSurveyUser(), pfTables, store);
pfSurvey.GetSurveyMatchlist();
Check(pfSurvey.tableData == pfDefinition, "actual PF survey preserves hexadecimal identity tail");
var tokens = new Activity.Services.EventTokenService(store);
Check(tokens.GetTokenCount(91) == 0, "missing PF token key remains a valid empty balance");
tokens.AddToken(91, 4);
Check(Newtonsoft.Json.JsonConvert.DeserializeObject<Dictionary<int, int>>(manager.GetLocalData("TransitionData"))[91] == 4,
"same real token consumer preserves the PF storage route");
var write = store.Write("M2PF", "unchanged");
Check(write.Accepted && store.Read("M2PF").Value == "unchanged", "PF adapter uses original local buffer");
Check((await store.ConfirmAsync(write.Receipt)).Status == PlayerSaveStatus.Buffered, "PF buffered receipt is not a cloud confirmation");
Check((await store.WriteAndConfirmAsync("M2PFImmediate", "value")).Confirmed, "PF immediate operation keeps original acknowledgement");
lifetime.Cancel();
Check(tokens.GetTokenCount(91) == 4, "original initialized token service can read its own cached balance after storage closes");
Check(!store.IsCurrent && !store.Write("M2PF", "stale").Accepted && !store.Read("M2PF").Success,
"captured PF lifetime does not follow replacement login");
Pass("merged-PF-original-provider-and-frozen-lifetime");
}
}
#endif