From 0eb43b2b40ad9a5bfaf8617c80829451166b3714 Mon Sep 17 00:00:00 2001 From: "Liubing\\LB" Date: Mon, 28 Sep 2026 18:14:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(upserver):=20=E6=8E=A5=E5=85=A5=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E4=B8=8EKV=E5=AD=98=E6=A1=A3=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E9=99=90=E5=AE=9A=E9=92=93=E9=B1=BC=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client/Assets/Editor/DebugConfig.cs | 8 +- Client/Assets/Editor/KVGameLoadValidation.cs | 23 + .../Editor/KVGameLoadValidation.cs.meta | 2 + Client/Assets/Editor/KVGameplayLoopSetup.cs | 85 + .../Assets/Editor/KVGameplayLoopSetup.cs.meta | 2 + Client/Assets/Editor/KVSaveRouteValidation.cs | 19 + .../Editor/KVSaveRouteValidation.cs.meta | 2 + Client/Assets/Editor/S101LoginWindow.cs | 74 + Client/Assets/Editor/S101LoginWindow.cs.meta | 11 + Client/Assets/Scripts/AOTScripts/GConstant.cs | 2 +- .../Activity/Sentry/ActivitySentryManager.cs | 13 +- Client/Assets/Scripts/Camp/CampDataMM.cs | 18 + Client/Assets/Scripts/Core/CustomServerMgr.cs | 162 +- Client/Assets/Scripts/Core/PlayFabMgr.cs | 107 ++ Client/Assets/Scripts/Core/SoundMgr.cs | 92 +- .../DataCenter/AchievementDataManager.cs | 25 + .../Scripts/DataCenter/BargainPackData.cs | 65 +- .../Scripts/DataCenter/BattlePassData.cs | 72 +- .../Scripts/DataCenter/BuffDataCenter.cs | 35 +- .../DataCenter/DataCenter.FishingEventData.cs | 40 + .../DataCenter/DataCenter.GuideData.cs | 44 + .../DataCenter/DataCenter.PlayerData.cs | 91 ++ .../DataCenter/DataCenter.PlayerFishData.cs | 48 + .../DataCenter/DataCenter.PlayerShopData.cs | 38 + .../DataCenter/DataCenter.SignupData.cs | 13 + .../DataCenter/DataCenter.SmallGame.cs | 20 + .../DataCenter/DataCenter.TriggerPackData.cs | 23 + .../Scripts/DataCenter/EventPackData.cs | 13 + .../DataCenter/EventPartnerGatherManager.cs | 45 + .../DataCenter/EventPinballDataManager.cs | 34 +- .../DataCenter/EventPotionDataManager.cs | 49 + .../EventScratchTicketDataManager.cs | 39 + .../Scripts/DataCenter/FishingBoxData.cs | 14 + .../DataCenter/FishingChallengeManager.cs | 36 +- .../DataCenter/OfferChainsChestManager.cs | 24 +- .../DataCenter/OfferDiscoPartyManager.cs | 24 +- .../Scripts/DataCenter/PiggyBankPackData.cs | 41 +- .../Data/EventCubeMeltManager.cs | 30 +- .../EventPartnerGather/PartnerInfoManager.cs | 18 +- .../GampPlay/FishingB/FishingBehaviorB.cs | 147 +- .../GampPlay/FishingB/FishingUpdateSys.cs | 77 + Client/Assets/Scripts/Others/ZZTimeHelper.cs | 2 +- .../Scripts/SandDig/DiggingGameManager.cs | 22 +- Client/Assets/Scripts/Services/Account.meta | 8 + .../Services/Account/S101AccountSession.cs | 224 +++ .../Account/S101AccountSession.cs.meta | 11 + .../Assets/Scripts/Services/AggUserService.cs | 62 +- Client/Assets/Scripts/Services/ClubService.cs | 312 ++-- .../Scripts/Services/FootPrintService.cs | 18 + .../Assets/Scripts/Services/PlayerData.meta | 8 + .../Services/PlayerData/PlayerKVCacheStore.cs | 105 ++ .../PlayerData/PlayerKVCacheStore.cs.meta | 11 + .../Services/PlayerData/PlayerKVClient.cs | 144 ++ .../PlayerData/PlayerKVClient.cs.meta | 11 + .../PlayerKVControlledLoadCapability.cs | 105 ++ .../PlayerKVControlledLoadCapability.cs.meta | 2 + .../PlayerData/PlayerKVDecodedSave.cs | 969 +++++++++++ .../PlayerData/PlayerKVDecodedSave.cs.meta | 2 + .../PlayerKVEditorValidationHost.cs | 1450 +++++++++++++++++ .../PlayerKVEditorValidationHost.cs.meta | 2 + .../PlayerData/PlayerKVGameLoadCoordinator.cs | 202 +++ .../PlayerKVGameLoadCoordinator.cs.meta | 2 + .../PlayerData/PlayerKVGameLoadGuard.cs | 43 + .../PlayerData/PlayerKVGameLoadGuard.cs.meta | 2 + .../PlayerData/PlayerKVGameLoadTransaction.cs | 100 ++ .../PlayerKVGameLoadTransaction.cs.meta | 2 + .../PlayerData/PlayerKVGameSessionBridge.cs | 276 ++++ .../PlayerKVGameSessionBridge.cs.meta | 2 + .../PlayerData/PlayerKVGameplayBindings.cs | 60 + .../PlayerKVGameplayBindings.cs.meta | 2 + .../PlayerKVInitializationIntents.cs | 30 + .../PlayerKVInitializationIntents.cs.meta | 2 + .../PlayerData/PlayerKVKeyReadResult.cs | 35 + .../PlayerData/PlayerKVKeyReadResult.cs.meta | 2 + .../PlayerData/PlayerKVLocalReadResult.cs | 30 + .../PlayerKVLocalReadResult.cs.meta | 2 + .../PlayerData/PlayerKVLoginContext.cs | 472 ++++++ .../PlayerData/PlayerKVLoginContext.cs.meta | 2 + .../PlayerData/PlayerKVLoginPrepareResult.cs | 57 + .../PlayerKVLoginPrepareResult.cs.meta | 2 + .../Services/PlayerData/PlayerKVModels.cs | 31 + .../PlayerData/PlayerKVModels.cs.meta | 11 + .../PlayerKVSaveRouteEditorValidationHost.cs | 126 ++ ...yerKVSaveRouteEditorValidationHost.cs.meta | 2 + .../Services/PlayerData/PlayerKVSaveSchema.cs | 1168 +++++++++++++ .../PlayerData/PlayerKVSaveSchema.cs.meta | 2 + .../PlayerData/PlayerKVSessionOwner.cs | 341 ++++ .../PlayerData/PlayerKVSessionOwner.cs.meta | 11 + .../PlayerData/PlayerKVShutdownRegistry.cs | 92 ++ .../PlayerKVShutdownRegistry.cs.meta | 2 + .../PlayerData/PlayerKVSyncCoordinator.cs | 339 ++++ .../PlayerKVSyncCoordinator.cs.meta | 11 + .../Services/PlayerData/PlayerKVSyncModels.cs | 249 +++ .../PlayerData/PlayerKVSyncModels.cs.meta | 11 + .../Services/PlayerData/PlayerKVSyncState.cs | 203 +++ .../PlayerData/PlayerKVSyncState.cs.meta | 11 + Client/Assets/Scripts/Services/RTService.cs | 245 ++- .../Services/UserService.KVFishingReward.cs | 291 ++++ .../UserService.KVFishingReward.cs.meta | 2 + .../UserService.KVFishingRewardValidation.cs | 557 +++++++ ...rService.KVFishingRewardValidation.cs.meta | 11 + .../UserService.KVFishingValidation.cs | 330 ++++ .../UserService.KVFishingValidation.cs.meta | 11 + .../Services/UserService.KVGameplayLoop.cs | 621 +++++++ .../UserService.KVGameplayLoop.cs.meta | 2 + .../Scripts/Services/UserService.KVLoad.cs | 110 ++ .../Services/UserService.KVLoad.cs.meta | 2 + Client/Assets/Scripts/Services/UserService.cs | 217 ++- Client/Assets/Scripts/Sound/SoundService.cs | 92 +- Client/Assets/Scripts/Stages/AggLoginAct.cs | 66 +- Client/Assets/Scripts/Stages/FishingAct.cs | 38 +- Client/Assets/Scripts/Stages/FishingStage.cs | 39 +- Client/Assets/Scripts/Stages/LoginStage.cs | 7 + Client/Assets/Scripts/Stages/PFLoginAct.cs | 81 +- Client/Assets/Scripts/Stages/QuitAct.cs | 37 +- Client/Assets/Scripts/SubBoostrap.cs | 3 + .../UI/FishingReward/FishingRewardPanel.cs | 151 +- .../Scripts/UI/Home/HomeBtnBattlepass.cs | 1 + Client/Assets/Scripts/UI/Home/HomePanel.cs | 7 +- .../PinataPack/EventPinataPackDataManager.cs | 28 +- .../Scripts/UI/Rod/FishingRodPanel_Level.cs | 16 + .../UI/ShootingRange/ShootingRangeData.cs | 37 +- .../Scripts/UI/Shop/BlackFridayCouponData.cs | 5 + .../UI/Shop/ChainPackWithProgressData.cs | 3 + .../Scripts/UI/Shop/ThanksGivingPackData.cs | 13 + .../Scripts/UI/Social/ClubApplyPopupPanel.cs | 2 +- .../UI/Social/ClubChooseHeirPopupPanel.cs | 2 +- .../Scripts/UI/Social/ClubInfoPopupPanel.cs | 6 +- .../Scripts/UI/Social/SocialClubJoined.cs | 24 +- 129 files changed, 11612 insertions(+), 573 deletions(-) create mode 100644 Client/Assets/Editor/KVGameLoadValidation.cs create mode 100644 Client/Assets/Editor/KVGameLoadValidation.cs.meta create mode 100644 Client/Assets/Editor/KVGameplayLoopSetup.cs create mode 100644 Client/Assets/Editor/KVGameplayLoopSetup.cs.meta create mode 100644 Client/Assets/Editor/KVSaveRouteValidation.cs create mode 100644 Client/Assets/Editor/KVSaveRouteValidation.cs.meta create mode 100644 Client/Assets/Editor/S101LoginWindow.cs create mode 100644 Client/Assets/Editor/S101LoginWindow.cs.meta create mode 100644 Client/Assets/Scripts/Services/Account.meta create mode 100644 Client/Assets/Scripts/Services/Account/S101AccountSession.cs create mode 100644 Client/Assets/Scripts/Services/Account/S101AccountSession.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVControlledLoadCapability.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVControlledLoadCapability.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVDecodedSave.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVDecodedSave.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadGuard.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadGuard.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadTransaction.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadTransaction.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVGameSessionBridge.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVGameSessionBridge.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVGameplayBindings.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVGameplayBindings.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVInitializationIntents.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVInitializationIntents.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVKeyReadResult.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVKeyReadResult.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVLocalReadResult.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVLocalReadResult.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginPrepareResult.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginPrepareResult.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveRouteEditorValidationHost.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveRouteEditorValidationHost.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveSchema.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveSchema.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVSessionOwner.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVSessionOwner.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVShutdownRegistry.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVShutdownRegistry.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs.meta create mode 100644 Client/Assets/Scripts/Services/UserService.KVFishingReward.cs create mode 100644 Client/Assets/Scripts/Services/UserService.KVFishingReward.cs.meta create mode 100644 Client/Assets/Scripts/Services/UserService.KVFishingRewardValidation.cs create mode 100644 Client/Assets/Scripts/Services/UserService.KVFishingRewardValidation.cs.meta create mode 100644 Client/Assets/Scripts/Services/UserService.KVFishingValidation.cs create mode 100644 Client/Assets/Scripts/Services/UserService.KVFishingValidation.cs.meta create mode 100644 Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs create mode 100644 Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs.meta create mode 100644 Client/Assets/Scripts/Services/UserService.KVLoad.cs create mode 100644 Client/Assets/Scripts/Services/UserService.KVLoad.cs.meta diff --git a/Client/Assets/Editor/DebugConfig.cs b/Client/Assets/Editor/DebugConfig.cs index 68fa70849..4f0efc66c 100644 --- a/Client/Assets/Editor/DebugConfig.cs +++ b/Client/Assets/Editor/DebugConfig.cs @@ -22,13 +22,13 @@ public class DebugConfig - private const string s101StaicResUrl = "http://192.168.9.101:8080/{0}"; - private const string s101AddrUrl = "http://192.168.9.101:8080/{0}/{1}"; - private const string s101EventAPIUrl = "http://192.168.9.101:5292/"; + private const string s101StaicResUrl = "http://s101.local:8080/{0}"; + private const string s101AddrUrl = "http://s101.local:8080/{0}/{1}"; + private const string s101EventAPIUrl = "http://s101.local:18080/"; private const string s101RTMPid = "80000560"; private const string s101RTMServerEndpoint = "rtm-nx-front.ilivedata.com:13321"; private const string s101RTMHmacSecret = "7a1ba6bb85a4473d96fa4bf45b01f1dd"; - private const string s101FuncUrl= "http://192.168.9.101:7071"; + private const string s101FuncUrl= "http://s101.local:7071"; private const string s101FuncKey= "testKey"; private const string devStaicResUrl = "https://sls-cloudfunction-ap-beijing-code-1305151099.cos.ap-beijing.myqcloud.com/dev/{0}"; diff --git a/Client/Assets/Editor/KVGameLoadValidation.cs b/Client/Assets/Editor/KVGameLoadValidation.cs new file mode 100644 index 000000000..4a4bc5070 --- /dev/null +++ b/Client/Assets/Editor/KVGameLoadValidation.cs @@ -0,0 +1,23 @@ +#if UNITY_EDITOR +using System; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +public static class KVGameLoadValidation +{ + // Fixed assembly/type/method and explicit case allow-list; no arbitrary reflection arguments. + public static async Task RunAsync(string caseId) + { + if (caseId != "M08-M23" && caseId != "M21-M22-M24" && caseId != "M09-M10-M12" && + caseId != "M14" && caseId != "M07" && caseId != "M20-M21-M22-M24-M28-M29" && caseId != "M16-M17" && + caseId != "M06-M13" && caseId != "M15-M26-M27" && caseId != "M30" && caseId != "M19" && caseId != "M11" && caseId != "M25" && + caseId != "M01-M02-M21-M22-M24" && caseId != "M04-M21-M22-M24" && caseId != "M05-M21-M22-M24" && caseId != "M03" && caseId != "M18" && caseId != "M31" && caseId != "B2-20") throw new ArgumentException("Unknown fixed validation case."); + var assembly = AppDomain.CurrentDomain.GetAssemblies().Single(a => a.GetName().Name == "HotUpdate"); + var type = assembly.GetType("game.PlayerDataStorage.PlayerKVEditorValidationHost", true); + var method = type.GetMethod("RunAsync", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(string) }, null); + if (method == null) throw new InvalidOperationException("Validation entry unavailable."); + return await (Task)method.Invoke(null, new object[] { caseId }); + } +} +#endif diff --git a/Client/Assets/Editor/KVGameLoadValidation.cs.meta b/Client/Assets/Editor/KVGameLoadValidation.cs.meta new file mode 100644 index 000000000..0281eef21 --- /dev/null +++ b/Client/Assets/Editor/KVGameLoadValidation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4ce05f2f22c2492f8a3c51a82a208647 diff --git a/Client/Assets/Editor/KVGameplayLoopSetup.cs b/Client/Assets/Editor/KVGameplayLoopSetup.cs new file mode 100644 index 000000000..a36a5f663 --- /dev/null +++ b/Client/Assets/Editor/KVGameplayLoopSetup.cs @@ -0,0 +1,85 @@ +#if UNITY_EDITOR +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +// Fixed CLI helpers; no arbitrary method names or process-marker deletion. +public static class KVGameplayLoopSetup +{ + static string Root => Path.GetFullPath(Path.Combine(Application.dataPath,"../../CodexOutput/upserver/KVGameplayLoopTests")); + static Type Find(string name) => AppDomain.CurrentDomain.GetAssemblies().Select(a=>a.GetType("game.PlayerDataStorage."+name)).FirstOrDefault(t=>t!=null) + ?? throw new InvalidOperationException("Gameplay assembly not loaded."); + static object ReadProperty(Type type,string name) => type.GetProperty(name,BindingFlags.Static|BindingFlags.NonPublic)?.GetValue(null); + + public static async System.Threading.Tasks.Task RunValidationAsync(string caseId) + { + if(caseId!="equipment-chain" && caseId!="fishing-consume" && caseId!="fishing-reward") throw new ArgumentException("Unknown fixed validation case."); + if(EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating) + throw new InvalidOperationException("Idle editor required."); + var method=Find("PlayerKVGameplayLoop").GetMethod(caseId=="equipment-chain"?"VerifyEquipmentForEditorAsync":caseId=="fishing-consume"?"VerifyFishingForEditorAsync":"VerifyFishingRewardForEditorAsync",BindingFlags.Static|BindingFlags.NonPublic); + if(method==null) throw new InvalidOperationException("Fixed equipment validation entry unavailable."); + return await (System.Threading.Tasks.Task)method.Invoke(null,null); + } + + public static string State() + { + var type=Find("PlayerKVGameplayLoop"); + var history=Path.Combine(Root,"run-control"); + return JsonConvert.SerializeObject(new{project=Application.dataPath,playing=EditorApplication.isPlaying, + enabled=ReadProperty(type,"Enabled"),ready=ReadProperty(type,"Ready"),error=ReadProperty(type,"Error"),run=ReadProperty(type,"RunPath"), + permitExists=File.Exists(Path.Combine(Root,"permit.json")), + processMarkers=Directory.Exists(history)?Directory.GetFiles(history,"*.claimed").Select(Path.GetFileName).OrderBy(x=>x).ToArray():Array.Empty()}); + } + + public static string ValidatePermit(string path) + { + var type=Find("PlayerKVLoopPermit"); + try + { + var permit=type.GetMethod("Read",BindingFlags.Static|BindingFlags.NonPublic).Invoke(null,new object[]{Path.GetFullPath(path)}); + return JsonConvert.SerializeObject(permit); + } + catch(TargetInvocationException e) { throw e.InnerException ?? e; } + } + + public static string InspectPFBackup(string path) + { + if(EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating) + throw new InvalidOperationException("Idle editor required."); + var method=Find("PlayerKVGameplayLoop").GetMethod("InspectPFBackupForEditor",BindingFlags.Static|BindingFlags.NonPublic); + try { return (string)method.Invoke(null,new object[]{path}); } + catch(TargetInvocationException e) { throw e.InnerException ?? e; } + } + + public static string PreparePermit(string account,string backupPath,string[] allowedKeys) + => PreparePermitCore(account,backupPath,allowedKeys,false); + public static string PrepareFishingPermit(string account,string backupPath,string[] allowedKeys) + => PreparePermitCore(account,backupPath,allowedKeys,true); + public static string PrepareFishingRewardPermit(string account,string backupPath,string[] allowedKeys) + => PreparePermitCore(account,backupPath,allowedKeys,true,true); + static string PreparePermitCore(string account,string backupPath,string[] allowedKeys,bool fishing,bool reward=false) + { + if(EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling) + throw new InvalidOperationException("Stop play before preparing a new process run."); + backupPath=Path.GetFullPath(backupPath); + var bytes=File.ReadAllBytes(backupPath); + string hash; + using(var sha=SHA256.Create()) hash=BitConverter.ToString(sha.ComputeHash(bytes)).Replace("-",""); + var candidate=Path.Combine(Root,"permit.candidate.json"); + Directory.CreateDirectory(Root); + var json=JObject.FromObject(new{Version=reward?3:fishing?2:1,Enabled=true,PlayerId=account, + Api="http://s101.local:18080/",BackupPath=backupPath,BackupSha256=hash,AllowedKeys=allowedKeys}); + if(fishing) json["Operation"]=reward?"fishing-reward-once":"fishing-consume-once"; + File.WriteAllText(candidate,json.ToString(Formatting.Indented)); + var validated=ValidatePermit(candidate); // Empty/mismatched backups cannot enable a run. + File.WriteAllText(Path.Combine(Root,"permit.json"),validated); + return State(); + } +} +#endif diff --git a/Client/Assets/Editor/KVGameplayLoopSetup.cs.meta b/Client/Assets/Editor/KVGameplayLoopSetup.cs.meta new file mode 100644 index 000000000..2ef7e0971 --- /dev/null +++ b/Client/Assets/Editor/KVGameplayLoopSetup.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ff2728a480374b609aaed9ea0742215e diff --git a/Client/Assets/Editor/KVSaveRouteValidation.cs b/Client/Assets/Editor/KVSaveRouteValidation.cs new file mode 100644 index 000000000..4976bd445 --- /dev/null +++ b/Client/Assets/Editor/KVSaveRouteValidation.cs @@ -0,0 +1,19 @@ +#if UNITY_EDITOR +using System; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +public static class KVSaveRouteValidation +{ + public static async Task RunAsync(string caseId) + { + if (caseId != "fixed-route") throw new ArgumentException("Unknown fixed validation case."); + var assembly = AppDomain.CurrentDomain.GetAssemblies().Single(a => a.GetName().Name == "HotUpdate"); + var type = assembly.GetType("game.PlayerDataStorage.PlayerKVSaveRouteEditorValidationHost", true); + var method = type.GetMethod("RunAsync", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(string) }, null); + if (method == null) throw new InvalidOperationException("Validation entry unavailable."); + return await (Task)method.Invoke(null, new object[] { caseId }); + } +} +#endif diff --git a/Client/Assets/Editor/KVSaveRouteValidation.cs.meta b/Client/Assets/Editor/KVSaveRouteValidation.cs.meta new file mode 100644 index 000000000..aca1614bf --- /dev/null +++ b/Client/Assets/Editor/KVSaveRouteValidation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dc9b5dbb615a4746a95df66b46987589 diff --git a/Client/Assets/Editor/S101LoginWindow.cs b/Client/Assets/Editor/S101LoginWindow.cs new file mode 100644 index 000000000..b158c1aed --- /dev/null +++ b/Client/Assets/Editor/S101LoginWindow.cs @@ -0,0 +1,74 @@ +using System; +using System.Threading; +using game.Account; +using UnityEditor; +using UnityEngine; + +public sealed class S101LoginWindow : EditorWindow +{ + string playerId = "101"; + string status = "输入涂游测试 ID,验证 s101 认证及 Cookie。"; + bool busy; + S101AccountSession session; + CancellationTokenSource cancellation; + + [MenuItem("Tools/Debug/S101 Minimal Login")] + static void Open() => GetWindow("S101 最小登录"); + + void OnGUI() + { + EditorGUILayout.LabelField("API", S101AccountSession.DefaultApiUrl); + using (new EditorGUI.DisabledScope(busy)) + { + playerId = EditorGUILayout.TextField("涂游测试 ID", playerId); + if (GUILayout.Button("登录并验证 Cookie")) Login(); + } + EditorGUILayout.HelpBox(status, MessageType.Info); + EditorGUILayout.LabelField("此入口只验证认证,不初始化玩法或写入存档。"); + } + + async void Login() + { + busy = true; + session?.Dispose(); + cancellation?.Dispose(); + cancellation = new CancellationTokenSource(); + var attempt = new S101AccountSession(); + session = attempt; + var token = cancellation.Token; + status = "正在登录…"; + try + { + var login = await attempt.LoginAsync(playerId, token); + if (token.IsCancellationRequested) return; + if (!login.Success) + { + status = $"登录失败:{login.Error},HTTP {login.StatusCode}"; + return; + } + var probe = await attempt.CheckAuthorizationAsync(token); + if (token.IsCancellationRequested) return; + status = probe.Success + ? $"认证通过:涂游 ID {attempt.PlayerId},内部 ID {attempt.InternalId};Cookie 授权读取通过。" + : $"登录成功,但授权读取未通过:{probe.Error},HTTP {probe.StatusCode}"; + } + catch (Exception ex) + { + status = "验证异常:" + ex.GetType().Name; + } + finally + { + busy = false; + if (this != null) Repaint(); + } + } + + void OnDisable() + { + cancellation?.Cancel(); + session?.Dispose(); + cancellation?.Dispose(); + cancellation = null; + session = null; + } +} diff --git a/Client/Assets/Editor/S101LoginWindow.cs.meta b/Client/Assets/Editor/S101LoginWindow.cs.meta new file mode 100644 index 000000000..af0cd00bc --- /dev/null +++ b/Client/Assets/Editor/S101LoginWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a8f49f7440c141e89d8519ff3aa1c034 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Scripts/AOTScripts/GConstant.cs b/Client/Assets/Scripts/AOTScripts/GConstant.cs index 2c6108fdd..0578945a8 100644 --- a/Client/Assets/Scripts/AOTScripts/GConstant.cs +++ b/Client/Assets/Scripts/AOTScripts/GConstant.cs @@ -77,7 +77,7 @@ public class GConstant public const string K_FuncUrl = "FuncUrl"; - public const string V_FuncUrl = "http://192.168.9.101:7071"; + public const string V_FuncUrl = "http://s101.local:7071"; public const string K_FuncKey = "FuncKey"; public const string V_FuncKey = "testKey"; diff --git a/Client/Assets/Scripts/Activity/Sentry/ActivitySentryManager.cs b/Client/Assets/Scripts/Activity/Sentry/ActivitySentryManager.cs index 2c81933c8..7c6059c0f 100644 --- a/Client/Assets/Scripts/Activity/Sentry/ActivitySentryManager.cs +++ b/Client/Assets/Scripts/Activity/Sentry/ActivitySentryManager.cs @@ -10,6 +10,15 @@ namespace Activity public class ActivitySentryManager { private static Dictionary<(int, int), Type> _activitySentryTypes; + private static object _registrationContainer; + + private static void EnsureCurrentContainer() + { + if (ReferenceEquals(_registrationContainer, GContext.container)) return; + _registrationContainer = GContext.container; + _activitySentryTypes = null; + TypeKeyCache.Clear(); + } /// /// 类型键缓存:Sentry 类型 → (EventType, EventSubType) @@ -21,6 +30,7 @@ namespace Activity { get { + EnsureCurrentContainer(); if (_activitySentryTypes != null) return _activitySentryTypes; InitActivitySentryTypes(); @@ -42,6 +52,7 @@ namespace Activity } public static void InitActivitySentryType(Type type) { + EnsureCurrentContainer(); _activitySentryTypes ??= new Dictionary<(int, int), Type>(); try { @@ -82,4 +93,4 @@ namespace Activity return TypeKeyCache.GetValueOrDefault(sentryType); } } -} \ No newline at end of file +} diff --git a/Client/Assets/Scripts/Camp/CampDataMM.cs b/Client/Assets/Scripts/Camp/CampDataMM.cs index 45d0cd5c4..72ba1be2c 100644 --- a/Client/Assets/Scripts/Camp/CampDataMM.cs +++ b/Client/Assets/Scripts/Camp/CampDataMM.cs @@ -101,6 +101,24 @@ public class CampDataMM //是否需要修复 public bool IsFix => _isFix && AreaIndex < AreaList.Count; bool _isFix; + + internal static game.PlayerDataStorage.PlayerKVCampState DecodeForKVLoad(IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeCamp(raw,errors); + internal void ResetForKVLoad() + { + CampDataDic=new Dictionary(StringComparer.Ordinal); + _isFix=false; _isOpenSkyscraper="0"; _id=101; + PreID=AreaIndex=AreaStep=AreaId=AreaStepCount=TimelineId=0; + AreaIcon=null; constructionArea=null; StepInfo=null; AreaStepList=null; IsUpdataArea=false; + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVCampState state) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + CampDataDic=state.Values.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal); + _isFix=state.IsFix; _isOpenSkyscraper=state.Skyscraper; + } + internal game.PlayerDataStorage.PlayerKVCampState SnapshotForKVValidation() => + new game.PlayerDataStorage.PlayerKVCampState(_isFix,_isOpenSkyscraper,CampDataDic); public void InitData(Dictionary userData) { string value; diff --git a/Client/Assets/Scripts/Core/CustomServerMgr.cs b/Client/Assets/Scripts/Core/CustomServerMgr.cs index 2434536d0..058bf787c 100644 --- a/Client/Assets/Scripts/Core/CustomServerMgr.cs +++ b/Client/Assets/Scripts/Core/CustomServerMgr.cs @@ -6,6 +6,8 @@ using System.Net.Http; using System.Text; using System.Threading.Tasks; using UnityEngine; +using game.Account; +using game.PlayerDataStorage; public interface ICustomServerMgr { @@ -21,6 +23,11 @@ public interface ICustomServerMgr Task EventPartnerRequest(string url, object data = null); Task EventPartnerRequest(string url, object data); CookieContainer Cookie { get; } + Uri ApiBaseUri { get; } + int SessionVersion { get; } + event Action SessionChanged; + void UseAccountSession(S101AccountSession session); + void ClearAccountSession(); } public class CustomServerMgr : ICustomServerMgr @@ -31,87 +38,122 @@ public class CustomServerMgr : ICustomServerMgr HttpClient client; private CookieContainer _cookie; + private readonly Func handlerFactory; + public int SessionVersion { get; private set; } + public event Action SessionChanged; + PlayerKVAccountLease kvLease; + bool kvOnly; + int pfRequests; + internal event Action KVUnauthorized; + internal bool IsFreshForKVLoad => pfRequests==0 && kvLease==null; + internal void BindKVForValidation(PlayerKVAccountLease lease) + { + if(lease==null || !lease.IsCurrent || lease.ApiBaseUri!=ApiBaseUri || !IsFreshForKVLoad) + throw new InvalidOperationException("Fresh matching KV lease required."); + kvOnly=true; SessionVersion++; kvLease=lease; _cookie=null; + var previous=client; client=null; previous?.Dispose(); SessionChanged?.Invoke(); + } + internal Task ClearKVForValidation() + { + SessionVersion++; var previous=kvLease; kvLease=null; + var closing=previous?.CloseAsync() ?? Task.CompletedTask; + SessionChanged?.Invoke(); return closing; + } + internal async Task SendKVForValidationAsync(string route) + { + var lease=kvLease; int version=SessionVersion; + if(!kvOnly || lease==null) return new PlayerKVAccountResponse(false); + var response=await lease.SendAsync(HttpMethod.Get,route); + if(version!=SessionVersion || !ReferenceEquals(lease,kvLease) || !response.IsCurrent) return new PlayerKVAccountResponse(false); + if(response.StatusCode==401 || response.StatusCode==403) + { + KVUnauthorized?.Invoke(version); + return new PlayerKVAccountResponse(false,response.StatusCode); + } + return response; + } public CookieContainer Cookie => _cookie ?? throw new System.NullReferenceException(); + public Uri ApiBaseUri { get; } - public CustomServerMgr(IConfig config) + public CustomServerMgr(IConfig config) : this(config, null) { } + + public CustomServerMgr(IConfig config, Func handlerFactory) { + this.handlerFactory = handlerFactory ?? (cookies => new HttpClientHandler + { + CookieContainer = cookies, UseCookies = true, AllowAutoRedirect = false + }); this.config = config; - var httpClientHandler = new HttpClientHandler(); - _cookie = new CookieContainer(); - httpClientHandler.CookieContainer = _cookie; - client = new HttpClient(httpClientHandler); - - client.BaseAddress = - new System.Uri(config.Get(GConstant.K_Event_API_URL, - "http://192.168.9.101:5292/")); - client.Timeout = TimeSpan.FromSeconds(15); + var url = config.Get(GConstant.K_Event_API_URL, S101AccountSession.DefaultApiUrl); + if (string.IsNullOrWhiteSpace(url)) url = S101AccountSession.DefaultApiUrl; + ApiBaseUri = new Uri(url.TrimEnd('/') + "/"); + ClearAccountSession(); } - public async Task CustomServerPost(string url, string bodyString, - string contentType = "application/json") + public void UseAccountSession(S101AccountSession session) { + if(kvOnly) throw new InvalidOperationException("Controlled container cannot switch to PlayFab."); + if (session == null || !session.IsAuthenticated || session.ApiBaseUri != ApiBaseUri) + throw new ArgumentException("An authenticated session for this API is required.", nameof(session)); + ReplaceTransport(session.Cookies); + } + + public void ClearAccountSession() + { + if(kvOnly) { PlayerKVShutdownRegistry.Process.Register(ClearKVForValidation()); return; } + ReplaceTransport(new CookieContainer()); + } + + private void ReplaceTransport(CookieContainer cookies) + { + SessionVersion++; + var previous = client; + _cookie = cookies; + client = new HttpClient(handlerFactory(cookies)) { BaseAddress = ApiBaseUri, Timeout = TimeSpan.FromSeconds(15) }; + previous?.Dispose(); + SessionChanged?.Invoke(); + } + + public Task CustomServerPost(string url, string bodyString, + string contentType = "application/json") => SendAsync(HttpMethod.Post, url, bodyString, contentType); + + public Task GetStringAsync(string url) => SendAsync(HttpMethod.Get, url, null, null); + + // Unity callers use the main thread. Capture a fixed transport before the first await. + private async Task SendAsync(HttpMethod method, string url, string body, string contentType) + { + if(kvOnly) throw new InvalidOperationException("Only controlled validation routes are enabled."); + pfRequests++; + var transport = client; + var version = SessionVersion; try { - #if UNITY_EDITOR - Debug.Log("client.BaseAddress.AbsoluteUri:" + client.BaseAddress.AbsoluteUri); - Debug.Log("CustomServerPost url:" + url); - Debug.Log("CustomServerPost bodyString:" + bodyString); - #endif - var content = new StringContent(bodyString, Encoding.UTF8, "application/json"); - var response = await client.PostAsync(url, content); - string result = await response.Content.ReadAsStringAsync(); - if (response.StatusCode == System.Net.HttpStatusCode.OK) + using (var request = new HttpRequestMessage(method, url)) { - //GameDebug.Log("CustomServerPost result:" + result.ToString()); - return result; - } - else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden) - { - GContext.Publish(new game.EvtAPISvrUnauthorized()); - return null; - } - else - { - Debug.LogError(response.StatusCode.ToString() + "+++CustomServerPost error:" + - result.ToString() + "+++CustomServerPost url:" + url + ":" + - bodyString); - return null; + if (body != null) request.Content = new StringContent(body, Encoding.UTF8, contentType); + using (var response = await transport.SendAsync(request)) + { + var result = await response.Content.ReadAsStringAsync(); + if (version != SessionVersion) return null; + if (response.StatusCode == HttpStatusCode.OK) return result; + if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden) + GContext.Publish(new game.EvtAPISvrUnauthorized { SessionVersion = version }); + else + Debug.LogError($"API request failed: HTTP {(int)response.StatusCode}"); + return null; + } } } catch (Exception e) { - Debug.LogError("CustomServerPost error:" + e.Message + "---CustomServerPost url:" + - url + ":" + bodyString + "\n" + e.StackTrace); + if (version == SessionVersion) Debug.LogError($"API request failed: {e.GetType().Name}"); return null; } } - public async Task GetStringAsync(string url) - { - var response = await client.GetAsync(url); - - if (response.StatusCode == HttpStatusCode.OK) - { - return await response.Content.ReadAsStringAsync(); - } - else - { - string result = await response.Content.ReadAsStringAsync(); - Debug.LogError(response.StatusCode.ToString() + "+++GetStringAsync error:" + - result.ToString() + "+++GetStringAsync url:" + url); - } - - if (response.StatusCode == HttpStatusCode.Unauthorized) - { - GContext.Publish(new game.EvtAPISvrUnauthorized()); - } - - return null; - } - public async Task AccountRequest(string url, object data) { return await CustomServerPost($"account/{url}", diff --git a/Client/Assets/Scripts/Core/PlayFabMgr.cs b/Client/Assets/Scripts/Core/PlayFabMgr.cs index af7bdb54b..0bc421a98 100644 --- a/Client/Assets/Scripts/Core/PlayFabMgr.cs +++ b/Client/Assets/Scripts/Core/PlayFabMgr.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using UnityEngine; +using game.PlayerDataStorage; public class PlayFabMgr : MonoBehaviour { @@ -25,6 +26,9 @@ public class PlayFabMgr : MonoBehaviour private CancellationTokenSource tokenSource; static PlayFabMgr _instance = null; + // An inactive, never-awakened component proves that no timer or PF send has ever been started. + internal bool IsUnstartedForKVLoad => tokenSource==null && dataBuffer==null && swapData==null && localData==null && lastUpdateTime==null && _instance!=this; + internal static bool HasLiveInstanceForKVLoad => _instance!=null; public static PlayFabMgr Instance { @@ -41,6 +45,10 @@ public class PlayFabMgr : MonoBehaviour public Dictionary CompareDatas(Dictionary remoteDatas, string userId) { +#if UNITY_EDITOR && !SERVICE_HOST + if(PlayerKVGameplayLoop.Enabled) throw PlayerKVGameplayLoop.Stop("PF archive comparison is disabled in KV loop."); +#endif + PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.Compare); Last_Update_Time_Key = $"{Last_Update_Time_Key_Prefix}_{userId}"; Local_Data_Key = $"{Local_Data_Key_prefix}_{userId}"; var lastUpdateTimeStr = PlayerPrefs.GetString(Last_Update_Time_Key, string.Empty); @@ -114,12 +122,26 @@ public class PlayFabMgr : MonoBehaviour void Awake() { + if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return; _instance = this; +#if UNITY_EDITOR && !SERVICE_HOST + if(PlayerKVGameplayLoop.Enabled) return; +#endif InitBuffer(); tokenSource = new CancellationTokenSource(); UpdateUserDataValue(tokenSource.Token); } +#if UNITY_EDITOR && !SERVICE_HOST + internal void StartKVGameplayLoopSync() + { + if(!PlayerKVGameplayLoop.Ready || tokenSource!=null) return; + tokenSource=new CancellationTokenSource(); + UpdateUserDataValue(tokenSource.Token); + } + internal void StopKVGameplayLoopSync() + { tokenSource?.Cancel(); tokenSource?.Dispose(); tokenSource=null; } +#endif void InitBuffer() { dataBuffer = new LinkedList>(); @@ -132,6 +154,15 @@ public class PlayFabMgr : MonoBehaviour void OnApplicationPause(bool is_pause) { +#if UNITY_EDITOR && !SERVICE_HOST + if(PlayerKVGameplayLoop.Enabled) + { + if(is_pause) { StopKVGameplayLoopSync(); _=PlayerKVGameplayLoop.FlushObservedAsync(); } + else StartKVGameplayLoopSync(); + return; + } +#endif + if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return; if (is_pause) { tokenSource.Cancel(); @@ -147,6 +178,10 @@ public class PlayFabMgr : MonoBehaviour void OnApplicationQuit() { +#if UNITY_EDITOR && !SERVICE_HOST + if(PlayerKVGameplayLoop.Enabled) { StopKVGameplayLoopSync(); _=PlayerKVGameplayLoop.CloseAsync(); return; } +#endif + if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return; tokenSource.Cancel(); tokenSource = null; SyncData(); @@ -154,13 +189,19 @@ public class PlayFabMgr : MonoBehaviour void OnDestroy() { +#if UNITY_EDITOR && !SERVICE_HOST + if(PlayerKVGameplayLoop.Enabled) { StopKVGameplayLoopSync(); _=PlayerKVGameplayLoop.CloseAsync(); return; } +#endif + if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return; tokenSource?.Cancel(); tokenSource = null; + if(!PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.PrefsSave)) PlayerPrefs.Save(); SyncData(); } private async void UpdateUserDataValue(CancellationToken token) { + if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return; try { while (!token.IsCancellationRequested) @@ -174,6 +215,10 @@ public class PlayFabMgr : MonoBehaviour public void UpdateUserDataValue(string key, string data, bool remoteStore = true) { +#if UNITY_EDITOR && !SERVICE_HOST + if(PlayerKVGameplayLoop.Enabled) { PlayerKVGameplayLoop.Write(key,data,remoteStore); return; } +#endif + PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.Update); if (remoteStore) { var cbuff = dataBuffer.First; @@ -203,6 +248,10 @@ public class PlayFabMgr : MonoBehaviour /// public string GetLocalData(string key) { +#if UNITY_EDITOR && !SERVICE_HOST + if(PlayerKVGameplayLoop.Enabled) return PlayerKVGameplayLoop.Read(key); +#endif + PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.ReadLocal); if (localData.ContainsKey(key)) { return localData[key]; @@ -215,6 +264,12 @@ public class PlayFabMgr : MonoBehaviour private void SyncData() { +#if UNITY_EDITOR && !SERVICE_HOST + if(PlayerKVGameplayLoop.Enabled) { _=PlayerKVGameplayLoop.FlushObservedAsync(); return; } +#endif + if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Sync)) return; + // Logout clears credentials before RootCtx is destroyed. Keep pending data local. + if (!PlayFabClientAPI.IsClientLoggedIn()) return; var buf = dataBuffer.First; if (buf.Value.Count == 0) return; @@ -267,6 +322,10 @@ public class PlayFabMgr : MonoBehaviour public async Task SyncDataImmidiate(string key, string data) { +#if UNITY_EDITOR && !SERVICE_HOST + if(PlayerKVGameplayLoop.Enabled) throw PlayerKVGameplayLoop.Stop("Immediate/consuming saves are outside the equipment loop."); +#endif + if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Immediate)) return false; foreach (var buff in dataBuffer) { if (buff.ContainsKey(key)) @@ -293,4 +352,52 @@ public class PlayFabMgr : MonoBehaviour ); return await taskSource.Task; } + + // BEGIN KV explicit fixed-handle access + internal static PlayerKVKeyReadResult ReadKV(PlayerKVGameSessionHandle handle, string key) + { + if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.ReadLocal)) + return new PlayerKVKeyReadResult(PlayerKVKeyReadError.LoadGuardActive, handle); + if (handle == null || !KVSnapshot.ValidKey(key)) + return new PlayerKVKeyReadResult(PlayerKVKeyReadError.InvalidInput, handle); + var read = handle.ReadLocalSnapshot(); + if (!read.Success) + return new PlayerKVKeyReadResult(read.Error == PlayerKVLocalReadError.StaleOrClosing + ? PlayerKVKeyReadError.StaleOrClosing : PlayerKVKeyReadError.NotReady, handle); + var found = read.Data.TryGetValue(key, out var value); + return new PlayerKVKeyReadResult(PlayerKVKeyReadError.None, handle, found, value); + } + + internal static PlayerKVEditResult WriteKV(PlayerKVGameSessionHandle handle, + string key, string value, bool remoteStore = true) + { + if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Update)) + return new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Blocked)); + if (handle == null) + return new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Validation)); + return handle.Update(key, value, remoteStore); + } + + internal static Task FlushKVAsync(PlayerKVGameSessionHandle handle, + CancellationToken waitToken = default) + { + if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Sync)) + return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, + handle == null ? PlayerKVSyncStatus.NotLoaded : handle.Status)); + if (handle == null) + return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Validation, PlayerKVSyncStatus.NotLoaded)); + return handle.FlushAsync(waitToken); + } + + internal static Task WaitForKVTicketAsync(PlayerKVGameSessionHandle handle, + PlayerKVSyncTicket ticket, CancellationToken waitToken = default) + { + if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Sync)) + return Task.FromResult(new PlayerKVTicketResult(PlayerKVTicketStatus.Blocked, PlayerKVError.Cancelled, + handle == null ? PlayerKVSyncStatus.NotLoaded : handle.Status)); + if (handle == null) + return Task.FromResult(new PlayerKVTicketResult(PlayerKVTicketStatus.Blocked, PlayerKVError.Validation, PlayerKVSyncStatus.NotLoaded)); + return handle.WaitForTicketAsync(ticket, waitToken); + } + // END KV explicit fixed-handle access } diff --git a/Client/Assets/Scripts/Core/SoundMgr.cs b/Client/Assets/Scripts/Core/SoundMgr.cs index b33415a4c..8dd17e9c5 100644 --- a/Client/Assets/Scripts/Core/SoundMgr.cs +++ b/Client/Assets/Scripts/Core/SoundMgr.cs @@ -198,54 +198,69 @@ namespace Game } protected CompositeDisposable disposables; + private int initializationVersion; public async Task Init(System.Action progress) { - //playerData = GContext.container.Resolve(); - _tables = GContext.container.Resolve(); - //var service = (SoundService)soundService; - await soundService.InitAsync(); - + if (residentClip != null || disposables != null) Release(); + var version = ++initializationVersion; + var container = GContext.container; + _tables = container.Resolve(); var opMap = new Dictionary>(); - - foreach (var addr in residentFish) + CompositeDisposable pendingSubscriptions = null; + bool transferred = false; + try { - opMap[addr] = Addressables.LoadAssetAsync(addr); - } - - var tasks = opMap.Values.Select(_ => _.Task); - var total = (float)tasks.Count(); - - while (true) - { - var completed = tasks.Count(_ => _.IsCompleted); - if (completed == total) + await soundService.InitAsync(); + if (version != initializationVersion || !ReferenceEquals(container, GContext.container)) return; + foreach (var addr in residentFish) { - progress.Invoke(1f); - break; + if (!opMap.ContainsKey(addr)) opMap.Add(addr, Addressables.LoadAssetAsync(addr)); } - else + var tasks = opMap.Values.Select(_ => _.Task).ToArray(); + var total = (float)tasks.Length; + while (true) { + var completed = tasks.Count(_ => _.IsCompleted); + if (completed == total) + { + progress.Invoke(1f); + break; + } progress.Invoke(completed / total); + await Awaiters.NextFrame; + if (version != initializationVersion || !ReferenceEquals(container, GContext.container)) return; + } + if (version != initializationVersion || !ReferenceEquals(container, GContext.container)) return; + var loadedClips = opMap.ToDictionary(_ => _.Key, _ => _.Value.Result); + pendingSubscriptions = new CompositeDisposable(); + GContext.OnEvent().Subscribe(PlaySound).AddTo(pendingSubscriptions); + GContext.OnEvent().Subscribe(PlayRewardSound).AddTo(pendingSubscriptions); + GContext.OnEvent().Subscribe(PlayFishingSound).AddTo(pendingSubscriptions); + GContext.OnEvent().Subscribe(PlayMusic).AddTo(pendingSubscriptions); + GContext.OnEvent().Subscribe(FadeOutEvent).AddTo(pendingSubscriptions); + GContext.OnEvent().Subscribe(PlaySoundByReference).AddTo(pendingSubscriptions); + GContext.OnEvent().Subscribe(PlayVoiceSoundByReference).AddTo(pendingSubscriptions); + + GContext.OnEvent().Subscribe(OnChangeRodData).AddTo(pendingSubscriptions); + GContext.OnEvent().Subscribe(OnSoundMute).AddTo(pendingSubscriptions); + GContext.OnEvent().Subscribe(OnSoundStateEvent).AddTo(pendingSubscriptions); + soundService.BGMVol = container.Resolve().Music ? 80 : 0; + soundService.SFXVol = container.Resolve().Sound ? 80 : 0; + soundService.VoiceVol = container.Resolve().Sound ? 80 : 0; + residentClip = loadedClips; + disposables = pendingSubscriptions; + transferred = true; + } + finally + { + if (!transferred) + { + pendingSubscriptions?.Dispose(); + if (version == initializationVersion) Release(); + foreach (var handle in opMap.Values) + if (handle.IsValid()) Addressables.Release(handle); } - await Awaiters.NextFrame; } - - residentClip = opMap.ToDictionary(_ => _.Key, _ => _.Value.Result); - disposables = new CompositeDisposable(); - GContext.OnEvent().Subscribe(PlaySound).AddTo(disposables); - GContext.OnEvent().Subscribe(PlayRewardSound).AddTo(disposables); - GContext.OnEvent().Subscribe(PlayFishingSound).AddTo(disposables); - GContext.OnEvent().Subscribe(PlayMusic).AddTo(disposables); - GContext.OnEvent().Subscribe(FadeOutEvent).AddTo(disposables); - GContext.OnEvent().Subscribe(PlaySoundByReference).AddTo(disposables); - GContext.OnEvent().Subscribe(PlayVoiceSoundByReference).AddTo(disposables); - - GContext.OnEvent().Subscribe(OnChangeRodData).AddTo(disposables); - GContext.OnEvent().Subscribe(OnSoundMute).AddTo(disposables); - GContext.OnEvent().Subscribe(OnSoundStateEvent).AddTo(disposables); - soundService.BGMVol = GContext.container.Resolve().Music ? 80 : 0; - soundService.SFXVol = GContext.container.Resolve().Sound ? 80 : 0; - soundService.VoiceVol = GContext.container.Resolve().Sound ? 80 : 0; } void OnSoundStateEvent(OnSoundStateEvent onSoundStateEvent) { @@ -757,6 +772,7 @@ namespace Game } public void Release() { + initializationVersion++; if (disposables != null) { disposables.Dispose(); diff --git a/Client/Assets/Scripts/DataCenter/AchievementDataManager.cs b/Client/Assets/Scripts/DataCenter/AchievementDataManager.cs index 430106411..8c3f70939 100644 --- a/Client/Assets/Scripts/DataCenter/AchievementDataManager.cs +++ b/Client/Assets/Scripts/DataCenter/AchievementDataManager.cs @@ -32,6 +32,31 @@ public class AchievementDataManager : IDisposable { _tables = tables; } + internal static game.PlayerDataStorage.PlayerKVAchievementState DecodeForKVLoad( + IReadOnlyDictionary raw, IReadOnlyDictionary modern, + game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot, game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) + => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeAchievements(raw, modern, snapshot, errors); + + internal void ResetForKVLoad() + { + _achievementDatas = new Dictionary(); + } + + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVAchievementState state) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + var replacement = new Dictionary(); + foreach (var entry in state.Values) + replacement.Add((ConditionType)entry.Key, new AchievementData + { Id = entry.Value.Id, Target = entry.Value.Target, day = entry.Value.Day }); + _achievementDatas = replacement; + } + + internal bool IsEmptyForKVValidation => _achievementDatas.Count == 0; + internal AchievementData FindForKVValidation(ConditionType key) + => _achievementDatas.TryGetValue(key, out var value) + ? new AchievementData { Id = value.Id, Target = value.Target, day = value.day } : null; + public void LoadAchievementData(string value) { _achievementDatas = Newtonsoft.Json.JsonConvert.DeserializeObject>(value); diff --git a/Client/Assets/Scripts/DataCenter/BargainPackData.cs b/Client/Assets/Scripts/DataCenter/BargainPackData.cs index 9c213aa56..71e950965 100644 --- a/Client/Assets/Scripts/DataCenter/BargainPackData.cs +++ b/Client/Assets/Scripts/DataCenter/BargainPackData.cs @@ -13,6 +13,62 @@ public class BargainPackData private readonly TbSpecialPack _spp = GContext.container.Resolve().TbSpecialPack; private GeneralEventPackData _data; + private bool _kvLoadPresent; +#if UNITY_EDITOR + internal bool SelectionsAreIndependentForKVValidation() + { + var source = new List { 7, 9 }; + var state = new game.PlayerDataStorage.PlayerKVBargainState(true,0,_data.redirectID,0,0,0,0,0,0,0,0,0,false,false,0,source); + ReplaceFromKVDecoded(state); + source.Clear(); + if (_data.selections.Count != 2 || ReferenceEquals(_data.selections, source) || ReferenceEquals(_data.selections, state.selections)) return false; + var previous = _data.selections; + _data.selections[0] = 10; + if (state.selections[0] != 7) return false; + ResetForKVLoad(); + return _data.selections.Count == 0 && !ReferenceEquals(previous, _data.selections) && !_kvLoadPresent; + } +#endif + internal bool KVLoadPresent => _kvLoadPresent; + internal static game.PlayerDataStorage.PlayerKVBargainState DecodeForKVLoad( + IReadOnlyDictionary raw, game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot, + game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + game.PlayerDataStorage.PlayerKVSaveSchema.DecodeBargain(raw, snapshot, errors); + + internal void ResetForKVLoad() + { + _kvLoadPresent = false; + _data = default(GeneralEventPackData); + _data.selections = new List(); + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVBargainState state) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + if (!state.IsPresent) { ResetForKVLoad(); return; } + var replacement = new GeneralEventPackData + { + currentEventID = state.currentEventID, + redirectID = state.redirectID, + VIPLvlWhenEventActivate = state.VIPLvlWhenEventActivate, + purchaseCount = state.purchaseCount, + savingProgress = state.savingProgress, + discountLvl = state.discountLvl, + visualProgress = state.visualProgress, + visualDiscountLvl = state.visualDiscountLvl, + rodID = state.rodID, + lastPackIDBought = state.lastPackIDBought, + packID = state.packID, + doNeedTrigger = state.doNeedTrigger, + isTriggered = state.isTriggered, + inflation = state.inflation, + selections = state.selections == null ? null : new List(state.selections), + }; + _data = replacement; + _kvLoadPresent = true; + } + internal game.PlayerDataStorage.PlayerKVBargainState SnapshotForKVValidation() => + new game.PlayerDataStorage.PlayerKVBargainState(_kvLoadPresent, _data.currentEventID, _data.redirectID, _data.VIPLvlWhenEventActivate, _data.purchaseCount, _data.savingProgress, _data.discountLvl, _data.visualProgress, _data.visualDiscountLvl, _data.rodID, _data.lastPackIDBought, _data.packID, _data.doNeedTrigger, _data.isTriggered, _data.inflation, _data.selections); + public int CurrentEventID => _data.currentEventID; public FishingEvent CurrentEvent => _tables.TbFishingEvent[_data.currentEventID]; @@ -25,10 +81,11 @@ public class BargainPackData { get { - if (CurrentEventID == 0) return false; + var fishingEvent = _tables.TbFishingEvent.GetOrDefault(CurrentEventID); + if (!(fishingEvent?.TimeDefinition is LimitedTime time)) return false; - var et = DateTime.Parse((_tables.TbFishingEvent[_data.currentEventID].TimeDefinition as LimitedTime).EndTime); - var st = DateTime.Parse((_tables.TbFishingEvent[_data.currentEventID].TimeDefinition as LimitedTime).StartTime); + var et = DateTime.Parse(time.EndTime); + var st = DateTime.Parse(time.StartTime); return ZZTimeHelper.UtcNow() >= st && ZZTimeHelper.UtcNow() < et; } } @@ -259,4 +316,4 @@ public class BargainPackProgressEvent { public int type; //0: 初始化 1:增加动画 public int addProgress; -} \ No newline at end of file +} diff --git a/Client/Assets/Scripts/DataCenter/BattlePassData.cs b/Client/Assets/Scripts/DataCenter/BattlePassData.cs index 4d9f50d44..9122be5af 100644 --- a/Client/Assets/Scripts/DataCenter/BattlePassData.cs +++ b/Client/Assets/Scripts/DataCenter/BattlePassData.cs @@ -36,6 +36,29 @@ public class BattlePassData public BattlePassRecord BattlePassRecord; public BattleTask BattleTask; + internal static game.PlayerDataStorage.PlayerKVBattlePassState DecodeForKVLoad(IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + game.PlayerDataStorage.PlayerKVSaveSchema.DecodeBattlePass(raw,snapshot,errors); + internal void ResetForKVLoad() + { + BattlePassRecord=new BattlePassRecord(); + BattleTask=new BattleTask(); + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVBattlePassState state) + { + if(state==null) throw new ArgumentNullException(nameof(state)); + var record=new BattlePassRecord { + ID=state.Record.ID,Level=state.Record.Level,Exp=state.Record.Exp,BuyVipToastIndex=state.Record.BuyVipToastIndex, + IsUnlockVip=state.Record.IsUnlockVip,NormalRewardGet=new List(state.Record.Normal),VipRewardGet=new List(state.Record.Vip), + LastDropIDs=new List(state.Record.LastDrops),InflationRate=state.Record.Inflation + }; + var task=new BattleTask { + dic_dailyTasks=state.Task.Daily.ToDictionary(p=>p.Key,p=>p.Value),dic_weeklyTasks=state.Task.Weekly.ToDictionary(p=>p.Key,p=>p.Value), + loginDays=new List(state.Task.LoginDays),DailyTaskEndTime=state.Task.DailyEnd,WeeklyTaskEndTime=state.Task.WeeklyEnd + }; + BattlePassRecord=record; BattleTask=task; + } + private Tables _tables; @@ -136,20 +159,16 @@ public class BattlePassData public void RefreshDaiyTask() { - var bpMain = _tables.TbBattlePassMain.GetOrDefault(_tables.TbFishingEvent.GetOrDefault(BattlePassRecord.ID).RedirectID); - if (bpMain != null) + var fishingEvent = _tables.TbFishingEvent.GetOrDefault(BattlePassRecord.ID); + if (fishingEvent == null) return; + var bpMain = _tables.TbBattlePassMain.GetOrDefault(fishingEvent.RedirectID); + if (bpMain == null) return; + BattleTask.DailyTaskEndTime = ConvertTools.GetDateTimeYMD(ZZTimeHelper.UtcNow().UtcNowOffset()).AddDays(1); + var dailyTasks = bpMain.DailyTaskList; + BattleTask.dic_dailyTasks.Clear(); + foreach (var task in dailyTasks) { - BattleTask.DailyTaskEndTime = ConvertTools.GetDateTimeYMD(ZZTimeHelper.UtcNow().UtcNowOffset()).AddDays(1); - var dailyTasks = bpMain.DailyTaskList; - BattleTask.dic_dailyTasks.Clear(); - foreach (var task in dailyTasks) - { - BattleTask.dic_dailyTasks.Add(task, 0); - } - } - else - { - BattleTask = new BattleTask(); + BattleTask.dic_dailyTasks.Add(task, 0); } GContext.Publish(new ConditionTypeEvent { type = ConditionType.LoginDays, count = 1 }); SaveBattleTask(); @@ -157,23 +176,17 @@ public class BattlePassData public void RefreshWeeklyTask() { - - var bpMain = _tables.TbBattlePassMain.GetOrDefault(_tables.TbFishingEvent.GetOrDefault(BattlePassRecord.ID).RedirectID); - if (bpMain != null) + var fishingEvent = _tables.TbFishingEvent.GetOrDefault(BattlePassRecord.ID); + if (fishingEvent == null) return; + var bpMain = _tables.TbBattlePassMain.GetOrDefault(fishingEvent.RedirectID); + if (bpMain == null) return; + var weeklyTasks = bpMain.WeeklyTaskList; + BattleTask.dic_weeklyTasks.Clear(); + foreach (var task in weeklyTasks) { - var weeklyTasks = bpMain.WeeklyTaskList; - BattleTask.dic_weeklyTasks.Clear(); - foreach (var task in weeklyTasks) - { - BattleTask.dic_weeklyTasks.Add(task, 0); - } - BattleTask.WeeklyTaskEndTime = ConvertTools.GetDateTimeSunDay(ZZTimeHelper.UtcNow().UtcNowOffset()).AddDays(1); - } - //当前没有战令了 - else - { - BattleTask = new BattleTask(); + BattleTask.dic_weeklyTasks.Add(task, 0); } + BattleTask.WeeklyTaskEndTime = ConvertTools.GetDateTimeSunDay(ZZTimeHelper.UtcNow().UtcNowOffset()).AddDays(1); BattlePassRecord.InflationRate = GContext.container.Resolve().InflationRate; GContext.Publish(new ConditionTypeEvent { type = ConditionType.LoginDays, count = 1 }); @@ -207,7 +220,7 @@ public class BattlePassDataProvider : IDisposable private Tables _tables; private Dictionary dic_BPLevel; - public bool IsActivating => Data.BattlePassRecord.ID != 0; + public bool IsActivating => Data.BattlePassRecord.ID != 0 && curPassMain != null; public bool UILockVip = false; public bool IsUnlockVip => Data.BattlePassRecord.IsUnlockVip == 1; @@ -249,6 +262,7 @@ public class BattlePassDataProvider : IDisposable public void CheckIfRefreshBattblePass() { + if (!IsActivating) return; DateTime endTime = GContext.container.Resolve().GetEventEndTime(Data.BattlePassRecord.ID); if (ZZTimeHelper.UtcNow().UtcNowOffset() >= endTime && IsActivating) diff --git a/Client/Assets/Scripts/DataCenter/BuffDataCenter.cs b/Client/Assets/Scripts/DataCenter/BuffDataCenter.cs index 1a5ccb3d9..5abb9b5f4 100644 --- a/Client/Assets/Scripts/DataCenter/BuffDataCenter.cs +++ b/Client/Assets/Scripts/DataCenter/BuffDataCenter.cs @@ -16,6 +16,39 @@ public class BuffDataCenter Dictionary globalBuffDataList = new Dictionary(); FishBuffTimeData fishDailyBuff = null; public List showFishBuffIds = new List(); + + internal static game.PlayerDataStorage.PlayerKVBuffState DecodeForKVLoad(IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + game.PlayerDataStorage.PlayerKVSaveSchema.DecodeBuffs(raw,snapshot,errors); + internal void ResetForKVLoad() + { + mapBuffDataList=new Dictionary(); + targetBuffDataList=new List(); + globalBuffDataList=new Dictionary(); + showFishBuffIds=new List(); fishDailyBuff=null; + } + static FishBuffTimeData MaterializeKVBuff(game.PlayerDataStorage.PlayerKVBuffValue value) => value==null ? null : new FishBuffTimeData + { SortID=value.SortID,sourceID=value.sourceID,buffID=value.buffID,isEnd=value.isEnd,buffEndTime=value.buffEndTime,isNew=false,addDown=0 }; + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVBuffState state) + { + if(state==null) throw new ArgumentNullException(nameof(state)); + var map=new Dictionary(); var global=new Dictionary(); var target=new List(); + foreach(var item in state.Map) map.Add(item.Key,MaterializeKVBuff(item.Value)); + foreach(var item in state.Global) global.Add(item.Key,MaterializeKVBuff(item.Value)); + foreach(var item in state.Target) target.Add(MaterializeKVBuff(item)); + mapBuffDataList=map; globalBuffDataList=global; targetBuffDataList=target; fishDailyBuff=MaterializeKVBuff(state.Daily); + } +#if UNITY_EDITOR + internal FishBuffTimeData BuffForKVValidation(int kind,int key) + { + if(kind==0) return mapBuffDataList.TryGetValue(key,out var value) ? value : null; + if(kind==1) return targetBuffDataList.Count>key ? targetBuffDataList[key] : null; + if(kind==2) return fishDailyBuff; + return globalBuffDataList.TryGetValue(key,out var global) ? global : null; + } + internal bool IsEmptyForKVValidation => mapBuffDataList.Count==0 && targetBuffDataList.Count==0 && + globalBuffDataList.Count==0 && showFishBuffIds.Count==0 && fishDailyBuff==null; +#endif public void InitData(Dictionary userDatas) { if (userDatas.TryGetValue("BuffDataList", out string buffDataStr)) @@ -558,4 +591,4 @@ public class AddGlobalBuffEvent { this.buffID = buffID; } -} \ No newline at end of file +} diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.FishingEventData.cs b/Client/Assets/Scripts/DataCenter/DataCenter.FishingEventData.cs index 9290ded43..3ad2e11f0 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.FishingEventData.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.FishingEventData.cs @@ -130,6 +130,46 @@ namespace GameCore //待释放 IDisposable disposable; + internal static game.PlayerDataStorage.PlayerKVEventBaseState DecodeForKVLoad(IReadOnlyDictionary raw, + IReadOnlyDictionary modern,game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) + => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeEventBase(raw,modern,snapshot,errors); + internal void ResetForKVLoad() + { + limitedTimeEventDic=new Dictionary(); fixedTimeEventDic=new Dictionary(); + ETDropAfterDropDic=new Dictionary(); RankTargetCount=new Dictionary(); WheelLevelCount=new Dictionary(); + TurntableCoupon=_tables.TbGlobalConfig.InitWheelTicket; WheelOperateCount=0; AllWheelOperateCount=0; freecount=0; PVPToken=0; + collectingTargetEventID=-1; virtualCollectingTargetEventID=-1; NewPlayerEvent=default; + collectingTargetInit=null; collectingTargetInitTokenID=0; collectingTargetInitTaskID=0; + TargetList=null; collectingTarget=null; preCollectingTarget=null; targetItemDatas=null; preRankTarget=null; rankItemDatas=null; + rankEvent=null; rankInit=null; rankTarget=null; turntableEvent=null; wheelInit=null; wheelLevel=null; + SystemOpen=new Dictionary(); _unlocked=new HashSet(); _isTargetGuidance=true; + IsJoinUsPanelOpen=false; collectingTargetResoure=true; IsAquariumOpen=false; IsOpenDrop=false; OpenAquariumID=0; + requiredCount=0; requiredRankCount=0; curActivateItemId=0; + duelData=null; rankTierChangeState=0; curSolomain=null; Pack1A1Data=null; Pack1A2Data=null; + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVEventBaseState state) + { + if(state==null) throw new ArgumentNullException(nameof(state)); + limitedTimeEventDic=state.Limited.ToDictionary(p=>p.Key,p=>new LimitedTimeEvent { id=p.Value.Id,duration=p.Value.Duration,startTime=p.Value.StartTime }); + fixedTimeEventDic=state.Fixed.ToDictionary(p=>p.Key,p=>new FixedTimeEvent { id=p.Value.id,endTime=p.Value.endTime }); + ETDropAfterDropDic=state.Target.ToDictionary(p=>p.Key,p=>p.Value); RankTargetCount=state.Rank.ToDictionary(p=>p.Key,p=>p.Value); + WheelLevelCount=state.Wheel.ToDictionary(p=>p.Key,p=>p.Value); TurntableCoupon=state.Coupon; + WheelOperateCount=state.Operate; AllWheelOperateCount=state.AllOperate; freecount=state.Free; PVPToken=state.PvpToken; + } +#if UNITY_EDITOR + internal game.PlayerDataStorage.PlayerKVEventBaseState SnapshotForKVValidation() => new game.PlayerDataStorage.PlayerKVEventBaseState( + limitedTimeEventDic.ToDictionary(p=>p.Key,p=>new game.PlayerDataStorage.PlayerKVLimitedValue(p.Value.id,p.Value.duration,p.Value.startTime)), + fixedTimeEventDic.ToDictionary(p=>p.Key,p=>new game.PlayerDataStorage.PlayerKVFixedTimeValue(p.Value.id,p.Value.endTime)), + ETDropAfterDropDic,RankTargetCount,WheelLevelCount,TurntableCoupon,WheelOperateCount,AllWheelOperateCount,freecount,PVPToken); + internal void MutateCollectionsForKVValidation() + { limitedTimeEventDic.Values.First().duration=99; fixedTimeEventDic.Values.First().id=99; ETDropAfterDropDic.Clear(); RankTargetCount.Clear(); WheelLevelCount.Clear(); } + internal bool IsInactiveForKVValidation => collectingTargetEventID==-1 && virtualCollectingTargetEventID==-1 && NewPlayerEvent==default && + collectingTargetInit==null && collectingTargetInitTokenID==0 && collectingTargetInitTaskID==0 && TargetList==null && collectingTarget==null && + preCollectingTarget==null && targetItemDatas==null && preRankTarget==null && rankItemDatas==null && rankEvent==null && rankInit==null && rankTarget==null && + turntableEvent==null && wheelInit==null && wheelLevel==null && SystemOpen.Count==0 && _unlocked.Count==0 && duelData==null && curSolomain==null && + Pack1A1Data==null && Pack1A2Data==null && !IsJoinUsPanelOpen && !IsAquariumOpen && !IsOpenDrop && OpenAquariumID==0 && requiredCount==0 && requiredRankCount==0 && curActivateItemId==0; +#endif + public void InitData(Dictionary userDatas) { InitTurntableRoll(_tables.TbGlobalConfig.InitWheelTicket); diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.GuideData.cs b/Client/Assets/Scripts/DataCenter/DataCenter.GuideData.cs index eb463ead2..657b755de 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.GuideData.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.GuideData.cs @@ -56,6 +56,20 @@ namespace GameCore public string popupPanelNode; public GuidanceGroupDefine curGroupDefine; public GuidanceDefineSetting guidanceDefineSetting; + + internal static game.PlayerDataStorage.PlayerKVGuideState DecodeForKVLoad(IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeGuides(raw,errors); + internal void ResetForKVLoad() + { + GuideSaveDataDic = new Dictionary(StringComparer.Ordinal); + curSaveData = null; curDefine = null; curGroupDefine = null; popupPanelNode = null; CurPanelName = null; + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVGuideState state) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + GuideSaveDataDic = state.Values.ToDictionary(p=>p.Key, + p=>new GuideSaveData { guideSubIndex=p.Value.Index, isFinish=p.Value.Finished, groupName=p.Value.Group },StringComparer.Ordinal); + } //待释放 IDisposable disposable; IDisposable disposable2; @@ -101,6 +115,13 @@ namespace GameCore if (GuideSaveDataDic.TryGetValue(item.groupName.ToString(), out _saveData)) { +#if UNITY_EDITOR && !SERVICE_HOST + if (DeferUnavailablePotionGuide(item, _saveData)) + { + keyValuePairs[item.groupName.ToString()] = _saveData; + continue; + } +#endif if (!_saveData.isFinish && _saveData.guideSubIndex > 0) { for (int i = _saveData.guideSubIndex; i < item.guidanceDefines.Length; i++) @@ -153,6 +174,29 @@ namespace GameCore } } } +#if UNITY_EDITOR && !SERVICE_HOST + private static bool DeferUnavailablePotionGuide(GuidanceGroupDefine group, GuideSaveData saved) + { + if (!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled || !game.PlayerDataStorage.PlayerKVGameplayLoop.Ready || + group.groupName != GroupName.EventPotion01 || saved == null || saved.isFinish || saved.guideSubIndex <= 0 || + group.guidanceDefines == null || saved.guideSubIndex >= group.guidanceDefines.Length || + group.guidanceDefines[saved.guideSubIndex] == null || + group.guidanceDefines[saved.guideSubIndex].panelName != "EventPotionPanel") + return false; + try + { + // Existing potion activity classification; availability query does not initialize activity data. + const int potionEventType = 4; + const int potionEventSubType = 5; + return GContext.container.Resolve().GetEvent(potionEventType, potionEventSubType) == -1; + } + catch (Exception ex) + { + Debug.LogWarning("Potion guide availability query failed; retaining original guide recovery: " + ex); + return false; + } + } +#endif public bool GetIsFinish(string groupName) { return GuideSaveDataDic.ContainsKey(groupName) && GuideSaveDataDic[groupName].isFinish; diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.PlayerData.cs b/Client/Assets/Scripts/DataCenter/DataCenter.PlayerData.cs index 4e7aaa8ce..9ad8a803f 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.PlayerData.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.PlayerData.cs @@ -34,6 +34,42 @@ namespace GameCore public const string Diamond = "DiamondTrue"; Dictionary NewPlayerDataDic = new Dictionary(); public IReadOnlyDictionary PlayerDataDic => NewPlayerDataDic; + + internal static game.PlayerDataStorage.PlayerKVPlayerState DecodeForKVLoad(IReadOnlyDictionary raw, + IReadOnlyDictionary modern,game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot, + game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodePlayer(raw,modern,snapshot,errors); + internal void ResetForKVLoad() + { + NewPlayerDataDic=new Dictionary(StringComparer.Ordinal); vipScoreDic=new Dictionary(); + newRodList=new List(); + lv=vip=vipAllScore=0; vipLastScore=PriceLv=0; priceLevel=null; + energy=pearl=diamond=equipRodID=magnification=seasonId=rewardNewMap=0; + gold=0; goldRM=0; energyRM=0; _currentMapId=_lastMapId=0; + _fishingFrequency=_allFishingFrequency=0; _lastEnergyRecoveryTime=DateTime.MinValue; + IsOpenAppraise=false; InfiniteBuildingData=null; + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVPlayerState state) + { + if(state==null || state.Core==null) throw new ArgumentNullException(nameof(state)); + var modern=state.Modern.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal); + var scores=state.VipScores.ToDictionary(p=>p.Key,p=>p.Value); + var rods=new List(state.NewRods); + var core=state.Core; + lv=core.Integer(0); gold=core.Unsigned(1); vipAllScore=core.Integer(2); energy=core.Integer(3); + pearl=core.Integer(4); magnification=core.Integer(5); equipRodID=core.Integer(6); _lastEnergyRecoveryTime=core.EnergyTime; + rewardNewMap=core.Integer(8); energyRM=core.Integer(9); goldRM=core.Unsigned(10); diamond=core.Integer(11); + _currentMapId=core.Integer(12); _lastMapId=core.Integer(13); _fishingFrequency=core.Integer(14); _allFishingFrequency=core.Integer(15); + NewPlayerDataDic=modern; vipScoreDic=scores; newRodList=rods; + seasonId=state.Season; IsOpenAppraise=state.Appraise; InfiniteBuildingData=state.Building; + } +#if UNITY_EDITOR + internal game.PlayerDataStorage.PlayerKVPlayerState SnapshotForKVValidation() => + new game.PlayerDataStorage.PlayerKVPlayerState(new game.PlayerDataStorage.PlayerKVCoreState(new object[] { + (int)lv,(ulong)gold,(int)vipAllScore,(int)energy,(int)pearl,(int)magnification,(int)equipRodID,_lastEnergyRecoveryTime, + (int)rewardNewMap,energyRM,goldRM,(int)diamond,(int)_currentMapId,(int)_lastMapId,_fishingFrequency,_allFishingFrequency,0,0,0 + }),NewPlayerDataDic,vipScoreDic,newRodList,seasonId,IsOpenAppraise,InfiniteBuildingData); + internal bool IsDerivedEmptyForKVValidation => (int)vip==0 && vipLastScore==0 && PriceLv==0 && priceLevel==null; +#endif public ObscuredInt lv { get; set; } public ObscuredInt vip { get; private set; } public ObscuredInt vipAllScore { get; private set; } @@ -1103,6 +1139,57 @@ namespace GameCore public void SavePlayerData(bool remote = true) { GContext.Publish(this); + SerializeAndSavePlayerData(remote); + } + +#if UNITY_EDITOR && !SERVICE_HOST + internal async System.Threading.Tasks.Task ConsumeEnergyForKVLoopAsync(int cost, + game.PlayerDataStorage.PlayerKVGameplayLoop.FishingAction action) + { + if(cost<=0 || Energy!r.Remote)}); + await game.PlayerDataStorage.PlayerKVGameplayLoop.ConfirmFishingAsync(action,batch,primary); + } + catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); throw; } + } + internal async System.Threading.Tasks.Task SetEquipRodIDForKVLoopAsync(int id) + { + var batch=game.PlayerDataStorage.PlayerKVGameplayLoop.BeginEquip(); + game.PlayerDataStorage.PlayerKVEditResult primary; + try + { + using(batch) + { + equipRodID=id; + GContext.container.Resolve().SetRedPoint(); + GContext.Publish(this); + primary=SerializeAndSavePlayerData(true); + } + await game.PlayerDataStorage.PlayerKVGameplayLoop.ConfirmEquipAsync(batch,primary); + } + catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); throw; } + } +#endif +#if UNITY_EDITOR && !SERVICE_HOST + internal game.PlayerDataStorage.PlayerKVEditResult SaveFishingRewardForKVLoop() + { + game.PlayerDataStorage.PlayerKVGameplayLoop.CheckRewardMainSave(); + return SerializeAndSavePlayerData(true); + } +#endif + private game.PlayerDataStorage.PlayerKVEditResult SerializeAndSavePlayerData(bool remote) + { StringBuilder sb = new StringBuilder(); sb.Append(lv).Append(","); sb.Append(gold).Append(","); @@ -1143,7 +1230,11 @@ namespace GameCore NewPlayerDataDic["ct"] = GContext.container.Resolve().CTSerialize(); NewPlayerDataDic["rt"] = GContext.container.Resolve().RTSerialize(); ; +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return game.PlayerDataStorage.PlayerKVGameplayLoop.Write("NewPlayerDataDic",Newtonsoft.Json.JsonConvert.SerializeObject(NewPlayerDataDic),remote); +#endif PlayFabMgr.Instance.UpdateUserDataValue("NewPlayerDataDic", Newtonsoft.Json.JsonConvert.SerializeObject(NewPlayerDataDic), remote); + return null; } public void Init() { diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.PlayerFishData.cs b/Client/Assets/Scripts/DataCenter/DataCenter.PlayerFishData.cs index 682e15c3f..a08c815f3 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.PlayerFishData.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.PlayerFishData.cs @@ -85,6 +85,46 @@ namespace GameCore public Dictionary LuckDataDic = new Dictionary(); public bool IsOpenPiercing = false; public int RodSelectedInPack = 0; + internal static game.PlayerDataStorage.PlayerKVFishState DecodeForKVLoad(IReadOnlyDictionary raw, + IReadOnlyDictionary modern,game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) + => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeFish(raw,modern,snapshot,errors); + internal void ResetForKVLoad() + { + mapFishData=new Dictionary>(); fishData=new Dictionary(); fishCardData=new Dictionary(); + mapProgress=new Dictionary(); rodData=new Dictionary(); rodLevelData=new Dictionary(); + fishSwimToMap=new Dictionary(); fishSwimToMapCount=new Dictionary(); _honorlevel=new Dictionary(); + LuckDataDic=new Dictionary(StringComparer.Ordinal); rodAccessoriesData=null; + InitBotLuck=0; InitFishLuck=0; BotCount=0; _isRedPoint_up=false; RodCanUp=false; RodCanUpLevel=false; + MapTipforUnlocked=0; IsOpenMap=false; IsOpenRod=false; IsOpenPiercing=false; RodSelectedInPack=0; + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVFishState state) + { + if(state==null) throw new ArgumentNullException(nameof(state)); + var maps=state.Maps.ToDictionary(m=>m.Key,m=>m.Value.ToDictionary(p=>p.Key,p=>new FishWeightData { + Level=p.Value.Level,MaxG=p.Value.Weight,TotalCard=p.Value.Cards,TotalNum=p.Value.Number,NewFish=p.Value.New })); + var flat=new Dictionary(); + foreach(var map in maps.Values) foreach(var p in map) flat.Add(p.Key,p.Value); + mapFishData=maps; fishData=flat; fishCardData=state.Cards.ToDictionary(p=>p.Key,p=>p.Value); + rodData=state.Rods.ToDictionary(p=>p.Key,p=>p.Value); rodLevelData=state.RodLevels.ToDictionary(p=>p.Key,p=>p.Value); + fishSwimToMap=state.Swim.ToDictionary(p=>p.Key,p=>p.Value); fishSwimToMapCount=state.SwimCount.ToDictionary(p=>p.Key,p=>p.Value); + mapProgress=state.Progress.ToDictionary(p=>p.Key,p=>p.Value); _honorlevel=state.Honor.ToDictionary(p=>p.Key,p=>p.Value); + LuckDataDic=state.Luck.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal); + var a=state.Accessories; + rodAccessoriesData=a==null ? null : new RodGSAData { GloveID=a.Glove,RodToSkin=a.RodSkin.ToDictionary(p=>p.Key,p=>p.Value), + GloveIds=new List(a.Gloves),SkinIds=new List(a.Skins),GloveNewIds=new List(a.NewGloves),SkinNewIds=new List(a.NewSkins) }; + } +#if UNITY_EDITOR + internal FishWeightData FishForKVValidation(int map,int id) => mapFishData[map][id]; + internal bool IndexesMatchForKVValidation(int map,int id,int card) => ReferenceEquals(mapFishData[map][id],fishData[id]) && fishCardData[card]==fishData[id].TotalCard; + internal bool EmptyForKVValidation => mapFishData.Count==0 && fishData.Count==0 && fishCardData.Count==0 && mapProgress.Count==0 && rodData.Count==0 && rodLevelData.Count==0 && + fishSwimToMap.Count==0 && fishSwimToMapCount.Count==0 && _honorlevel.Count==0 && LuckDataDic.Count==0 && rodAccessoriesData==null && + InitBotLuck==0 && InitFishLuck==0 && BotCount==0 && !_isRedPoint_up && !RodCanUp && !RodCanUpLevel && !IsOpenMap && !IsOpenRod && !IsOpenPiercing && MapTipforUnlocked==0 && RodSelectedInPack==0; + internal bool DictionariesMatchForKVValidation(game.PlayerDataStorage.PlayerKVFishState s) => + rodData.OrderBy(p=>p.Key).SequenceEqual(s.Rods.OrderBy(p=>p.Key)) && rodLevelData.OrderBy(p=>p.Key).SequenceEqual(s.RodLevels.OrderBy(p=>p.Key)) && + fishSwimToMap.OrderBy(p=>p.Key).SequenceEqual(s.Swim.OrderBy(p=>p.Key)) && fishSwimToMapCount.OrderBy(p=>p.Key).SequenceEqual(s.SwimCount.OrderBy(p=>p.Key)) && + mapProgress.OrderBy(p=>p.Key).SequenceEqual(s.Progress.OrderBy(p=>p.Key)) && _honorlevel.OrderBy(p=>p.Key).SequenceEqual(s.Honor.OrderBy(p=>p.Key)); +#endif + public void InitData(Dictionary userDatas) { string data_value; @@ -1154,6 +1194,14 @@ namespace GameCore SetFishData(id, fishData[id]); } +#if UNITY_EDITOR && !SERVICE_HOST + internal void SaveCaughtFishForKVLoop(int fishId) + { + if(!fishData.TryGetValue(fishId,out var data)) throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Caught fish data is missing."); + game.PlayerDataStorage.PlayerKVGameplayLoop.CheckCaughtFishSave(fishId,data.TotalNum); + SetFishData(fishId,data); + } +#endif void SetFishData(int id, FishWeightData data) { int mapID = _tables.TbFishData.GetOrDefault(id).AreaID; diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.PlayerShopData.cs b/Client/Assets/Scripts/DataCenter/DataCenter.PlayerShopData.cs index 369b10e25..b68f7d001 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.PlayerShopData.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.PlayerShopData.cs @@ -126,6 +126,44 @@ namespace GameCore Dictionary advertCount = new Dictionary(); #endregion + internal static game.PlayerDataStorage.PlayerKVShopState DecodeForKVLoad(IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) + => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeShop(raw,snapshot,errors); + internal void ResetForKVLoad() + { + shopPackDailyBuyCount=new Dictionary(StringComparer.Ordinal); shopAllPackBuyCount=new Dictionary(); + shopGiftBuyCount=new Dictionary(); buyData=new List(); advertCount=new Dictionary(); + Voucher=0; ChainPackData=null; VIPAdPackData=null; TurntableAdData=null; + _diamondShopItemRecords=new Dictionary(); _diamondShopRefreshCount=0; _diamondShopRefreshTime=default; + CouponData.ResetForKVLoad(); + } + static DailySignEvent KVDaily(game.PlayerDataStorage.PlayerKVDailyValue value) => value==null ? null : + new DailySignEvent { ID=value.Id,index=value.Index,lastID=value.LastId,time=value.Time }; + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVShopState state) + { + if(state==null) throw new ArgumentNullException(nameof(state)); + shopPackDailyBuyCount=state.Daily.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal); + shopAllPackBuyCount=state.All.ToDictionary(p=>p.Key,p=>p.Value); shopGiftBuyCount=state.Gift.ToDictionary(p=>p.Key,p=>p.Value); + advertCount=state.Advert.ToDictionary(p=>p.Key,p=>p.Value); buyData=new List(state.Buy); Voucher=state.Voucher; + ChainPackData=KVDaily(state.Chain); TurntableAdData=KVDaily(state.Turntable); + VIPAdPackData=state.Vip==null ? null : new AdvertData { ID=state.Vip.Id,index=state.Vip.Index,day=state.Vip.Day,cdTime=state.Vip.Time,isOpen=state.Vip.Open }; + _diamondShopItemRecords=state.Items.ToDictionary(p=>p.Key,p=>new ItemShopRecord(p.Value.Id,p.Value.Discount,p.Value.Bought)); + _diamondShopRefreshCount=state.RefreshCount; _diamondShopRefreshTime=state.RefreshTime; CouponData.ReplaceFromKVDecoded(state.Coupon); + } +#if UNITY_EDITOR + internal game.PlayerDataStorage.PlayerKVShopState SnapshotForKVValidation() + { + game.PlayerDataStorage.PlayerKVDailyValue Daily(DailySignEvent v) => v==null ? null : new game.PlayerDataStorage.PlayerKVDailyValue(v.ID,v.index,v.lastID,v.time); + return new game.PlayerDataStorage.PlayerKVShopState(shopPackDailyBuyCount,shopAllPackBuyCount,shopGiftBuyCount,advertCount, + _diamondShopItemRecords.ToDictionary(p=>p.Key,p=>new game.PlayerDataStorage.PlayerKVShopItemValue(p.Value.ItemID,p.Value.Discount,p.Value.hasBought)), + buyData,Voucher,_diamondShopRefreshCount,_diamondShopRefreshTime,Daily(ChainPackData),Daily(TurntableAdData), + VIPAdPackData==null ? null : new game.PlayerDataStorage.PlayerKVAdvertValue(VIPAdPackData.ID,VIPAdPackData.index,VIPAdPackData.day,VIPAdPackData.cdTime,VIPAdPackData.isOpen), + new game.PlayerDataStorage.PlayerKVCouponState(CouponData.ActivateTime,CouponData.EventId,CouponData.RedirectId,CouponData.IsBought)); + } + internal void MutateCollectionsForKVValidation() + { buyData.Clear(); advertCount.Clear(); _diamondShopItemRecords.Values.First().Discount=99; } +#endif + public void InitData(Dictionary userDatas) { if (userDatas == null || userDatas.Count < 1) diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.SignupData.cs b/Client/Assets/Scripts/DataCenter/DataCenter.SignupData.cs index 0cd68f27d..d87e23ce1 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.SignupData.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.SignupData.cs @@ -37,6 +37,19 @@ namespace GameCore MonthlySignEvent _monthlySignEvent = null; + internal static game.PlayerDataStorage.PlayerKVMonthlySignState DecodeForKVLoad(IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeMonthlySign(raw,errors); + internal void ResetForKVLoad() { _monthlySignEvent = null; } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVMonthlySignState state) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + _monthlySignEvent = !state.IsPresent ? null : new MonthlySignEvent + { + index = state.Index, + dailySignEvent = new DailySignEvent { ID=state.DailyId, index=state.DailyIndex, lastID=state.LastId, time=state.Time } + }; + } + public DailySignEvent dailySignEvent { get => _monthlySignEvent == null ? null : _monthlySignEvent.dailySignEvent; diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.SmallGame.cs b/Client/Assets/Scripts/DataCenter/DataCenter.SmallGame.cs index ccc89f410..80f92d9b5 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.SmallGame.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.SmallGame.cs @@ -47,6 +47,26 @@ namespace GameCore public int FishHuntGameCount => _fishHuntGameCount; public int BombGameCount => _bombGameCount; + internal void ResetForKVLoad() + { + _heistGameCount=_bombGameCount=_fishHuntGameCount=0; + opponentItemDatas=new List(); opponentFriendItemDatas=new List(); + opponentNotFriendItemDatas=new List(); opponentFriend=new List(); + opponentFriendDic=new Dictionary(); opponentEnemy=new Dictionary>(); + opponentItemDatasPre=new List(); opponentFriendItemDatasPre=new List(); + opponentNotFriendItemDatasPre=new List(); powerData=new Dictionary(); + opponentData=null; nextOpponent=null; curEnemyType=EnemyType.Aquarium; RobotMapId=nextRobotMapId=0; + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVCoreState state) + { + if(state==null) throw new ArgumentNullException(nameof(state)); + _heistGameCount=state.Integer(16); _bombGameCount=state.Integer(17); _fishHuntGameCount=state.Integer(18); + } + internal bool IsOpponentEmptyForKVValidation => opponentItemDatas.Count==0 && opponentFriendItemDatas.Count==0 && + opponentNotFriendItemDatas.Count==0 && opponentFriend.Count==0 && opponentFriendDic.Count==0 && opponentEnemy.Count==0 && + opponentItemDatasPre.Count==0 && opponentFriendItemDatasPre.Count==0 && opponentNotFriendItemDatasPre.Count==0 && + powerData.Count==0 && opponentData==null && nextOpponent==null && RobotMapId==0 && nextRobotMapId==0; + //public UnityEngine.Vector3 image_extra_pos { set; get; } public void SetHeistGameCount(int value) diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.TriggerPackData.cs b/Client/Assets/Scripts/DataCenter/DataCenter.TriggerPackData.cs index b3953067d..36133eee0 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.TriggerPackData.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.TriggerPackData.cs @@ -52,6 +52,29 @@ namespace GameCore //VIP 等级叠加 public Dictionary giftVIPDData = new Dictionary(); + + internal static game.PlayerDataStorage.PlayerKVTriggerState DecodeForKVLoad(IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeTriggers(raw,errors); + internal void ResetForKVLoad() + { + triggerPackList=new List(); + giftVIPDData=new Dictionary(StringComparer.Ordinal); + fixedTimeShopDic=new Dictionary(); + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVTriggerState state) + { + if(state==null) throw new ArgumentNullException(nameof(state)); + var packs=state.Packs.Select(p=>new TriggerPackBuyData { + ID=p.ID,index=p.index,lastID=p.lastID,vipLevel=p.vipLevel,mapID=p.mapID,time=p.time }).ToList(); + var gifts=state.Gifts.ToDictionary(p=>p.Key,p=>new GiftVIPDData { + oldVipLevel=p.Value.oldVipLevel,curVipLevel=p.Value.curVipLevel,timer=p.Value.timer },StringComparer.Ordinal); + var fixedValues=state.Fixed.ToDictionary(p=>p.Key,p=>new FixedTimeEvent { id=p.Value.id,endTime=p.Value.endTime }); + triggerPackList=packs; giftVIPDData=gifts; fixedTimeShopDic=fixedValues; + } +#if UNITY_EDITOR + internal FixedTimeEvent FixedForKVValidation(int key) => fixedTimeShopDic.TryGetValue(key,out var value) ? value : null; + internal bool IsEmptyForKVValidation => triggerPackList.Count==0 && giftVIPDData.Count==0 && fixedTimeShopDic.Count==0; +#endif public void Init() { playerShopData = GContext.container.Resolve(); diff --git a/Client/Assets/Scripts/DataCenter/EventPackData.cs b/Client/Assets/Scripts/DataCenter/EventPackData.cs index c2adc849e..78fdf29e2 100644 --- a/Client/Assets/Scripts/DataCenter/EventPackData.cs +++ b/Client/Assets/Scripts/DataCenter/EventPackData.cs @@ -19,6 +19,19 @@ public class EventPackData } } private Data _data; + + internal static game.PlayerDataStorage.PlayerKVEventPackState DecodeForKVLoad(System.Collections.Generic.IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + game.PlayerDataStorage.PlayerKVSaveSchema.DecodeEventPack(raw,snapshot,errors); + internal void ResetForKVLoad() { _data=default(Data); } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVEventPackState state) + { + if (state == null) throw new System.ArgumentNullException(nameof(state)); + _data=new Data { currentEventID=state.EventId,purchaseCount=state.PurchaseCount, + VIPLvlWhenEventActivate=state.VipLevel,redirectID=state.RedirectId }; + } + internal game.PlayerDataStorage.PlayerKVEventPackState SnapshotForKVValidation() => + new game.PlayerDataStorage.PlayerKVEventPackState(_data.currentEventID,_data.purchaseCount,_data.VIPLvlWhenEventActivate,_data.redirectID); public int CurrentEventID { get { return _data.currentEventID; } } public int PurchaseCount { get { return _data.purchaseCount; } } public int VIPLvlWhenEventActivated { get { return _data.VIPLvlWhenEventActivate; } } diff --git a/Client/Assets/Scripts/DataCenter/EventPartnerGatherManager.cs b/Client/Assets/Scripts/DataCenter/EventPartnerGatherManager.cs index a4649f7f8..3ba5697d3 100644 --- a/Client/Assets/Scripts/DataCenter/EventPartnerGatherManager.cs +++ b/Client/Assets/Scripts/DataCenter/EventPartnerGatherManager.cs @@ -102,6 +102,51 @@ namespace DataCenter { private const bool EnableTraceLogs = false; public const string SaveKey = "EventPartnerGatherData"; + internal static game.PlayerDataStorage.PlayerKVPartnerState DecodeForKVLoad( + IReadOnlyDictionary raw,game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot, + game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodePartner(raw,snapshot,errors); + + internal void ResetForKVLoad() + { + // Only a fresh controlled container is admitted. Never detach active gameplay work. + if(_isInit || _act!=null || _disposables!=null || _autoMatchStatusInFlight!=null || PartnerInfoManager.HasSubscriptionsForKVLoad) + throw new InvalidOperationException("Active partner module cannot enter KV load."); + var partner=new PartnerInfoManager(); + var robot=new EventPartnerGatherRobotInvitationSystem(); + GatherData=null; _isEventDataChanged=false; CurrentBuildPartner=null; + PartnerInfoManager=partner; RobotInvitationSystem=robot; + _fishingEvent=null; EventPartnerMineMainData=null; EventPartnerMineSpinPointList=null; EventPartnerMineInitData=null; + _nodeCumulativeSteps.Clear(); _nodeSteps.Clear(); _stageDropList.Clear(); StartTime=default; EndTime=default; + _isInit=false; _spinId=-1; _userGm=false; BonusPercent=0; BonusExpireDuration=0; + _dateTimeForGetEvent=default; startTime=0; delta=0; _lastAutoMatchStatusUtc=default; _cachedAutoMatchStatus=null; + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVPartnerState state) + { + if(state==null) throw new ArgumentNullException(nameof(state)); + if(!state.Present) { GatherData=null; return; } + GatherData=new EventPartnerGatherData + { + FirstOpen=state.FirstOpen,EventId=state.EventId,StartTime=state.StartTime,EndTime=state.EndTime, + CurrentPoints=state.CurrentPoints,CurrentMiningProgress=state.CurrentMiningProgress,Token=state.Token, + InvitedCount=state.InvitedCount,TicketCount=state.TicketCount, + CompletedNodesList=state.CompletedNodesList.Select(n=>new List(n)).ToList(), + BuilderPartnerScore=new List<(int,int)>(state.BuilderPartnerScore),PartnerBonus=(state.BonusPercent,state.BonusExpireUtc), + FreeSpinNum=state.FreeSpinNum,Bots=state.Bots.ToDictionary(p=>p.Key,p=>new EventBuildBotInfo + { Id=p.Value.Id,ScorePlayer=p.Value.ScorePlayer,ScoreBot=p.Value.ScoreBot,Grade=p.Value.Grade, + TargetScore=p.Value.TargetScore,WakeStartTime=p.Value.WakeStartTime,WakingSeconds=p.Value.WakingSeconds }), + IsRecvFinalReward=state.IsRecvFinalReward,UIFinishedPartners=new List(state.UIFinishedPartners), + IsFinished=state.IsFinished,SpinState=(ESpinState)state.SpinState,InflationRate=state.InflationRate, + IsEnableAutoMatch=state.IsEnableAutoMatch,LastAutoMatchTime=state.LastAutoMatchTime + }; + } +#if UNITY_EDITOR + internal bool RuntimeEmptyForKVValidation => !_isInit && !_isEventDataChanged && _act==null && _disposables==null && + _autoMatchStatusInFlight==null && _cachedAutoMatchStatus==null && _fishingEvent==null && CurrentBuildPartner==null && + EventPartnerMineMainData==null && EventPartnerMineSpinPointList==null && EventPartnerMineInitData==null && + _nodeCumulativeSteps.Count==0 && _nodeSteps.Count==0 && _stageDropList.Count==0 && StartTime==default && EndTime==default && + _spinId==-1 && !_userGm && BonusPercent==0 && BonusExpireDuration==0 && _dateTimeForGetEvent==default && + startTime==0 && delta==0 && _lastAutoMatchStatusUtc==default && !PartnerInfoManager.HasSubscriptionsForKVLoad; +#endif [Inject] public Tables _tables { get; set; } [Inject] public EventPartnerService EventPartnerService { get; set; } diff --git a/Client/Assets/Scripts/DataCenter/EventPinballDataManager.cs b/Client/Assets/Scripts/DataCenter/EventPinballDataManager.cs index 9b6039187..510ec9b71 100644 --- a/Client/Assets/Scripts/DataCenter/EventPinballDataManager.cs +++ b/Client/Assets/Scripts/DataCenter/EventPinballDataManager.cs @@ -449,6 +449,38 @@ namespace game #region 数据相关 private EventPinballData m_PinballData; + internal bool IsInactiveForKVValidation => m_Tables==null && m_TbEventPinballMain==null && m_TbEventPinballPoint==null && + m_TbEventPinballReward==null && m_UIPanel==null && m_InfoPanel==null && m_RewardList.Count==0 && + m_PointWeightList.Count==0 && !m_IsSimulatorMode && !m_IsMultipleMax && m_CumulativeResponseNumber==0 && + m_AccumulatedTriggerCount==0 && m_TemporaryScore==0 && m_TemporaryConsumedToken==0 && m_MagnificationListIndex==0 && m_BonusResponseNumber==10; + + internal static game.PlayerDataStorage.PlayerKVPinballState DecodeForKVLoad(IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + game.PlayerDataStorage.PlayerKVSaveSchema.DecodePinball(raw,snapshot,errors); + internal void ResetForKVLoad() + { + m_PinballData=null; m_BonusResponseNumber=10; m_CumulativeResponseNumber=m_AccumulatedTriggerCount=m_TemporaryScore=m_TemporaryConsumedToken=m_MagnificationListIndex=0; + m_IsMultipleMax=false; m_IsSimulatorMode=false; m_RewardList=new List(); m_PointWeightList=new List(); m_UIPanel=m_InfoPanel=null; + m_Tables=null; m_TbEventPinballMain=null; m_TbEventPinballPoint=null; m_TbEventPinballReward=null; + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVPinballState state) + { + if(state==null) throw new ArgumentNullException(nameof(state)); + m_PinballData=!state.IsPresent ? null : new EventPinballData + { + EventID=state.EventID, + PinballMainID=state.PinballMainID, + SpinPoint=state.SpinPoint, + Scores=state.Scores, + Multiple=state.Multiple, + Token=state.Token, + ConsumedToken=state.ConsumedToken, + RewardIndex=state.RewardIndex, + Force=state.Force, + LuckyValue=state.LuckyValue, + InflationRate=state.InflationRate + }; + } public EventPinballData PinballData { get { return m_PinballData; } } public int RewardMaxIndex { @@ -957,4 +989,4 @@ namespace game public const int MultipleMax = 10; } -} \ No newline at end of file +} diff --git a/Client/Assets/Scripts/DataCenter/EventPotionDataManager.cs b/Client/Assets/Scripts/DataCenter/EventPotionDataManager.cs index 7fdcf28d2..21d1f61c9 100644 --- a/Client/Assets/Scripts/DataCenter/EventPotionDataManager.cs +++ b/Client/Assets/Scripts/DataCenter/EventPotionDataManager.cs @@ -125,6 +125,55 @@ namespace game #region 数据相关 private EventPotionData m_PotionData; + + internal static PlayerDataStorage.PlayerKVPotionState DecodeForKVLoad(IReadOnlyDictionary raw, + PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => PlayerDataStorage.PlayerKVSaveSchema.DecodePotion(raw,errors); + internal void ResetForKVLoad() + { + m_PotionData=null; m_PendingFirstPlayedEventId=0; m_SpriteAtlas=null; m_Tables=null; + m_TbEventPotionMain=null; m_TbEventPotionStage=null; m_TbEventPotionBlock=null; m_TbEventPotionBottle=null; + m_PotionResDict=new Dictionary(); m_WallsBefore=new List(); m_WallsAfter=new List(); + m_UIPanel=m_InfoPanel=m_RewardPopupPanel=null; m_ScrollerDataList=new List(); + } + static PotionItemData MaterializeKVPotionItem(PlayerDataStorage.PlayerKVPotionItem value) => new PotionItemData { + name=value.Name,index=value.Index,isOver=value.IsOver,DataType=(EventPotionDataType)value.DataType, + ColorTypeList=new List(value.Colors),ShelfTypeList=value.Shelves.Select(n=>(EventPotionShelfType)n).ToList(), + GridStateList=value.Grids.Select(n=>(EventPotionGridState)n).ToList(),FromDisplayAreaIndexList=new List(value.FromDisplay) + }; + internal void ReplaceFromKVDecoded(PlayerDataStorage.PlayerKVPotionState state) + { + if(state==null) throw new ArgumentNullException(nameof(state)); + m_PotionData=!state.IsPresent ? null : new EventPotionData { + EventID=state.EventID, + PotionID=state.PotionID, + StageID=state.StageID, + Stage1TotalLevel=state.Stage1TotalLevel, + Stage2TotalLevel=state.Stage2TotalLevel, + LevelID=state.LevelID, + LevelIndex=state.LevelIndex, + CorrectNum=state.CorrectNum, + SubmitNum=state.SubmitNum, + Token=state.Token, + ConsumedToken=state.ConsumedToken, + Rule=state.Rule, + PreEventID=state.PreEventID, + PrePotionID=state.PrePotionID, + PreStageID=state.PreStageID, + PreLevelID=state.PreLevelID, + Round=state.Round, + FirstPlayedEventId=state.FirstPlayedEventId, + IsOver=state.IsOver, + Name=state.Name, + InflationRate=state.InflationRate, + LevelTargetData=MaterializeKVPotionItem(state.LevelTargetData), + LevelDisplayData=MaterializeKVPotionItem(state.LevelDisplayData), + LevelProcessedList=state.LevelProcessedList.Select(MaterializeKVPotionItem).ToList() + }; + } + internal bool IsInactiveForKVValidation => m_PendingFirstPlayedEventId==0 && m_SpriteAtlas==null && m_Tables==null && + m_TbEventPotionMain==null && m_TbEventPotionStage==null && m_TbEventPotionBlock==null && m_TbEventPotionBottle==null && + m_PotionResDict.Count==0 && m_WallsBefore.Count==0 && m_WallsAfter.Count==0 && m_ScrollerDataList.Count==0 && + m_UIPanel==null && m_InfoPanel==null && m_RewardPopupPanel==null; public EventPotionData PotionData { get { return m_PotionData; } } public void InitLevel() diff --git a/Client/Assets/Scripts/DataCenter/EventScratchTicketDataManager.cs b/Client/Assets/Scripts/DataCenter/EventScratchTicketDataManager.cs index 563ab70f5..d4bd33e31 100644 --- a/Client/Assets/Scripts/DataCenter/EventScratchTicketDataManager.cs +++ b/Client/Assets/Scripts/DataCenter/EventScratchTicketDataManager.cs @@ -94,6 +94,45 @@ namespace game #region 数据相关 private EventScratchTicketData m_ScratchTicketData; + internal bool IsInactiveForKVValidation => m_Tables==null && m_TbEventScratchMain==null && m_TbEventScratchCard==null && + m_TbEventScratchReward==null && m_ChainPackData==null && m_NormalPackData==null && m_SpriteAtlas==null && m_SpriteDict==null && + m_UIPanel==null && m_InfoPanel==null && m_RewardPanel==null && m_ChainPackPanel==null && m_PackPanel==null && + m_RecordList.Count==0 && m_PointWeightList.Count==0 && m_ScratchStampList.Count==0 && + TempRecordRewardTaskID==0 && TempRecordRewardTaskConsumedToken==0; + + internal static game.PlayerDataStorage.PlayerKVScratchState DecodeForKVLoad(IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + game.PlayerDataStorage.PlayerKVSaveSchema.DecodeScratch(raw,snapshot,errors); + internal void ResetForKVLoad() + { + m_ScratchTicketData=null; TempRecordRewardTaskID=TempRecordRewardTaskConsumedToken=0; m_RecordList=new List(); + m_PointWeightList=new List(); m_ScratchStampList=new List(); m_ChainPackData=null; m_NormalPackData=null; + m_UIPanel=m_InfoPanel=m_RewardPanel=m_ChainPackPanel=m_PackPanel=null; m_SpriteAtlas=null; m_SpriteDict=null; + m_Tables=null; m_TbEventScratchMain=null; m_TbEventScratchCard=null; m_TbEventScratchReward=null; + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVScratchState state) + { + if(state==null) throw new ArgumentNullException(nameof(state)); + m_ScratchTicketData=!state.IsPresent ? null : new EventScratchTicketData + { + EventID=state.EventID, + ScratchMainID=state.ScratchMainID, + ChainPackID=state.ChainPackID, + Token=state.Token, + ConsumedToken=state.ConsumedToken, + RewardTaskConsumedToken=state.RewardTaskConsumedToken, + EventScratchRewardTaskID=state.EventScratchRewardTaskID, + EventScratchCardID=state.EventScratchCardID, + TotalConsumedToken=state.TotalConsumedToken, + ChainPackProgress=state.ChainPackProgress, + magIndex=state.magIndex, + IsOver=state.IsOver, + LuckyValue=state.LuckyValue, + InflationRate=state.InflationRate, + StampRewardList=new List(state.StampRewardList), + StampRewardDict=state.StampRewardDict.ToDictionary(p=>p.Key,p=>new EventScratchTicketOwnedStampRewardData { EventScratchCardID=p.Value.EventScratchCardID,Count=p.Value.Count }) + }; + } public EventScratchTicketData ScratchTicketData { get { return m_ScratchTicketData; } } public EventScratchMain GetFreshScratchTicketData() { diff --git a/Client/Assets/Scripts/DataCenter/FishingBoxData.cs b/Client/Assets/Scripts/DataCenter/FishingBoxData.cs index 3e44334f0..3d6af2e18 100644 --- a/Client/Assets/Scripts/DataCenter/FishingBoxData.cs +++ b/Client/Assets/Scripts/DataCenter/FishingBoxData.cs @@ -13,6 +13,20 @@ public class FishingBoxData public Dictionary FishingNewBoxs = new(); public Dictionary FishingBoxOpenNum = new(); + internal static game.PlayerDataStorage.PlayerKVIntMapState DecodeForKVLoad(IReadOnlyDictionary raw, + IReadOnlyDictionary modern, game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + game.PlayerDataStorage.PlayerKVSaveSchema.DecodeFishingBox(raw,modern,errors); + internal void ResetForKVLoad() + { + FishingNewBoxs = new Dictionary(); + FishingBoxOpenNum = new Dictionary(); + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVIntMapState state) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + FishingBoxOpenNum = state.Values.ToDictionary(p=>p.Key,p=>p.Value); + } + public void InitData(string data) { if (string.IsNullOrEmpty(data) == false) diff --git a/Client/Assets/Scripts/DataCenter/FishingChallengeManager.cs b/Client/Assets/Scripts/DataCenter/FishingChallengeManager.cs index 53225ee7a..cbcbb3847 100644 --- a/Client/Assets/Scripts/DataCenter/FishingChallengeManager.cs +++ b/Client/Assets/Scripts/DataCenter/FishingChallengeManager.cs @@ -61,6 +61,40 @@ public class FishingChallengeManager private DateTime EndTime; // public FishingChallengeData fishingChallengeData { get; private set; } + internal bool IsInactiveForKVValidation => !IsInit && CurFishingEventData==null && eventChallengeMain==null && + eventChallengeConfig==null && challengeMapData==null && _stageDropList.Count==0 && _eliminateListScore.Count==0; + + internal static game.PlayerDataStorage.PlayerKVChallengeState DecodeForKVLoad(IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + game.PlayerDataStorage.PlayerKVSaveSchema.DecodeChallenge(raw,snapshot,errors); + internal void ResetForKVLoad() + { + fishingChallengeData=null; IsInit=false; PrefabName=null; TopRankTier=Rank=FullStep=PlayerFullNumber=PreScore=_cheatingScore=0; + CurFishingEventData=null; StartTime=EndTime=default; eventChallengeMain=null; eventChallengeConfig=null; challengeMapData=null; itemDataList=null; + _stageDropList.Clear(); _eliminateListScore.Clear(); _eventTrackStageList=new List(); _eventTrackNormalRewardList=new List(); InVisibleReason=string.Empty; + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVChallengeState state) + { + if(state==null) throw new ArgumentNullException(nameof(state)); + fishingChallengeData=!state.IsPresent || state.IsExplicitNull ? null : new FishingChallengeData + { + FirstOpen=state.FirstOpen, + EventId=state.EventId, + RankTierLevel=state.RankTierLevel, + Finished=state.Finished, + RedirectID=state.RedirectID, + Score=state.Score, + ProcessedScore=state.ProcessedScore, + step=state.step, + uiStep=state.uiStep, + index=state.index, + StartTime=state.StartTime, + EndTime=state.EndTime, + eliminateNumber=new List(state.eliminateNumber), + robotAccountList=new List(state.robotAccountList), + InflationRate=state.InflationRate + }; + } public EventChallengeMain eventChallengeMain { get; private set; } public EventChallengeConfig eventChallengeConfig { get; private set; } public MapData challengeMapData { get; private set; } @@ -1071,4 +1105,4 @@ public class FishingChallengeManager _stageDropList.Clear(); _eventTrackStageList = new(); } -} \ No newline at end of file +} diff --git a/Client/Assets/Scripts/DataCenter/OfferChainsChestManager.cs b/Client/Assets/Scripts/DataCenter/OfferChainsChestManager.cs index bb388d30a..f313b3ba4 100644 --- a/Client/Assets/Scripts/DataCenter/OfferChainsChestManager.cs +++ b/Client/Assets/Scripts/DataCenter/OfferChainsChestManager.cs @@ -51,6 +51,28 @@ namespace DataCenter public List OfferChainsChestRewards { get; set; } // 当前可保存数据 public TOfferChainsData OfferChainsData { get; private set; } + + internal static game.PlayerDataStorage.PlayerKVOfferState DecodeForKVLoad(IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + game.PlayerDataStorage.PlayerKVSaveSchema.DecodeOffer(raw,false,snapshot,errors); + internal void ResetForKVLoad() + { + OfferChainsData=null; ValidIds=new List(); _fishingEvent=null; _isInit=false; + OfferChainsChestMain=null; OfferChainsChestRewards=null; + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVOfferState state) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + OfferChainsData=!state.IsPresent ? null : new TOfferChainsData + { + FirstOpen=state.FirstOpen,EventId=state.EventId,StartTime=state.StartTime,EndTime=state.EndTime, + ItemId=state.ItemId,LotteryNum=state.LotteryNum, + Items=state.Items.Select(item=>new TOfferChainsData.TItem { + Id=item.Id,Item=item.Item,Number=item.Number,Status=item.Status }).ToList(), + RewardList=new List(state.RewardList),Finished=state.Finished,ChestReward=state.ChestReward + }; + } + internal bool IsInactiveForKVValidation => !_isInit && _fishingEvent == null; public List ValidIds; public bool IsFirstOpen @@ -546,4 +568,4 @@ namespace DataCenter return iAPItemList; } } -} \ No newline at end of file +} diff --git a/Client/Assets/Scripts/DataCenter/OfferDiscoPartyManager.cs b/Client/Assets/Scripts/DataCenter/OfferDiscoPartyManager.cs index d551a042d..95470678a 100644 --- a/Client/Assets/Scripts/DataCenter/OfferDiscoPartyManager.cs +++ b/Client/Assets/Scripts/DataCenter/OfferDiscoPartyManager.cs @@ -61,6 +61,28 @@ namespace DataCenter public TOfferDiscoPartyData OfferDiscoPartyData { get; private set; } + internal static game.PlayerDataStorage.PlayerKVOfferState DecodeForKVLoad(IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + game.PlayerDataStorage.PlayerKVSaveSchema.DecodeOffer(raw,true,snapshot,errors); + internal void ResetForKVLoad() + { + OfferDiscoPartyData=null; ValidIds=new List(); _fishingEvent=null; _isInit=false; + OfferDiscoParty=null; OfferDiscoPartyRewards=null; FreeTime=1; + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVOfferState state) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + OfferDiscoPartyData=!state.IsPresent ? null : new TOfferDiscoPartyData + { + FirstOpen=state.FirstOpen,EventId=state.EventId,StartTime=state.StartTime,EndTime=state.EndTime, + ItemId=state.ItemId,LotteryNum=state.LotteryNum, + Items=state.Items.Select(item=>new TOfferDiscoPartyData.TItem { + Id=item.Id,Item=item.Item,Number=item.Number,Status=(TItemStatues) item.Status }).ToList(), + RewardList=new List(state.RewardList) + }; + } + internal bool IsInactiveForKVValidation => !_isInit && _fishingEvent == null; + public List ValidIds; // @@ -561,4 +583,4 @@ namespace DataCenter return iAPItemList; } } -} \ No newline at end of file +} diff --git a/Client/Assets/Scripts/DataCenter/PiggyBankPackData.cs b/Client/Assets/Scripts/DataCenter/PiggyBankPackData.cs index 907894646..1e48cd6d5 100644 --- a/Client/Assets/Scripts/DataCenter/PiggyBankPackData.cs +++ b/Client/Assets/Scripts/DataCenter/PiggyBankPackData.cs @@ -34,14 +34,47 @@ public class PiggyBankPackData } } private Data _data; + + private bool _kvLoadPresent; + internal bool KVLoadPresent => _kvLoadPresent; + internal static game.PlayerDataStorage.PlayerKVPiggyBankState DecodeForKVLoad( + IReadOnlyDictionary raw, game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot, + game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + game.PlayerDataStorage.PlayerKVSaveSchema.DecodePiggyBank(raw, snapshot, errors); + + internal void ResetForKVLoad() + { + _kvLoadPresent = false; + _data = default(Data); + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVPiggyBankState state) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + if (!state.IsPresent) { ResetForKVLoad(); return; } + var replacement = new Data + { + currentEventID = state.currentEventID, + purchaseCount = state.purchaseCount, + VIPLvlWhenEventActivate = state.VIPLvlWhenEventActivate, + savingProgress = state.savingProgress, + redirectID = state.redirectID, + doNeedTrigger = state.doNeedTrigger, + isTriggered = state.isTriggered, + inflation = state.inflation, + }; + _data = replacement; + _kvLoadPresent = true; + } + internal game.PlayerDataStorage.PlayerKVPiggyBankState SnapshotForKVValidation() => + new game.PlayerDataStorage.PlayerKVPiggyBankState(_kvLoadPresent, _data.currentEventID, _data.purchaseCount, _data.VIPLvlWhenEventActivate, _data.savingProgress, _data.redirectID, _data.doNeedTrigger, _data.isTriggered, _data.inflation); public bool IsWithinEventTime { get { - if (!_tables.TbFishingEvent.DataMap.Keys.Contains(_data.currentEventID)) - return false; - var et = DateTime.Parse((_tables.TbFishingEvent[_data.currentEventID].TimeDefinition as LimitedTime).EndTime); - var st = DateTime.Parse((_tables.TbFishingEvent[_data.currentEventID].TimeDefinition as LimitedTime).StartTime); + var fishingEvent = _tables.TbFishingEvent.GetOrDefault(_data.currentEventID); + if (!(fishingEvent?.TimeDefinition is LimitedTime time)) return false; + var et = DateTime.Parse(time.EndTime); + var st = DateTime.Parse(time.StartTime); return ZZTimeHelper.UtcNow() >= st && ZZTimeHelper.UtcNow() < et; } } diff --git a/Client/Assets/Scripts/EventCubeMelt/Data/EventCubeMeltManager.cs b/Client/Assets/Scripts/EventCubeMelt/Data/EventCubeMeltManager.cs index 3f60644bd..bd192258e 100644 --- a/Client/Assets/Scripts/EventCubeMelt/Data/EventCubeMeltManager.cs +++ b/Client/Assets/Scripts/EventCubeMelt/Data/EventCubeMeltManager.cs @@ -168,6 +168,34 @@ public class EventCubeMeltManager [Inject] public Tables _tables { get; set; } public EventCubeMeltData EventData { get; private set; } + + internal static game.PlayerDataStorage.PlayerKVCubeState DecodeForKVLoad(IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + game.PlayerDataStorage.PlayerKVSaveSchema.DecodeCube(raw,snapshot,errors); + internal void ResetForKVLoad() + { + EventData=null; _isInit=false; _fishingEvent=null; + EventCubeMeltMainData=null; EventCubeMeltConfig=null; EventCubeMeltStageData=null; EventCubeMeltCollectionData=null; + EventCubeMeltPieces=null; EventCubeMeltPieceItems=new List(); EventCubeMeltItems=null; EventCubeMeltCollections=null; + EventStageList=null; StageCount=0; StartTime=EndTime=default; CurrentPieceItems=new List(); _pieceDimension=default; + PrevData=null; NextData=null; PrevBoardData=null; PrevPieceDatas=null; CurrentBoardData=null; CurrentPieceDatas=null; GuideIndex=0; + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVCubeState state) + { + if(state==null) throw new ArgumentNullException(nameof(state)); + EventData=!state.IsPresent ? null : new EventCubeMeltData { + TicketCount=state.TicketCount,FirstOpen=state.FirstOpen,IsFinished=state.IsFinished,EventId=state.EventId, + IsInGuide=state.IsInGuide,StartTime=state.StartTime,EndTime=state.EndTime,CurrentStagetId=state.CurrentStagetId, + CollectItems=state.CollectItems.Select(p=>new CollectItem { ItemId=p.ItemId,Count=p.Count,MaxCount=p.MaxCount,ItemTrueId=p.ItemTrueId }).ToList(), + BoardData=new List(state.BoardData),BoardViewSize=new Vector2Int(state.Width,state.Height), + CollectRewardsId=new List(state.CollectRewardsId),IsFinalRewardGet=state.IsFinalRewardGet, + PieceDatas=state.PieceDatas.Select(p=>new List(p)).ToList(),Cycle=state.Cycle,InflationRate=state.InflationRate + }; + } + internal bool IsInactiveForKVValidation => !_isInit && _fishingEvent==null && EventCubeMeltMainData==null && + EventCubeMeltStageData==null && EventCubeMeltCollectionData==null && EventCubeMeltPieces==null && + EventCubeMeltPieceItems.Count==0 && CurrentPieceItems.Count==0 && PrevData==null && NextData==null && + PrevBoardData==null && CurrentBoardData==null && PrevPieceDatas==null && CurrentPieceDatas==null; public FishingEvent FishingEvent {get => _fishingEvent;set => _fishingEvent = value;} @@ -1711,4 +1739,4 @@ public class EventCubeMeltManager var cubeMeltData = _tables.TbEventCubeMeltCollection.DataList.FirstOrDefault(data => data.ID == stageId); return cubeMeltData?.CollectingReward?? 0; } -} \ No newline at end of file +} diff --git a/Client/Assets/Scripts/EventPartnerGather/PartnerInfoManager.cs b/Client/Assets/Scripts/EventPartnerGather/PartnerInfoManager.cs index 5d4cda949..4cafb9d4c 100644 --- a/Client/Assets/Scripts/EventPartnerGather/PartnerInfoManager.cs +++ b/Client/Assets/Scripts/EventPartnerGather/PartnerInfoManager.cs @@ -24,7 +24,7 @@ namespace EventPartnerGather public class PartnerInfoManager : IDisposable { // [Inject] - private static Tables _tables { get; set; } = GContext.container.Resolve(); + private readonly Tables _tables = GContext.container.Resolve(); private FriendService _friendService { get; set; } = GContext.container.Resolve(); private IUserService _userService { get; } = GContext.container.Resolve(); @@ -41,9 +41,15 @@ namespace EventPartnerGather public int EventId { get; set; } // - public static readonly int BuilderCapacity = _tables.TbEventPartnerConfig.InvitationCount[1]; + // D4: current-context read. Unlike the old field, this is not frozen across configuration changes. + public static int BuilderCapacity => GContext.container.Resolve().TbEventPartnerConfig.InvitationCount[1]; - public readonly BuildPartner[] BuildPartners = new BuildPartner[BuilderCapacity]; + public readonly BuildPartner[] BuildPartners; + public PartnerInfoManager() + { + BuildPartners = new BuildPartner[_tables.TbEventPartnerConfig.InvitationCount[1]]; + } + internal bool HasSubscriptionsForKVLoad => disposables != null; // public bool IsFullyMatched => BuildPartners.All(b => b != null && b.IsValid()); @@ -387,9 +393,9 @@ namespace EventPartnerGather public bool AddBuildPartner(BuildPartner buildPartner) { var index = buildPartner.SlotId; - if (index >= BuilderCapacity) + if (index >= BuildPartners.Length) { - LogWarning($"AddBuildPartnerFailed eventId={EventId} reason=SlotOutOfRange slotId={index} capacity={BuilderCapacity}"); + LogWarning($"AddBuildPartnerFailed eventId={EventId} reason=SlotOutOfRange slotId={index} capacity={BuildPartners.Length}"); return false; } @@ -475,4 +481,4 @@ namespace EventPartnerGather } -} \ No newline at end of file +} diff --git a/Client/Assets/Scripts/GampPlay/FishingB/FishingBehaviorB.cs b/Client/Assets/Scripts/GampPlay/FishingB/FishingBehaviorB.cs index ebe61b8ba..8e4a66bcc 100644 --- a/Client/Assets/Scripts/GampPlay/FishingB/FishingBehaviorB.cs +++ b/Client/Assets/Scripts/GampPlay/FishingB/FishingBehaviorB.cs @@ -6,6 +6,7 @@ using GameCore; using System; using System.Collections; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using UniRx; using UnityEngine; @@ -23,6 +24,52 @@ public class ShowHandEvent [DefaultExecutionOrder(10000)] public partial class FishingBehaviorB : MonoBehaviour { +#if UNITY_EDITOR && !SERVICE_HOST + game.PlayerDataStorage.PlayerKVGameplayLoop.FishingAction kvFishingAction; + bool KVFishingCurrent() + { + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return true; + return this!=null && isActiveAndEnabled && game.PlayerDataStorage.PlayerKVGameplayLoop.FishingCurrent(kvFishingAction); + } + void CancelKVFishing(string reason) => game.PlayerDataStorage.PlayerKVGameplayLoop.CancelFishing(kvFishingAction,reason); + void TrackKVCastTween(Tween tween) + { + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return; + var action=kvFishingAction; + bool completed=false; + tween.onComplete+=()=>completed=true; + tween.onKill+=()=> { if(!completed) game.PlayerDataStorage.PlayerKVGameplayLoop.CancelFishing(action,"cast tween killed"); }; + } + async Task ConsumeBaitOnKVAsync() + { + try + { + if(!KVFishingCurrent()) return; + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.ClaimFishingConsumption(kvFishingAction)) return; + if(fishingData.fishItem==null || !fishingData.IsGeneralFish() || fishingData.IsFree || fishingData.IsRetry || + fishingData.IsDuel || fishingData.IsPackItemFish() || fishingData.curFishData==null) + throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Only one ordinary paid fishing action is permitted. fishItem.ID="+(fishingData.fishItem==null?"null":fishingData.fishItem.ID.ToString())+", fishItem.SubType="+(fishingData.fishItem==null?"null":fishingData.fishItem.SubType.ToString())+", curFishData.ID="+(fishingData.curFishData==null?"null":fishingData.curFishData.ID.ToString())+", IsFree="+fishingData.IsFree+", IsRetry="+fishingData.IsRetry+", IsDuel="+fishingData.IsDuel); + if(Application.internetReachability==NetworkReachability.NotReachable) + throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Fishing stopped before consumption: offline."); + if(_tables.TbFishingEvent.DataList.Any(e=>e.ConditionList.Any(c=>c.Condition==ConditionType.ConsumeEnergy))) + throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("ConsumeEnergy activity activation requires separate review."); + var player=GContext.container.Resolve(); + int mag=player.magnification; + if(mag<0 || mag>=_tables.TbEnergyDef.DataList.Count) + throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Invalid fishing magnification."); + int fish=fishingData.curFishData.ID, rod=fishingData.RodId, map=player.lastMapId; + int cost=_tables.TbEnergyDef.DataList[mag].Mag; + game.PlayerDataStorage.PlayerKVGameplayLoop.PrepareFishingReward(kvFishingAction,fishingData); + await player.ConsumeEnergyForKVLoopAsync(cost,kvFishingAction); + if(!KVFishingCurrent()) return; + if(fishingData.curFishData==null || fishingData.curFishData.ID!=fish || fishingData.RodId!=rod || + player.lastMapId!=map || (int)player.magnification!=mag || fishingData.IsFree || fishingData.IsRetry || fishingData.IsDuel) + throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Fishing context changed during confirmation."); + game.PlayerDataStorage.PlayerKVGameplayLoop.ContinueFishing(kvFishingAction,WaitForFishBiteED); + } + catch(Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); } + } +#endif Tables _tables; [Header("RELATED OBJECT SETTINGS")] [SerializeField] @@ -213,7 +260,13 @@ public partial class FishingBehaviorB : MonoBehaviour { Addressables.ReleaseInstance(handAB); } - handAB = await Addressables.InstantiateAsync(gloveData.Fbx, transform).Task; + var loadedHand = await Addressables.InstantiateAsync(gloveData.Fbx, transform).Task; + if (this == null) + { + if (loadedHand != null) Addressables.ReleaseInstance(loadedHand); + return; + } + handAB = loadedHand; if (handAB != null) { HandController = handAB.GetComponent(); @@ -225,7 +278,13 @@ public partial class FishingBehaviorB : MonoBehaviour } async void LoadBait() { - baitAB = await Addressables.InstantiateAsync("BaitRoot", _bait.transform).Task; + var loadedBait = await Addressables.InstantiateAsync("BaitRoot", _bait.transform).Task; + if (this == null) + { + if (loadedBait != null) Addressables.ReleaseInstance(loadedBait); + return; + } + baitAB = loadedBait; baitAB.transform.localPosition = Vector3.zero; baitAB.transform.localRotation = Quaternion.identity; baitAB.transform.localScale = Vector3.one; @@ -421,10 +480,20 @@ public partial class FishingBehaviorB : MonoBehaviour float FishingScale = fishRodSkinData.FishingScale; var goPrefab = await Addressables.LoadAssetAsync(path).Task; + if (this == null) + { + if (goPrefab != null) Addressables.Release(goPrefab); + return; + } if (goPrefab == null) { Debug.LogError("Load Rod Elemental as Default Rod"); goPrefab = await Addressables.LoadAssetAsync("Rod_elemental").Task; + if (this == null) + { + if (goPrefab != null) Addressables.Release(goPrefab); + return; + } } _bait.transform.SetParent(transform); if (goPrefab != null) @@ -690,9 +759,25 @@ public partial class FishingBehaviorB : MonoBehaviour //开始抛鱼线 public async void CastLineBehavior() { +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) + { + try + { + var action=game.PlayerDataStorage.PlayerKVGameplayLoop.BeginFishing(this); + if(action==null) return; + kvFishingAction=action; + if(!KVFishingCurrent()) return; + } + catch(Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); return; } + } +#endif //计算主画布分辨率下的宽高和屏幕的宽高的比列: fishingData.mainCamera.DOLocalRotateQuaternion(Quaternion.identity, 0.1f); await Awaiters.NextFrame; +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVFishingCurrent()) return; +#endif CastBait(); //播放抛竿动画 @@ -703,12 +788,18 @@ public partial class FishingBehaviorB : MonoBehaviour rodHandleAni.SetBool("_idle", false); rodHandleAni.SetTrigger("_drawing"); await Awaiters.Seconds(fishingBehaviorConf.audioDelay); +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVFishingCurrent()) return; +#endif GContext.Publish(new EventFishingSound(SoundType.ThrowingAudio)); GContext.Publish(new VibrationData(1)); } private void ConsumeBait() { +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { _=ConsumeBaitOnKVAsync(); return; } +#endif if (fishingData.IsFree) { WaitForFishBiteED(); @@ -910,6 +1001,9 @@ public partial class FishingBehaviorB : MonoBehaviour } public void BaitMove() { +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVFishingCurrent()) return; +#endif flyEndPosTrue = _bait.transform.position; GContext.Publish(new EventFishingSound(SoundType.audio_fishing_throwing_baitintowater)); @@ -920,9 +1014,12 @@ public partial class FishingBehaviorB : MonoBehaviour var fishEffect = _effectDic["fx_fishing_splashloopwaiting"]; float z = WaitingZ(); float timer = Mathf.Lerp(fishingBehaviorConf.waitingLineDrawMinTime, fishingBehaviorConf.waitingLineDrawMaxTime, Random.value); - _bait.transform.DOLocalMoveZ(z, timer).OnUpdate( + var waitingTween=_bait.transform.DOLocalMoveZ(z, timer).OnUpdate( (() => { +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVFishingCurrent()) return; +#endif flyEndPosTrue = _bait.transform.position; if (fishEffect) { @@ -931,6 +1028,9 @@ public partial class FishingBehaviorB : MonoBehaviour fishEffect.transform.position = vector; } })); ; +#if UNITY_EDITOR && !SERVICE_HOST + TrackKVCastTween(waitingTween); +#endif StartCoroutine(WaitForFishBite(timer)); } IEnumerator PlayWaitingFx() @@ -953,6 +1053,9 @@ public partial class FishingBehaviorB : MonoBehaviour } private IEnumerator WaitForFishBite(float timer) { +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVFishingCurrent()) yield break; +#endif EventFishingSound eventFishingSound1 = new EventFishingSound(SoundType.audio_fishing_waiting); EventFishingSound eventFishingSound2 = new EventFishingSound(SoundType.ReelingAudio, loop: true); @@ -962,6 +1065,9 @@ public partial class FishingBehaviorB : MonoBehaviour timer += Mathf.Lerp(fishingBehaviorConf.waitingMinTime, fishingBehaviorConf.waitingMaxTime, Random.value) * (1 - EnhancedWaitingTime); yield return new WaitForSeconds(timer); +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVFishingCurrent()) yield break; +#endif if (eventFishingSound2.Sound != null) { @@ -1085,18 +1191,27 @@ public partial class FishingBehaviorB : MonoBehaviour } private async void CastBait() { +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVFishingCurrent()) return; +#endif InitEffect(); if (fishingBehaviorConf.fishFreeControlTime > 0f) { await new WaitForSeconds(fishingBehaviorConf.fishFreeControlTime); +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVFishingCurrent()) return; +#endif _isCastLine = true; _bait.transform.SetParent(transform); - _bait.transform.DOMoveX(0, fishingBehaviorConf.flyToWaterTime); - _bait.transform.DOMoveZ(fishingBehaviorConf.flyDistance, fishingBehaviorConf.flyToWaterTime).SetEase(fishingBehaviorConf.flyForwardCurve); + var castX=_bait.transform.DOMoveX(0, fishingBehaviorConf.flyToWaterTime); + var castZ=_bait.transform.DOMoveZ(fishingBehaviorConf.flyDistance, fishingBehaviorConf.flyToWaterTime).SetEase(fishingBehaviorConf.flyForwardCurve); _cameraAnimator.SetTrigger("_waiting"); - _bait.transform.DOMoveY(_waterHeight, fishingBehaviorConf.flyToWaterTime) + var castY=_bait.transform.DOMoveY(_waterHeight, fishingBehaviorConf.flyToWaterTime) .SetEase(fishingBehaviorConf.flyDownCurve).OnComplete((() => { +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVFishingCurrent()) return; +#endif fishingRodAnimator.SetTrigger("_waiting"); GContext.Publish(ERightState.Waiting); LoadEffect("fx_fishing_splashinto"); @@ -1106,16 +1221,25 @@ public partial class FishingBehaviorB : MonoBehaviour BaitMove(); _bait.SetActive(false); })); +#if UNITY_EDITOR && !SERVICE_HOST + TrackKVCastTween(castX); TrackKVCastTween(castZ); TrackKVCastTween(castY); +#endif AddLinePos(); } else { await new WaitForSeconds(1.15f); +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVFishingCurrent()) return; +#endif //当前动画_fishingRodAnimator播放时间 float time = fishingRodAnimator.GetCurrentAnimatorStateInfo(0).normalizedTime; _isCastLine = true; _cameraAnimator.SetTrigger("_waiting"); await new WaitForSeconds(time); +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVFishingCurrent()) return; +#endif _bait.transform.SetParent(transform); _bait.SetActive(false); //鱼漂位置 @@ -1259,7 +1383,7 @@ public partial class FishingBehaviorB : MonoBehaviour } void SetIKTarget(Transform target) { - if (RodBehaviour.cCDIK) + if (RodBehaviour != null && RodBehaviour.cCDIK) { RodBehaviour.cCDIK.solver.target = target; } @@ -1896,6 +2020,9 @@ public partial class FishingBehaviorB : MonoBehaviour _cameraAnimator.SetBool("_reeling", false); fishingRodAnimator.SetBool("_pulling", false); CameraPullingEnd(); +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled && (!KVFishingCurrent() || !game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance)) yield break; +#endif fishingData.IsCaughtFish = true; PlayCollectingEffect(); if (fx_fishing_splashwaveloop != null) @@ -2308,6 +2435,9 @@ public partial class FishingBehaviorB : MonoBehaviour private void OnDisable() { +#if UNITY_EDITOR && !SERVICE_HOST + CancelKVFishing("disabled"); +#endif CloseAllEffect(); } public void CloseAllEffect() @@ -2331,6 +2461,9 @@ public partial class FishingBehaviorB : MonoBehaviour } private void OnDestroy() { +#if UNITY_EDITOR && !SERVICE_HOST + CancelKVFishing("destroyed"); +#endif disposables?.Dispose(); disposables = null; foreach (var o in _effectDic) diff --git a/Client/Assets/Scripts/GampPlay/FishingB/FishingUpdateSys.cs b/Client/Assets/Scripts/GampPlay/FishingB/FishingUpdateSys.cs index 7215d1daf..9da5aba3f 100644 --- a/Client/Assets/Scripts/GampPlay/FishingB/FishingUpdateSys.cs +++ b/Client/Assets/Scripts/GampPlay/FishingB/FishingUpdateSys.cs @@ -522,6 +522,22 @@ public class FishingUpdateSys : MonoBehaviour int isFailCount = 0; // 按了收线按钮 public void PiercingFish(int waitingZone) + { +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) + { + try + { + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardDrawing(this)) return; + using(game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardSegment("strike")) PiercingFishCore(waitingZone); + } + catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); } + return; + } +#endif + PiercingFishCore(waitingZone); + } + void PiercingFishCore(int waitingZone) { //waitingZone==100表示鱼没有开启刺鱼 if (!fishingData.IsDrawing) @@ -623,6 +639,9 @@ public class FishingUpdateSys : MonoBehaviour if (piercingToComboB > 0) { await Awaiters.Seconds(damageProtectionTime); +#if UNITY_EDITOR && !SERVICE_HOST + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return; +#endif _comboMultiplayer += Random.Range(piercingToComboA, piercingToComboB); //新技能生效 刺鱼触发全力收线进度 PublishPerkEvent(RodPerkType.BattlePerk_PiercingToCombo); @@ -632,6 +651,9 @@ public class FishingUpdateSys : MonoBehaviour public async void EternalDeath() { await Awaiters.Seconds(damageProtectionTime); +#if UNITY_EDITOR && !SERVICE_HOST + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return; +#endif StopAllCoroutines(); OnFishingIsOver(); @@ -1042,6 +1064,10 @@ public class FishingUpdateSys : MonoBehaviour private void Update() { +#if UNITY_EDITOR && !SERVICE_HOST + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return; +#endif + if (fishingData.IsDrawing) { Vector3 fishPos = fishingBehavior.GetFishPos(); @@ -1219,6 +1245,34 @@ public class FishingUpdateSys : MonoBehaviour } void FishEnd() + { +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) + { + try + { + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardEnding(this,fishingData.IsCaughtFish && !isFail)) return; + using(game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardSegment("ending")) FishEndCore(); + _=ShowKVRewardPanelAsync(); + } + catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); } + return; + } +#endif + FishEndCore(); + } +#if UNITY_EDITOR && !SERVICE_HOST + async Task ShowKVRewardPanelAsync() + { + try + { + var panel=await UIManager.Instance.ShowUI(UITypes.FishingRewardPanel); + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardRunCurrent && panel!=null) Destroy(panel); + } + catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); } + } +#endif + void FishEndCore() { if (isFail) { @@ -1297,6 +1351,9 @@ public class FishingUpdateSys : MonoBehaviour } else { +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return; // Wrapper opens UI after S2 disposal. +#endif _ = UIManager.Instance.ShowUI(UITypes.FishingRewardPanel); } } @@ -1746,6 +1803,15 @@ public class FishingUpdateSys : MonoBehaviour //全力收线 public async void FullCombo() { +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) + { + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return; + try { game.PlayerDataStorage.PlayerKVGameplayLoop.CheckRewardGuide(); } + catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); return; } + } +#endif + if (_isCombo == 1 && curFishingStateName != FishingStateName.UIEating) { if (comboToExDmg > 0 && comboToExDmgCount < curComboToExDmgCount) @@ -1786,6 +1852,9 @@ public class FishingUpdateSys : MonoBehaviour } } await Awaiters.Seconds(0.2f); +#if UNITY_EDITOR && !SERVICE_HOST + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return; +#endif lockCombo = false; } } @@ -3130,6 +3199,10 @@ public class FishingUpdateSys : MonoBehaviour } private void OnDisable() { +#if UNITY_EDITOR && !SERVICE_HOST + game.PlayerDataStorage.PlayerKVGameplayLoop.CancelReward(this,"controller disabled"); +#endif + StopSoundCombo(); StopRodAudio(); StopNotReeling(); @@ -3176,6 +3249,10 @@ public class FishingUpdateSys : MonoBehaviour } private void OnDestroy() { +#if UNITY_EDITOR && !SERVICE_HOST + game.PlayerDataStorage.PlayerKVGameplayLoop.CancelReward(this,"controller destroyed"); +#endif + disposables?.Dispose(); _comboDisposable?.Dispose(); _hpcoroutine = null; diff --git a/Client/Assets/Scripts/Others/ZZTimeHelper.cs b/Client/Assets/Scripts/Others/ZZTimeHelper.cs index f59cf487b..369d53ac4 100644 --- a/Client/Assets/Scripts/Others/ZZTimeHelper.cs +++ b/Client/Assets/Scripts/Others/ZZTimeHelper.cs @@ -101,7 +101,7 @@ public class ZZTimeHelper HttpClient client = new HttpClient(); client.Timeout = TimeSpan.FromSeconds(5); IConfig config = GContext.container.Resolve(); - string apiUrl = config.Get(GConstant.K_Event_API_URL, "http://192.168.9.101:5292/"); + string apiUrl = config.Get(GConstant.K_Event_API_URL, "http://s101.local:18080/"); string url = $"{apiUrl}account/svrtime"; var svrTimeStr = await client.GetStringAsync(url); // var svrTimeStr = await client.GetStringAsync(timeUrl); diff --git a/Client/Assets/Scripts/SandDig/DiggingGameManager.cs b/Client/Assets/Scripts/SandDig/DiggingGameManager.cs index c706684b3..b525d4e64 100644 --- a/Client/Assets/Scripts/SandDig/DiggingGameManager.cs +++ b/Client/Assets/Scripts/SandDig/DiggingGameManager.cs @@ -27,6 +27,26 @@ namespace Script.RuntimeScript private DiggingGameModel _dataModel; public DiggingGameModel DataModel => _dataModel; + internal static game.PlayerDataStorage.PlayerKVEmptyModuleState DecodeForKVLoad() => + game.PlayerDataStorage.PlayerKVEmptyModuleState.Instance; + internal bool CanResetForKVLoad => _monoBehaviour==null && _digAni==null && _cameraVir==null && _tables==null && + fx_DgSanddig==null && fx_DgSanddighard==null && fx_DgSanddisappear==null && diggingMoveCurve==null && + _broadLogic==null && _broadContainer==null && _gridContianer==null && _gridPropContianer==null && + _propContainer==null && _showPropPositon==null && _explosionManager==null && sanddig_dig_sound==null && + sanddig_dig_audio==null && sanddig_dighard_audio==null && _fxRoot==null && _onCloseAwardPanel==null && + (_dataModel==null || (_dataModel.CurActivityId==0 && _dataModel.digActivityConfig==null && _dataModel.AllClamps.Count==0)); + internal void ResetForKVLoad() + { + if(!CanResetForKVLoad) throw new InvalidOperationException("KV digging module is active."); + _dataModel=new DiggingGameModel(); _broadLogic=null; _gameState=SandDigGameState.Normal; + IsCanClick=true; ClampIsPass=false; itemDatas=new List(); _awardPanelIsOpen=false; + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVEmptyModuleState state) + { + if(state==null) throw new ArgumentNullException(nameof(state)); + if(!CanResetForKVLoad || _dataModel==null) throw new InvalidOperationException("KV digging reset required."); + } + private Animation _digAni; private CinemachineVirtualCamera _cameraVir; @@ -785,4 +805,4 @@ namespace Script.RuntimeScript Special, None } -} \ No newline at end of file +} diff --git a/Client/Assets/Scripts/Services/Account.meta b/Client/Assets/Scripts/Services/Account.meta new file mode 100644 index 000000000..cf0098488 --- /dev/null +++ b/Client/Assets/Scripts/Services/Account.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4930c4a417ba4c908416717d26695848 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Scripts/Services/Account/S101AccountSession.cs b/Client/Assets/Scripts/Services/Account/S101AccountSession.cs new file mode 100644 index 000000000..f036c7e0c --- /dev/null +++ b/Client/Assets/Scripts/Services/Account/S101AccountSession.cs @@ -0,0 +1,224 @@ +using System; +using System.Globalization; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace game.Account +{ + public enum AccountLoginError { None, Validation, Http, Rejected, Protocol, Network, Timeout, Cancelled } + + public sealed class AccountLoginResult + { + public bool Success => Error == AccountLoginError.None; + public AccountLoginError Error { get; } + public int? StatusCode { get; } + internal AccountLoginResult(AccountLoginError error, int? status = null) + { Error = error; StatusCode = status; } + } + + /// + /// Minimal Tuyou-ID authentication, independent of gameplay and PlayFab storage. + /// Each login owns a fresh cookie jar; dispose the session on logout. + /// + public sealed class S101AccountSession : IDisposable + { + public const string DefaultApiUrl = "http://s101.local:18080/"; + readonly object gate = new object(); + readonly Uri api; + readonly Func handlerFactory; + HttpClient http; + int generation; + bool disposed; + + public bool IsAuthenticated { get; private set; } + public string PlayerId { get; private set; } + public uint InternalId { get; private set; } + public DateTime CreateTimeUtc { get; private set; } + public DateTime ServerTimeUtc { get; private set; } + public bool NewlyCreated { get; private set; } + public CookieContainer Cookies { get; private set; } + public Uri ApiBaseUri => api; + + public S101AccountSession() : this(new Uri(DefaultApiUrl)) { } + + // The factory is optional for transport tests. Returned handlers are owned by this session. + public S101AccountSession(Uri api, Func handlerFactory = null) + { + if (api == null || !api.IsAbsoluteUri || + (api.Scheme != "http" && api.Scheme != "https") || + !string.IsNullOrEmpty(api.Query) || !string.IsNullOrEmpty(api.Fragment) || + !string.IsNullOrEmpty(api.UserInfo)) + throw new ArgumentException("An absolute HTTP(S) API base URI is required.", nameof(api)); + this.api = new Uri(api.AbsoluteUri.TrimEnd('/') + "/"); + this.handlerFactory = handlerFactory ?? (jar => new HttpClientHandler + { + CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false + }); + } + + public async Task LoginAsync(string tuyooId, CancellationToken token = default) + { + HttpClient client; + CookieContainer cookies; + int requestGeneration; + string id; + lock (gate) + { + if (disposed) return Result(AccountLoginError.Cancelled); + generation++; + ClearIdentity(); + http?.Dispose(); + http = null; + Cookies = null; + if (string.IsNullOrEmpty(tuyooId) || + !long.TryParse(tuyooId, NumberStyles.None, CultureInfo.InvariantCulture, out var numericId) || numericId <= 0) + return Result(AccountLoginError.Validation); + if (token.IsCancellationRequested) return Result(AccountLoginError.Cancelled); + id = numericId.ToString(CultureInfo.InvariantCulture); + cookies = new CookieContainer(); + client = new HttpClient(handlerFactory(cookies)) { Timeout = TimeSpan.FromSeconds(15) }; + http = client; + Cookies = cookies; + requestGeneration = generation; + } + + int? status = null; + try + { + using (var request = Request("account/Login", new JObject { ["playerId"] = id })) + using (var response = await client.SendAsync(request, token)) + { + status = (int)response.StatusCode; + if (response.StatusCode != HttpStatusCode.OK) return Result(AccountLoginError.Http, status); + var body = Parse(await response.Content.ReadAsStringAsync()); + if (body["state"]?.Type != JTokenType.Integer) return Result(AccountLoginError.Protocol, status); + if (body["state"].Value() != 0) return Result(AccountLoginError.Rejected, status); + if (body["internalId"]?.Type != JTokenType.Integer || + !uint.TryParse(body["internalId"].ToString(), out var internalId) || internalId == 0 || + body["newCreate"]?.Type != JTokenType.Boolean || + !ReadUtc(body["createTime"], out var created) || !ReadUtc(body["svrTime"], out var serverTime) || + cookies.GetCookies(api).Count == 0) + return Result(AccountLoginError.Protocol, status); + lock (gate) + { + if (disposed || generation != requestGeneration || token.IsCancellationRequested) + return Result(AccountLoginError.Cancelled, status); + PlayerId = id; + InternalId = internalId; + CreateTimeUtc = created; + ServerTimeUtc = serverTime; + NewlyCreated = body["newCreate"].Value(); + IsAuthenticated = true; + } + return Result(AccountLoginError.None, status); + } + } + catch (OperationCanceledException) + { + lock (gate) + return Result(disposed || generation != requestGeneration || token.IsCancellationRequested + ? AccountLoginError.Cancelled : AccountLoginError.Timeout, status); + } + catch (ObjectDisposedException) { return Result(AccountLoginError.Cancelled, status); } + catch (HttpRequestException) { return Result(AccountLoginError.Network, status); } + catch (JsonException) { return Result(AccountLoginError.Protocol, status); } + catch (OverflowException) { return Result(AccountLoginError.Protocol, status); } + } + + /// Read-only probe: verifies that the server accepts this session cookie; never writes KV. + public async Task CheckAuthorizationAsync(CancellationToken token = default) + { + HttpClient client; + int requestGeneration; + lock (gate) + { + if (disposed || !IsAuthenticated) return Result(AccountLoginError.Rejected); + client = http; + requestGeneration = generation; + } + int? status = null; + try + { + using (var request = Request("playerdata/Get", new JObject())) + using (var response = await client.SendAsync(request, token)) + { + status = (int)response.StatusCode; + lock (gate) + { + if (disposed || generation != requestGeneration || token.IsCancellationRequested) + return Result(AccountLoginError.Cancelled, status); + if (status == 401 || status == 403) ClearIdentity(); + } + if (response.StatusCode != HttpStatusCode.OK) return Result(AccountLoginError.Http, status); + var body = Parse(await response.Content.ReadAsStringAsync()); + if (!(body["data"] is JObject data)) return Result(AccountLoginError.Protocol, status); + foreach (var item in data.Properties()) + if (item.Value.Type != JTokenType.String) return Result(AccountLoginError.Protocol, status); + lock (gate) + return Result(disposed || generation != requestGeneration || token.IsCancellationRequested + ? AccountLoginError.Cancelled : AccountLoginError.None, status); + } + } + catch (OperationCanceledException) + { + lock (gate) + return Result(disposed || generation != requestGeneration || token.IsCancellationRequested + ? AccountLoginError.Cancelled : AccountLoginError.Timeout, status); + } + catch (ObjectDisposedException) { return Result(AccountLoginError.Cancelled, status); } + catch (HttpRequestException) { return Result(AccountLoginError.Network, status); } + catch (JsonException) { return Result(AccountLoginError.Protocol, status); } + } + + HttpRequestMessage Request(string path, JObject body) => new HttpRequestMessage(HttpMethod.Post, new Uri(api, path)) + { Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json") }; + + static JObject Parse(string text) + { + using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None }) + { + var value = JObject.Load(reader); + if (reader.Read()) throw new JsonReaderException("Trailing JSON content."); + return value; + } + } + + static bool ReadUtc(JToken value, out DateTime date) + { + date = default; + return value?.Type == JTokenType.String && DateTime.TryParse(value.Value(), CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out date); + } + + static AccountLoginResult Result(AccountLoginError error, int? status = null) => new AccountLoginResult(error, status); + void ClearIdentity() + { + IsAuthenticated = false; + PlayerId = null; + InternalId = 0; + CreateTimeUtc = default; + ServerTimeUtc = default; + NewlyCreated = false; + } + + public void Dispose() + { + lock (gate) + { + if (disposed) return; + disposed = true; + generation++; + ClearIdentity(); + http?.Dispose(); + http = null; + Cookies = null; + } + } + } +} diff --git a/Client/Assets/Scripts/Services/Account/S101AccountSession.cs.meta b/Client/Assets/Scripts/Services/Account/S101AccountSession.cs.meta new file mode 100644 index 000000000..380463ab4 --- /dev/null +++ b/Client/Assets/Scripts/Services/Account/S101AccountSession.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 748d56d3a1b2489d85e081bfed580314 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Scripts/Services/AggUserService.cs b/Client/Assets/Scripts/Services/AggUserService.cs index c052e92f4..6d9c90951 100644 --- a/Client/Assets/Scripts/Services/AggUserService.cs +++ b/Client/Assets/Scripts/Services/AggUserService.cs @@ -13,6 +13,17 @@ namespace game public class AggUserService : PlayFabUserService { + public AggUserService() { } + internal AggUserService(PlayerDataStorage.PlayerKVControlledLoadCapability capability) : base(capability) { } + internal IReadOnlyList KVLinkedAccounts { get; private set; } = + Array.Empty(); + internal override void ResetForKVLoad() + { + base.ResetForKVLoad(); + KVLinkedAccounts=Array.Empty(); + } + internal override void ReplaceKVLinked(PlayerDataStorage.PlayerKVLoginState state) + { KVLinkedAccounts=new List(state.Linked).AsReadOnly(); } protected override string NewPlayerName { get @@ -45,6 +56,18 @@ namespace game public override async Task Login(Action progress, bool tryLoginByToken = true) { +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) + { + CustomId=game.PlayerDataStorage.PlayerKVGameplayLoop.Permit.PlayerId; + OnInitData+=InitData; + try { return await LoginWithBackend(progress,"kv-loop"); } + finally { OnInitData-=InitData; } + } +#endif + var backend=CaptureSaveBackend(); + if(RejectSaveBackend(backend)) return ELoginStep.Api; + var generation = logoutGeneration; if (IsLogin) { progress?.Invoke(3); @@ -58,10 +81,12 @@ namespace game if (!TYSdkFacade.IsLoggedIn) { loginInfo = await TYSdkFacade.Instance.LoginBySns(); + if (generation != logoutGeneration) return ELoginStep.Sdk; if (!loginInfo.isSuccess && tryLoginByToken) { loginInfo = await TYSdkFacade.Instance.LoginByToken(); + if (generation != logoutGeneration) return ELoginStep.Sdk; } if (!loginInfo.isSuccess) @@ -69,7 +94,9 @@ namespace game var evt = new LoginTypeEvt(); GContext.Publish(evt); var loginType = await evt.loginType.Task; + if (generation != logoutGeneration) return ELoginStep.Sdk; loginInfo = await TYSdkFacade.Instance.Login(loginType); + if (generation != logoutGeneration) return ELoginStep.Sdk; } } else @@ -80,21 +107,18 @@ namespace game if (!loginInfo.isSuccess) return ELoginStep.Sdk; - progress.Invoke(1); + progress?.Invoke(1); CustomId = loginInfo.StrUserId; OnInitData += InitData; - var step = await base.Login(step => progress?.Invoke(step + 1)); - if (step != ELoginStep.Done) - { - OnInitData -= InitData; - return step; - } - - OnInitData -= InitData; + ELoginStep step; + try { step = await LoginWithBackend(value => progress?.Invoke(value + 1),backend); } + finally { OnInitData -= InitData; } + if (generation != logoutGeneration) return ELoginStep.Sdk; + if (step != ELoginStep.Done) return step; if (ChannelManager.IsRustoreStore) { @@ -166,8 +190,9 @@ namespace game public override void Logout() { - TYSdkFacade.Instance.Logout(); base.Logout(); + if(IsControlledKVInstance) return; + TYSdkFacade.Instance.Logout(); } public override async Task GetLinkState(EAccoutType accoutType) @@ -193,8 +218,25 @@ namespace game return (linkResult.code, linkResult.userId); } + internal int KVNotificationCount { get; private set; } + internal override async Task LoadKVForValidationAsync(PlayerDataStorage.PlayerKVGameLoadCapability capability) + { + EnterKVReceiver(ReceiveKVData); + try { return await base.LoadKVForValidationAsync(capability); } + finally { ExitKVReceiver(); } + } + void ReceiveKVData(IDictionary data) + { + if(!IsControlledKVInstance || KVLoadingCopy==null || data.Count!=KVLoadingCopy.Count) + throw new InvalidOperationException("Controlled notification window invalid."); + KVNotificationCount++; + } private void InitData(IDictionary data) { +#if UNITY_EDITOR && !SERVICE_HOST + // Editor loop has no channel SDK login. M31 already replaced account URLs and linked flags. + if(PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return; +#endif if (data.TryGetValue(EAccoutType.hwGoogle.ToString(), out var google) && google == "1") { TYSdkFacade.TYAccountInfo.linkedAccout.Add(EAccoutType.hwGoogle); diff --git a/Client/Assets/Scripts/Services/ClubService.cs b/Client/Assets/Scripts/Services/ClubService.cs index bfaff20d8..6d0148349 100644 --- a/Client/Assets/Scripts/Services/ClubService.cs +++ b/Client/Assets/Scripts/Services/ClubService.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using UnityEngine; +using Newtonsoft.Json.Linq; namespace game { @@ -47,6 +48,27 @@ namespace game public DateTime? signTime { get; private set; } LureDonateData lureDonateData; + + // KV loading owns these three fields only; membership, transport and chat lifecycle stay untouched. + internal static PlayerDataStorage.PlayerKVClubState DecodeForKVLoad(IReadOnlyDictionary raw, + PlayerDataStorage.PlayerKVSchemaSnapshot snapshot, PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + PlayerDataStorage.PlayerKVSaveSchema.DecodeClub(raw,snapshot,errors); + internal void ResetForKVLoad() + { + signTime = null; + lureDonateData = new LureDonateData(); + IsOpenClubRed = true; + } + internal void ReplaceFromKVDecoded(PlayerDataStorage.PlayerKVClubState state) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + var donation = new LureDonateData { DonateCountTime=state.DonateTime, + ClubLureDonateCount=state.DonateCount, ClubLureRequestCDEnd=state.RequestEnd }; + signTime = state.SignTime; lureDonateData = donation; IsOpenClubRed = state.OpenRed; + } + internal PlayerDataStorage.PlayerKVClubState SnapshotForKVValidation() => + new PlayerDataStorage.PlayerKVClubState(signTime,lureDonateData.DonateCountTime, + lureDonateData.ClubLureDonateCount,lureDonateData.ClubLureRequestCDEnd,IsOpenClubRed); public int ClubLureDonateCount => lureDonateData.ClubLureDonateCount; public DateTime ClubLureRequestCDEnd => lureDonateData.ClubLureRequestCDEnd; @@ -154,28 +176,37 @@ namespace game } public async void GetSelfClub() { + if (!userService.IsAuthorized || string.IsNullOrEmpty(userService.ApiPlayerId)) + return; + var sessionVersion = customServerMgr.SessionVersion; GetSelfClubRequest getSelfClubRequest = new GetSelfClubRequest() { - playerId = userService.UserId, + playerId = userService.ApiPlayerId, }; string json = await customServerMgr.ClubRequest("GetSelfClub", getSelfClubRequest); - if (!string.IsNullOrEmpty(json) && json != "[]") + if (sessionVersion != customServerMgr.SessionVersion) return; + if (string.IsNullOrWhiteSpace(json)) return; + GetSelfClubResp response; + try { - GetSelfClubResp getSelfClubResp = Newtonsoft.Json.JsonConvert.DeserializeObject(json); - myClubInfo = getSelfClubResp.clubInfo; - if (myClubInfo != null) - { - memberRole = getSelfClubResp.role; - //signTime = getSelfClubResp.signTime; - internalIdToPlayerId = getSelfClubResp.playerIDs; - SetClubPlayerProfile(getSelfClubResp.clubPlayerProfiles); - chatService.GetConversationList((long)myClubInfo.id); - } + var body = JObject.Parse(json); + if (!body.TryGetValue("clubInfo", out var club) || + (club.Type != JTokenType.Null && club.Type != JTokenType.Object)) return; + response = body.ToObject(); + if (response == null) return; + if (club.Type == JTokenType.Object && + (response.clubInfo == null || response.clubInfo.id == 0 || + body["role"]?.Type != JTokenType.Integer || !Enum.IsDefined(typeof(EMemberRole), response.role))) return; } - else + catch (Newtonsoft.Json.JsonException) { return; } + catch (OverflowException) { return; } + myClubInfo = response.clubInfo; + memberRole = myClubInfo == null ? EMemberRole.Member : response.role; + internalIdToPlayerId = response.playerIDs ?? new Dictionary(); + if (myClubInfo != null) { - memberRole = EMemberRole.Member; - myClubInfo = null; + SetClubPlayerProfile(response.clubPlayerProfiles); + chatService.GetConversationList((long)myClubInfo.id); } SetClubRed(); MyClubInfoChange(); @@ -218,16 +249,48 @@ namespace game } } + private static bool IsDecimalPlayerId(string id) => + long.TryParse(id, System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out var number) && number > 0; + + private bool TryGetClubSession(out string playerId, out int version) + { + playerId = userService.ApiPlayerId; + version = customServerMgr.SessionVersion; + return userService.IsAuthorized && IsDecimalPlayerId(playerId); + } + + private bool IsClubSessionCurrent(string playerId, int version) => + version == customServerMgr.SessionVersion && userService.IsAuthorized && playerId == userService.ApiPlayerId; + + private static bool TryReadClubResponse(string json, out T result, params int[] acceptedCodes) where T : class + { + result = null; + if (string.IsNullOrWhiteSpace(json)) return false; + try + { + var body = Newtonsoft.Json.Linq.JObject.Parse(json); + var code = body["code"]; + if (code?.Type != Newtonsoft.Json.Linq.JTokenType.Integer || !acceptedCodes.Contains(code.Value())) return false; + result = body.ToObject(); + return result != null; + } + catch (Newtonsoft.Json.JsonException) { return false; } + catch (OverflowException) { return false; } + } + public async Task> GetClubDetails(ulong clubID, bool applyState = false) { + if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return null; ApplyState = false; GetClubDetailsRequest getClubDetailsRequest = new GetClubDetailsRequest() { id = clubID, - playerId = userService.UserId, + playerId = apiPlayerId, applyState = applyState, }; string json = await customServerMgr.ClubRequest("GetClubDetails", getClubDetailsRequest); + if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return null; if (!string.IsNullOrEmpty(json) && json != "[]") { GetClubDetailsResp clubDetails = Newtonsoft.Json.JsonConvert.DeserializeObject(json); @@ -243,6 +306,7 @@ namespace game } List longs1 = clubDetails.playerIDs.Keys.ToList(); HashSet longs = await chatService.GetOnlineUsers(longs1); + if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return null; List clubPlayerProfiles = clubDetails.clubPlayerProfiles; SetClubPlayerProfile(clubPlayerProfiles); @@ -284,40 +348,27 @@ namespace game public async Task CreateClub(ClubSettingData clubSettingData) { - CreateClubRequest createClubRequest = new CreateClubRequest() - { - playerId = userService.UserId, - //entityKey = userService.entityKey.Id, - clubSettingData = clubSettingData, - }; - string json = await customServerMgr.ClubRequest("CreateClub", createClubRequest); - if (!string.IsNullOrEmpty(json) && json != "[]") - { - CreateClubResp createClubResp = Newtonsoft.Json.JsonConvert.DeserializeObject(json); - if (createClubResp.code != 2) - { - if (createClubResp.code == 0) - { - memberRole = EMemberRole.Owner; - ShowPromote(); - } - myClubInfo = createClubResp.clubInfo; - chatService.GetConversationList((long)myClubInfo.id); - internalIdToPlayerId[userService.InternalId] = userService.UserId; - MyClubInfoChange(true); - return myClubInfo; - } - } - else - { - myClubInfo = null; - } - MyClubInfoChange(); + if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return null; + var internalId = userService.InternalId; + var request = new CreateClubRequest { playerId = apiPlayerId, clubSettingData = clubSettingData }; + var json = await customServerMgr.ClubRequest("CreateClub", request); + if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return null; + // Code 1 means already a member; returning a club would charge the create UI again. + if (!TryReadClubResponse(json, out var response, 0) || response.clubInfo == null || response.clubInfo.id == 0) + return null; + memberRole = EMemberRole.Owner; + myClubInfo = response.clubInfo; + internalIdToPlayerId[internalId] = apiPlayerId; + ShowPromote(); + chatService.GetConversationList((long)myClubInfo.id); + MyClubInfoChange(true); return myClubInfo; } public async void ShowPromote() { + if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return; GameObject go = await UIManager.Instance.ShowUI(UITypes.ClubPromoteNoticePopupPanel); + if (!IsClubSessionCurrent(apiPlayerId, sessionVersion) || go == null) return; var ClubPromoteNoticePopupPanel = go.GetComponent(); ClubPromoteNoticePopupPanel.SetRole(memberRole); } @@ -344,19 +395,21 @@ namespace game public async Task ApplyClub(ulong clubID) { - memberRole = EMemberRole.Member; + if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return false; ApplyClubRequest applyClubRequest = new ApplyClubRequest() { - playerId = userService.UserId, + playerId = apiPlayerId, //entityKey = userService.entityKey.Id, id = clubID, }; string json = await customServerMgr.ClubRequest("ApplyClub", applyClubRequest); + if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return false; if (!string.IsNullOrEmpty(json) && json != "[]") { ApplyClubResp applyClubResp = Newtonsoft.Json.JsonConvert.DeserializeObject(json); if (applyClubResp.code == 0) { + memberRole = EMemberRole.Member; ToastPanel.Show(LocalizationMgr.GetText("UI_ToastPanel_41")); myClubInfo = applyClubResp.clubInfo; internalIdToPlayerId = applyClubResp.playerIDs; @@ -380,6 +433,10 @@ namespace game public async void HandleApply(bool isAgree, string applyId) { + if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return; + if (applyId == "" && isAgree) return; + if (applyId != "" && !IsDecimalPlayerId(applyId)) return; + if (myClubInfo == null) return; if (isAgree && myClubInfo.curMembers >= myClubInfo.maxMembers) { //人数已满 @@ -392,31 +449,32 @@ namespace game isAgree = isAgree, id = myClubInfo.id, }; - if (applyId == "") - { - clubPlayerInfos.Clear(); - RedPointManager.Instance.SetRedPointState(RedPointName.ApplyList, false); - } - else - { - for (int i = 0; i < clubPlayerInfos.Count; i++) - { - if (clubPlayerInfos[i].playFabId == applyId) - { - clubPlayerInfos.RemoveAt(i); - RedPointManager.Instance.SetRedPointState(RedPointName.ApplyList, clubPlayerInfos.Count > 0 - && CanAccepted); - break; - } - } - } string json = await customServerMgr.ClubRequest("HandleApply", handleApplyRequest); + if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return; if (!string.IsNullOrEmpty(json) && json != "[]") { - HandleApplyResp handleApplyResp = Newtonsoft.Json.JsonConvert.DeserializeObject(json); - if (handleApplyResp.code == 0) + if (!TryReadClubResponse(json, out var handleApplyResp, applyId == "" ? 4 : 0) || myClubInfo == null) return; + if (handleApplyResp.code == 0 || handleApplyResp.code == 4) { - myClubInfo.curMembers++; + if (applyId == "") + { + clubPlayerInfos.Clear(); + RedPointManager.Instance.SetRedPointState(RedPointName.ApplyList, false); + } + else + { + for (int i = 0; i < clubPlayerInfos.Count; i++) + { + if (clubPlayerInfos[i].playFabId == applyId) + { + clubPlayerInfos.RemoveAt(i); + RedPointManager.Instance.SetRedPointState(RedPointName.ApplyList, clubPlayerInfos.Count > 0 + && CanAccepted); + break; + } + } + } + if (isAgree) myClubInfo.curMembers++; GContext.Publish(new ClubSttingUpdateEvent()); } } @@ -424,17 +482,19 @@ namespace game public async void GetApplyList() { - clubPlayerInfos = new List(); - RedPointManager.Instance.SetRedPointState(RedPointName.ApplyList, false); + if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return; + if (myClubInfo == null) return; GetApplyListRequest getApplyListRequest = new GetApplyListRequest() { - playerId = userService.UserId, + playerId = apiPlayerId, id = myClubInfo.id, }; string json = await customServerMgr.ClubRequest("GetApplyList", getApplyListRequest); + if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return; if (!string.IsNullOrEmpty(json) && json != "[]") { GetApplyListResp getApplyListResp = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + clubPlayerInfos = new List(); SetClubPlayerProfile(getApplyListResp.clubPlayerProfiles); for (int i = 0; i < getApplyListResp.userIDList.Count; i++) { @@ -467,16 +527,12 @@ namespace game } public async void QuitClub(string appointId = "0") { - QuitClubRequest quitClubRequest = new QuitClubRequest() - { - playerId = userService.UserId, - appointId = appointId, - }; - string json = await customServerMgr.ClubRequest("QuitClub", quitClubRequest); - //if (!string.IsNullOrEmpty(json) && json != "[]") - //{ - // QuitClubResp quitClubResp = Newtonsoft.Json.JsonConvert.DeserializeObject(json); - //} + if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return; + if (!string.IsNullOrEmpty(appointId) && appointId != "0" && (!IsDecimalPlayerId(appointId) || appointId == apiPlayerId)) return; + var request = new QuitClubRequest { playerId = apiPlayerId, appointId = appointId }; + var json = await customServerMgr.ClubRequest("QuitClub", request); + if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return; + if (!TryReadClubResponse(json, out var response, 0, 1)) return; chatService.OnQuit(); myClubInfo = null; ClearLikeCursorIds(); @@ -485,36 +541,49 @@ namespace game } public async void Demote(string playerId) { + if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return; + if (!IsDecimalPlayerId(playerId) || playerId == apiPlayerId) return; + if (myClubInfo == null) return; DemoteClubRequest demoteClubRequest = new DemoteClubRequest() { culbId = myClubInfo.id, demoteId = playerId, - playerId = userService.UserId, + playerId = apiPlayerId, }; string json = await customServerMgr.ClubRequest("DemoteClub", demoteClubRequest); + if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return; } public async void Promote(string playerId, EMemberRole role) { + if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return; + if (!IsDecimalPlayerId(playerId) || playerId == apiPlayerId) return; + if (myClubInfo == null) return; PromoteClubRequest promoteClubRequest = new PromoteClubRequest() { culbId = myClubInfo.id, promoteId = playerId, - playerId = userService.UserId, + playerId = apiPlayerId, role = role, }; string json = await customServerMgr.ClubRequest("PromoteClub", promoteClubRequest); + if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return; } public async void Kickout(string playerId) { + if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return; + if (!IsDecimalPlayerId(playerId) || playerId == apiPlayerId) return; + if (myClubInfo == null) return; KickoutClubRequest kickoutClubRequest = new KickoutClubRequest() { culbId = myClubInfo.id, kickoutId = playerId, - playerId = userService.UserId, + playerId = apiPlayerId, }; - myClubInfo.curMembers--; - GContext.Publish(new ClubSttingUpdateEvent()); string json = await customServerMgr.ClubRequest("KickoutClub", kickoutClubRequest); + if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return; + if (!TryReadClubResponse(json, out var response, 0) || myClubInfo == null) return; + myClubInfo.curMembers = Math.Max(0, myClubInfo.curMembers - 1); + GContext.Publish(new ClubSttingUpdateEvent()); //if (!string.IsNullOrEmpty(json) && json != "[]") //{ // KickoutClubResp quitClubResp = Newtonsoft.Json.JsonConvert.DeserializeObject(json); @@ -522,52 +591,67 @@ namespace game } public async void ReadClubEvent() { + if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return; ReadClubEventRequest readClubEventRequest = new ReadClubEventRequest() { - playerId = userService.UserId, + playerId = apiPlayerId, }; string json = await customServerMgr.ClubRequest("ReadClubEvent", readClubEventRequest); + if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return; //if (!string.IsNullOrEmpty(json) && json != "[]") //{ // ReadClubEventResp readClubEventResp = Newtonsoft.Json.JsonConvert.DeserializeObject(json); //} } - public async void ClubSingin() + private int? signingSession; + public async Task ClubSingin() { - signTime = ZZTimeHelper.UtcNow().UtcNowOffset(); - ClubSinginRequest clubSinginRequest = new ClubSinginRequest() + if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return; + if (myClubInfo == null) return; + if (signingSession == sessionVersion || signTime?.Date == ZZTimeHelper.UtcNow().UtcNowOffset().Date) return; + signingSession = sessionVersion; + try { - playerId = userService.UserId, - id = myClubInfo.id, - }; - string json = await customServerMgr.ClubRequest("ClubSingin", clubSinginRequest); - if (!string.IsNullOrEmpty(json) && json != "[]") - { - ClubSinginResp clubSinginResp = Newtonsoft.Json.JsonConvert.DeserializeObject(json); - signTime = ZZTimeHelper.UtcNow().UtcNowOffset(); - PlayFabMgr.Instance.UpdateUserDataValue("ClubSignin", signTime.Value.ToString("yyyy-MM-dd HH:mm:ss")); - if (clubSinginResp.code == 0) + ClubSinginRequest clubSinginRequest = new ClubSinginRequest() { - chatService.SendSystemMessage($"{MessageChatType.SI}_{userService.UserId}"); - //签到成功奖励 - GContext.container.Resolve().AddItemByDrop(GContext.container.Resolve().TbServerConfig.ClubSignReward, null); - GContext.Publish(new ShowData()); -#if AGG - using (var e = GEvent.GameEvent("guild_sign")) + playerId = apiPlayerId, + id = myClubInfo.id, + }; + string json = await customServerMgr.ClubRequest("ClubSingin", clubSinginRequest); + if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return; + if (!string.IsNullOrEmpty(json) && json != "[]") + { + if (!TryReadClubResponse(json, out var clubSinginResp, 0)) return; + signTime = ZZTimeHelper.UtcNow().UtcNowOffset(); + PlayFabMgr.Instance.UpdateUserDataValue("ClubSignin", signTime.Value.ToString("yyyy-MM-dd HH:mm:ss")); + if (clubSinginResp.code == 0) { - e.AddContent("guild_id", myClubInfo.id) - .AddContent("drop_list", GContext.container.Resolve().TbServerConfig.ClubSignReward); + chatService.SendSystemMessage($"{MessageChatType.SI}_{apiPlayerId}"); + //签到成功奖励 + GContext.container.Resolve().AddItemByDrop(GContext.container.Resolve().TbServerConfig.ClubSignReward, null); + GContext.Publish(new ShowData()); + #if AGG + using (var e = GEvent.GameEvent("guild_sign")) + { + e.AddContent("guild_id", myClubInfo.id) + .AddContent("drop_list", GContext.container.Resolve().TbServerConfig.ClubSignReward); + } + #endif } -#endif } + SetClubRed(); + } + finally + { + if (signingSession == sessionVersion) signingSession = null; } - SetClubRed(); } public void SendSystemMessage2(int index) { - chatService.SendSystemMessage2($"{userService.UserId}_{index}"); + if (!TryGetClubSession(out var apiPlayerId, out _)) return; + chatService.SendSystemMessage2($"{apiPlayerId}_{index}"); } /// @@ -583,15 +667,17 @@ namespace game } public async void GetServiceEvent() { - energyAddCount = 0; + if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return; GetPictureRequest getPictureRequest = new GetPictureRequest() { - playerId = userService.UserId, + playerId = apiPlayerId, }; string json = await customServerMgr.ClubRequest("GetPictureList", getPictureRequest); + if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return; if (!string.IsNullOrEmpty(json) && json != "[]") { GetPictureResp getPictureResp = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + energyAddCount = 0; //var pictureDatas = getPictureResp.pictureDataList; //if (pictureDatas != null && pictureDatas.Count > 0) //{ diff --git a/Client/Assets/Scripts/Services/FootPrintService.cs b/Client/Assets/Scripts/Services/FootPrintService.cs index 5338aa3c1..0be0ee465 100644 --- a/Client/Assets/Scripts/Services/FootPrintService.cs +++ b/Client/Assets/Scripts/Services/FootPrintService.cs @@ -12,6 +12,24 @@ public interface IFootPrintService public class FootPrintService : IFootPrintService { private EFootPrint _footPrint; + private ulong _kvCompleteFootPrint; + internal ulong CompleteFootPrintForKVValidation => _kvCompleteFootPrint; + internal static game.PlayerDataStorage.PlayerKVFootPrintState DecodeForKVLoad( + System.Collections.Generic.IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) + => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeFootPrint(raw, errors); + internal void ResetForKVLoad() + { + _kvCompleteFootPrint = 0; + _footPrint = 0; + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVFootPrintState state) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + _kvCompleteFootPrint = state.Bits; + // This legacy view contains only the low 32 bits. The ulong field retains the complete KV state. + _footPrint = unchecked((EFootPrint)(int)(uint)state.Bits); + } public EFootPrint FootPrint => _footPrint; public void UpdateFootPrint(EFootPrint newState) { diff --git a/Client/Assets/Scripts/Services/PlayerData.meta b/Client/Assets/Scripts/Services/PlayerData.meta new file mode 100644 index 000000000..54f08c000 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 761582591ce048769d522f8410c647cc +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs new file mode 100644 index 000000000..4fe78cca9 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs @@ -0,0 +1,105 @@ +using System; +using System.IO; +using System.Security; +using System.Text; +using Newtonsoft.Json; + +namespace game.PlayerDataStorage +{ + // Internal seam for deterministic disk-failure tests; no production callback is supplied. + internal enum KVCacheWriteStage { BeforeWrite, AfterWrite, AfterFlush, BeforeReplace, AfterReplace } + + internal sealed class PlayerKVCacheStore : IDisposable + { + const long MaxBytes = 64L * 1024 * 1024; + readonly KVIdentity identity; + readonly FileStream lease; + readonly Action fault; + readonly string main, temporary, backup; + bool disposed; + + PlayerKVCacheStore(KVIdentity identity, FileStream lease, Action fault) + { + this.identity = identity; this.lease = lease; this.fault = fault; + main = Path.Combine(identity.DirectoryPath, "state.json"); + temporary = main + ".tmp"; backup = main + ".bak"; + } + + internal static PlayerKVOpenError Open(KVIdentity identity, Action fault, + out PlayerKVCacheStore store, out KVSnapshot snapshot) + { + store = null; snapshot = null; + FileStream lease = null; + try + { + Directory.CreateDirectory(identity.DirectoryPath); + try { lease = new FileStream(Path.Combine(identity.DirectoryPath, "state.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } + catch (IOException ex) when ((ex.HResult & 0xffff) == 32 || (ex.HResult & 0xffff) == 33) + { return PlayerKVOpenError.Busy; } + store = new PlayerKVCacheStore(identity, lease, fault); + if (!File.Exists(store.main)) + { + if (File.Exists(store.backup) || File.Exists(store.temporary)) throw KVSnapshot.Invalid(); + snapshot = new KVSnapshot(); + } + else + { + if (new FileInfo(store.main).Length > MaxBytes) throw KVSnapshot.Invalid(); + snapshot = KVSnapshot.Decode(File.ReadAllText(store.main, new UTF8Encoding(false, true)), identity); + } + return PlayerKVOpenError.None; + } + catch (Exception ex) when (ex is JsonException || ex is OverflowException || ex is FormatException || ex is DecoderFallbackException) + { + store?.Dispose(); lease?.Dispose(); store = null; + return PlayerKVOpenError.RecoveryRequired; + } + catch (Exception ex) when (DiskError(ex)) + { + store?.Dispose(); lease?.Dispose(); store = null; + return PlayerKVOpenError.StorageError; + } + } + + internal bool Save(KVSnapshot snapshot, out bool mayHaveCommitted) + { + mayHaveCommitted = false; + try + { + if (disposed) throw new ObjectDisposedException(nameof(PlayerKVCacheStore)); + snapshot.Validate(); + var bytes = new UTF8Encoding(false, true).GetBytes(snapshot.Encode(identity)); + if (bytes.LongLength > MaxBytes) throw new IOException("KV cache capacity exceeded."); + fault?.Invoke(KVCacheWriteStage.BeforeWrite); + using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None)) + { + stream.Write(bytes, 0, bytes.Length); + fault?.Invoke(KVCacheWriteStage.AfterWrite); + stream.Flush(true); + fault?.Invoke(KVCacheWriteStage.AfterFlush); + } + fault?.Invoke(KVCacheWriteStage.BeforeReplace); + mayHaveCommitted = true; + // The temp file is on the same filesystem. Never delete the main file first. + if (File.Exists(main)) File.Replace(temporary, main, backup); + else File.Move(temporary, main); + fault?.Invoke(KVCacheWriteStage.AfterReplace); + mayHaveCommitted = false; + return true; + } + catch (Exception ex) when (DiskError(ex) || ex is JsonException || ex is EncoderFallbackException) + { return false; } + } + + static bool DiskError(Exception ex) => ex is IOException || ex is UnauthorizedAccessException || + ex is SecurityException || ex is NotSupportedException || ex is ObjectDisposedException; + + public void Dispose() + { + if (disposed) return; + disposed = true; + lease.Dispose(); + // Keep files, including failed temporary writes, for explicit recovery diagnosis. + } + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs.meta new file mode 100644 index 000000000..31697b0a5 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bb1e30eab9b24686a5b5110bc68c19b6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs new file mode 100644 index 000000000..d5e8938d1 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace game.PlayerDataStorage +{ + /// + /// Private KV protocol for one fixed authenticated account/environment. + /// Caller owns the HttpClient, cookies and cancellation lifetime; never reuse + /// that transport for another account. This class neither retries nor logs payloads. + /// Production login/storage integration is deliberately outside this adapter. + /// + public sealed class PlayerKVClient + { + readonly HttpClient http; + readonly Uri api; + + public PlayerKVClient(HttpClient http, Uri apiBaseUri) + { + this.http = http ?? throw new ArgumentNullException(nameof(http)); + if (apiBaseUri == null || !apiBaseUri.IsAbsoluteUri || + (apiBaseUri.Scheme != "http" && apiBaseUri.Scheme != "https") || + !string.IsNullOrEmpty(apiBaseUri.UserInfo) || + !string.IsNullOrEmpty(apiBaseUri.Query) || !string.IsNullOrEmpty(apiBaseUri.Fragment)) + throw new ArgumentException("An absolute HTTP(S) API base URI is required.", nameof(apiBaseUri)); + api = new Uri(apiBaseUri.AbsoluteUri.TrimEnd('/') + "/"); + } + + public Task GetAsync(IEnumerable keys = null, CancellationToken token = default) + { + if (!CopyKeys(keys, true, out var snapshot)) return Invalid(); + var body = new JObject(); + if (snapshot.Count > 0) body["keys"] = new JArray(snapshot); + return SendAsync("Get", body, false, token); + } + + public Task SetAsync(IDictionary data, CancellationToken token = default) + { + if (data == null) return Invalid(); + var snapshot = new JObject(); + foreach (var pair in data) + { + if (!ValidKey(pair.Key) || pair.Value == null) return Invalid(); + snapshot.Add(pair.Key, pair.Value); + } + return SendAsync("Set", new JObject { ["data"] = snapshot }, true, token); + } + + public Task DeleteAsync(IEnumerable keys, CancellationToken token = default) + { + if (!CopyKeys(keys, false, out var snapshot)) return Invalid(); + return SendAsync("Delete", new JObject { ["keys"] = new JArray(snapshot) }, true, token); + } + + static bool ValidKey(string key) => !string.IsNullOrEmpty(key) && key.Length <= 128; + + static bool CopyKeys(IEnumerable keys, bool allowNull, out List snapshot) + { + snapshot = new List(); + if (keys == null) return allowNull; + foreach (var key in keys) + { + if (!ValidKey(key)) return false; + snapshot.Add(key); + } + return true; + } + + static Task Invalid() => Task.FromResult(new PlayerKVResult(PlayerKVError.Validation)); + + async Task SendAsync(string action, JObject body, bool mutation, CancellationToken token) + { + if (token.IsCancellationRequested) return new PlayerKVResult(PlayerKVError.Cancelled); + int? status = null; + bool enteredTransport = false; + try + { + using (var request = new HttpRequestMessage(HttpMethod.Post, new Uri(api, "playerdata/" + action))) + { + request.Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json"); + token.ThrowIfCancellationRequested(); + enteredTransport = true; + using (var response = await http.SendAsync(request, token)) + { + status = (int)response.StatusCode; + token.ThrowIfCancellationRequested(); + if (response.StatusCode != HttpStatusCode.OK) + return new PlayerKVResult(status == 401 || status == 403 + ? PlayerKVError.Unauthorized : PlayerKVError.Http, status, + mayHaveCommitted: mutation); + if (mutation) return new PlayerKVResult(PlayerKVError.None, status); + var text = await response.Content.ReadAsStringAsync(); + token.ThrowIfCancellationRequested(); + return new PlayerKVResult(PlayerKVError.None, status, ParseData(text)); + } + } + } + catch (OperationCanceledException) + { + return new PlayerKVResult(token.IsCancellationRequested ? PlayerKVError.Cancelled : PlayerKVError.Timeout, + status, mayHaveCommitted: mutation && enteredTransport); + } + catch (ObjectDisposedException) + { + return new PlayerKVResult(PlayerKVError.Cancelled, status, mayHaveCommitted: mutation && enteredTransport); + } + catch (HttpRequestException) + { + return new PlayerKVResult(PlayerKVError.Network, status, mayHaveCommitted: mutation && enteredTransport); + } + catch (IOException) + { + return new PlayerKVResult(PlayerKVError.Network, status, mayHaveCommitted: mutation && enteredTransport); + } + catch (JsonException) + { + return new PlayerKVResult(PlayerKVError.Protocol, status, mayHaveCommitted: mutation && enteredTransport); + } + } + + static Dictionary ParseData(string text) + { + using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None }) + { + var body = JObject.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error }); + if (reader.Read() || !(body["data"] is JObject data)) throw new JsonReaderException("Invalid KV response."); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var property in data.Properties()) + { + if (property.Value.Type != JTokenType.String) throw new JsonReaderException("KV value must be a string."); + result.Add(property.Name, property.Value.Value()); + } + return result; + } + } + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs.meta new file mode 100644 index 000000000..4172a5dba --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bcdbe8122e0b4f9eb82d2d1579081a7d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVControlledLoadCapability.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVControlledLoadCapability.cs new file mode 100644 index 000000000..abd8e7f31 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVControlledLoadCapability.cs @@ -0,0 +1,105 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using game.Account; + +namespace game.PlayerDataStorage +{ + // The lease-only capability remains source-compatible with the independent 4B1 regression host. + // This separate capability supplies every dependency required by a complete controlled load. + internal sealed class PlayerKVGameLoadCapability + { + internal PlayerKVIdentitySnapshot Identity { get; } + internal PlayerKVSchemaSnapshot Snapshot { get; } + internal Uri Api { get; } + internal string CacheRoot { get; } + internal int LoginObserveMs { get; } + internal int QuitObserveMs { get; } + internal Func Delay { get; } + internal PlayerKVControlledLoadCapability LeaseCapability { get; private set; } + readonly PlayerKVControlledLoadCapability auth,kv,lease; + readonly object gate=new object(); + CookieContainer cookies; + HttpMessageHandler pendingKV,pendingLease; + Exception cleanupFailure; + readonly System.Collections.Generic.List failedHandlers=new System.Collections.Generic.List(); + bool claimed; + private PlayerKVGameLoadCapability(PlayerKVIdentitySnapshot identity,PlayerKVSchemaSnapshot snapshot,Uri api,string cacheRoot, + int loginMs,int quitMs,Func delay, + PlayerKVControlledLoadCapability auth,PlayerKVControlledLoadCapability kv,PlayerKVControlledLoadCapability lease) + { + Identity=identity ?? throw new ArgumentNullException(nameof(identity)); Snapshot=snapshot ?? throw new ArgumentNullException(nameof(snapshot)); + if(api==null || !api.IsAbsoluteUri || (api.Scheme!="http" && api.Scheme!="https") || !string.IsNullOrEmpty(api.UserInfo) || + !string.IsNullOrEmpty(api.Query) || !string.IsNullOrEmpty(api.Fragment) || string.IsNullOrEmpty(cacheRoot) || + loginMs<1000 || loginMs>15000 || quitMs<1000 || quitMs>15000) throw new ArgumentException("Controlled load configuration invalid."); + Api=new Uri(api.AbsoluteUri.TrimEnd('/')+"/"); CacheRoot=cacheRoot; + LoginObserveMs=loginMs; QuitObserveMs=quitMs; Delay=delay ?? Task.Delay; this.auth=auth; this.kv=kv; this.lease=lease; + } +#if UNITY_EDITOR + internal static PlayerKVGameLoadCapability CreateForValidation(PlayerKVIdentitySnapshot identity,PlayerKVSchemaSnapshot snapshot, + Uri api,string cacheRoot,Func auth,Func kv, + Func lease,int loginMs=10000,int quitMs=5000,Func delay=null) + { + var capability=new PlayerKVGameLoadCapability(identity,snapshot,api,cacheRoot,loginMs,quitMs,delay, + PlayerKVControlledLoadCapability.CreateForValidation(auth),PlayerKVControlledLoadCapability.CreateForValidation(kv), + PlayerKVControlledLoadCapability.CreateForValidation(lease)); + capability.LeaseCapability=PlayerKVControlledLoadCapability.CreateForValidation(capability.TakeLease); + return capability; + } +#endif + internal PlayerKVLoginContext CreateContext(string id,long epoch,Func current) + { + lock(gate) { if(claimed) throw new InvalidOperationException("Controlled capability already used."); claimed=true; } + return new PlayerKVLoginContext(id,CacheRoot,epoch,current,Api, + ()=>new S101AccountSession(Api,CreateAuthentication),()=>new PlayerKVGameSessionBridge(TakeKV)); + } + HttpMessageHandler CreateAuthentication(CookieContainer jar) + { + lock(gate) + { + if(cookies!=null) throw new InvalidOperationException("Repeated authentication factory."); + cookies=jar; HttpMessageHandler authentication=null; + try + { + // Validate all three returned handler graphs before the first authentication send. + authentication=auth.CreateHandler(jar); pendingKV=kv.CreateHandler(jar); pendingLease=lease.CreateHandler(jar); + if(ReferenceEquals(authentication,pendingKV) || ReferenceEquals(authentication,pendingLease) || ReferenceEquals(pendingKV,pendingLease)) + throw new InvalidOperationException("Distinct transport ownership required."); + return authentication; + } + catch + { + try { authentication?.Dispose(); } catch(Exception ex) { cleanupFailure=ex; failedHandlers.Add(authentication); } + try { CloseUnused(); } catch { } + throw; + } + } + } + HttpMessageHandler TakeKV(CookieContainer jar) + { + lock(gate) + { if(!ReferenceEquals(jar,cookies) || pendingKV==null) throw new InvalidOperationException("KV transport ownership invalid."); + var result=pendingKV; pendingKV=null; return result; } + } + HttpMessageHandler TakeLease(CookieContainer jar) + { + lock(gate) + { if(!ReferenceEquals(jar,cookies) || pendingLease==null) throw new InvalidOperationException("Lease transport ownership invalid."); + var result=pendingLease; pendingLease=null; return result; } + } + internal void CloseUnused() + { + lock(gate) + { + // Attempt every owned cleanup once; retain a failure even if a later Dispose succeeds. + var ownedLease=pendingLease; pendingLease=null; + var ownedKV=pendingKV; pendingKV=null; + try { ownedLease?.Dispose(); } catch(Exception ex) { cleanupFailure=cleanupFailure ?? ex; failedHandlers.Add(ownedLease); } + try { ownedKV?.Dispose(); } catch(Exception ex) { cleanupFailure=cleanupFailure ?? ex; failedHandlers.Add(ownedKV); } + if(cleanupFailure!=null) throw new InvalidOperationException("Controlled transport cleanup failed.",cleanupFailure); + } + } + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVControlledLoadCapability.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVControlledLoadCapability.cs.meta new file mode 100644 index 000000000..77638ecb7 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVControlledLoadCapability.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 89934f8eb7b441bcb60409e83a018b76 diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVDecodedSave.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVDecodedSave.cs new file mode 100644 index 000000000..a80978149 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVDecodedSave.cs @@ -0,0 +1,969 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; + +namespace game.PlayerDataStorage +{ + internal enum PlayerKVSchemaVerdict { Valid, EmptyNew, EmptyExisting, MissingCore, Malformed, ConflictingRepresentations, UnsupportedSchema, Stale } + internal sealed class PlayerKVProgressPackPair + { + internal PlayerKVProgressPackState Current { get; } + internal PlayerKVProgressPackState Migration { get; } + internal PlayerKVProgressPackPair(PlayerKVProgressPackState current,PlayerKVProgressPackState migration) + { Current=current; Migration=migration; } + } + internal sealed class PlayerKVDecodedSave + { + readonly object[] modules; + internal string PlayerId { get; } + internal long Epoch { get; } + internal string ConfigurationVersion { get; } + internal IReadOnlyDictionary Raw { get; } + internal int ModuleCount => modules.Length; + internal int UnconsumedTopLevelCount { get; } + internal int UnconsumedModernCount { get; } + internal int UnconsumedCount => UnconsumedTopLevelCount+UnconsumedModernCount; + internal PlayerKVDecodedSave(string playerId,long epoch,string version,IReadOnlyDictionary raw,object[] modules,int unconsumedTop=0,int unconsumedModern=0) + { + if(modules==null || modules.Length!=31 || modules.Any(m=>m==null)) throw new ArgumentException("Complete decoded save required."); + PlayerId=playerId; Epoch=epoch; ConfigurationVersion=version; + Raw=new ReadOnlyDictionary(raw.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal)); + this.modules=(object[])modules.Clone(); + UnconsumedTopLevelCount=unconsumedTop; UnconsumedModernCount=unconsumedModern; + } + internal object Module(int id) { if(id<1 || id>31) throw new ArgumentOutOfRangeException(nameof(id)); return modules[id-1]; } + internal T Module(int id) where T:class => Module(id) as T ?? throw new InvalidOperationException("Module state mismatch."); + } + internal sealed class PlayerKVSchemaResult + { + internal PlayerKVSchemaVerdict Verdict { get; } + internal PlayerKVSchemaDiagnostics Diagnostics { get; } + internal PlayerKVDecodedSave Data { get; } + internal PlayerKVSchemaResult(PlayerKVSchemaVerdict verdict,PlayerKVSchemaDiagnostics diagnostics,PlayerKVDecodedSave data=null) + { Verdict=verdict; Diagnostics=diagnostics; Data=verdict==PlayerKVSchemaVerdict.Valid ? data : null; } + } + internal enum PlayerKVIdentityChannel { hwGoogle, hwFacebook, hwGuest, hwVkid, Apple, none } + internal sealed class PlayerKVIdentitySnapshot + { + internal string UserId { get; } + internal bool NewlyCreated { get; } + internal PlayerKVIdentityChannel Channel { get; } + internal IReadOnlyList Linked { get; } + internal PlayerKVIdentitySnapshot(string userId,bool newlyCreated,PlayerKVIdentityChannel channel,IEnumerable linked) + { + if(!Enum.IsDefined(typeof(PlayerKVIdentityChannel),channel) || linked==null) throw new ArgumentException("Identity snapshot required."); + var copy=linked.Distinct().ToList(); + if(copy.Any(c=>!Enum.IsDefined(typeof(PlayerKVIdentityChannel),c))) throw new ArgumentException("Unknown identity channel."); + UserId=userId; NewlyCreated=newlyCreated; Channel=channel; Linked=copy.AsReadOnly(); + } + } + internal sealed class PlayerKVLoginState + { + internal int MMTest { get; } + internal int TapJoyBalance { get; } + internal IReadOnlyDictionary AccountUrls { get; } + internal IReadOnlyDictionary Normalizations { get; } + internal IReadOnlyList Linked { get; } + internal PlayerKVIdentityChannel Channel { get; } + internal PlayerKVLoginState(int mm,int balance,IDictionary urls,IDictionary normalizations, + IEnumerable linked,PlayerKVIdentityChannel channel) + { + MMTest=mm; TapJoyBalance=balance; + AccountUrls=new ReadOnlyDictionary(new Dictionary(urls,StringComparer.Ordinal)); + Normalizations=new ReadOnlyDictionary(new Dictionary(normalizations,StringComparer.Ordinal)); + Linked=linked.Distinct().ToList().AsReadOnly(); Channel=channel; + } + } + internal sealed class PlayerKVPartnerBot + { + internal string Id { get; } + internal int ScorePlayer { get; } + internal int ScoreBot { get; } + internal int Grade { get; } + internal int TargetScore { get; } + internal DateTime WakeStartTime { get; } + internal int WakingSeconds { get; } + internal PlayerKVPartnerBot(string id,int player,int bot,int grade,int target,DateTime wake,int seconds) + { Id=id; ScorePlayer=player; ScoreBot=bot; Grade=grade; TargetScore=target; WakeStartTime=wake; WakingSeconds=seconds; } + } + internal sealed class PlayerKVPartnerState + { + internal bool Present { get; } + internal bool FirstOpen { get; } + internal int EventId { get; } + internal DateTime StartTime { get; } + internal DateTime EndTime { get; } + internal int CurrentPoints { get; } + internal int CurrentMiningProgress { get; } + internal int Token { get; } + internal int InvitedCount { get; } + internal int TicketCount { get; } + internal IReadOnlyList> CompletedNodesList { get; } + internal IReadOnlyList<(int myScore,int FriendScore)> BuilderPartnerScore { get; } + internal float BonusPercent { get; } + internal DateTime BonusExpireUtc { get; } + internal int FreeSpinNum { get; } + internal IReadOnlyDictionary Bots { get; } + internal bool IsRecvFinalReward { get; } + internal IReadOnlyList UIFinishedPartners { get; } + internal bool IsFinished { get; } + internal int SpinState { get; } + internal float InflationRate { get; } + internal bool IsEnableAutoMatch { get; } + internal DateTime LastAutoMatchTime { get; } + internal PlayerKVPartnerState(bool present,bool firstOpen,int eventId,DateTime start,DateTime end, + int points,int progress,int token,int invited,int tickets,IEnumerable> nodes, + IEnumerable<(int,int)> scores,float bonus,DateTime expires,int free,IDictionary bots, + bool reward,IEnumerable ui,bool finished,int spin,float inflation,bool autoMatch,DateTime lastMatch) + { + Present=present; FirstOpen=firstOpen; EventId=eventId; StartTime=start; EndTime=end; + CurrentPoints=points; CurrentMiningProgress=progress; Token=token; InvitedCount=invited; TicketCount=tickets; + CompletedNodesList=nodes.Select(n=>(IReadOnlyList)new List(n).AsReadOnly()).ToList().AsReadOnly(); + BuilderPartnerScore=new List<(int,int)>(scores).AsReadOnly(); BonusPercent=bonus; BonusExpireUtc=expires; + FreeSpinNum=free; Bots=new ReadOnlyDictionary(new Dictionary(bots)); + IsRecvFinalReward=reward; UIFinishedPartners=new List(ui).AsReadOnly(); IsFinished=finished; + SpinState=spin; InflationRate=inflation; IsEnableAutoMatch=autoMatch; LastAutoMatchTime=lastMatch; + } + internal static PlayerKVPartnerState Missing() => new PlayerKVPartnerState(false,false,0,default,default, + 0,0,0,0,0,Array.Empty(),Array.Empty<(int,int)>(),0,default,0,new Dictionary(), + false,Array.Empty(),false,0,0,false,default); + } + internal enum PlayerKVSchemaError { Malformed, MissingCore, ConflictingRepresentations, UnsupportedSchema } + + internal sealed class PlayerKVSchemaException : Exception + { + internal PlayerKVSchemaError Code { get; } + internal PlayerKVSchemaException(PlayerKVSchemaError code = PlayerKVSchemaError.Malformed) + : base("KV schema rejected.") { Code = code; } + } + + internal sealed class PlayerKVSchemaDetail + { + internal int Module { get; } + internal int Field { get; } + internal PlayerKVSchemaError Code { get; } + internal PlayerKVSchemaDetail(int module, int field, PlayerKVSchemaError code) + { Module = module; Field = field; Code = code; } + } + + // Values, save keys and exception messages are deliberately absent from diagnostic output. + internal sealed class PlayerKVSchemaDiagnostics + { + readonly List details = new List(); + readonly SortedDictionary> counts = + new SortedDictionary>(); + internal int TotalErrors { get; private set; } + internal int OmittedDetails => TotalErrors - details.Count; + internal IReadOnlyList Details => details.AsReadOnly(); + internal int Count(int module, PlayerKVSchemaError code) => + counts.TryGetValue(module, out var codes) && codes.TryGetValue(code, out var count) ? count : 0; + internal void Add(int module, int field, PlayerKVSchemaError code) + { + if (module < 1 || module > 31 || field < 0) throw new ArgumentOutOfRangeException(); + TotalErrors++; + if (!counts.TryGetValue(module, out var codes)) counts.Add(module, codes = new Dictionary()); + codes[code] = Count(module, code) + 1; + // Only retain 32 items, even when callers visit representations in different orders. + var item = new PlayerKVSchemaDetail(module, field, code); + int index = details.FindIndex(x => x.Module > module || (x.Module == module && x.Field > field)); + if (index < 0) index = details.Count; + if (index < 32) details.Insert(index, item); + if (details.Count > 32) details.RemoveAt(32); + } + internal T Capture(int module, int field, Func parse) + { + try { return parse(); } + catch (PlayerKVSchemaException ex) { Add(module, field, ex.Code); return default; } + } + } + + // Captured copies only: decoding never resolves Unity services or reads current culture/time. + internal sealed class PlayerKVSchemaSnapshot + { + readonly HashSet rods, maps; + readonly IReadOnlyDictionary packTypes; + readonly IReadOnlyDictionary events; + readonly IReadOnlyDictionary conditions; + readonly HashSet conditionValues; + readonly IReadOnlyDictionary fish; + readonly IReadOnlyDictionary cards; + internal string Version { get; } + internal CultureInfo Culture { get; } + internal DateTime NowUtc { get; } + internal DateTime NowGame { get; } + internal int InitWheelTicket { get; } + internal PlayerKVSchemaSnapshot(string version, IEnumerable rods, IEnumerable maps, + IDictionary conditions, CultureInfo culture, DateTime nowUtc, int initWheelTicket, + IDictionary packTypes = null, IDictionary events = null, int globalTimeOffset = 0, + IDictionary fish = null, IDictionary cards = null) + { + if (string.IsNullOrEmpty(version) || rods == null || maps == null || conditions == null || culture == null) + throw new ArgumentException("Schema snapshot required."); + Version = version; this.rods = new HashSet(rods); this.maps = new HashSet(maps); + this.packTypes = new ReadOnlyDictionary(new Dictionary(packTypes ?? new Dictionary())); + this.events = new ReadOnlyDictionary(new Dictionary(events ?? new Dictionary())); + this.conditions = new ReadOnlyDictionary(new Dictionary(conditions, StringComparer.Ordinal)); + conditionValues = new HashSet(conditions.Values); + Culture = CultureInfo.ReadOnly((CultureInfo)culture.Clone()); NowUtc = nowUtc; InitWheelTicket = initWheelTicket; + NowGame = nowUtc.AddHours(-(double)globalTimeOffset); + this.fish=new ReadOnlyDictionary(new Dictionary(fish ?? new Dictionary())); + this.cards=new ReadOnlyDictionary(new Dictionary(cards ?? new Dictionary())); + } + internal PlayerKVFishConfig Fish(int id) => fish.TryGetValue(id,out var value) ? value : null; + internal PlayerKVFishCardConfig Card(int id) => cards.TryGetValue(id,out var value) ? value : null; + internal bool HasRod(int id) => rods.Contains(id); + internal bool HasPack(int id) => packTypes.ContainsKey(id); + internal PlayerKVEventConfig Event(int id) => events.TryGetValue(id,out var value) ? value : null; + internal bool HasPackType(int id, int type) => packTypes.TryGetValue(id, out var actual) && actual == type; + internal bool HasMap(int id) => maps.Contains(id); + internal int Condition(string text) + { + if (conditions.TryGetValue(text, out var named)) return named; + if (int.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var numeric) + && conditionValues.Contains(numeric)) return numeric; + throw new PlayerKVSchemaException(); + } + } + + internal sealed class PlayerKVCoreState + { + readonly object[] slots; + internal PlayerKVCoreState(object[] slots) { this.slots = (object[])slots.Clone(); } + internal int Integer(int slot) => (int)slots[slot]; + internal ulong Unsigned(int slot) => (ulong)slots[slot]; + internal DateTime EnergyTime => (DateTime)slots[7]; + internal bool Matches(int slot, object value) => Equals(slots[slot], value); + } + + internal sealed class PlayerKVAchievementValue + { + internal int Id { get; } + internal ulong Target { get; } + internal int Day { get; } + internal PlayerKVAchievementValue(int id, ulong target, int day) { Id = id; Target = target; Day = day; } + internal bool Same(PlayerKVAchievementValue other) => Id == other.Id && Target == other.Target && Day == other.Day; + } + + internal sealed class PlayerKVAchievementState + { + internal IReadOnlyDictionary Values { get; } + internal PlayerKVAchievementState(IDictionary values) + { Values = new ReadOnlyDictionary(new Dictionary(values)); } + } + + + internal sealed class PlayerKVPiggyBankState + { + internal bool IsPresent { get; } + internal int currentEventID { get; } + internal int purchaseCount { get; } + internal int VIPLvlWhenEventActivate { get; } + internal int savingProgress { get; } + internal int redirectID { get; } + internal bool doNeedTrigger { get; } + internal bool isTriggered { get; } + internal float inflation { get; } + internal PlayerKVPiggyBankState(bool present, int currentEventID, int purchaseCount, int VIPLvlWhenEventActivate, int savingProgress, int redirectID, bool doNeedTrigger, bool isTriggered, float inflation) + { + IsPresent = present; + this.currentEventID = currentEventID; + this.purchaseCount = purchaseCount; + this.VIPLvlWhenEventActivate = VIPLvlWhenEventActivate; + this.savingProgress = savingProgress; + this.redirectID = redirectID; + this.doNeedTrigger = doNeedTrigger; + this.isTriggered = isTriggered; + this.inflation = inflation; + } + internal static PlayerKVPiggyBankState Missing() => new PlayerKVPiggyBankState(false, 0, 0, 0, 0, 0, false, false, 0); + } + + internal sealed class PlayerKVBargainState + { + internal bool IsPresent { get; } + internal int currentEventID { get; } + internal int redirectID { get; } + internal int VIPLvlWhenEventActivate { get; } + internal int purchaseCount { get; } + internal int savingProgress { get; } + internal int discountLvl { get; } + internal int visualProgress { get; } + internal int visualDiscountLvl { get; } + internal int rodID { get; } + internal int lastPackIDBought { get; } + internal int packID { get; } + internal bool doNeedTrigger { get; } + internal bool isTriggered { get; } + internal float inflation { get; } + internal IReadOnlyList selections { get; } + internal PlayerKVBargainState(bool present, int currentEventID, int redirectID, int VIPLvlWhenEventActivate, int purchaseCount, int savingProgress, int discountLvl, int visualProgress, int visualDiscountLvl, int rodID, int lastPackIDBought, int packID, bool doNeedTrigger, bool isTriggered, float inflation, IEnumerable selections) + { + IsPresent = present; + this.currentEventID = currentEventID; + this.redirectID = redirectID; + this.VIPLvlWhenEventActivate = VIPLvlWhenEventActivate; + this.purchaseCount = purchaseCount; + this.savingProgress = savingProgress; + this.discountLvl = discountLvl; + this.visualProgress = visualProgress; + this.visualDiscountLvl = visualDiscountLvl; + this.rodID = rodID; + this.lastPackIDBought = lastPackIDBought; + this.packID = packID; + this.doNeedTrigger = doNeedTrigger; + this.isTriggered = isTriggered; + this.inflation = inflation; + this.selections = selections == null ? null : new List(selections).AsReadOnly(); + } + internal static PlayerKVBargainState Missing() => new PlayerKVBargainState(false, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, false, 0, Array.Empty()); + } + + internal sealed class PlayerKVShootingRangeState + { + internal bool IsPresent { get; } + internal int EventId { get; } + internal int CycleId { get; } + internal int RedirectId { get; } + internal int AmmoCount { get; } + internal int CurrentStageIdx { get; } + internal int ChainProgress { get; } + internal long RewardFlag { get; } + internal long TargetFlag { get; } + internal bool HasBeenOpened { get; } + internal float Inflation { get; } + internal PlayerKVShootingRangeState(bool present, int EventId, int CycleId, int RedirectId, int AmmoCount, int CurrentStageIdx, int ChainProgress, long RewardFlag, long TargetFlag, bool HasBeenOpened, float Inflation) + { + IsPresent = present; + this.EventId = EventId; + this.CycleId = CycleId; + this.RedirectId = RedirectId; + this.AmmoCount = AmmoCount; + this.CurrentStageIdx = CurrentStageIdx; + this.ChainProgress = ChainProgress; + this.RewardFlag = RewardFlag; + this.TargetFlag = TargetFlag; + this.HasBeenOpened = HasBeenOpened; + this.Inflation = Inflation; + } + internal static PlayerKVShootingRangeState Missing() => new PlayerKVShootingRangeState(false, 0, 0, 0, 0, 0, 0, 0, 0, false, 0); + } + + + internal sealed class PlayerKVBuffValue + { + internal int SortID { get; } + internal int sourceID { get; } + internal int buffID { get; } + internal bool isEnd { get; } + internal DateTime buffEndTime { get; } + internal PlayerKVBuffValue(int SortID,int sourceID,int buffID,bool isEnd,DateTime buffEndTime) + { this.SortID=SortID; this.sourceID=sourceID; this.buffID=buffID; this.isEnd=isEnd; this.buffEndTime=buffEndTime; } + } + + internal sealed class PlayerKVTriggerValue + { + internal int ID { get; } + internal int index { get; } + internal int lastID { get; } + internal int vipLevel { get; } + internal int mapID { get; } + internal string time { get; } + internal PlayerKVTriggerValue(int ID,int index,int lastID,int vipLevel,int mapID,string time) + { this.ID=ID; this.index=index; this.lastID=lastID; this.vipLevel=vipLevel; this.mapID=mapID; this.time=time; } + } + + internal sealed class PlayerKVGiftVipValue + { + internal int oldVipLevel { get; } + internal int curVipLevel { get; } + internal int timer { get; } + internal PlayerKVGiftVipValue(int oldVipLevel,int curVipLevel,int timer) + { this.oldVipLevel=oldVipLevel; this.curVipLevel=curVipLevel; this.timer=timer; } + } + + internal sealed class PlayerKVFixedTimeValue + { + internal int id { get; } + internal string endTime { get; } + internal PlayerKVFixedTimeValue(int id,string endTime) + { this.id=id; this.endTime=endTime; } + } + + internal sealed class PlayerKVBuffState + { + internal IReadOnlyDictionary Map { get; } + internal IReadOnlyDictionary Global { get; } + internal IReadOnlyList Target { get; } + internal PlayerKVBuffValue Daily { get; } + internal PlayerKVBuffState(IDictionary map,IDictionary global, + IEnumerable target,PlayerKVBuffValue daily) + { + Map=new ReadOnlyDictionary(new Dictionary(map)); + Global=new ReadOnlyDictionary(new Dictionary(global)); + Target=new List(target).AsReadOnly(); Daily=daily; + } + } + internal sealed class PlayerKVTriggerState + { + internal IReadOnlyList Packs { get; } + internal IReadOnlyDictionary Gifts { get; } + internal IReadOnlyDictionary Fixed { get; } + internal PlayerKVTriggerState(IEnumerable packs,IDictionary gifts, + IDictionary fixedValues) + { + Packs=new List(packs).AsReadOnly(); + Gifts=new ReadOnlyDictionary(new Dictionary(gifts,StringComparer.Ordinal)); + Fixed=new ReadOnlyDictionary(new Dictionary(fixedValues)); + } + } + + + internal sealed class PlayerKVPlayerState + { + internal PlayerKVCoreState Core { get; } + internal IReadOnlyDictionary Modern { get; } + internal IReadOnlyDictionary VipScores { get; } + internal IReadOnlyList NewRods { get; } + internal int Season { get; } + internal bool Appraise { get; } + internal string Building { get; } + internal PlayerKVPlayerState(PlayerKVCoreState core,IDictionary modern,IDictionary scores, + IEnumerable rods,int season,bool appraise,string building) + { + Core=core; Modern=new ReadOnlyDictionary(new Dictionary(modern,StringComparer.Ordinal)); + VipScores=new ReadOnlyDictionary(new Dictionary(scores)); NewRods=new List(rods).AsReadOnly(); + Season=season; Appraise=appraise; Building=building; + } + } + internal sealed class PlayerKVPotionItem + { + internal string Name { get; } + internal int Index { get; } + internal bool IsOver { get; } + internal int DataType { get; } + internal IReadOnlyList Colors { get; } + internal IReadOnlyList Shelves { get; } + internal IReadOnlyList Grids { get; } + internal IReadOnlyList FromDisplay { get; } + internal PlayerKVPotionItem(string name,int index,bool over,int type,IEnumerable colors, + IEnumerable shelves,IEnumerable grids,IEnumerable fromDisplay) + { + Name=name; Index=index; IsOver=over; DataType=type; + Colors=new List(colors).AsReadOnly(); Shelves=new List(shelves).AsReadOnly(); + Grids=new List(grids).AsReadOnly(); FromDisplay=new List(fromDisplay).AsReadOnly(); + } + } + internal sealed class PlayerKVPotionState + { + internal bool IsPresent { get; } + internal int EventID { get; } + internal int PotionID { get; } + internal int StageID { get; } + internal int Stage1TotalLevel { get; } + internal int Stage2TotalLevel { get; } + internal int LevelID { get; } + internal int LevelIndex { get; } + internal int CorrectNum { get; } + internal int SubmitNum { get; } + internal int Token { get; } + internal int ConsumedToken { get; } + internal int Rule { get; } + internal int PreEventID { get; } + internal int PrePotionID { get; } + internal int PreStageID { get; } + internal int PreLevelID { get; } + internal int Round { get; } + internal int FirstPlayedEventId { get; } + internal bool IsOver { get; } + internal string Name { get; } + internal float InflationRate { get; } + internal PlayerKVPotionItem LevelTargetData { get; } + internal PlayerKVPotionItem LevelDisplayData { get; } + internal IReadOnlyList LevelProcessedList { get; } + internal PlayerKVPotionState(bool present,int EventID,int PotionID,int StageID,int Stage1TotalLevel,int Stage2TotalLevel,int LevelID,int LevelIndex,int CorrectNum,int SubmitNum,int Token,int ConsumedToken,int Rule,int PreEventID,int PrePotionID,int PreStageID,int PreLevelID,int Round,int FirstPlayedEventId,bool IsOver,string Name,float InflationRate,PlayerKVPotionItem LevelTargetData,PlayerKVPotionItem LevelDisplayData,List LevelProcessedList) + { + IsPresent=present; + this.EventID=EventID; + this.PotionID=PotionID; + this.StageID=StageID; + this.Stage1TotalLevel=Stage1TotalLevel; + this.Stage2TotalLevel=Stage2TotalLevel; + this.LevelID=LevelID; + this.LevelIndex=LevelIndex; + this.CorrectNum=CorrectNum; + this.SubmitNum=SubmitNum; + this.Token=Token; + this.ConsumedToken=ConsumedToken; + this.Rule=Rule; + this.PreEventID=PreEventID; + this.PrePotionID=PrePotionID; + this.PreStageID=PreStageID; + this.PreLevelID=PreLevelID; + this.Round=Round; + this.FirstPlayedEventId=FirstPlayedEventId; + this.IsOver=IsOver; + this.Name=Name; + this.InflationRate=InflationRate; + this.LevelTargetData=LevelTargetData; + this.LevelDisplayData=LevelDisplayData; + this.LevelProcessedList=new List(LevelProcessedList).AsReadOnly(); + } + internal static PlayerKVPotionState Missing() => new PlayerKVPotionState(false,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,new List()); + } + internal sealed class PlayerKVPassRecord + { + internal int ID { get; } + internal int Level { get; } + internal int Exp { get; } + internal int BuyVipToastIndex { get; } + internal int IsUnlockVip { get; } + internal IReadOnlyList Normal { get; } + internal IReadOnlyList Vip { get; } + internal IReadOnlyList LastDrops { get; } + internal float Inflation { get; } + internal PlayerKVPassRecord(int id,int level,int exp,int toast,int unlock,IEnumerable normal, + IEnumerable vip,IEnumerable lastDrops,float inflation) + { + ID=id; Level=level; Exp=exp; BuyVipToastIndex=toast; IsUnlockVip=unlock; + Normal=new List(normal).AsReadOnly(); Vip=new List(vip).AsReadOnly(); + LastDrops=new List(lastDrops).AsReadOnly(); Inflation=inflation; + } + internal bool CommonEquals(PlayerKVPassRecord other) => ID==other.ID && Level==other.Level && Exp==other.Exp && + BuyVipToastIndex==other.BuyVipToastIndex && IsUnlockVip==other.IsUnlockVip && Normal.SequenceEqual(other.Normal) && Vip.SequenceEqual(other.Vip); + internal static PlayerKVPassRecord Empty() => new PlayerKVPassRecord(0,1,0,0,0,Array.Empty(),Array.Empty(),Array.Empty(),0); + } + internal sealed class PlayerKVPassTask + { + internal IReadOnlyDictionary Daily { get; } + internal IReadOnlyDictionary Weekly { get; } + internal IReadOnlyList LoginDays { get; } + internal DateTime DailyEnd { get; } + internal DateTime WeeklyEnd { get; } + internal PlayerKVPassTask(IDictionary daily,IDictionary weekly,IEnumerable days,DateTime dailyEnd,DateTime weeklyEnd) + { + Daily=new ReadOnlyDictionary(new Dictionary(daily)); Weekly=new ReadOnlyDictionary(new Dictionary(weekly)); + LoginDays=new List(days).AsReadOnly(); DailyEnd=dailyEnd; WeeklyEnd=weeklyEnd; + } + internal bool Same(PlayerKVPassTask other) => Daily.Count==other.Daily.Count && Weekly.Count==other.Weekly.Count && + Daily.All(p=>other.Daily.TryGetValue(p.Key,out var value) && value==p.Value) && + Weekly.All(p=>other.Weekly.TryGetValue(p.Key,out var value) && value==p.Value) && + LoginDays.SequenceEqual(other.LoginDays) && DailyEnd==other.DailyEnd && WeeklyEnd==other.WeeklyEnd; + internal static PlayerKVPassTask Empty() => new PlayerKVPassTask(new Dictionary(),new Dictionary(),Array.Empty(),default,default); + } + internal sealed class PlayerKVBattlePassState + { + internal PlayerKVPassRecord Record { get; } + internal PlayerKVPassTask Task { get; } + internal PlayerKVBattlePassState(PlayerKVPassRecord record,PlayerKVPassTask task) { Record=record; Task=task; } + } + internal sealed class PlayerKVCollectValue + { + internal int ItemId { get; } + internal int Count { get; } + internal int MaxCount { get; } + internal int ItemTrueId { get; } + internal PlayerKVCollectValue(int itemId,int count,int maxCount,int trueId) + { ItemId=itemId; Count=count; MaxCount=maxCount; ItemTrueId=trueId; } + } + internal sealed class PlayerKVCubeState + { + internal bool IsPresent { get; } + internal int TicketCount { get; } + internal bool FirstOpen { get; } + internal bool IsFinished { get; } + internal int EventId { get; } + internal bool IsInGuide { get; } + internal DateTime StartTime { get; } + internal DateTime EndTime { get; } + internal int CurrentStagetId { get; } + internal IReadOnlyList CollectItems { get; } + internal IReadOnlyList BoardData { get; } + internal int Width { get; } + internal int Height { get; } + internal IReadOnlyList CollectRewardsId { get; } + internal bool IsFinalRewardGet { get; } + internal IReadOnlyList> PieceDatas { get; } + internal int Cycle { get; } + internal float InflationRate { get; } + internal PlayerKVCubeState(bool present,int ticketCount,bool firstOpen,bool finished,int eventId,bool guide, + DateTime start,DateTime end,int stage,IEnumerable items,IEnumerable board, + int width,int height,IEnumerable rewards,bool final,IEnumerable> pieces,int cycle,float inflation) + { + IsPresent=present; TicketCount=ticketCount; FirstOpen=firstOpen; IsFinished=finished; EventId=eventId; IsInGuide=guide; + StartTime=start; EndTime=end; CurrentStagetId=stage; CollectItems=new List(items).AsReadOnly(); + BoardData=new List(board).AsReadOnly(); Width=width; Height=height; CollectRewardsId=new List(rewards).AsReadOnly(); + IsFinalRewardGet=final; PieceDatas=pieces.Select(p=>(IReadOnlyList)new List(p).AsReadOnly()).ToList().AsReadOnly(); + Cycle=cycle; InflationRate=inflation; + } + internal static PlayerKVCubeState Missing() => new PlayerKVCubeState(false,0,false,false,0,false,default,default,0, + Array.Empty(),Array.Empty(),0,0,Array.Empty(),false,Array.Empty>(),0,0); + } + internal sealed class PlayerKVPinataState + { + internal int EventID { get; } + internal int PinataMainID { get; } + internal int PurchasedCount { get; } + internal PlayerKVPinataState(int eventId,int mainId,int count) { EventID=eventId; PinataMainID=mainId; PurchasedCount=count; } + } + internal sealed class PlayerKVEmptyModuleState + { + internal static readonly PlayerKVEmptyModuleState Instance=new PlayerKVEmptyModuleState(); + private PlayerKVEmptyModuleState() { } + } + internal sealed class PlayerKVStampValue + { + internal int EventScratchCardID { get; } + internal int Count { get; } + internal PlayerKVStampValue(int id,int count) { EventScratchCardID=id; Count=count; } + } + + internal sealed class PlayerKVChallengeState + { + internal bool IsPresent { get; } + /// Internal in-memory representation of a present JSON null; not serialized to the server. + internal bool IsExplicitNull { get; } + internal bool FirstOpen { get; } + internal int EventId { get; } + internal int RankTierLevel { get; } + internal int Finished { get; } + internal int RedirectID { get; } + internal int Score { get; } + internal int ProcessedScore { get; } + internal int step { get; } + internal int uiStep { get; } + internal int index { get; } + internal DateTime StartTime { get; } + internal DateTime EndTime { get; } + internal IReadOnlyList eliminateNumber { get; } + internal IReadOnlyList robotAccountList { get; } + internal float InflationRate { get; } + internal PlayerKVChallengeState(bool present,bool FirstOpen,int EventId,int RankTierLevel,int Finished,int RedirectID,int Score,int ProcessedScore,int step,int uiStep,int index,DateTime StartTime,DateTime EndTime,List eliminateNumber,List robotAccountList,float InflationRate,bool explicitNull=false) + { + if (explicitNull && !present) throw new ArgumentException("An explicit null must have a present key.", nameof(explicitNull)); + IsPresent=present; + IsExplicitNull=explicitNull; + this.FirstOpen=FirstOpen; + this.EventId=EventId; + this.RankTierLevel=RankTierLevel; + this.Finished=Finished; + this.RedirectID=RedirectID; + this.Score=Score; + this.ProcessedScore=ProcessedScore; + this.step=step; + this.uiStep=uiStep; + this.index=index; + this.StartTime=StartTime; + this.EndTime=EndTime; + this.eliminateNumber=new List(eliminateNumber).AsReadOnly(); + this.robotAccountList=new List(robotAccountList).AsReadOnly(); + this.InflationRate=InflationRate; + } + internal static PlayerKVChallengeState Missing() => new PlayerKVChallengeState(false,default,default,default,default,default,default,default,default,default,default,default,default,new List(),new List(),default); + /// Creates an internal empty-state marker, not a server serialization payload. + internal static PlayerKVChallengeState ExplicitNull() => new PlayerKVChallengeState(true,default,default,default,default,default,default,default,default,default,default,default,default,new List(),new List(),default,true); + } + + internal sealed class PlayerKVPinballState + { + internal bool IsPresent { get; } + internal int EventID { get; } + internal int PinballMainID { get; } + internal int SpinPoint { get; } + internal int Scores { get; } + internal int Multiple { get; } + internal int Token { get; } + internal int ConsumedToken { get; } + internal int RewardIndex { get; } + internal float Force { get; } + internal float LuckyValue { get; } + internal float InflationRate { get; } + internal PlayerKVPinballState(bool present,int EventID,int PinballMainID,int SpinPoint,int Scores,int Multiple,int Token,int ConsumedToken,int RewardIndex,float Force,float LuckyValue,float InflationRate) + { + IsPresent=present; + this.EventID=EventID; + this.PinballMainID=PinballMainID; + this.SpinPoint=SpinPoint; + this.Scores=Scores; + this.Multiple=Multiple; + this.Token=Token; + this.ConsumedToken=ConsumedToken; + this.RewardIndex=RewardIndex; + this.Force=Force; + this.LuckyValue=LuckyValue; + this.InflationRate=InflationRate; + } + internal static PlayerKVPinballState Missing() => new PlayerKVPinballState(false,default,default,default,default,default,default,default,default,default,default,default); + } + + internal sealed class PlayerKVScratchState + { + internal bool IsPresent { get; } + internal int EventID { get; } + internal int ScratchMainID { get; } + internal int ChainPackID { get; } + internal int Token { get; } + internal int ConsumedToken { get; } + internal int RewardTaskConsumedToken { get; } + internal int EventScratchRewardTaskID { get; } + internal int EventScratchCardID { get; } + internal int TotalConsumedToken { get; } + internal int ChainPackProgress { get; } + internal int magIndex { get; } + internal bool IsOver { get; } + internal float LuckyValue { get; } + internal float InflationRate { get; } + internal IReadOnlyList StampRewardList { get; } + internal IReadOnlyDictionary StampRewardDict { get; } + internal PlayerKVScratchState(bool present,int EventID,int ScratchMainID,int ChainPackID,int Token,int ConsumedToken,int RewardTaskConsumedToken,int EventScratchRewardTaskID,int EventScratchCardID,int TotalConsumedToken,int ChainPackProgress,int magIndex,bool IsOver,float LuckyValue,float InflationRate,List StampRewardList,Dictionary StampRewardDict) + { + IsPresent=present; + this.EventID=EventID; + this.ScratchMainID=ScratchMainID; + this.ChainPackID=ChainPackID; + this.Token=Token; + this.ConsumedToken=ConsumedToken; + this.RewardTaskConsumedToken=RewardTaskConsumedToken; + this.EventScratchRewardTaskID=EventScratchRewardTaskID; + this.EventScratchCardID=EventScratchCardID; + this.TotalConsumedToken=TotalConsumedToken; + this.ChainPackProgress=ChainPackProgress; + this.magIndex=magIndex; + this.IsOver=IsOver; + this.LuckyValue=LuckyValue; + this.InflationRate=InflationRate; + this.StampRewardList=new List(StampRewardList).AsReadOnly(); + this.StampRewardDict=new ReadOnlyDictionary(new Dictionary(StampRewardDict)); + } + internal static PlayerKVScratchState Missing() => new PlayerKVScratchState(false,default,default,default,default,default,default,default,default,default,default,default,default,default,default,new List(),new Dictionary()); + } + internal sealed class PlayerKVOfferItem + { + internal int Id { get; } + internal int Item { get; } + internal int Number { get; } + internal int Status { get; } + internal PlayerKVOfferItem(int id,int item,int number,int status) { Id=id; Item=item; Number=number; Status=status; } + } + internal sealed class PlayerKVOfferState + { + internal bool IsPresent { get; } + internal bool FirstOpen { get; } + internal int EventId { get; } + internal DateTime StartTime { get; } + internal DateTime EndTime { get; } + internal int ItemId { get; } + internal int LotteryNum { get; } + internal IReadOnlyList Items { get; } + internal IReadOnlyList RewardList { get; } + internal bool Finished { get; } + internal int ChestReward { get; } + internal PlayerKVOfferState(bool present,bool firstOpen,int eventId,DateTime startTime,DateTime endTime, + int itemId,int lotteryNum,IEnumerable items,IEnumerable rewardList,bool finished,int chestReward) + { + IsPresent=present; FirstOpen=firstOpen; EventId=eventId; StartTime=startTime; EndTime=endTime; + ItemId=itemId; LotteryNum=lotteryNum; Items=new List(items).AsReadOnly(); + RewardList=new List(rewardList).AsReadOnly(); Finished=finished; ChestReward=chestReward; + } + internal static PlayerKVOfferState Missing() => new PlayerKVOfferState(false,false,0,default,default,0,0, + Array.Empty(),Array.Empty(),false,0); + } + internal sealed class PlayerKVEventConfig + { + internal int RedirectId { get; } + internal string EndTime { get; } + internal PlayerKVEventConfig(int redirectId,string endTime) { RedirectId=redirectId; EndTime=endTime; } + } + internal sealed class PlayerKVEventPackState + { + internal int EventId { get; } + internal int PurchaseCount { get; } + internal int VipLevel { get; } + internal int RedirectId { get; } + internal PlayerKVEventPackState(int eventId,int purchaseCount,int vipLevel,int redirectId) + { EventId=eventId; PurchaseCount=purchaseCount; VipLevel=vipLevel; RedirectId=redirectId; } + } + internal sealed class PlayerKVCampState + { + internal bool IsFix { get; } + internal string Skyscraper { get; } + internal IReadOnlyDictionary Values { get; } + internal PlayerKVCampState(bool isFix,string skyscraper,IDictionary values) + { IsFix=isFix; Skyscraper=skyscraper; Values=new ReadOnlyDictionary(new Dictionary(values,StringComparer.Ordinal)); } + } + internal sealed class PlayerKVClubState + { + internal DateTime? SignTime { get; } + internal DateTime DonateTime { get; } + internal int DonateCount { get; } + internal DateTime RequestEnd { get; } + internal bool OpenRed { get; } + internal PlayerKVClubState(DateTime? signTime, DateTime donateTime, int donateCount, DateTime requestEnd, bool openRed) + { SignTime=signTime; DonateTime=donateTime; DonateCount=donateCount; RequestEnd=requestEnd; OpenRed=openRed; } + } + internal sealed class PlayerKVProgressPackState + { + internal bool IsPresent { get; } + internal int EventId { get; } + internal int RedirectId { get; } + internal int TokenProgress { get; } + internal int ChainProgress { get; } + internal PlayerKVProgressPackState(bool present, int eventId, int redirectId, int tokenProgress, int chainProgress) + { IsPresent=present; EventId=eventId; RedirectId=redirectId; TokenProgress=tokenProgress; ChainProgress=chainProgress; } + } + internal sealed class PlayerKVIntMapState + { + internal IReadOnlyDictionary Values { get; } + internal PlayerKVIntMapState(IDictionary values) + { Values = new ReadOnlyDictionary(new Dictionary(values)); } + } + internal sealed class PlayerKVGuideValue + { + internal int Index { get; } + internal bool Finished { get; } + internal string Group { get; } + internal PlayerKVGuideValue(int index, bool finished, string group) + { Index = index; Finished = finished; Group = group; } + internal bool Same(PlayerKVGuideValue other) => Index == other.Index && Finished == other.Finished && Group == other.Group; + } + internal sealed class PlayerKVGuideState + { + internal IReadOnlyDictionary Values { get; } + internal PlayerKVGuideState(IDictionary values) + { Values = new ReadOnlyDictionary(new Dictionary(values, StringComparer.Ordinal)); } + } + internal sealed class PlayerKVMonthlySignState + { + internal bool IsPresent { get; } + internal int Index { get; } + internal int DailyId { get; } + internal int DailyIndex { get; } + internal int LastId { get; } + internal string Time { get; } + internal PlayerKVMonthlySignState(bool present, int index, int dailyId, int dailyIndex, int lastId, string time) + { IsPresent = present; Index = index; DailyId = dailyId; DailyIndex = dailyIndex; LastId = lastId; Time = time; } + } + internal sealed class PlayerKVDailyValue + { + internal int Id { get; } internal int Index { get; } internal int LastId { get; } internal string Time { get; } + internal PlayerKVDailyValue(int id,int index,int lastId,string time) { Id=id; Index=index; LastId=lastId; Time=time; } + } + internal sealed class PlayerKVAdvertValue + { + internal int Id { get; } internal int Index { get; } internal int Day { get; } + internal string Time { get; } internal bool Open { get; } + internal PlayerKVAdvertValue(int id,int index,int day,string time,bool open) { Id=id; Index=index; Day=day; Time=time; Open=open; } + } + internal sealed class PlayerKVShopItemValue + { + internal int Id { get; } internal float Discount { get; } internal int Bought { get; } + internal PlayerKVShopItemValue(int id,float discount,int bought) { Id=id; Discount=discount; Bought=bought; } + } + internal sealed class PlayerKVCouponState + { + internal DateTime ActivateTime { get; } internal int EventId { get; } internal int RedirectId { get; } internal bool Bought { get; } + internal PlayerKVCouponState(DateTime time,int eventId,int redirectId,bool bought) { ActivateTime=time; EventId=eventId; RedirectId=redirectId; Bought=bought; } + } + internal sealed class PlayerKVShopState + { + internal IReadOnlyDictionary Daily { get; } + internal IReadOnlyDictionary All { get; } internal IReadOnlyDictionary Gift { get; } + internal IReadOnlyDictionary Advert { get; } + internal IReadOnlyDictionary Items { get; } + internal IReadOnlyList Buy { get; } + internal int Voucher { get; } internal int RefreshCount { get; } internal DateTime RefreshTime { get; } + internal PlayerKVDailyValue Chain { get; } internal PlayerKVDailyValue Turntable { get; } + internal PlayerKVAdvertValue Vip { get; } internal PlayerKVCouponState Coupon { get; } + internal PlayerKVShopState(IDictionary daily,IDictionary all,IDictionary gift, + IDictionary advert,IDictionary items,IEnumerable buy,int voucher,int refreshCount, + DateTime refreshTime,PlayerKVDailyValue chain,PlayerKVDailyValue turntable,PlayerKVAdvertValue vip,PlayerKVCouponState coupon) + { + Daily=new ReadOnlyDictionary(new Dictionary(daily,StringComparer.Ordinal)); + All=new ReadOnlyDictionary(new Dictionary(all)); Gift=new ReadOnlyDictionary(new Dictionary(gift)); + Advert=new ReadOnlyDictionary(new Dictionary(advert)); + Items=new ReadOnlyDictionary(new Dictionary(items)); + Buy=new List(buy).AsReadOnly(); Voucher=voucher; RefreshCount=refreshCount; RefreshTime=refreshTime; + Chain=chain; Turntable=turntable; Vip=vip; Coupon=coupon; + } + } + internal sealed class PlayerKVLimitedValue + { + internal int Id { get; } internal double Duration { get; } internal string StartTime { get; } + internal PlayerKVLimitedValue(int id,double duration,string time) { Id=id; Duration=duration; StartTime=time; } + } + internal sealed class PlayerKVEventBaseState + { + internal IReadOnlyDictionary Limited { get; } + internal IReadOnlyDictionary Fixed { get; } + internal IReadOnlyDictionary Target { get; } internal IReadOnlyDictionary Rank { get; } + internal IReadOnlyDictionary Wheel { get; } + internal int Coupon { get; } internal int Operate { get; } internal int AllOperate { get; } + internal int Free { get; } internal int PvpToken { get; } + internal PlayerKVEventBaseState(IDictionary limited,IDictionary fixedValues, + IDictionary target,IDictionary rank,IDictionary wheel,int coupon,int operate,int allOperate,int free,int pvpToken) + { + Limited=new ReadOnlyDictionary(new Dictionary(limited)); + Fixed=new ReadOnlyDictionary(new Dictionary(fixedValues)); + Target=new ReadOnlyDictionary(new Dictionary(target)); Rank=new ReadOnlyDictionary(new Dictionary(rank)); + Wheel=new ReadOnlyDictionary(new Dictionary(wheel)); Coupon=coupon; Operate=operate; AllOperate=allOperate; Free=free; PvpToken=pvpToken; + } + } + internal sealed class PlayerKVFishConfig + { + internal int Id { get; } internal int Area { get; } internal int Quality { get; } internal int Card { get; } + internal float Weight { get; } internal float Extra { get; } internal float Random { get; } + internal PlayerKVFishConfig(int id,int area,int quality,int card,float weight,float extra,float random) + { Id=id; Area=area; Quality=quality; Card=card; Weight=weight; Extra=extra; Random=random; } + } + internal sealed class PlayerKVFishCardConfig + { + internal int Id { get; } internal int MaxLevel { get; } + internal IReadOnlyList Required { get; } internal IReadOnlyList Weights { get; } + internal PlayerKVFishCardConfig(int id,int max,IEnumerable required,IEnumerable weights) + { Id=id; MaxLevel=max; Required=new List(required).AsReadOnly(); Weights=new List(weights).AsReadOnly(); } + } + internal sealed class PlayerKVFishValue + { + internal int Level { get; } internal float Weight { get; } internal int Cards { get; } + internal int Number { get; } internal bool New { get; } + internal IReadOnlyList Compatibility { get; } + internal PlayerKVFishValue(int level,float weight,int cards,int number,bool isNew,IEnumerable compatibility=null) + { Level=level; Weight=weight; Cards=cards; Number=number; New=isNew; Compatibility=new List(compatibility ?? Array.Empty()).AsReadOnly(); } + internal bool Same(PlayerKVFishValue other) => Level==other.Level && Weight==other.Weight && Cards==other.Cards && Number==other.Number && New==other.New; + } + internal sealed class PlayerKVRodAccessories + { + internal int Glove { get; } internal IReadOnlyDictionary RodSkin { get; } + internal IReadOnlyList Gloves { get; } internal IReadOnlyList Skins { get; } + internal IReadOnlyList NewGloves { get; } internal IReadOnlyList NewSkins { get; } + internal PlayerKVRodAccessories(int glove,IDictionary rodSkin,IEnumerable gloves,IEnumerable skins,IEnumerable newGloves,IEnumerable newSkins) + { Glove=glove; RodSkin=new ReadOnlyDictionary(new Dictionary(rodSkin)); Gloves=new List(gloves).AsReadOnly(); + Skins=new List(skins).AsReadOnly(); NewGloves=new List(newGloves).AsReadOnly(); NewSkins=new List(newSkins).AsReadOnly(); } + } + internal sealed class PlayerKVFishState + { + internal IReadOnlyDictionary Rods { get; } internal IReadOnlyDictionary RodLevels { get; } + internal IReadOnlyDictionary Swim { get; } internal IReadOnlyDictionary SwimCount { get; } + internal IReadOnlyDictionary Progress { get; } internal IReadOnlyDictionary Honor { get; } + internal IReadOnlyDictionary Luck { get; } internal PlayerKVRodAccessories Accessories { get; } + internal IReadOnlyDictionary> RawMaps { get; } + internal IReadOnlyDictionary> Maps { get; } + internal IReadOnlyDictionary Fish { get; } internal IReadOnlyDictionary Cards { get; } + static IReadOnlyDictionary Copy(IDictionary values) => new ReadOnlyDictionary(new Dictionary(values)); + static IReadOnlyDictionary> CopyMaps(IDictionary> maps) => + new ReadOnlyDictionary>(maps.ToDictionary(p=>p.Key,p=>(IReadOnlyDictionary)new ReadOnlyDictionary(new Dictionary(p.Value)))); + internal PlayerKVFishState(IDictionary rods,IDictionary rodLevels,IDictionary swim,IDictionary swimCount, + IDictionary progress,IDictionary honor,IDictionary luck,PlayerKVRodAccessories accessories, + IDictionary> rawMaps,IDictionary> maps, + IDictionary fish,IDictionary cards) + { Rods=Copy(rods); RodLevels=Copy(rodLevels); Swim=Copy(swim); SwimCount=Copy(swimCount); Progress=Copy(progress); Honor=Copy(honor); + Luck=new ReadOnlyDictionary(new Dictionary(luck,StringComparer.Ordinal)); Accessories=accessories; + RawMaps=CopyMaps(rawMaps); Maps=CopyMaps(maps); Fish=new ReadOnlyDictionary(new Dictionary(fish)); Cards=Copy(cards); } + } + internal sealed class PlayerKVFootPrintState + { + internal ulong Bits { get; } + internal PlayerKVFootPrintState(ulong bits) { Bits = bits; } + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVDecodedSave.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVDecodedSave.cs.meta new file mode 100644 index 000000000..f58dacdb3 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVDecodedSave.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 35b5e1a67b2043b5aa3c34a67d2fe8f0 diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs new file mode 100644 index 000000000..114fcc7a9 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs @@ -0,0 +1,1450 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using UnityEditor; + +namespace game.PlayerDataStorage +{ + // Fixed module cases plus the complete B2-20 transaction. Global state is restored after actual drainage. + internal static class PlayerKVEditorValidationHost + { + internal static Task RunAsync(string caseId) + { + if (caseId != "M08-M23" && caseId != "M21-M22-M24" && caseId != "M09-M10-M12" && + caseId != "M14" && caseId != "M07" && caseId != "M20-M21-M22-M24-M28-M29" && caseId != "M16-M17" && + caseId != "M06-M13" && caseId != "M15-M26-M27" && caseId != "M30" && caseId != "M19" && caseId != "M11" && caseId != "M25" && + caseId != "M01-M02-M21-M22-M24" && caseId != "M04-M21-M22-M24" && caseId != "M05-M21-M22-M24" && caseId != "M03" && caseId != "M18" && caseId != "M31" && caseId != "B2-20") throw new ArgumentException("Unknown fixed validation case."); + if (EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating) + throw new InvalidOperationException("Editor is not idle."); + int assertions = 0; + if (caseId == "B2-20") return RunFullLoad(); + if (caseId == "M31") return RunLoginState(); + if (caseId == "M18") return RunPartner(); + if (caseId == "M03") return RunFish(); + if (caseId == "M21-M22-M24") return RunD3(); + if (caseId == "M09-M10-M12") return RunCollections(); + if (caseId == "M14") return RunClub(); + if (caseId == "M20-M21-M22-M24-M28-M29") return RunD3(true); + if (caseId == "M07") return RunCamp(); + if (caseId == "M16-M17") return RunOffers(); + if (caseId == "M06-M13") return RunBuffTrigger(); + if (caseId == "M15-M26-M27") return RunActivities(); + if (caseId == "M30") return RunDigging(); + if (caseId == "M19") return RunCube(); + if (caseId == "M11") return RunBattlePass(); + if (caseId == "M25") return RunPotion(); + if (caseId == "M05-M21-M22-M24") return RunD3(false,false,false,true); + if (caseId == "M04-M21-M22-M24") return RunD3(false,false,true); + if (caseId == "M01-M02-M21-M22-M24") return RunD3(false,true); + void Check(bool value) { assertions++; if (!value) throw new InvalidOperationException("KV module assertion failed."); } + var names = Enum.GetNames(typeof(cfg.ConditionType)).ToDictionary(n => n, + n => (int)Enum.Parse(typeof(cfg.ConditionType), n), StringComparer.Ordinal); + var snapshot = new PlayerKVSchemaSnapshot("synthetic-M08-M23", new[] { 101 }, new[] { 1 }, names, + CultureInfo.InvariantCulture, new DateTime(2026, 9, 23, 0, 0, 0, DateTimeKind.Utc), 17); + var achievements = new AchievementDataManager(null); + var foot = new FootPrintService(); + try + { + var e = new PlayerKVSchemaDiagnostics(); + var a = AchievementDataManager.DecodeForKVLoad(new Dictionary(), + new Dictionary { ["AD"] = "{\"0\":\"7|18446744073709551615|9\"}" }, snapshot, e); + Check(a != null && e.TotalErrors == 0); + achievements.ResetForKVLoad(); achievements.ReplaceFromKVDecoded(a); + var actual = achievements.FindForKVValidation((cfg.ConditionType)0); + Check(actual != null && actual.Id == 7 && actual.Target == ulong.MaxValue && actual.day == 9); + actual.Target = 0; + Check(achievements.FindForKVValidation((cfg.ConditionType)0).Target == ulong.MaxValue); + var bad = AchievementDataManager.DecodeForKVLoad(new Dictionary(), + new Dictionary { ["AD"] = "{\"0\":\"7|9\"}", ["ad"] = "0:7|1|9" }, snapshot, new PlayerKVSchemaDiagnostics()); + Check(bad == null && achievements.FindForKVValidation((cfg.ConditionType)0).Target == ulong.MaxValue); + var b = AchievementDataManager.DecodeForKVLoad(new Dictionary(), new Dictionary(), snapshot, new PlayerKVSchemaDiagnostics()); + achievements.ResetForKVLoad(); achievements.ReplaceFromKVDecoded(b); + Check(achievements.IsEmptyForKVValidation && achievements.FindForKVValidation((cfg.ConditionType)0) == null); + foreach (ulong bits in new[] { 0UL, 255UL, 1UL << 31, 1UL << 32, ulong.MaxValue }) + { + e = new PlayerKVSchemaDiagnostics(); + var state = FootPrintService.DecodeForKVLoad(new Dictionary + { ["FootPrint"] = bits.ToString(CultureInfo.InvariantCulture) }, e); + Check(state != null && e.TotalErrors == 0); + foot.ResetForKVLoad(); foot.ReplaceFromKVDecoded(state); + Check(foot.CompleteFootPrintForKVValidation == bits); + Check(unchecked((uint)(int)foot.FootPrint) == unchecked((uint)bits)); + var missing = FootPrintService.DecodeForKVLoad(new Dictionary(), new PlayerKVSchemaDiagnostics()); + foot.ResetForKVLoad(); foot.ReplaceFromKVDecoded(missing); + Check(foot.CompleteFootPrintForKVValidation == 0 && (int)foot.FootPrint == 0); + } + foreach (string value in new[] { "-1", "18446744073709551616", "null" }) + Check(FootPrintService.DecodeForKVLoad(new Dictionary { ["FootPrint"] = value }, new PlayerKVSchemaDiagnostics()) == null); + return Task.FromResult(JsonConvert.SerializeObject(new + { success = true, caseId, assertions, scope = "M08-M23-only", fullB220 = false, sharedContainerTouched = false })); + } + catch (Exception ex) + { + return Task.FromResult(JsonConvert.SerializeObject(new + { success = false, caseId, assertions, errorType = ex.GetType().Name, scope = "M08-M23-only", fullB220 = false })); + } + finally + { + // No global replacement or background work exists in these synchronous pure cases. + achievements.ResetForKVLoad(); achievements.Dispose(); foot.ResetForKVLoad(); + } + } + + + + + static bool Same(PlayerKVPlayerState a,PlayerKVPlayerState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.Core,b.Core) && SameDictionary(a.Modern,b.Modern,(x,y)=>Same(x,y)) && SameDictionary(a.VipScores,b.VipScores,(x,y)=>Same(x,y)) && SameList(a.NewRods,b.NewRods,(x,y)=>Same(x,y)) && Same(a.Season,b.Season) && Same(a.Appraise,b.Appraise) && Same(a.Building,b.Building)); + static bool Same(PlayerKVShopState a,PlayerKVShopState b) => ReferenceEquals(a,b) || (a!=null && b!=null && SameDictionary(a.Daily,b.Daily,(x,y)=>Same(x,y)) && SameDictionary(a.All,b.All,(x,y)=>Same(x,y)) && SameDictionary(a.Gift,b.Gift,(x,y)=>Same(x,y)) && SameDictionary(a.Advert,b.Advert,(x,y)=>Same(x,y)) && SameDictionary(a.Items,b.Items,(x,y)=>Same(x,y)) && SameList(a.Buy,b.Buy,(x,y)=>Same(x,y)) && Same(a.Voucher,b.Voucher) && Same(a.RefreshCount,b.RefreshCount) && Same(a.RefreshTime,b.RefreshTime) && Same(a.Chain,b.Chain) && Same(a.Turntable,b.Turntable) && Same(a.Vip,b.Vip) && Same(a.Coupon,b.Coupon)); + static bool Same(PlayerKVEventBaseState a,PlayerKVEventBaseState b) => ReferenceEquals(a,b) || (a!=null && b!=null && SameDictionary(a.Limited,b.Limited,(x,y)=>Same(x,y)) && SameDictionary(a.Fixed,b.Fixed,(x,y)=>Same(x,y)) && SameDictionary(a.Target,b.Target,(x,y)=>Same(x,y)) && SameDictionary(a.Rank,b.Rank,(x,y)=>Same(x,y)) && SameDictionary(a.Wheel,b.Wheel,(x,y)=>Same(x,y)) && Same(a.Coupon,b.Coupon) && Same(a.Operate,b.Operate) && Same(a.AllOperate,b.AllOperate) && Same(a.Free,b.Free) && Same(a.PvpToken,b.PvpToken)); + static bool Same(PlayerKVCampState a,PlayerKVCampState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.IsFix,b.IsFix) && Same(a.Skyscraper,b.Skyscraper) && SameDictionary(a.Values,b.Values,(x,y)=>Same(x,y))); + static bool Same(PlayerKVClubState a,PlayerKVClubState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.SignTime,b.SignTime) && Same(a.DonateTime,b.DonateTime) && Same(a.DonateCount,b.DonateCount) && Same(a.RequestEnd,b.RequestEnd) && Same(a.OpenRed,b.OpenRed)); + static bool Same(PlayerKVEventPackState a,PlayerKVEventPackState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.EventId,b.EventId) && Same(a.PurchaseCount,b.PurchaseCount) && Same(a.VipLevel,b.VipLevel) && Same(a.RedirectId,b.RedirectId)); + static bool Same(PlayerKVPiggyBankState a,PlayerKVPiggyBankState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.IsPresent,b.IsPresent) && Same(a.currentEventID,b.currentEventID) && Same(a.purchaseCount,b.purchaseCount) && Same(a.VIPLvlWhenEventActivate,b.VIPLvlWhenEventActivate) && Same(a.savingProgress,b.savingProgress) && Same(a.redirectID,b.redirectID) && Same(a.doNeedTrigger,b.doNeedTrigger) && Same(a.isTriggered,b.isTriggered) && Same(a.inflation,b.inflation)); + static bool Same(PlayerKVBargainState a,PlayerKVBargainState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.IsPresent,b.IsPresent) && Same(a.currentEventID,b.currentEventID) && Same(a.redirectID,b.redirectID) && Same(a.VIPLvlWhenEventActivate,b.VIPLvlWhenEventActivate) && Same(a.purchaseCount,b.purchaseCount) && Same(a.savingProgress,b.savingProgress) && Same(a.discountLvl,b.discountLvl) && Same(a.visualProgress,b.visualProgress) && Same(a.visualDiscountLvl,b.visualDiscountLvl) && Same(a.rodID,b.rodID) && Same(a.lastPackIDBought,b.lastPackIDBought) && Same(a.packID,b.packID) && Same(a.doNeedTrigger,b.doNeedTrigger) && Same(a.isTriggered,b.isTriggered) && Same(a.inflation,b.inflation) && (a.selections==null ? b.selections==null : b.selections!=null && SameList(a.selections,b.selections,(x,y)=>Same(x,y)))); + static bool Same(PlayerKVShootingRangeState a,PlayerKVShootingRangeState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.IsPresent,b.IsPresent) && Same(a.EventId,b.EventId) && Same(a.CycleId,b.CycleId) && Same(a.RedirectId,b.RedirectId) && Same(a.AmmoCount,b.AmmoCount) && Same(a.CurrentStageIdx,b.CurrentStageIdx) && Same(a.ChainProgress,b.ChainProgress) && Same(a.RewardFlag,b.RewardFlag) && Same(a.TargetFlag,b.TargetFlag) && Same(a.HasBeenOpened,b.HasBeenOpened) && Same(a.Inflation,b.Inflation)); + static bool Same(PlayerKVProgressPackState a,PlayerKVProgressPackState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.IsPresent,b.IsPresent) && Same(a.EventId,b.EventId) && Same(a.RedirectId,b.RedirectId) && Same(a.TokenProgress,b.TokenProgress) && Same(a.ChainProgress,b.ChainProgress)); + static bool Same(PlayerKVShopItemValue a,PlayerKVShopItemValue b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.Id,b.Id) && Same(a.Discount,b.Discount) && Same(a.Bought,b.Bought)); + static bool Same(PlayerKVDailyValue a,PlayerKVDailyValue b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.Id,b.Id) && Same(a.Index,b.Index) && Same(a.LastId,b.LastId) && Same(a.Time,b.Time)); + static bool Same(PlayerKVAdvertValue a,PlayerKVAdvertValue b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.Id,b.Id) && Same(a.Index,b.Index) && Same(a.Day,b.Day) && Same(a.Time,b.Time) && Same(a.Open,b.Open)); + static bool Same(PlayerKVCouponState a,PlayerKVCouponState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.ActivateTime,b.ActivateTime) && Same(a.EventId,b.EventId) && Same(a.RedirectId,b.RedirectId) && Same(a.Bought,b.Bought)); + static bool Same(PlayerKVLimitedValue a,PlayerKVLimitedValue b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.Id,b.Id) && Same(a.Duration,b.Duration) && Same(a.StartTime,b.StartTime)); + static bool Same(PlayerKVFixedTimeValue a,PlayerKVFixedTimeValue b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.id,b.id) && Same(a.endTime,b.endTime)); + static bool Same(T a,T b) => EqualityComparer.Default.Equals(a,b); + static bool SameList(IReadOnlyList a,IReadOnlyList b,Func same) => + a.Count==b.Count && Enumerable.Range(0,a.Count).All(i=>same(a[i],b[i])); + static bool SameDictionary(IReadOnlyDictionary a,IReadOnlyDictionary b,Func same) => + a.Count==b.Count && a.All(p=>b.TryGetValue(p.Key,out var value) && same(p.Value,value)); + static bool Same(PlayerKVCoreState a,PlayerKVCoreState b) => Enumerable.Range(0,16).All(i=> + i==1 || i==10 ? a.Unsigned(i)==b.Unsigned(i) : i==7 ? a.EnergyTime==b.EnergyTime : a.Integer(i)==b.Integer(i)); + static bool ContainsPayload(Newtonsoft.Json.Linq.JToken actual,Newtonsoft.Json.Linq.JToken expected) + { + if(expected is Newtonsoft.Json.Linq.JObject obj) + return actual is Newtonsoft.Json.Linq.JObject && obj.Properties().All(p=>ContainsPayload(actual[p.Name],p.Value)); + if(expected is Newtonsoft.Json.Linq.JArray arr) + return actual is Newtonsoft.Json.Linq.JArray other && arr.Count==other.Count && Enumerable.Range(0,arr.Count).All(i=>ContainsPayload(other[i],arr[i])); + return Newtonsoft.Json.Linq.JToken.DeepEquals(actual,expected); + } + sealed class ValidationConfig : asap.core.IConfig + { + readonly Dictionary values=new Dictionary(); + public void Set(string key,object value) => values[key]=value; + public T Get(string key,T fallback) => values.TryGetValue(key,out var value) ? (T)Convert.ChangeType(value,typeof(T),CultureInfo.InvariantCulture) : fallback; + public string this[string key] => Get(key,(string)null); + public float GetFloat(string key,float fallback) => Get(key,fallback); + } + sealed class ValidationEvents : asap.core.IEventAggregator + { + readonly asap.core.EventAggregator inner=new asap.core.EventAggregator(); + internal int Published,Subscriptions; + public IObservable GetEvent(string key) { Subscriptions++; return inner.GetEvent(key); } + public void Publish(T value,string key) { Published++; throw new InvalidOperationException("Gameplay event during controlled load."); } + } + sealed class ValidationHttp : System.Net.Http.HttpMessageHandler + { + readonly Func send; + internal ValidationHttp(Func send) { this.send=send; } + protected override Task SendAsync(System.Net.Http.HttpRequestMessage request,System.Threading.CancellationToken token) => Task.FromResult(send(request)); + } + static System.Net.Http.HttpResponseMessage ValidationJson(object value) => new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + { Content=new System.Net.Http.StringContent(JsonConvert.SerializeObject(value),System.Text.Encoding.UTF8,"application/json") }; + static Dictionary FullRaw(cfg.Tables tables,bool optional) + { + var now=new DateTime(2026,9,23,0,0,0,DateTimeKind.Utc); + string day=now.ToString("O",CultureInfo.InvariantCulture),end=now.AddDays(1).ToString("O",CultureInfo.InvariantCulture); + var modern=new Dictionary{["newData"]=string.Join(",",new[]{"3","999","4","5","6","7",tables.TbRodData.DataList[0].ID.ToString(CultureInfo.InvariantCulture),day,"-1","8","77","9","0","0","10","11","12","13","14"})}; + var raw=new Dictionary(); + void Put(string key,object value) { raw[key]=JsonConvert.SerializeObject(value); } + if(optional) + { + modern["vipL"]="1,2"; modern["ld"]="1.25|2.5|3"; modern["bd"]="1,2"; modern["ct"]="1:2"; modern["rt"]="3:4"; + int condition=(int)Enum.GetValues(typeof(cfg.ConditionType)).GetValue(0); modern["ad"]=condition+":1|2|3"; + Put("NewRodList",new[]{7,8}); raw["SeasonId"]="9"; raw["IsOpenAppraise"]="1"; raw["InfiniteBuildingData"]="synthetic"; + foreach(var key in new[]{"RodData","RodLevelData","FishSwimToMap","FishSwimToMapCount","MapProgress","HonorLevelData"}) raw[key]="{\"-1\":7}"; + var fish=tables.TbFishData.DataList.First(f=>tables.TbMapData.DataMap.ContainsKey(f.AreaID)); + Put("Map_5_"+fish.AreaID,new Dictionary{[fish.ID]="0|-1|2|3|True"}); + Put("InitRodAccessoriesData",new{GloveID=1,RodToSkin=new Dictionary{[2]=3},GloveIds=new[]{4},SkinIds=new[]{5},GloveNewIds=new[]{6},SkinNewIds=new[]{7}}); + raw["ShopPackBuyCount"]="{\"x\":1}"; raw["ShopAllPackBuyCount"]="{\"2\":3}"; raw["ShopGiftBuyCount"]="{\"4\":5}"; raw["Voucher"]="6"; + var daily=new{ID=1,index=2,lastID=3,time="raw"}; Put("ChainPackDataNew",daily); Put("TurntableAdData",daily); + Put("VIPAdPackData",new{ID=7,index=8,day=9,cdTime="raw",isOpen=true}); Put("BuyData",new[]{1.5,2.5}); raw["AdvertAllCount"]="{\"-2\":10}"; + raw["DiamondShopRefreshCount"]="11"; raw["DiamondShopRefreshTime"]=day; + Put("DiamondShopItemRecords",new Dictionary{[12]=new{ItemID=13,Discount=.5,hasBought=1}}); raw["BlackFridayCouponDate"]="2026,9,23,1,2,3,14,15,1"; + Put("LimitedTimeEventDic",new Dictionary{[1]=new{id=2,duration=9999999d,startTime=day}}); + Put("FixedTimeEventDic",new Dictionary{[3]=new{id=4,endTime=end}}); raw["WheelLevelCount"]="{\"5\":6}"; + foreach(var key in new[]{"TurntableRoll","WheelOperateCount","AllWheelOperateCount","WheelLevelFreeCount","PVPToken"}) raw[key]="7"; + var buff=new{SortID=1,sourceID=2,buffID=3,isEnd=false,buffEndTime=end}; + Put("BuffDataList",new Dictionary{[7]=buff}); Put("TargetBuffDataList",new[]{buff}); Put("FishDailyBuff",buff); Put("GlobalBuffDataList",new Dictionary{[7]=buff}); + raw["IsFix"]="true";raw["IsOpenSkyscraper"]="1";raw["CampDataDic"]="{\"CampId\":205}";raw["GuideSaveDataNew"]="A|3|1;B|4|0"; + Put("BattlePassRecord",new{ID=1,Level=2,Exp=3,BuyVipToastIndex=4,IsUnlockVip=1,NormalRewardGet=new[]{5,6},VipRewardGet=new[]{7,8},LastDropIDs=new[]{9},InflationRate=2.5f}); + Put("BattleTask",new{dic_dailyTasks=new Dictionary{[9]=10},dic_weeklyTasks=new Dictionary{[11]=12},loginDays=new[]{13,14},DailyTaskEndTime=now,WeeklyTaskEndTime=now.AddDays(1)}); + Put("MonthlySignEvent",new{index=4,dailySignEvent=daily}); + Put("TriggerPackDataList",new[]{new{ID=1,index=2,lastID=3,vipLevel=4,mapID=5,time="raw"}}); + Put("GiftVIPDData",new Dictionary{["A"]=new{oldVipLevel=1,curVipLevel=2,timer=3}}); Put("FixedTimeShopDic",new Dictionary{[7]=new{id=8,endTime=end}}); + raw["ClubSignin"]=day;raw["IsOpenClubRed"]="1";Put("LureDonateData",new{DonateCountTime=day,ClubLureDonateCount=7,ClubLureRequestCDEnd=end}); + Put("FishingChallengeData",new{FirstOpen=true,EventId=11,RankTierLevel=11,Finished=11,RedirectID=11,Score=11,ProcessedScore=11,step=11,uiStep=11,index=11,StartTime=day,EndTime=end,eliminateNumber=new[]{7,9},robotAccountList=new[]{7,9},InflationRate=1.5f}); + var offer=new{FirstOpen=true,EventId=7,StartTime=day,EndTime=end,ItemId=-1,LotteryNum=5,Items=new[]{new{Id=1,Item=2,Number=3,Status=2}},RewardList=new[]{8,9},Finished=true,ChestReward=10}; + Put("OfferChainsChestData",offer);Put("OfferDiscoPartyManager",offer); + Put("EventPartnerGatherData",new{FirstOpen=false,EventId=18,StartTime=day,EndTime=end,CurrentPoints=1,CurrentMiningProgress=2,Token=3,InvitedCount=4,TicketCount=5, + CompletedNodesList=new[]{new[]{6,7}},BuilderPartnerScore=new[]{new{Item1=8,Item2=9}},PartnerBonus=new{Item1=1.25f,Item2=end},FreeSpinNum=10, + Bots=new Dictionary{[11]=new{Id="synthetic-bot",ScorePlayer=12,ScoreBot=13,Grade=14,TargetScore=15,WakeStartTime=day,WakingSeconds=16}}, + IsRecvFinalReward=true,UIFinishedPartners=new[]{true,false},IsFinished=true,SpinState=2,InflationRate=2.5f,IsEnableAutoMatch=true,LastAutoMatchTime=day}); + Put("EventCubeMeltData",new{TicketCount=1,FirstOpen=true,IsFinished=true,EventId=2,IsInGuide=true,StartTime=day,EndTime=end,CurrentStagetId=3, + CollectItems=new[]{new{ItemId=4,Count=5,MaxCount=6,ItemTrueId=7}},BoardData=new[]{8,9},BoardViewSize=new{x=10,y=11},CollectRewardsId=new[]{12,13},IsFinalRewardGet=true,PieceDatas=new[]{new[]{14,15},new[]{16,17}},Cycle=18,InflationRate=1.5f}); + Put("EventPackData",new{currentEventID=-1,purchaseCount=2,VIPLvlWhenEventActivate=3,redirectID=4}); + Put("PiggyBankPackData",new{currentEventID=11,purchaseCount=11,VIPLvlWhenEventActivate=11,savingProgress=11,redirectID=0,doNeedTrigger=true,isTriggered=true,inflation=1.5f}); + Put("GeneralEventPackData",new{currentEventID=11,redirectID=tables.TbEventPackManager.DataList.First(p=>p.PackType==3).ID,VIPLvlWhenEventActivate=11,purchaseCount=11,savingProgress=11,discountLvl=11,visualProgress=11,visualDiscountLvl=11,rodID=11,lastPackIDBought=11,packID=11,doNeedTrigger=true,isTriggered=true,inflation=1.5f,selections=new[]{7,9}}); + raw["FootPrint"]="18446744073709551615"; + Put("EventShootingRangeData",new{EventId=11,CycleId=11,RedirectId=11,AmmoCount=11,CurrentStageIdx=11,ChainProgress=11,RewardFlag=long.MaxValue,TargetFlag=long.MinValue,HasBeenOpened=true,Inflation=1.5f}); + var item=new{name="synthetic",index=3,isOver=true,DataType=2,ColorTypeList=new[]{7,9},ShelfTypeList=new[]{0,1},GridStateList=new[]{0,5},FromDisplayAreaIndexList=new[]{1,2}}; + Put("epdk",new{EventID=11,PotionID=11,StageID=11,Stage1TotalLevel=11,Stage2TotalLevel=11,LevelID=11,LevelIndex=11,CorrectNum=11,SubmitNum=11,Token=11,ConsumedToken=11,Rule=11,PreEventID=11,PrePotionID=11,PreStageID=11,PreLevelID=11,Round=11,FirstPlayedEventId=11,IsOver=true,Name="synthetic",InflationRate=1.5f,LevelTargetData=item,LevelDisplayData=item,LevelProcessedList=new[]{item}}); + Put("epbdk",new{EventID=11,PinballMainID=11,SpinPoint=11,Scores=11,Multiple=11,Token=11,ConsumedToken=11,RewardIndex=11,Force=1.5f,LuckyValue=1.5f,InflationRate=1.5f}); + Put("estdk",new{EventID=11,ScratchMainID=11,ChainPackID=11,Token=11,ConsumedToken=11,RewardTaskConsumedToken=11,EventScratchRewardTaskID=11,EventScratchCardID=11,TotalConsumedToken=11,ChainPackProgress=11,magIndex=11,IsOver=true,LuckyValue=1.5f,InflationRate=1.5f,StampRewardList=new[]{7,9},StampRewardDict=new Dictionary{[7]=new{EventScratchCardID=8,Count=9}}}); + raw["ThanksGivingPackData"]="1,2,3,4";raw["ThanksGivingPackMigrationData"]="5,6,7,8";Put("EventPinataPackData",new{EventID=7,PinataMainID=8,PurchasedCount=9}); + raw["EAccoutUrl"]="{\"synthetic\":\"avatar\"}";raw["hwGoogle"]="1";raw["Apple"]="1";raw["TapJoy_Diamond"]="12"; + } + Put("NewPlayerDataDic",modern); return raw; + } + static async Task RunFullLoad() + { + if(running || asap.core.GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll().Length!=0 || PlayFabMgr.HasLiveInstanceForKVLoad) + throw new InvalidOperationException("Game context is active."); + running=true; int assertions=0,auth=0,gets=0,borrowed=0,forbidden=0; bool success=false,restored=false; string errorType=null; + var originalContainer=asap.core.GContext.container; var originalEvents=ValidationContextAccess.Events; + var isolated=new asap.core.Container(); var events=new ValidationEvents(); UnityEngine.GameObject shell=null; + AggUserService user=null; PlayerKVGameLoadTransaction transaction=null; + void Check(bool value) { assertions++; if(!value) throw new InvalidOperationException("Full load assertion failed."); } + try + { + var directory=System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath,"../../cfgData")); + var tables=new cfg.Tables(name=>SimpleJSON.JSON.Parse(System.IO.File.ReadAllText(System.IO.Path.Combine(directory,name+".json")))); + var snapshot=new PlayerKVSchemaSnapshot("local-full-4B2",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys, + Enum.GetNames(typeof(cfg.ConditionType)).ToDictionary(n=>n,n=>(int)Enum.Parse(typeof(cfg.ConditionType),n)),CultureInfo.InvariantCulture, + new DateTime(2026,9,23,0,0,0,DateTimeKind.Utc),tables.TbGlobalConfig.InitWheelTicket, + tables.TbEventPackManager.DataMap.ToDictionary(p=>p.Key,p=>p.Value.PackType), + tables.TbFishingEvent.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVEventConfig(p.Value.RedirectID,(p.Value.TimeDefinition as cfg.LimitedTime)?.EndTime)),tables.TbGlobalConfig.GlobalTimeOffset, + tables.TbFishData.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishConfig(p.Key,p.Value.AreaID,p.Value.Quality,p.Value.FishCardID,p.Value.BaseWeight,p.Value.PointExtraParam,p.Value.WeightRandom)), + tables.TbFishCard.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishCardConfig(p.Key,p.Value.MaxLevel,p.Value.CardsRequiredList,p.Value.WeightMagList))); + ValidationContextAccess.Use(isolated); ValidationContextAccess.Events=events; + Check(asap.core.ContainerExtensions.RegisterInstance(isolated,tables)); + shell=new UnityEngine.GameObject("KV controlled inactive fixture"); shell.SetActive(false); shell.hideFlags=UnityEngine.HideFlags.HideAndDontSave; + var pf=shell.AddComponent(); Check(pf.IsUnstartedForKVLoad && !PlayFabMgr.HasLiveInstanceForKVLoad); + var api=new Uri("http://synthetic.invalid/"); var config=new ValidationConfig(); config.Set(GConstant.K_Event_API_URL,api.AbsoluteUri); + var server=new CustomServerMgr(config,_=>new ValidationHttp(_=>{forbidden++;throw new InvalidOperationException("PF API transport forbidden.");})); + user=new AggUserService(PlayerKVControlledLoadCapability.CreateForValidation(_=>throw new InvalidOperationException())){CustomId="101",config=config,customServerMgr=server}; + var player=new GameCore.PlayerData(tables,null); var small=new GameCore.SmallGameData(tables,null,null); var fish=new GameCore.PlayerFishData(tables,null); + var shop=new GameCore.PlayerShopData(); var eventBase=new GameCore.FishingEventData(tables); var buffs=new BuffDataCenter(tables); var camp=new CampDataMM(tables,null); + var achievements=new AchievementDataManager(tables); var box=new FishingBoxData(); var guide=new GameCore.GuideDataCenter(null,null); var battle=new BattlePassData(tables); + var sign=new GameCore.SigninData(tables); var trigger=new GameCore.TriggerPackData(tables); var club=new ClubService(); var challenge=new FishingChallengeManager(); + var chains=new DataCenter.OfferChainsChestManager(); var disco=new DataCenter.OfferDiscoPartyManager(); var partner=new DataCenter.EventPartnerGatherManager(); var cube=new EventCubeMeltManager(); + var eventPack=new EventPackData(); var piggy=new PiggyBankPackData(); var bargain=new BargainPackData(); var foot=new FootPrintService(); var shooting=new EventShootingRangeData(); + var potion=new EventPotionDataManager(); var pinball=new EventPinballDataManager(); var scratch=new EventScratchTicketDataManager(); + var progress=eventBase.ThanksGivingPackData; var migration=eventBase.ChainPackWithProgressMigrationData; + var pinataTarget=new EventPinataPackData(); Check(asap.core.ContainerExtensions.RegisterInstance(isolated,pinataTarget)); + var pinata=EventPinataPackDataManager.CreateForKVLoad(); var digging=new Script.RuntimeScript.DiggingGameManager(); + var modules=new List(); + void Add(int id,Action reset,Action replace) where T:class => modules.Add(new PlayerKVLoadModule(id,reset,d=>replace(d.Module(id)))); + Add(1,player.ResetForKVLoad,player.ReplaceFromKVDecoded); Add(2,small.ResetForKVLoad,small.ReplaceFromKVDecoded); + Add(3,fish.ResetForKVLoad,fish.ReplaceFromKVDecoded); Add(4,shop.ResetForKVLoad,shop.ReplaceFromKVDecoded); + Add(5,eventBase.ResetForKVLoad,eventBase.ReplaceFromKVDecoded); Add(6,buffs.ResetForKVLoad,buffs.ReplaceFromKVDecoded); + Add(7,camp.ResetForKVLoad,camp.ReplaceFromKVDecoded); Add(8,achievements.ResetForKVLoad,achievements.ReplaceFromKVDecoded); + Add(9,box.ResetForKVLoad,box.ReplaceFromKVDecoded); Add(10,guide.ResetForKVLoad,guide.ReplaceFromKVDecoded); + Add(11,battle.ResetForKVLoad,battle.ReplaceFromKVDecoded); Add(12,sign.ResetForKVLoad,sign.ReplaceFromKVDecoded); + Add(13,trigger.ResetForKVLoad,trigger.ReplaceFromKVDecoded); Add(14,club.ResetForKVLoad,club.ReplaceFromKVDecoded); + Add(15,challenge.ResetForKVLoad,challenge.ReplaceFromKVDecoded); Add(16,chains.ResetForKVLoad,chains.ReplaceFromKVDecoded); + Add(17,disco.ResetForKVLoad,disco.ReplaceFromKVDecoded); Add(18,partner.ResetForKVLoad,partner.ReplaceFromKVDecoded); + Add(19,cube.ResetForKVLoad,cube.ReplaceFromKVDecoded); Add(20,eventPack.ResetForKVLoad,eventPack.ReplaceFromKVDecoded); + Add(21,piggy.ResetForKVLoad,piggy.ReplaceFromKVDecoded); Add(22,bargain.ResetForKVLoad,bargain.ReplaceFromKVDecoded); + Add(23,foot.ResetForKVLoad,foot.ReplaceFromKVDecoded); Add(24,shooting.ResetForKVLoad,shooting.ReplaceFromKVDecoded); + Add(25,potion.ResetForKVLoad,potion.ReplaceFromKVDecoded); Add(26,pinball.ResetForKVLoad,pinball.ReplaceFromKVDecoded); + Add(27,scratch.ResetForKVLoad,scratch.ReplaceFromKVDecoded); + Add(28,()=>{progress.ResetForKVLoad();migration.ResetForKVLoad();},p=>{progress.ReplaceFromKVDecoded(p.Current);migration.ReplaceFromKVDecoded(p.Migration);}); + Add(29,pinata.ResetForKVLoad,pinata.ReplaceFromKVDecoded); Add(30,digging.ResetForKVLoad,digging.ReplaceFromKVDecoded); + modules.Add(new PlayerKVLoadModule(31,user.ResetForKVLoad,d=>user.ReplaceFromKVDecoded(d.Module(31),d.Epoch,d.Raw))); + transaction=new PlayerKVGameLoadTransaction(modules,System.Threading.Thread.CurrentThread.ManagedThreadId,()=>user.KVIntents?.Discard()); + user.ConfigureKVForValidation(transaction,pf,server,()=>!asap.core.GContext.Ready && digging.CanResetForKVLoad); + var rawA=FullRaw(tables,true); var rawB=FullRaw(tables,false); + void CheckSavedPayload(string key,object actual,params string[] ignored) + { + var expected=Newtonsoft.Json.Linq.JToken.Parse(rawA[key]); + foreach(var name in ignored) ((Newtonsoft.Json.Linq.JObject)expected).Remove(name); + Check(ContainsPayload(Newtonsoft.Json.Linq.JToken.Parse(JsonConvert.SerializeObject(actual)),expected)); + } + void CheckSnapshots(Dictionary raw) + { + var errors=new PlayerKVSchemaDiagnostics();var modern=PlayerKVSaveSchema.DecodeModern(raw,errors); + Check(Same(player.SnapshotForKVValidation(),PlayerKVSaveSchema.DecodePlayer(raw,modern,snapshot,errors))); + Check(Same(shop.SnapshotForKVValidation(),PlayerKVSaveSchema.DecodeShop(raw,snapshot,errors))); + Check(Same(eventBase.SnapshotForKVValidation(),PlayerKVSaveSchema.DecodeEventBase(raw,modern,snapshot,errors))); + Check(Same(camp.SnapshotForKVValidation(),PlayerKVSaveSchema.DecodeCamp(raw,errors))); + Check(Same(club.SnapshotForKVValidation(),PlayerKVSaveSchema.DecodeClub(raw,snapshot,errors))); + Check(Same(eventPack.SnapshotForKVValidation(),PlayerKVSaveSchema.DecodeEventPack(raw,snapshot,errors))); + Check(Same(piggy.SnapshotForKVValidation(),PlayerKVSaveSchema.DecodePiggyBank(raw,snapshot,errors))); + Check(Same(bargain.SnapshotForKVValidation(),PlayerKVSaveSchema.DecodeBargain(raw,snapshot,errors))); + Check(Same(shooting.SnapshotForKVValidation(),PlayerKVSaveSchema.DecodeShootingRange(raw,snapshot,errors))); + Check(Same(progress.SnapshotForKVValidation(),PlayerKVSaveSchema.DecodeProgressPack(raw,false,errors))); + Check(Same(migration.SnapshotForKVValidation(),PlayerKVSaveSchema.DecodeProgressPack(raw,true,errors))); + Check(errors.TotalErrors==0); + } + PlayerKVGameLoadCapability Capability(Dictionary raw,bool newPF) + { + System.Net.CookieContainer jar=null; + return PlayerKVGameLoadCapability.CreateForValidation(new PlayerKVIdentitySnapshot("FF",newPF,newPF ? PlayerKVIdentityChannel.hwVkid : PlayerKVIdentityChannel.none,Array.Empty()),snapshot,api, + System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath,"../../CodexOutput/upserver/KVGameLoadTests/unity-cache",Guid.NewGuid().ToString("N"))), + cookies=> {jar=cookies;return new ValidationHttp(r=>{if(r.RequestUri.AbsolutePath!="/account/Login"){forbidden++;throw new InvalidOperationException();} auth++;jar.Add(api,new System.Net.Cookie("sid","synthetic"));return ValidationJson(new{state=0,internalId=17,newCreate=false,createTime="2026-01-01T00:00:00Z",svrTime="2026-01-02T00:00:00Z"});});}, + cookies=>{Check(ReferenceEquals(cookies,jar));return new ValidationHttp(r=>{if(r.RequestUri.AbsolutePath!="/playerdata/Get"){forbidden++;throw new InvalidOperationException();}gets++;return ValidationJson(new{data=raw});});}, + cookies=>{Check(ReferenceEquals(cookies,jar));return new ValidationHttp(r=>{borrowed++;return new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized){Content=new System.Net.Http.StringContent("{}")} ;});}); + } + var a=await user.LoadKVForValidationAsync(Capability(rawA,true)); + Check(a.State==PlayerKVGameLoadState.LoadedForValidation && a.ModuleCount==31 && a.IntentCount==2); + Check(user.KVLoadedForValidation && !user.IsLogin && !user.IsAuthorized && user.InternalId==0 && user.KVReceiverCountForValidation==0 && user.KVNotificationCount==1); + CheckSnapshots(rawA); + CheckSavedPayload("BattlePassRecord",battle.BattlePassRecord); CheckSavedPayload("BattleTask",battle.BattleTask); + CheckSavedPayload("MonthlySignEvent",sign.monthlySignEvent); CheckSavedPayload("FishingChallengeData",challenge.fishingChallengeData); + CheckSavedPayload("OfferChainsChestData",chains.OfferChainsData); CheckSavedPayload("OfferDiscoPartyManager",disco.OfferDiscoPartyData,"Finished","ChestReward"); + CheckSavedPayload("EventPartnerGatherData",partner.GatherData); CheckSavedPayload("EventCubeMeltData",cube.EventData); + CheckSavedPayload("epdk",potion.PotionData); CheckSavedPayload("epbdk",pinball.PinballData); CheckSavedPayload("estdk",scratch.ScratchTicketData); + CheckSavedPayload("TriggerPackDataList",trigger.triggerPackList); CheckSavedPayload("GiftVIPDData",trigger.giftVIPDData); + CheckSavedPayload("InitRodAccessoriesData",fish.rodAccessoriesData); + var selectedFish=tables.TbFishData.DataList.First(f=>tables.TbMapData.DataMap.ContainsKey(f.AreaID)); + var fishValue=fish.FishForKVValidation(selectedFish.AreaID,selectedFish.ID); + Check(fishValue.Level==0 && fishValue.MaxG==-1 && fishValue.TotalCard==2 && fishValue.TotalNum==3 && fishValue.NewFish && fish.IndexesMatchForKVValidation(selectedFish.AreaID,selectedFish.ID,selectedFish.FishCardID)); + Check(fish.DictionariesMatchForKVValidation(PlayerKVSaveSchema.DecodeFish(rawA,PlayerKVSaveSchema.DecodeModern(rawA,new PlayerKVSchemaDiagnostics()),snapshot,new PlayerKVSchemaDiagnostics()))); + Check(fish.LuckDataDic["InitFishLuck"]==1.25f && fish.LuckDataDic["InitBotLuck"]==2.5f && fish.LuckDataDic["BotCount"]==3 && fish.InitBotLuck==0 && fish.InitFishLuck==0 && fish.BotCount==0); + Check(box.FishingBoxOpenNum[1]==2 && user.KVTapJoyBalance==12 && user.MMTestForKVValidation==15 && user.EAccoutUrl["synthetic"]=="avatar" && + user.KVLinkedAccounts.OrderBy(c=>c).SequenceEqual(new[]{PlayerKVIdentityChannel.hwGoogle,PlayerKVIdentityChannel.Apple,PlayerKVIdentityChannel.hwVkid}.OrderBy(c=>c))); + for(int kind=0;kind<4;kind++) {var buff=buffs.BuffForKVValidation(kind,kind==1 ? 0 : 7);Check(buff.SortID==1 && buff.sourceID==2 && buff.buffID==3 && !buff.isEnd && buff.buffEndTime==snapshot.NowUtc.AddDays(1) && !buff.isNew && buff.addDown==0);} + Check(small.BombGameCount==13 && small.FishHuntGameCount==14 && small.IsOpponentEmptyForKVValidation); + var achievement=achievements.FindForKVValidation((cfg.ConditionType)Enum.GetValues(typeof(cfg.ConditionType)).GetValue(0)); + Check(achievement.Id==1 && achievement.Target==2 && achievement.day==3); + Check(guide.GuideSaveDataDic["A"].groupName=="A" && guide.GuideSaveDataDic["A"].guideSubIndex==3 && guide.GuideSaveDataDic["A"].isFinish && !guide.GuideSaveDataDic["B"].isFinish && guide.GuideSaveDataDic["B"].guideSubIndex==4); + Check(trigger.FixedForKVValidation(7).id==8 && trigger.FixedForKVValidation(7).endTime==snapshot.NowUtc.AddDays(1).ToString("O",CultureInfo.InvariantCulture)); + Check(pinataTarget.EventID==7 && pinataTarget.PinataMainID==8 && pinataTarget.PurchasedCount==9); + Check(player.SnapshotForKVValidation().Season==9 && small.HeistGameCount==12 && !fish.EmptyForKVValidation && shop.SnapshotForKVValidation().Voucher==6); + Check(eventBase.SnapshotForKVValidation().PvpToken==7 && !buffs.IsEmptyForKVValidation && camp.SnapshotForKVValidation().IsFix && !achievements.IsEmptyForKVValidation); + Check(box.FishingBoxOpenNum.Count==1 && guide.GuideSaveDataDic.Count==2 && battle.BattlePassRecord.ID==1 && sign.monthlySignEvent.index==4 && !trigger.IsEmptyForKVValidation); + Check(club.SnapshotForKVValidation().DonateCount==7 && challenge.fishingChallengeData!=null && chains.OfferChainsData!=null && disco.OfferDiscoPartyData!=null && partner.GatherData!=null && cube.EventData!=null); + Check(eventPack.SnapshotForKVValidation().PurchaseCount==2 && piggy.KVLoadPresent && bargain.KVLoadPresent && foot.CompleteFootPrintForKVValidation==ulong.MaxValue && shooting.KVLoadPresent); + Check(potion.PotionData!=null && pinball.PinballData!=null && scratch.ScratchTicketData!=null && progress.SnapshotForKVValidation().IsPresent && migration.SnapshotForKVValidation().IsPresent && pinataTarget.PurchasedCount==9 && digging.DataModel!=null); + var oldPartner=partner.PartnerInfoManager; var oldRobot=partner.RobotInvitationSystem; var oldModel=digging.DataModel; var intents=user.KVIntents; + user.CustomId="102"; var b=await user.LoadKVForValidationAsync(Capability(rawB,false)); + Check(b.State==PlayerKVGameLoadState.LoadedForValidation && b.ModuleCount==31 && b.IntentCount==1 && b.Epoch>a.Epoch); + CheckSnapshots(rawB); + Check(player.SnapshotForKVValidation().Season==0 && !player.SnapshotForKVValidation().Appraise && player.SnapshotForKVValidation().NewRods.Count==0 && player.SnapshotForKVValidation().VipScores.Count==0 && player.IsDerivedEmptyForKVValidation && small.IsOpponentEmptyForKVValidation); + Check(fish.EmptyForKVValidation && shop.SnapshotForKVValidation().Daily.Count==0 && shop.SnapshotForKVValidation().Items.Count==0 && shop.SnapshotForKVValidation().Voucher==0); + Check(eventBase.SnapshotForKVValidation().Limited.Count==0 && eventBase.SnapshotForKVValidation().Fixed.Count==0 && eventBase.SnapshotForKVValidation().Target.Count==0 && eventBase.SnapshotForKVValidation().Rank.Count==0 && eventBase.SnapshotForKVValidation().Wheel.Count==0 && eventBase.IsInactiveForKVValidation); + Check(buffs.IsEmptyForKVValidation && !camp.SnapshotForKVValidation().IsFix && camp.SnapshotForKVValidation().Values.Count==0 && achievements.IsEmptyForKVValidation && box.FishingBoxOpenNum.Count==0 && guide.GuideSaveDataDic.Count==0); + Check(battle.BattlePassRecord.ID==0 && battle.BattlePassRecord.NormalRewardGet.Count==0 && battle.BattleTask.dic_dailyTasks.Count==0 && sign.monthlySignEvent==null && trigger.IsEmptyForKVValidation); + var br=battle.BattlePassRecord; var bt=battle.BattleTask; + Check(br.Level==1 && br.Exp==0 && br.BuyVipToastIndex==0 && br.IsUnlockVip==0 && br.InflationRate==0 && br.VipRewardGet.Count==0 && br.LastDropIDs.Count==0 && + bt.dic_weeklyTasks.Count==0 && bt.loginDays.Count==0 && bt.DailyTaskEndTime==default && bt.WeeklyTaskEndTime==default); + Check(club.SnapshotForKVValidation().SignTime==null && club.SnapshotForKVValidation().DonateCount==0 && club.SnapshotForKVValidation().OpenRed); + Check(challenge.fishingChallengeData==null && challenge.IsInactiveForKVValidation && chains.OfferChainsData==null && chains.IsInactiveForKVValidation && disco.OfferDiscoPartyData==null && disco.FreeTime==1 && disco.IsInactiveForKVValidation); + Check(partner.GatherData==null && partner.RuntimeEmptyForKVValidation && !ReferenceEquals(oldPartner,partner.PartnerInfoManager) && !ReferenceEquals(oldRobot,partner.RobotInvitationSystem)); + Check(cube.EventData==null && cube.IsInactiveForKVValidation && eventPack.SnapshotForKVValidation().PurchaseCount==0 && !piggy.KVLoadPresent && !bargain.KVLoadPresent && !shooting.KVLoadPresent && foot.CompleteFootPrintForKVValidation==0); + Check(potion.PotionData==null && potion.IsInactiveForKVValidation && pinball.PinballData==null && pinball.IsInactiveForKVValidation && scratch.ScratchTicketData==null && scratch.IsInactiveForKVValidation); + Check(!progress.SnapshotForKVValidation().IsPresent && !migration.SnapshotForKVValidation().IsPresent && pinataTarget.EventID==0 && pinataTarget.PinataMainID==0 && pinataTarget.PurchasedCount==0 && !ReferenceEquals(oldModel,digging.DataModel)); + Check(user.KVTapJoyBalance==0 && user.MMTestForKVValidation==0 && user.EAccoutUrl.Count==0 && user.KVLinkedAccounts.Count==0 && intents.Discarded && user.KVReceiverCountForValidation==0 && user.KVNotificationCount==2); + Check(auth==2 && gets==2 && forbidden==0 && events.Published==0 && events.Subscriptions==0 && pf.IsUnstartedForKVLoad); + var unauthorized=await server.SendKVForValidationAsync("__kv_validation/unauthorized"); + await user.CloseKVForValidationAsync(); Check(!unauthorized.IsCurrent && !user.KVLoadedForValidation && user.InternalId==0 && borrowed==1 && user.KVIntents==null); + success=true; + } + catch(Exception ex) { errorType=ex.GetType().Name; } + finally + { + try { if(user!=null) await user.CloseKVForValidationAsync(); if(transaction!=null) Check(transaction.Clear()); } + finally + { + isolated.Dispose(); if(shell!=null) UnityEngine.Object.DestroyImmediate(shell); + ValidationContextAccess.Use(originalContainer); ValidationContextAccess.Events=originalEvents; running=false; + restored=ReferenceEquals(originalContainer,asap.core.GContext.container) && ReferenceEquals(originalEvents,ValidationContextAccess.Events); + } + } + return JsonConvert.SerializeObject(new{success=success && restored,caseId="B2-20",assertions,errorType,restored,auth,gets,borrowed,forbidden,eventPublishes=events.Published,eventSubscriptions=events.Subscriptions, + realModules=31,sameObjects=true,fullB220=success && restored,scope="31-real-modules-two-epochs-controlled-transaction"}); + } + static Task RunLoginState() + { + int assertions=0; + void Check(bool value) { assertions++; if(!value) throw new InvalidOperationException("Login state assertion failed."); } + var capability=PlayerKVControlledLoadCapability.CreateForValidation(_=>throw new InvalidOperationException("No transport in module test.")); + var user=new AggUserService(capability); + try + { + var identity=new PlayerKVIdentitySnapshot("FF",true,PlayerKVIdentityChannel.hwVkid,new[]{PlayerKVIdentityChannel.hwFacebook}); + var raw=new Dictionary{["EAccoutUrl"]="{\"synthetic\":\"avatar\"}",["hwGoogle"]="1",["Apple"]="1",["TapJoy_Diamond"]="12"}; + var a=PlayerKVSaveSchema.DecodeLogin(raw,identity,new PlayerKVSchemaDiagnostics()); + user.ResetForKVLoad(); user.ReplaceFromKVDecoded(a,1,raw); + Check(user.MMTestForKVValidation==15 && user.KVTapJoyBalance==12 && user.EAccoutUrl["synthetic"]=="avatar"); + Check(user.KVLinkedAccounts.Count==4 && user.KVLinkedAccounts.Contains(PlayerKVIdentityChannel.hwFacebook) && user.KVLinkedAccounts.Contains(PlayerKVIdentityChannel.hwVkid)); + Check(user.KVIntents.Count==2 && user.KVLoadingCopy["MMTest"]=="MMFailParam#15" && user.KVLoadingCopy["hwVkid"]=="1" && !raw.ContainsKey("hwVkid")); + Check(!user.IsLogin && !user.IsAuthorized && user.InternalId==0 && user.ApiPlayerId==null); + var intents=user.KVIntents; var oldLinks=user.KVLinkedAccounts; + user.EAccoutUrl.Clear(); raw.Clear(); + Check(a.AccountUrls.Count==1 && a.Linked.Count==4 && a.Normalizations.Count==2); + var b=PlayerKVSaveSchema.DecodeLogin(new Dictionary(),new PlayerKVIdentitySnapshot(null,false,PlayerKVIdentityChannel.none,Array.Empty()),new PlayerKVSchemaDiagnostics()); + user.ResetForKVLoad(); user.ReplaceFromKVDecoded(b,2,new Dictionary()); + Check(intents.Discarded && intents.DiscardedCount==2 && intents.Count==0); + Check(user.MMTestForKVValidation==0 && user.KVTapJoyBalance==0 && user.EAccoutUrl.Count==0 && user.KVLinkedAccounts.Count==0 && oldLinks.Count==4); + Check(user.KVLoadingCopy.Count==1 && user.KVIntents.Count==1 && user.KVIntents.Epoch==2); + user.ResetForKVLoad(); + Check(user.KVIntents==null && user.KVLoadingCopy==null && user.EAccoutUrl.Count==0 && user.KVLinkedAccounts.Count==0 && user.InternalId==0); + return Task.FromResult(JsonConvert.SerializeObject(new{success=true,caseId="M31",assertions,fullB220=false,sharedContainerTouched=false})); + } + catch(Exception ex) { return Task.FromResult(JsonConvert.SerializeObject(new{success=false,caseId="M31",assertions,errorType=ex.GetType().Name,fullB220=false})); } + finally { user.ResetForKVLoad(); } + } + static Task RunPartner() + { + if(running || asap.core.GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll().Length!=0) + throw new InvalidOperationException("Game context is active."); + running=true; + var originalContainer=asap.core.GContext.container; + var originalAggregator=ValidationContextAccess.Events; + var containerA=new asap.core.Container(); var containerB=new asap.core.Container(); + bool success=false,restored=false; int assertions=0; string errorType=null; + void Check(bool value) { assertions++; if(!value) throw new InvalidOperationException("Partner assertion failed."); } + try + { + var directory=System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath,"../../cfgData")); + cfg.Tables Tables(bool change) => new cfg.Tables(name=> + { + var json=SimpleJSON.JSON.Parse(System.IO.File.ReadAllText(System.IO.Path.Combine(directory,name+".json"))); + if(change && name=="tbeventpartnerconfig") json[0]["InvitationCount"][1]=json[0]["InvitationCount"][1].AsInt+1; + return json; + }); + var tablesA=Tables(false); var tablesB=Tables(true); + int capacityA=tablesA.TbEventPartnerConfig.InvitationCount[1],capacityB=tablesB.TbEventPartnerConfig.InvitationCount[1]; + Check(capacityB==capacityA+1); + Check(asap.core.ContainerExtensions.RegisterInstance(containerA,tablesA)); + Check(asap.core.ContainerExtensions.RegisterInstance(containerB,tablesB)); + ValidationContextAccess.Use(containerA); ValidationContextAccess.Events=new asap.core.EventAggregator(); + // Empty containers do not have factories for live identity/friend services. + Check(asap.core.ContainerExtensions.Resolve(containerA)==null && asap.core.ContainerExtensions.Resolve(containerA)==null); + var module=new DataCenter.EventPartnerGatherManager(); + var oldPartner=module.PartnerInfoManager; var oldRobot=module.RobotInvitationSystem; + Check(oldPartner.BuildPartners.Length==capacityA && EventPartnerGather.PartnerInfoManager.BuilderCapacity==capacityA); + var now=new DateTime(2026,9,23,0,0,0,DateTimeKind.Utc); + var sample=new DataCenter.EventPartnerGatherData + { + FirstOpen=false,EventId=18,StartTime=now,EndTime=now.AddDays(1),CurrentPoints=1,CurrentMiningProgress=2, + Token=3,InvitedCount=4,TicketCount=5,CompletedNodesList=new List>{new List{6,7},new List()}, + BuilderPartnerScore=new List<(int,int)>{(8,9)},PartnerBonus=(1.25f,now.AddHours(1)),FreeSpinNum=10, + Bots=new Dictionary{[11]=new EventPartnerData.EventBuildBotInfo + { Id="synthetic-bot",ScorePlayer=12,ScoreBot=13,Grade=14,TargetScore=15,WakeStartTime=now.AddHours(2),WakingSeconds=16 }}, + IsRecvFinalReward=true,UIFinishedPartners=new List{true,false},IsFinished=true,SpinState=DataCenter.ESpinState.Morespin, + InflationRate=2.5f,IsEnableAutoMatch=true,LastAutoMatchTime=now.AddHours(3) + }; + var text=JsonConvert.SerializeObject(sample); + var snapshot=new PlayerKVSchemaSnapshot("local-M18",Array.Empty(),Array.Empty(),new Dictionary(),CultureInfo.InvariantCulture,now,0); + var state=DataCenter.EventPartnerGatherManager.DecodeForKVLoad(new Dictionary{["EventPartnerGatherData"]=text},snapshot,new PlayerKVSchemaDiagnostics()); + Check(state!=null); module.ResetForKVLoad(); module.ReplaceFromKVDecoded(state); + Check(JsonConvert.SerializeObject(module.GatherData)==text && module.GatherData.Bots[11].ActionQueue==null); + Check(module.RuntimeEmptyForKVValidation && !ReferenceEquals(oldPartner,module.PartnerInfoManager) && !ReferenceEquals(oldRobot,module.RobotInvitationSystem)); + module.GatherData.CompletedNodesList[0].Clear(); module.GatherData.BuilderPartnerScore.Clear(); + module.GatherData.Bots[11].ScoreBot=999; module.GatherData.UIFinishedPartners.Clear(); + Check(state.CompletedNodesList[0].Count==2 && state.BuilderPartnerScore.Count==1 && state.Bots[11].ScoreBot==13 && state.UIFinishedPartners.Count==2); + module.ReplaceFromKVDecoded(state); Check(JsonConvert.SerializeObject(module.GatherData)==text); + var partnerA=module.PartnerInfoManager; var robotA=module.RobotInvitationSystem; + partnerA.Cache.RefusedPartnerIds=new List{"synthetic-old"}; + ValidationContextAccess.Use(containerB); ValidationContextAccess.Events=new asap.core.EventAggregator(); + Check(asap.core.ContainerExtensions.Resolve(containerB)==null && asap.core.ContainerExtensions.Resolve(containerB)==null); + var moduleB=new DataCenter.EventPartnerGatherManager(); + Check(partnerA.BuildPartners.Length==capacityA && oldPartner.BuildPartners.Length==capacityA && moduleB.PartnerInfoManager.BuildPartners.Length==capacityB && EventPartnerGather.PartnerInfoManager.BuilderCapacity==capacityB); + Check(oldPartner.ActiveFriendCountThreshold==tablesA.TbEventPartnerConfig.FriendRecommendParam1[0]); + var missing=DataCenter.EventPartnerGatherManager.DecodeForKVLoad(new Dictionary(),snapshot,new PlayerKVSchemaDiagnostics()); + module.ResetForKVLoad(); module.ReplaceFromKVDecoded(missing); + Check(module.GatherData==null && module.RuntimeEmptyForKVValidation); + Check(!ReferenceEquals(partnerA,module.PartnerInfoManager) && !ReferenceEquals(robotA,module.RobotInvitationSystem)); + Check(module.PartnerInfoManager.BuildPartners.Length==capacityB && module.PartnerInfoManager.BuildPartners.All(p=>p==null) && + module.PartnerInfoManager.Cache.ActionQ==null && module.PartnerInfoManager.Cache.RefusedPartnerIds==null && module.PartnerInfoManager.EventBuildInfo==null); + Check(partnerA.Cache.RefusedPartnerIds.Count==1); + module.ReplaceFromKVDecoded(state); Check(JsonConvert.SerializeObject(module.GatherData)==text); + module.ResetForKVLoad(); moduleB.ResetForKVLoad(); + success=true; + } + catch(Exception ex) { errorType=ex.GetType().Name; } + finally + { + try { containerA.Dispose(); containerB.Dispose(); } + finally + { + ValidationContextAccess.Use(originalContainer); ValidationContextAccess.Events=originalAggregator; running=false; + restored=ReferenceEquals(asap.core.GContext.container,originalContainer) && ReferenceEquals(ValidationContextAccess.Events,originalAggregator); + } + } + return Task.FromResult(JsonConvert.SerializeObject(new { success=success && restored,caseId="M18",assertions,errorType,restored,scope="M18-D4-only",fullB220=false })); + } + static Task RunFish() + { + int assertions=0; + int roundingCase=-1,actualRoundBits=0,expectedRoundBits=0; + void Check(bool value) { assertions++; if(!value) throw new InvalidOperationException("Fish assertion failed."); } + var module=new GameCore.PlayerFishData(null,null); + try + { + var directory=System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath,"../../cfgData")); + var tables=new cfg.Tables(name=>SimpleJSON.JSON.Parse(System.IO.File.ReadAllText(System.IO.Path.Combine(directory,name+".json")))); + var snapshot=new PlayerKVSchemaSnapshot("local-M03",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys, + new Dictionary(),CultureInfo.InvariantCulture,new DateTime(2026,9,23,0,0,0,DateTimeKind.Utc),tables.TbGlobalConfig.InitWheelTicket, + fish:tables.TbFishData.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishConfig(p.Key,p.Value.AreaID,p.Value.Quality,p.Value.FishCardID,p.Value.BaseWeight,p.Value.PointExtraParam,p.Value.WeightRandom)), + cards:tables.TbFishCard.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishCardConfig(p.Key,p.Value.MaxLevel,p.Value.CardsRequiredList,p.Value.WeightMagList))); + var config=tables.TbFishData.DataList.First(f=>f.Quality<=5 && snapshot.HasMap(f.AreaID) && snapshot.Card(f.FishCardID)!=null && + snapshot.Card(f.FishCardID).MaxLevel>=1 && snapshot.Card(f.FishCardID).Weights.Count>=1 && snapshot.Card(f.FishCardID).Required.Count>=1); + var raw=new Dictionary { ["Map_5_"+config.AreaID]=JsonConvert.SerializeObject(new Dictionary{[config.ID]="1|99999|0|7|True"}), + ["InitRodAccessoriesData"]="{\"GloveID\":1,\"RodToSkin\":{\"2\":3},\"GloveIds\":[4],\"SkinIds\":[5],\"GloveNewIds\":[6],\"SkinNewIds\":[7]}" }; + foreach(var key in new[] { "RodData","RodLevelData","FishSwimToMap","FishSwimToMapCount","MapProgress","HonorLevelData" }) raw[key]="{\"-1\":7}"; + var state=GameCore.PlayerFishData.DecodeForKVLoad(raw,new Dictionary{["ld"]="1.25|2.5|3"},snapshot,new PlayerKVSchemaDiagnostics()); + Check(state!=null); module.ResetForKVLoad(); module.ReplaceFromKVDecoded(state); + var actual=module.FishForKVValidation(config.AreaID,config.ID); + float limit=config.BaseWeight*config.PointExtraParam*(1+config.WeightRandom); + limit*=1+tables.TbFishCard[config.FishCardID].WeightMagList[0]; limit=UnityEngine.Mathf.Round(limit*100)/100; + Check(actual.Level==1 && actual.MaxG==Math.Min(99999,limit) && actual.TotalCard==Math.Max(0,tables.TbFishCard[config.FishCardID].CardsRequiredList[0]) && actual.TotalNum==7 && actual.NewFish); + Check(module.IndexesMatchForKVValidation(config.AreaID,config.ID,config.FishCardID) && module.DictionariesMatchForKVValidation(state)); + Check(module.InitFishLuck==0 && module.InitBotLuck==0 && module.BotCount==0 && module.LuckDataDic["BotCount"]==3); + var acc=module.rodAccessoriesData; + Check(acc.GloveID==1 && acc.RodToSkin[2]==3 && acc.GloveIds[0]==4 && acc.SkinIds[0]==5 && acc.GloveNewIds[0]==6 && acc.SkinNewIds[0]==7); + actual.TotalNum=999; module.LuckDataDic.Clear(); acc.RodToSkin.Clear(); acc.GloveIds.Clear(); acc.SkinIds.Clear(); acc.GloveNewIds.Clear(); acc.SkinNewIds.Clear(); + Check(state.Fish[config.ID].Number==7 && state.Luck.Count==3 && state.Accessories.RodSkin.Count==1 && state.Accessories.Gloves.Count==1 && + state.Accessories.Skins.Count==1 && state.Accessories.NewGloves.Count==1 && state.Accessories.NewSkins.Count==1); + module.ReplaceFromKVDecoded(state); + Check(!ReferenceEquals(actual,module.FishForKVValidation(config.AreaID,config.ID)) && module.FishForKVValidation(config.AreaID,config.ID).TotalNum==7); + module.IsOpenMap=true; module.IsOpenRod=true; module.IsOpenPiercing=true; module.MapTipforUnlocked=8; module.RodSelectedInPack=9; + module.InitBotLuck=2; module.InitFishLuck=3; module.BotCount=4; + module.ResetForKVLoad(); module.ReplaceFromKVDecoded(GameCore.PlayerFishData.DecodeForKVLoad(new Dictionary(),new Dictionary(),snapshot,new PlayerKVSchemaDiagnostics())); + Check(module.EmptyForKVValidation); + foreach(float n in new[] { 0f,.005f,.015f,.025f,-.005f,-.015f,-.025f,.0049999994f,.0050000004f,123.456f,-123.456f,1000000f,1e20f,1e30f }) + { + roundingCase++; + actualRoundBits=BitConverter.ToInt32(BitConverter.GetBytes(PlayerKVSaveSchema.RoundFishWeight(n)),0); + expectedRoundBits=BitConverter.ToInt32(BitConverter.GetBytes(UnityEngine.Mathf.Round(n*100)/100),0); + Check(actualRoundBits==expectedRoundBits); + } + var synthetic=new PlayerKVSchemaSnapshot("M03-min",Array.Empty(),new[]{1},new Dictionary(),CultureInfo.InvariantCulture,snapshot.NowUtc,0, + fish:new Dictionary{[1]=new PlayerKVFishConfig(1,1,5,1,2,1,0)},cards:new Dictionary{[1]=new PlayerKVFishCardConfig(1,1,new[]{10},new[]{0f})}); + var small=GameCore.PlayerFishData.DecodeForKVLoad(new Dictionary{["Map_5_1"]="{\"1\":\"1|0.125|0|1|false\"}"},new Dictionary(),synthetic,new PlayerKVSchemaDiagnostics()); + module.ResetForKVLoad(); module.ReplaceFromKVDecoded(small); + Check(module.FishForKVValidation(1,1).MaxG==.125f && module.IndexesMatchForKVValidation(1,1,1)); + return Task.FromResult(JsonConvert.SerializeObject(new {success=true,caseId="M03",assertions,fullB220=false,sharedContainerTouched=false})); + } + catch(Exception ex) { return Task.FromResult(JsonConvert.SerializeObject(new {success=false,caseId="M03",assertions,errorType=ex.GetType().Name,roundingCase,actualRoundBits,expectedRoundBits,fullB220=false})); } + finally { module.ResetForKVLoad(); } + } + static Task RunPotion() + { + int assertions=0; + void Check(bool value) { assertions++; if(!value) throw new InvalidOperationException("Potion assertion failed."); } + var manager=new EventPotionDataManager(); + try + { + var item=new { name="synthetic",index=3,isOver=true,DataType=2,ColorTypeList=new[] { 7,9 },ShelfTypeList=new[] { 0,1 },GridStateList=new[] { 0,5 },FromDisplayAreaIndexList=new[] { 1,2 } }; + var state=EventPotionDataManager.DecodeForKVLoad(new Dictionary { ["epdk"]=JsonConvert.SerializeObject(new { EventID=11,PotionID=11,StageID=11,Stage1TotalLevel=11,Stage2TotalLevel=11,LevelID=11,LevelIndex=11,CorrectNum=11,SubmitNum=11,Token=11,ConsumedToken=11,Rule=11,PreEventID=11,PrePotionID=11,PreStageID=11,PreLevelID=11,Round=11,FirstPlayedEventId=11,IsOver=true,Name="synthetic",InflationRate=1.5f,LevelTargetData=item,LevelDisplayData=item,LevelProcessedList=new[] { item } }) },new PlayerKVSchemaDiagnostics()); + Check(state!=null && state.IsPresent); + manager.ResetForKVLoad(); manager.ReplaceFromKVDecoded(state); + var actual=manager.PotionData; + Check(actual.EventID==state.EventID); + Check(actual.PotionID==state.PotionID); + Check(actual.StageID==state.StageID); + Check(actual.Stage1TotalLevel==state.Stage1TotalLevel); + Check(actual.Stage2TotalLevel==state.Stage2TotalLevel); + Check(actual.LevelID==state.LevelID); + Check(actual.LevelIndex==state.LevelIndex); + Check(actual.CorrectNum==state.CorrectNum); + Check(actual.SubmitNum==state.SubmitNum); + Check(actual.Token==state.Token); + Check(actual.ConsumedToken==state.ConsumedToken); + Check(actual.Rule==state.Rule); + Check(actual.PreEventID==state.PreEventID); + Check(actual.PrePotionID==state.PrePotionID); + Check(actual.PreStageID==state.PreStageID); + Check(actual.PreLevelID==state.PreLevelID); + Check(actual.Round==state.Round); + Check(actual.FirstPlayedEventId==state.FirstPlayedEventId); + Check(actual.IsOver==state.IsOver); + Check(actual.Name==state.Name); + Check(actual.InflationRate==state.InflationRate); + foreach(var entry in new[] { actual.LevelTargetData,actual.LevelDisplayData,actual.LevelProcessedList[0] }) + { + Check(entry.name=="synthetic" && entry.index==3 && entry.isOver && (int)entry.DataType==2 && + entry.ColorTypeList.SequenceEqual(new[] { 7,9 }) && entry.ShelfTypeList.Select(n=>(int)n).SequenceEqual(new[] { 0,1 }) && + entry.GridStateList.Select(n=>(int)n).SequenceEqual(new[] { 0,5 }) && entry.FromDisplayAreaIndexList.SequenceEqual(new[] { 1,2 })); + entry.ColorTypeList.Clear(); entry.ShelfTypeList.Clear(); entry.GridStateList.Clear(); entry.FromDisplayAreaIndexList.Clear(); + } + Check(state.LevelTargetData.Colors.Count==2 && state.LevelDisplayData.Shelves.Count==2 && state.LevelProcessedList[0].Grids.Count==2); + manager.ReplaceFromKVDecoded(state); + Check(!ReferenceEquals(actual,manager.PotionData) && manager.PotionData.LevelTargetData.ColorTypeList.Count==2 && + !ReferenceEquals(manager.PotionData.LevelTargetData,manager.PotionData.LevelDisplayData)); + manager.ScrollerDataList.Add(actual.LevelTargetData); + manager.ResetForKVLoad(); manager.ReplaceFromKVDecoded(EventPotionDataManager.DecodeForKVLoad(new Dictionary(),new PlayerKVSchemaDiagnostics())); + Check(manager.PotionData==null && manager.IsInactiveForKVValidation); + return Task.FromResult(JsonConvert.SerializeObject(new { success=true,caseId="M25",assertions,fullB220=false,sharedContainerTouched=false })); + } + catch(Exception ex) { return Task.FromResult(JsonConvert.SerializeObject(new { success=false,caseId="M25",assertions,errorType=ex.GetType().Name,fullB220=false })); } + finally { manager.ResetForKVLoad(); } + } + static Task RunBattlePass() + { + int assertions=0; + void Check(bool value) { assertions++; if(!value) throw new InvalidOperationException("Battle pass assertion failed."); } + var manager=new BattlePassData(null); + var snapshot=new PlayerKVSchemaSnapshot("M11",Array.Empty(),Array.Empty(),new Dictionary(), + CultureInfo.InvariantCulture,new DateTime(2026,9,23),0); + try + { + var raw=new Dictionary { + ["BattlePassRecord"]=JsonConvert.SerializeObject(new { ID=1,Level=2,Exp=3,BuyVipToastIndex=4,IsUnlockVip=1, + NormalRewardGet=new[] { 5,6 },VipRewardGet=new[] { 7,8 },LastDropIDs=new[] { 99 },InflationRate=2.5f }), + ["BattleTask"]=JsonConvert.SerializeObject(new { dic_dailyTasks=new Dictionary { [9]=10 }, + dic_weeklyTasks=new Dictionary { [11]=12 },loginDays=new[] { 13,14 },DailyTaskEndTime=new DateTime(15),WeeklyTaskEndTime=new DateTime(16) }) + }; + var a=BattlePassData.DecodeForKVLoad(raw,snapshot,new PlayerKVSchemaDiagnostics()); + manager.ResetForKVLoad(); manager.ReplaceFromKVDecoded(a); + var r=manager.BattlePassRecord; var t=manager.BattleTask; + Check(r.ID==1 && r.Level==2 && r.Exp==3 && r.BuyVipToastIndex==4 && r.IsUnlockVip==1 && + r.NormalRewardGet.SequenceEqual(new[] { 5,6 }) && r.VipRewardGet.SequenceEqual(new[] { 7,8 }) && + r.LastDropIDs.SequenceEqual(new[] { 99 }) && r.InflationRate==2.5f); + Check(t.dic_dailyTasks[9]==10 && t.dic_weeklyTasks[11]==12 && t.loginDays.SequenceEqual(new[] { 13,14 }) && + t.DailyTaskEndTime.Ticks==15 && t.WeeklyTaskEndTime.Ticks==16); + r.NormalRewardGet.Clear(); r.VipRewardGet.Clear(); r.LastDropIDs.Clear(); t.dic_dailyTasks.Clear(); t.dic_weeklyTasks.Clear(); t.loginDays.Clear(); + Check(a.Record.Normal.Count==2 && a.Record.Vip.Count==2 && a.Record.LastDrops.Count==1 && a.Task.Daily.Count==1 && a.Task.Weekly.Count==1 && a.Task.LoginDays.Count==2); + manager.ResetForKVLoad(); manager.ReplaceFromKVDecoded(BattlePassData.DecodeForKVLoad(new Dictionary(),snapshot,new PlayerKVSchemaDiagnostics())); + r=manager.BattlePassRecord; t=manager.BattleTask; + Check(r.ID==0 && r.Level==1 && r.Exp==0 && r.BuyVipToastIndex==0 && r.IsUnlockVip==0 && r.InflationRate==0 && + r.NormalRewardGet.Count==0 && r.VipRewardGet.Count==0 && r.LastDropIDs.Count==0); + Check(t.dic_dailyTasks.Count==0 && t.dic_weeklyTasks.Count==0 && t.loginDays.Count==0 && t.DailyTaskEndTime==default && t.WeeklyTaskEndTime==default); + return Task.FromResult(JsonConvert.SerializeObject(new { success=true,caseId="M11",assertions,fullB220=false,sharedContainerTouched=false })); + } + catch(Exception ex) { return Task.FromResult(JsonConvert.SerializeObject(new { success=false,caseId="M11",assertions,errorType=ex.GetType().Name,fullB220=false })); } + finally { manager.ResetForKVLoad(); } + } + static Task RunCube() + { + int assertions=0; + void Check(bool value) { assertions++; if(!value) throw new InvalidOperationException("Cube assertion failed."); } + var manager=new EventCubeMeltManager(); + var snapshot=new PlayerKVSchemaSnapshot("M19",Array.Empty(),Array.Empty(),new Dictionary(), + CultureInfo.InvariantCulture,new DateTime(2026,9,23),0); + try + { + const string json="{\"TicketCount\":1,\"FirstOpen\":true,\"IsFinished\":true,\"EventId\":2,\"IsInGuide\":true,\"StartTime\":\"2026-09-22T00:00:00.0000000Z\",\"EndTime\":\"2026-09-24T00:00:00.0000000Z\",\"CurrentStagetId\":3,\"CollectItems\":[{\"ItemId\":4,\"Count\":5,\"MaxCount\":6,\"ItemTrueId\":7}],\"BoardData\":[8,9],\"BoardViewSize\":{\"x\":10,\"y\":11},\"CollectRewardsId\":[12,13],\"IsFinalRewardGet\":true,\"PieceDatas\":[[14,15],[16,17]],\"Cycle\":18,\"InflationRate\":1.5}"; + var a=EventCubeMeltManager.DecodeForKVLoad(new Dictionary { ["EventCubeMeltData"]=json },snapshot,new PlayerKVSchemaDiagnostics()); + manager.ResetForKVLoad(); manager.ReplaceFromKVDecoded(a); + var actual=manager.EventData; + Check(actual.TicketCount==1 && actual.FirstOpen && actual.IsFinished && actual.EventId==2 && actual.IsInGuide && + actual.StartTime==a.StartTime && actual.EndTime==a.EndTime && actual.CurrentStagetId==3); + Check(actual.CollectItems[0].ItemId==4 && actual.CollectItems[0].Count==5 && actual.CollectItems[0].MaxCount==6 && + actual.CollectItems[0].ItemTrueId==7 && actual.BoardData.SequenceEqual(new[] { 8,9 })); + Check(actual.BoardViewSize.x==10 && actual.BoardViewSize.y==11 && actual.CollectRewardsId.SequenceEqual(new[] { 12,13 }) && + actual.IsFinalRewardGet && actual.Cycle==18 && actual.InflationRate==1.5f); + Check(actual.PieceDatas[0].SequenceEqual(new[] { 14,15 }) && actual.PieceDatas[1].SequenceEqual(new[] { 16,17 })); + actual.PieceDatas[0].Clear(); actual.BoardData.Clear(); actual.CollectItems[0].Count=99; actual.CollectRewardsId.Clear(); + Check(a.PieceDatas[0].Count==2 && a.BoardData.Count==2 && a.CollectItems[0].Count==5 && a.CollectRewardsId.Count==2); + manager.ReplaceFromKVDecoded(a); + Check(!ReferenceEquals(actual,manager.EventData) && manager.EventData.PieceDatas[0].Count==2 && manager.EventData.CollectItems[0].Count==5); + manager.PrevPieceDatas=new List> { new List { 99 } }; manager.CurrentPieceDatas=new List> { new List { 88 } }; + manager.ResetForKVLoad(); manager.ReplaceFromKVDecoded(EventCubeMeltManager.DecodeForKVLoad(new Dictionary(),snapshot,new PlayerKVSchemaDiagnostics())); + Check(manager.EventData==null && manager.IsInactiveForKVValidation); + return Task.FromResult(JsonConvert.SerializeObject(new { success=true,caseId="M19",assertions,fullB220=false,sharedContainerTouched=false })); + } + catch(Exception ex) { return Task.FromResult(JsonConvert.SerializeObject(new { success=false,caseId="M19",assertions,errorType=ex.GetType().Name,fullB220=false })); } + finally { manager.ResetForKVLoad(); } + } + static Task RunDigging() + { + int assertions=0; + void Check(bool value) { assertions++; if(!value) throw new InvalidOperationException("Digging assertion failed."); } + var manager=new Script.RuntimeScript.DiggingGameManager(); + try + { + var state=Script.RuntimeScript.DiggingGameManager.DecodeForKVLoad(); + manager.ResetForKVLoad(); manager.ReplaceFromKVDecoded(state); + var a=manager.DataModel; + a.InflationRate=7; a.NewAddDigCount=9; + manager.ResetForKVLoad(); manager.ReplaceFromKVDecoded(state); + Check(manager.DataModel!=null && !ReferenceEquals(a,manager.DataModel) && manager.DataModel.InflationRate==0 && manager.DataModel.NewAddDigCount==0); + Check(manager.CanResetForKVLoad && manager.BroadLogic==null && manager.GameState==Script.RuntimeScript.SandDigGameState.Normal); + var active=manager.DataModel; active.AllClamps.Add(1); + Check(!manager.CanResetForKVLoad); + bool rejected=false; + try { manager.ResetForKVLoad(); } catch(InvalidOperationException) { rejected=true; } + Check(rejected && ReferenceEquals(manager.DataModel,active) && active.AllClamps.Count==1); + active.AllClamps.Clear(); + return Task.FromResult(JsonConvert.SerializeObject(new { success=true,caseId="M30",assertions,fullB220=false,sharedContainerTouched=false })); + } + catch(Exception ex) { return Task.FromResult(JsonConvert.SerializeObject(new { success=false,caseId="M30",assertions,errorType=ex.GetType().Name,fullB220=false })); } + finally { if(manager.CanResetForKVLoad) manager.ResetForKVLoad(); } + } + static Task RunActivities() + { + int assertions=0; + void Check(bool value) { assertions++; if(!value) throw new InvalidOperationException("Activity assertion failed."); } + var snapshot=new PlayerKVSchemaSnapshot("M15-M26-M27",Array.Empty(),Array.Empty(),new Dictionary(), + CultureInfo.InvariantCulture,new DateTime(2026,9,23),0); + var challenge=new FishingChallengeManager(); var pinball=new EventPinballDataManager(); var scratch=new EventScratchTicketDataManager(); + try + { + var nullErrors = new PlayerKVSchemaDiagnostics(); + var nullState = FishingChallengeManager.DecodeForKVLoad(new Dictionary { ["FishingChallengeData"] = " \r\n\tnull " }, snapshot, nullErrors); + Check(nullState != null && nullState.IsPresent && nullState.IsExplicitNull && nullErrors.TotalErrors == 0); + challenge.ReplaceFromKVDecoded(nullState); + Check(challenge.fishingChallengeData == null && challenge.IsInactiveForKVValidation); + Check(challenge.ChallengeState() == 0 && !challenge.ShouldOpen()); + Check(JsonConvert.SerializeObject(challenge.fishingChallengeData) == "null"); + + { + var raw=new Dictionary { ["FishingChallengeData"]=JsonConvert.SerializeObject(new { FirstOpen=true,EventId=11,RankTierLevel=11,Finished=11,RedirectID=11,Score=11,ProcessedScore=11,step=11,uiStep=11,index=11,StartTime="2026-09-23T00:00:00.0000000Z",EndTime="2026-09-23T00:00:00.0000000Z",eliminateNumber=new[] { 7,9 },robotAccountList=new[] { 7,9 },InflationRate=1.5f }) }; + var state=FishingChallengeManager.DecodeForKVLoad(raw,snapshot,new PlayerKVSchemaDiagnostics()); + Check(state!=null && state.IsPresent); + challenge.ResetForKVLoad(); challenge.ReplaceFromKVDecoded(state); + var actual=challenge.fishingChallengeData; + Check(actual.FirstOpen==state.FirstOpen); + Check(actual.EventId==state.EventId); + Check(actual.RankTierLevel==state.RankTierLevel); + Check(actual.Finished==state.Finished); + Check(actual.RedirectID==state.RedirectID); + Check(actual.Score==state.Score); + Check(actual.ProcessedScore==state.ProcessedScore); + Check(actual.step==state.step); + Check(actual.uiStep==state.uiStep); + Check(actual.index==state.index); + Check(actual.StartTime==state.StartTime); + Check(actual.EndTime==state.EndTime); + Check(actual.eliminateNumber.SequenceEqual(state.eliminateNumber)); + Check(actual.robotAccountList.SequenceEqual(state.robotAccountList)); + Check(actual.InflationRate==state.InflationRate); + challenge.ReplaceFromKVDecoded(state); + Check(!ReferenceEquals(actual,challenge.fishingChallengeData)); + actual.eliminateNumber.Clear(); + Check(state.eliminateNumber.Count==2 && challenge.fishingChallengeData.eliminateNumber.Count==2); + actual.robotAccountList.Clear(); + Check(state.robotAccountList.Count==2 && challenge.fishingChallengeData.robotAccountList.Count==2); + challenge.ResetForKVLoad(); challenge.ReplaceFromKVDecoded(nullState); + Check(challenge.fishingChallengeData == null && challenge.IsInactiveForKVValidation); + Check(challenge.ChallengeState() == 0 && !challenge.ShouldOpen()); + Check(JsonConvert.SerializeObject(challenge.fishingChallengeData) == "null"); + challenge.ReplaceFromKVDecoded(state); + Check(challenge.fishingChallengeData != null && !state.IsExplicitNull); + challenge.ResetForKVLoad(); challenge.ReplaceFromKVDecoded(FishingChallengeManager.DecodeForKVLoad(new Dictionary(),snapshot,new PlayerKVSchemaDiagnostics())); + Check(challenge.fishingChallengeData==null && challenge.IsInactiveForKVValidation); + } + + { + var raw=new Dictionary { ["epbdk"]=JsonConvert.SerializeObject(new { EventID=11,PinballMainID=11,SpinPoint=11,Scores=11,Multiple=11,Token=11,ConsumedToken=11,RewardIndex=11,Force=1.5f,LuckyValue=1.5f,InflationRate=1.5f }) }; + var state=EventPinballDataManager.DecodeForKVLoad(raw,snapshot,new PlayerKVSchemaDiagnostics()); + Check(state!=null && state.IsPresent); + pinball.ResetForKVLoad(); pinball.ReplaceFromKVDecoded(state); + var actual=pinball.PinballData; + Check(actual.EventID==state.EventID); + Check(actual.PinballMainID==state.PinballMainID); + Check(actual.SpinPoint==state.SpinPoint); + Check(actual.Scores==state.Scores); + Check(actual.Multiple==state.Multiple); + Check(actual.Token==state.Token); + Check(actual.ConsumedToken==state.ConsumedToken); + Check(actual.RewardIndex==state.RewardIndex); + Check(actual.Force==state.Force); + Check(actual.LuckyValue==state.LuckyValue); + Check(actual.InflationRate==state.InflationRate); + pinball.ReplaceFromKVDecoded(state); + Check(!ReferenceEquals(actual,pinball.PinballData)); + pinball.ResetForKVLoad(); pinball.ReplaceFromKVDecoded(EventPinballDataManager.DecodeForKVLoad(new Dictionary(),snapshot,new PlayerKVSchemaDiagnostics())); + Check(pinball.PinballData==null && pinball.IsInactiveForKVValidation); + } + + { + var raw=new Dictionary { ["estdk"]=JsonConvert.SerializeObject(new { EventID=11,ScratchMainID=11,ChainPackID=11,Token=11,ConsumedToken=11,RewardTaskConsumedToken=11,EventScratchRewardTaskID=11,EventScratchCardID=11,TotalConsumedToken=11,ChainPackProgress=11,magIndex=11,IsOver=true,LuckyValue=1.5f,InflationRate=1.5f,StampRewardList=new[] { 7,9 },StampRewardDict=new Dictionary { [7]=new { EventScratchCardID=8,Count=9 } } }) }; + var state=EventScratchTicketDataManager.DecodeForKVLoad(raw,snapshot,new PlayerKVSchemaDiagnostics()); + Check(state!=null && state.IsPresent); + scratch.ResetForKVLoad(); scratch.ReplaceFromKVDecoded(state); + var actual=scratch.ScratchTicketData; + Check(actual.EventID==state.EventID); + Check(actual.ScratchMainID==state.ScratchMainID); + Check(actual.ChainPackID==state.ChainPackID); + Check(actual.Token==state.Token); + Check(actual.ConsumedToken==state.ConsumedToken); + Check(actual.RewardTaskConsumedToken==state.RewardTaskConsumedToken); + Check(actual.EventScratchRewardTaskID==state.EventScratchRewardTaskID); + Check(actual.EventScratchCardID==state.EventScratchCardID); + Check(actual.TotalConsumedToken==state.TotalConsumedToken); + Check(actual.ChainPackProgress==state.ChainPackProgress); + Check(actual.magIndex==state.magIndex); + Check(actual.IsOver==state.IsOver); + Check(actual.LuckyValue==state.LuckyValue); + Check(actual.InflationRate==state.InflationRate); + Check(actual.StampRewardList.SequenceEqual(state.StampRewardList)); + Check(actual.StampRewardDict[7].EventScratchCardID==state.StampRewardDict[7].EventScratchCardID && actual.StampRewardDict[7].Count==state.StampRewardDict[7].Count); + scratch.ReplaceFromKVDecoded(state); + Check(!ReferenceEquals(actual,scratch.ScratchTicketData)); + actual.StampRewardList.Clear(); + Check(state.StampRewardList.Count==2 && scratch.ScratchTicketData.StampRewardList.Count==2); + actual.StampRewardDict[7].Count=99; + Check(state.StampRewardDict[7].Count==9 && scratch.ScratchTicketData.StampRewardDict[7].Count==9); + scratch.ResetForKVLoad(); scratch.ReplaceFromKVDecoded(EventScratchTicketDataManager.DecodeForKVLoad(new Dictionary(),snapshot,new PlayerKVSchemaDiagnostics())); + Check(scratch.ScratchTicketData==null && scratch.IsInactiveForKVValidation); + } + + return Task.FromResult(JsonConvert.SerializeObject(new { success=true,caseId="M15-M26-M27",assertions,fullB220=false,sharedContainerTouched=false })); + } + catch(Exception ex) { return Task.FromResult(JsonConvert.SerializeObject(new { success=false,caseId="M15-M26-M27",assertions,errorType=ex.GetType().Name,fullB220=false })); } + finally { challenge.ResetForKVLoad(); pinball.ResetForKVLoad(); scratch.ResetForKVLoad(); } + } + static Task RunBuffTrigger() + { + int assertions=0; + void Check(bool value) { assertions++; if(!value) throw new InvalidOperationException("Buff/trigger assertion failed."); } + var buffs=new BuffDataCenter(null); var triggers=new GameCore.TriggerPackData(null); + var snapshot=new PlayerKVSchemaSnapshot("M06-M13",Array.Empty(),Array.Empty(),new Dictionary(), + CultureInfo.InvariantCulture,new DateTime(2026,9,23),0); + try + { + const string value="{\"SortID\":1,\"sourceID\":2,\"buffID\":3,\"isEnd\":false,\"buffEndTime\":\"2026-09-24T00:00:00.0000000Z\"}"; + var state=BuffDataCenter.DecodeForKVLoad(new Dictionary { + ["BuffDataList"]="{\"7\":"+value+"}",["TargetBuffDataList"]="["+value+"]", + ["FishDailyBuff"]=value,["GlobalBuffDataList"]="{\"7\":"+value+"}" + },snapshot,new PlayerKVSchemaDiagnostics()); + buffs.ResetForKVLoad(); buffs.ReplaceFromKVDecoded(state); + for(int kind=0;kind<4;kind++) + { + var actual=buffs.BuffForKVValidation(kind,kind==1 ? 0 : 7); + Check(actual.SortID==1 && actual.sourceID==2 && actual.buffID==3 && !actual.isEnd && + actual.buffEndTime==state.Daily.buffEndTime && !actual.isNew && actual.addDown==0); + actual.buffID=99; actual.isNew=true; actual.addDown=9; + } + Check(state.Map[7].buffID==3 && state.Target[0].buffID==3 && state.Daily.buffID==3 && state.Global[7].buffID==3); + buffs.showFishBuffIds.Add(new FishBuffTimeData { buffID=99 }); + buffs.ResetForKVLoad(); buffs.ReplaceFromKVDecoded(BuffDataCenter.DecodeForKVLoad(new Dictionary(),snapshot,new PlayerKVSchemaDiagnostics())); + Check(buffs.IsEmptyForKVValidation); + var raw=new Dictionary { + ["TriggerPackDataList"]="[{\"ID\":1,\"index\":2,\"lastID\":3,\"vipLevel\":4,\"mapID\":5,\"time\":\"2026-09-24\"}]", + ["GiftVIPDData"]="{\"A\":{\"oldVipLevel\":1,\"curVipLevel\":2,\"timer\":3}}", + ["FixedTimeShopDic"]="{\"7\":{\"id\":8,\"endTime\":\"2026-09-24\"}}" + }; + var packs=GameCore.TriggerPackData.DecodeForKVLoad(raw,new PlayerKVSchemaDiagnostics()); + triggers.ResetForKVLoad(); triggers.ReplaceFromKVDecoded(packs); + var pack=triggers.triggerPackList[0]; var gift=triggers.giftVIPDData["A"]; var fixedTime=triggers.FixedForKVValidation(7); + Check(pack.ID==1 && pack.index==2 && pack.lastID==3 && pack.vipLevel==4 && pack.mapID==5 && pack.time=="2026-09-24"); + Check(gift.oldVipLevel==1 && gift.curVipLevel==2 && gift.timer==3 && fixedTime.id==8 && fixedTime.endTime=="2026-09-24"); + pack.ID=99; gift.timer=99; fixedTime.id=99; + Check(packs.Packs[0].ID==1 && packs.Gifts["A"].timer==3 && packs.Fixed[7].id==8); + triggers.ResetForKVLoad(); triggers.ReplaceFromKVDecoded(GameCore.TriggerPackData.DecodeForKVLoad(new Dictionary(),new PlayerKVSchemaDiagnostics())); + Check(triggers.IsEmptyForKVValidation); + return Task.FromResult(JsonConvert.SerializeObject(new { success=true,caseId="M06-M13",assertions,fullB220=false,sharedContainerTouched=false })); + } + catch(Exception ex) { return Task.FromResult(JsonConvert.SerializeObject(new { success=false,caseId="M06-M13",assertions,errorType=ex.GetType().Name,fullB220=false })); } + finally { buffs.ResetForKVLoad(); triggers.ResetForKVLoad(); } + } + static Task RunOffers() + { + int assertions=0; + void Check(bool value) { assertions++; if(!value) throw new InvalidOperationException("Offer assertion failed."); } + var chains=new DataCenter.OfferChainsChestManager(); + var disco=new DataCenter.OfferDiscoPartyManager(); + var snapshot=new PlayerKVSchemaSnapshot("M16-M17",Array.Empty(),Array.Empty(),new Dictionary(), + CultureInfo.InvariantCulture,new DateTime(2026,9,23),0); + try + { + var text=JsonConvert.SerializeObject(new { FirstOpen=true,EventId=7,StartTime="2026-09-22T00:00:00.0000000Z",EndTime="2026-09-24T00:00:00.0000000Z", + ItemId=-1,LotteryNum=5,Items=new[] { new { Id=1,Item=2,Number=3,Status=2 } },RewardList=new[] { 8,9 },Finished=true,ChestReward=10 }); + + { + var state=DataCenter.OfferChainsChestManager.DecodeForKVLoad(new Dictionary { ["OfferChainsChestData"]=text },snapshot,new PlayerKVSchemaDiagnostics()); + chains.ResetForKVLoad(); chains.ReplaceFromKVDecoded(state); + var actual=chains.OfferChainsData; + Check(actual.FirstOpen && actual.EventId==7 && actual.StartTime==state.StartTime && actual.EndTime==state.EndTime && + actual.ItemId == -1 && actual.LotteryNum==5); + Check(actual.Items.Count==1 && actual.Items[0].Id==1 && actual.Items[0].Item==2 && actual.Items[0].Number==3 && + (int)actual.Items[0].Status==2 && actual.RewardList.SequenceEqual(new[] { 8,9 })); + Check(actual.Finished && actual.ChestReward==10); + actual.Items[0].Number=99; actual.RewardList.Clear(); + Check(state.Items[0].Number==3 && state.RewardList.Count==2); + chains.ReplaceFromKVDecoded(state); + Check(!ReferenceEquals(actual,chains.OfferChainsData) && !ReferenceEquals(actual.Items[0],chains.OfferChainsData.Items[0]) && + chains.OfferChainsData.Items[0].Number==3); + chains.ValidIds.Add(7); + var missing=DataCenter.OfferChainsChestManager.DecodeForKVLoad(new Dictionary(),snapshot,new PlayerKVSchemaDiagnostics()); + chains.ResetForKVLoad(); chains.ReplaceFromKVDecoded(missing); + Check(chains.OfferChainsData==null && chains.ValidIds.Count==0 && chains.IsInactiveForKVValidation); + Check(chains.OfferChainsChestMain==null && chains.OfferChainsChestRewards==null); + } + + { + var state=DataCenter.OfferDiscoPartyManager.DecodeForKVLoad(new Dictionary { ["OfferDiscoPartyManager"]=text },snapshot,new PlayerKVSchemaDiagnostics()); + disco.ResetForKVLoad(); disco.ReplaceFromKVDecoded(state); + var actual=disco.OfferDiscoPartyData; + Check(actual.FirstOpen && actual.EventId==7 && actual.StartTime==state.StartTime && actual.EndTime==state.EndTime && + actual.ItemId == -1 && actual.LotteryNum==5); + Check(actual.Items.Count==1 && actual.Items[0].Id==1 && actual.Items[0].Item==2 && actual.Items[0].Number==3 && + (int)actual.Items[0].Status==2 && actual.RewardList.SequenceEqual(new[] { 8,9 })); + + actual.Items[0].Number=99; actual.RewardList.Clear(); + Check(state.Items[0].Number==3 && state.RewardList.Count==2); + disco.ReplaceFromKVDecoded(state); + Check(!ReferenceEquals(actual,disco.OfferDiscoPartyData) && !ReferenceEquals(actual.Items[0],disco.OfferDiscoPartyData.Items[0]) && + disco.OfferDiscoPartyData.Items[0].Number==3); + disco.ValidIds.Add(7); disco.FreeTime=99; + var missing=DataCenter.OfferDiscoPartyManager.DecodeForKVLoad(new Dictionary(),snapshot,new PlayerKVSchemaDiagnostics()); + disco.ResetForKVLoad(); disco.ReplaceFromKVDecoded(missing); + Check(disco.OfferDiscoPartyData==null && disco.ValidIds.Count==0 && disco.IsInactiveForKVValidation); + Check(disco.OfferDiscoParty==null && disco.OfferDiscoPartyRewards==null && disco.FreeTime==1); + } + + return Task.FromResult(JsonConvert.SerializeObject(new { success=true,caseId="M16-M17",assertions,fullB220=false,sharedContainerTouched=false })); + } + catch(Exception ex) { return Task.FromResult(JsonConvert.SerializeObject(new { success=false,caseId="M16-M17",assertions,errorType=ex.GetType().Name,fullB220=false })); } + finally { chains.ResetForKVLoad(); disco.ResetForKVLoad(); } + } + static Task RunCamp() + { + int assertions=0; + void Check(bool value) { assertions++; if (!value) throw new InvalidOperationException("Camp assertion failed."); } + var camp=new CampDataMM(null,null); + try + { + var a=CampDataMM.DecodeForKVLoad(new Dictionary { ["IsFix"]="true",["IsOpenSkyscraper"]="1",["CampDataDic"]="{\"CampId\":205}" },new PlayerKVSchemaDiagnostics()); + camp.ResetForKVLoad(); camp.ReplaceFromKVDecoded(a); + var actual=camp.SnapshotForKVValidation(); + Check(actual.IsFix && actual.Skyscraper=="1" && actual.Values["CampId"]==205); + camp.Id=205; camp.PreID=204; camp.SetCampAreaIndex("3"); camp.SetCampAreaStep("4"); camp.IsUpdataArea=true; + var b=CampDataMM.DecodeForKVLoad(new Dictionary(),new PlayerKVSchemaDiagnostics()); + camp.ResetForKVLoad(); camp.ReplaceFromKVDecoded(b); + actual=camp.SnapshotForKVValidation(); + Check(!actual.IsFix && actual.Skyscraper=="0" && actual.Values.Count==0); + Check(camp.Id==101 && camp.PreID==0 && camp.AreaIndex==0 && camp.AreaStep==0 && camp.AreaId==0 && camp.AreaStepCount==0 && camp.TimelineId==0); + Check(camp.AreaIcon==null && camp.constructionArea==null && camp.StepInfo==null && camp.AreaStepList==null && !camp.IsUpdataArea); + Check(a.Values.Count==1); + return Task.FromResult(JsonConvert.SerializeObject(new { success=true,caseId="M07",assertions,fullB220=false,sharedContainerTouched=false })); + } + catch(Exception ex) { return Task.FromResult(JsonConvert.SerializeObject(new { success=false,caseId="M07",assertions,errorType=ex.GetType().Name,fullB220=false })); } + finally { camp.ResetForKVLoad(); } + } + static Task RunClub() + { + int assertions = 0; + void Check(bool condition) { assertions++; if (!condition) throw new InvalidOperationException("Club assertion failed."); } + var club = new ClubService(); + var snapshot = new PlayerKVSchemaSnapshot("M14",Array.Empty(),Array.Empty(),new Dictionary(), + CultureInfo.InvariantCulture,new DateTime(2026,9,23,0,0,0,DateTimeKind.Utc),0); + try + { + club.OpenPanelType = 17; club.IsOpenClubJoined = true; club.internalIdToPlayerId[1] = "synthetic"; + var unrelated = club.internalIdToPlayerId; + var a = ClubService.DecodeForKVLoad(new Dictionary { + ["ClubSignin"] = "2026-09-23T00:00:00.0000000Z", + ["LureDonateData"] = "{\"DonateCountTime\":\"2026-09-22T00:00:00.0000000Z\",\"ClubLureDonateCount\":7,\"ClubLureRequestCDEnd\":\"2026-09-24T00:00:00.0000000Z\"}", + ["IsOpenClubRed"] = "1" + },snapshot,new PlayerKVSchemaDiagnostics()); + club.ResetForKVLoad(); club.ReplaceFromKVDecoded(a); + var actual = club.SnapshotForKVValidation(); + Check(actual.SignTime == a.SignTime && actual.DonateTime == a.DonateTime && actual.DonateCount == 7 && + actual.RequestEnd == a.RequestEnd && !actual.OpenRed); + var missing = ClubService.DecodeForKVLoad(new Dictionary(),snapshot,new PlayerKVSchemaDiagnostics()); + club.ResetForKVLoad(); club.ReplaceFromKVDecoded(missing); + actual = club.SnapshotForKVValidation(); + Check(actual.SignTime == null && actual.DonateTime == default && actual.RequestEnd == default && + actual.DonateCount == 0 && actual.OpenRed); + Check(ReferenceEquals(unrelated,club.internalIdToPlayerId) && unrelated.Count == 1 && club.OpenPanelType == 17 && club.IsOpenClubJoined); + return Task.FromResult(JsonConvert.SerializeObject(new { success=true,caseId="M14",assertions,fullB220=false,sharedContainerTouched=false })); + } + catch (Exception ex) + { return Task.FromResult(JsonConvert.SerializeObject(new { success=false,caseId="M14",assertions,errorType=ex.GetType().Name,fullB220=false })); } + finally { club.ResetForKVLoad(); } + } + static Task RunCollections() + { + int assertions = 0; + void Check(bool condition) { assertions++; if (!condition) throw new InvalidOperationException("Collection assertion failed."); } + var box = new FishingBoxData(); + var guide = new GameCore.GuideDataCenter(null,null); + var signin = new GameCore.SigninData(null); + try + { + var source = new Dictionary { [1] = 7 }; + var boxes = new PlayerKVIntMapState(source); + source.Clear(); + box.ResetForKVLoad(); box.ReplaceFromKVDecoded(boxes); + Check(box.FishingBoxOpenNum.Count == 1 && box.FishingBoxOpenNum[1] == 7); + box.FishingBoxOpenNum[1] = 8; box.FishingNewBoxs[2] = 9; + Check(boxes.Values[1] == 7); + var previousBoxes = box.FishingNewBoxs; + var previousCounts = box.FishingBoxOpenNum; + var missingBoxes = FishingBoxData.DecodeForKVLoad(new Dictionary(),new Dictionary(),new PlayerKVSchemaDiagnostics()); + box.ResetForKVLoad(); box.ReplaceFromKVDecoded(missingBoxes); + Check(box.FishingNewBoxs.Count == 0 && box.FishingBoxOpenNum.Count == 0); + Check(!ReferenceEquals(previousBoxes,box.FishingNewBoxs) && !ReferenceEquals(previousCounts,box.FishingBoxOpenNum)); + var guides = GameCore.GuideDataCenter.DecodeForKVLoad(new Dictionary { ["GuideSaveDataNew"] = "A|3|1;B|4|0" },new PlayerKVSchemaDiagnostics()); + guide.ResetForKVLoad(); guide.ReplaceFromKVDecoded(guides); + Check(guide.GuideSaveDataDic.Count == 2 && guide.GuideSaveDataDic["A"].guideSubIndex == 3 && + guide.GuideSaveDataDic["A"].isFinish && guide.GuideSaveDataDic["A"].groupName == "A"); + guide.GuideSaveDataDic["A"].guideSubIndex = 99; + Check(guides.Values["A"].Index == 3); + guide.curSaveData = guide.GuideSaveDataDic["A"]; + guide.popupPanelNode = "synthetic"; guide.CurPanelName = "synthetic"; + guide.curDefine = new GuidanceDefine(); + guide.curGroupDefine = new GuidanceGroupDefine(); + var definitions = new GuidanceGroupDefine[0]; + guide.guidanceGroupDefines = definitions; + var missingGuides = GameCore.GuideDataCenter.DecodeForKVLoad(new Dictionary(),new PlayerKVSchemaDiagnostics()); + guide.ResetForKVLoad(); guide.ReplaceFromKVDecoded(missingGuides); + Check(guide.GuideSaveDataDic.Count == 0 && guide.curSaveData == null && guide.curDefine == null && + guide.curGroupDefine == null && guide.popupPanelNode == null && guide.CurPanelName == null); + Check(ReferenceEquals(guide.guidanceGroupDefines,definitions)); + var sign = GameCore.SigninData.DecodeForKVLoad(new Dictionary { + ["MonthlySignEvent"] = "{\"index\":5,\"dailySignEvent\":{\"ID\":7,\"index\":2,\"lastID\":-1,\"time\":\"2026-09-23\"}}" + },new PlayerKVSchemaDiagnostics()); + signin.ResetForKVLoad(); signin.ReplaceFromKVDecoded(sign); + var actual = signin.monthlySignEvent; + Check(actual.index == 5 && actual.dailySignEvent.ID == 7 && actual.dailySignEvent.index == 2 && + actual.dailySignEvent.lastID == -1 && actual.dailySignEvent.time == "2026-09-23"); + actual.dailySignEvent.ID = 99; + Check(sign.DailyId == 7); + signin.ReplaceFromKVDecoded(sign); + Check(!ReferenceEquals(actual,signin.monthlySignEvent) && + !ReferenceEquals(actual.dailySignEvent,signin.monthlySignEvent.dailySignEvent) && signin.monthlySignEvent.dailySignEvent.ID == 7); + var missingSign = GameCore.SigninData.DecodeForKVLoad(new Dictionary(),new PlayerKVSchemaDiagnostics()); + signin.ResetForKVLoad(); signin.ReplaceFromKVDecoded(missingSign); + Check(signin.monthlySignEvent == null); + return Task.FromResult(JsonConvert.SerializeObject(new { success=true,caseId="M09-M10-M12",assertions,fullB220=false,sharedContainerTouched=false })); + } + catch (Exception ex) + { return Task.FromResult(JsonConvert.SerializeObject(new { success=false,caseId="M09-M10-M12",assertions,errorType=ex.GetType().Name,fullB220=false })); } + finally { box.ResetForKVLoad(); guide.ResetForKVLoad(); guide.Dispose(); signin.ResetForKVLoad(); } + } + + // A static access shim only; never added as a component or instantiated. + sealed class ValidationContextAccess : asap.core.GContext + { + protected override Task SetupAsync() => throw new InvalidOperationException("Not a gameplay context."); + internal static void Use(asap.core.Container value) { container = value; } + internal static asap.core.IEventAggregator Events { get => eventAggregator; set => eventAggregator = value; } + } + static bool running; + static Task RunD3(bool includeProgress = false,bool includeCore = false,bool includeShop = false,bool includeEventBase = false) + { + if (running || asap.core.GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll().Length != 0) + throw new InvalidOperationException("Game context is active."); + running = true; + var originalContainer = asap.core.GContext.container; + var originalAggregator = ValidationContextAccess.Events; + var isolated = new asap.core.Container(); + PiggyBankPackData piggy = null; + BargainPackData bargain = null; + EventShootingRangeData shooting = null; + ThanksGivingPackData progress = null; + ChainPackWithProgressMigrationData migration = null; + EventPackData eventPack = null; + EventPinataPackDataManager pinata = null; + GameCore.FishingEventData eventBase = null; + GameCore.PlayerShopData shop = null; + GameCore.PlayerData player = null; + GameCore.SmallGameData small = null; + int assertions = 0; + bool success = false, restored = false; + string errorType = null; + void Check(bool condition) { assertions++; if (!condition) throw new InvalidOperationException("D3 assertion failed."); } + try + { + // Read local tables into a private graph. No active account, network, runtime init or table writes. + var directory = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../cfgData")); + var tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(System.IO.File.ReadAllText(System.IO.Path.Combine(directory, name + ".json")))); + var packTypes = tables.TbEventPackManager.DataMap.ToDictionary(p => p.Key, p => p.Value.PackType); + int bargainId = packTypes.First(p => p.Value == 3).Key; + var snapshot = new PlayerKVSchemaSnapshot("local-D3", Array.Empty(), Array.Empty(), + new Dictionary(), CultureInfo.InvariantCulture, new DateTime(2026,9,23,0,0,0,DateTimeKind.Utc),0,packTypes); + Check(snapshot.HasPack(4055001)); + ValidationContextAccess.Use(isolated); + ValidationContextAccess.Events = new asap.core.EventAggregator(); + Check(asap.core.ContainerExtensions.RegisterInstance(isolated, tables)); + Check(ReferenceEquals(asap.core.ContainerExtensions.Resolve(isolated), tables)); + piggy = new PiggyBankPackData(); bargain = new BargainPackData(); shooting = new EventShootingRangeData(); + if(includeEventBase) + { + eventBase=new GameCore.FishingEventData(tables); + var progressReference=eventBase.ThanksGivingPackData; + var eventSnapshot=new PlayerKVSchemaSnapshot("local-M05",Array.Empty(),Array.Empty(),new Dictionary(),CultureInfo.InvariantCulture,snapshot.NowUtc,tables.TbGlobalConfig.InitWheelTicket,globalTimeOffset:tables.TbGlobalConfig.GlobalTimeOffset); + var raw=new Dictionary { + ["LimitedTimeEventDic"]="{\"1\":{\"id\":2,\"duration\":60.123456789,\"startTime\":\"2026-09-23T00:00:00.0000000Z\"},\"3\":{\"id\":4,\"duration\":60,\"startTime\":\"2026-09-22T00:00:00.0000000Z\"}}", + ["FixedTimeEventDic"]="{\"5\":{\"id\":6,\"endTime\":\"raw\"}}", + ["ETDropAfterDropDic"]="{\"1\":2,\"3\":4}",["RankTargetCount"]="{\"5\":6,\"7\":8}", + ["WheelLevelCount"]="{\"9\":10}",["TurntableRoll"]="11",["WheelOperateCount"]="12",["AllWheelOperateCount"]="13",["WheelLevelFreeCount"]="14",["PVPToken"]="15" + }; + var state=GameCore.FishingEventData.DecodeForKVLoad(raw,new Dictionary{["ct"]="1:2",["rt"]="5:6"},eventSnapshot,new PlayerKVSchemaDiagnostics()); + Check(state!=null); eventBase.ResetForKVLoad(); eventBase.ReplaceFromKVDecoded(state); + var actual=eventBase.SnapshotForKVValidation(); + Check(actual.Limited.Count==1 && actual.Limited[1].Id==2 && actual.Limited[1].Duration==60.123456789 && actual.Fixed[5].id==6 && actual.Fixed[5].endTime=="raw"); + Check(actual.Target.Count==1 && actual.Target[1]==2 && actual.Rank.Count==1 && actual.Rank[5]==6 && actual.Wheel[9]==10); + Check(actual.Coupon==11 && actual.Operate==12 && actual.AllOperate==13 && actual.Free==14 && actual.PvpToken==15 && eventBase.IsInactiveForKVValidation); + eventBase.MutateCollectionsForKVValidation(); + Check(state.Limited[1].Duration==60.123456789 && state.Fixed[5].id==6 && state.Target.Count==1 && state.Rank.Count==1 && state.Wheel.Count==1); + eventBase.ResetForKVLoad(); eventBase.ReplaceFromKVDecoded(GameCore.FishingEventData.DecodeForKVLoad(new Dictionary(),new Dictionary(),eventSnapshot,new PlayerKVSchemaDiagnostics())); + actual=eventBase.SnapshotForKVValidation(); + Check(actual.Limited.Count==0 && actual.Fixed.Count==0 && actual.Target.Count==0 && actual.Rank.Count==0 && actual.Wheel.Count==0); + Check(actual.Coupon==tables.TbGlobalConfig.InitWheelTicket && actual.Operate==0 && actual.AllOperate==0 && actual.Free==0 && actual.PvpToken==0 && eventBase.IsInactiveForKVValidation); + Check(ReferenceEquals(progressReference,eventBase.ThanksGivingPackData)); + } + if(includeShop) + { + shop=new GameCore.PlayerShopData(); + var couponReference=shop.CouponData; + var daily="{\"ID\":1,\"index\":2,\"lastID\":3,\"time\":\"raw\"}"; + var raw=new Dictionary { + ["ShopPackBuyCount"]="{\"x\":1}",["ShopAllPackBuyCount"]="{\"2\":3}",["ShopGiftBuyCount"]="{\"4\":5}", + ["Voucher"]="6",["ChainPackDataNew"]=daily,["TurntableAdData"]=daily, + ["VIPAdPackData"]="{\"ID\":7,\"index\":8,\"day\":9,\"cdTime\":\"raw\",\"isOpen\":true}", + ["BuyData"]="[1.5,2.5]",["AdvertAllCount"]="{\"-2\":10}",["DiamondShopRefreshCount"]="11", + ["DiamondShopRefreshTime"]="2026-09-23T00:00:00.0000000Z", + ["DiamondShopItemRecords"]="{\"12\":{\"ItemID\":13,\"Discount\":0.5,\"hasBought\":1}}", + ["BlackFridayCouponDate"]="2026,9,23,1,2,3,14,15,1" + }; + var state=GameCore.PlayerShopData.DecodeForKVLoad(raw,snapshot,new PlayerKVSchemaDiagnostics()); + Check(state!=null); shop.ResetForKVLoad(); shop.ReplaceFromKVDecoded(state); + var actual=shop.SnapshotForKVValidation(); + Check(actual.Daily["x"]==1 && actual.All[2]==3 && actual.Gift[4]==5 && actual.Voucher==6); + Check(actual.Chain.Id==1 && actual.Chain.Index==2 && actual.Chain.LastId==3 && actual.Chain.Time=="raw" && actual.Turntable.LastId==3); + Check(actual.Vip.Id==7 && actual.Vip.Index==8 && actual.Vip.Day==9 && actual.Vip.Time=="raw" && actual.Vip.Open); + Check(actual.Advert[-2]==10 && actual.Buy.SequenceEqual(new[] {1.5f,2.5f}) && actual.RefreshCount==11 && actual.RefreshTime==snapshot.NowUtc); + Check(actual.Items[12].Id==13 && actual.Items[12].Discount==.5f && actual.Items[12].Bought==1); + Check(ReferenceEquals(couponReference,shop.CouponData) && actual.Coupon.ActivateTime==new DateTime(2026,9,23,1,2,3) && actual.Coupon.EventId==14 && actual.Coupon.RedirectId==15 && actual.Coupon.Bought); + shop.shopPackDailyBuyCount.Clear(); shop.shopAllPackBuyCount.Clear(); shop.shopGiftBuyCount.Clear(); + shop.ChainPackData.index=99; shop.TurntableAdData.index=99; shop.VIPAdPackData.index=99; shop.MutateCollectionsForKVValidation(); + Check(state.Daily.Count==1 && state.All.Count==1 && state.Gift.Count==1 && state.Advert.Count==1 && state.Buy.Count==2 && state.Items[12].Discount==.5f && state.Chain.Index==2 && state.Turntable.Index==2 && state.Vip.Index==8); + shop.ResetForKVLoad(); shop.ReplaceFromKVDecoded(GameCore.PlayerShopData.DecodeForKVLoad(new Dictionary(),snapshot,new PlayerKVSchemaDiagnostics())); + actual=shop.SnapshotForKVValidation(); + Check(actual.Daily.Count==0 && actual.All.Count==0 && actual.Gift.Count==0 && actual.Buy.Count==0 && actual.Items.Count==0 && actual.Advert.Count==0); + Check(actual.Voucher==0 && actual.RefreshCount==0 && actual.RefreshTime==default && actual.Chain==null && actual.Turntable==null && actual.Vip==null); + Check(ReferenceEquals(couponReference,shop.CouponData) && actual.Coupon.ActivateTime==default && actual.Coupon.EventId==0 && actual.Coupon.RedirectId==0 && !actual.Coupon.Bought); + } + if(includeCore) + { + int rod=tables.TbRodData.DataList[0].ID; + var coreSnapshot=new PlayerKVSchemaSnapshot("local-M01-M02",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys, + new Dictionary(),CultureInfo.InvariantCulture,snapshot.NowUtc,0); + var slots=new[] { "3","18446744073709551615","4","5","6","7",rod.ToString(CultureInfo.InvariantCulture), + "2026-09-23T00:00:00.0000000Z","-1","8","18446744073709551614","9","0","0","10","11","12","13","14" }; + var modern=new Dictionary { ["newData"]=string.Join(",",slots),["vipL"]="-1,5|0,6|1,7" }; + var raw=new Dictionary { ["NewRodList"]="[7,8]",["SeasonId"]="9",["IsOpenAppraise"]="1",["InfiniteBuildingData"]="raw" }; + var state=GameCore.PlayerData.DecodeForKVLoad(raw,modern,coreSnapshot,new PlayerKVSchemaDiagnostics()); + Check(state!=null); + player=new GameCore.PlayerData(tables,null); small=new GameCore.SmallGameData(tables,null,null); + player.ResetForKVLoad(); small.ResetForKVLoad(); + player.ReplaceFromKVDecoded(state); small.ReplaceFromKVDecoded(state.Core); + var actual=player.SnapshotForKVValidation(); + for(int i=0;i<16;i++) + { + object value=i==1 || i==10 ? (object)state.Core.Unsigned(i) : i==7 ? (object)state.Core.EnergyTime : state.Core.Integer(i); + Check(actual.Core.Matches(i,value)); + } + Check(actual.NewRods.SequenceEqual(new[] { 7,8 }) && actual.Season==9 && actual.Appraise && actual.Building=="raw" && + actual.VipScores[-1]==5 && actual.VipScores[0]==6 && actual.VipScores[1]==7); + Check(small.HeistGameCount==12 && small.BombGameCount==13 && small.FishHuntGameCount==14); + player.newRodList.Clear(); Check(state.NewRods.Count==2); + small.opponentItemDatas.Add(null); small.opponentFriendItemDatas.Add(null); small.opponentNotFriendItemDatas.Add(null); + small.opponentFriend.Add(null); small.powerData["synthetic"]=1; small.RobotMapId=99; small.nextRobotMapId=98; + modern.Remove("vipL"); + var b=GameCore.PlayerData.DecodeForKVLoad(new Dictionary(),modern,coreSnapshot,new PlayerKVSchemaDiagnostics()); + player.ResetForKVLoad(); small.ResetForKVLoad(); player.ReplaceFromKVDecoded(b); small.ReplaceFromKVDecoded(b.Core); + actual=player.SnapshotForKVValidation(); + Check(actual.NewRods.Count==0 && actual.VipScores.Count==0 && actual.Season==0 && !actual.Appraise && actual.Building=="0"); + Check(player.InfiniteBuildingLevel=="0"); // Actual leaderboard consumer after missing-key Replace. + foreach(var building in new[]{"", "23,7"}) + { + var existing=new Dictionary{{"InfiniteBuildingData",building}}; + var saved=GameCore.PlayerData.DecodeForKVLoad(existing,modern,coreSnapshot,new PlayerKVSchemaDiagnostics()); + player.ResetForKVLoad(); player.ReplaceFromKVDecoded(saved); + Check(player.InfiniteBuildingData==building && player.InfiniteBuildingLevel==building.Split(',')[0]); + } + var nullErrors=new PlayerKVSchemaDiagnostics(); + Check(GameCore.PlayerData.DecodeForKVLoad(new Dictionary{{"InfiniteBuildingData",null}},modern,coreSnapshot,nullErrors)==null && nullErrors.TotalErrors>0); + Check(small.IsOpponentEmptyForKVValidation && player.IsDerivedEmptyForKVValidation); + player.ResetForKVLoad(); small.ResetForKVLoad(); actual=player.SnapshotForKVValidation(); + for(int i=0;i<16;i++) + { + object value=i==1 || i==10 ? (object)0UL : i==7 ? (object)DateTime.MinValue : 0; + Check(actual.Core.Matches(i,value)); + } + Check(small.HeistGameCount==0 && small.BombGameCount==0 && small.FishHuntGameCount==0 && player.PlayerDataDic.Count==0 && player.InfiniteBuildingData==null); + } + { + var raw = new Dictionary { ["PiggyBankPackData"] = JsonConvert.SerializeObject(new { currentEventID = 11, purchaseCount = 11, VIPLvlWhenEventActivate = 11, savingProgress = 11, redirectID = 0, doNeedTrigger = true, isTriggered = true, inflation = 1.5f }) }; + var errors = new PlayerKVSchemaDiagnostics(); + var a = PiggyBankPackData.DecodeForKVLoad(raw, snapshot, errors); + Check(a != null && a.IsPresent && errors.TotalErrors == 0); + piggy.ResetForKVLoad(); piggy.ReplaceFromKVDecoded(a); + var actual = piggy.SnapshotForKVValidation(); + Check(piggy.KVLoadPresent && actual.IsPresent); + Check(actual.currentEventID == a.currentEventID); + Check(actual.purchaseCount == a.purchaseCount); + Check(actual.VIPLvlWhenEventActivate == a.VIPLvlWhenEventActivate); + Check(actual.savingProgress == a.savingProgress); + Check(actual.redirectID == a.redirectID); + Check(actual.doNeedTrigger == a.doNeedTrigger); + Check(actual.isTriggered == a.isTriggered); + Check(actual.inflation == a.inflation); + var missing = PiggyBankPackData.DecodeForKVLoad(new Dictionary(), snapshot, new PlayerKVSchemaDiagnostics()); + piggy.ResetForKVLoad(); piggy.ReplaceFromKVDecoded(missing); + actual = piggy.SnapshotForKVValidation(); + Check(!piggy.KVLoadPresent && !actual.IsPresent); + Check(actual.currentEventID == 0); + Check(actual.purchaseCount == 0); + Check(actual.VIPLvlWhenEventActivate == 0); + Check(actual.savingProgress == 0); + Check(actual.redirectID == 0); + Check(actual.doNeedTrigger == false); + Check(actual.isTriggered == false); + Check(actual.inflation == 0); + piggy.ReplaceFromKVDecoded(a); + actual = piggy.SnapshotForKVValidation(); + Check(piggy.KVLoadPresent && actual.IsPresent); + Check(actual.currentEventID == a.currentEventID); + Check(actual.purchaseCount == a.purchaseCount); + Check(actual.VIPLvlWhenEventActivate == a.VIPLvlWhenEventActivate); + Check(actual.savingProgress == a.savingProgress); + Check(actual.redirectID == a.redirectID); + Check(actual.doNeedTrigger == a.doNeedTrigger); + Check(actual.isTriggered == a.isTriggered); + Check(actual.inflation == a.inflation); + raw["PiggyBankPackData"] = "null"; + Check(PiggyBankPackData.DecodeForKVLoad(raw,snapshot,new PlayerKVSchemaDiagnostics()) == null); + Check(piggy.KVLoadPresent); // Rejected decode cannot mutate the live object. + piggy.ResetForKVLoad(); + Check(!piggy.KVLoadPresent); + } + { + var raw = new Dictionary { ["GeneralEventPackData"] = JsonConvert.SerializeObject(new { currentEventID = 11, redirectID = bargainId, VIPLvlWhenEventActivate = 11, purchaseCount = 11, savingProgress = 11, discountLvl = 11, visualProgress = 11, visualDiscountLvl = 11, rodID = 11, lastPackIDBought = 11, packID = 11, doNeedTrigger = true, isTriggered = true, inflation = 1.5f, selections = new[] { 7, 9 } }) }; + var errors = new PlayerKVSchemaDiagnostics(); + var a = BargainPackData.DecodeForKVLoad(raw, snapshot, errors); + Check(a != null && a.IsPresent && errors.TotalErrors == 0); + bargain.ResetForKVLoad(); bargain.ReplaceFromKVDecoded(a); + var actual = bargain.SnapshotForKVValidation(); + Check(bargain.KVLoadPresent && actual.IsPresent); + Check(actual.currentEventID == a.currentEventID); + Check(actual.redirectID == a.redirectID); + Check(actual.VIPLvlWhenEventActivate == a.VIPLvlWhenEventActivate); + Check(actual.purchaseCount == a.purchaseCount); + Check(actual.savingProgress == a.savingProgress); + Check(actual.discountLvl == a.discountLvl); + Check(actual.visualProgress == a.visualProgress); + Check(actual.visualDiscountLvl == a.visualDiscountLvl); + Check(actual.rodID == a.rodID); + Check(actual.lastPackIDBought == a.lastPackIDBought); + Check(actual.packID == a.packID); + Check(actual.doNeedTrigger == a.doNeedTrigger); + Check(actual.isTriggered == a.isTriggered); + Check(actual.inflation == a.inflation); + Check(actual.selections.SequenceEqual(a.selections)); + var missing = BargainPackData.DecodeForKVLoad(new Dictionary(), snapshot, new PlayerKVSchemaDiagnostics()); + bargain.ResetForKVLoad(); bargain.ReplaceFromKVDecoded(missing); + actual = bargain.SnapshotForKVValidation(); + Check(!bargain.KVLoadPresent && !actual.IsPresent); + Check(actual.currentEventID == 0); + Check(actual.redirectID == 0); + Check(actual.VIPLvlWhenEventActivate == 0); + Check(actual.purchaseCount == 0); + Check(actual.savingProgress == 0); + Check(actual.discountLvl == 0); + Check(actual.visualProgress == 0); + Check(actual.visualDiscountLvl == 0); + Check(actual.rodID == 0); + Check(actual.lastPackIDBought == 0); + Check(actual.packID == 0); + Check(actual.doNeedTrigger == false); + Check(actual.isTriggered == false); + Check(actual.inflation == 0); + Check(actual.selections.Count == 0); + bargain.ReplaceFromKVDecoded(a); + actual = bargain.SnapshotForKVValidation(); + Check(bargain.KVLoadPresent && actual.IsPresent); + Check(actual.currentEventID == a.currentEventID); + Check(actual.redirectID == a.redirectID); + Check(actual.VIPLvlWhenEventActivate == a.VIPLvlWhenEventActivate); + Check(actual.purchaseCount == a.purchaseCount); + Check(actual.savingProgress == a.savingProgress); + Check(actual.discountLvl == a.discountLvl); + Check(actual.visualProgress == a.visualProgress); + Check(actual.visualDiscountLvl == a.visualDiscountLvl); + Check(actual.rodID == a.rodID); + Check(actual.lastPackIDBought == a.lastPackIDBought); + Check(actual.packID == a.packID); + Check(actual.doNeedTrigger == a.doNeedTrigger); + Check(actual.isTriggered == a.isTriggered); + Check(actual.inflation == a.inflation); + Check(actual.selections.SequenceEqual(a.selections)); + var listObject = Newtonsoft.Json.Linq.JObject.Parse(raw["GeneralEventPackData"]); + PlayerKVBargainState nullSnapshot = null; + foreach (var selection in new Newtonsoft.Json.Linq.JToken[] { Newtonsoft.Json.Linq.JValue.CreateNull(), + new Newtonsoft.Json.Linq.JArray(), new Newtonsoft.Json.Linq.JArray(7,9) }) + { + listObject["selections"] = selection; + raw["GeneralEventPackData"] = listObject.ToString(Newtonsoft.Json.Formatting.None); + errors = new PlayerKVSchemaDiagnostics(); + var decoded = BargainPackData.DecodeForKVLoad(raw, snapshot, errors); + Check(decoded != null && decoded.IsPresent && errors.TotalErrors == 0); + bargain.ReplaceFromKVDecoded(decoded); + var captured = bargain.SnapshotForKVValidation(); + Check(bargain.KVLoadPresent && Same(decoded, captured)); + // Fixed field read of the value SaveData serializes; never call the network save. + var live = (GeneralEventPackData)typeof(BargainPackData).GetField("_data", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).GetValue(bargain); + var serialized = Newtonsoft.Json.Linq.JObject.Parse(JsonConvert.SerializeObject(live)); + Check(Newtonsoft.Json.Linq.JToken.DeepEquals(serialized["selections"], selection)); + if (selection.Type == Newtonsoft.Json.Linq.JTokenType.Null) + { + Check(live.selections == null && captured.selections == null); + nullSnapshot = captured; + var different = (Newtonsoft.Json.Linq.JObject)listObject.DeepClone(); + different["purchaseCount"] = decoded.purchaseCount + 1; + var differentState = BargainPackData.DecodeForKVLoad(new Dictionary { + ["GeneralEventPackData"] = different.ToString(Newtonsoft.Json.Formatting.None) }, snapshot, new PlayerKVSchemaDiagnostics()); + Check(differentState != null && !Same(captured, differentState)); + } + else + { + Check(captured.selections != null && !Same(nullSnapshot, captured) && !Same(captured, nullSnapshot)); + Check(!ReferenceEquals(live.selections, decoded.selections) && !ReferenceEquals(live.selections, captured.selections)); + Check(!ReferenceEquals(captured.selections, bargain.SnapshotForKVValidation().selections)); + if (live.selections.Count > 0) + { + live.selections[0] = 99; + Check(decoded.selections[0] == 7 && captured.selections[0] == 7); + bargain.ReplaceFromKVDecoded(decoded); + Check(Same(decoded, bargain.SnapshotForKVValidation())); + } + } + } + Check(bargain.SelectionsAreIndependentForKVValidation()); + Check(nullSnapshot != null && nullSnapshot.selections == null); + bargain.ReplaceFromKVDecoded(a); + raw["GeneralEventPackData"] = "null"; + Check(BargainPackData.DecodeForKVLoad(raw,snapshot,new PlayerKVSchemaDiagnostics()) == null); + Check(bargain.KVLoadPresent); // Rejected decode cannot mutate the live object. + bargain.ResetForKVLoad(); + Check(!bargain.KVLoadPresent); + } + { + var raw = new Dictionary { ["EventShootingRangeData"] = JsonConvert.SerializeObject(new { EventId = 11, CycleId = 11, RedirectId = 11, AmmoCount = 11, CurrentStageIdx = 11, ChainProgress = 11, RewardFlag = long.MaxValue, TargetFlag = long.MinValue, HasBeenOpened = true, Inflation = 1.5f }) }; + var errors = new PlayerKVSchemaDiagnostics(); + var a = EventShootingRangeData.DecodeForKVLoad(raw, snapshot, errors); + Check(a != null && a.IsPresent && errors.TotalErrors == 0); + shooting.ResetForKVLoad(); shooting.ReplaceFromKVDecoded(a); + var actual = shooting.SnapshotForKVValidation(); + Check(shooting.KVLoadPresent && actual.IsPresent); + Check(actual.EventId == a.EventId); + Check(actual.CycleId == a.CycleId); + Check(actual.RedirectId == a.RedirectId); + Check(actual.AmmoCount == a.AmmoCount); + Check(actual.CurrentStageIdx == a.CurrentStageIdx); + Check(actual.ChainProgress == a.ChainProgress); + Check(actual.RewardFlag == a.RewardFlag); + Check(actual.TargetFlag == a.TargetFlag); + Check(actual.HasBeenOpened == a.HasBeenOpened); + Check(actual.Inflation == a.Inflation); + var missing = EventShootingRangeData.DecodeForKVLoad(new Dictionary(), snapshot, new PlayerKVSchemaDiagnostics()); + shooting.ResetForKVLoad(); shooting.ReplaceFromKVDecoded(missing); + actual = shooting.SnapshotForKVValidation(); + Check(!shooting.KVLoadPresent && !actual.IsPresent); + Check(actual.EventId == 0); + Check(actual.CycleId == 0); + Check(actual.RedirectId == 0); + Check(actual.AmmoCount == 0); + Check(actual.CurrentStageIdx == 0); + Check(actual.ChainProgress == 0); + Check(actual.RewardFlag == 0); + Check(actual.TargetFlag == 0); + Check(actual.HasBeenOpened == false); + Check(actual.Inflation == 0); + shooting.ReplaceFromKVDecoded(a); + actual = shooting.SnapshotForKVValidation(); + Check(shooting.KVLoadPresent && actual.IsPresent); + Check(actual.EventId == a.EventId); + Check(actual.CycleId == a.CycleId); + Check(actual.RedirectId == a.RedirectId); + Check(actual.AmmoCount == a.AmmoCount); + Check(actual.CurrentStageIdx == a.CurrentStageIdx); + Check(actual.ChainProgress == a.ChainProgress); + Check(actual.RewardFlag == a.RewardFlag); + Check(actual.TargetFlag == a.TargetFlag); + Check(actual.HasBeenOpened == a.HasBeenOpened); + Check(actual.Inflation == a.Inflation); + raw["EventShootingRangeData"] = "null"; + Check(EventShootingRangeData.DecodeForKVLoad(raw,snapshot,new PlayerKVSchemaDiagnostics()) == null); + Check(shooting.KVLoadPresent); // Rejected decode cannot mutate the live object. + shooting.ResetForKVLoad(); + Check(!shooting.KVLoadPresent); + } + // Present zero values remain distinguishable from Missing (with required redirect config). + piggy.ReplaceFromKVDecoded(new PlayerKVPiggyBankState(true,0,0,0,0,4055001,false,false,0)); + bargain.ReplaceFromKVDecoded(new PlayerKVBargainState(true,0,bargainId,0,0,0,0,0,0,0,0,0,false,false,0,new int[0])); + shooting.ReplaceFromKVDecoded(new PlayerKVShootingRangeState(true,0,0,0,0,0,0,0,0,false,0)); + Check(piggy.KVLoadPresent && bargain.KVLoadPresent && shooting.KVLoadPresent); + Check(bargain.SelectionsAreIndependentForKVValidation()); + if (includeProgress) + { + var pinataTarget=new EventPinataPackData(); + Check(asap.core.ContainerExtensions.RegisterInstance(isolated,pinataTarget)); + pinata=EventPinataPackDataManager.CreateForKVLoad(); + var pinataState=EventPinataPackDataManager.DecodeForKVLoad(new Dictionary { + ["EventPinataPackData"]="{\"EventID\":1,\"PinataMainID\":2,\"PurchasedCount\":3}" + },new PlayerKVSchemaDiagnostics()); + pinata.ResetForKVLoad(); pinata.ReplaceFromKVDecoded(pinataState); + Check(ReferenceEquals(pinataTarget,asap.core.ContainerExtensions.Resolve(isolated)) && + pinataTarget.EventID==1 && pinataTarget.PinataMainID==2 && pinataTarget.PurchasedCount==3); + pinata.ResetForKVLoad(); pinata.ReplaceFromKVDecoded(EventPinataPackDataManager.DecodeForKVLoad(new Dictionary(),new PlayerKVSchemaDiagnostics())); + Check(ReferenceEquals(pinataTarget,asap.core.ContainerExtensions.Resolve(isolated)) && + pinataTarget.EventID==0 && pinataTarget.PinataMainID==0 && pinataTarget.PurchasedCount==0); + eventPack=new EventPackData(); + var eventSnapshot=new PlayerKVSchemaSnapshot("local-M20",Array.Empty(),Array.Empty(),new Dictionary(), + CultureInfo.InvariantCulture,snapshot.NowUtc,0,packTypes, + tables.TbFishingEvent.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVEventConfig(p.Value.RedirectID,(p.Value.TimeDefinition as cfg.LimitedTime)?.EndTime))); + var pack=EventPackData.DecodeForKVLoad(new Dictionary { ["EventPackData"]="{\"currentEventID\":0,\"purchaseCount\":7,\"VIPLvlWhenEventActivate\":8,\"redirectID\":9}" }, + eventSnapshot,new PlayerKVSchemaDiagnostics()); + eventPack.ResetForKVLoad(); eventPack.ReplaceFromKVDecoded(pack); + var packActual=eventPack.SnapshotForKVValidation(); + Check(packActual.EventId==0 && packActual.PurchaseCount==7 && packActual.VipLevel==8 && packActual.RedirectId==9); + eventPack.ResetForKVLoad(); eventPack.ReplaceFromKVDecoded(EventPackData.DecodeForKVLoad(new Dictionary(),eventSnapshot,new PlayerKVSchemaDiagnostics())); + packActual=eventPack.SnapshotForKVValidation(); + Check(packActual.EventId==0 && packActual.PurchaseCount==0 && packActual.VipLevel==0 && packActual.RedirectId==0); + progress = new ThanksGivingPackData(); migration = new ChainPackWithProgressMigrationData(); + var raw = new Dictionary { ["ThanksGivingPackData"] = "1,2,3,4", ["ThanksGivingPackMigrationData"] = "5,6,7,8" }; + var a = ThanksGivingPackData.DecodeForKVLoad(raw,new PlayerKVSchemaDiagnostics()); + var b = ChainPackWithProgressMigrationData.DecodeForKVLoad(raw,new PlayerKVSchemaDiagnostics()); + progress.ResetForKVLoad(); migration.ResetForKVLoad(); + progress.ReplaceFromKVDecoded(a); migration.ReplaceFromKVDecoded(b); + var actual = progress.SnapshotForKVValidation(); var actualMigration = migration.SnapshotForKVValidation(); + Check(actual.IsPresent && actual.EventId == 1 && actual.RedirectId == 2 && actual.TokenProgress == 3 && actual.ChainProgress == 4); + Check(actualMigration.IsPresent && actualMigration.EventId == 5 && actualMigration.RedirectId == 6 && + actualMigration.TokenProgress == 7 && actualMigration.ChainProgress == 8); + migration.ReplaceFromKVDecoded(a); progress.ChainProgress = 99; + Check(migration.ChainProgress == 4 && a.ChainProgress == 4); + var missing = ThanksGivingPackData.DecodeForKVLoad(new Dictionary(),new PlayerKVSchemaDiagnostics()); + var missingMigration = ChainPackWithProgressMigrationData.DecodeForKVLoad(new Dictionary(),new PlayerKVSchemaDiagnostics()); + progress.ResetForKVLoad(); migration.ResetForKVLoad(); + progress.ReplaceFromKVDecoded(missing); migration.ReplaceFromKVDecoded(missingMigration); + Check(!progress.SnapshotForKVValidation().IsPresent && !migration.SnapshotForKVValidation().IsPresent); + } + success = true; + } + catch (Exception ex) { errorType = ex.GetType().Name; } + finally + { + try { piggy?.ResetForKVLoad(); bargain?.ResetForKVLoad(); shooting?.ResetForKVLoad(); progress?.ResetForKVLoad(); migration?.ResetForKVLoad(); eventPack?.ResetForKVLoad(); pinata?.ResetForKVLoad(); player?.ResetForKVLoad(); small?.ResetForKVLoad(); shop?.ResetForKVLoad(); eventBase?.ResetForKVLoad(); } + catch (Exception ex) { success = false; errorType = ex.GetType().Name; } + try { isolated.Dispose(); } + catch (Exception ex) { success = false; errorType = ex.GetType().Name; } + finally + { + // Synchronous test only: no workers, leases or pending operations can outlive this scope. + ValidationContextAccess.Use(originalContainer); + ValidationContextAccess.Events = originalAggregator; + restored = ReferenceEquals(asap.core.GContext.container,originalContainer) && ReferenceEquals(ValidationContextAccess.Events,originalAggregator); + running = false; + } + } + return Task.FromResult(JsonConvert.SerializeObject(new + { success = success && restored, caseId = includeEventBase ? "M05-M21-M22-M24" : includeShop ? "M04-M21-M22-M24" : includeCore ? "M01-M02-M21-M22-M24" : includeProgress ? "M20-M21-M22-M24-M28-M29" : "M21-M22-M24", assertions, restored, errorType, + scope = includeEventBase ? "M05-D3-only" : includeShop ? "M04-D3-only" : includeCore ? "M01-M02-D3-only" : includeProgress ? "M20-D3-M28-M29-only" : "D3-only", fullB220 = false, formalRequests = 0 })); + } + } +} +#endif diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs.meta new file mode 100644 index 000000000..f23f8de23 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 46103e7278684f2db18a8cb6340a4b76 diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs new file mode 100644 index 000000000..2ce900a10 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs @@ -0,0 +1,202 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace game.PlayerDataStorage +{ + internal enum PlayerKVGameLoadError + { None, BackendNotEnabled, BackendUnsupported, Input, Prepare, Schema, Apply, Stale, SideEffect, GateBusy, PreviousClosePending, PreviousCloseFailed, ClosePending, CloseFailed } + internal enum PlayerKVGameLoadState { Rejected, LoadedForValidation } + // Preserve stage evidence without retaining a successful Prepare payload on a later load failure. + internal sealed class PlayerKVPrepareEvidence + { + internal PlayerKVLoginPrepareError Error { get; } + internal PlayerKVLoginPrepareError OriginalError { get; } + internal game.Account.AccountLoginError? AccountError { get; } + internal PlayerKVGameOpenError? OpenError { get; } + internal PlayerKVSessionOpenError? OwnerError { get; } + internal PlayerKVError? KVError { get; } + internal PlayerKVSyncStatus? SyncStatus { get; } + internal int? HttpStatusCode { get; } + internal string ExceptionType { get; } + internal string CleanupExceptionType { get; } + internal PlayerKVPrepareEvidence(PlayerKVLoginPrepareResult value) + { Error=value.Error; OriginalError=value.OriginalError; AccountError=value.AccountError; OpenError=value.OpenError; + OwnerError=value.OwnerError; KVError=value.KVError; SyncStatus=value.SyncStatus; HttpStatusCode=value.HttpStatusCode; + ExceptionType=value.ExceptionType; CleanupExceptionType=value.CleanupExceptionType; } + } + internal sealed class PlayerKVGameLoadResult + { + internal PlayerKVGameLoadState State => Error==PlayerKVGameLoadError.None ? PlayerKVGameLoadState.LoadedForValidation : PlayerKVGameLoadState.Rejected; + internal PlayerKVGameLoadError Error { get; } + internal PlayerKVGameLoadError OriginalError { get; } + internal bool CleanupFailed => Error==PlayerKVGameLoadError.CloseFailed; + internal PlayerKVSchemaVerdict? SchemaVerdict { get; } + internal PlayerKVPrepareEvidence Prepare { get; } + internal long Epoch { get; } + internal int ModuleCount { get; } + internal int IntentCount { get; } + internal PlayerKVGameLoadResult(PlayerKVGameLoadError error,long epoch,PlayerKVGameLoadError? original=null, + PlayerKVSchemaVerdict? verdict=null,PlayerKVLoginPrepareResult prepare=null,int modules=0,int intents=0) + { Error=error; OriginalError=original ?? error; SchemaVerdict=verdict; Prepare=prepare==null ? null : new PlayerKVPrepareEvidence(prepare); Epoch=epoch; ModuleCount=modules; IntentCount=intents; } + } + internal sealed class PlayerKVGameLoadCoordinator + { + readonly SemaphoreSlim loginGate=new SemaphoreSlim(1,1); + readonly object closeGate=new object(); + readonly PlayerKVGameLoadTransaction transaction; + readonly Func unstarted; + readonly Action notify; + readonly Func intentCount; + readonly Action bind; + readonly Func unbind; + readonly PlayerKVShutdownRegistry registry; + readonly int mainThread; + readonly SynchronizationContext mainContext; + PlayerKVLoginContext context; + PlayerKVGameLoadGuard guard; + PlayerKVGameLoadCapability capability; + Task closing; + CancellationTokenRegistration lifetimeRegistration; + Action clearAfterApply; + long epoch; + bool loaded; + internal bool LoadedForValidation => loaded && context!=null && context.IsCurrent && guard!=null && guard.IsCurrent && guard.Violations==0; + internal int QuitObserveMs => capability?.QuitObserveMs ?? 5000; + internal PlayerKVGameLoadCoordinator(PlayerKVGameLoadTransaction transaction,Func unstarted,Action notify,Func intentCount, + Action bind,Func unbind,PlayerKVShutdownRegistry registry=null) + { + this.transaction=transaction ?? throw new ArgumentNullException(nameof(transaction)); + this.unstarted=unstarted ?? throw new ArgumentNullException(nameof(unstarted)); this.notify=notify ?? throw new ArgumentNullException(nameof(notify)); + this.intentCount=intentCount ?? throw new ArgumentNullException(nameof(intentCount)); this.bind=bind ?? throw new ArgumentNullException(nameof(bind)); + this.unbind=unbind ?? throw new ArgumentNullException(nameof(unbind)); this.registry=registry ?? PlayerKVShutdownRegistry.Process; + mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current; + } + internal async Task LoadAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func identityCurrent=null) + { + if(next==null || Thread.CurrentThread.ManagedThreadId!=mainThread || string.IsNullOrEmpty(id)) + return Result(PlayerKVGameLoadError.Input); + if(!await EnterGate(next,token)) return Result(token.IsCancellationRequested ? PlayerKVGameLoadError.Stale : PlayerKVGameLoadError.GateBusy); + PlayerKVLoginPrepareResult prepared=null; PlayerKVSchemaVerdict? verdict=null; + long attempt=0; + try + { + // An earlier successful validation must also be explicitly revoked before replacing its account. + if(context!=null) _=Revoke(); + var previous=await registry.ObserveAsync(next.LoginObserveMs,token,next.Delay); + if(previous!=PlayerKVShutdownStatus.Ready) return Result(previous==PlayerKVShutdownStatus.Failed ? PlayerKVGameLoadError.PreviousCloseFailed : + previous==PlayerKVShutdownStatus.Cancelled ? PlayerKVGameLoadError.Stale : PlayerKVGameLoadError.PreviousClosePending); + if(token.IsCancellationRequested || !unstarted() || transaction.Poisoned) return Result(PlayerKVGameLoadError.Input); + if(!PlayerKVGameLoadGuard.TryEnter(out var enteredGuard)) return Result(PlayerKVGameLoadError.GateBusy); + lifetimeRegistration.Dispose(); + guard=enteredGuard; capability=next; closing=null; loaded=false; + attempt=Interlocked.Increment(ref epoch); + context=next.CreateContext(id,attempt,e=>Interlocked.Read(ref epoch)==e && !token.IsCancellationRequested && (identityCurrent==null || identityCurrent())); + lifetimeRegistration=token.Register(()=> { _=Revoke(attempt); }); + var work=context.PrepareAsync(token); + var cancelled=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using(token.Register(()=>cancelled.TrySetResult(true))) + { + if(await Task.WhenAny(work,cancelled.Task)!=work) + return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict); + prepared=await work; + } + if(!prepared.Prepared) return await Reject(prepared.CleanupFailed ? PlayerKVGameLoadError.CloseFailed : PlayerKVGameLoadError.Prepare,attempt,next,prepared,verdict); + if(!context.IsCurrent || epoch!=attempt) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict); + var decoded=PlayerKVSaveSchema.Decode(prepared,context.AccountDescriptor,id,attempt,context.IsCurrent,next.Snapshot,next.Snapshot.Version,next.Identity); + verdict=decoded.Verdict; + if(decoded.Data==null) return await Reject(PlayerKVGameLoadError.Schema,attempt,next,prepared,verdict); + if(!context.TryAcquireAccountLease(next.LeaseCapability,out var lease)) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict); + bind(lease); + PlayerKVApplyError applied; + try { applied=transaction.Apply(decoded.Data,()=>context.IsCurrent && epoch==attempt && unstarted(),()=>guard.IsCurrent && guard.Violations==0,notify); } + finally { var pending=clearAfterApply; clearAfterApply=null; pending?.Invoke(); } + if(applied!=PlayerKVApplyError.None) return await Reject(applied==PlayerKVApplyError.Stale ? PlayerKVGameLoadError.Stale : + applied==PlayerKVApplyError.SideEffect ? PlayerKVGameLoadError.SideEffect : PlayerKVGameLoadError.Apply,attempt,next,prepared,verdict); + if(token.IsCancellationRequested || !context.IsCurrent || guard.Violations!=0) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict); + loaded=true; + return new PlayerKVGameLoadResult(PlayerKVGameLoadError.None,attempt,verdict:verdict,modules:31,intents:intentCount()); + } + catch { return await Reject(PlayerKVGameLoadError.Apply,attempt,next,prepared,verdict); } + finally { loginGate.Release(); } + } + async Task EnterGate(PlayerKVGameLoadCapability next,CancellationToken token) + { + using(var cancellation=CancellationTokenSource.CreateLinkedTokenSource(token)) + { + var wait=loginGate.WaitAsync(cancellation.Token); var deadline=next.Delay(next.LoginObserveMs,cancellation.Token); + bool acquired=false,accepted=false; + try + { + if(await Task.WhenAny(wait,deadline)==wait) { await wait; acquired=true; accepted=!token.IsCancellationRequested && !deadline.IsCompleted; return accepted; } + cancellation.Cancel(); + try { await wait; acquired=true; } catch(OperationCanceledException) { } + return false; + } + catch(OperationCanceledException) { return false; } + finally + { + cancellation.Cancel(); + // A lock won at the timeout/cancel boundary belongs to this attempt and must be returned. + if(acquired && !accepted) loginGate.Release(); + } + } + } + internal Task Revoke(long? expectedEpoch=null) + { + lock(closeGate) + { + if(expectedEpoch.HasValue && expectedEpoch.Value!=Interlocked.Read(ref epoch)) return Task.CompletedTask; + if(closing!=null) return closing; + loaded=false; Interlocked.Increment(ref epoch); + if(context==null && guard==null) return Task.CompletedTask; + var completion=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + closing=completion.Task; + var owner=context; var leaseGuard=guard; var source=capability; + // Context cancellation is synchronous and occurs even if the main-thread reset is queued. + var drain=owner?.CloseAsync() ?? Task.CompletedTask; + var cleared=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void Clear() + { + try + { + var detached=unbind(); + if(!transaction.Clear()) throw new InvalidOperationException("Controlled container is poisoned."); + CompleteClear(detached,cleared); + } + catch(Exception ex) { cleared.TrySetException(ex); } + } + if(Thread.CurrentThread.ManagedThreadId==mainThread && transaction.IsApplying) clearAfterApply=Clear; + else if(Thread.CurrentThread.ManagedThreadId==mainThread) Clear(); + else if(mainContext!=null) mainContext.Post(_=>Clear(),null); + else cleared.TrySetException(new InvalidOperationException("Main-thread cleanup unavailable.")); + _=FinishClose(drain,cleared.Task,leaseGuard,source,completion); + registry.Register(drain,owner); + registry.Register(cleared.Task,owner); + registry.Register(closing,owner); + return closing; + } + } + static async void CompleteClear(Task detached,TaskCompletionSource cleared) + { try { await detached; cleared.TrySetResult(true); } catch(Exception ex) { cleared.TrySetException(ex); } } + static async Task FinishClose(Task drain,Task clear,PlayerKVGameLoadGuard leaseGuard,PlayerKVGameLoadCapability source,TaskCompletionSource completion) + { + try + { + await Task.WhenAll(drain,clear).ConfigureAwait(false); source?.CloseUnused(); leaseGuard?.Dispose(); completion.TrySetResult(true); + } + catch(Exception ex) { completion.TrySetException(ex); } + } + async Task Reject(PlayerKVGameLoadError original,long attempt,PlayerKVGameLoadCapability source, + PlayerKVLoginPrepareResult prepare,PlayerKVSchemaVerdict? verdict) + { + var close=Revoke(); + var status=await registry.ObserveAsync(source.LoginObserveMs,CancellationToken.None,source.Delay); + var error=status==PlayerKVShutdownStatus.Failed || close.IsFaulted ? PlayerKVGameLoadError.CloseFailed : + !close.IsCompleted ? PlayerKVGameLoadError.ClosePending : original; + return new PlayerKVGameLoadResult(error,attempt,original:prepare?.CleanupFailed==true ? PlayerKVGameLoadError.Prepare : original,verdict:verdict,prepare:prepare); + } + PlayerKVGameLoadResult Result(PlayerKVGameLoadError error) => new PlayerKVGameLoadResult(error,Interlocked.Read(ref epoch)); + internal Task ObserveQuitAsync() => registry.ObserveAsync(QuitObserveMs,delay:capability?.Delay); + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs.meta new file mode 100644 index 000000000..1082e3a0f --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 097cfee9d4554f3fadd160787f0857af diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadGuard.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadGuard.cs new file mode 100644 index 000000000..a54daa746 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadGuard.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading; + +namespace game.PlayerDataStorage +{ + internal enum PlayerKVGuardPoint { Compare, ReadLocal, Update, Immediate, Sync, Lifecycle, PrefsSave } + internal sealed class PlayerKVLoadSideEffectException : InvalidOperationException + { internal PlayerKVLoadSideEffectException() : base("KV controlled load rejected a legacy side effect.") { } } + internal sealed class PlayerKVGameLoadGuard : IDisposable + { + static readonly object gate=new object(); + static PlayerKVGameLoadGuard owner; + readonly int[] counts=new int[7]; + int violations; + bool disposed; + private PlayerKVGameLoadGuard() { } + internal static bool TryEnter(out PlayerKVGameLoadGuard lease) + { + lock(gate) + { + lease=null; if(owner!=null) return false; + owner=lease=new PlayerKVGameLoadGuard(); return true; + } + } + internal bool IsCurrent { get { lock(gate) return !disposed && ReferenceEquals(owner,this); } } + internal int Violations => Volatile.Read(ref violations); + internal int Count(PlayerKVGuardPoint point) => Volatile.Read(ref counts[(int)point]); + internal static bool Reject(PlayerKVGuardPoint point) + { + lock(gate) + { + if(owner==null) return false; + Interlocked.Increment(ref owner.counts[(int)point]); Interlocked.Increment(ref owner.violations); return true; + } + } + internal static void RequireLegacyAllowed(PlayerKVGuardPoint point) + { if(Reject(point)) throw new PlayerKVLoadSideEffectException(); } + public void Dispose() + { + lock(gate) { if(disposed) return; disposed=true; if(ReferenceEquals(owner,this)) owner=null; } + } + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadGuard.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadGuard.cs.meta new file mode 100644 index 000000000..8d88392e2 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadGuard.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 90067312ead44486b7d1c9694e2a0814 diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadTransaction.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadTransaction.cs new file mode 100644 index 000000000..184eb1a1e --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadTransaction.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace game.PlayerDataStorage +{ + internal interface IPlayerKVLoadModule + { + int Id { get; } + void Reset(); + void Replace(PlayerKVDecodedSave decoded); + } + internal sealed class PlayerKVLoadModule : IPlayerKVLoadModule + { + readonly Action reset; + readonly Action replace; + public int Id { get; } + internal PlayerKVLoadModule(int id,Action reset,Action replace) + { Id=id; this.reset=reset ?? throw new ArgumentNullException(nameof(reset)); this.replace=replace ?? throw new ArgumentNullException(nameof(replace)); } + public void Reset() => reset(); + public void Replace(PlayerKVDecodedSave decoded) => replace(decoded); + } + internal enum PlayerKVApplyError { None, Input, Stale, Apply, SideEffect, Poisoned } + internal sealed class PlayerKVGameLoadTransaction + { + readonly IPlayerKVLoadModule[] modules; + readonly int mainThread; + readonly Action discard; + bool applying; + internal bool IsApplying => applying; + internal bool Poisoned { get; private set; } + internal int AppliedModules { get; private set; } + internal PlayerKVGameLoadTransaction(IEnumerable modules,int mainThread,Action discard) + { + this.modules=modules?.ToArray() ?? throw new ArgumentNullException(nameof(modules)); + if(this.modules.Length!=31 || !this.modules.Select(m=>m?.Id ?? 0).SequenceEqual(Enumerable.Range(1,31))) + throw new ArgumentException("Exactly 31 ordered modules required."); + this.mainThread=mainThread; this.discard=discard ?? throw new ArgumentNullException(nameof(discard)); + } + // Synchronous main-thread region: no callback may yield between Reset and publication. + internal PlayerKVApplyError Apply(PlayerKVDecodedSave decoded,Func current,Func clean,Action notify) + { + if(Poisoned) return PlayerKVApplyError.Poisoned; + if(applying || Thread.CurrentThread.ManagedThreadId!=mainThread || decoded==null || current==null || clean==null || notify==null) + return PlayerKVApplyError.Input; + applying=true; AppliedModules=0; + PlayerKVApplyError error=PlayerKVApplyError.Apply; + try + { + if(!current()) error=PlayerKVApplyError.Stale; + else if(!clean()) error=PlayerKVApplyError.SideEffect; + else if(!ResetAll()) error=PlayerKVApplyError.Poisoned; + else + { + foreach(var module in modules) + { + if(!current()) { error=PlayerKVApplyError.Stale; break; } + if(!clean()) { error=PlayerKVApplyError.SideEffect; break; } + module.Replace(decoded); AppliedModules++; + } + if(AppliedModules==31) + { + // The last module can revoke the epoch or trip a guard; never publish in that gap. + if(!current()) error=PlayerKVApplyError.Stale; + else if(!clean()) error=PlayerKVApplyError.SideEffect; + else + { + notify(); + error=!current() ? PlayerKVApplyError.Stale : !clean() ? PlayerKVApplyError.SideEffect : PlayerKVApplyError.None; + } + } + } + } + catch { error=PlayerKVApplyError.Apply; } + finally { applying=false; } + if(error!=PlayerKVApplyError.None) + { + if(!ResetAll()) error=PlayerKVApplyError.Poisoned; + try { discard(); } catch { Poisoned=true; error=PlayerKVApplyError.Poisoned; } + AppliedModules=0; + } + return error; + } + internal bool Clear() + { + if(applying || Thread.CurrentThread.ManagedThreadId!=mainThread) throw new InvalidOperationException("Main thread required."); + bool cleared=ResetAll(); + try { discard(); } catch { Poisoned=true; cleared=false; } + AppliedModules=0; return cleared; + } + bool ResetAll() + { + bool ok=true; + foreach(var module in modules) try { module.Reset(); } catch { ok=false; } + if(!ok) Poisoned=true; + return ok; + } + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadTransaction.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadTransaction.cs.meta new file mode 100644 index 000000000..dc84ce479 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadTransaction.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c1ea0dd4977649169863e3c917802188 diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameSessionBridge.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameSessionBridge.cs new file mode 100644 index 000000000..6f03586af --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameSessionBridge.cs @@ -0,0 +1,276 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using game.Account; + +namespace game.PlayerDataStorage +{ + public enum PlayerKVGameOpenError { None, OwnerRejected, Cancelled, Stale, CloseFailed } + + public sealed class PlayerKVGameOpenResult + { + public bool Success => Error == PlayerKVGameOpenError.None; + public PlayerKVGameOpenError Error { get; } + public PlayerKVSessionOpenError OwnerError { get; } + public PlayerKVError TransportError { get; } + public PlayerKVGameSessionHandle Handle { get; } + internal PlayerKVGameOpenResult(PlayerKVGameOpenError error, + PlayerKVSessionOpenError ownerError = PlayerKVSessionOpenError.None, + PlayerKVError transportError = PlayerKVError.None, PlayerKVGameSessionHandle handle = null) + { Error = error; OwnerError = ownerError; TransportError = transportError; Handle = handle; } + } + + /// Owns the KV owner, but never disposes the supplied login session. + public sealed class PlayerKVGameSessionBridge + { + // A fixed slot is also the handle's identity. It can never be rebound to another owner. + internal sealed class Slot + { + internal readonly CancellationTokenSource Lifetime; + internal readonly Func ExternalCurrent; + internal readonly long Epoch; + readonly S101AccountSession session; readonly CookieContainer cookies; + internal PlayerKVSessionOwner Owner; + internal Task Creation; + internal Task Closing; + readonly object cancellationGate = new object(); + bool lifetimeDisposed; Exception cancellationFailure; + internal Exception CancellationFailure { get { lock (cancellationGate) return cancellationFailure; } } + int revoked; + internal Slot(S101AccountSession session, long epoch, Func current, CancellationToken token) + { this.session = session; cookies = session?.Cookies; Epoch = epoch; ExternalCurrent = current; Lifetime = CancellationTokenSource.CreateLinkedTokenSource(token); } + internal void DisposeLifetime() + { + lock (cancellationGate) + { + if (lifetimeDisposed) return; + Lifetime.Dispose(); lifetimeDisposed = true; + } + } + internal bool Revoked => Volatile.Read(ref revoked) != 0; + internal void Revoke() => Interlocked.Exchange(ref revoked, 1); + internal bool IsCurrent(long epoch) => + !Revoked && !Lifetime.IsCancellationRequested && session != null && session.IsAuthenticated && + ReferenceEquals(session.Cookies, cookies) && epoch == Epoch && ExternalCurrent(epoch); + internal void Cancel() + { + Revoke(); + // Close can revoke an opening slot while Create is completing its cleanup. + // Serialize CTS disposal with cancellation, without holding the bridge monitor. + lock (cancellationGate) + { + if (lifetimeDisposed) return; + try { Lifetime.Cancel(); } + catch (Exception ex) { cancellationFailure = ex; } + } + } + } + + sealed class OpenRequest + { + internal bool Entered; + internal CancellationTokenRegistration Registration; + internal readonly TaskCompletionSource Result = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + readonly object gate = new object(); + readonly Func handlerFactory; + Task tail = Task.CompletedTask; + Task lastClose; + Slot active; + long closeGeneration; + bool closeFailed; + + public PlayerKVGameSessionBridge() : this(jar => new HttpClientHandler + { CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }) { } + + internal PlayerKVGameSessionBridge(Func handlerFactory) + { this.handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory)); } + + public Task OpenAsync(S101AccountSession session, string cacheRoot, + string playerId, Uri api, long epoch, Func isCurrent, CancellationToken lifetimeToken = default) + { + if (isCurrent == null) + return Task.FromResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.OwnerRejected, + PlayerKVSessionOpenError.Validation)); + var request = new OpenRequest(); + var done = NewCompletion(); + Task predecessor; long generation; + lock (gate) + { + predecessor = tail; tail = done.Task; + generation = closeGeneration; + lastClose = null; + } + request.Registration = lifetimeToken.Register(() => + { + lock (gate) + if (!request.Entered) + request.Result.TrySetResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.Cancelled)); + }); + // The reserved tail stays pending even if the caller's queued wait is cancelled. + _ = OpenCoreAsync(predecessor, done, request, generation, session, cacheRoot, + playerId, api, epoch, isCurrent, lifetimeToken); + return request.Result.Task; + } + + async Task OpenCoreAsync(Task predecessor, TaskCompletionSource done, OpenRequest request, + long generation, S101AccountSession session, string cacheRoot, string playerId, + Uri api, long epoch, Func isCurrent, CancellationToken token) + { + try + { + await predecessor.ConfigureAwait(false); + Slot previous; + lock (gate) + { + request.Entered = true; + if (closeFailed) { Complete(request, PlayerKVGameOpenError.CloseFailed); return; } + if (token.IsCancellationRequested) { Complete(request, PlayerKVGameOpenError.Cancelled); return; } + if (generation != closeGeneration) { Complete(request, PlayerKVGameOpenError.Stale); return; } + previous = active; + previous?.Revoke(); + } + if (previous != null) await CloseSlotAsync(previous).ConfigureAwait(false); + + Slot slot; + lock (gate) + { + if (token.IsCancellationRequested) { Complete(request, PlayerKVGameOpenError.Cancelled); return; } + if (generation != closeGeneration) { Complete(request, PlayerKVGameOpenError.Stale); return; } + slot = new Slot(session, epoch, isCurrent, token); + active = slot; + } + slot.Creation = PlayerKVSessionOwner.CreateAsync(session, cacheRoot, playerId, api, + epoch, slot.IsCurrent, slot.Lifetime.Token, handlerFactory); + var opened = await slot.Creation.ConfigureAwait(false); + if (!opened.Success) + { + // CreateAsync returns failure only after its failed owner has drained. + if (slot.CancellationFailure != null) throw slot.CancellationFailure; + lock (gate) if (ReferenceEquals(active, slot)) active = null; + slot.Revoke(); slot.DisposeLifetime(); + request.Result.TrySetResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.OwnerRejected, + opened.Error, opened.TransportError)); + return; + } + slot.Owner = opened.Owner; + var valid = slot.IsCurrent(epoch); + lock (gate) + { + if (valid && generation == closeGeneration && !slot.Revoked && !token.IsCancellationRequested) + { + request.Result.TrySetResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.None, + handle: new PlayerKVGameSessionHandle(slot))); + return; + } + slot.Revoke(); + } + await CloseSlotAsync(slot).ConfigureAwait(false); + Complete(request, token.IsCancellationRequested ? PlayerKVGameOpenError.Cancelled : PlayerKVGameOpenError.Stale); + } + catch (Exception) + { + // Includes failed Create cleanup. Retain the slot, Creation/Closing task and owner; + // no later Open is allowed to pretend the cache is reusable. + lock (gate) { closeFailed = true; active?.Revoke(); } + Complete(request, PlayerKVGameOpenError.CloseFailed); + } + finally + { + request.Registration.Dispose(); + done.TrySetResult(true); + } + } + + /// Immediately revokes admission, then drains every preceding lifecycle operation. + public Task CloseAsync() + { + Task predecessor; Slot observed; + var done = NewCompletion(); + var result = NewCompletion(); + lock (gate) + { + if (lastClose != null) return lastClose; + closeGeneration++; + observed = active; observed?.Revoke(); + predecessor = tail; tail = done.Task; lastClose = result.Task; + } + // Cancellation callbacks must never run while the bridge monitor is held. + observed?.Cancel(); + _ = CloseCoreAsync(predecessor, done, result); + return result.Task; + } + + async Task CloseCoreAsync(Task predecessor, TaskCompletionSource done, TaskCompletionSource result) + { + try + { + await predecessor.ConfigureAwait(false); + Slot slot; + lock (gate) + { + if (closeFailed) throw new InvalidOperationException("KV owner cleanup previously failed."); + slot = active; + } + if (slot != null) await CloseSlotAsync(slot).ConfigureAwait(false); + result.TrySetResult(true); + } + catch (Exception ex) + { + lock (gate) { closeFailed = true; active?.Revoke(); } + result.TrySetException(ex); + } + finally { done.TrySetResult(true); } + } + + async Task CloseSlotAsync(Slot slot) + { + slot.Cancel(); + if (slot.Owner != null) + { + slot.Closing = slot.Closing ?? slot.Owner.CloseAsync(); + await slot.Closing.ConfigureAwait(false); + } + if (slot.CancellationFailure != null) throw slot.CancellationFailure; + lock (gate) if (ReferenceEquals(active, slot)) active = null; + slot.DisposeLifetime(); + } + + static TaskCompletionSource NewCompletion() => + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + static void Complete(OpenRequest request, PlayerKVGameOpenError error) => + request.Result.TrySetResult(new PlayerKVGameOpenResult(error)); + } + + /// Captured by delayed callbacks; all operations target this original owner only. + public sealed class PlayerKVGameSessionHandle + { + readonly PlayerKVGameSessionBridge.Slot slot; + internal PlayerKVGameSessionHandle(PlayerKVGameSessionBridge.Slot slot) { this.slot = slot; } + PlayerKVSessionOwner Owner => slot.Owner; + public string OwnerInstanceId => Owner.OwnerInstanceId; + public string PlayerId => Owner.PlayerId; + public long Epoch => Owner.Epoch; + public PlayerKVSyncStatus Status => Owner.Status; + + public PlayerKVLocalReadResult ReadLocalSnapshot() => slot.Revoked + ? new PlayerKVLocalReadResult(PlayerKVLocalReadError.StaleOrClosing, OwnerInstanceId, PlayerId, Epoch) + : Owner.ReadLocalSnapshot(); + + public PlayerKVEditResult Update(string key, string value, bool remoteStore = true) => + slot.Revoked ? BlockedEdit() : Owner.Update(key, value, remoteStore); + public PlayerKVEditResult Delete(string key) => slot.Revoked ? BlockedEdit() : Owner.Delete(key); + public Task FlushAsync(CancellationToken waitToken = default) => slot.Revoked + ? Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, Owner.Status)) + : Owner.FlushAsync(waitToken); + public Task WaitForTicketAsync(PlayerKVSyncTicket ticket, CancellationToken token = default) => + !slot.IsCurrent(Epoch) ? Task.FromResult(new PlayerKVTicketResult( + PlayerKVTicketStatus.Blocked, PlayerKVError.Cancelled, Owner.Status)) : Owner.WaitForTicketAsync(ticket, token); + static PlayerKVEditResult BlockedEdit() => + new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Blocked)); + } +} \ No newline at end of file diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameSessionBridge.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameSessionBridge.cs.meta new file mode 100644 index 000000000..9bf85ff46 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameSessionBridge.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ebfda20b77ad4dc1ac25f16acffb9995 diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameplayBindings.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameplayBindings.cs new file mode 100644 index 000000000..71480556f --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameplayBindings.cs @@ -0,0 +1,60 @@ +#if UNITY_EDITOR && !SERVICE_HOST +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using asap.core; + +namespace game.PlayerDataStorage +{ + // Bind the existing game container. Missing registrations fail closed; no parallel fixture objects. + internal static class PlayerKVGameplayBindings + { + static T Require() where T:class => GContext.container.Resolve() ?? + throw new InvalidOperationException("KV game module is not registered: "+typeof(T).FullName); + internal static PlayerKVSchemaSnapshot Snapshot(DateTime nowUtc) + { + var tables=Require(); + return new PlayerKVSchemaSnapshot("kv-loop",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys, + Enum.GetNames(typeof(cfg.ConditionType)).ToDictionary(n=>n,n=>(int)Enum.Parse(typeof(cfg.ConditionType),n)),CultureInfo.InvariantCulture, + nowUtc,tables.TbGlobalConfig.InitWheelTicket, + tables.TbEventPackManager.DataMap.ToDictionary(p=>p.Key,p=>p.Value.PackType), + tables.TbFishingEvent.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVEventConfig(p.Value.RedirectID,(p.Value.TimeDefinition as cfg.LimitedTime)?.EndTime)),tables.TbGlobalConfig.GlobalTimeOffset, + tables.TbFishData.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishConfig(p.Key,p.Value.AreaID,p.Value.Quality,p.Value.FishCardID,p.Value.BaseWeight,p.Value.PointExtraParam,p.Value.WeightRandom)), + tables.TbFishCard.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishCardConfig(p.Key,p.Value.MaxLevel,p.Value.CardsRequiredList,p.Value.WeightMagList))); + } + internal static PlayerKVGameLoadTransaction Bind(PlayFabUserService user) + { + var player=Require(); var small=Require(); var fish=Require(); + var shop=Require(); var eventBase=Require(); var buffs=Require(); var camp=Require(); + var achievements=Require(); var box=Require(); var guide=Require(); var battle=Require(); + var sign=Require(); var trigger=Require(); var club=Require(); var challenge=Require(); + var chains=Require(); var disco=Require(); var partner=Require(); var cube=Require(); + var eventPack=Require(); var piggy=Require(); var bargain=Require(); var foot=Require() as FootPrintService ?? throw new InvalidOperationException("M23 FootPrint binding mismatch."); var shooting=Require(); + var potion=Require(); var pinball=Require(); var scratch=Require(); + var progress=eventBase.ThanksGivingPackData; var migration=eventBase.ChainPackWithProgressMigrationData; + var pinata=Require(); var digging=Require(); + var modules=new List(); + void Add(int id,Action reset,Action replace) where T:class => modules.Add(new PlayerKVLoadModule(id,reset,d=>replace(d.Module(id)))); + Add(1,player.ResetForKVLoad,player.ReplaceFromKVDecoded); Add(2,small.ResetForKVLoad,small.ReplaceFromKVDecoded); + Add(3,fish.ResetForKVLoad,fish.ReplaceFromKVDecoded); Add(4,shop.ResetForKVLoad,shop.ReplaceFromKVDecoded); + Add(5,eventBase.ResetForKVLoad,eventBase.ReplaceFromKVDecoded); Add(6,buffs.ResetForKVLoad,buffs.ReplaceFromKVDecoded); + Add(7,camp.ResetForKVLoad,camp.ReplaceFromKVDecoded); Add(8,achievements.ResetForKVLoad,achievements.ReplaceFromKVDecoded); + Add(9,box.ResetForKVLoad,box.ReplaceFromKVDecoded); Add(10,guide.ResetForKVLoad,guide.ReplaceFromKVDecoded); + Add(11,battle.ResetForKVLoad,battle.ReplaceFromKVDecoded); Add(12,sign.ResetForKVLoad,sign.ReplaceFromKVDecoded); + Add(13,trigger.ResetForKVLoad,trigger.ReplaceFromKVDecoded); Add(14,club.ResetForKVLoad,club.ReplaceFromKVDecoded); + Add(15,challenge.ResetForKVLoad,challenge.ReplaceFromKVDecoded); Add(16,chains.ResetForKVLoad,chains.ReplaceFromKVDecoded); + Add(17,disco.ResetForKVLoad,disco.ReplaceFromKVDecoded); Add(18,partner.ResetForKVLoad,partner.ReplaceFromKVDecoded); + Add(19,cube.ResetForKVLoad,cube.ReplaceFromKVDecoded); Add(20,eventPack.ResetForKVLoad,eventPack.ReplaceFromKVDecoded); + Add(21,piggy.ResetForKVLoad,piggy.ReplaceFromKVDecoded); Add(22,bargain.ResetForKVLoad,bargain.ReplaceFromKVDecoded); + Add(23,foot.ResetForKVLoad,foot.ReplaceFromKVDecoded); Add(24,shooting.ResetForKVLoad,shooting.ReplaceFromKVDecoded); + Add(25,potion.ResetForKVLoad,potion.ReplaceFromKVDecoded); Add(26,pinball.ResetForKVLoad,pinball.ReplaceFromKVDecoded); + Add(27,scratch.ResetForKVLoad,scratch.ReplaceFromKVDecoded); + Add(28,()=>{progress.ResetForKVLoad();migration.ResetForKVLoad();},p=>{progress.ReplaceFromKVDecoded(p.Current);migration.ReplaceFromKVDecoded(p.Migration);}); + Add(29,pinata.ResetForKVLoad,pinata.ReplaceFromKVDecoded); Add(30,digging.ResetForKVLoad,digging.ReplaceFromKVDecoded); + modules.Add(new PlayerKVLoadModule(31,user.ResetForKVLoad,d=>user.ReplaceFromKVDecoded(d.Module(31),d.Epoch,d.Raw))); + return new PlayerKVGameLoadTransaction(modules,System.Threading.Thread.CurrentThread.ManagedThreadId,()=>user.KVIntents?.Discard()); + } + } +} +#endif diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameplayBindings.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameplayBindings.cs.meta new file mode 100644 index 000000000..968890e5c --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameplayBindings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7803da4435ea45c28007ff0198c676fb diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVInitializationIntents.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVInitializationIntents.cs new file mode 100644 index 000000000..107d7763f --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVInitializationIntents.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace game.PlayerDataStorage +{ + // Only two approved in-memory normalizations. Never a persistence receipt or retry queue. + internal sealed class PlayerKVInitializationIntents + { + readonly Dictionary values=new Dictionary(StringComparer.Ordinal); + readonly string channel; + internal long Epoch { get; } + internal bool Discarded { get; private set; } + internal int DiscardedCount { get; private set; } + internal int Count => values.Count; + internal IReadOnlyDictionary Snapshot => new ReadOnlyDictionary(new Dictionary(values)); + internal PlayerKVInitializationIntents(long epoch,PlayerKVIdentityChannel channel) + { + if(!Enum.IsDefined(typeof(PlayerKVIdentityChannel),channel)) throw new ArgumentException("Unknown intent channel."); + Epoch=epoch; this.channel=channel==PlayerKVIdentityChannel.hwGuest || channel==PlayerKVIdentityChannel.none ? null : channel.ToString(); + } + internal void Add(long epoch,string key,string value) + { + if(Discarded || epoch!=Epoch || key==null || (key!="MMTest" && key!=channel) || value==null) + throw new InvalidOperationException("Initialization intent rejected."); + values[key]=value; + } + internal void Discard() { if(Discarded) return; DiscardedCount=values.Count; values.Clear(); Discarded=true; } + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVInitializationIntents.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVInitializationIntents.cs.meta new file mode 100644 index 000000000..526b5a9a1 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVInitializationIntents.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6215961788794950aff0709dbb499793 diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVKeyReadResult.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVKeyReadResult.cs new file mode 100644 index 000000000..c62e35741 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVKeyReadResult.cs @@ -0,0 +1,35 @@ +using System; + +namespace game.PlayerDataStorage +{ + internal enum PlayerKVKeyReadError { None, InvalidInput, LoadGuardActive, StaleOrClosing, NotReady } + + /// + /// A detached key observation, not authority for a later gameplay mutation. + /// Found=null means failure; Found=false means a valid snapshot lacked the key; + /// Found=true means a value exists, including the empty string. + /// + internal sealed class PlayerKVKeyReadResult + { + internal PlayerKVKeyReadError Error { get; } + internal bool Success => Error == PlayerKVKeyReadError.None; + internal bool? Found { get; } + internal string Value { get; } + internal string OwnerInstanceId { get; } + internal string PlayerId { get; } + internal long? Epoch { get; } + + internal PlayerKVKeyReadResult(PlayerKVKeyReadError error, PlayerKVGameSessionHandle handle, + bool? found = null, string value = null) + { + if (error == PlayerKVKeyReadError.None && (!found.HasValue || (found.Value && value == null))) + throw new ArgumentException("A successful key observation requires an existence verdict."); + Error = error; + OwnerInstanceId = handle?.OwnerInstanceId; + PlayerId = handle?.PlayerId; + Epoch = handle?.Epoch; + Found = Success ? found : null; + Value = Success && found == true ? value : null; + } + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVKeyReadResult.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVKeyReadResult.cs.meta new file mode 100644 index 000000000..0347d1116 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVKeyReadResult.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 916120c58a7f4f71bf59e17a4a045bb8 diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVLocalReadResult.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLocalReadResult.cs new file mode 100644 index 000000000..c3e797965 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLocalReadResult.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace game.PlayerDataStorage +{ + public enum PlayerKVLocalReadError { None, StaleOrClosing, NotReady } + + /// A detached snapshot. Failed reads have no data, never a pretend empty save. + public sealed class PlayerKVLocalReadResult + { + public bool Success => Error == PlayerKVLocalReadError.None; + public PlayerKVLocalReadError Error { get; } + public string OwnerInstanceId { get; } + public string PlayerId { get; } + public long Epoch { get; } + public IReadOnlyDictionary Data { get; } + + internal PlayerKVLocalReadResult(PlayerKVLocalReadError error, string ownerId, + string playerId, long epoch, IReadOnlyDictionary data = null) + { + Error = error; OwnerInstanceId = ownerId; PlayerId = playerId; Epoch = epoch; + if (error != PlayerKVLocalReadError.None) return; + if (data == null) throw new ArgumentNullException(nameof(data)); + var copy = new Dictionary(StringComparer.Ordinal); + foreach (var item in data) copy.Add(item.Key, item.Value); + Data = new ReadOnlyDictionary(copy); + } + } +} \ No newline at end of file diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVLocalReadResult.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLocalReadResult.cs.meta new file mode 100644 index 000000000..fecb1b105 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLocalReadResult.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4bbcb1b86b2b4301af3d7518408edd6f diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs new file mode 100644 index 000000000..a1bb78c83 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs @@ -0,0 +1,472 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using game.Account; + +namespace game.PlayerDataStorage +{ + /// + /// Owns exactly one authenticated session and its KV bridge. Does not initialize gameplay or write. + /// The caller must await the previous context's CloseAsync before creating the next context. + /// A failed Close must never be bypassed by constructing another instance. + /// + public sealed class PlayerKVLoginContext + { + readonly object gate = new object(); + readonly string requestedId, cacheRoot; + readonly long epoch; + readonly Uri api; + readonly Func externalCurrent; + readonly Func sessionFactory; + readonly Func bridgeFactory; + readonly CancellationTokenSource lifetime = new CancellationTokenSource(); + readonly TaskCompletionSource workSettled = NewSignal(); + TaskCompletionSource preparation; + TaskCompletionSource closing; + CancellationToken firstToken; + CancellationTokenRegistration registration; + S101AccountSession session; + CookieContainer cookies; + PlayerKVGameSessionBridge bridge; + PlayerKVGameSessionHandle handle; + PlayerKVAccountDescriptor accountDescriptor; + PlayerKVAccountLease accountLease; + bool leaseIssued; + string playerId, predicateExceptionType; + int revoked, prepared, differentTokenCount; + + // Internal factories are only for synthetic transport/cleanup faults. No public session or handle escapes. + public PlayerKVLoginContext(string playerId, string cacheRoot, long epoch, Func isCurrent) + : this(playerId, cacheRoot, epoch, isCurrent, new Uri(S101AccountSession.DefaultApiUrl), + () => new S101AccountSession(), () => new PlayerKVGameSessionBridge()) { } + + internal PlayerKVLoginContext(string playerId, string cacheRoot, long epoch, Func isCurrent, + Uri api, Func sessionFactory, Func bridgeFactory) + { + requestedId = playerId; this.cacheRoot = cacheRoot; this.epoch = epoch; externalCurrent = isCurrent; + this.api = api; this.sessionFactory = sessionFactory; this.bridgeFactory = bridgeFactory; + } + + internal int DifferentTokenCount => Volatile.Read(ref differentTokenCount); + + internal PlayerKVAccountDescriptor AccountDescriptor => + Volatile.Read(ref prepared) != 0 && IsCurrent ? accountDescriptor : null; + + // All issuance and Close admission share gate. The editor factory may not block on Close. + internal bool TryAcquireAccountLease(PlayerKVControlledLoadCapability capability, + out PlayerKVAccountLease lease) + { + lease = null; + if (capability == null || !IsCurrent) return false; + lock (gate) + { + if (closing != null || revoked != 0 || prepared == 0 || leaseIssued) return false; + leaseIssued = true; + accountLease = new PlayerKVAccountLease(api, epoch, cookies, capability, () => IsCurrent, + () => Volatile.Read(ref revoked) == 0); + // A synthetic factory can synchronously revoke this context while being invoked. + if (revoked != 0 || closing != null) return false; + lease = accountLease; + return true; + } + } + + /// Historical Prepared results and their data survive Close, but their authority does not. + public bool IsCurrent + { + get + { + if (Volatile.Read(ref prepared) == 0 || !EpochCurrent(epoch)) return false; + var observed = session; + return observed != null && observed.IsAuthenticated && observed.PlayerId == playerId && + ReferenceEquals(observed.Cookies, cookies) && Volatile.Read(ref revoked) == 0; + } + } + + /// + /// One task per instance. Only the FIRST token owns the lifetime; a different token on later calls + /// is diagnosed but never replaces it. Even after Close this returns the original historical task. + /// Cancellation/failure does not complete until transport and Close have really drained. A transport + /// ignoring cancellation can hold this task indefinitely; observe CloseAsync, never bypass its barrier. + /// + public Task PrepareAsync(CancellationToken lifetimeToken = default) + { + Task result; + bool start, alreadyClosing; + lock (gate) + { + start = preparation == null; + if (start) + { + preparation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + firstToken = lifetimeToken; + } + else if (firstToken != lifetimeToken) Interlocked.Increment(ref differentTokenCount); + result = preparation.Task; + alreadyClosing = closing != null; + } + if (!start) + { + if (firstToken != lifetimeToken) Debug.WriteLine("KV PrepareAsync ignored a different lifetime token; the first token still owns this context."); + return result; + } + if (alreadyClosing) _ = CompleteFailureAsync(new Attempt(), PlayerKVLoginPrepareError.Cancelled); + else _ = PrepareCoreAsync(); + return result; + } + + sealed class Attempt + { + internal AccountLoginError? AccountError; + internal int? HttpStatus; + internal PlayerKVGameOpenError? OpenError; + internal PlayerKVSessionOpenError? OwnerError; + internal PlayerKVError? KVError; + internal PlayerKVSyncStatus? SyncStatus; + internal string OwnerId, ExceptionType; + } + + async Task PrepareCoreAsync() + { + var evidence = new Attempt(); + var error = PlayerKVLoginPrepareError.Unexpected; + try + { + registration = firstToken.Register(() => { _ = CloseAsync(); }); + if (!Validate()) error = PlayerKVLoginPrepareError.Validation; + else if (!EpochCurrent(epoch)) error = InactiveError(); + else + { + session = sessionFactory() ?? throw new InvalidOperationException("Session factory returned null."); + if (session.ApiBaseUri != api) error = PlayerKVLoginPrepareError.Validation; + else + { + var auth = await session.LoginAsync(playerId, lifetime.Token).ConfigureAwait(false); + evidence.AccountError = auth.Error; evidence.HttpStatus = auth.StatusCode; + if (!EpochCurrent(epoch)) error = InactiveError(); + else if (!auth.Success) error = PlayerKVLoginPrepareError.AuthenticationFailed; + else + { + cookies = session.Cookies; + bridge = bridgeFactory() ?? throw new InvalidOperationException("Bridge factory returned null."); + var opened = await bridge.OpenAsync(session, cacheRoot, playerId, api, epoch, + EpochCurrent, lifetime.Token).ConfigureAwait(false); + evidence.OpenError = opened.Error; + if (opened.Error == PlayerKVGameOpenError.OwnerRejected) + { evidence.OwnerError = opened.OwnerError; evidence.KVError = opened.TransportError; } + if (!EpochCurrent(epoch)) error = InactiveError(); + else if (!opened.Success) error = PlayerKVLoginPrepareError.OwnerRejected; + else + { + handle = opened.Handle; + evidence.OwnerId = handle.OwnerInstanceId; + var read = handle.ReadLocalSnapshot(); + evidence.SyncStatus = handle.Status; + if (!EpochCurrent(epoch)) error = InactiveError(); + else if (!read.Success) error = read.Error == PlayerKVLocalReadError.StaleOrClosing + ? PlayerKVLoginPrepareError.Stale : PlayerKVLoginPrepareError.NotReady; + else + { + var success = Result(evidence, PlayerKVLoginPrepareError.None, read.Data); + // External callback is never invoked under our monitor. Close/reentry is rechecked at publication. + if (!EpochCurrent(epoch)) error = InactiveError(); + else lock (gate) + { + if (revoked == 0) + { + accountDescriptor = new PlayerKVAccountDescriptor(session.PlayerId, + epoch, session.InternalId, session.NewlyCreated, + session.CreateTimeUtc, session.ServerTimeUtc); + Volatile.Write(ref prepared, 1); + preparation.TrySetResult(success); + return; + } + error = PlayerKVLoginPrepareError.Cancelled; + } + } + } + } + } + } + } + catch (Exception ex) + { + evidence.ExceptionType = ex.GetType().Name; + error = Volatile.Read(ref revoked) != 0 ? PlayerKVLoginPrepareError.Cancelled : PlayerKVLoginPrepareError.Unexpected; + } + finally { workSettled.TrySetResult(true); } + evidence.ExceptionType = evidence.ExceptionType ?? Volatile.Read(ref predicateExceptionType); + await CompleteFailureAsync(evidence, error).ConfigureAwait(false); + } + + bool Validate() + { + if (epoch < 0 || externalCurrent == null || sessionFactory == null || bridgeFactory == null || + string.IsNullOrWhiteSpace(cacheRoot) || !Path.IsPathFullyQualified(cacheRoot) || + !long.TryParse(requestedId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0 || + api == null || !api.IsAbsoluteUri || (api.Scheme != "http" && api.Scheme != "https") || + api.UserInfo.Length != 0 || api.Query.Length != 0 || api.Fragment.Length != 0) return false; + try { Path.GetFullPath(cacheRoot); } + catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException) { return false; } + playerId = id.ToString(CultureInfo.InvariantCulture); + return true; + } + + bool EpochCurrent(long observedEpoch) + { + if (Volatile.Read(ref revoked) != 0 || observedEpoch != epoch) return false; + try { return externalCurrent != null && externalCurrent(epoch) && Volatile.Read(ref revoked) == 0; } + catch (Exception ex) + { + Interlocked.CompareExchange(ref predicateExceptionType, ex.GetType().Name, null); + _ = CloseAsync(); + return false; + } + } + + PlayerKVLoginPrepareError InactiveError() => Volatile.Read(ref predicateExceptionType) != null + ? PlayerKVLoginPrepareError.Unexpected : Volatile.Read(ref revoked) != 0 + ? PlayerKVLoginPrepareError.Cancelled : PlayerKVLoginPrepareError.Stale; + + PlayerKVLoginPrepareResult Result(Attempt e, PlayerKVLoginPrepareError error, + System.Collections.Generic.IReadOnlyDictionary data = null, + string cleanupException = null, PlayerKVLoginPrepareError? original = null) => + new PlayerKVLoginPrepareResult(error, playerId, epoch, e.AccountError, e.HttpStatus, + e.OpenError, e.OwnerError, e.KVError, e.SyncStatus, e.OwnerId, data, + e.ExceptionType, cleanupException, original); + + async Task CompleteFailureAsync(Attempt evidence, PlayerKVLoginPrepareError error) + { + try + { + await CloseAsync().ConfigureAwait(false); + preparation.TrySetResult(Result(evidence, error)); + } + catch (Exception ex) + { + preparation.TrySetResult(Result(evidence, PlayerKVLoginPrepareError.CloseFailed, + cleanupException: ex.GetType().Name, original: error)); + } + } + + /// Synchronously revokes admission; drains preparation and bridge before disposing the session. + public Task CloseAsync() + { + TaskCompletionSource completion; + lock (gate) + { + if (closing != null) return closing.Task; + Volatile.Write(ref revoked, 1); + completion = closing = NewSignal(); + if (preparation == null) workSettled.TrySetResult(true); + } + // A cancellation registration must not execute disposal of itself or user cancellation callbacks inline. + // Observe faults without changing the shared task, even when cancellation initiates a background Close. + _ = completion.Task.ContinueWith(t => { _ = t.Exception; }, CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + _ = Task.Run(() => CloseCoreAsync(completion)); + return completion.Task; + } + + async Task CloseCoreAsync(TaskCompletionSource completion) + { + try + { + Exception cancellationFailure = null; + try { lifetime.Cancel(); } + catch (Exception ex) { cancellationFailure = ex; } + await workSettled.Task.ConfigureAwait(false); + PlayerKVAccountLease borrowed; + lock (gate) borrowed = accountLease; + if (borrowed != null) await borrowed.CloseAsync().ConfigureAwait(false); + if (bridge != null) await bridge.CloseAsync().ConfigureAwait(false); + // Never dispose the session on a failed bridge close. References and the faulted task stay retained. + session?.Dispose(); + registration.Dispose(); + lifetime.Dispose(); + if (cancellationFailure != null) throw cancellationFailure; + completion.TrySetResult(true); + } + catch (Exception ex) { completion.TrySetException(ex); } + } + + static TaskCompletionSource NewSignal() => + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + // Kept with the context so its existing explicitly linked regression host retains all dependencies. + internal sealed class PlayerKVAccountDescriptor + { + internal string PlayerId { get; } + internal long Epoch { get; } + internal uint InternalId { get; } + internal bool NewlyCreated { get; } + internal DateTime CreateTimeUtc { get; } + internal DateTime ServerTimeUtc { get; } + internal PlayerKVAccountDescriptor(string playerId, long epoch, uint internalId, + bool newlyCreated, DateTime createTimeUtc, DateTime serverTimeUtc) + { + PlayerId = playerId; Epoch = epoch; InternalId = internalId; NewlyCreated = newlyCreated; + CreateTimeUtc = createTimeUtc; ServerTimeUtc = serverTimeUtc; + } + } + + /// Construction is absent from player builds; no default network handler is supplied. + internal sealed class PlayerKVControlledLoadCapability + { + readonly Func leaseHandler; + private PlayerKVControlledLoadCapability(Func leaseHandler) + { this.leaseHandler = leaseHandler ?? throw new ArgumentNullException(nameof(leaseHandler)); } + +#if UNITY_EDITOR + internal static PlayerKVControlledLoadCapability CreateForValidation( + Func leaseHandler) => new PlayerKVControlledLoadCapability(leaseHandler); +#endif + + internal HttpMessageHandler CreateHandler(CookieContainer cookies) + { + var handler = leaseHandler(cookies) ?? throw new InvalidOperationException("Synthetic handler required."); + var seen = new HashSet(); + var node = handler; + while (node != null) + { + // Disposing a cyclic DelegatingHandler graph recursively would overflow the stack. + // It never becomes a lease; the rejecting factory retains responsibility for that graph. + if (!seen.Add(node)) + throw new InvalidOperationException("Cyclic transport is not a validation handler."); + if (node is HttpClientHandler || + node.GetType().FullName == "System.Net.Http.SocketsHttpHandler") + { + handler.Dispose(); + throw new InvalidOperationException("Real or cyclic transport is not a validation handler."); + } + node = (node as DelegatingHandler)?.InnerHandler; + } + return handler; + } + } + + internal sealed class PlayerKVAccountResponse + { + internal bool IsCurrent { get; } + internal int? StatusCode { get; } + internal string Body { get; } + internal PlayerKVAccountResponse(bool isCurrent, int? statusCode = null, string body = null) + { IsCurrent = isCurrent; StatusCode = isCurrent ? statusCode : null; Body = isCurrent ? body : null; } + } + + /// A single context-owned, synthetic-only borrower. Close drains before disposing its client. + internal sealed class PlayerKVAccountLease + { + readonly object gate = new object(); + readonly HttpClient client; + readonly Func current; + readonly Func admission; + readonly CancellationTokenSource lifetime = new CancellationTokenSource(); + TaskCompletionSource drained; + TaskCompletionSource closing; + int active; + internal Uri ApiBaseUri { get; } + internal long Epoch { get; } + internal bool IsCurrent + { + get + { + lock (gate) if (closing != null) return false; + return current(); + } + } + + internal PlayerKVAccountLease(Uri api, long epoch, CookieContainer cookies, + PlayerKVControlledLoadCapability capability, Func current, Func admission) + { + if (capability == null) throw new ArgumentNullException(nameof(capability)); + ApiBaseUri = api; Epoch = epoch; this.current = current; this.admission = admission; + client = new HttpClient(capability.CreateHandler(cookies)) { BaseAddress = api }; + } + + internal async Task SendAsync(HttpMethod method, string route, + string body = null, string contentType = "application/json", CancellationToken token = default) + { + // Exact canonical relative paths: no query, fragment, encoding, traversal or absolute URL. + if (route != "__kv_validation/ok" && route != "__kv_validation/unauthorized") + throw new ArgumentException("Only canonical validation routes are allowed.", nameof(route)); + if (method != HttpMethod.Get && method != HttpMethod.Post) + throw new ArgumentException("Unsupported validation method.", nameof(method)); + if (!IsCurrent) return new PlayerKVAccountResponse(false); + lock (gate) + { + if (closing != null || !admission()) return new PlayerKVAccountResponse(false); + active++; + } + try + { + using (var linked = CancellationTokenSource.CreateLinkedTokenSource(token, lifetime.Token)) + using (var request = new HttpRequestMessage(method, route)) + { + if (body != null) request.Content = new StringContent(body, System.Text.Encoding.UTF8, contentType); + using (var response = await client.SendAsync(request, linked.Token).ConfigureAwait(false)) + { + if (!IsCurrent || linked.IsCancellationRequested) return new PlayerKVAccountResponse(false); + var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + // No redirect following, even if a synthetic handler returns a Location header. + if ((int)response.StatusCode >= 300 && (int)response.StatusCode < 400) + throw new InvalidOperationException("Redirects are not accepted."); + return IsCurrent && !linked.IsCancellationRequested + ? new PlayerKVAccountResponse(true, (int)response.StatusCode, text) + : new PlayerKVAccountResponse(false); + } + } + } + catch (OperationCanceledException) when (!IsCurrent || token.IsCancellationRequested || lifetime.IsCancellationRequested) + { return new PlayerKVAccountResponse(false); } + finally + { + lock (gate) + { + active--; + if (active == 0) drained?.TrySetResult(true); + } + } + } + + internal Task CloseAsync() + { + TaskCompletionSource completion; + lock (gate) + { + if (closing != null) return closing.Task; + completion = closing = Signal(); + drained = Signal(); + if (active == 0) drained.TrySetResult(true); + } + _ = completion.Task.ContinueWith(t => { _ = t.Exception; }, CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + _ = Task.Run(async () => + { + try + { + Exception cancellationFailure = null; + try { lifetime.Cancel(); } + catch (Exception ex) { cancellationFailure = ex; } + await drained.Task.ConfigureAwait(false); + client.Dispose(); + lifetime.Dispose(); + if (cancellationFailure != null) throw cancellationFailure; + completion.TrySetResult(true); + } + catch (Exception ex) { completion.TrySetException(ex); } + }); + return completion.Task; + } + + static TaskCompletionSource Signal() => + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs.meta new file mode 100644 index 000000000..2a169a038 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1cc96c3a86184fd9b723896a55c6a929 diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginPrepareResult.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginPrepareResult.cs new file mode 100644 index 000000000..63d668fa4 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginPrepareResult.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using game.Account; + +namespace game.PlayerDataStorage +{ + public enum PlayerKVLoginPrepareError + { + None, Validation, Cancelled, Stale, AuthenticationFailed, OwnerRejected, NotReady, Unexpected, CloseFailed + } + + /// + /// Historical outcome of one preparation, not permission to initialize gameplay or create a save. + /// Check the context's IsCurrent before using a prepared snapshot. Failure/cancellation waits for + /// actual transport and Close completion; a transport ignoring cancellation may hold it indefinitely. + /// + public sealed class PlayerKVLoginPrepareResult + { + public bool Prepared => Error == PlayerKVLoginPrepareError.None; + public PlayerKVLoginPrepareError Error { get; } + public PlayerKVLoginPrepareError OriginalError { get; } + public bool CleanupFailed => Error == PlayerKVLoginPrepareError.CloseFailed; + public AccountLoginError? AccountError { get; } + public PlayerKVGameOpenError? OpenError { get; } + public PlayerKVSessionOpenError? OwnerError { get; } + public PlayerKVError? KVError { get; } + public PlayerKVSyncStatus? SyncStatus { get; } + public int? HttpStatusCode { get; } + public string PlayerId { get; } + public long Epoch { get; } + public string OwnerInstanceId { get; } + public string ExceptionType { get; } + public string CleanupExceptionType { get; } + public IReadOnlyDictionary Data { get; } + public bool IsEmpty => Prepared && Data.Count == 0; + + internal PlayerKVLoginPrepareResult(PlayerKVLoginPrepareError error, string playerId, long epoch, + AccountLoginError? accountError = null, int? httpStatus = null, + PlayerKVGameOpenError? openError = null, PlayerKVSessionOpenError? ownerError = null, + PlayerKVError? kvError = null, PlayerKVSyncStatus? syncStatus = null, + string ownerId = null, IReadOnlyDictionary data = null, + string exceptionType = null, string cleanupExceptionType = null, + PlayerKVLoginPrepareError? originalError = null) + { + Error = error; OriginalError = originalError ?? error; PlayerId = playerId; Epoch = epoch; + AccountError = accountError; HttpStatusCode = httpStatus; OpenError = openError; + OwnerError = ownerError; KVError = kvError; SyncStatus = syncStatus; OwnerInstanceId = ownerId; + ExceptionType = exceptionType; CleanupExceptionType = cleanupExceptionType; + if (!Prepared) return; + if (data == null) throw new ArgumentNullException(nameof(data)); + var copy = new Dictionary(StringComparer.Ordinal); + foreach (var pair in data) copy.Add(pair.Key, pair.Value); + Data = new ReadOnlyDictionary(copy); + } + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginPrepareResult.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginPrepareResult.cs.meta new file mode 100644 index 000000000..616b629bd --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginPrepareResult.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d2c0df39903a45f780cc6751d1774c51 diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs new file mode 100644 index 000000000..954aa6f87 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace game.PlayerDataStorage +{ + public enum PlayerKVError + { + None, Validation, Unauthorized, Http, Network, Timeout, Cancelled, Protocol + } + + public sealed class PlayerKVResult + { + public bool Success => Error == PlayerKVError.None; + public PlayerKVError Error { get; } + public int? StatusCode { get; } + // Only a successful Get has Data. Failure must never look like an empty save. + public IReadOnlyDictionary Data { get; } + // A failed write that entered the transport may already have reached the server. + // This is conservative, including HTTP failures. Do not blindly replay the write. + public bool MayHaveCommitted { get; } + + internal PlayerKVResult(PlayerKVError error, int? statusCode = null, + Dictionary data = null, bool mayHaveCommitted = false) + { + Error = error; + StatusCode = statusCode; + Data = data == null ? null : new ReadOnlyDictionary(data); + MayHaveCommitted = mayHaveCommitted; + } + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs.meta new file mode 100644 index 000000000..4c6f3f164 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9401473b45424dbf94ddece933db1c86 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveRouteEditorValidationHost.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveRouteEditorValidationHost.cs new file mode 100644 index 000000000..4d4713a7a --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveRouteEditorValidationHost.cs @@ -0,0 +1,126 @@ +#if UNITY_EDITOR && !SERVICE_HOST +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using game.Account; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace game.PlayerDataStorage +{ + internal static class PlayerKVSaveRouteEditorValidationHost + { + static int running; + // Retain a failed cleanup's owner for diagnosis; never silently restart over it. + static object failedLifetime; + internal static async Task RunAsync(string caseId) + { + if (caseId != "fixed-route") throw new ArgumentException("Unknown fixed validation case."); + if (Interlocked.CompareExchange(ref running, 1, 0) != 0 || failedLifetime != null) + throw new InvalidOperationException("Validation already running or cleanup failed."); + var run = new Run(); + try { return await run.Execute(); } + finally + { + try { await run.Close(); } + catch { failedLifetime = run; throw; } + finally { Interlocked.Exchange(ref running, 0); } + } + } + + sealed class Run + { + readonly Uri api = new Uri("http://kv-route.invalid/"); + readonly List sessions = new List(); + readonly Dictionary> remote = new Dictionary> + { ["101"] = new Dictionary { ["k"] = "A", ["empty"] = "" }, ["102"] = new Dictionary { ["k"] = "B" } }; + readonly string cache = Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath, + "../../CodexOutput/upserver/KVSaveRouteTests/unity-runs", DateTime.UtcNow.ToString("yyyyMMddHHmmss") + "_" + Guid.NewGuid().ToString("N"))); + PlayerKVGameSessionBridge bridge; + PlayerKVGameLoadGuard guard; + int assertions, auth, gets, writes; + long epoch = 1; + void Check(bool value) { assertions++; if (!value) throw new InvalidOperationException("Fixed route assertion " + assertions); } + static HttpResponseMessage Json(object value) => new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent(JsonConvert.SerializeObject(value)) }; + sealed class SyntheticHandler : HttpMessageHandler + { + readonly Func> send; + internal SyntheticHandler(Func> send) { this.send = send; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken token) => send(request); + } + HttpMessageHandler KV(CookieContainer jar) + { + var id = jar.GetCookies(api)["sid"].Value; + return new SyntheticHandler(async request => + { + Check(request.RequestUri.Host == "kv-route.invalid" && request.RequestUri.Port == 80); + var path = request.RequestUri.AbsolutePath; + Check(path == "/playerdata/Get" || path == "/playerdata/Set"); + var body = JObject.Parse(await request.Content.ReadAsStringAsync()); + if (path == "/playerdata/Get") { gets++; return Json(new { data = remote[id] }); } + writes++; + foreach (var p in ((JObject)body["data"]).Properties()) remote[id][p.Name] = (string)p.Value; + return Json(new { }); + }); + } + async Task Open(string id) + { + var session = new S101AccountSession(api, jar => new SyntheticHandler(request => + { + Check(request.RequestUri.Host == "kv-route.invalid" && request.RequestUri.AbsolutePath == "/account/Login"); + auth++; jar.Add(api, new Cookie("sid", id)); + return Task.FromResult(Json(new { state = 0, internalId = int.Parse(id), newCreate = false, + createTime = "2026-01-01T00:00:00Z", svrTime = "2026-01-01T00:00:00Z" })); + })); + sessions.Add(session); + Check((await session.LoginAsync(id)).Success); + var opened = await bridge.OpenAsync(session, cache, id, api, epoch, e => e == epoch); + Check(opened.Success); return opened.Handle; + } + internal async Task Execute() + { + // Never disturb a guard held by another validation or a controlled login. + Check(PlayerKVGameLoadGuard.TryEnter(out guard)); guard.Dispose(); guard = null; + Directory.CreateDirectory(cache); bridge = new PlayerKVGameSessionBridge(KV); + var a = await Open("101"); + var present = PlayFabMgr.ReadKV(a, "k"); Check(present.Success && present.Found == true && present.Value == "A"); + Check(PlayFabMgr.ReadKV(a, "missing").Found == false); + Check(PlayFabMgr.ReadKV(a, "empty").Found == true && PlayFabMgr.ReadKV(a, "empty").Value == ""); + Check(PlayFabMgr.ReadKV(a, null).Found == null); + var edit = PlayFabMgr.WriteKV(a, "k", "remote"); Check(edit.Accepted && edit.Ticket != null && writes == 0); + var local = PlayFabMgr.WriteKV(a, "k", "local", false); Check(local.Accepted && local.Ticket == null); + Check(PlayFabMgr.ReadKV(a, "k").Value == "local"); + var flush = await PlayFabMgr.FlushKVAsync(a); Check(flush.Confirmed == 1 && remote["101"]["k"] == "remote"); + Check((await PlayFabMgr.WaitForKVTicketAsync(a, edit.Ticket)).Status == PlayerKVTicketStatus.Confirmed); + await bridge.CloseAsync(); epoch++; + var b = await Open("102"); var writesBefore = writes; + Check(PlayFabMgr.ReadKV(a, "k").Error == PlayerKVKeyReadError.StaleOrClosing); + Check(!PlayFabMgr.WriteKV(a, "k", "late").Accepted); + Check((await PlayFabMgr.FlushKVAsync(a)).Blocked == 1); + Check((await PlayFabMgr.WaitForKVTicketAsync(b, edit.Ticket)).Status == PlayerKVTicketStatus.Blocked); + Check(PlayFabMgr.ReadKV(b, "k").Value == "B" && writes == writesBefore); + Check(PlayerKVGameLoadGuard.TryEnter(out guard)); + Check(PlayFabMgr.ReadKV(null, null).Error == PlayerKVKeyReadError.LoadGuardActive); + Check(PlayFabMgr.WriteKV(null, null, null).StateResult.Error == PlayerKVChangeError.Blocked); + Check((await PlayFabMgr.FlushKVAsync(b)).TransportError == PlayerKVError.Cancelled); + Check((await PlayFabMgr.WaitForKVTicketAsync(b, null)).TransportError == PlayerKVError.Cancelled); + Check(guard.Violations == 4 && guard.Count(PlayerKVGuardPoint.ReadLocal) == 1 && guard.Count(PlayerKVGuardPoint.Update) == 1 && guard.Count(PlayerKVGuardPoint.Sync) == 2); + Check(auth == 2 && gets == 2 && writes == 1); + await Close(); + return JsonConvert.SerializeObject(new { success = true, assertions, auth, gets, writes, formalRequests = 0, restored = true, cache }); + } + internal async Task Close() + { + if (bridge != null) await bridge.CloseAsync(); + foreach (var session in sessions) session.Dispose(); sessions.Clear(); + guard?.Dispose(); guard = null; + } + } + } +} +#endif diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveRouteEditorValidationHost.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveRouteEditorValidationHost.cs.meta new file mode 100644 index 000000000..cfa3852f6 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveRouteEditorValidationHost.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 860b04f8a2a64eb8acf77e96695f6c54 diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveSchema.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveSchema.cs new file mode 100644 index 000000000..31750be99 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveSchema.cs @@ -0,0 +1,1168 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace game.PlayerDataStorage +{ + // Pure decoding; Valid is only issued after the complete 31-module graph is checked. + internal static class PlayerKVSaveSchema + { + static readonly CultureInfo Invariant = CultureInfo.InvariantCulture; + static readonly string[] CoreKeys = { "Lv", "Gold", "vipAllScore", "Energy", "Diamond", "Magnification", + "EquipRodID", "LastEnergyRecoveryTime", "RewardNewMap", "EnergyRM", "GoldRM", "DiamondTrue", + "CurrentMapId", "LastMapId", "FishingFrequency", "AllFishingFrequency", "HeistGameCount", "BombGameCount", "FishHuntGameCount" }; + static readonly string[] Aliases = { "lv", "gold", "vipS", "energy", "pearl", "mag", "rodID", "EnergyTime", + "rewardNewMap", "energyRM", "goldRM", "diamond", "cMapID", "lMapID", "FF", "AFF", "HGC", "BGC", "FHGC" }; + + internal static PlayerKVSchemaResult Decode(PlayerKVLoginPrepareResult prepared,PlayerKVAccountDescriptor account, + string expectedId,long epoch,bool current,PlayerKVSchemaSnapshot snapshot,string expectedVersion,PlayerKVIdentitySnapshot identity) + { + var errors=new PlayerKVSchemaDiagnostics(); + PlayerKVSchemaResult Fail(PlayerKVSchemaVerdict verdict) => new PlayerKVSchemaResult(verdict,errors); + if(!current || prepared==null || !prepared.Prepared || account==null || prepared.PlayerId!=expectedId || + account.PlayerId!=expectedId || prepared.Epoch!=epoch || account.Epoch!=epoch) return Fail(PlayerKVSchemaVerdict.Stale); + var raw=prepared.Data; + if(raw.Count==0) return Fail(account.NewlyCreated ? PlayerKVSchemaVerdict.EmptyNew : PlayerKVSchemaVerdict.EmptyExisting); + if(snapshot==null || snapshot.Version!=expectedVersion || identity==null) return Fail(PlayerKVSchemaVerdict.UnsupportedSchema); + var modern=DecodeModern(raw,errors); + if(modern==null) return Fail(PlayerKVSchemaVerdict.Malformed); + var player=DecodePlayer(raw,modern,snapshot,errors); + var modules=new object[] + { + player,player?.Core,DecodeFish(raw,modern,snapshot,errors),DecodeShop(raw,snapshot,errors), + DecodeEventBase(raw,modern,snapshot,errors),DecodeBuffs(raw,snapshot,errors),DecodeCamp(raw,errors), + DecodeAchievements(raw,modern,snapshot,errors),DecodeFishingBox(raw,modern,errors),DecodeGuides(raw,errors), + DecodeBattlePass(raw,snapshot,errors),DecodeMonthlySign(raw,errors),DecodeTriggers(raw,errors),DecodeClub(raw,snapshot,errors), + DecodeChallenge(raw,snapshot,errors),DecodeOffer(raw,false,snapshot,errors),DecodeOffer(raw,true,snapshot,errors), + DecodePartner(raw,snapshot,errors),DecodeCube(raw,snapshot,errors),DecodeEventPack(raw,snapshot,errors), + DecodePiggyBank(raw,snapshot,errors),DecodeBargain(raw,snapshot,errors),DecodeFootPrint(raw,errors),DecodeShootingRange(raw,snapshot,errors), + DecodePotion(raw,errors),DecodePinball(raw,snapshot,errors),DecodeScratch(raw,snapshot,errors), + new PlayerKVProgressPackPair(DecodeProgressPack(raw,false,errors),DecodeProgressPack(raw,true,errors)), + DecodePinata(raw,errors),PlayerKVEmptyModuleState.Instance,DecodeLogin(raw,identity,errors) + }; + if(errors.TotalErrors>0) + { + // Core classification has precedence; remaining failures are deterministic by module/field order. + if(errors.Count(1,PlayerKVSchemaError.MissingCore)>0) return Fail(PlayerKVSchemaVerdict.MissingCore); + if(errors.Count(1,PlayerKVSchemaError.ConflictingRepresentations)>0) return Fail(PlayerKVSchemaVerdict.ConflictingRepresentations); + var code=errors.Details[0].Code; + return Fail(code==PlayerKVSchemaError.UnsupportedSchema ? PlayerKVSchemaVerdict.UnsupportedSchema : + code==PlayerKVSchemaError.ConflictingRepresentations ? PlayerKVSchemaVerdict.ConflictingRepresentations : PlayerKVSchemaVerdict.Malformed); + } + var topKeys=new HashSet(CoreKeys,StringComparer.Ordinal); + topKeys.UnionWith(("NewPlayerDataDic NewRodList SeasonId IsOpenAppraise InfiniteBuildingData RodData RodLevelData FishSwimToMap FishSwimToMapCount MapProgress HonorLevelData InitRodAccessoriesData LuckDataDic "+ + "ShopPackBuyCount ShopAllPackBuyCount ShopGiftBuyCount Voucher ChainPackDataNew VIPAdPackData TurntableAdData BuyData AdvertAllCount DiamondShopRefreshCount DiamondShopRefreshTime DiamondShopItemRecords BlackFridayCouponDate "+ + "LimitedTimeEventDic FixedTimeEventDic ETDropAfterDropDic RankTargetCount WheelLevelCount TurntableRoll WheelOperateCount AllWheelOperateCount WheelLevelFreeCount PVPToken "+ + "BuffDataList TargetBuffDataList FishDailyBuff GlobalBuffDataList IsFix IsOpenSkyscraper CampDataDic AchievementData FishingBoxData GuideSaveData GuideSaveDataNew BattlePassRecord BattleTask BattlePassRecordTask MonthlySignEvent "+ + "TriggerPackDataList GiftVIPDData FixedTimeShopDic ClubSignin LureDonateData IsOpenClubRed FishingChallengeData OfferChainsChestData OfferDiscoPartyManager EventPartnerGatherData EventCubeMeltData EventPackData PiggyBankPackData GeneralEventPackData FootPrint EventShootingRangeData epdk epbdk estdk ThanksGivingPackData ThanksGivingPackMigrationData EventPinataPackData MMTest EAccoutUrl TapJoy_Diamond hwGoogle hwFacebook Apple").Split(' ')); + if(identity.Channel!=PlayerKVIdentityChannel.none && identity.Channel!=PlayerKVIdentityChannel.hwGuest) topKeys.Add(identity.Channel.ToString()); + var modernKeys=new HashSet(Aliases,StringComparer.Ordinal); + modernKeys.UnionWith(new[]{"newData","vipL","LDD","ld","AD","ad","bd","ct","rt"}); + int unconsumedTop=raw.Keys.Count(k=>!topKeys.Contains(k) && !System.Text.RegularExpressions.Regex.IsMatch(k,"^Map_(5_)?([0-9]+)$")); + int unconsumedModern=modern.Keys.Count(k=>!modernKeys.Contains(k)); + return new PlayerKVSchemaResult(PlayerKVSchemaVerdict.Valid,errors,new PlayerKVDecodedSave(expectedId,epoch,snapshot.Version,raw,modules,unconsumedTop,unconsumedModern)); + } + + internal static PlayerKVLoginState DecodeLogin(IReadOnlyDictionary raw,PlayerKVIdentitySnapshot identity, + PlayerKVSchemaDiagnostics errors) + { + if(identity==null) throw new ArgumentNullException(nameof(identity)); + int before=errors.TotalErrors; + var normalized=new Dictionary(StringComparer.Ordinal); + int mm=errors.Capture(31,0,()=> + { + int value=0; bool normalize=true; + if(raw.TryGetValue("MMTest",out var text)) + { + if(text==null) throw new PlayerKVSchemaException(); + if(text=="MMFailParam" || text.StartsWith("MMFailParam#",StringComparison.Ordinal)) + { + var parts=text.Split('#'); if(parts.Length!=2) throw new PlayerKVSchemaException(); + value=Int32(parts[1]); normalize=false; + } + } + else if(identity.NewlyCreated) + { + if(string.IsNullOrEmpty(identity.UserId) || identity.UserId.Length>16 || identity.UserId.Any(c=>!Uri.IsHexDigit(c)) || + !ulong.TryParse(identity.UserId,NumberStyles.AllowHexSpecifier,Invariant,out var id)) throw new PlayerKVSchemaException(); + value=(int)(id%16); + } + if(normalize) normalized["MMTest"]="MMFailParam#"+value.ToString(Invariant); + return value; + }); + var linked=new HashSet(identity.Linked); + var channels=new HashSet{PlayerKVIdentityChannel.hwGoogle,PlayerKVIdentityChannel.hwFacebook,PlayerKVIdentityChannel.Apple}; + bool hasChannel=identity.Channel!=PlayerKVIdentityChannel.hwGuest && identity.Channel!=PlayerKVIdentityChannel.none; + if(hasChannel) channels.Add(identity.Channel); + int field=1; + foreach(var channel in channels.OrderBy(c=>(int)c)) + { + string key=channel.ToString(); + if(raw.TryGetValue(key,out var text)) + { + int flag=errors.Capture(31,field,()=> { if(text!="0" && text!="1") throw new PlayerKVSchemaException(); return text=="1" ? 1 : 0; }); + if(flag==1) linked.Add(channel); + } + field++; + } + if(hasChannel && !raw.ContainsKey(identity.Channel.ToString())) + { normalized[identity.Channel.ToString()]="1"; linked.Add(identity.Channel); } + var urls=raw.TryGetValue("EAccoutUrl",out var urlsText) ? errors.Capture(31,6,()=>StringMap(urlsText)) : new Dictionary(); + int balance=raw.TryGetValue("TapJoy_Diamond",out var balanceText) ? errors.Capture(31,7,()=>Int32(balanceText)) : 0; + return errors.TotalErrors==before ? new PlayerKVLoginState(mm,balance,urls,normalized,linked,identity.Channel) : null; + } + internal static int Int32(string value) + { + if (!int.TryParse(value, NumberStyles.AllowLeadingSign, Invariant, out var n)) throw new PlayerKVSchemaException(); + return n; + } + internal static ulong UInt64(string value) + { + if (!ulong.TryParse(value, NumberStyles.None, Invariant, out var n)) throw new PlayerKVSchemaException(); + return n; + } + internal static float Single(string value) + { + if (!float.TryParse(value, NumberStyles.Float, Invariant, out var n) || float.IsNaN(n) || float.IsInfinity(n)) + throw new PlayerKVSchemaException(); + return n; + } + internal static DateTime Date(string value, PlayerKVSchemaSnapshot snapshot) + { + if (System.DateTime.TryParseExact(value, "O", Invariant, DateTimeStyles.RoundtripKind, out var roundtrip)) return roundtrip; + const DateTimeStyles styles = DateTimeStyles.RoundtripKind | DateTimeStyles.NoCurrentDateDefault; + bool local = System.DateTime.TryParse(value, snapshot.Culture, styles, out var a); + bool invariant = System.DateTime.TryParse(value, Invariant, styles, out var b); + if (local && invariant && a != b) throw new PlayerKVSchemaException(PlayerKVSchemaError.UnsupportedSchema); + if (local) return a; + if (invariant) return b; + throw new PlayerKVSchemaException(); + } + internal static JToken Json(string value) + { + if (value == null) throw new PlayerKVSchemaException(); + StrictJsonSyntax.Validate(value); + try + { + // Json.NET's default parser accepts comments, constructors and non-finite literals. + // Reject those before materializing tokens; never deserialize a runtime game class here. + using (var check = new JsonTextReader(new StringReader(value)) { DateParseHandling = DateParseHandling.None }) + while (check.Read()) + { + if (check.TokenType == JsonToken.Comment || check.TokenType == JsonToken.StartConstructor || check.TokenType == JsonToken.Undefined) + throw new PlayerKVSchemaException(); + if (check.TokenType == JsonToken.Float && check.Value is double d && (double.IsNaN(d) || double.IsInfinity(d))) + throw new PlayerKVSchemaException(); + } + using (var reader = new JsonTextReader(new StringReader(value)) { DateParseHandling = DateParseHandling.None }) + { + var result = JToken.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error }); + if (reader.Read() || result.Type == JTokenType.Null) throw new PlayerKVSchemaException(); + return result; + } + } + catch (JsonException) { throw new PlayerKVSchemaException(); } + } + internal static JObject Object(JToken token) + { if (!(token is JObject obj)) throw new PlayerKVSchemaException(); return obj; } + internal static string String(JToken token) + { if (token == null || token.Type != JTokenType.String) throw new PlayerKVSchemaException(); return (string)token; } + internal static int Integer(JToken token) + { if (token == null || token.Type != JTokenType.Integer) throw new PlayerKVSchemaException(); return Int32(token.ToString(Formatting.None)); } + internal static ulong Unsigned(JToken token) + { if (token == null || token.Type != JTokenType.Integer) throw new PlayerKVSchemaException(); return UInt64(token.ToString(Formatting.None)); } + internal static long Signed64(JToken token) + { + if (token == null || token.Type != JTokenType.Integer || + !long.TryParse(token.ToString(Formatting.None), NumberStyles.AllowLeadingSign, Invariant, out var value)) + throw new PlayerKVSchemaException(); + return value; + } + internal static bool Boolean(JToken token) + { if (token == null || token.Type != JTokenType.Boolean) throw new PlayerKVSchemaException(); return (bool)token; } + internal static float Finite(JToken token) + { + if (token == null || (token.Type != JTokenType.Integer && token.Type != JTokenType.Float)) throw new PlayerKVSchemaException(); + return Single(token.ToString(Formatting.None)); + } + internal static List IntegerList(JToken token) + { + if (!(token is JArray array)) throw new PlayerKVSchemaException(); + return array.Select(Integer).ToList(); + } + internal static PlayerKVPiggyBankState DecodePiggyBank(IReadOnlyDictionary raw, + PlayerKVSchemaSnapshot snapshot, PlayerKVSchemaDiagnostics errors) + { + if (!raw.TryGetValue("PiggyBankPackData", out var text)) return PlayerKVPiggyBankState.Missing(); + return errors.Capture(21, 0, () => + { + var obj = Object(Json(text)); + var currentEventID = Integer(obj["currentEventID"]); + var purchaseCount = Integer(obj["purchaseCount"]); + var VIPLvlWhenEventActivate = Integer(obj["VIPLvlWhenEventActivate"]); + var savingProgress = Integer(obj["savingProgress"]); + var redirectID = Integer(obj["redirectID"]); + var doNeedTrigger = Boolean(obj["doNeedTrigger"]); + var isTriggered = Boolean(obj["isTriggered"]); + var inflation = Finite(obj["inflation"]); + if (redirectID == 0) redirectID = 4055001; + if (!snapshot.HasPack(redirectID)) throw new PlayerKVSchemaException(PlayerKVSchemaError.UnsupportedSchema); + return new PlayerKVPiggyBankState(true, currentEventID, purchaseCount, VIPLvlWhenEventActivate, savingProgress, redirectID, doNeedTrigger, isTriggered, inflation); + }); + } + internal static PlayerKVBargainState DecodeBargain(IReadOnlyDictionary raw, + PlayerKVSchemaSnapshot snapshot, PlayerKVSchemaDiagnostics errors) + { + if (!raw.TryGetValue("GeneralEventPackData", out var text)) return PlayerKVBargainState.Missing(); + return errors.Capture(22, 0, () => + { + var obj = Object(Json(text)); + var currentEventID = Integer(obj["currentEventID"]); + var redirectID = Integer(obj["redirectID"]); + var VIPLvlWhenEventActivate = Integer(obj["VIPLvlWhenEventActivate"]); + var purchaseCount = Integer(obj["purchaseCount"]); + var savingProgress = Integer(obj["savingProgress"]); + var discountLvl = Integer(obj["discountLvl"]); + var visualProgress = Integer(obj["visualProgress"]); + var visualDiscountLvl = Integer(obj["visualDiscountLvl"]); + var rodID = Integer(obj["rodID"]); + var lastPackIDBought = Integer(obj["lastPackIDBought"]); + var packID = Integer(obj["packID"]); + var doNeedTrigger = Boolean(obj["doNeedTrigger"]); + var isTriggered = Boolean(obj["isTriggered"]); + var inflation = Finite(obj["inflation"]); + var selectionToken = obj["selections"]; + var selections = selectionToken != null && selectionToken.Type == JTokenType.Null ? null : IntegerList(selectionToken); + if (!snapshot.HasPackType(redirectID, 3)) throw new PlayerKVSchemaException(PlayerKVSchemaError.UnsupportedSchema); + return new PlayerKVBargainState(true, currentEventID, redirectID, VIPLvlWhenEventActivate, purchaseCount, savingProgress, discountLvl, visualProgress, visualDiscountLvl, rodID, lastPackIDBought, packID, doNeedTrigger, isTriggered, inflation, selections); + }); + } + internal static PlayerKVShootingRangeState DecodeShootingRange(IReadOnlyDictionary raw, + PlayerKVSchemaSnapshot snapshot, PlayerKVSchemaDiagnostics errors) + { + if (!raw.TryGetValue("EventShootingRangeData", out var text)) return PlayerKVShootingRangeState.Missing(); + return errors.Capture(24, 0, () => + { + var obj = Object(Json(text)); + var EventId = Integer(obj["EventId"]); + var CycleId = Integer(obj["CycleId"]); + var RedirectId = Integer(obj["RedirectId"]); + var AmmoCount = Integer(obj["AmmoCount"]); + var CurrentStageIdx = Integer(obj["CurrentStageIdx"]); + var ChainProgress = Integer(obj["ChainProgress"]); + var RewardFlag = Signed64(obj["RewardFlag"]); + var TargetFlag = Signed64(obj["TargetFlag"]); + var HasBeenOpened = Boolean(obj["HasBeenOpened"]); + var Inflation = Finite(obj["Inflation"]); + return new PlayerKVShootingRangeState(true, EventId, CycleId, RedirectId, AmmoCount, CurrentStageIdx, ChainProgress, RewardFlag, TargetFlag, HasBeenOpened, Inflation); + }); + } + internal static Dictionary StringMap(string text) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var p in Object(Json(text)).Properties()) result.Add(p.Name, String(p.Value)); + return result; + } + internal static Dictionary DecodeModern(IReadOnlyDictionary raw, PlayerKVSchemaDiagnostics errors) + { + if (!raw.TryGetValue("NewPlayerDataDic", out var text)) return new Dictionary(StringComparer.Ordinal); + return errors.Capture(1, 0, () => StringMap(text)); + } + static object CoreValue(int slot, string value, PlayerKVSchemaSnapshot snapshot) + { + if (slot == 1 || slot == 10) return UInt64(value); + if (slot == 7) return Date(value, snapshot); + int n = Int32(value); + if (slot == 6) { if (!snapshot.HasRod(n)) throw new PlayerKVSchemaException(PlayerKVSchemaError.UnsupportedSchema); } + else if (slot == 12 || slot == 13) { if (n != 0 && !snapshot.HasMap(n)) throw new PlayerKVSchemaException(PlayerKVSchemaError.UnsupportedSchema); } + else if (slot != 8 && n < (slot == 0 ? 1 : 0)) throw new PlayerKVSchemaException(); + return n; + } + internal static PlayerKVCoreState DecodeCore(IReadOnlyDictionary raw, + IReadOnlyDictionary modern, PlayerKVSchemaSnapshot snapshot, PlayerKVSchemaDiagnostics errors) + { + if (modern == null) return null; // Outer representation already rejected; no legacy fallback. + int before = errors.TotalErrors; + object[] selected = null; + if (modern.TryGetValue("newData", out var compact)) + { + var parts = compact?.Split(','); + if (parts == null || parts.Length != 19) { errors.Add(1, 1, PlayerKVSchemaError.Malformed); return null; } + selected = new object[19]; + for (int i = 0; i < 19; i++) + { + try { selected[i] = CoreValue(i, parts[i], snapshot); } + catch (PlayerKVSchemaException ex) { errors.Add(i >= 16 ? 2 : 1, i + 2, ex.Code); } + } + } + bool completeLegacy = CoreKeys.All(raw.ContainsKey); + if (selected == null && !completeLegacy) errors.Add(1, 1, PlayerKVSchemaError.MissingCore); + var legacy = new object[19]; + for (int i = 0; i < 19; i++) + { + try + { + if (raw.TryGetValue(CoreKeys[i], out var old)) + { + legacy[i] = CoreValue(i, old, snapshot); + if (selected != null && selected[i] != null && !Equals(selected[i], legacy[i])) + errors.Add(i >= 16 ? 2 : 1, 32 + i, PlayerKVSchemaError.ConflictingRepresentations); + } + } + catch (PlayerKVSchemaException ex) { errors.Add(i >= 16 ? 2 : 1, 32 + i, ex.Code); } + try + { + if (modern.TryGetValue(Aliases[i], out var alias)) + { + var value = CoreValue(i, alias, snapshot); + var canonical = selected != null ? selected[i] : legacy[i]; + if (canonical != null && !Equals(canonical, value)) + errors.Add(i >= 16 ? 2 : 1, 64 + i, PlayerKVSchemaError.ConflictingRepresentations); + } + } + catch (PlayerKVSchemaException ex) { errors.Add(i >= 16 ? 2 : 1, 64 + i, ex.Code); } + } + return errors.TotalErrors == before ? new PlayerKVCoreState(selected ?? legacy) : null; + } + + static PlayerKVAchievementValue AchievementSlots(string text) + { + var parts = text?.Split('|'); + if (parts == null || parts.Length != 3) throw new PlayerKVSchemaException(); + return new PlayerKVAchievementValue(Int32(parts[0]), UInt64(parts[1]), Int32(parts[2])); + } + static void AddAchievement(Dictionary result, int key, PlayerKVAchievementValue value) + { if (result.ContainsKey(key)) throw new PlayerKVSchemaException(); result.Add(key, value); } + static Dictionary Achievements(string text, int format, PlayerKVSchemaSnapshot snapshot) + { + var result = new Dictionary(); + if (format == 2) + { + if (text == "") return result; + if (text == null) throw new PlayerKVSchemaException(); + foreach (var record in text.Split(';')) + { + var parts = record.Split(':'); + if (parts.Length != 2) throw new PlayerKVSchemaException(); + AddAchievement(result, snapshot.Condition(parts[0]), AchievementSlots(parts[1])); + } + } + else foreach (var p in Object(Json(text)).Properties()) + { + PlayerKVAchievementValue value; + if (format == 1) value = AchievementSlots(String(p.Value)); + else + { + var obj = Object(p.Value); + value = new PlayerKVAchievementValue(Integer(obj["Id"]), Unsigned(obj["Target"]), Integer(obj["day"])); + } + AddAchievement(result, snapshot.Condition(p.Name), value); + } + return result; + } + internal static PlayerKVAchievementState DecodeAchievements(IReadOnlyDictionary raw, + IReadOnlyDictionary modern, PlayerKVSchemaSnapshot snapshot, PlayerKVSchemaDiagnostics errors) + { + if (modern == null) return null; + int before = errors.TotalErrors; + var representations = new List>(); + string[] keys = { "AchievementData", "AD", "ad" }; + for (int format = 0; format < 3; format++) + { + var source = format == 0 ? raw : modern; + if (!source.TryGetValue(keys[format], out var text)) continue; + int captured = format; + var state = errors.Capture(8, format, () => Achievements(text, captured, snapshot)); + if (state != null) representations.Add(state); + } + for (int a = 0; a < representations.Count; a++) + for (int b = a + 1; b < representations.Count; b++) + foreach (var item in representations[a].OrderBy(x => x.Key)) + if (representations[b].TryGetValue(item.Key, out var other) && !item.Value.Same(other)) + errors.Add(8, 3, PlayerKVSchemaError.ConflictingRepresentations); + if (errors.TotalErrors != before) return null; + return new PlayerKVAchievementState(representations.Count == 0 + ? new Dictionary() : representations[representations.Count - 1]); + } + internal static Dictionary IntegerDictionary(JToken token,Func decode) + { + var result=new Dictionary(); + foreach(var p in Object(token).Properties()) + { + int key=Int32(p.Name); + if(result.ContainsKey(key)) throw new PlayerKVSchemaException(); + result.Add(key,decode(p.Value)); + } + return result; + } + internal static List ArrayValues(JToken token,Func decode) + { + if(!(token is JArray array)) throw new PlayerKVSchemaException(); + return array.Select(decode).ToList(); + } + static PlayerKVBuffValue Buff(JToken token,PlayerKVSchemaSnapshot snapshot) + { + var obj=Object(token); + // isNew/addDown are explicitly NonSerialized runtime fields, not saved fields. + return new PlayerKVBuffValue(Integer(obj["SortID"]),Integer(obj["sourceID"]),Integer(obj["buffID"]), + Boolean(obj["isEnd"]),Date(String(obj["buffEndTime"]),snapshot)); + } + internal static PlayerKVBuffState DecodeBuffs(IReadOnlyDictionary raw,PlayerKVSchemaSnapshot snapshot,PlayerKVSchemaDiagnostics errors) + { + int before=errors.TotalErrors; + var map=new Dictionary(); var global=new Dictionary(); + var target=new List(); PlayerKVBuffValue daily=null; + if(raw.TryGetValue("BuffDataList",out var text)) map=errors.Capture(6,0,()=>IntegerDictionary(Json(text),t=>Buff(t,snapshot))); + if(raw.TryGetValue("TargetBuffDataList",out var targetText)) target=errors.Capture(6,1,()=>ArrayValues(Json(targetText),t=>Buff(t,snapshot))); + if(raw.TryGetValue("FishDailyBuff",out var dailyText)) daily=errors.Capture(6,2,()=>Buff(Json(dailyText),snapshot)); + if(raw.TryGetValue("GlobalBuffDataList",out var globalText)) global=errors.Capture(6,3,()=>IntegerDictionary(Json(globalText),t=>Buff(t,snapshot))); + return errors.TotalErrors==before ? new PlayerKVBuffState(map,global,target,daily) : null; + } + static PlayerKVTriggerValue Trigger(JToken token) + { + var obj=Object(token); + return new PlayerKVTriggerValue(Integer(obj["ID"]),Integer(obj["index"]),Integer(obj["lastID"]), + Integer(obj["vipLevel"]),Integer(obj["mapID"]),String(obj["time"])); + } + static PlayerKVGiftVipValue GiftVip(JToken token) + { + var obj=Object(token); + return new PlayerKVGiftVipValue(Integer(obj["oldVipLevel"]),Integer(obj["curVipLevel"]),Integer(obj["timer"])); + } + internal static PlayerKVFixedTimeValue FixedTime(JToken token) + { var obj=Object(token); return new PlayerKVFixedTimeValue(Integer(obj["id"]),String(obj["endTime"])); } + internal static PlayerKVTriggerState DecodeTriggers(IReadOnlyDictionary raw,PlayerKVSchemaDiagnostics errors) + { + int before=errors.TotalErrors; + var packs=new List(); var gifts=new Dictionary(StringComparer.Ordinal); + var fixedValues=new Dictionary(); + if(raw.TryGetValue("TriggerPackDataList",out var text)) packs=errors.Capture(13,0,()=>ArrayValues(Json(text),Trigger)); + if(raw.TryGetValue("GiftVIPDData",out var giftText)) gifts=errors.Capture(13,1,()=>Object(Json(giftText)).Properties().ToDictionary(p=>p.Name,p=>GiftVip(p.Value),StringComparer.Ordinal)); + if(raw.TryGetValue("FixedTimeShopDic",out var fixedText)) fixedValues=errors.Capture(13,2,()=>IntegerDictionary(Json(fixedText),FixedTime)); + return errors.TotalErrors==before ? new PlayerKVTriggerState(packs,gifts,fixedValues) : null; + } + + static PlayerKVDailyValue Daily(JToken token) + { var o=Object(token); return new PlayerKVDailyValue(Integer(o["ID"]),Integer(o["index"]),Integer(o["lastID"]),String(o["time"])); } + static PlayerKVAdvertValue Advert(JToken token) + { var o=Object(token); return new PlayerKVAdvertValue(Integer(o["ID"]),Integer(o["index"]),Integer(o["day"]),o["cdTime"]?.Type == JTokenType.Null ? null : String(o["cdTime"]),Boolean(o["isOpen"])); } + static PlayerKVShopItemValue ShopItem(JToken token) + { var o=Object(token); return new PlayerKVShopItemValue(Integer(o["ItemID"]),Finite(o["Discount"]),Integer(o["hasBought"])); } + static PlayerKVCouponState Coupon(string text) + { + if(text==null) throw new PlayerKVSchemaException(); + var fields=text.Split(','); if(fields.Length!=7 && fields.Length!=9) throw new PlayerKVSchemaException(); + var n=fields.Select(Int32).ToArray(); + if(n.Length==9 && n[8]!=0 && n[8]!=1) throw new PlayerKVSchemaException(); + DateTime date; + try { date=new DateTime(n[0],n[1],n[2],n[3],n[4],n[5]); } + catch(ArgumentOutOfRangeException) { throw new PlayerKVSchemaException(); } + return new PlayerKVCouponState(date,n[6],n.Length==9 ? n[7] : 0,n.Length==9 && n[8]==1); + } + internal static PlayerKVShopState DecodeShop(IReadOnlyDictionary raw,PlayerKVSchemaSnapshot snapshot,PlayerKVSchemaDiagnostics errors) + { + int before=errors.TotalErrors; + T Read(string key,int field,Func decode,T missing) => raw.TryGetValue(key,out var text) ? errors.Capture(4,field,()=>decode(text)) : missing; + var daily=Read("ShopPackBuyCount",0,t=>Object(Json(t)).Properties().ToDictionary(p=>p.Name,p=>Integer(p.Value),StringComparer.Ordinal),new Dictionary()); + var all=Read("ShopAllPackBuyCount",1,t=>IntegerMap(Json(t)),new Dictionary()); + var gift=Read("ShopGiftBuyCount",2,t=>IntegerMap(Json(t)),new Dictionary()); + int voucher=Read("Voucher",3,Int32,0); + var chain=Read("ChainPackDataNew",4,t=>Daily(Json(t)),(PlayerKVDailyValue)null); + var vip=Read("VIPAdPackData",5,t=>Advert(Json(t)),(PlayerKVAdvertValue)null); + var turntable=Read("TurntableAdData",6,t=>Daily(Json(t)),(PlayerKVDailyValue)null); + var buy=Read("BuyData",7,t=>ArrayValues(Json(t),Finite),new List()); + var advert=Read("AdvertAllCount",8,t=>IntegerMap(Json(t)),new Dictionary()); + int count=Read("DiamondShopRefreshCount",9,Int32,0); + var time=Read("DiamondShopRefreshTime",10,t=>Date(t,snapshot),default(DateTime)); + var items=Read("DiamondShopItemRecords",11,t=>IntegerDictionary(Json(t),ShopItem),new Dictionary()); + var coupon=Read("BlackFridayCouponDate",12,Coupon,new PlayerKVCouponState(default,0,0,false)); + return errors.TotalErrors==before ? new PlayerKVShopState(daily,all,gift,advert,items,buy,voucher,count,time,chain,turntable,vip,coupon) : null; + } + + static PlayerKVLimitedValue Limited(JToken token) + { + var o=Object(token); var t=o["duration"]; + if(t==null || (t.Type!=JTokenType.Float && t.Type!=JTokenType.Integer) || + !double.TryParse(t.ToString(Formatting.None),NumberStyles.Float,Invariant,out var d) || double.IsNaN(d) || double.IsInfinity(d)) throw new PlayerKVSchemaException(); + return new PlayerKVLimitedValue(Integer(o["id"]),d,String(o["startTime"])); + } + static Dictionary PreferIntMap(IReadOnlyDictionary raw,string oldKey, + IReadOnlyDictionary modern,string newKey,PlayerKVSchemaDiagnostics errors,int module,int field) + { + Dictionary old=null,current=null; + if(raw.TryGetValue(oldKey,out var text)) old=errors.Capture(module,field,()=>IntegerMap(Json(text))); + if(modern.TryGetValue(newKey,out var compact)) current=errors.Capture(module,field+1,()=>ColonIntegers(compact)); + if(old!=null && current!=null && old.Any(p=>current.TryGetValue(p.Key,out var n) && n!=p.Value)) errors.Add(module,field,PlayerKVSchemaError.ConflictingRepresentations); + return current ?? old ?? new Dictionary(); + } + internal static PlayerKVEventBaseState DecodeEventBase(IReadOnlyDictionary raw,IReadOnlyDictionary modern, + PlayerKVSchemaSnapshot snapshot,PlayerKVSchemaDiagnostics errors) + { + int before=errors.TotalErrors; + T Read(string key,int field,Func decode,T missing) => raw.TryGetValue(key,out var text) ? errors.Capture(5,field,()=>decode(text)) : missing; + var limited=Read("LimitedTimeEventDic",0,t=> + { + var all=IntegerDictionary(Json(t),Limited); + // Validate even expired entries, then filter solely against the epoch's captured clock. + return all.Where(p=>(snapshot.NowGame-Date(p.Value.StartTime,snapshot)).TotalMinutesp.Key,p=>p.Value); + },new Dictionary()); + var fixedValues=Read("FixedTimeEventDic",1,t=>IntegerDictionary(Json(t),FixedTime),new Dictionary()); + var target=PreferIntMap(raw,"ETDropAfterDropDic",modern,"ct",errors,5,2); + var rank=PreferIntMap(raw,"RankTargetCount",modern,"rt",errors,5,4); + var wheel=Read("WheelLevelCount",6,t=>IntegerMap(Json(t)),new Dictionary()); + int coupon=Read("TurntableRoll",7,Int32,snapshot.InitWheelTicket),operate=Read("WheelOperateCount",8,Int32,0), + allOperate=Read("AllWheelOperateCount",9,Int32,0),free=Read("WheelLevelFreeCount",10,Int32,0),pvp=Read("PVPToken",11,Int32,0); + return errors.TotalErrors==before ? new PlayerKVEventBaseState(limited,fixedValues,target,rank,wheel,coupon,operate,allOperate,free,pvp) : null; + } + + static PlayerKVFishValue FishValue(JToken token,bool compact) + { + if(!compact) + { + var o=Object(token); + return new PlayerKVFishValue(Integer(o["Level"]),Finite(o["MaxG"]),Integer(o["TotalCard"]),Integer(o["TotalNum"]),Boolean(o["NewFish"])); + } + var f=String(token).Split('|'); + if(f.Length!=5 && f.Length!=9) throw new PlayerKVSchemaException(); + int c=f.Length==5 ? 2 : 5; + if(!bool.TryParse(f[c+2],out var isNew)) throw new PlayerKVSchemaException(); + return new PlayerKVFishValue(Int32(f[0]),Single(f[1]),Int32(f[c]),Int32(f[c+1]),isNew, + f.Length==9 ? new[] { f[2],f[3],f[4],f[8] } : Array.Empty()); + } + static PlayerKVRodAccessories Accessories(string text) + { + var o=Object(Json(text)); + return new PlayerKVRodAccessories(Integer(o["GloveID"]),IntegerMap(o["RodToSkin"]),IntegerList(o["GloveIds"]), + IntegerList(o["SkinIds"]),IntegerList(o["GloveNewIds"]),IntegerList(o["SkinNewIds"])); + } + static Dictionary Luck(string text,bool compact) + { + if(text==null) throw new PlayerKVSchemaException(); + Dictionary result; + if(compact) + { + var f=text.Split('|'); if(f.Length!=3) throw new PlayerKVSchemaException(); + result=new Dictionary(StringComparer.Ordinal) { ["InitFishLuck"]=Single(f[0]),["InitBotLuck"]=Single(f[1]),["BotCount"]=Single(f[2]) }; + } + else result=Object(Json(text)).Properties().ToDictionary(p=>p.Name,p=>Finite(p.Value),StringComparer.Ordinal); + if(result.TryGetValue("BotCount",out var count) && ((double)countint.MaxValue || Math.Truncate((double)count)!=count)) + throw new PlayerKVSchemaException(); + return result; + } + static float ConfigFinite(float value) + { if(float.IsNaN(value) || float.IsInfinity(value)) throw new PlayerKVSchemaException(PlayerKVSchemaError.UnsupportedSchema); return value; } + internal static float RoundFishWeight(float value) + { + float scaled=ConfigFinite(value*100f); + return (float)Math.Round((double)scaled,MidpointRounding.ToEven)/100f; + } + static PlayerKVFishValue DeriveFish(int id,int map,PlayerKVFishValue raw,PlayerKVSchemaSnapshot snapshot,out int cardId) + { + var fish=snapshot.Fish(id); + if(fish==null || fish.Id!=id || fish.Area!=map) throw new PlayerKVSchemaException(PlayerKVSchemaError.UnsupportedSchema); + cardId=fish.Card; var card=snapshot.Card(cardId); + int level=raw.Level,count=raw.Cards; float weight=raw.Weight; + if(fish.Quality<=5) + { + float limit=ConfigFinite(ConfigFinite(fish.Weight)*ConfigFinite(fish.Extra)); + limit=ConfigFinite(limit*ConfigFinite(1f+ConfigFinite(fish.Random))); + if(level>0) + { + if(card==null || card.Id!=cardId || level>card.Weights.Count) throw new PlayerKVSchemaException(PlayerKVSchemaError.UnsupportedSchema); + limit=ConfigFinite(limit*ConfigFinite(1f+ConfigFinite(card.Weights[level-1]))); + } + limit=RoundFishWeight(limit); + if(weight>limit) weight=limit; + } + if(level>0) + { + if(card==null || card.Id!=cardId || card.MaxLevel<1) throw new PlayerKVSchemaException(PlayerKVSchemaError.UnsupportedSchema); + level=Math.Min(level,card.MaxLevel); + if(level>card.Required.Count) throw new PlayerKVSchemaException(PlayerKVSchemaError.UnsupportedSchema); + count=Math.Max(count,card.Required[level-1]); + } + return new PlayerKVFishValue(level,weight,count,raw.Number,raw.New,raw.Compatibility); + } + internal static PlayerKVFishState DecodeFish(IReadOnlyDictionary raw,IReadOnlyDictionary modern, + PlayerKVSchemaSnapshot snapshot,PlayerKVSchemaDiagnostics errors) + { + int before=errors.TotalErrors; + Dictionary Read(string key,int field) => raw.TryGetValue(key,out var text) ? errors.Capture(3,field,()=>IntegerMap(Json(text))) : new Dictionary(); + var rods=Read("RodData",0); var levels=Read("RodLevelData",1); var swim=Read("FishSwimToMap",2); + var swimCount=Read("FishSwimToMapCount",3); var progress=Read("MapProgress",4); var honor=Read("HonorLevelData",5); + PlayerKVRodAccessories accessories=null; + if(raw.TryGetValue("InitRodAccessoriesData",out var acc)) accessories=errors.Capture(3,6,()=>Accessories(acc)); + var lucks=new List>(); + if(raw.TryGetValue("LuckDataDic",out var top)) lucks.Add(errors.Capture(3,7,()=>Luck(top,false))); + if(modern.TryGetValue("LDD",out var oldLuck)) lucks.Add(errors.Capture(3,8,()=>Luck(oldLuck,false))); + if(modern.TryGetValue("ld",out var newLuck)) lucks.Add(errors.Capture(3,9,()=>Luck(newLuck,true))); + for(int i=0;ilucks[j].TryGetValue(p.Key,out var v) && v!=p.Value)) + errors.Add(3,7,PlayerKVSchemaError.ConflictingRepresentations); + var luck=lucks.LastOrDefault() ?? new Dictionary(StringComparer.Ordinal); + var legacy=new Dictionary>(); var current=new Dictionary>(); + int field=100; + foreach(var pair in raw.OrderBy(p=>p.Key,StringComparer.Ordinal)) + { + var match=System.Text.RegularExpressions.Regex.Match(pair.Key,"^Map_(5_)?([0-9]+)$"); + if(!match.Success) continue; + errors.Capture(3,field++,() => + { + string suffix=match.Groups[2].Value; int map=Int32(suffix); + if(map<=0 || suffix!=map.ToString(Invariant)) throw new PlayerKVSchemaException(); + if(!snapshot.HasMap(map)) throw new PlayerKVSchemaException(PlayerKVSchemaError.UnsupportedSchema); + bool compact=match.Groups[1].Success; + var target=compact ? current : legacy; + target.Add(map,IntegerDictionary(Json(pair.Value),t=>FishValue(t,compact))); + return true; + }); + } + foreach(var map in legacy) + if(current.TryGetValue(map.Key,out var other) && map.Value.Any(p=>other.TryGetValue(p.Key,out var v) && !p.Value.Same(v))) + errors.Add(3,10,PlayerKVSchemaError.ConflictingRepresentations); + var selected=legacy.ToDictionary(p=>p.Key,p=>p.Value); + foreach(var map in current) selected[map.Key]=map.Value; + var maps=new Dictionary>(); var fishIndex=new Dictionary(); var cards=new Dictionary(); + // Conflicting raw representations cannot become valid after normalization. + if(errors.TotalErrors==before) + errors.Capture(3,11,() => + { + foreach(var map in selected.OrderBy(p=>p.Key)) + { + var values=new Dictionary(); + foreach(var p in map.Value.OrderBy(p=>p.Key)) + { + var value=DeriveFish(p.Key,map.Key,p.Value,snapshot,out var card); + if(fishIndex.ContainsKey(p.Key) || (cards.TryGetValue(card,out var n) && n!=value.Cards)) + throw new PlayerKVSchemaException(PlayerKVSchemaError.UnsupportedSchema); + values.Add(p.Key,value); fishIndex.Add(p.Key,value); cards[card]=value.Cards; + } + maps.Add(map.Key,values); + } + return true; + }); + return errors.TotalErrors==before ? new PlayerKVFishState(rods,levels,swim,swimCount,progress,honor,luck,accessories,selected,maps,fishIndex,cards) : null; + } + + internal static PlayerKVPlayerState DecodePlayer(IReadOnlyDictionary raw, + IReadOnlyDictionary modern,PlayerKVSchemaSnapshot snapshot,PlayerKVSchemaDiagnostics errors) + { + int before=errors.TotalErrors; + var core=DecodeCore(raw,modern,snapshot,errors); + var scores=new Dictionary(); var rods=new List(); int season=0; + string building="0"; bool appraise=raw.TryGetValue("IsOpenAppraise",out var appraiseText); + if(appraise && appraiseText==null) errors.Add(1,100,PlayerKVSchemaError.Malformed); + if(raw.TryGetValue("NewRodList",out var rodText)) rods=errors.Capture(1,101,()=>IntegerList(Json(rodText))); + if(raw.TryGetValue("SeasonId",out var seasonText)) season=errors.Capture(1,102,()=>Int32(seasonText)); + if(raw.TryGetValue("InfiniteBuildingData",out var buildingText)) + { + if(buildingText==null) errors.Add(1,103,PlayerKVSchemaError.Malformed); + else building=buildingText; + } + if(modern!=null && modern.TryGetValue("vipL",out var vipText)) + scores=errors.Capture(1,104,() => + { + if(vipText==null) throw new PlayerKVSchemaException(); + var result=new Dictionary(); + if(vipText.Length==0) return result; + foreach(var item in vipText.Split('|')) + { + var fields=item.Split(','); + if(fields.Length!=2) throw new PlayerKVSchemaException(); + int key=Int32(fields[0]); + if(result.ContainsKey(key)) throw new PlayerKVSchemaException(); + result.Add(key,Int32(fields[1])); + } + return result; + }); + return core!=null && modern!=null && errors.TotalErrors==before + ? new PlayerKVPlayerState(core,modern.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal),scores,rods,season,appraise,building) : null; + } + static PlayerKVPotionItem PotionItem(JToken token) + { + var obj=Object(token); + int type=Integer(obj["DataType"]); + var shelves=IntegerList(obj["ShelfTypeList"]); var grids=IntegerList(obj["GridStateList"]); + if(type<0 || type>2 || shelves.Any(n=>n<0 || n>1) || grids.Any(n=>n<0 || n>5)) throw new PlayerKVSchemaException(); + return new PlayerKVPotionItem(String(obj["name"]),Integer(obj["index"]),Boolean(obj["isOver"]),type, + IntegerList(obj["ColorTypeList"]),shelves,grids,IntegerList(obj["FromDisplayAreaIndexList"])); + } + internal static PlayerKVPotionState DecodePotion(IReadOnlyDictionary raw,PlayerKVSchemaDiagnostics errors) + { + if(!raw.TryGetValue("epdk",out var text)) return PlayerKVPotionState.Missing(); + return errors.Capture(25,0,() => + { + var obj=Object(Json(text)); + return new PlayerKVPotionState(true, + Integer(obj["EventID"]), + Integer(obj["PotionID"]), + Integer(obj["StageID"]), + Integer(obj["Stage1TotalLevel"]), + Integer(obj["Stage2TotalLevel"]), + Integer(obj["LevelID"]), + Integer(obj["LevelIndex"]), + Integer(obj["CorrectNum"]), + Integer(obj["SubmitNum"]), + Integer(obj["Token"]), + Integer(obj["ConsumedToken"]), + Integer(obj["Rule"]), + Integer(obj["PreEventID"]), + Integer(obj["PrePotionID"]), + Integer(obj["PreStageID"]), + Integer(obj["PreLevelID"]), + Integer(obj["Round"]), + Integer(obj["FirstPlayedEventId"]), + Boolean(obj["IsOver"]), + String(obj["Name"]), + Finite(obj["InflationRate"]), + PotionItem(obj["LevelTargetData"]), + PotionItem(obj["LevelDisplayData"]), + ArrayValues(obj["LevelProcessedList"],PotionItem)); + }); + } + static List CsvIntegers(string text) => text.Length==0 ? new List() : text.Split(',').Select(Int32).ToList(); + static Dictionary ColonIntegers(string text) + { + if(text==null) throw new PlayerKVSchemaException(); + var result=new Dictionary(); + if(text.Length==0) return result; + foreach(var part in text.Split(',')) + { + var fields=part.Split(':'); if(fields.Length!=2) throw new PlayerKVSchemaException(); + int key=Int32(fields[0]); if(result.ContainsKey(key)) throw new PlayerKVSchemaException(); + result.Add(key,Int32(fields[1])); + } + return result; + } + static DateTime Ticks(string text) + { + if(!long.TryParse(text,NumberStyles.AllowLeadingSign,Invariant,out var value) || value<0 || value>System.DateTime.MaxValue.Ticks) + throw new PlayerKVSchemaException(); + return new DateTime(value); + } + static PlayerKVBattlePassState CompactBattlePass(string text) + { + var pair=text?.Split('#'); + if(pair==null || pair.Length!=2) throw new PlayerKVSchemaException(); + var record=pair[0].Split('|'); var task=pair[1].Split('|'); + if(record.Length!=7 || task.Length!=5) throw new PlayerKVSchemaException(); + return new PlayerKVBattlePassState(new PlayerKVPassRecord(Int32(record[0]),Int32(record[1]),Int32(record[2]), + Int32(record[3]),Int32(record[4]),CsvIntegers(record[5]),CsvIntegers(record[6]),Array.Empty(),0), + new PlayerKVPassTask(ColonIntegers(task[0]),ColonIntegers(task[1]),CsvIntegers(task[2]),Ticks(task[3]),Ticks(task[4]))); + } + static PlayerKVPassRecord OldPassRecord(string text) + { + var obj=Object(Json(text)); + return new PlayerKVPassRecord(Integer(obj["ID"]),Integer(obj["Level"]),Integer(obj["Exp"]),Integer(obj["BuyVipToastIndex"]), + Integer(obj["IsUnlockVip"]),IntegerList(obj["NormalRewardGet"]),IntegerList(obj["VipRewardGet"]), + IntegerList(obj["LastDropIDs"]),Finite(obj["InflationRate"])); + } + static PlayerKVPassTask OldPassTask(string text,PlayerKVSchemaSnapshot snapshot) + { + var obj=Object(Json(text)); + return new PlayerKVPassTask(IntegerMap(obj["dic_dailyTasks"]),IntegerMap(obj["dic_weeklyTasks"]),IntegerList(obj["loginDays"]), + Date(String(obj["DailyTaskEndTime"]),snapshot),Date(String(obj["WeeklyTaskEndTime"]),snapshot)); + } + internal static PlayerKVBattlePassState DecodeBattlePass(IReadOnlyDictionary raw,PlayerKVSchemaSnapshot snapshot,PlayerKVSchemaDiagnostics errors) + { + int before=errors.TotalErrors; PlayerKVPassRecord oldRecord=null; PlayerKVPassTask oldTask=null; PlayerKVBattlePassState current=null; + if(raw.TryGetValue("BattlePassRecord",out var record)) oldRecord=errors.Capture(11,0,()=>OldPassRecord(record)); + if(raw.TryGetValue("BattleTask",out var task)) oldTask=errors.Capture(11,1,()=>OldPassTask(task,snapshot)); + if(raw.TryGetValue("BattlePassRecordTask",out var compact)) current=errors.Capture(11,2,()=>CompactBattlePass(compact)); + if(current!=null && oldRecord!=null && !oldRecord.CommonEquals(current.Record)) errors.Add(11,3,PlayerKVSchemaError.ConflictingRepresentations); + if(current!=null && oldTask!=null && !oldTask.Same(current.Task)) errors.Add(11,4,PlayerKVSchemaError.ConflictingRepresentations); + return errors.TotalErrors==before ? current ?? new PlayerKVBattlePassState(oldRecord ?? PlayerKVPassRecord.Empty(),oldTask ?? PlayerKVPassTask.Empty()) : null; + } + internal static PlayerKVCubeState DecodeCube(IReadOnlyDictionary raw,PlayerKVSchemaSnapshot snapshot,PlayerKVSchemaDiagnostics errors) + { + if(!raw.TryGetValue("EventCubeMeltData",out var text)) return PlayerKVCubeState.Missing(); + return errors.Capture(19,0,() => + { + var obj=Object(Json(text)); var size=Object(obj["BoardViewSize"]); + var items=ArrayValues(obj["CollectItems"],t => { var item=Object(t); return new PlayerKVCollectValue( + Integer(item["ItemId"]),Integer(item["Count"]),Integer(item["MaxCount"]),Integer(item["ItemTrueId"])); }); + var pieces=ArrayValues(obj["PieceDatas"],IntegerList); + return new PlayerKVCubeState(true,Integer(obj["TicketCount"]),Boolean(obj["FirstOpen"]),Boolean(obj["IsFinished"]), + Integer(obj["EventId"]),Boolean(obj["IsInGuide"]),Date(String(obj["StartTime"]),snapshot),Date(String(obj["EndTime"]),snapshot), + Integer(obj["CurrentStagetId"]),items,IntegerList(obj["BoardData"]),Integer(size["x"]),Integer(size["y"]), + IntegerList(obj["CollectRewardsId"]),Boolean(obj["IsFinalRewardGet"]),pieces,Integer(obj["Cycle"]),Finite(obj["InflationRate"])); + }); + } + internal static PlayerKVPinataState DecodePinata(IReadOnlyDictionary raw,PlayerKVSchemaDiagnostics errors) + { + if(!raw.TryGetValue("EventPinataPackData",out var text)) return new PlayerKVPinataState(0,0,0); + return errors.Capture(29,0,() => + { + var obj=Object(Json(text)); + return new PlayerKVPinataState(Integer(obj["EventID"]),Integer(obj["PinataMainID"]),Integer(obj["PurchasedCount"])); + }); + } + internal static PlayerKVChallengeState DecodeChallenge(IReadOnlyDictionary raw, + PlayerKVSchemaSnapshot snapshot,PlayerKVSchemaDiagnostics errors) + { + if(!raw.TryGetValue("FishingChallengeData",out var text)) return PlayerKVChallengeState.Missing(); + return errors.Capture(15,0,() => + { + // Json rejects root null before returning; recognize this one empty state before Object(Json(...)). + if (text != null && text.Trim(' ', '\t', '\r', '\n') == "null") + { + StrictJsonSyntax.Validate(text); + return PlayerKVChallengeState.ExplicitNull(); + } + var obj=Object(Json(text)); + return new PlayerKVChallengeState(true, + Boolean(obj["FirstOpen"]), + Integer(obj["EventId"]), + Integer(obj["RankTierLevel"]), + Integer(obj["Finished"]), + Integer(obj["RedirectID"]), + Integer(obj["Score"]), + Integer(obj["ProcessedScore"]), + Integer(obj["step"]), + Integer(obj["uiStep"]), + Integer(obj["index"]), + Date(String(obj["StartTime"]),snapshot), + Date(String(obj["EndTime"]),snapshot), + IntegerList(obj["eliminateNumber"]), + IntegerList(obj["robotAccountList"]), + Finite(obj["InflationRate"])); + }); + } + internal static PlayerKVPinballState DecodePinball(IReadOnlyDictionary raw, + PlayerKVSchemaSnapshot snapshot,PlayerKVSchemaDiagnostics errors) + { + if(!raw.TryGetValue("epbdk",out var text)) return PlayerKVPinballState.Missing(); + return errors.Capture(26,0,() => + { + var obj=Object(Json(text)); + return new PlayerKVPinballState(true, + Integer(obj["EventID"]), + Integer(obj["PinballMainID"]), + Integer(obj["SpinPoint"]), + Integer(obj["Scores"]), + Integer(obj["Multiple"]), + Integer(obj["Token"]), + Integer(obj["ConsumedToken"]), + Integer(obj["RewardIndex"]), + Finite(obj["Force"]), + Finite(obj["LuckyValue"]), + Finite(obj["InflationRate"])); + }); + } + internal static PlayerKVScratchState DecodeScratch(IReadOnlyDictionary raw, + PlayerKVSchemaSnapshot snapshot,PlayerKVSchemaDiagnostics errors) + { + if(!raw.TryGetValue("estdk",out var text)) return PlayerKVScratchState.Missing(); + return errors.Capture(27,0,() => + { + var obj=Object(Json(text)); + return new PlayerKVScratchState(true, + Integer(obj["EventID"]), + Integer(obj["ScratchMainID"]), + Integer(obj["ChainPackID"]), + Integer(obj["Token"]), + Integer(obj["ConsumedToken"]), + Integer(obj["RewardTaskConsumedToken"]), + Integer(obj["EventScratchRewardTaskID"]), + Integer(obj["EventScratchCardID"]), + Integer(obj["TotalConsumedToken"]), + Integer(obj["ChainPackProgress"]), + Integer(obj["magIndex"]), + Boolean(obj["IsOver"]), + Finite(obj["LuckyValue"]), + Finite(obj["InflationRate"]), + IntegerList(obj["StampRewardList"]), + IntegerDictionary(obj["StampRewardDict"],t=> { var item=Object(t); return new PlayerKVStampValue(Integer(item["EventScratchCardID"]),Integer(item["Count"])); })); + }); + } + internal static PlayerKVPartnerState DecodePartner(IReadOnlyDictionary raw, + PlayerKVSchemaSnapshot snapshot,PlayerKVSchemaDiagnostics errors) + { + if(!raw.TryGetValue("EventPartnerGatherData",out var text)) return PlayerKVPartnerState.Missing(); + return errors.Capture(18,0,()=> + { + var obj=Object(Json(text)); + int spin=Integer(obj["SpinState"]); + if(spin<0 || spin>2) throw new PlayerKVSchemaException(); + var bonus=Object(obj["PartnerBonus"]); + return new PlayerKVPartnerState(true,Boolean(obj["FirstOpen"]),Integer(obj["EventId"]), + Date(String(obj["StartTime"]),snapshot),Date(String(obj["EndTime"]),snapshot), + Integer(obj["CurrentPoints"]),Integer(obj["CurrentMiningProgress"]),Integer(obj["Token"]), + Integer(obj["InvitedCount"]),Integer(obj["TicketCount"]),ArrayValues(obj["CompletedNodesList"],IntegerList), + ArrayValues(obj["BuilderPartnerScore"],t=> { var score=Object(t); return (Integer(score["Item1"]),Integer(score["Item2"])); }), + Finite(bonus["Item1"]),Date(String(bonus["Item2"]),snapshot),Integer(obj["FreeSpinNum"]), + IntegerDictionary(obj["Bots"],t=> { var bot=Object(t); return new PlayerKVPartnerBot(String(bot["Id"]), + Integer(bot["ScorePlayer"]),Integer(bot["ScoreBot"]),Integer(bot["Grade"]),Integer(bot["TargetScore"]), + Date(String(bot["WakeStartTime"]),snapshot),Integer(bot["WakingSeconds"])); }), + Boolean(obj["IsRecvFinalReward"]),ArrayValues(obj["UIFinishedPartners"],Boolean),Boolean(obj["IsFinished"]), + spin,Finite(obj["InflationRate"]),Boolean(obj["IsEnableAutoMatch"]),Date(String(obj["LastAutoMatchTime"]),snapshot)); + }); + } + internal static PlayerKVOfferState DecodeOffer(IReadOnlyDictionary raw,bool disco, + PlayerKVSchemaSnapshot snapshot,PlayerKVSchemaDiagnostics errors) + { + if (!raw.TryGetValue(disco ? "OfferDiscoPartyManager" : "OfferChainsChestData",out var text)) return PlayerKVOfferState.Missing(); + return errors.Capture(disco ? 17 : 16,0,() => + { + var obj=Object(Json(text)); + if (!(obj["Items"] is JArray array)) throw new PlayerKVSchemaException(); + var items=new List(); + foreach(var token in array) + { + var item=Object(token); + int status=Integer(item["Status"]); + if (disco && (status<0 || status>2)) throw new PlayerKVSchemaException(); + items.Add(new PlayerKVOfferItem(Integer(item["Id"]),Integer(item["Item"]),Integer(item["Number"]),status)); + } + return new PlayerKVOfferState(true,Boolean(obj["FirstOpen"]),Integer(obj["EventId"]), + Date(String(obj["StartTime"]),snapshot),Date(String(obj["EndTime"]),snapshot), + Integer(obj["ItemId"]),Integer(obj["LotteryNum"]),items,IntegerList(obj["RewardList"]), + !disco && Boolean(obj["Finished"]),disco ? 0 : Integer(obj["ChestReward"])); + }); + } + internal static PlayerKVEventPackState DecodeEventPack(IReadOnlyDictionary raw, + PlayerKVSchemaSnapshot snapshot, PlayerKVSchemaDiagnostics errors) + { + if (!raw.TryGetValue("EventPackData",out var text)) return new PlayerKVEventPackState(0,0,0,0); + return errors.Capture(20,0,() => + { + var obj=Object(Json(text)); + int eventId=Integer(obj["currentEventID"]), count=Integer(obj["purchaseCount"]), + vip=Integer(obj["VIPLvlWhenEventActivate"]), redirect=Integer(obj["redirectID"]); + var config=snapshot.Event(eventId); + if (config != null) + { + // The legacy loader evaluates EndTime before any pack-table dereference. + if (config.EndTime == null) throw new PlayerKVSchemaException(PlayerKVSchemaError.UnsupportedSchema); + if (Date(config.EndTime,snapshot) > snapshot.NowUtc) + { + if (!snapshot.HasPackType(config.RedirectId,1)) throw new PlayerKVSchemaException(PlayerKVSchemaError.UnsupportedSchema); + if (redirect == 0) redirect=4054001; + if (!snapshot.HasPack(redirect)) throw new PlayerKVSchemaException(PlayerKVSchemaError.UnsupportedSchema); + } + } + return new PlayerKVEventPackState(eventId,count,vip,redirect); + }); + } + internal static PlayerKVCampState DecodeCamp(IReadOnlyDictionary raw,PlayerKVSchemaDiagnostics errors) + { + int before=errors.TotalErrors; + bool fix=false; + string skyscraper="0"; + var data=new Dictionary(StringComparer.Ordinal); + if (raw.TryGetValue("IsFix",out var fixText)) + fix=errors.Capture(7,0,() => { if (!bool.TryParse(fixText,out var value)) throw new PlayerKVSchemaException(); return value; }); + if (raw.TryGetValue("IsOpenSkyscraper",out var skyText)) + { + if (skyText != "0" && skyText != "1") errors.Add(7,1,PlayerKVSchemaError.Malformed); + else skyscraper=skyText; + } + if (raw.TryGetValue("CampDataDic",out var dataText)) + data=errors.Capture(7,2,() => Object(Json(dataText)).Properties().ToDictionary(p=>p.Name,p=>Integer(p.Value),StringComparer.Ordinal)); + return errors.TotalErrors == before ? new PlayerKVCampState(fix,skyscraper,data) : null; + } + internal static PlayerKVClubState DecodeClub(IReadOnlyDictionary raw, + PlayerKVSchemaSnapshot snapshot, PlayerKVSchemaDiagnostics errors) + { + int before = errors.TotalErrors; + DateTime? sign = null; + DateTime donateTime = default, requestEnd = default; + int count = 0; + if (raw.TryGetValue("ClubSignin",out var signText)) + sign = errors.Capture(14,0,() => signText == "" ? (DateTime?)null : Date(signText,snapshot)); + if (raw.TryGetValue("LureDonateData",out var donateText)) + errors.Capture(14,1,() => + { + var obj = Object(Json(donateText)); + donateTime = Date(String(obj["DonateCountTime"]),snapshot); + count = Integer(obj["ClubLureDonateCount"]); + requestEnd = Date(String(obj["ClubLureRequestCDEnd"]),snapshot); + return true; + }); + bool openRed = !raw.TryGetValue("IsOpenClubRed",out var redText); + if (!openRed && redText == null) errors.Add(14,2,PlayerKVSchemaError.Malformed); + return errors.TotalErrors == before ? new PlayerKVClubState(sign,donateTime,count,requestEnd,openRed) : null; + } + internal static PlayerKVProgressPackState DecodeProgressPack(IReadOnlyDictionary raw, + bool migration, PlayerKVSchemaDiagnostics errors) + { + if (!raw.TryGetValue(migration ? "ThanksGivingPackMigrationData" : "ThanksGivingPackData",out var text)) + return new PlayerKVProgressPackState(false,0,0,0,0); + return errors.Capture(28,migration ? 1 : 0,() => + { + var parts = text?.Split(','); + if (parts == null || parts.Length != 4) throw new PlayerKVSchemaException(); + return new PlayerKVProgressPackState(true,Int32(parts[0]),Int32(parts[1]),Int32(parts[2]),Int32(parts[3])); + }); + } + internal static Dictionary IntegerMap(JToken token) + { + var result = new Dictionary(); + foreach (var p in Object(token).Properties()) + { + int key = Int32(p.Name); + if (result.ContainsKey(key)) throw new PlayerKVSchemaException(); + result.Add(key, Integer(p.Value)); + } + return result; + } + static Dictionary IntegerPairs(string text) + { + if (text == null) throw new PlayerKVSchemaException(); + var result = new Dictionary(); + if (text.Length == 0) return result; + foreach (var item in text.Split(';')) + { + var fields = item.Split(','); + if (fields.Length != 2) throw new PlayerKVSchemaException(); + int key = Int32(fields[0]); + if (result.ContainsKey(key)) throw new PlayerKVSchemaException(); + result.Add(key, Int32(fields[1])); + } + return result; + } + internal static PlayerKVIntMapState DecodeFishingBox(IReadOnlyDictionary raw, + IReadOnlyDictionary modern, PlayerKVSchemaDiagnostics errors) + { + if (modern == null) return null; + int before = errors.TotalErrors; + Dictionary old = null, current = null; + if (raw.TryGetValue("FishingBoxData",out var legacy)) + old = errors.Capture(9,0,()=>IntegerMap(Object(Json(legacy))["FishingBoxOpenNum"])); + if (modern.TryGetValue("bd",out var compact)) + current = errors.Capture(9,1,()=>IntegerPairs(compact)); + if (old != null && current != null) + foreach (var item in old.OrderBy(p=>p.Key)) + if (current.TryGetValue(item.Key,out var value) && value != item.Value) + errors.Add(9,2,PlayerKVSchemaError.ConflictingRepresentations); + return errors.TotalErrors == before ? new PlayerKVIntMapState(current ?? old ?? new Dictionary()) : null; + } + static Dictionary GuideRecords(string text, bool compact) + { + var result = new Dictionary(StringComparer.Ordinal); + if (compact) + { + if (text == null) throw new PlayerKVSchemaException(); + if (text.Length == 0) return result; + foreach (var item in text.Split(';')) + { + var parts = item.Split('|'); + if (parts.Length != 3 || (parts[2] != "0" && parts[2] != "1") || result.ContainsKey(parts[0])) + throw new PlayerKVSchemaException(); + result.Add(parts[0], new PlayerKVGuideValue(Int32(parts[1]), parts[2] == "1", parts[0])); + } + } + else + foreach (var p in Object(Json(text)).Properties()) + { + var value = Object(p.Value); + result.Add(p.Name, new PlayerKVGuideValue(Integer(value["guideSubIndex"]),Boolean(value["isFinish"]),String(value["groupName"]))); + } + return result; + } + internal static PlayerKVGuideState DecodeGuides(IReadOnlyDictionary raw, PlayerKVSchemaDiagnostics errors) + { + int before = errors.TotalErrors; + Dictionary old = null, current = null; + if (raw.TryGetValue("GuideSaveData",out var legacy)) old = errors.Capture(10,0,()=>GuideRecords(legacy,false)); + if (raw.TryGetValue("GuideSaveDataNew",out var compact)) current = errors.Capture(10,1,()=>GuideRecords(compact,true)); + if (old != null && current != null) + foreach (var item in old.OrderBy(p=>p.Key,StringComparer.Ordinal)) + if (current.TryGetValue(item.Key,out var value) && !value.Same(item.Value)) + errors.Add(10,2,PlayerKVSchemaError.ConflictingRepresentations); + return errors.TotalErrors == before ? new PlayerKVGuideState(current ?? old ?? new Dictionary()) : null; + } + internal static PlayerKVMonthlySignState DecodeMonthlySign(IReadOnlyDictionary raw, PlayerKVSchemaDiagnostics errors) + { + if (!raw.TryGetValue("MonthlySignEvent",out var text)) return new PlayerKVMonthlySignState(false,0,0,0,0,null); + return errors.Capture(12,0,() => + { + var obj = Object(Json(text)); + var daily = Object(obj["dailySignEvent"]); + return new PlayerKVMonthlySignState(true,Integer(obj["index"]),Integer(daily["ID"]), + Integer(daily["index"]),Integer(daily["lastID"]),String(daily["time"])); + }); + } + internal static PlayerKVFootPrintState DecodeFootPrint(IReadOnlyDictionary raw, PlayerKVSchemaDiagnostics errors) => + errors.Capture(23, 0, () => new PlayerKVFootPrintState(raw.TryGetValue("FootPrint", out var text) ? UInt64(text) : 0)); + } + + // Validate RFC JSON syntax before Json.NET, which also accepts single quotes, unquoted names and trailing commas. + internal sealed class StrictJsonSyntax + { + readonly string text; + int position; + StrictJsonSyntax(string text) { this.text = text; } + internal static void Validate(string text) + { + var parser = new StrictJsonSyntax(text); parser.Value(0); parser.Space(); + if (parser.position != text.Length) throw new PlayerKVSchemaException(); + } + char Peek => position < text.Length ? text[position] : '\0'; + static bool Digit(char c) => c >= '0' && c <= '9'; + void Space() { while (Peek == ' ' || Peek == '\t' || Peek == '\r' || Peek == '\n') position++; } + void Require(char c) { if (Peek != c) throw new PlayerKVSchemaException(); position++; } + void Value(int depth) + { + if (depth > 64) throw new PlayerKVSchemaException(); + Space(); + if (Peek == '{' || Peek == '[') + { + bool obj = Peek == '{'; position++; Space(); char end = obj ? '}' : ']'; + if (Peek == end) { position++; return; } + while (true) + { + if (obj) { Quoted(); Space(); Require(':'); } + Value(depth + 1); Space(); + if (Peek == end) { position++; return; } + Require(','); Space(); + } + } + else if (Peek == '"') Quoted(); + else if (Peek == 't') Literal("true"); + else if (Peek == 'f') Literal("false"); + else if (Peek == 'n') Literal("null"); + else + { + if (Peek == '-') position++; + if (Peek == '0') position++; + else { if (Peek < '1' || Peek > '9') throw new PlayerKVSchemaException(); while (Digit(Peek)) position++; } + if (Peek == '.') { position++; Digits(); } + if (Peek == 'e' || Peek == 'E') { position++; if (Peek == '+' || Peek == '-') position++; Digits(); } + } + } + void Digits() { if (!Digit(Peek)) throw new PlayerKVSchemaException(); while (Digit(Peek)) position++; } + void Literal(string value) + { + foreach (char c in value) Require(c); + } + void Quoted() + { + Require('"'); + while (true) + { + char c = Peek; + if (c == '"') { position++; return; } + if (c < ' ') throw new PlayerKVSchemaException(); + position++; + if (c != '\\') continue; + c = Peek; position++; + if (c == 'u') + { + for (int i = 0; i < 4; i++) + { + char h = Peek; + if (!Digit(h) && !(h >= 'a' && h <= 'f') && !(h >= 'A' && h <= 'F')) throw new PlayerKVSchemaException(); + position++; + } + } + else if (c != '"' && c != '\\' && c != '/' && c != 'b' && c != 'f' && c != 'n' && c != 'r' && c != 't') + throw new PlayerKVSchemaException(); + } + } + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveSchema.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveSchema.cs.meta new file mode 100644 index 000000000..bacabfec0 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveSchema.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3c49515cb90840a189772d23ad69fd2b diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSessionOwner.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSessionOwner.cs new file mode 100644 index 000000000..9309192f1 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSessionOwner.cs @@ -0,0 +1,341 @@ +using System; +using System.Globalization; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using game.Account; + +namespace game.PlayerDataStorage +{ + public enum PlayerKVSessionOpenError + { + None, Validation, Unauthenticated, Busy, RecoveryRequired, StorageError, + ReadFailed, Unauthorized, Stale, Cancelled + } + + public sealed class PlayerKVSessionOpenResult + { + public bool Success => Error == PlayerKVSessionOpenError.None; + public PlayerKVSessionOpenError Error { get; } + public PlayerKVError TransportError { get; } + public PlayerKVSessionOwner Owner { get; } + internal PlayerKVSessionOpenResult(PlayerKVSessionOpenError error, + PlayerKVError transportError = PlayerKVError.None, PlayerKVSessionOwner owner = null) + { Error = error; TransportError = transportError; Owner = owner; } + } + + /// Owns one immutable account/epoch/cache/transport tuple. + public sealed class PlayerKVSessionOwner : IDisposable + { + sealed class OperationLease : IDisposable + { + PlayerKVSessionOwner owner; + internal OperationLease(PlayerKVSessionOwner owner) { this.owner = owner; } + public void Dispose() { Interlocked.Exchange(ref owner, null)?.ExitOperation(); } + } + + readonly object admission = new object(); + readonly S101AccountSession session; + readonly CookieContainer cookies; + readonly CancellationTokenSource lifetime; + readonly Func isCurrent; + readonly PlayerKVSyncState state; + readonly PlayerKVSyncCoordinator coordinator; + readonly TaskCompletionSource drained = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + readonly string ownerId = Guid.NewGuid().ToString("N"); + int activeOperations; + bool closing, closed; + TaskCompletionSource closeCompletion; + + public string OwnerInstanceId => ownerId; + public Uri ApiBaseUri { get; } + public string PlayerId { get; } + public long Epoch { get; } + public PlayerKVSyncStatus Status => state.Status; + + PlayerKVSessionOwner(S101AccountSession session, CookieContainer cookies, Uri api, string playerId, + long epoch, Func isCurrent, CancellationTokenSource lifetime, + PlayerKVSyncState state, HttpClient http) + { + this.session = session; this.cookies = cookies; ApiBaseUri = api; PlayerId = playerId; + Epoch = epoch; this.isCurrent = isCurrent; this.lifetime = lifetime; this.state = state; + coordinator = new PlayerKVSyncCoordinator(state, new PlayerKVClient(http, api), http, + ownerId, epoch, IsCapturedOwnerCurrent, lifetime.Token); + } + + public static Task CreateAsync(S101AccountSession session, string cacheRoot, + string expectedPlayerId, Uri expectedApiBaseUri, long epoch, Func isCurrent, + CancellationToken lifetimeToken = default) => + CreateAsync(session, cacheRoot, expectedPlayerId, expectedApiBaseUri, epoch, isCurrent, + lifetimeToken, jar => new HttpClientHandler + { CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }); + + internal static async Task CreateAsync(S101AccountSession session, string cacheRoot, + string expectedPlayerId, Uri expectedApiBaseUri, long epoch, Func isCurrent, + CancellationToken lifetimeToken, Func handlerFactory, + TimeSpan? transportTimeout = null) + { + if (session == null || expectedApiBaseUri == null || isCurrent == null || handlerFactory == null || epoch < 0 || + !long.TryParse(expectedPlayerId, NumberStyles.None, CultureInfo.InvariantCulture, out var numericId) || numericId <= 0) + return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Validation); + var playerId = numericId.ToString(CultureInfo.InvariantCulture); + Uri expectedApi; + try { expectedApi = NormalizeApi(expectedApiBaseUri); } + catch (ArgumentException) { return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Validation); } + if (lifetimeToken.IsCancellationRequested) + return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Cancelled, PlayerKVError.Cancelled); + if (!session.IsAuthenticated || session.Cookies == null) + return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Unauthenticated); + var capturedCookies = session.Cookies; + var capturedApi = NormalizeApi(session.ApiBaseUri); + if (session.PlayerId != playerId || capturedApi != expectedApi || !isCurrent(epoch)) + return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Stale); + + var opened = PlayerKVSyncState.Open(cacheRoot, capturedApi, playerId); + if (!opened.Success) return new PlayerKVSessionOpenResult(MapOpen(opened.Error)); + HttpClient http = null; + CancellationTokenSource lifetime = null; + PlayerKVSessionOwner owner = null; + try + { + var handler = handlerFactory(capturedCookies); + if (handler == null) throw new ArgumentException("Transport factory returned null."); + http = new HttpClient(handler) { Timeout = transportTimeout ?? TimeSpan.FromSeconds(15) }; + lifetime = CancellationTokenSource.CreateLinkedTokenSource(lifetimeToken); + owner = new PlayerKVSessionOwner(session, capturedCookies, capturedApi, playerId, epoch, + isCurrent, lifetime, opened.State, http); + http = null; lifetime = null; + PlayerKVResult read; + using (var lease = owner.TryEnter()) + { + if (lease == null) return await owner.FailCreate(PlayerKVSessionOpenError.Cancelled); + read = await owner.coordinator.InitializeAsync(); + } + if (!read.Success) + return await owner.FailCreate(read.Error == PlayerKVError.Unauthorized + ? PlayerKVSessionOpenError.Unauthorized + : read.Error == PlayerKVError.Cancelled ? PlayerKVSessionOpenError.Cancelled + : PlayerKVSessionOpenError.ReadFailed, read.Error); + if (!owner.IsCapturedOwnerCurrent()) + return await owner.FailCreate(PlayerKVSessionOpenError.Stale, PlayerKVError.Cancelled); + return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.None, owner: owner); + } + catch (OperationCanceledException) + { + if (owner != null) return await owner.FailCreate(PlayerKVSessionOpenError.Cancelled, PlayerKVError.Cancelled); + opened.State.Dispose(); + return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Cancelled, PlayerKVError.Cancelled); + } + catch (Exception) + { + if (owner != null) return await owner.FailCreate(PlayerKVSessionOpenError.Validation, PlayerKVError.Protocol); + http?.Dispose(); lifetime?.Dispose(); opened.State.Dispose(); + return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Validation, PlayerKVError.Protocol); + } + } + + /// Reads the existing state under an operation lease; does not open another cache. + public PlayerKVLocalReadResult ReadLocalSnapshot() + { + using (var lease = TryEnter()) + { + if (lease == null) return ReadFailure(PlayerKVLocalReadError.StaleOrClosing); + try + { + var data = state.ReadLocal(); + lock (admission) + { + if (!IsCapturedOwnerCurrent() || closing || closed) + return ReadFailure(PlayerKVLocalReadError.StaleOrClosing); + return new PlayerKVLocalReadResult(PlayerKVLocalReadError.None, + ownerId, PlayerId, Epoch, data); + } + } + catch (InvalidOperationException) + { + lock (admission) + return ReadFailure(!IsCapturedOwnerCurrent() || closing || closed + ? PlayerKVLocalReadError.StaleOrClosing : PlayerKVLocalReadError.NotReady); + } + } + } + + PlayerKVLocalReadResult ReadFailure(PlayerKVLocalReadError error) => + new PlayerKVLocalReadResult(error, ownerId, PlayerId, Epoch); + public PlayerKVEditResult Update(string key, string value, bool remoteStore = true) + { + using (var lease = TryEnter()) + return lease == null ? ClosedEdit() : coordinator.Update(key, value, remoteStore); + } + + public PlayerKVEditResult Delete(string key) + { + using (var lease = TryEnter()) + return lease == null ? ClosedEdit() : coordinator.Delete(key); + } + + public Task FlushAsync(CancellationToken waitToken = default) + { + var lease = TryEnter(); + if (lease == null) return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, + PlayerKVError.Cancelled, state.Status)); + return WaitForFlushAsync(RunFlushAsync(lease), waitToken); + } + + /// Runs a complete recovery Get on the same serialized channel and fixed owner transport. + public Task RefreshAsync(CancellationToken waitToken = default) + { + var lease = TryEnter(); + if (lease == null) return Task.FromResult(new PlayerKVResult(PlayerKVError.Cancelled)); + var shared = RunRefreshAsync(lease); + return WaitForRefreshAsync(shared, waitToken); + } + + public Task WaitForTicketAsync(PlayerKVSyncTicket ticket, + CancellationToken cancellationToken = default) => coordinator.WaitForTicketAsync(ticket, cancellationToken); + + // Watermark capture must happen inside the lease scope: a failure there still + // returns the operation lease, or CloseAsync would wait for this operation forever. + async Task RunFlushAsync(OperationLease lease) + { + using (lease) + { + try + { + var watermark = coordinator.CaptureWatermark(out var targetSequences); + return await coordinator.FlushThroughAsync(watermark, targetSequences); + } + catch (Exception) { return new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Protocol, state.Status); } + } + } + + async Task RunRefreshAsync(OperationLease lease) + { + using (lease) + { + try { return await coordinator.InitializeAsync(); } + catch (OperationCanceledException) { return new PlayerKVResult(PlayerKVError.Cancelled); } + catch (ObjectDisposedException) { return new PlayerKVResult(PlayerKVError.Cancelled); } + catch (HttpRequestException) { return new PlayerKVResult(PlayerKVError.Network); } + catch (IOException) { return new PlayerKVResult(PlayerKVError.Network); } + catch (Exception) { return new PlayerKVResult(PlayerKVError.Protocol); } + } + } + + static async Task WaitForRefreshAsync(Task shared, CancellationToken token) + { + if (!token.CanBeCanceled) return await shared; + if (token.IsCancellationRequested) return new PlayerKVResult(PlayerKVError.Cancelled); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (token.Register(() => cancelled.TrySetResult(true))) + { + if (await Task.WhenAny(shared, cancelled.Task) == shared) return await shared; + } + return new PlayerKVResult(PlayerKVError.Cancelled); + } + + async Task WaitForFlushAsync(Task shared, CancellationToken token) + { + if (!token.CanBeCanceled) return await shared; + if (token.IsCancellationRequested) return new PlayerKVFlushResult(0, 0, 1, 0, + PlayerKVError.Cancelled, state.Status); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (token.Register(() => cancelled.TrySetResult(true))) + { + if (await Task.WhenAny(shared, cancelled.Task) == shared) return await shared; + } + return new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, state.Status); + } + + OperationLease TryEnter() + { + lock (admission) + { + if (closing || closed || !IsCapturedOwnerCurrent()) return null; + activeOperations++; + return new OperationLease(this); + } + } + + void ExitOperation() + { + lock (admission) + { + activeOperations--; + if (closing && activeOperations == 0) drained.TrySetResult(true); + } + } + + public Task CloseAsync() + { + TaskCompletionSource completion; + lock (admission) + { + if (closeCompletion != null) return closeCompletion.Task; + closing = true; + completion = closeCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (activeOperations == 0) drained.TrySetResult(true); + } + _ = CloseCoreAsync(completion); + return completion.Task; + } + + async Task CloseCoreAsync(TaskCompletionSource completion) + { + try + { + lifetime.Cancel(); + coordinator.CloseTickets(); + await drained.Task; + coordinator.Dispose(); + state.Dispose(); + lifetime.Dispose(); + lock (admission) closed = true; + completion.TrySetResult(true); + } + catch (Exception ex) { completion.TrySetException(ex); } + } + + async Task FailCreate(PlayerKVSessionOpenError error, + PlayerKVError transport = PlayerKVError.None) + { + await CloseAsync(); + return new PlayerKVSessionOpenResult(error, transport); + } + + bool IsCapturedOwnerCurrent() + { + if (lifetime.IsCancellationRequested || !isCurrent(Epoch)) return false; + return session.IsAuthenticated && ReferenceEquals(session.Cookies, cookies) && + session.PlayerId == PlayerId && NormalizeApi(session.ApiBaseUri) == ApiBaseUri; + } + + static Uri NormalizeApi(Uri api) + { + if (api == null || !api.IsAbsoluteUri || (api.Scheme != "http" && api.Scheme != "https") || + api.UserInfo.Length != 0 || api.Query.Length != 0 || api.Fragment.Length != 0) + throw new ArgumentException("Invalid API base URI."); + return new Uri(api.AbsoluteUri.TrimEnd('/') + "/"); + } + + static PlayerKVSessionOpenError MapOpen(PlayerKVOpenError error) + { + if (error == PlayerKVOpenError.Busy) return PlayerKVSessionOpenError.Busy; + if (error == PlayerKVOpenError.RecoveryRequired) return PlayerKVSessionOpenError.RecoveryRequired; + if (error == PlayerKVOpenError.StorageError) return PlayerKVSessionOpenError.StorageError; + return PlayerKVSessionOpenError.Validation; + } + + PlayerKVEditResult ClosedEdit() => new PlayerKVEditResult( + new PlayerKVStateResult(PlayerKVChangeError.Blocked, revision: state.Inspect().Revision)); + + public void Dispose() + { + _ = CloseAsync().ContinueWith(task => { var observed = task.Exception; }, + CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); + } + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSessionOwner.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSessionOwner.cs.meta new file mode 100644 index 000000000..2053707e2 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSessionOwner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e46e2622d9b94d61aeb796d53cea5cdc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVShutdownRegistry.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVShutdownRegistry.cs new file mode 100644 index 000000000..11559861e --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVShutdownRegistry.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace game.PlayerDataStorage +{ + internal enum PlayerKVShutdownStatus { Ready, Pending, Failed, Cancelled, Input } + + /// Process-lifetime barrier. Faulted and unfinished work cannot be reset by replacing a scene/container. + internal sealed class PlayerKVShutdownRegistry + { + internal static PlayerKVShutdownRegistry Process { get; } = new PlayerKVShutdownRegistry(); + readonly object gate = new object(); + readonly Dictionary pending = new Dictionary(); + TaskCompletionSource changed = Signal(); + + internal void Register(Task task, PlayerKVLoginContext owner = null) + { + if (task == null) throw new ArgumentNullException(nameof(task)); + lock (gate) + { + if (!pending.ContainsKey(task) || owner != null) pending[task] = owner; + NotifyChanged(); + } + _ = task.ContinueWith(t => + { + if (t.IsFaulted) { _ = t.Exception; } + lock (gate) + { + if (t.Status == TaskStatus.RanToCompletion) pending.Remove(t); + NotifyChanged(); + } + // Cancelled work does not prove resource cleanup; retain it as a failure barrier. + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + + internal PlayerKVShutdownStatus Status + { + get + { + lock (gate) + { + if (pending.Keys.Any(t => t.IsFaulted || t.IsCanceled)) return PlayerKVShutdownStatus.Failed; + return pending.Keys.Any(t => !t.IsCompleted) ? PlayerKVShutdownStatus.Pending : PlayerKVShutdownStatus.Ready; + } + } + } + + // Injection is an observation clock only, never a force-close or permission to reopen. + internal async Task ObserveAsync(int timeoutMs, CancellationToken token = default, + Func delay = null) + { + if (timeoutMs < 1000 || timeoutMs > 15000) return PlayerKVShutdownStatus.Input; + if (token.IsCancellationRequested) return PlayerKVShutdownStatus.Cancelled; + using (var clock = CancellationTokenSource.CreateLinkedTokenSource(token)) + { + var deadline = (delay ?? Task.Delay)(timeoutMs, clock.Token); + try + { + while (true) + { + if (token.IsCancellationRequested) return PlayerKVShutdownStatus.Cancelled; + Task notification; + lock (gate) + { + var status = Status; + if (status != PlayerKVShutdownStatus.Pending) return status; + notification = changed.Task; + } + if (await Task.WhenAny(notification, deadline).ConfigureAwait(false) == deadline) + { + if (token.IsCancellationRequested) return PlayerKVShutdownStatus.Cancelled; + return Status; + } + // Recheck the live collection; another Root may have registered work during the await. + } + } + finally { clock.Cancel(); } + } + } + + void NotifyChanged() + { + var previous = changed; changed = Signal(); previous.TrySetResult(true); + } + + static TaskCompletionSource Signal() => + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVShutdownRegistry.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVShutdownRegistry.cs.meta new file mode 100644 index 000000000..cdaecbf5b --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVShutdownRegistry.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c524f69ad00844b4a1d0456b4d3c43ae diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs new file mode 100644 index 000000000..e9b0e25b3 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs @@ -0,0 +1,339 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace game.PlayerDataStorage +{ + public enum PlayerKVTicketStatus { Pending, Confirmed, Superseded, Blocked, Unknown, Cancelled } + + public sealed class PlayerKVSyncTicket + { + public string OwnerInstanceId { get; } + public long Epoch { get; } + public string Key { get; } + public long Sequence { get; } + internal PlayerKVSyncTicket(string ownerId, long epoch, string key, long sequence) + { OwnerInstanceId = ownerId; Epoch = epoch; Key = key; Sequence = sequence; } + } + + public sealed class PlayerKVTicketResult + { + public PlayerKVTicketStatus Status { get; } + public PlayerKVError TransportError { get; } + public PlayerKVSyncStatus CacheStatus { get; } + internal PlayerKVTicketResult(PlayerKVTicketStatus status, PlayerKVError transportError, PlayerKVSyncStatus cacheStatus) + { Status = status; TransportError = transportError; CacheStatus = cacheStatus; } + } + + public sealed class PlayerKVEditResult + { + public bool Accepted => StateResult != null && StateResult.Success; + public PlayerKVStateResult StateResult { get; } + public PlayerKVSyncTicket Ticket { get; } + internal PlayerKVEditResult(PlayerKVStateResult result, PlayerKVSyncTicket ticket = null) + { StateResult = result; Ticket = ticket; } + } + + public sealed class PlayerKVFlushResult + { + public int Confirmed { get; } + public int Superseded { get; } + public int Blocked { get; } + public int Unknown { get; } + public PlayerKVError TransportError { get; } + public PlayerKVSyncStatus CacheStatus { get; } + internal PlayerKVFlushResult(int confirmed, int superseded, int blocked, int unknown, + PlayerKVError transportError, PlayerKVSyncStatus cacheStatus) + { + Confirmed = confirmed; Superseded = superseded; Blocked = blocked; Unknown = unknown; + TransportError = transportError; CacheStatus = cacheStatus; + } + } + + /// One fixed owner's serialized Get/Set/Delete channel. No timers or automatic retries. + public sealed class PlayerKVSyncCoordinator : IDisposable + { + sealed class TicketRecord + { + internal readonly PlayerKVSyncTicket Ticket; + internal readonly TaskCompletionSource Completion = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + internal bool Dispatched, ClaimedByFlush; + internal TicketRecord(PlayerKVSyncTicket ticket) { Ticket = ticket; } + } + + readonly object ticketGate = new object(); + readonly Dictionary tickets = new Dictionary(); + readonly SemaphoreSlim dispatch = new SemaphoreSlim(1, 1); + readonly PlayerKVSyncState state; + readonly PlayerKVClient client; + readonly System.Net.Http.HttpClient http; + readonly string ownerId; + readonly long epoch; + readonly Func isOwnerCurrent; + readonly CancellationToken ownerToken; + bool disposed; + + internal PlayerKVSyncCoordinator(PlayerKVSyncState state, PlayerKVClient client, + System.Net.Http.HttpClient http, string ownerId, long epoch, Func isOwnerCurrent, + CancellationToken ownerToken) + { + this.state = state; this.client = client; this.http = http; this.ownerId = ownerId; + this.epoch = epoch; this.isOwnerCurrent = isOwnerCurrent; this.ownerToken = ownerToken; + } + + internal async Task InitializeAsync() + { + await dispatch.WaitAsync(ownerToken); + try + { + if (!Current()) return CancelledResult(); + var result = await client.GetAsync(null, ownerToken); + if (!Current()) return CancelledResult(); + if (result.Success) + { + var reconciled = state.Reconcile(result.Data); + return reconciled.Success ? result : new PlayerKVResult(PlayerKVError.Protocol); + } + state.RecordReadFailure(result.Error == PlayerKVError.Unauthorized); + return result; + } + finally { dispatch.Release(); } + } + + internal PlayerKVEditResult Update(string key, string value, bool remoteStore) + { + var result = state.Update(key, value, remoteStore); + if (!result.Success || !remoteStore) return new PlayerKVEditResult(result); + var ticket = new PlayerKVSyncTicket(ownerId, epoch, key, result.AttemptSequence); + lock (ticketGate) + { + SupersedeUnsentForKey(key, result.AttemptSequence); + tickets.Add(ticket.Sequence, new TicketRecord(ticket)); + } + return new PlayerKVEditResult(result, ticket); + } + + internal PlayerKVEditResult Delete(string key) + { + var result = state.Delete(key); + if (!result.Success) return new PlayerKVEditResult(result); + var ticket = new PlayerKVSyncTicket(ownerId, epoch, key, result.AttemptSequence); + lock (ticketGate) + { + SupersedeUnsentForKey(key, result.AttemptSequence); + tickets.Add(ticket.Sequence, new TicketRecord(ticket)); + } + return new PlayerKVEditResult(result, ticket); + } + + internal long CaptureWatermark(out long[] targetSequences) + { + var next = state.Inspect().NextSequence; + var watermark = next <= 1 ? 0 : next - 1; + lock (ticketGate) + { + targetSequences = tickets.Values.Where(record => record.Ticket.Sequence <= watermark && !record.ClaimedByFlush) + .Select(record => { record.ClaimedByFlush = true; return record.Ticket.Sequence; }).ToArray(); + } + return watermark; + } + + internal async Task FlushThroughAsync(long watermark, long[] targetSequences) + { + PlayerKVError finalError = PlayerKVError.None; + var targetSet = new HashSet(targetSequences ?? Array.Empty()); + var recoveredConfirmed = 0; + var recoveredBlocked = 0; + var recoveredUnknown = 0; + var acquired = false; + try + { + await dispatch.WaitAsync(ownerToken); + acquired = true; + while (Current()) + { + MarkSuperseded(watermark); + var begin = state.BeginBatch(watermark, out var batch); + if (!begin.Success) + { + recoveredBlocked += state.Inspect().Pending.Values.Count(item => + item.Sequence <= watermark && !targetSet.Contains(item.Sequence)); + MarkEligibleBlocked(watermark, PlayerKVError.None); + break; + } + if (batch == null) break; + MarkDispatched(batch); + PlayerKVResult result; + if (batch.IsDelete) + result = await client.DeleteAsync(batch.Items.Select(item => item.Key), ownerToken); + else + result = await client.SetAsync(batch.Items.ToDictionary(item => item.Key, item => item.Target.Value, + StringComparer.Ordinal), ownerToken); + + if (!Current() && result.Success) + result = new PlayerKVResult(PlayerKVError.Cancelled, mayHaveCommitted: true); + + if (result.Success) + { + var confirmed = state.ConfirmBatch(batch.Id); + if (!confirmed.Success) + { + CompleteBatch(batch, PlayerKVTicketStatus.Unknown, PlayerKVError.None); + recoveredUnknown += batch.Items.Count(item => !targetSet.Contains(item.Sequence)); + finalError = PlayerKVError.None; + break; + } + CompleteBatch(batch, PlayerKVTicketStatus.Confirmed, PlayerKVError.None); + recoveredConfirmed += batch.Items.Count(item => !targetSet.Contains(item.Sequence)); + continue; + } + + finalError = result.Error; + var failed = state.FailBatch(batch.Id, result.MayHaveCommitted, + result.Error == PlayerKVError.Unauthorized); + var status = result.MayHaveCommitted ? PlayerKVTicketStatus.Unknown : + ownerToken.IsCancellationRequested ? PlayerKVTicketStatus.Cancelled : PlayerKVTicketStatus.Blocked; + CompleteBatch(batch, status, result.Error); + if (status == PlayerKVTicketStatus.Unknown) + recoveredUnknown += batch.Items.Count(item => !targetSet.Contains(item.Sequence)); + else + recoveredBlocked += batch.Items.Count(item => !targetSet.Contains(item.Sequence)); + if (!failed.Success && status != PlayerKVTicketStatus.Unknown) + MarkEligibleBlocked(watermark, result.Error); + break; + } + if (!Current()) + { + if (finalError == PlayerKVError.None) finalError = PlayerKVError.Cancelled; + MarkEligibleOnClose(watermark); + } + } + catch (OperationCanceledException) + { + finalError = PlayerKVError.Cancelled; + MarkEligibleOnClose(watermark); + } + catch (Exception) + { + finalError = PlayerKVError.Protocol; + MarkEligibleBlocked(watermark, finalError); + } + finally { if (acquired) dispatch.Release(); } + return Summary(targetSequences ?? Array.Empty(), finalError, + recoveredConfirmed, recoveredBlocked, recoveredUnknown); + } + + internal async Task WaitForTicketAsync(PlayerKVSyncTicket ticket, CancellationToken token) + { + if (ticket == null || ticket.OwnerInstanceId != ownerId || ticket.Epoch != epoch) + return new PlayerKVTicketResult(PlayerKVTicketStatus.Blocked, PlayerKVError.Validation, state.Status); + TicketRecord record; + lock (ticketGate) + if (!tickets.TryGetValue(ticket.Sequence, out record) || record.Ticket.Key != ticket.Key) + return new PlayerKVTicketResult(PlayerKVTicketStatus.Blocked, PlayerKVError.Validation, state.Status); + if (!token.CanBeCanceled) return await record.Completion.Task; + if (token.IsCancellationRequested) + return new PlayerKVTicketResult(PlayerKVTicketStatus.Cancelled, PlayerKVError.Cancelled, state.Status); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (token.Register(() => cancelled.TrySetResult(true))) + { + if (await Task.WhenAny(record.Completion.Task, cancelled.Task) == record.Completion.Task) + return await record.Completion.Task; + } + return new PlayerKVTicketResult(PlayerKVTicketStatus.Cancelled, PlayerKVError.Cancelled, state.Status); + } + + internal void CloseTickets() + { + lock (ticketGate) + foreach (var record in tickets.Values) + if (!record.Completion.Task.IsCompleted) + Complete(record, record.Dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Cancelled, + PlayerKVError.Cancelled); + } + + void MarkDispatched(PlayerKVBatch batch) + { + lock (ticketGate) + foreach (var item in batch.Items) + if (tickets.TryGetValue(item.Sequence, out var record)) record.Dispatched = true; + } + + void CompleteBatch(PlayerKVBatch batch, PlayerKVTicketStatus status, PlayerKVError error) + { + lock (ticketGate) + foreach (var item in batch.Items) + if (tickets.TryGetValue(item.Sequence, out var record)) Complete(record, status, error); + } + + void SupersedeUnsentForKey(string key, long newerSequence) + { + foreach (var record in tickets.Values) + if (record.Ticket.Key == key && record.Ticket.Sequence < newerSequence && !record.Dispatched && !record.Completion.Task.IsCompleted) + Complete(record, PlayerKVTicketStatus.Superseded, PlayerKVError.None); + } + + void MarkSuperseded(long watermark) + { + var snapshot = state.Inspect(); + lock (ticketGate) + foreach (var record in tickets.Values) + if (record.Ticket.Sequence <= watermark && !record.Dispatched && !record.Completion.Task.IsCompleted && + snapshot.Pending.TryGetValue(record.Ticket.Key, out var latest) && latest.Sequence > record.Ticket.Sequence) + Complete(record, PlayerKVTicketStatus.Superseded, PlayerKVError.None); + } + + void MarkEligibleBlocked(long watermark, PlayerKVError error) + { + lock (ticketGate) + foreach (var record in tickets.Values) + if (record.Ticket.Sequence <= watermark && !record.Completion.Task.IsCompleted) + Complete(record, record.Dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Blocked, error); + } + + void MarkEligibleOnClose(long watermark) + { + lock (ticketGate) + foreach (var record in tickets.Values) + if (record.Ticket.Sequence <= watermark && !record.Completion.Task.IsCompleted) + Complete(record, record.Dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Cancelled, + PlayerKVError.Cancelled); + } + + void Complete(TicketRecord record, PlayerKVTicketStatus status, PlayerKVError error) => + record.Completion.TrySetResult(new PlayerKVTicketResult(status, error, state.Status)); + + PlayerKVFlushResult Summary(IEnumerable targetSequences, PlayerKVError error, + int recoveredConfirmed, int recoveredBlocked, int recoveredUnknown) + { + var counts = new Dictionary(); + lock (ticketGate) + foreach (var sequence in targetSequences) + { + if (!tickets.TryGetValue(sequence, out var record)) continue; + var status = record.Completion.Task.IsCompleted + ? record.Completion.Task.GetAwaiter().GetResult().Status : PlayerKVTicketStatus.Pending; + counts[status] = counts.TryGetValue(status, out var count) ? count + 1 : 1; + } + Func countOf = status => counts.TryGetValue(status, out var count) ? count : 0; + return new PlayerKVFlushResult(countOf(PlayerKVTicketStatus.Confirmed) + recoveredConfirmed, + countOf(PlayerKVTicketStatus.Superseded), + countOf(PlayerKVTicketStatus.Blocked) + countOf(PlayerKVTicketStatus.Cancelled) + recoveredBlocked, + countOf(PlayerKVTicketStatus.Unknown) + recoveredUnknown, error, state.Status); + } + + bool Current() => !ownerToken.IsCancellationRequested && isOwnerCurrent(); + static PlayerKVResult CancelledResult() => new PlayerKVResult(PlayerKVError.Cancelled); + + public void Dispose() + { + if (disposed) return; + disposed = true; + http.Dispose(); + dispatch.Dispose(); + } + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs.meta new file mode 100644 index 000000000..2efd357dd --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9ff223eaf9574c4894c4acbb6b06a387 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs new file mode 100644 index 000000000..2b7eed7d4 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace game.PlayerDataStorage +{ + public enum PlayerKVSyncStatus { NotLoaded, Ready, ReadFailed, Conflict, UnknownOutcome, Unauthorized, StorageError, Closed } + public enum PlayerKVOpenError { None, Validation, Busy, StorageError, RecoveryRequired } + public enum PlayerKVChangeError { None, Validation, Blocked, StorageError } + + public sealed class PlayerKVStateResult + { + public bool Success => Error == PlayerKVChangeError.None; + public PlayerKVChangeError Error { get; } + public long AttemptSequence { get; } + public long Revision { get; } + public bool LocalCommitMayHaveSucceeded { get; } + internal PlayerKVStateResult(PlayerKVChangeError error, long sequence = 0, long revision = 0, bool uncertain = false) + { Error = error; AttemptSequence = sequence; Revision = revision; LocalCommitMayHaveSucceeded = uncertain; } + } + + public sealed class PlayerKVOpenResult + { + public bool Success => Error == PlayerKVOpenError.None; + public PlayerKVOpenError Error { get; } + public PlayerKVSyncState State { get; } + internal PlayerKVOpenResult(PlayerKVOpenError error, PlayerKVSyncState state = null) { Error = error; State = state; } + } + + // Existence is separate from value: an empty string is not a deletion. + public sealed class PlayerKVValue : IEquatable + { + public static readonly PlayerKVValue Missing = new PlayerKVValue(false, null); + public bool Exists { get; } + public string Value { get; } + PlayerKVValue(bool exists, string value) { Exists = exists; Value = value; } + public static PlayerKVValue Present(string value) => new PlayerKVValue(true, value ?? throw new ArgumentNullException(nameof(value))); + public bool Equals(PlayerKVValue other) => other != null && Exists == other.Exists && Value == other.Value; + public override bool Equals(object obj) => Equals(obj as PlayerKVValue); + public override int GetHashCode() => Exists ? (Value.GetHashCode() ^ 397) : 0; + internal static PlayerKVValue At(IReadOnlyDictionary data, string key) => + data.TryGetValue(key, out var value) ? Present(value) : Missing; + } + + public sealed class PlayerKVChange + { + public string Key { get; } + public long Sequence { get; } + public PlayerKVValue BaseValue { get; } + public PlayerKVValue Target { get; } + internal PlayerKVChange(string key, long sequence, PlayerKVValue baseline, PlayerKVValue target) + { Key = key; Sequence = sequence; BaseValue = baseline; Target = target; } + internal PlayerKVChange WithBase(PlayerKVValue baseline) => new PlayerKVChange(Key, Sequence, baseline, Target); + } + + public sealed class PlayerKVBatch + { + public string Id { get; } + public IReadOnlyList Items { get; } + public bool IsDelete => !Items[0].Target.Exists; + internal PlayerKVBatch(string id, IEnumerable items) + { Id = id; Items = Array.AsReadOnly(items.ToArray()); } + } + + public sealed class PlayerKVDiagnosticSnapshot + { + public long Revision { get; } + public long NextSequence { get; } + public bool HasBaseline { get; } + public bool AuthenticationInvalid { get; } + public IReadOnlyDictionary Baseline { get; } + public IReadOnlyDictionary LocalOnly { get; } + public IReadOnlyDictionary Pending { get; } + public IReadOnlyDictionary ConflictObservations { get; } + public PlayerKVBatch ActiveBatch { get; } + public PlayerKVBatch UnknownBatch { get; } + internal PlayerKVDiagnosticSnapshot(KVSnapshot source) + { + Revision = source.Revision; NextSequence = source.NextSequence; HasBaseline = source.HasBaseline; + AuthenticationInvalid = source.AuthenticationInvalid; + Baseline = ReadOnly(source.Baseline); LocalOnly = ReadOnly(source.LocalOnly); + Pending = ReadOnly(source.Pending); ConflictObservations = ReadOnly(source.Conflicts); + ActiveBatch = source.Active; UnknownBatch = source.Unknown; + } + static IReadOnlyDictionary ReadOnly(Dictionary source) => + new ReadOnlyDictionary(new Dictionary(source, StringComparer.Ordinal)); + } + + internal sealed class KVIdentity + { + internal readonly string Api, PlayerId, DirectoryPath; + internal KVIdentity(string root, Uri api, string playerId) + { + if (string.IsNullOrWhiteSpace(root) || !Path.IsPathFullyQualified(root) || api == null || !api.IsAbsoluteUri || + (api.Scheme != "http" && api.Scheme != "https") || api.UserInfo.Length != 0 || api.Query.Length != 0 || api.Fragment.Length != 0 || + !long.TryParse(playerId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0) + throw new ArgumentException("Invalid KV cache identity."); + Api = api.AbsoluteUri.TrimEnd('/') + "/"; + PlayerId = id.ToString(CultureInfo.InvariantCulture); + using (var hash = SHA256.Create()) + { + var bytes = hash.ComputeHash(Encoding.UTF8.GetBytes(Api)); + var fingerprint = string.Concat(bytes.Select(b => b.ToString("x2", CultureInfo.InvariantCulture))); + DirectoryPath = Path.Combine(Path.GetFullPath(root), "player-kv", "v1", fingerprint, PlayerId); + } + } + } + + internal sealed class KVSnapshot + { + internal long Revision, NextSequence = 1; + internal bool HasBaseline, AuthenticationInvalid; + internal Dictionary Baseline = new Dictionary(StringComparer.Ordinal); + internal Dictionary LocalOnly = new Dictionary(StringComparer.Ordinal); + internal Dictionary Pending = new Dictionary(StringComparer.Ordinal); + internal Dictionary Conflicts = new Dictionary(StringComparer.Ordinal); + internal PlayerKVBatch Active, Unknown; + internal KVSnapshot Clone() => new KVSnapshot { + Revision = Revision, NextSequence = NextSequence, HasBaseline = HasBaseline, AuthenticationInvalid = AuthenticationInvalid, + Baseline = new Dictionary(Baseline, StringComparer.Ordinal), LocalOnly = new Dictionary(LocalOnly, StringComparer.Ordinal), + Pending = new Dictionary(Pending, StringComparer.Ordinal), Conflicts = new Dictionary(Conflicts, StringComparer.Ordinal), + Active = Active, Unknown = Unknown + }; + internal static bool ValidKey(string key) => !string.IsNullOrEmpty(key) && key.Length <= 128; + + internal string Encode(KVIdentity identity) + { + var pending = new JArray(Pending.Values.OrderBy(p => p.Sequence).Select(ChangeJson)); + var conflicts = new JObject(); + foreach (var pair in Conflicts) conflicts.Add(pair.Key, ValueJson(pair.Value)); + return new JObject { + ["formatVersion"] = 1, ["apiBaseUri"] = identity.Api, ["playerId"] = identity.PlayerId, + ["revision"] = Revision, ["nextSequence"] = NextSequence, ["hasBaseline"] = HasBaseline, + ["authenticationInvalid"] = AuthenticationInvalid, ["baseline"] = JObject.FromObject(Baseline), + ["localOnly"] = JObject.FromObject(LocalOnly), ["pending"] = pending, ["conflictObservations"] = conflicts, + ["activeBatch"] = BatchJson(Active), ["unknownBatch"] = BatchJson(Unknown) + }.ToString(Formatting.None); + } + static JObject ValueJson(PlayerKVValue value) => new JObject { ["exists"] = value.Exists, ["value"] = value.Value }; + static JObject ChangeJson(PlayerKVChange item) => new JObject { + ["key"] = item.Key, ["sequence"] = item.Sequence, ["base"] = ValueJson(item.BaseValue), ["target"] = ValueJson(item.Target) + }; + static JToken BatchJson(PlayerKVBatch batch) => batch == null ? JValue.CreateNull() : (JToken)new JObject { + ["id"] = batch.Id, ["items"] = new JArray(batch.Items.Select(ChangeJson)) + }; + + internal static KVSnapshot Decode(string text, KVIdentity identity) + { + JObject root; + using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None }) + { + root = JObject.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error }); + if (reader.Read()) throw Invalid(); + } + if (Integer(root, "formatVersion") != 1 || String(root, "apiBaseUri") != identity.Api || String(root, "playerId") != identity.PlayerId) throw Invalid(); + var result = new KVSnapshot { + Revision = Integer(root, "revision"), NextSequence = Integer(root, "nextSequence"), + HasBaseline = Boolean(root, "hasBaseline"), AuthenticationInvalid = Boolean(root, "authenticationInvalid"), + Baseline = Strings(Field(root, "baseline", JTokenType.Object)), LocalOnly = Strings(Field(root, "localOnly", JTokenType.Object)), + Active = Batch(root, "activeBatch"), Unknown = Batch(root, "unknownBatch") + }; + foreach (var item in Field(root, "pending", JTokenType.Array)) + { + var change = Change(item); + if (result.Pending.ContainsKey(change.Key)) throw Invalid(); + result.Pending.Add(change.Key, change); + } + foreach (var property in ((JObject)Field(root, "conflictObservations", JTokenType.Object)).Properties()) + { + if (!ValidKey(property.Name)) throw Invalid(); + result.Conflicts.Add(property.Name, Value(property.Value)); + } + result.Validate(); + return result; + } + internal void Validate() + { + if (Revision < 1 || NextSequence < 1 || (Active != null && Unknown != null) || + (!HasBaseline && (Baseline.Count != 0 || LocalOnly.Count != 0 || Pending.Count != 0 || Active != null || Unknown != null))) throw Invalid(); + var sequences = new Dictionary(); + foreach (var item in Pending.Values) + { + if (item.Sequence < 1 || item.Sequence >= NextSequence || sequences.ContainsKey(item.Sequence)) throw Invalid(); + sequences.Add(item.Sequence, item.Key); + } + foreach (var key in Conflicts.Keys) if (!Pending.ContainsKey(key)) throw Invalid(); + var batch = Active ?? Unknown; + if (batch == null) return; + foreach (var item in batch.Items) + { + if (item.Sequence < 1 || item.Sequence >= NextSequence || !Pending.TryGetValue(item.Key, out var latest) || latest.Sequence < item.Sequence || + (sequences.TryGetValue(item.Sequence, out var key) && key != item.Key) || + !latest.BaseValue.Equals(item.BaseValue) || + (latest.Sequence == item.Sequence && !latest.Target.Equals(item.Target))) throw Invalid(); + } + } + static Dictionary Strings(JToken value) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var property in ((JObject)value).Properties()) + { + if (!ValidKey(property.Name) || property.Value.Type != JTokenType.String) throw Invalid(); + result.Add(property.Name, (string)property.Value); + } + return result; + } + static PlayerKVChange Change(JToken item) + { + var key = String(item, "key"); + if (!ValidKey(key)) throw Invalid(); + return new PlayerKVChange(key, Integer(item, "sequence"), Value(Field(item, "base", JTokenType.Object)), Value(Field(item, "target", JTokenType.Object))); + } + static PlayerKVValue Value(JToken item) + { + var exists = Boolean(item, "exists"); + var value = Field(item, "value", exists ? JTokenType.String : JTokenType.Null); + return exists ? PlayerKVValue.Present((string)value) : PlayerKVValue.Missing; + } + static PlayerKVBatch Batch(JToken parent, string name) + { + var node = parent[name]; + if (node == null) throw Invalid(); + if (node.Type == JTokenType.Null) return null; + var id = String(node, "id"); + if (!Guid.TryParseExact(id, "N", out _)) throw Invalid(); + var items = Field(node, "items", JTokenType.Array).Select(Change).ToArray(); + if (items.Length == 0 || items.Length > 10 || items.Select(i => i.Key).Distinct(StringComparer.Ordinal).Count() != items.Length || + items.Select(i => i.Sequence).Distinct().Count() != items.Length || items.Any(i => i.Target.Exists != items[0].Target.Exists) || + !items.Select(i => i.Sequence).SequenceEqual(items.Select(i => i.Sequence).OrderBy(s => s))) throw Invalid(); + return new PlayerKVBatch(id, items); + } + static JToken Field(JToken parent, string name, JTokenType type) + { + if (!(parent is JObject obj) || obj[name] == null || obj[name].Type != type) throw Invalid(); + return obj[name]; + } + static string String(JToken parent, string name) => (string)Field(parent, name, JTokenType.String); + static long Integer(JToken parent, string name) => (long)Field(parent, name, JTokenType.Integer); + static bool Boolean(JToken parent, string name) => (bool)Field(parent, name, JTokenType.Boolean); + internal static JsonException Invalid() => new JsonException("Invalid KV cache snapshot."); + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs.meta new file mode 100644 index 000000000..0833a73a5 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c949512a03dc431b94569c20f06102af +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs new file mode 100644 index 000000000..91013f662 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; + +namespace game.PlayerDataStorage +{ + /// + /// Synchronous, fixed-identity cache state. No HTTP, gameplay initialization or timers. + /// All transitions and disk writes share a gate. The phase-3B owner must drain its + /// admitted network operations before disposing this state and its filesystem lease. + /// + public sealed class PlayerKVSyncState : IDisposable + { + readonly object gate = new object(); + readonly PlayerKVCacheStore store; + KVSnapshot snapshot; + PlayerKVSyncStatus status; + public PlayerKVSyncStatus Status { get { lock (gate) return status; } } + public string ApiBaseUri { get; } + public string PlayerId { get; } + + PlayerKVSyncState(KVIdentity identity, PlayerKVCacheStore store, KVSnapshot snapshot) + { + ApiBaseUri = identity.Api; PlayerId = identity.PlayerId; this.store = store; this.snapshot = snapshot; + // Authentication belongs to the old owner. A new owner must obtain a fresh + // successful Get before Ready; this never clears a persisted unknown batch. + snapshot.AuthenticationInvalid = false; + // A recovered active batch might have reached the server. Never acknowledge it by guessing. + if (snapshot.Active != null) { snapshot.Unknown = snapshot.Active; snapshot.Active = null; } + status = snapshot.Unknown != null ? PlayerKVSyncStatus.UnknownOutcome : PlayerKVSyncStatus.NotLoaded; + } + + public static PlayerKVOpenResult Open(string root, Uri api, string playerId) => Open(root, api, playerId, null); + internal static PlayerKVOpenResult Open(string root, Uri api, string playerId, Action fault) + { + KVIdentity identity; + try { identity = new KVIdentity(root, api, playerId); } + catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException) + { return new PlayerKVOpenResult(PlayerKVOpenError.Validation); } + var error = PlayerKVCacheStore.Open(identity, fault, out var store, out var snapshot); + return error == PlayerKVOpenError.None ? new PlayerKVOpenResult(error, new PlayerKVSyncState(identity, store, snapshot)) : new PlayerKVOpenResult(error); + } + + public PlayerKVDiagnosticSnapshot Inspect() { lock (gate) return new PlayerKVDiagnosticSnapshot(snapshot); } + + public IReadOnlyDictionary ReadLocal() + { + lock (gate) + { + if (status != PlayerKVSyncStatus.Ready) throw new InvalidOperationException("KV data is not ready."); + var result = new Dictionary(snapshot.Baseline, StringComparer.Ordinal); + foreach (var item in snapshot.Pending.Values) Apply(result, item.Key, item.Target); + foreach (var pair in snapshot.LocalOnly) result[pair.Key] = pair.Value; + return new ReadOnlyDictionary(result); + } + } + + // The caller must supply a successful complete Get. Failed reads must use RecordReadFailure. + public PlayerKVStateResult Reconcile(IReadOnlyDictionary remote) + { + lock (gate) + { + if (Terminal || snapshot.Active != null || snapshot.AuthenticationInvalid) return Blocked(); + if (remote == null || remote.Any(p => !KVSnapshot.ValidKey(p.Key) || p.Value == null)) return Invalid(); + var copy = snapshot.Clone(); + copy.Conflicts.Clear(); + if (copy.Unknown != null) + { + foreach (var item in copy.Pending.Values) copy.Conflicts[item.Key] = PlayerKVValue.At(remote, item.Key); + return Commit(copy, PlayerKVSyncStatus.UnknownOutcome); + } + foreach (var item in copy.Pending.Values.ToArray()) + { + var observed = PlayerKVValue.At(remote, item.Key); + if (observed.Equals(item.Target)) copy.Pending.Remove(item.Key); + else if (!observed.Equals(item.BaseValue)) copy.Conflicts[item.Key] = observed; + } + copy.Baseline = remote.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); + copy.HasBaseline = true; + return Commit(copy, copy.Conflicts.Count == 0 ? PlayerKVSyncStatus.Ready : PlayerKVSyncStatus.Conflict); + } + } + + public PlayerKVStateResult RecordReadFailure(bool unauthorized = false) + { + lock (gate) + { + if (Terminal || snapshot.Active != null) return Blocked(); + if (unauthorized) + { + var copy = snapshot.Clone(); copy.AuthenticationInvalid = true; + return Commit(copy, copy.Unknown == null ? PlayerKVSyncStatus.Unauthorized : PlayerKVSyncStatus.UnknownOutcome); + } + status = snapshot.Unknown != null ? PlayerKVSyncStatus.UnknownOutcome : PlayerKVSyncStatus.ReadFailed; + return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision); + } + } + + public PlayerKVStateResult Update(string key, string value, bool remoteStore = true) => + Edit(key, value == null ? null : PlayerKVValue.Present(value), remoteStore); + public PlayerKVStateResult Delete(string key) => Edit(key, PlayerKVValue.Missing, true); + + PlayerKVStateResult Edit(string key, PlayerKVValue target, bool remote) + { + lock (gate) + { + if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid) return Blocked(); + if (!KVSnapshot.ValidKey(key) || target == null || snapshot.NextSequence == long.MaxValue) return Invalid(); + var copy = snapshot.Clone(); + var sequence = copy.NextSequence++; + if (remote) + { + var baseline = copy.Pending.TryGetValue(key, out var previous) ? previous.BaseValue : PlayerKVValue.At(copy.Baseline, key); + copy.Pending[key] = new PlayerKVChange(key, sequence, baseline, target); + copy.LocalOnly.Remove(key); + } + else copy.LocalOnly[key] = target.Value; + return Commit(copy, PlayerKVSyncStatus.Ready, sequence); + } + } + + public PlayerKVStateResult BeginBatch(long throughSequence, out PlayerKVBatch batch) + { + lock (gate) + { + batch = null; + if (status != PlayerKVSyncStatus.Ready || snapshot.Active != null || snapshot.Unknown != null || snapshot.AuthenticationInvalid) return Blocked(); + if (throughSequence < 0) return Invalid(); + var ordered = snapshot.Pending.Values.Where(p => p.Sequence <= throughSequence).OrderBy(p => p.Sequence).ToArray(); + if (ordered.Length == 0) return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision); + var copy = snapshot.Clone(); + copy.Active = new PlayerKVBatch(Guid.NewGuid().ToString("N"), ordered.TakeWhile(p => p.Target.Exists == ordered[0].Target.Exists).Take(10)); + var result = Commit(copy, PlayerKVSyncStatus.Ready); + if (result.Success) batch = snapshot.Active; // Never expose a batch until its write-ahead record is durable. + return result; + } + } + + public PlayerKVStateResult ConfirmBatch(string batchId) + { + lock (gate) + { + if (status != PlayerKVSyncStatus.Ready || snapshot.Active == null || snapshot.Active.Id != batchId) return Blocked(); + var copy = snapshot.Clone(); + foreach (var item in copy.Active.Items) + { + Apply(copy.Baseline, item.Key, item.Target); + var current = copy.Pending[item.Key]; + if (current.Sequence == item.Sequence) copy.Pending.Remove(item.Key); + else copy.Pending[item.Key] = current.WithBase(item.Target); + } + copy.Active = null; + return Commit(copy, PlayerKVSyncStatus.Ready); + } + } + + // mayHaveCommitted=false is only valid with positive evidence of no dispatch. + public PlayerKVStateResult FailBatch(string batchId, bool mayHaveCommitted, bool unauthorized = false) + { + lock (gate) + { + if (Terminal || snapshot.Active == null || snapshot.Active.Id != batchId) return Blocked(); + var copy = snapshot.Clone(); + copy.AuthenticationInvalid |= unauthorized; + if (mayHaveCommitted) copy.Unknown = copy.Active; + copy.Active = null; + return Commit(copy, mayHaveCommitted ? PlayerKVSyncStatus.UnknownOutcome : + copy.AuthenticationInvalid ? PlayerKVSyncStatus.Unauthorized : PlayerKVSyncStatus.ReadFailed); + } + } + + bool Terminal => status == PlayerKVSyncStatus.Closed || status == PlayerKVSyncStatus.StorageError; + PlayerKVStateResult Commit(KVSnapshot copy, PlayerKVSyncStatus nextStatus, long sequence = 0) + { + if (snapshot.Revision == long.MaxValue) + { status = PlayerKVSyncStatus.StorageError; return new PlayerKVStateResult(PlayerKVChangeError.StorageError, sequence, snapshot.Revision); } + copy.Revision = snapshot.Revision + 1; + if (!store.Save(copy, out var uncertain)) + { + status = PlayerKVSyncStatus.StorageError; + return new PlayerKVStateResult(PlayerKVChangeError.StorageError, sequence, copy.Revision, uncertain); + } + snapshot = copy; status = nextStatus; + return new PlayerKVStateResult(PlayerKVChangeError.None, sequence, copy.Revision); + } + static void Apply(Dictionary data, string key, PlayerKVValue value) + { if (value.Exists) data[key] = value.Value; else data.Remove(key); } + PlayerKVStateResult Blocked() => new PlayerKVStateResult(PlayerKVChangeError.Blocked, revision: snapshot.Revision); + PlayerKVStateResult Invalid() => new PlayerKVStateResult(PlayerKVChangeError.Validation, revision: snapshot.Revision); + + public void Dispose() + { + lock (gate) + { + if (status == PlayerKVSyncStatus.Closed) return; + status = PlayerKVSyncStatus.Closed; + store.Dispose(); + } + } + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs.meta b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs.meta new file mode 100644 index 000000000..70764ab14 --- /dev/null +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0aabf3ee88684f80ba8770cbf96a9be8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Scripts/Services/RTService.cs b/Client/Assets/Scripts/Services/RTService.cs index e61252cec..945285225 100644 --- a/Client/Assets/Scripts/Services/RTService.cs +++ b/Client/Assets/Scripts/Services/RTService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using UnityEngine; using System.Net; using asap.core; +using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.SignalR.Client; using Newtonsoft.Json; using UniRx; @@ -22,184 +23,159 @@ public interface IRTService public Task Publish(string methodName, string arg1, string arg2); //public void Reset(); public void Release(); + public Task ReleaseAsync(); } public class SignalRService : IRTService { private HubConnection _hubConnection; - private ICustomServerMgr _serverMgr; - private HashSet _bindedEvent; - private IEventAggregator _eventAgg; - private string _url; - private Dictionary> _cachedEvents; + private readonly ICustomServerMgr _serverMgr; + private readonly IEventAggregator _eventAgg = new EventAggregator(); + private readonly string _url; + private readonly Dictionary> handlers = new Dictionary>(); + private readonly System.Threading.SemaphoreSlim lifecycle = new System.Threading.SemaphoreSlim(1, 1); + private IDisposable loginSubscription; + private bool released; + private int connectionVersion; + private System.Threading.CancellationTokenSource connecting; public SignalRService(ICustomServerMgr serverMgr, IConfig config) { - this._serverMgr = serverMgr; - var baseUrl = config.Get(GConstant.K_Event_API_URL, string.Empty); -#if UNITY_EDITOR - UnityEngine.Assertions.Assert.IsTrue(!string.IsNullOrEmpty(baseUrl)); -#endif - _url = baseUrl + "gamehub"; - _eventAgg = new EventAggregator(); - _cachedEvents = new Dictionary>(); - _bindedEvent = new HashSet(); + _serverMgr = serverMgr; + _url = new Uri(serverMgr.ApiBaseUri, "gamehub").AbsoluteUri; + _serverMgr.SessionChanged += OnSessionChanged; } public void WaitForAPISvrLogin() { - GContext.OnEvent().Subscribe(OnApiSvrLoginSuccess); + if (released || loginSubscription != null) return; + loginSubscription = GContext.OnEvent().Subscribe(OnApiSvrLoginSuccess); } private async void OnApiSvrLoginSuccess(EvtAPISvrLoginSuccess evt) { - try{ - Reset(); - await Connect(); - } - catch (Exception ex) - { - Debug.LogException(ex); - } + try { await Connect(); } + catch (Exception ex) { Debug.LogException(ex); } } - public void Release() + private async void OnSessionChanged() { - if (_hubConnection != null) - { - _hubConnection.StopAsync(); - _hubConnection.DisposeAsync(); - _hubConnection = null; - } - - if (_bindedEvent != null) - { - _bindedEvent.Clear(); - _bindedEvent = null; - } - - if (_eventAgg != null) - { - _eventAgg = null; - } - - if(_cachedEvents != null) - { - _cachedEvents.Clear(); - _cachedEvents = null; - } + connectionVersion++; + connecting?.Cancel(); + try { await Disconnect(); } + catch (Exception ex) { Debug.LogException(ex); } } - - private void Reset() + private async Task CloseConnection() { - if (_hubConnection != null) - { - _hubConnection.StopAsync(); - _hubConnection.DisposeAsync(); - _hubConnection = null; - } - - var cookie = _serverMgr.Cookie; - _hubConnection = new HubConnectionBuilder() - .WithUrl(_url, option => option.Cookies = cookie) - .WithAutomaticReconnect() - .Build(); - - _hubConnection.Reconnected += OnHubConnected; - - _hubConnection.On("ForceQuit", ForceQuit); - - if (_bindedEvent != null) - { - _bindedEvent.Clear(); - } - else - { - _bindedEvent = new HashSet(); - } + var previous = _hubConnection; + _hubConnection = null; + if (previous == null) return; + try { await previous.StopAsync(); } + finally { await previous.DisposeAsync(); } } - private Task OnHubConnected(string connectionId) + public async void Release() { - if(_cachedEvents.Count > 0) - { - foreach(var kv in _cachedEvents) - { - _hubConnection.On(kv.Key, kv.Value); - } - _cachedEvents.Clear(); - } - _hubConnection.Reconnected -= OnHubConnected; - return Task.CompletedTask; + try { await ReleaseAsync(); } + catch (Exception ex) { Debug.LogException(ex); } } - public bool IsConnected => _hubConnection != null && _hubConnection.State == HubConnectionState.Connected; + public async Task ReleaseAsync() + { + released = true; + connectionVersion++; + connecting?.Cancel(); + loginSubscription?.Dispose(); + loginSubscription = null; + _serverMgr.SessionChanged -= OnSessionChanged; + await lifecycle.WaitAsync(); + try { await CloseConnection(); handlers.Clear(); } + finally { lifecycle.Release(); } + } + + private bool IsCurrent(HubConnection hub, int version, int session) => + !released && version == connectionVersion && session == _serverMgr.SessionVersion && ReferenceEquals(hub, _hubConnection); + + public bool IsConnected => !released && _hubConnection != null && _hubConnection.State == HubConnectionState.Connected; public async Task Connect() { - if(_hubConnection == null) + var session = _serverMgr.SessionVersion; + var version = connectionVersion; + var cookies = _serverMgr.Cookie; + await lifecycle.WaitAsync(); + try { - Debug.LogWarning("[RTService] hubConnection is null"); - return; + if (released || version != connectionVersion || session != _serverMgr.SessionVersion) return; + if (_hubConnection != null && _hubConnection.State != HubConnectionState.Disconnected) return; + await CloseConnection(); + if (released || version != connectionVersion || session != _serverMgr.SessionVersion) return; + var hub = new HubConnectionBuilder() + .WithUrl(_url, option => + { + option.Cookies = cookies; + option.SkipNegotiation = true; + option.Transports = HttpTransportType.WebSockets; + }) + .WithAutomaticReconnect() + .Build(); + _hubConnection = hub; + hub.On("ForceQuit", (title, info, button) => + { + if (IsCurrent(hub, version, session)) ForceQuit(title, info, button, version, session); + }); + foreach (var handler in handlers) + Bind(hub, version, session, handler.Key, handler.Value); + connecting?.Dispose(); + connecting = new System.Threading.CancellationTokenSource(); + try { await hub.StartAsync(connecting.Token); } + catch { await CloseConnection(); throw; } + if (!IsCurrent(hub, version, session)) { await CloseConnection(); return; } + Debug.Log("[RTService] Connected to SignalR server True"); } + finally { lifecycle.Release(); } + } - //UnityEngine.Assertions.Assert.IsNotNull(_hubConnection, "[RTService] hubConnection is null"); - if (_hubConnection.State == HubConnectionState.Disconnected) + private void Bind(HubConnection hub, int version, int session, string name, Action handler) + { + hub.On(name, data => { - await _hubConnection.StartAsync(); - Debug.Log($"[RTService] Connected to SignalR server {_hubConnection.State == HubConnectionState.Connected}"); - } + if (IsCurrent(hub, version, session)) handler(data); + }); } public async Task Disconnect() { - if(_hubConnection == null) - { - Debug.LogWarning("[RTService] hubConnection is null"); - return; - } - - //UnityEngine.Assertions.Assert.IsNotNull(_hubConnection, "[RTService] hubConnection is null"); - if (_hubConnection.State == HubConnectionState.Connected) - { - await _hubConnection.StopAsync(); - Debug.Log($"[RTService] Disconnected to SignalR server {_hubConnection.State == HubConnectionState.Disconnected}"); - } + await lifecycle.WaitAsync(); + try { await CloseConnection(); } + finally { lifecycle.Release(); } } public IDisposable OnEvent(Action handler) { - var methodName = typeof(T).Name; - - if(!_bindedEvent.Contains(typeof(T))) + if (released) throw new ObjectDisposedException(nameof(SignalRService)); + var name = typeof(T).Name; + if (!handlers.ContainsKey(name)) { - _bindedEvent.Add(typeof(T)); - if(IsConnected) - { - _hubConnection.On(methodName, data => { - var result =JsonConvert.DeserializeObject(data); - _eventAgg.Publish(result); - }); - } - else - { - _cachedEvents[methodName] = data => { - - var result =JsonConvert.DeserializeObject(data); - _eventAgg.Publish(result); - }; - } + Action dispatch = data => _eventAgg.Publish(JsonConvert.DeserializeObject(data)); + handlers.Add(name, dispatch); + if (_hubConnection != null) + Bind(_hubConnection, connectionVersion, _serverMgr.SessionVersion, name, dispatch); } - return _eventAgg.GetEvent().Subscribe(handler); } - + public async Task Publish(string methodName, string arg1) { + var hub = _hubConnection; + var version = connectionVersion; + var session = _serverMgr.SessionVersion; + if (hub == null || !IsCurrent(hub, version, session)) return null; try { - return await _hubConnection.InvokeAsync(methodName, arg1); + var result = await hub.InvokeAsync(methodName, arg1); + return IsCurrent(hub, version, session) ? result : null; } catch(Exception e) { @@ -210,9 +186,14 @@ public class SignalRService : IRTService public async Task Publish(string methodName, string arg1, string arg2) { + var hub = _hubConnection; + var version = connectionVersion; + var session = _serverMgr.SessionVersion; + if (hub == null || !IsCurrent(hub, version, session)) return null; try { - return await _hubConnection.InvokeAsync(methodName, arg1, arg2); + var result = await hub.InvokeAsync(methodName, arg1, arg2); + return IsCurrent(hub, version, session) ? result : null; } catch(Exception e) { @@ -221,15 +202,17 @@ public class SignalRService : IRTService } } - private async void ForceQuit(string titleKey, string infoKey, string btnKey) + private async void ForceQuit(string titleKey, string infoKey, string btnKey, int version, int session) { await Awaiters.NextFrame; + if (released || version != connectionVersion || session != _serverMgr.SessionVersion) return; GameObject go = await UIManager.Instance.ShowUI(UITypes.NoticeConfirmPopupPanel); + if (released || version != connectionVersion || session != _serverMgr.SessionVersion) return; var noticeConfirmPopupPanel = go.GetComponent(); string title = LocalizationMgr.GetText(titleKey ?? "UI_NoticePopupPanel_3"); string info = LocalizationMgr.GetText(infoKey ?? "UI_NoticePopupPanel_4"); string label = LocalizationMgr.GetText(btnKey ?? "UI_COMMON_confirm"); - noticeConfirmPopupPanel.Init(1, title, info, onClickLeft: Restart, onClose: Restart, btn_left_text: label); + noticeConfirmPopupPanel.Init(1, title, info, onClickLeft: () => { if (!released && version == connectionVersion && session == _serverMgr.SessionVersion) Restart(); }, onClose: () => { if (!released && version == connectionVersion && session == _serverMgr.SessionVersion) Restart(); }, btn_left_text: label); } private async void Restart() diff --git a/Client/Assets/Scripts/Services/UserService.KVFishingReward.cs b/Client/Assets/Scripts/Services/UserService.KVFishingReward.cs new file mode 100644 index 000000000..74c06461f --- /dev/null +++ b/Client/Assets/Scripts/Services/UserService.KVFishingReward.cs @@ -0,0 +1,291 @@ +#if UNITY_EDITOR && !SERVICE_HOST +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using asap.core; +using cfg; +using GameCore; +using Newtonsoft.Json; +using UnityEngine; + +namespace game.PlayerDataStorage +{ + internal static partial class PlayerKVGameplayLoop + { + internal sealed class FishingRewardPlan + { + internal FishingData Data; + internal int Fish, Item, Area, Rod, Map, Mag; + internal string Activities; + internal string MapKey => "Map_5_"+Area; + } + internal sealed class FishingRewardAction + { + internal readonly FishingAction Consumption; + internal readonly object Controller; + internal readonly FishingRewardPlan Plan; + internal readonly List Records=new List(); + internal readonly HashSet Segments=new HashSet(StringComparer.Ordinal); + internal object Panel; + internal WriteBatch Segment; + internal string SegmentName; + internal bool Ending,Applying,Sealed,Confirmed,Presented,Cancelled; + internal int GuideNotifications,BeforeFishCount; + internal FishingRewardAction(FishingAction consumption,object controller,FishingRewardPlan plan) + { Consumption=consumption; Controller=controller; Plan=plan; } + } + static FishingRewardPlan rewardPlan; + static FishingRewardAction rewardAction; + static readonly ConditionType[] rewardConditions={ConditionType.ConsumeEnergy,ConditionType.PerfectStrikeCount, + ConditionType.GetFishCount,ConditionType.CashFromFishing,ConditionType.MaxCashFromFishing, + ConditionType.GetFishWeight,ConditionType.GetFishID,ConditionType.GetBoosFish,ConditionType.GetBiggerThanHugeFishCount}; + static readonly HashSet rewardTaskKeys=new HashSet(StringComparer.Ordinal) + {"BattlePassRecordTask","MiniBPKeyNew","EventLuckGameRecord","EventLuckMagicPlayfabData"}; + + static string ActivityStamp(Tables tables) + { + var manager=GContext.container.Resolve(); + var scopes=GContext.container.Resolve(); + return string.Join(";",tables.TbFishingEvent.DataList.GroupBy(e=>(e.Type,e.SubType)).Select(g=>g.First()) + .Select(e=>e.Type+":"+e.SubType+":"+manager.GetSentry(e)?.CurrentEventConfig?.ID+":"+scopes.HasActiveScope((e.Type,e.SubType)))); + } + static readonly HashSet specialRewardGuides=new HashSet(StringComparer.Ordinal) + {"FishSkill01","Pollution01","FishSkill02","FishSkill03","FishSkill04","Egyptian01","EasterFish01"}; + internal static void CheckRewardGuide(FishingData data=null) + { + var guide=GContext.container.Resolve(); + if(guide.curDefine!=null || guide.curGroupDefine!=null || guide.guidanceGroupDefines==null || + guide.guidanceGroupDefines.Any(g=>g==null || g.guidanceDefines==null || g.guidanceDefines.Any(n=>n==null))) + throw Stop("Reward requires inactive and complete guide definitions."); + var groups=guide.guidanceGroupDefines.Where(g=>g.guidanceDefines.Any(n=>n.panelName=="FishingPanel" || + n.panelName=="FishingPanel_Advanced" || n.panelName=="FishingRewardPanel")) + .OrderBy(g=>g.groupName.ToString(),StringComparer.Ordinal).ToArray(); + var asset=Path.Combine(Application.dataPath,"ABPackage/pkgPrepare/Export/GuidanceConfig.asset"); + var baseline=File.ReadAllBytes(Path.GetFullPath(Path.Combine(Application.dataPath,"../../CodexOutput/upserver/KVFishingRewardTests/guide-baseline-projection.jsonl"))); + if(PlayerKVLoopPermit.Hash(File.ReadAllBytes(asset))!="5926124E5633166CC1B91569B8A71AB2C388AA3446A736B3E7E42B3EBD9F3D3A" || + PlayerKVLoopPermit.Hash(baseline)!="A3D056313FE0F40D395FE92F97BEDE31BA47D8D79A3C4C28781395A43118E89C" || groups.Length!=11 || + string.Join("\n",groups.Select(g=>JsonUtility.ToJson(g)))!=System.Text.Encoding.UTF8.GetString(baseline)) + throw Stop("Reward guide configuration differs from the reviewed full-node projection."); + foreach(var g in groups) + if(!guide.GetIsFinish(g.groupName.ToString()) && (!specialRewardGuides.Contains(g.groupName.ToString()) || + g.TriggerConditionsType!=GuideTriggerConditions.None || g.guidanceDefines.Length==0 || g.guidanceDefines[0].panelName!="FishingPanel_Advanced")) + throw Stop("Reward requires completed ordinary fishing guides."); + data=data??rewardPlan?.Data; + if(data?.curFishData?.FishSKillList==null || data.curFishData.FishEscape==null || data.curFishData.FishSKillList.Any(x=>x==null) || + data.fishSpSkillData!=null || data.superDashSpSkillData!=null || data.dirtyStuckSpSkillData!=null) + throw Stop("Reward requires an ordinary fish without active special skills."); + var tables=GContext.container.Resolve(); + var queried=data.curFishData.FishSKillList.Concat(new[]{data.curFishData.FishEscape}).Select(x=>x.SpSkillID).Distinct().OrderBy(x=>x).ToArray(); + bool invalid=false; + foreach(var id in queried) { var resolved=tables.TbFishSpSkill.GetOrDefault(id); if(id>0 || resolved!=null)invalid=true; } + Audit("fishing-reward-special-skill-check",new{queriedIds=queried,invalid}); + if(invalid) throw Stop("Reward selected fish has a special skill; no consumption is allowed."); + } + static string EventSignature(FishingEvent e) => JsonConvert.SerializeObject(new{e.ID,e.Type,e.SubType,e.RedirectID, + e.IsABTest,e.Channel,e.ConditionRelation,e.PopupPriority,e.PopupType, + conditions=e.ConditionList.Select(c=>new{c.Condition,c.Param}),time=e.TimeDefinition.ToString()}); + static void CheckRewardConfig(Tables tables) + { + var path=Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData/tbfishingevent.json")); + var bytes=File.ReadAllBytes(path); + if(PlayerKVLoopPermit.Hash(bytes)!="C8A4A9794E9390C94B9C8F1927B3E718B3EB9F784F0889508E1E576DD4B77A2D") + throw Stop("Reward event configuration differs from the reviewed baseline."); + var baseline=new TbFishingEvent(SimpleJSON.JSON.Parse(System.Text.Encoding.UTF8.GetString(bytes))); + var relevant=tables.TbFishingEvent.DataList.Where(e=>e.ConditionList.Any(c=>rewardConditions.Contains(c.Condition))).ToArray(); + if(relevant.Length!=19 || tables.TbTriggerPackManager.DataList.Any(t=>rewardConditions.Contains(t.Condition))) + throw Stop("Reward activates an unreviewed event or trigger pack."); + foreach(var e in relevant) + { + var old=baseline.GetOrDefault(e.ID); + if(old==null || EventSignature(old)!=EventSignature(e)) throw Stop("Reward event fields changed."); + if(e.ID==10100000 || e.ID==10200000 || e.ID==11300000) continue; + if(e.ID<30200350 || e.ID>30200365 || !(e.TimeDefinition is LimitedTime time) || + !DateTime.TryParse(time.EndTime,CultureInfo.InvariantCulture,DateTimeStyles.None,out var end) || + ZZTimeHelper.UtcNow() < end.AddSeconds(120)) throw Stop("Reward event must be outside its reviewed activity window."); + } + } + static void CheckRewardEnvironment(FishingData data,bool checkConfig) + { + var player=GContext.container.Resolve(); + var fish=GContext.container.Resolve(); + var events=GContext.container.Resolve(); + var challenge=GContext.container.Resolve(); + if(data==null || data.curFishData==null || data.fishItem==null || !data.IsGeneralFish() || data.IsFree || + data.IsRetry || data.IsDuel || data.fixedFishList!=null || data.IsPackItemFish() || data.IsEventGameFish()) + throw Stop("Reward supports one ordinary paid fish only."); + var tables=GContext.container.Resolve(); + if(tables.TbEventSlapSpinach.TryGetByItemID(data.fishItem.ID,out _)) throw Stop("Slap reward is outside this run."); + var cruise=fish.GetFishSwimToMap(data.MapId); + if((cruise!=-1 && cruise!=0) || events.collectingTargetInit!=null || + GContext.container.Resolve().GetAddCollecting()!=0 || + GContext.container.Resolve().IsPackActivated || GContext.container.Resolve().IsPackActivated) + throw Stop("Additional collection, cruise or pack rewards require separate review."); + if(challenge.fishingChallengeData!=null && challenge.ChallengeState()!=0 && challenge.ChallengeState()!=3 && challenge.IsTimeAllow()) + throw Stop("Active challenge rewards require separate review."); + if(player._fishingFrequency<3 || !player.IsOpenMagnification || !fish.IsOpenPiercing) + throw Stop("Reward requires already unlocked basic fishing."); + CheckRewardGuide(data); + if(checkConfig) CheckRewardConfig(tables); + } + internal static void PrepareFishingReward(FishingAction action,FishingData data) + { + if(Permit?.AllowsReward!=true) return; + if(rewardPlan!=null || !FishingCurrent(action)) throw Stop("Reward preflight already used or stale."); + CheckRewardEnvironment(data,true); + var p=GContext.container.Resolve(); + var plan=new FishingRewardPlan{Data=data,Fish=data.curFishData.ID,Item=data.fishItem.ID,Area=data.curFishData.AreaID, + Rod=data.RodId,Map=p.lastMapId,Mag=p.magnification,Activities=ActivityStamp(GContext.container.Resolve())}; + if(!Permit.AllowedKeys.Contains(plan.MapKey) || Read(plan.MapKey)==null) throw Stop("Reward requires an existing backed-up fish map key."); + rewardPlan=plan; + Audit("fishing-reward-preflight",new{plan.Fish,plan.Item,plan.Area,plan.Rod,plan.Map,plan.Mag}); + } + static void CheckRewardContext() + { + var p=rewardPlan; var player=GContext.container.Resolve(); + if(p==null || p.Data.curFishData==null || p.Data.fishItem==null || p.Data.curFishData.ID!=p.Fish || p.Data.fishItem.ID!=p.Item || + p.Data.curFishData.AreaID!=p.Area || p.Data.RodId!=p.Rod || player.lastMapId!=p.Map || player.magnification!=p.Mag) + throw Stop("Reward fishing context changed."); + CheckRewardEnvironment(p.Data,false); + if(p.Activities!=ActivityStamp(GContext.container.Resolve())) throw Stop("Activity changed during fishing reward."); + } + internal static bool RewardRunCurrent => RewardCurrent(rewardAction); + internal static bool RewardCanAdvance => !Enabled || (FishingCurrent(fishingAction) && + (rewardAction==null || (!rewardAction.Cancelled && !rewardAction.Sealed))); + internal static bool BeginRewardDrawing(object controller) + { + if(Permit?.AllowsReward!=true || !FishingCurrent(fishingAction) || !fishingAction.Confirmed || !fishingAction.Continued) + throw Stop("A confirmed v3 fishing action is required before drawing."); + if(rewardAction!=null) { Audit("duplicate-suppressed",new{point="draw"}); return false; } + CheckRewardContext(); + rewardAction=new FishingRewardAction(fishingAction,controller,rewardPlan); + return true; + } + internal static bool BeginRewardEnding(object controller,bool success) + { + var a=rewardAction; + if(a==null || !ReferenceEquals(controller,a.Controller) || !RewardCurrent(a)) throw Stop("Reward ending is stale."); + if(a.Ending) { Audit("duplicate-suppressed",new{point="fish-end"}); return false; } + a.Ending=true; + if(!success) throw Stop("Fishing failed; reward run stops without retry or refund."); + CheckRewardContext(); return true; + } + internal static FishingRewardAction BindRewardPanel(object panel) + { + var a=rewardAction; + if(!RewardCurrent(a) || !a.Ending) throw Stop("Reward panel has no completed catch."); + if(a.Applying) { Audit("duplicate-suppressed",new{point="reward-panel"}); return null; } + CheckRewardContext(); + a.Applying=true; a.Panel=panel; + var fish=GContext.container.Resolve(); + var map=JsonConvert.DeserializeObject>(Read(a.Plan.MapKey)); + a.BeforeFishCount=map.TryGetValue(a.Plan.Fish,out var old)?FishWeightData.Deserialize(old).TotalNum:0; + return a; + } + internal static bool RewardCurrent(FishingRewardAction a) => a!=null && ReferenceEquals(a,rewardAction) && + !a.Cancelled && FishingCurrent(a.Consumption); + internal static bool RewardPanelCurrent(FishingRewardAction a,object panel) => RewardCurrent(a) && ReferenceEquals(a.Panel,panel); + sealed class RewardSegment : IDisposable + { + readonly FishingRewardAction action; + internal RewardSegment(FishingRewardAction a) { action=a; } + public void Dispose() + { + if(action.Segment==null) return; + action.Segment.Dispose(); action.Records.AddRange(action.Segment.Records); + Audit("fishing-reward-segment",new{segment=action.SegmentName,writes=action.Segment.Records.Count}); + action.Segment=null;action.SegmentName=null; + } + } + internal static IDisposable BeginRewardSegment(string name) + { + var a=rewardAction; + if(!RewardCurrent(a) || a.Sealed || collecting!=null || a.Segment!=null || + (name!="strike" && name!="ending" && name!="reward") || !a.Segments.Add(name)) throw Stop("Reward segment is not admissible."); + if((name=="ending" && !a.Ending) || (name=="reward" && !a.Applying)) throw Stop("Reward segment order is invalid."); + a.SegmentName=name; a.Segment=collecting=new WriteBatch(a.Consumption.Owner); + return new RewardSegment(a); + } + internal static void CheckRewardWrite(string key,string value) + { + var a=rewardAction; + if(Permit?.AllowsReward!=true || a==null || a.Cancelled || a.Confirmed) return; + if(!RewardCurrent(a)) throw Stop("Reward write belongs to a stale action."); + if(a.Segment==null) + { + Audit("fishing-reward-outside-segment",new{key}); + if(!string.Equals(Read(key),value,StringComparison.Ordinal)) throw Stop("Value changed outside a fishing reward segment: "+key); + } + else if(key!="NewPlayerDataDic" && key!=a.Plan.MapKey && !rewardTaskKeys.Contains(key)) + throw Stop("Unexpected fishing reward key: "+key); + } + internal static void CheckCaughtFishSave(int fish,int count) + { + var a=rewardAction; + if(!RewardCurrent(a) || a.SegmentName!="reward" || a.Plan.Fish!=fish || + count!=checked(a.BeforeFishCount+GContext.container.Resolve().GetMagnification())) + throw Stop("Caught fish final save must contain exactly this catch's increment."); + } + internal static void CheckRewardMainSave() + { + if(!RewardCurrent(rewardAction) || rewardAction.SegmentName!="reward") throw Stop("Final reward save outside reward segment."); + } + internal static void RecordRewardGuideNotification() + { + var a=rewardAction; + if(!RewardCurrent(a) || a.SegmentName!="reward" || ++a.GuideNotifications!=1) throw Stop("Duplicate reward guide notification."); + CheckRewardGuide(); Audit("fishing-reward-guide",new{notifications=a.GuideNotifications,activated=0}); + } + internal static async Task ConfirmRewardAsync(FishingRewardAction a,PlayerKVEditResult primary) + { + try + { + if(!RewardCurrent(a) || a.Segment!=null || collecting!=null || a.Sealed || a.Segments.Count!=3 || a.GuideNotifications!=1) + throw Stop("Reward records must be complete and sealed once."); + a.Sealed=true; + var last=a.Records.GroupBy(r=>r.Key,StringComparer.Ordinal).ToDictionary(g=>g.Key,g=>g.Last(),StringComparer.Ordinal); + if(!last.TryGetValue("NewPlayerDataDic",out var main) || !ReferenceEquals(main.Edit,primary) || + !last.ContainsKey(a.Plan.MapKey) || last.Values.Any(r=>!r.Remote || r.Edit.Ticket==null)) + throw Stop("Reward final writes require the original map/main and remote tickets."); + using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30))) + { + var owner=a.Consumption.Owner; + var flush=await PlayFabMgr.FlushKVAsync(owner,timeout.Token); + if(!RewardCurrent(a) || flush.Unknown>0 || flush.Blocked>0 || flush.TransportError!=PlayerKVError.None) + throw Stop("Reward flush: "+flush.TransportError); + foreach(var r in a.Records) + { + var t=r.Edit.Ticket;if(t==null)continue; + var final=last[r.Key].Edit.Ticket; + if(t.OwnerInstanceId!=owner.OwnerInstanceId || t.Epoch!=owner.Epoch || t.Key!=r.Key || + final.OwnerInstanceId!=t.OwnerInstanceId || final.Epoch!=t.Epoch) throw Stop("Reward ticket identity mismatch."); + var result=await PlayFabMgr.WaitForKVTicketAsync(owner,t,timeout.Token); + if(!RewardCurrent(a)) throw Stop("Reward action changed while waiting."); + bool explained=result.Status==PlayerKVTicketStatus.Superseded && !ReferenceEquals(t,final) && final.Sequence>t.Sequence; + if(result.Status!=PlayerKVTicketStatus.Confirmed && !explained) throw Stop("Reward ticket: "+result.Status); + } + } + a.Confirmed=true; + Audit("fishing-reward-confirmed",new{tickets=last.Values.Select(r=>new{key=r.Key,sequence=r.Edit.Ticket.Sequence,valueHash=r.ValueHash})}); + } + catch(Exception e) { Fault(e.Message);throw; } + } + internal static void PresentReward(FishingRewardAction a,Action show) + { + if(!RewardCurrent(a) || !a.Confirmed) throw Stop("Reward cannot be presented before confirmation."); + if(a.Presented)return;a.Presented=true;show();Audit("fishing-reward-presented",new{a.Plan.Fish}); + } + internal static void CancelReward(object actor,string reason) + { + var a=rewardAction; + if(a==null || a.Cancelled || (!ReferenceEquals(actor,a.Controller) && !ReferenceEquals(actor,a.Panel) && !ReferenceEquals(actor,a.Consumption.Actor)))return; + if(a.Ending && !a.Confirmed && ReferenceEquals(actor,a.Controller))return; + a.Cancelled=true; + if(Ready && !a.Confirmed) Fault("Fishing reward cancelled: "+reason); + } + } +} +#endif diff --git a/Client/Assets/Scripts/Services/UserService.KVFishingReward.cs.meta b/Client/Assets/Scripts/Services/UserService.KVFishingReward.cs.meta new file mode 100644 index 000000000..0fe2ccc27 --- /dev/null +++ b/Client/Assets/Scripts/Services/UserService.KVFishingReward.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7e000adddc1b4e5796d6728a1d142813 diff --git a/Client/Assets/Scripts/Services/UserService.KVFishingRewardValidation.cs b/Client/Assets/Scripts/Services/UserService.KVFishingRewardValidation.cs new file mode 100644 index 000000000..454cf07cd --- /dev/null +++ b/Client/Assets/Scripts/Services/UserService.KVFishingRewardValidation.cs @@ -0,0 +1,557 @@ +#if UNITY_EDITOR && !SERVICE_HOST +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using asap.core; +using GameCore; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UniRx; +using UnityEngine; + +namespace game.PlayerDataStorage +{ + internal static partial class PlayerKVGameplayLoop + { + internal static async Task VerifyFishingRewardForEditorAsync() + { + if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || + Resources.FindObjectsOfTypeAll().Length!=0 || PlayFabMgr.HasLiveInstanceForKVLoad || Enabled || handle!=null || + File.Exists(Path.Combine(Root,"permit.json")) || validationCleanupFailure!=null) + throw new InvalidOperationException("Idle editor without a real permit required."); + if(Interlocked.CompareExchange(ref validationRunning,1,0)!=0) throw new InvalidOperationException("Validation running."); + var saved=(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction,rewardPlan,rewardAction); + var oldContainer=GContext.container;var oldEvents=FishingContextAccess.Events; + var folder=Path.Combine(Root,"reward-fixed-runs",DateTime.UtcNow.ToString("yyyyMMddTHHmmss")+"_"+Guid.NewGuid().ToString("N")); + EquipmentFixture fixture=null;Task pending=null;Container container=null;PlayerData player=null; + RewardSubscribers subscribers=null; + var oldLanguages=LocalizationMgr.enumLanguage; + var textTable=typeof(LocalizationMgr).GetField("table",BindingFlags.Static|BindingFlags.NonPublic); + var oldTextTable=textTable.GetValue(null); + GameObject pfShell=null;var pfField=typeof(PlayFabMgr).GetField("_instance",BindingFlags.Static|BindingFlags.NonPublic);var oldPF=pfField.GetValue(null); + int checks=0,groups=0;var passed=new List();string result=null; + var ioDiagnostics=new System.Collections.Concurrent.ConcurrentQueue(); + EventHandler observeIO=(_,e)=> + {if(e.Exception is IOException || e.Exception is UnauthorizedAccessException)ioDiagnostics.Enqueue(e.Exception.ToString());}; + AppDomain.CurrentDomain.FirstChanceException+=observeIO; + void Check(bool yes,string reason) {checks++;if(!yes)throw new InvalidOperationException("Reward validation: "+reason);} + async Task Start(string name) + { + subscribers?.Dispose();subscribers=null; + player?.StopRecoveryEnergy();player=null; + if(fixture!=null)await fixture.Close();if(pending!=null){try{await pending;}catch{}pending=null;} + pfField.SetValue(null,oldPF);if(pfShell!=null)UnityEngine.Object.DestroyImmediate(pfShell);pfShell=null; + FishingContextAccess.Use(oldContainer);FishingContextAccess.Events=oldEvents;container?.Dispose();container=null; + fixture=new EquipmentFixture(Path.Combine(folder,(++groups).ToString())); + fixture.Remote["Map_5_102"]="{\"10211\":\"0|10|20|20|False\",\"10241\":\"2|20|30|40|False\"}"; + var owner=await fixture.Open(); + Enabled=true;Ready=false;Error=null;failed=0;handle=null;collecting=null; + Permit=new PlayerKVLoopPermit{Version=3,Operation="fishing-reward-once",PlayerId="101", + AllowedKeys=new[]{"NewPlayerDataDic","Map_5_102","BattlePassRecordTask","late"}}; + RunPath=folder;mainThread=Thread.CurrentThread.ManagedThreadId;mainContext=SynchronizationContext.Current; + Attach(_=>fixture.Faults++,fixture.Close);Publish(owner); + fishingAction=new FishingAction(new object(),owner){Consuming=true,Confirmed=true,Continued=true}; + rewardPlan=new FishingRewardPlan{Fish=10211,Area=102}; + rewardAction=new FishingRewardAction(fishingAction,new object(),rewardPlan); + passed.Add(name); + } + PlayerKVEditResult Batch(bool localFinal=false) + { + using(BeginRewardSegment("strike")) Write("BattlePassRecordTask","strike",true); + rewardAction.Ending=true;using(BeginRewardSegment("ending")){} + rewardAction.Applying=true; + PlayerKVEditResult primary; + using(BeginRewardSegment("reward")) + { + Write("BattlePassRecordTask","reward-task",true); + Write("Map_5_102","fish",!localFinal); + Write("NewPlayerDataDic","intermediate",false); + primary=Write("NewPlayerDataDic","reward-main",true); + } + rewardAction.GuideNotifications=1; + return primary; + } + async Task Reject(Task task,string label) + { + bool rejected=false;try{await task;}catch(InvalidOperationException){rejected=true;} + Check(rejected && !Ready && fixture.Faults==1,label); + } + void RejectSync(Action body,string label) + {bool rejected=false;try{body();}catch(InvalidOperationException){rejected=true;}Check(rejected,label);} + try + { + Directory.CreateDirectory(folder); + // Exercise the real async Init path, using isolated services and the actual loaded guide asset. + var potionSource=JObject.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath, + "../../CodexOutput/upserver/KVGameplayLoopTests/backups/20260928T082052Z/101.json"))))["data"].ToObject>(); + var guideAssetHandle=UnityEngine.AddressableAssets.Addressables.LoadAssetAsync("GuidanceConfig"); + var guideAsset=(GuidanceDefineSetting)guideAssetHandle.WaitForCompletion(); + var savedGuideGroups=guideAsset.guidanceGroupDefines; + var awaitAssembly=typeof(IEnumeratorAwaitExtensions).Assembly; + var syncType=awaitAssembly.GetType("UnityAsyncAwaitUtil.SyncContextUtil",true); + var syncField=syncType.GetField("k__BackingField",BindingFlags.Static|BindingFlags.NonPublic); + var threadField=syncType.GetField("k__BackingField",BindingFlags.Static|BindingFlags.NonPublic); + var runnerField=awaitAssembly.GetType("UnityAsyncAwaitUtil.AsyncCoroutineRunner",true).GetField("_instance",BindingFlags.Static|BindingFlags.NonPublic); + var savedSync=syncField.GetValue(null);var savedThread=threadField.GetValue(null);var savedRunner=runnerField.GetValue(null); + syncField.SetValue(null,SynchronizationContext.Current);threadField.SetValue(null,Thread.CurrentThread.ManagedThreadId); + try + { + foreach(var scenario in new[]{"unavailable-real-101","available","query-failure","node-panel-mismatch", + "other-group","finished","index-zero","missing-save","out-of-range","preserve-other-pointer"}) + { + await Start("potion-actual-init-"+scenario); + rewardPlan=null;rewardAction=null; + Permit.AllowedKeys=Permit.AllowedKeys.Concat(new[]{"GuideSaveDataNew"}).ToArray(); + var tables=new cfg.Tables(n=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData",n+".json"))))); + container=new Container();FishingContextAccess.Use(container);FishingContextAccess.Events=new EventAggregator(); + player=new PlayerData(tables,null);player.lv=62; + container.RegisterInstance(tables);container.RegisterInstance(player); + var eventData=new FishingEventData(tables); + if(scenario!="query-failure")container.RegisterInstance(eventData); + if(scenario=="available") + { + var ev=tables.TbFishingEvent.DataList.First(e=>e.Type==4 && e.SubType==5); + typeof(cfg.FishingEvent).GetProperty("TimeDefinition").SetValue(ev,new cfg.LimitedTime( + ZZTimeHelper.UtcNow().AddHours(-1).ToString("yyyy/MM/dd HH:mm:ss",System.Globalization.CultureInfo.InvariantCulture), + ZZTimeHelper.UtcNow().AddHours(1).ToString("yyyy/MM/dd HH:mm:ss",System.Globalization.CultureInfo.InvariantCulture))); + typeof(cfg.FishingEvent).GetProperty("ConditionList").SetValue(ev,new List{ + new cfg.EventCondition(cfg.ConditionType.AccountLevel,new List{"1"},"")}); + Check(eventData.GetEvent(4,5)==ev.ID,"isolated actual query sees available potion event"); + } + else if(scenario!="query-failure")Check(eventData.GetEvent(4,5)==-1,"isolated actual query sees no potion event"); + var guideState=new GuideDataCenter(tables,player);container.RegisterInstance(guideState); + var diagnostics=new PlayerKVSchemaDiagnostics();guideState.ReplaceFromKVDecoded(GuideDataCenter.DecodeForKVLoad(potionSource,diagnostics)); + Check(diagnostics.TotalErrors==0,"real 101 guide source decoded"); + var potionGroup=JsonUtility.FromJson(JsonUtility.ToJson(savedGuideGroups.First(g=>g.groupName==GroupName.EventPotion01))); + guideAsset.guidanceGroupDefines=savedGuideGroups.Select(g=>g.groupName==GroupName.EventPotion01?potionGroup:g).ToArray(); + var key=GroupName.EventPotion01.ToString();var entry=guideState.GuideSaveDataDic[key]; + Check(entry.guideSubIndex==4 && !entry.isFinish,"real historical potion progress is unfinished at four"); + if(scenario=="node-panel-mismatch")potionGroup.guidanceDefines[entry.guideSubIndex].panelName="DifferentPanel"; + if(scenario=="other-group") + { + guideState.GuideSaveDataDic.Remove(key);key=GroupName.EventPotion02.ToString(); + entry.groupName=key;guideState.GuideSaveDataDic[key]=entry; + guideAsset.guidanceGroupDefines=guideAsset.guidanceGroupDefines.Where(g=>g.groupName!=GroupName.EventPotion02).ToArray(); + potionGroup.groupName=GroupName.EventPotion02; + } + if(scenario=="finished")entry.isFinish=true; + if(scenario=="index-zero")entry.guideSubIndex=0; + if(scenario=="missing-save")guideState.GuideSaveDataDic.Remove(key); + if(scenario=="out-of-range")entry.guideSubIndex=potionGroup.guidanceDefines.Length; + var otherGroup=savedGuideGroups.First(g=>g.groupName==GroupName.Fish_01); + if(scenario=="preserve-other-pointer") + { + guideState.curGroupDefine=otherGroup;guideState.curDefine=otherGroup.guidanceDefines[0]; + guideState.curSaveData=guideState.GuideSaveDataDic[otherGroup.groupName.ToString()]; + } + var before=JsonConvert.SerializeObject(guideState.GuideSaveDataDic); + pfShell=new GameObject("KV inactive guide Init fixture");pfShell.SetActive(false);pfShell.hideFlags=HideFlags.HideAndDontSave; + var pf=pfShell.AddComponent(); + Check(pf.IsUnstartedForKVLoad,"guide fixture never starts PF lifecycle");pfField.SetValue(null,pf); + var initErrors=new List();var initWarnings=new List();int completeEvents=0; + Application.LogCallback onLog=(message,stack,type)=>{if(type==LogType.Exception || type==LogType.Error)initErrors.Add(message);if(type==LogType.Warning)initWarnings.Add(message);}; + Application.logMessageReceived+=onLog; + var completion=GContext.OnEvent().Subscribe(e=>{if(e.type==cfg.ConditionType.CompleteGuide)completeEvents++;}); + bool initStarted=false; + try + { + // Asset is preloaded above: Init has no outstanding asset operation when teardown begins. + initStarted=true;guideState.Init(); + var deadline=System.Diagnostics.Stopwatch.StartNew(); + while(guideState.guidanceGroupDefines==null && deadline.ElapsedMilliseconds<10000)await Task.Delay(10); + await Task.Delay(1); + Check(guideState.guidanceGroupDefines!=null && initErrors.Count==0,"actual Init completed without asynchronous errors: "+scenario+"; "+string.Join(" | ",initErrors)); + if(scenario=="unavailable-real-101") + { + Check(guideState.curDefine==null && guideState.curGroupDefine==null && guideState.curSaveData==null, + "unavailable historical potion guide is not activated by actual Init"); + Check(before==JsonConvert.SerializeObject(guideState.GuideSaveDataDic),"deferred actual Init preserves every guide entry"); + guideState.AddIndex(true);GContext.Publish(new GuideNextEvent()); + Check(before==JsonConvert.SerializeObject(guideState.GuideSaveDataDic) && completeEvents==0, + "real AddIndex and GuideNext cannot finish deferred potion progress"); + Check(!handle.ReadLocalSnapshot().Data.ContainsKey("GuideSaveDataNew") && fixture.Sets==0, + "deferral and callbacks introduce no guide write or remote request"); + } + else if(scenario=="preserve-other-pointer") + Check(ReferenceEquals(guideState.curGroupDefine,otherGroup) && ReferenceEquals(guideState.curDefine,otherGroup.guidanceDefines[0]),"deferral does not clear another active guide"); + else if(scenario=="available" || scenario=="query-failure" || scenario=="node-panel-mismatch" || scenario=="other-group") + Check(ReferenceEquals(guideState.curGroupDefine,potionGroup) && guideState.curDefine!=null,"non-deferred input retains original recovery: "+scenario); + else + Check(guideState.curGroupDefine==null && guideState.curDefine==null,"original non-resuming boundary retained: "+scenario); + if(scenario!="missing-save")Check(ReferenceEquals(guideState.GuideSaveDataDic[key],entry),"saved progress object retained: "+scenario); + if(scenario=="query-failure")Check(initWarnings.Any(w=>w.Contains("Potion guide availability")),"query failure is reported and not treated as unavailable"); + } + finally + { + completion.Dispose();Application.logMessageReceived-=onLog;guideState.Dispose(); + guideAsset.guidanceGroupDefines=savedGuideGroups; + if(initStarted)UnityEngine.AddressableAssets.Addressables.Release(guideAsset); + } + } + } + finally + { + guideAsset.guidanceGroupDefines=savedGuideGroups; + UnityEngine.AddressableAssets.Addressables.Release(guideAssetHandle); + var runner=runnerField.GetValue(null) as MonoBehaviour; + if(runner!=null && !ReferenceEquals(runner,savedRunner))UnityEngine.Object.DestroyImmediate(runner.gameObject); + runnerField.SetValue(null,savedRunner);syncField.SetValue(null,savedSync);threadField.SetValue(null,savedThread); + } + await Start("original-tickets-cross-segment-supersession");var primary=Batch(); + Check(collecting==null && rewardAction.Segment==null && rewardAction.Records.Count==5,"three scopes disposed and all original writes captured"); + await ConfirmRewardAsync(rewardAction,primary);int shows=0;PresentReward(rewardAction,()=>shows++);PresentReward(rewardAction,()=>shows++); + Check(shows==1 && fixture.Remote["Map_5_102"]=="fish" && fixture.Remote["BattlePassRecordTask"]=="reward-task","final values and one presentation"); + + await Start("pending-cannot-present");primary=Batch();fixture.HoldSet=true; + pending=ConfirmRewardAsync(rewardAction,primary);await fixture.SetEntered.Task; + Check(!rewardAction.Confirmed && !rewardAction.Presented,"pending is not success"); + fixture.ReleaseSet.TrySetResult(true);await pending;pending=null;Check(rewardAction.Confirmed,"response confirms original action"); + + await Start("localOnly-map-final-rejected");await Reject(ConfirmRewardAsync(rewardAction,Batch(true)),"local map not remote confirmed"); + await Start("outside-segment-before-final-save-rejected"); + using(BeginRewardSegment("strike"))Write("BattlePassRecordTask","strike",true); + RejectSync(()=>Write("NewPlayerDataDic","external",true),"external changed write denied before later reward can hide it"); + Check(fixture.Sets==0 && ReadRejected(),"stopped facade and no outgoing request"); + + await Start("same-value-external-supersession-still-rejected");primary=Batch(); + Write("NewPlayerDataDic","reward-main",true); + await Reject(ConfirmRewardAsync(rewardAction,primary),"same-value external ticket is not the original final ticket"); + await Start("unexpected-backed-up-key-before-admission"); + using(BeginRewardSegment("strike"))RejectSync(()=>Write("late","unrelated",true),"backed-up but unrelated reward key rejected"); + Check(rewardAction.Records.Count==0 && fixture.Sets==0,"no unrelated admission"); + + await Start("lost-response-no-reward-replay");primary=Batch();fixture.LoseResponse=true; + await Reject(ConfirmRewardAsync(rewardAction,primary),"unknown result stops reward");Check(fixture.Sets==1 && !rewardAction.Presented,"single request only"); + await Start("unauthorized-no-reauthentication");primary=Batch();fixture.Unauthorized=true; + await Reject(ConfirmRewardAsync(rewardAction,primary),"401 stops reward");Check(fixture.Auth==1 && fixture.Sets==1,"no relogin or replay"); + await Start("partial-commit-is-not-success");primary=Batch();fixture.PartialCommit=true;fixture.LoseResponse=true; + await Reject(ConfirmRewardAsync(rewardAction,primary),"partial result remains failure");Check(fixture.Sets==1 && !rewardAction.Confirmed,"no refund or second write"); + await Start("cancel-after-consume-before-reward-confirmed");primary=Batch();fixture.HoldSet=true; + pending=ConfirmRewardAsync(rewardAction,primary);await fixture.SetEntered.Task; + CancelFishing(fishingAction,"actual consumed action cancellation");fixture.ReleaseSet.TrySetResult(true); + await Reject(pending,"C1 Continued cannot exempt pending reward");pending=null;Check(rewardAction.Cancelled && !rewardAction.Presented,"old callbacks revoked"); + await Start("confirmed-normal-cancel-no-second-exit");primary=Batch();await ConfirmRewardAsync(rewardAction,primary); + CancelReward(rewardAction.Controller,"normal disable");CancelFishing(fishingAction,"normal actor destroy"); + Check(rewardAction.Cancelled && fishingAction.Cancelled && fixture.Faults==0 && Ready,"confirmed normal disable never faults"); + await Start("battle-controller-disable-still-cancels"); + CancelReward(rewardAction.Controller,"controller disabled"); + Check(rewardAction.Cancelled && !Ready && fixture.Faults==1,"battle controller disable still cancels"); + await Start("ending-teardown-pre-confirm-not-cancel");primary=Batch(); + CancelReward(rewardAction.Controller,"controller disabled");CancelReward(rewardAction.Controller,"controller destroyed"); + Check(!rewardAction.Cancelled && Ready && fixture.Faults==0,"ending teardown window is not a cancellation"); + await ConfirmRewardAsync(rewardAction,primary);shows=0;PresentReward(rewardAction,()=>shows++); + Check(shows==1 && rewardAction.Confirmed && rewardAction.Presented,"reward completes and presents after teardown window"); + await Start("repeated-segment-and-sealed-action-rejected"); + using(BeginRewardSegment("strike")){} + RejectSync(()=>BeginRewardSegment("strike"),"strike cannot repeat"); + + await Start("reward-storage-error-before-send"); + var file=Directory.GetFiles(Path.Combine(folder,groups.ToString()),"state.json",SearchOption.AllDirectories).Single(); + using(var locked=new FileStream(file,FileMode.Open,FileAccess.Read,FileShare.None)) + using(BeginRewardSegment("strike"))RejectSync(()=>Write("NewPlayerDataDic","locked",true),"real state-file storage failure"); + Check(fixture.Sets==0 && !Ready,"storage fault before network"); + + foreach(var scenario in new[]{"non-record","new-record","new-fish"}) + { + await Start("real-fish-final-save-"+scenario); + var tables=new cfg.Tables(n=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData",n+".json"))))); + container=new Container();FishingContextAccess.Use(container);FishingContextAccess.Events=new EventAggregator();container.RegisterInstance(tables); + player=new PlayerData(tables,null);var fish=new PlayerFishData(tables,null);container.RegisterInstance(player);container.RegisterInstance(fish); + var core=new PlayerKVCoreState(new object[]{60,100UL,0,393,5,4,30003,new DateTime(2026,1,1),0,112,7UL,2,102,102,13,102,0,0,0}); + player.ReplaceFromKVDecoded(new PlayerKVPlayerState(core,new Dictionary(),new Dictionary{{-1,1}},new List(),0,false,"0")); + var data=new Dictionary{{10241,FishWeightData.Deserialize("2|20|30|40|False")}}; + if(scenario!="new-fish")data[10211]=FishWeightData.Deserialize("0|10|20|20|False"); + var fields=BindingFlags.Instance|BindingFlags.NonPublic; + typeof(PlayerFishData).GetField("fishData",fields).SetValue(fish,data); + typeof(PlayerFishData).GetField("mapFishData",fields).SetValue(fish,new Dictionary>{{102,data}}); + pfShell=new GameObject("KV reward unstarted PF fixture");pfShell.SetActive(false);pfShell.hideFlags=HideFlags.HideAndDontSave; + var pf=pfShell.AddComponent();Check(pf.IsUnstartedForKVLoad,"PF lifecycle never starts");pfField.SetValue(null,pf); + rewardAction.BeforeFishCount=scenario=="new-fish"?0:20; + using(BeginRewardSegment("strike")){}rewardAction.Ending=true;using(BeginRewardSegment("ending")){} + rewardAction.Applying=true; + var events=new List();using(GContext.OnEvent().Subscribe(e=>events.Add(e.type))) + using(BeginRewardSegment("reward")) + { + fish.SetGetFishCount(10211);fish.SetWeight(10211,scenario=="new-record"?12:5); + fish.SaveCaughtFishForKVLoop(10211); + primary=Write("NewPlayerDataDic","fixture-main",true); + } + rewardAction.GuideNotifications=1;await ConfirmRewardAsync(rewardAction,primary); + var savedMap=JsonConvert.DeserializeObject>(fixture.Remote["Map_5_102"]); + var value=FishWeightData.Deserialize(savedMap[10211]); + Check(value.TotalNum==(scenario=="new-fish"?10:30),"original count increment exactly once"); + Check(value.MaxG==(scenario=="new-record"?12:scenario=="new-fish"?5:10),"original maximum weight rule"); + Check(savedMap[10241]=="2|20|30|40|False" && value.TotalCard==(scenario=="new-fish"?0:20) && value.Level==0,"other fish and card data unchanged"); + Check(events.Count==2 && events.Contains(cfg.ConditionType.GetFishWeight) && events.Contains(cfg.ConditionType.GetFishID),"final save never republishes fish events"); + var fishSchema=new PlayerKVSchemaSnapshot("reward-fish",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys,new Dictionary(),System.Globalization.CultureInfo.CurrentCulture,new DateTime(2026,9,24),0, + fish:tables.TbFishData.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishConfig(p.Key,p.Value.AreaID,p.Value.Quality,p.Value.FishCardID,p.Value.BaseWeight,p.Value.PointExtraParam,p.Value.WeightRandom)), + cards:tables.TbFishCard.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishCardConfig(p.Key,p.Value.MaxLevel,p.Value.CardsRequiredList,p.Value.WeightMagList))); + var fishErrors=new PlayerKVSchemaDiagnostics(); + var decodedFish=PlayerKVSaveSchema.DecodeFish(new Dictionary{{"Map_5_102",fixture.Remote["Map_5_102"]}},new Dictionary(),fishSchema,fishErrors); + Check(decodedFish!=null && fishErrors.TotalErrors==0 && decodedFish.Maps[102][10211].Number==value.TotalNum && decodedFish.RawMaps[102][10211].Weight==value.MaxG,"real Map schema reads the saved catch"); + } + foreach(var cash in new[]{0,122}) + { + await Start("actual-main-reward-and-subscribers-cash-"+cash); + var tables=new cfg.Tables(n=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData",n+".json"))))); + container=new Container();FishingContextAccess.Use(container);FishingContextAccess.Events=new EventAggregator();container.RegisterInstance(tables); + LocalizationMgr.enumLanguage=tables.TbLanguageConfig.DataList.Select(x=>x.ID).ToList();textTable.SetValue(null,null); + player=new PlayerData(tables,null);var fish=new PlayerFishData(tables,null);var small=new SmallGameData(tables,null,null); + var achievements=new AchievementDataManager(tables);var box=new FishingBoxData();var events=new FishingEventData(tables); + container.RegisterInstance(player);container.RegisterInstance(fish);container.RegisterInstance(small);container.RegisterInstance(achievements);container.RegisterInstance(box);container.RegisterInstance(events); + fish.ResetForKVLoad();small.ResetForKVLoad();achievements.ResetForKVLoad();box.ResetForKVLoad();achievements.Init(); + var core=new PlayerKVCoreState(new object[]{60,100UL,0,393,5,4,30003,new DateTime(2026,1,1),0,112,7UL,2,102,102,13,102,0,0,0}); + player.ReplaceFromKVDecoded(new PlayerKVPlayerState(core,new Dictionary(),new Dictionary{{-1,1}},new List(),0,false,"0")); + subscribers=new RewardSubscribers();subscribers.Bind(tables,events,container); + Permit.AllowedKeys=Permit.AllowedKeys.Concat(rewardTaskKeys).Distinct().ToArray(); + int countEvents=0;var seen=new List(); + using(GContext.OnEvent().Subscribe(e=>{seen.Add(e.type);if(e.type==cfg.ConditionType.GetFishCount)countEvents++;})) + { + using(BeginRewardSegment("strike"))GContext.Publish(new ConditionTypeEvent(cfg.ConditionType.PerfectStrikeCount,1)); + rewardAction.Ending=true;using(BeginRewardSegment("ending")){} + rewardAction.Applying=true; + using(BeginRewardSegment("reward")) + { + // Actual unchanged data methods; visual/formula extraction is tested separately. + player.SaveFishingFrequency(); + GContext.Publish(new ConditionTypeEvent(cfg.ConditionType.CashFromFishing,cash)); + GContext.Publish(new ConditionTypeEvent(cfg.ConditionType.MaxCashFromFishing,cash/10)); + if(cash>0)player.AddGold((ulong)cash); + foreach(var condition in new[]{cfg.ConditionType.GetFishWeight,cfg.ConditionType.GetFishID,cfg.ConditionType.GetBoosFish,cfg.ConditionType.GetBiggerThanHugeFishCount}) + GContext.Publish(new ConditionTypeEvent(condition,2)); + Write("Map_5_102",Read("Map_5_102"),true); + primary=player.SaveFishingRewardForKVLoop(); + } + } + rewardAction.GuideNotifications=1;await ConfirmRewardAsync(rewardAction,primary); + var modern=JsonConvert.DeserializeObject>(fixture.Remote["NewPlayerDataDic"]); + var coreValues=modern["newData"].Split(','); + Check(coreValues[1]==(100+cash).ToString() && coreValues[14]=="14" && coreValues[15]=="112","cash and one catch with original x10 total frequency"); + Check(coreValues[3]=="393" && coreValues[9]=="112" && countEvents==1,"reward never consumes again or repeats count event"); + Check(modern["ad"]==achievements.AchievementDataToJson() && achievements.GetAchievementDatas(cfg.ConditionType.GetFishCount)==1,"last primary serializes real achievement subscriber"); + Check(modern.ContainsKey("ld") && modern["ld"].Split('|').Length==3,"original luck serialization preserved"); + Check(subscribers.Battle.Data.BattleTask.dic_dailyTasks.Any(k=>tables.TbBattlePassTask.GetOrDefault(k.Key).Type==cfg.ConditionType.PerfectStrikeCount && k.Value==1),"S1 real battle task writes"); + Check(subscribers.Lucky.DicTasks.All(k=>k.Value==0) && subscribers.Magic.DicTasks.All(k=>k.Value==0) && subscribers.Mini.Data.Exp==0,"actual lucky/magic/mini ignore reward conditions absent from their real config"); + foreach(var condition in rewardConditions.Where(x=>x!=cfg.ConditionType.ConsumeEnergy)) + Check(seen.Count(x=>x==condition)==1,"reward condition once: "+condition); + Check(fixture.Remote.ContainsKey("BattlePassRecordTask") && !fixture.Remote.ContainsKey("MiniBPKeyNew") && !fixture.Remote.ContainsKey("EventLuckGameRecord") && !fixture.Remote.ContainsKey("EventLuckMagicPlayfabData"),"only applicable original task saves confirmed; no invented task updates"); + var schema=new PlayerKVSchemaSnapshot("reward-main",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys,new Dictionary(),System.Globalization.CultureInfo.CurrentCulture,new DateTime(2026,9,24),0); + var errors=new PlayerKVSchemaDiagnostics();Check(PlayerKVSaveSchema.DecodeCore(new Dictionary(),modern,schema,errors)!=null && errors.TotalErrors==0,"main schema reads final original serializer"); + } + await Start("actual-reentry-and-old-permit-gates"); + Check(!BeginRewardDrawing(rewardAction.Controller),"duplicate drawing never reaches original battle"); + rewardAction.Ending=true;Check(!BeginRewardEnding(rewardAction.Controller,true),"duplicate fish end never opens another panel"); + rewardAction.Applying=true;Check(BindRewardPanel(new object())==null,"second panel never reapplies rewards"); + Permit.Version=2;Permit.Operation="fishing-consume-once";RejectSync(()=>BeginRewardDrawing(new object()),"v2 cannot draw"); + await Start("v1-cannot-draw");Permit.Version=1;Permit.Operation=null;RejectSync(()=>BeginRewardDrawing(new object()),"v1 cannot draw"); + await Start("natural-failure-stops-without-reward"); + RejectSync(()=>BeginRewardEnding(rewardAction.Controller,false),"natural failure not converted to catch"); + Check(fixture.Sets==0 && rewardAction.Records.Count==0 && !rewardAction.Applying,"failure has no reward or refund"); + foreach(bool complete in new[]{false,true}) + { + await Start(complete?"actual-panel-disable-confirmed":"actual-panel-disable-pending"); + primary=Batch();if(complete)await ConfirmRewardAsync(rewardAction,primary); + var shell=new GameObject("KV inactive reward panel lifecycle fixture");shell.SetActive(false);shell.hideFlags=HideFlags.HideAndDontSave; + try + { + var panel=shell.AddComponent();rewardAction.Panel=panel; + typeof(FishingRewardPanel).GetMethod("OnDisable",BindingFlags.Instance|BindingFlags.NonPublic).Invoke(panel,null); + Check(rewardAction.Cancelled && fixture.Faults==(complete?0:1),"actual panel OnDisable revokes pending only"); + Check(!RewardPanelCurrent(rewardAction,panel),"old panel continuation is revoked"); + } + finally{UnityEngine.Object.DestroyImmediate(shell);} + } + await Start("real-backup-no-special-fish-guide-preflight"); + container=new Container();FishingContextAccess.Use(container);FishingContextAccess.Events=new EventAggregator(); + var guide=new GuideDataCenter(null,null);container.RegisterInstance(guide); + var backup=JsonConvert.DeserializeObject>(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../CodexOutput/upserver/KVGameplayLoopTests/backups/20260924T084836Z/102.json")))); + var raw=JsonConvert.DeserializeObject>(backup["data"].ToString()); + var guideErrors=new PlayerKVSchemaDiagnostics();guide.ReplaceFromKVDecoded(GuideDataCenter.DecodeForKVLoad(raw,guideErrors)); + guide.guidanceGroupDefines=UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/ABPackage/pkgPrepare/Export/GuidanceConfig.asset").guidanceGroupDefines; + Check(guideErrors.TotalErrors==0,"real guide backup decodes"); + var gateTables=new cfg.Tables(n=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData",n+".json"))))); + container.RegisterInstance(gateTables); + rewardPlan.Data=new FishingData{curFishData=gateTables.TbFishData.GetOrDefault(10211)}; + var guideBefore=JsonConvert.SerializeObject(guide.GuideSaveDataDic); + CheckRewardGuide(); + Check(Ready && fixture.Sets==0 && guideBefore==JsonConvert.SerializeObject(guide.GuideSaveDataDic),"ordinary fish admitted without guide writes or consumption"); + var originalGroups=guide.guidanceGroupDefines; + foreach(var kind in new[]{"ordinary-incomplete","active-node","active-group","last-node","extra-node","missing-node","trigger","parameter","unknown-group","positive-skill","last-skill","escape-skill","active-skill","active-dash","active-dirty"}) + { + // Read-only fault probes share an isolated fixture; no real run is reset. + var guideCopy=JsonUtility.FromJson(JsonUtility.ToJson(originalGroups.First(g=>g.groupName.ToString()=="FishSkill01"))); + var baseFish=gateTables.TbFishData.GetOrDefault(10211); + var fishJson=SimpleJSON.JSON.Parse(baseFish.json.ToString()); + var testFish=new cfg.FishData(fishJson); + var testData=new FishingData{curFishData=testFish}; + guide.guidanceGroupDefines=originalGroups.Select(g=>g.groupName==guideCopy.groupName?guideCopy:g).ToArray(); + guide.curDefine=null;guide.curGroupDefine=null; + guide.GuideSaveDataDic["Fish_01"].isFinish=true; + switch(kind) + { + case "ordinary-incomplete":guide.GuideSaveDataDic["Fish_01"].isFinish=false;break; + case "active-node":guide.curDefine=guideCopy.guidanceDefines[0];break; + case "active-group":guide.curGroupDefine=guideCopy;break; + case "last-node":guideCopy.guidanceDefines.Last().panelName="FishingRewardPanel";break; + case "extra-node":guideCopy.guidanceDefines=guideCopy.guidanceDefines.Concat(new[]{new GuidanceDefine{panelName="FishingPanel_Advanced"}}).ToArray();break; + case "missing-node":guideCopy.guidanceDefines=Array.Empty();break; + case "trigger":guideCopy.TriggerConditionsType=GuideTriggerConditions.Next;break; + case "parameter":guideCopy.TriggerConditionsParameter="changed";break; + case "unknown-group":guideCopy.groupName=(GroupName)999999;break; + case "positive-skill":case "last-skill":case "escape-skill": + var skillJson=SimpleJSON.JSON.Parse(baseFish.FishEscape.json.ToString());skillJson["SpSkillID"]=123456789; + if(kind=="escape-skill")fishJson["FishEscape"]=skillJson; + else {var list=new SimpleJSON.JSONArray();if(kind=="last-skill")list.Add(SimpleJSON.JSON.Parse(baseFish.FishEscape.json.ToString()));list.Add(skillJson);fishJson["FishSKillList"]=list;} + testData.curFishData=new cfg.FishData(fishJson);break; + case "active-skill":testData.fishSpSkillData=new CorruptSpSkillData();break; + case "active-dash":testData.superDashSpSkillData=new SuperDashSpSkillData();break; + case "active-dirty":testData.dirtyStuckSpSkillData=new DirtyStuckSpSkillData();break; + } + // Stop() is idempotent; all cases are read-only gate probes, not fresh consumption attempts. + RejectSync(()=>CheckRewardGuide(testData),"guide/skill drift denied: "+kind); + Check(fixture.Sets==0,"gate drift never writes: "+kind); + } + guide.guidanceGroupDefines=originalGroups;guide.curDefine=null;guide.curGroupDefine=null;guide.GuideSaveDataDic["Fish_01"].isFinish=true; + + foreach(var scenario in new[]{"ordinary","zero-cruise","cruise","invalid-cruise","free","retry","fixed","duel","special-item","missing-map","wrong-area","event-field","event-count","trigger-pack","activity-drift","guide-before-S1","guide-before-S2","guide-before-S3"}) + { + await Start("actual-full-reward-preflight-"+scenario); + var tables=new cfg.Tables(n=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData",n+".json"))))); + container=new Container();FishingContextAccess.Use(container);FishingContextAccess.Events=new EventAggregator();container.RegisterInstance(tables); + player=new PlayerData(tables,null);var fish=new PlayerFishData(tables,null);fish.ResetForKVLoad(); + var events=new FishingEventData(tables);var guideState=new GuideDataCenter(tables,player); + var diag=new PlayerKVSchemaDiagnostics();guideState.ReplaceFromKVDecoded(GuideDataCenter.DecodeForKVLoad(raw,diag));guideState.guidanceGroupDefines=originalGroups; + guideState.guidanceDefineSetting=UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/ABPackage/pkgPrepare/Export/GuidanceConfig.asset"); + var scopes=new Activity.ActivityScopeManager();var manager=new Activity.ActivityManager(tables,scopes); + container.RegisterInstance(player);container.RegisterInstance(fish);container.RegisterInstance(events);container.RegisterInstance(guideState); + container.RegisterInstance(new CollectingData());container.RegisterInstance(new PiggyBankPackData());container.RegisterInstance(new BargainPackData());container.RegisterInstance(new FishingChallengeManager()); + container.RegisterInstance(scopes);container.RegisterInstance(manager); + player.ReplaceFromKVDecoded(new PlayerKVPlayerState(new PlayerKVCoreState(new object[]{60,100UL,0,393,5,4,30003,new DateTime(2026,1,1),0,112,7UL,2,102,102,13,102,0,0,0}),new Dictionary(),new Dictionary{{-1,1}},new List(),0,false,"0")); + player.IsOpenMagnification=true;fish.IsOpenPiercing=true; + var data=new FishingData{curFishData=tables.TbFishData.GetOrDefault(10211),fishItem=tables.TbItem.DataList.First(x=>x.RedirectID==10211 && x.SubType==1),RodId=30003,MapId=102}; + rewardPlan=null;rewardAction=null; + switch(scenario) + { + case "zero-cruise":case "cruise":case "invalid-cruise": + typeof(PlayerFishData).GetField("fishSwimToMap",BindingFlags.Instance|BindingFlags.NonPublic).SetValue(fish,new Dictionary{{102,scenario=="zero-cruise"?0:scenario=="cruise"?10211:-2}});break; + case "free":data.IsFree=true;break;case "retry":data.IsRetry=true;break;case "duel":data.IsDuel=true;break; + case "fixed":data.fixedFishList=tables.TbFixedFishList.DataList.First();break; + case "special-item":data.fishItem=tables.TbItem.DataList.First(x=>x.SubType==11);break; + case "missing-map":Permit.AllowedKeys=new[]{"NewPlayerDataDic"};break; + case "wrong-area":var fj=SimpleJSON.JSON.Parse(data.curFishData.json.ToString());fj["AreaID"]=999999;data.curFishData=new cfg.FishData(fj);break; + case "event-field":var ev=tables.TbFishingEvent.DataList.First(x=>x.ID==10100000);typeof(cfg.FishingEvent).GetProperty("PopupPriority").SetValue(ev,ev.PopupPriority+1);break; + case "event-count":tables.TbFishingEvent.DataList.Remove(tables.TbFishingEvent.DataList.First(x=>x.ID==10100000));break; + case "trigger-pack":typeof(cfg.TriggerPackManager).GetProperty("Condition").SetValue(tables.TbTriggerPackManager.DataList.First(),cfg.ConditionType.GetFishCount);break; + } + if(scenario=="ordinary" || scenario=="zero-cruise" || scenario=="activity-drift" || scenario.StartsWith("guide-before-")) + { + PrepareFishingReward(fishingAction,data); + Check(Ready && rewardPlan.Data==data && fixture.Sets==0,"actual full preflight admitted before any consumption"); + if(scenario=="activity-drift") + {scopes.CreateActivityScope((1,1));RejectSync(CheckRewardContext,"actual activity scope drift denied");} + else if(scenario.StartsWith("guide-before-")) + { + var actor=new object(); + if(scenario!="guide-before-S1") {Check(BeginRewardDrawing(actor),"first drawing");using(BeginRewardSegment("strike")){} } + if(scenario=="guide-before-S3") {Check(BeginRewardEnding(actor,true),"first ending");using(BeginRewardSegment("ending")){} } + guideState.curDefine=originalGroups.First().guidanceDefines.First(); + if(scenario=="guide-before-S1")RejectSync(()=>BeginRewardDrawing(actor),"active guide before drawing"); + else if(scenario=="guide-before-S2")RejectSync(()=>BeginRewardEnding(actor,true),"active guide before ending"); + else RejectSync(()=>BindRewardPanel(new object()),"active guide before reward data"); + } + else + { + var actor=new object();Check(BeginRewardDrawing(actor),"actual prepare-draw");using(BeginRewardSegment("strike")){} + Check(BeginRewardEnding(actor,true),"actual ending");using(BeginRewardSegment("ending")){} + Check(BindRewardPanel(new object())!=null,"actual panel binding with before count"); + using(GContext.OnEvent().Subscribe(guideState.TriggerGuideNext)) + using(BeginRewardSegment("reward")){GContext.Publish(new OnEventTriggerGuide("FishingRewardPanel"));RecordRewardGuideNotification();} + Check(rewardAction.GuideNotifications==1 && guideState.curDefine==null && guideState.curGroupDefine==null,"one original guide notification and no activated guide"); + } + } + else RejectSync(()=>PrepareFishingReward(fishingAction,data),"full gate rejects "+scenario); + Check(fixture.Sets==0,"preflight case no network or consumed energy: "+scenario); + } + await Start("reward-confirmation-timeout-no-replay");primary=Batch();fixture.HoldSet=true; + pending=ConfirmRewardAsync(rewardAction,primary);await fixture.SetEntered.Task; + await Reject(pending,"actual 30-second timeout");pending=null;fixture.ReleaseSet.TrySetResult(true); + Check(fixture.Sets==1 && !rewardAction.Confirmed && !rewardAction.Presented,"timeout never repeats reward"); + result=JsonConvert.SerializeObject(new{success=true,groups,assertions=checks,passed,formalRequests=0,synthetic=true,folder}); + } + finally + { + try + { + subscribers?.Dispose();subscribers=null; + LocalizationMgr.enumLanguage=oldLanguages;textTable.SetValue(null,oldTextTable); + player?.StopRecoveryEnergy();if(fixture!=null)await fixture.Close();if(pending!=null){try{await pending;}catch{}} + pfField.SetValue(null,oldPF);if(pfShell!=null)UnityEngine.Object.DestroyImmediate(pfShell); + FishingContextAccess.Use(oldContainer);FishingContextAccess.Events=oldEvents;container?.Dispose(); + (Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction,rewardPlan,rewardAction)=saved; + } + catch {validationCleanupFailure=fixture;throw;} + finally + { + AppDomain.CurrentDomain.FirstChanceException-=observeIO; + if(Directory.Exists(folder))File.WriteAllText(Path.Combine(folder,"storage-diagnostics.json"),JsonConvert.SerializeObject(ioDiagnostics.ToArray())); + Interlocked.Exchange(ref validationRunning,0); + } + } + File.WriteAllText(Path.Combine(folder,"result.json"),result);return result; + } + sealed class RewardSubscribers:IDisposable + { + const System.Reflection.BindingFlags Fields=System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic; + static readonly System.Reflection.FieldInfo PF=typeof(PlayFabMgr).GetField("_instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic); + static readonly System.Reflection.FieldInfo Red=typeof(RedPointManager).GetField("instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic); + readonly object oldRed=Red.GetValue(null),oldPF=PF.GetValue(null); + GameObject shell; LuckyTaskData magicTask; MiniBattlePassDataProvider provider; + internal BattlePassDataProvider Battle; internal EventLuckyGameModel Lucky; + internal EventLuckMagicModel Magic; internal MiniBattlePassDataModel Mini; + internal void Bind(cfg.Tables tables,FishingEventData events,Container container) + { + Red.SetValue(null,new RedPointManager()); + shell=new GameObject("KV inactive subscriber storage fixture");shell.SetActive(false);shell.hideFlags=HideFlags.HideAndDontSave; + var pf=shell.AddComponent(); + if(!pf.IsUnstartedForKVLoad)throw new InvalidOperationException("No PF lifecycle may start in this fixture."); + PF.SetValue(null,pf); + var items=new PlayerItemData(tables);container.RegisterInstance(items); + var data=new BattlePassData(tables); + data.BattlePassRecord.ID=tables.TbFishingEvent.DataList.First(e=>tables.TbBattlePassMain.GetOrDefault(e.RedirectID)!=null).ID; + data.BattlePassRecord.Level=1; + foreach(var t in tables.TbBattlePassTask.DataList.Where(t=>rewardConditions.Contains(t.Type)))data.BattleTask.dic_dailyTasks.Add(t.TaskID,0); + Battle=new BattlePassDataProvider(tables,data,items);container.RegisterInstance(Battle); + var common=tables.TbCommonTask.DataList.Where(t=>rewardConditions.Contains(t.TaskType)).Select(t=>t.Id).ToArray(); + Lucky=new EventLuckyGameModel(events,tables);foreach(var id in common)Lucky.DicTasks.Add(id,0); + Magic=new EventLuckMagicModel{DicTasks=common.ToDictionary(x=>x,x=>0),HigherRingData=EventLuckMagicHigherRingData.CreateEmpty()}; + var rings=(List)typeof(EventLuckMagicModel).GetField("_rings",Fields).GetValue(Magic); + for(int i=1;i<=3;i++)rings.Add(EventLuckMagicRing.CreateEmpty(1,0,(EEventLuckMagicRingType)i)); + magicTask=new LuckyTaskData(Magic);typeof(EventLuckMagicModel).GetField("_taskData",Fields).SetValue(Magic,magicTask); + provider=new MiniBattlePassDataProvider(tables,items); + var table=tables.TbMiniBattlePass.DataList.First(t=>t.TaskType==cfg.ConditionType.ConsumeEnergy && t.Param==0); + var activity=tables.TbFishingEvent.DataList.First(t=>t.Type==9 && t.RedirectID==table.Id); + var type=(MiniBattlePassType)table.Type; + Mini=provider.GetModel(type); + provider.GetMiniBattlePassRecord("BP_"+type).Id=activity.ID; + typeof(MiniBattlePassDataModel).GetField("fishingEvent",Fields).SetValue(Mini,activity); + typeof(MiniBattlePassDataModel).GetMethod("InitData",Fields).Invoke(Mini,new object[]{table.Id}); + // Synthetic fixture has an active window; real-run time is never modified. + Mini.startTime=ZZTimeHelper.UtcNow().AddDays(-1); + typeof(MiniBattlePassDataModel).GetProperty("endTime").SetValue(Mini,ZZTimeHelper.UtcNow().AddDays(1)); + } + public void Dispose() + { + Battle?.Dispose();Lucky?.Dispose();magicTask?.Dispose(); + if(Mini!=null)(typeof(MiniBattlePassDataModel).GetField("disposables",Fields).GetValue(Mini) as IDisposable)?.Dispose(); + PF.SetValue(null,oldPF);Red.SetValue(null,oldRed); + if(shell!=null)UnityEngine.Object.DestroyImmediate(shell); + } + } + static bool ReadRejected() {try{Read("NewPlayerDataDic");return false;}catch(InvalidOperationException){return true;}} + } +} +#endif diff --git a/Client/Assets/Scripts/Services/UserService.KVFishingRewardValidation.cs.meta b/Client/Assets/Scripts/Services/UserService.KVFishingRewardValidation.cs.meta new file mode 100644 index 000000000..493d585c4 --- /dev/null +++ b/Client/Assets/Scripts/Services/UserService.KVFishingRewardValidation.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8725a7f81e4971046acaa6c14a8f6f97 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Scripts/Services/UserService.KVFishingValidation.cs b/Client/Assets/Scripts/Services/UserService.KVFishingValidation.cs new file mode 100644 index 000000000..cf3c6dd88 --- /dev/null +++ b/Client/Assets/Scripts/Services/UserService.KVFishingValidation.cs @@ -0,0 +1,330 @@ +#if UNITY_EDITOR && !SERVICE_HOST +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using asap.core; +using GameCore; +using Newtonsoft.Json; +using UniRx; +using DG.Tweening; +using UnityEngine; + +namespace game.PlayerDataStorage +{ + internal static partial class PlayerKVGameplayLoop + { + // Only a fixed idle-editor test; the handler inherited from EquipmentFixture never forwards requests. + internal static async Task VerifyFishingForEditorAsync() + { + if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || + Resources.FindObjectsOfTypeAll().Length!=0 || PlayFabMgr.HasLiveInstanceForKVLoad || + Enabled || handle!=null || File.Exists(Path.Combine(Root,"permit.json")) || validationCleanupFailure!=null) + throw new InvalidOperationException("Idle editor without a real permit required."); + if(Interlocked.CompareExchange(ref validationRunning,1,0)!=0) throw new InvalidOperationException("Validation running."); + var saved=(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction); + var oldContainer=GContext.container; var oldEvents=FishingContextAccess.Events; + var folder=Path.Combine(Root,"fishing-fixed-runs",DateTime.UtcNow.ToString("yyyyMMddTHHmmss")+"_"+Guid.NewGuid().ToString("N")); + EquipmentFixture fixture=null; Task pending=null; Container container=null; PlayerData player=null; IDisposable subscription=null; + var oldLanguages=LocalizationMgr.enumLanguage; + var textTable=typeof(LocalizationMgr).GetField("table",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic); + var oldTextTable=textTable.GetValue(null); + FishingSubscribers subscribers=null; + int checks=0,groups=0; var passed=new List(); string result=null; + void Check(bool yes,string reason) { checks++; if(!yes) throw new InvalidOperationException("Fishing validation: "+reason); } + async Task Start(string name) + { + subscribers?.Dispose(); subscribers=null; + player?.StopRecoveryEnergy(); player=null; subscription?.Dispose(); subscription=null; + if(fixture!=null) await fixture.Close(); + if(pending!=null) { try { await pending; } catch { } pending=null; } + FishingContextAccess.Use(oldContainer); FishingContextAccess.Events=oldEvents; container?.Dispose(); container=null; + fixture=new EquipmentFixture(Path.Combine(folder,(++groups).ToString())); + var owner=await fixture.Open(); + Enabled=true; Ready=false; Error=null; failed=0; handle=null; collecting=null; fishingAction=null; + Permit=new PlayerKVLoopPermit{Version=2,Operation="fishing-consume-once",PlayerId="101",AllowedKeys=new[]{"k","NewPlayerDataDic","late"}}; + RunPath=folder; mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current; + Attach(_=>fixture.Faults++,fixture.Close); Publish(owner); + passed.Add(name); + } + (FishingAction action,WriteBatch batch,PlayerKVEditResult primary) Batch(bool duplicates=false) + { + var action=BeginFishing(new object()); Check(ClaimFishingConsumption(action),"first consume admitted"); + var batch=BeginFishingWrites(action); + if(duplicates) { Write("NewPlayerDataDic","intermediate-local",false); Write("NewPlayerDataDic","intermediate-remote",true); } + Write("k","task",true); var primary=Write("NewPlayerDataDic","final",true); batch.Dispose(); + return(action,batch,primary); + } + async Task Reject(Task task,string label) + { + bool rejected=false; try { await task; } catch(InvalidOperationException) { rejected=true; } + Check(rejected && !Ready && fixture.Faults==1,label); + } + try + { + Directory.CreateDirectory(folder); + await Start("duplicate-state-writes-and-original-final-tickets"); + var b=Batch(true); Check(b.batch.Records.Count==4 && !b.batch.Records[0].Remote && b.batch.Records[0].Edit.Ticket==null,"local intermediate captured"); + await ConfirmFishingAsync(b.action,b.batch,b.primary); + Check(fixture.Remote["NewPlayerDataDic"]=="final" && fixture.Remote["k"]=="task","both final values persisted"); + int continued=0; ContinueFishing(b.action,()=>continued++); ContinueFishing(b.action,()=>continued++); + Check(continued==1 && !ClaimFishingConsumption(b.action) && BeginFishing(new object())==null,"continue and consume/cast only once"); + + await Start("pending-response-does-not-continue"); b=Batch(); fixture.HoldSet=true; + pending=ConfirmFishingAsync(b.action,b.batch,b.primary); + Check(ReferenceEquals(await Task.WhenAny(fixture.SetEntered.Task,pending,Task.Delay(5000)),fixture.SetEntered.Task),"Set entered"); + Check(!b.action.Confirmed && !b.action.Continued,"no continuation during pending response"); + fixture.ReleaseSet.TrySetResult(true); await pending; pending=null; + Check(b.action.Confirmed,"original response confirms"); + + await Start("final-localOnly-rejected-before-flush"); b=Batch(); + // A sealed record ledger from actual writes with a localOnly final key. + collecting=b.batch; Write("k","local-tail",false); b.batch.Dispose(); + await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"local final cannot report remote success"); Check(fixture.Sets==0,"no flush of rejected batch"); + + await Start("external-final-supersession"); b=Batch(); Write("NewPlayerDataDic","later-action",true); + await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"never substitute later ticket"); + + await Start("lost-response-after-commit"); b=Batch(); fixture.LoseResponse=true; + await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"Unknown stops run"); + Check(fixture.Sets==1 && fixture.Remote["NewPlayerDataDic"]=="final","one uncertain commit, no replay"); + + await Start("unauthorized-does-not-reauthenticate"); b=Batch(); fixture.Unauthorized=true; + await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"401 stops run"); Check(fixture.Auth==1 && fixture.Sets==1,"single auth and Set"); + + await Start("cancelled-object-does-not-continue-after-commit"); b=Batch(); fixture.HoldSet=true; + pending=ConfirmFishingAsync(b.action,b.batch,b.primary); await fixture.SetEntered.Task; + CancelFishing(b.action,"synthetic disable"); fixture.ReleaseSet.TrySetResult(true); + await Reject(pending,"disabled original action cannot continue"); pending=null; + Check(!b.action.Continued && b.action.Cancelled,"no stale continuation"); + + await Start("close-before-confirmation"); b=Batch(); await fixture.Close(); + await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"closed owner rejected"); Check(fixture.Sets==0,"close does not invent flush"); + + await Start("partial-commit-is-not-atomic-success"); b=Batch(); fixture.PartialCommit=true; fixture.LoseResponse=true; + await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"partial unknown stops run"); + Check(fixture.Sets==1 && fixture.Remote["k"]=="task" && fixture.Remote["NewPlayerDataDic"]=="original-save","partial commit retained without compensation"); + + await Start("storage-error-before-send"); + var stateFile=Directory.GetFiles(Path.Combine(folder,groups.ToString()),"state.json",SearchOption.AllDirectories).Single(); + using(var locked=new FileStream(stateFile,FileMode.Open,FileAccess.Read,FileShare.None)) + { + bool storage=false; try { Write("NewPlayerDataDic","locked",true); } catch(InvalidOperationException e) { storage=e.Message.Contains("StorageError"); } + Check(storage && !Ready && fixture.Faults==1 && fixture.Sets==0,"storage failure cannot be accepted or retried"); + } + + foreach(bool complete in new[]{false,true}) + { + await Start(complete?"actual-tween-autoKill-after-complete":"actual-tween-kill-before-complete"); + var shell=new GameObject("KV inactive fishing tween fixture"); shell.SetActive(false); shell.hideFlags=HideFlags.HideAndDontSave; + Tween tween=null; + // DOTween.Init deliberately does nothing in EditMode; Kill otherwise returns + // before reaching the real tween engine. Scope this fixed fixture gate to this + // synchronous block and restore it even when an assertion fails. + var initialized=typeof(DOTween).GetField("initialized",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic|System.Reflection.BindingFlags.Public); + var wasInitialized=initialized.GetValue(null); + try + { + initialized.SetValue(null,true); + var behavior=shell.AddComponent(); + var action=BeginFishing(behavior); + typeof(FishingBehaviorB).GetField("kvFishingAction",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).SetValue(behavior,action); + float value=0; tween=DOTween.To(()=>value,v=>value=v,1,100).SetAutoKill(true).SetUpdate(UpdateType.Manual); + typeof(FishingBehaviorB).GetMethod("TrackKVCastTween",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).Invoke(behavior,new object[]{tween}); + Check(tween.onKill!=null && tween.onComplete!=null,"actual callbacks installed"); + if(complete) tween.Complete(); else tween.Kill(false); + Check(action.Cancelled!=complete && !action.Continued,"actual Tween Kill boundary complete="+complete+" cancelled="+action.Cancelled+" enabled="+Enabled+" active="+tween.IsActive()); + Check(fixture.Faults==(complete?0:1) && fixture.Sets==0,"autoKill is not a cancellation"); + } + finally + { + try { tween?.Kill(false); UnityEngine.Object.DestroyImmediate(shell); } + finally { initialized.SetValue(null,wasInitialized); } + } + } + + await Start("completed-action-normal-disable-is-not-failure"); + var completedShell=new GameObject("KV completed disable fixture");completedShell.SetActive(false);completedShell.hideFlags=HideFlags.HideAndDontSave; + try + { + var behavior=completedShell.AddComponent(); + var action=BeginFishing(behavior);Check(ClaimFishingConsumption(action),"completed action admitted"); + var batch=BeginFishingWrites(action);var primary=Write("NewPlayerDataDic","completed",true);batch.Dispose(); + await ConfirmFishingAsync(action,batch,primary);ContinueFishing(action,()=>{}); + typeof(FishingBehaviorB).GetField("kvFishingAction",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).SetValue(behavior,action); + typeof(FishingBehaviorB).GetMethod("OnDisable",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).Invoke(behavior,null); + Check(action.Cancelled && action.Continued && Ready && fixture.Faults==0,"normal disable after completed consumption must not Fault"); + Check(!FishingCurrent(action) && BeginFishing(behavior)==null,"normal disable still revokes callbacks and never restores quota"); + } + finally { UnityEngine.Object.DestroyImmediate(completedShell); } + + await Start("observation-timeout-does-not-replay"); b=Batch(); fixture.HoldSet=true; + pending=ConfirmFishingAsync(b.action,b.batch,b.primary); + await Reject(pending,"30 second observer deadline stops continuation"); pending=null; + Check(fixture.Sets==1 && !b.action.Continued,"timeout leaves one admitted write"); + fixture.ReleaseSet.TrySetResult(true); + + await Start("equipment-permit-cannot-cast"); Permit.Version=1; Permit.Operation=null; + bool denied=false; try { BeginFishing(new object()); } catch(InvalidOperationException) { denied=true; } + Check(denied && fixture.Sets==0,"old permit cannot consume"); + + foreach(var scenario in new[]{"above-limit","cross-limit","below-limit","recovery-during-pending"}) + { + await Start("actual-PlayerData-"+scenario); + int initialEnergy=scenario=="above-limit"?400:scenario=="cross-limit"?300:250; + var tables=new cfg.Tables(n=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData",n+".json"))))); + container=new Container(); FishingContextAccess.Use(container); FishingContextAccess.Events=new EventAggregator(); + container.RegisterInstance(tables); + LocalizationMgr.enumLanguage=tables.TbLanguageConfig.DataList.Select(x=>x.ID).ToList(); + textTable.SetValue(null,null); + player=new PlayerData(tables,null); var small=new SmallGameData(tables,null,null); var fish=new PlayerFishData(tables,null); + var achievements=new AchievementDataManager(tables); var box=new FishingBoxData(); var events=new FishingEventData(tables); + container.RegisterInstance(player); container.RegisterInstance(small); container.RegisterInstance(fish); + container.RegisterInstance(achievements); container.RegisterInstance(box); container.RegisterInstance(events); + container.RegisterInstance(new FishingNotifications()); + fish.ResetForKVLoad(); small.ResetForKVLoad(); achievements.ResetForKVLoad(); box.ResetForKVLoad(); + achievements.Init(); + int rod=tables.TbRodData.DataMap.Keys.First(),map=tables.TbMapData.DataMap.Keys.First(); + var core=new PlayerKVCoreState(new object[]{1,10UL,0,initialEnergy,5,0,rod,new DateTime(2026,1,1),0,7,10UL,2,map,map,0,0,0,0,0}); + player.ReplaceFromKVDecoded(new PlayerKVPlayerState(core,new Dictionary(),new Dictionary{{-1,1}},new List(),0,false,"0")); + // Fixed backing property only, no caller-controlled reflection or gameplay registration. + typeof(PlayerData).GetProperty("benefitsEnergyLimit").SetValue(player,300); + typeof(PlayerData).GetProperty("benefitsEnergyRecover").SetValue(player,1); + if(scenario=="above-limit") + { + Permit.AllowedKeys=Permit.AllowedKeys.Concat(new[]{"BattlePassRecordTask","MiniBPKeyNew","EventLuckGameRecord","EventLuckMagicPlayfabData"}).ToArray(); + subscribers=new FishingSubscribers(); + subscribers.Bind(tables,events,container); + Check(!tables.TbFishingEvent.DataList.Any(e=>e.ConditionList.Any(c=>c.Condition==cfg.ConditionType.ConsumeEnergy)) && + !tables.TbTriggerPackManager.DataList.Any(t=>t.Condition==cfg.ConditionType.ConsumeEnergy),"real config excludes async activation and trigger-pack writes"); + } + int consumed=0,notified=0; + subscription=GContext.OnEvent().Subscribe(e=>{if(e.type==cfg.ConditionType.ConsumeEnergy){consumed+=e.count; Write("k","task-"+e.count,true);}}); + using(GContext.OnEvent().Subscribe(_=>notified++)) + { + var action=BeginFishing(new object()); Check(ClaimFishingConsumption(action),"real action claimed"); + fixture.HoldSet=scenario=="recovery-during-pending"; + var consumption=player.ConsumeEnergyForKVLoopAsync(3,action); pending=consumption; + if(fixture.HoldSet) + { + await fixture.SetEntered.Task; + Check(!action.Confirmed && !action.Continued,"real player pending cannot continue"); + // Drive the actual due recovery tick while the original Set is still held. + // No replacement serialization, no paused production recovery algorithm. + player.CoRecoveryEnergy(tables.TbGlobalConfig.InitEnergyRestorationSpeed*60); + Check((int)player.Energy==initialEnergy-2 && !action.Confirmed,"real recovery can write during pending"); + fixture.ReleaseSet.TrySetResult(true); + } + await consumption; pending=null; + Check((int)player.Energy==initialEnergy-(fixture.HoldSet?2:3) && player.GetEnergyRM()==10 && consumed==3,"one real deduction and event"); + Check(notified==(scenario=="cross-limit"?2:fixture.HoldSet?3:1),"original notification count preserved"); + var modern=JsonConvert.DeserializeObject>(fixture.Remote["NewPlayerDataDic"]); + Check(modern["newData"].Split(',')[3]==(initialEnergy-3).ToString() && !modern.ContainsKey("energy"),"one canonical representation"); + Check(fixture.Remote["k"]=="task-3" && action.Confirmed,"synchronous task write confirmed"); + Check(achievements.GetAchievementDatas(cfg.ConditionType.ConsumeEnergy)==3 && modern["ad"].Contains(":") && modern["ad"]==achievements.AchievementDataToJson(),"actual achievement subscriber captured after event"); + var savedTime=DateTime.Parse(modern["newData"].Split(',')[7]); + Check(scenario=="cross-limit"?savedTime>new DateTime(2026,1,1):savedTime==new DateTime(2026,1,1),"recovery timestamp crossing behavior preserved"); + if(fixture.HoldSet) + { + await PlayFabMgr.FlushKVAsync(action.Owner); + var recovered=JsonConvert.DeserializeObject>(fixture.Remote["NewPlayerDataDic"]); + Check(recovered["newData"].Split(',')[3]==(initialEnergy-2).ToString(),"later recovery has separate flush, not original action confirmation"); + } + if(subscribers!=null) + { + Check(subscribers.Battle.Data.BattleTask.dic_dailyTasks.Values.Single()==3 && subscribers.Lucky.DicTasks.Values.Single()==3 && subscribers.Magic.DicTasks.Values.Single()==3,"actual task subscriptions each receive one consume event"); + Check(subscribers.Mini.Data.Exp==3,"actual mini BP subscription receives the event"); + Check(new[]{"BattlePassRecordTask","MiniBPKeyNew","EventLuckGameRecord","EventLuckMagicPlayfabData"}.All(fixture.Remote.ContainsKey),"all actual subscriber save methods confirmed in original action"); + } + var parsed=new PlayerKVSchemaSnapshot("fixed-fishing",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys, + new Dictionary(),System.Globalization.CultureInfo.CurrentCulture,new DateTime(2026,9,24,0,0,0,DateTimeKind.Utc),0); + var errors=new PlayerKVSchemaDiagnostics(); + Check(PlayerKVSaveSchema.DecodeCore(new Dictionary(),modern,parsed,errors)!=null && errors.TotalErrors==0,"actual serialized core loads without alias conflict"); + } + } + result=JsonConvert.SerializeObject(new{success=true,groups,assertions=checks,passed,formalRequests=0,synthetic=true,restored=true,folder}); + } + finally + { + try + { + subscribers?.Dispose(); subscribers=null; + player?.StopRecoveryEnergy(); subscription?.Dispose(); + if(fixture!=null) await fixture.Close(); + if(pending!=null) { try { await pending; } catch(Exception e) { Debug.LogWarning("Observed fishing test failure: "+e.GetType().Name); } } + FishingContextAccess.Use(oldContainer); FishingContextAccess.Events=oldEvents; container?.Dispose(); + LocalizationMgr.enumLanguage=oldLanguages; textTable.SetValue(null,oldTextTable); + (Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction)=saved; + } + catch { validationCleanupFailure=fixture; throw; } + finally { Interlocked.Exchange(ref validationRunning,0); } + } + File.WriteAllText(Path.Combine(folder,"result.json"),result); return result; + } + // Fixed real subscriber fixture. Reflection targets are compiled constants, never supplied by CLI. + sealed class FishingSubscribers:IDisposable + { + const System.Reflection.BindingFlags Fields=System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic; + static readonly System.Reflection.FieldInfo PF=typeof(PlayFabMgr).GetField("_instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic); + static readonly System.Reflection.FieldInfo Red=typeof(RedPointManager).GetField("instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic); + readonly object oldRed=Red.GetValue(null),oldPF=PF.GetValue(null); + GameObject shell; LuckyTaskData magicTask; MiniBattlePassDataProvider provider; + internal BattlePassDataProvider Battle; internal EventLuckyGameModel Lucky; + internal EventLuckMagicModel Magic; internal MiniBattlePassDataModel Mini; + internal void Bind(cfg.Tables tables,FishingEventData events,Container container) + { + Red.SetValue(null,new RedPointManager()); + shell=new GameObject("KV inactive subscriber storage fixture");shell.SetActive(false);shell.hideFlags=HideFlags.HideAndDontSave; + var pf=shell.AddComponent(); + if(!pf.IsUnstartedForKVLoad)throw new InvalidOperationException("No PF lifecycle may start in this fixture."); + PF.SetValue(null,pf); + var items=new PlayerItemData(tables);container.RegisterInstance(items); + var data=new BattlePassData(tables); + data.BattlePassRecord.ID=tables.TbFishingEvent.DataList.First(e=>tables.TbBattlePassMain.GetOrDefault(e.RedirectID)!=null).ID; + data.BattlePassRecord.Level=1; + data.BattleTask.dic_dailyTasks.Add(tables.TbBattlePassTask.DataList.First(t=>t.Type==cfg.ConditionType.ConsumeEnergy).TaskID,0); + Battle=new BattlePassDataProvider(tables,data,items);container.RegisterInstance(Battle); + var common=tables.TbCommonTask.DataList.First(t=>t.TaskType==cfg.ConditionType.ConsumeEnergy).Id; + Lucky=new EventLuckyGameModel(events,tables);Lucky.DicTasks.Add(common,0); + Magic=new EventLuckMagicModel{DicTasks=new Dictionary{{common,0}},HigherRingData=EventLuckMagicHigherRingData.CreateEmpty()}; + var rings=(List)typeof(EventLuckMagicModel).GetField("_rings",Fields).GetValue(Magic); + for(int i=1;i<=3;i++)rings.Add(EventLuckMagicRing.CreateEmpty(1,0,(EEventLuckMagicRingType)i)); + magicTask=new LuckyTaskData(Magic);typeof(EventLuckMagicModel).GetField("_taskData",Fields).SetValue(Magic,magicTask); + provider=new MiniBattlePassDataProvider(tables,items); + var table=tables.TbMiniBattlePass.DataList.First(t=>t.TaskType==cfg.ConditionType.ConsumeEnergy && t.Param==0); + var activity=tables.TbFishingEvent.DataList.First(t=>t.Type==9 && t.RedirectID==table.Id); + var type=(MiniBattlePassType)table.Type; + Mini=provider.GetModel(type); + provider.GetMiniBattlePassRecord("BP_"+type).Id=activity.ID; + typeof(MiniBattlePassDataModel).GetField("fishingEvent",Fields).SetValue(Mini,activity); + typeof(MiniBattlePassDataModel).GetMethod("InitData",Fields).Invoke(Mini,new object[]{table.Id}); + // Synthetic fixture has an active window; real-run time is never modified. + Mini.startTime=ZZTimeHelper.UtcNow().AddDays(-1); + typeof(MiniBattlePassDataModel).GetProperty("endTime").SetValue(Mini,ZZTimeHelper.UtcNow().AddDays(1)); + } + public void Dispose() + { + Battle?.Dispose();Lucky?.Dispose();magicTask?.Dispose(); + if(Mini!=null)(typeof(MiniBattlePassDataModel).GetField("disposables",Fields).GetValue(Mini) as IDisposable)?.Dispose(); + PF.SetValue(null,oldPF);Red.SetValue(null,oldRed); + if(shell!=null)UnityEngine.Object.DestroyImmediate(shell); + } + } + sealed class FishingContextAccess:GContext + { + protected override Task SetupAsync()=>throw new InvalidOperationException("Test-only context."); + internal static void Use(Container value) { container=value; } + internal static IEventAggregator Events { get=>eventAggregator; set=>eventAggregator=value; } + } + sealed class FishingNotifications:ILocalNotificationService + { + public Task Init()=>Task.CompletedTask; public bool IsNoticeOpen=>false; + public void OpenSettings(){} public void ScheduleOneTimeNotification(int id,DateTime time,string title,string text){} + public void ScheduleDailyNotification(int id,DateTime time,string title,string text){} + public void ClearAllNotification(){} public void ClearNotification(int id){} public void ScheduleDefaultNotification(){} + } + } +} +#endif diff --git a/Client/Assets/Scripts/Services/UserService.KVFishingValidation.cs.meta b/Client/Assets/Scripts/Services/UserService.KVFishingValidation.cs.meta new file mode 100644 index 000000000..8d8085622 --- /dev/null +++ b/Client/Assets/Scripts/Services/UserService.KVFishingValidation.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f0d0cfb141639484a8e9592320fd607a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs b/Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs new file mode 100644 index 000000000..5607b10ca --- /dev/null +++ b/Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs @@ -0,0 +1,621 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +#if !SERVICE_HOST +using System.Threading; +using System.Threading.Tasks; +using System.Net; +using System.Net.Http; +using asap.core; +using GameCore; +using game.PlayerDataStorage; +using UnityEngine; +#endif + +namespace game.PlayerDataStorage +{ + // Editor-only permission to use an existing, backed-up disposable save. Never a migration tool. + internal sealed class PlayerKVLoopPermit + { + public int Version { get; set; } + public bool Enabled { get; set; } + public string PlayerId { get; set; } + public string Api { get; set; } + public string BackupPath { get; set; } + public string BackupSha256 { get; set; } + public string[] AllowedKeys { get; set; } + [JsonProperty(NullValueHandling=NullValueHandling.Ignore)] + public string Operation { get; set; } + [JsonIgnore] internal bool AllowsFishing => (Version==2 && Operation=="fishing-consume-once") || AllowsReward; + [JsonIgnore] internal bool AllowsReward => Version==3 && Operation=="fishing-reward-once"; + internal static string Hash(byte[] bytes) + { using (var sha=SHA256.Create()) return BitConverter.ToString(sha.ComputeHash(bytes)).Replace("-", ""); } + internal static PlayerKVLoopPermit Read(string path) + { + try + { + var document=JObject.Parse(File.ReadAllText(path)); + var p=document.ToObject(); + if(p==null || (p.Version!=1 && p.Version!=2 && p.Version!=3) || + (p.Version==1 && document.Property("Operation",StringComparison.Ordinal)!=null) || + (p.Version==2 && p.Operation!="equipment" && p.Operation!="fishing-consume-once") || + (p.Version==3 && p.Operation!="fishing-reward-once") || + !p.Enabled || (p.PlayerId!="101" && p.PlayerId!="102") || + p.Api!="http://s101.local:18080/" || p.AllowedKeys==null || p.AllowedKeys.Length==0 || + p.AllowedKeys.Distinct(StringComparer.Ordinal).Count()!=p.AllowedKeys.Length || + !p.AllowedKeys.Contains("NewPlayerDataDic")) throw new InvalidOperationException("Invalid loop permission."); + var root=Path.GetFullPath(Path.GetDirectoryName(path))+Path.DirectorySeparatorChar; + var backup=Path.GetFullPath(p.BackupPath); + if(!backup.StartsWith(root,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Backup must be inside loop workspace."); + var bytes=File.ReadAllBytes(backup); + if(!string.Equals(Hash(bytes),p.BackupSha256,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Backup hash mismatch."); + var saved=JObject.Parse(Encoding.UTF8.GetString(bytes).TrimStart('\uFEFF')); + var data=saved["data"] as JObject; + if((string)saved["playerId"]!=p.PlayerId || (string)saved["api"]!=p.Api || data==null || + p.AllowedKeys.Any(k=>string.IsNullOrEmpty(k) || k.Length>128 || data.Property(k,StringComparison.Ordinal)==null) || + data.Properties().Any(v=>v.Value.Type!=JTokenType.String)) throw new InvalidOperationException("Backup identity or key permission mismatch."); + return p; + } + catch(InvalidOperationException) { throw; } + catch(Exception e) { throw new InvalidOperationException("Cannot validate loop permission.",e); } + } + internal static string Claim(string root,string processIdentity) + { + if(string.IsNullOrEmpty(processIdentity) || processIdentity.Any(c=>!char.IsLetterOrDigit(c) && c!='-' && c!='_')) + throw new InvalidOperationException("Invalid process identity."); + var folder=Path.Combine(root,"run-control"); Directory.CreateDirectory(folder); + var path=Path.Combine(folder,processIdentity+".claimed"); + using(var file=new FileStream(path,FileMode.CreateNew,FileAccess.Write,FileShare.Read)) + { var bytes=Encoding.UTF8.GetBytes(DateTime.UtcNow.ToString("O")); file.Write(bytes,0,bytes.Length); file.Flush(true); } + return path; + } + } + +#if !SERVICE_HOST + internal static partial class PlayerKVGameplayLoop + { + internal static bool Enabled { get; private set; } + internal static bool Ready { get; private set; } + internal static string Error { get; private set; } + internal static PlayerKVLoopPermit Permit { get; private set; } + internal static string Root => Path.GetFullPath(Path.Combine(Application.dataPath,"../../CodexOutput/upserver/KVGameplayLoopTests")); + internal static string RunPath { get; private set; } + internal static string CacheRoot => Path.Combine(RunPath,"cache"); + static PlayerKVGameSessionHandle handle; + static Action onFault; + static Func onClose; + static WriteBatch collecting; + static int mainThread, failed; + static SynchronizationContext mainContext; + static readonly object auditGate=new object(); + + internal static void BeforeBootstrap() + { + var path=Path.Combine(Root,"permit.json"); + if(!File.Exists(path)) return; + // A malformed or active permit must never silently fall back to PF. + Enabled=true; + if(JObject.Parse(File.ReadAllText(path))["Enabled"]?.Value()==false) { Enabled=false; return; } + if(PlayFabMgr.HasLiveInstanceForKVLoad) throw new InvalidOperationException("Cannot switch a running storage backend."); + Permit=PlayerKVLoopPermit.Read(path); + using(var process=System.Diagnostics.Process.GetCurrentProcess()) + { + var identity=process.Id+"_"+process.StartTime.ToUniversalTime().Ticks; + PlayerKVLoopPermit.Claim(Root,identity); + RunPath=Path.Combine(Root,"runs",identity); Directory.CreateDirectory(RunPath); + } + mainThread=Thread.CurrentThread.ManagedThreadId; + mainContext=SynchronizationContext.Current ?? throw new InvalidOperationException("Unity main context required."); + GContext.container.Resolve().Set("SaveBackend","kv-loop"); + GContext.container.Resolve().Set("CustomID",Permit.PlayerId); + Audit("claim",new{Permit.PlayerId,Permit.BackupSha256}); + } + internal static void Attach(Action fault,Func close) { onFault=fault; onClose=close; } + internal static void Publish(PlayerKVGameSessionHandle value) + { + if(!Enabled || failed!=0 || handle!=null || value==null || value.PlayerId!=Permit.PlayerId) + throw new InvalidOperationException("Loop handle cannot be published."); + handle=value; Ready=true; Audit("ready",new{value.PlayerId,value.OwnerInstanceId,value.Epoch}); + } + internal static PlayerKVGameSessionHandle Capture() + { + if(!Ready || failed!=0 || handle==null || Thread.CurrentThread.ManagedThreadId!=mainThread) + throw Stop("Loop not writable on this thread or requires process restart."); + return handle; + } + internal static string Read(string key) + { + var result=PlayFabMgr.ReadKV(Capture(),key); + if(!result.Success) throw Stop("Read: "+result.Error); + return result.Found==true ? result.Value : null; + } + internal static PlayerKVEditResult Write(string key,string value,bool remote) + { + var owner=Capture(); + if(!Permit.AllowedKeys.Contains(key,StringComparer.Ordinal)) throw Stop("Write outside backed-up permission: "+key); + CheckRewardWrite(key,value); + var valueHash=value==null?null:PlayerKVLoopPermit.Hash(Encoding.UTF8.GetBytes(value)); + Audit("write-attempt",new{key,remote,valueHash}); + var result=PlayFabMgr.WriteKV(owner,key,value,remote); + if(!result.Accepted) throw Stop("Write: "+result.StateResult.Error+" uncertain="+result.StateResult.LocalCommitMayHaveSucceeded); + if(collecting!=null) + { + collecting.Edits.Add(result); + collecting.Records.Add(new WriteRecord(key,remote,result,valueHash)); + } + Audit("accepted",new{key,remote,result.StateResult.AttemptSequence,result.StateResult.Revision}); + return result; + } + internal sealed class WriteBatch : IDisposable + { + internal readonly PlayerKVGameSessionHandle Owner; + internal readonly List Edits=new List(); + internal readonly List Records=new List(); + internal WriteBatch(PlayerKVGameSessionHandle owner) { Owner=owner; } + public void Dispose() { if(ReferenceEquals(collecting,this)) collecting=null; } + } + internal static WriteBatch BeginEquip() + { + if(Permit?.AllowsFishing==true) throw Stop("Equipment is outside this fishing permit."); + if(collecting!=null) throw Stop("Reentrant equipment action."); + return collecting=new WriteBatch(Capture()); + } + internal sealed class WriteRecord + { + internal readonly string Key,ValueHash; + internal readonly bool Remote; + internal readonly PlayerKVEditResult Edit; + internal WriteRecord(string key,bool remote,PlayerKVEditResult edit,string valueHash) { Key=key; Remote=remote; Edit=edit; ValueHash=valueHash; } + } + internal sealed class FishingAction + { + internal readonly object Actor; + internal readonly PlayerKVGameSessionHandle Owner; + internal bool Consuming,WritesStarted,Cancelled,Confirmed,Continued; + internal FishingAction(object actor,PlayerKVGameSessionHandle owner) { Actor=actor; Owner=owner; } + } + static FishingAction fishingAction; + internal static FishingAction BeginFishing(object actor) + { + if(Permit?.AllowsFishing!=true) throw Stop("A fishing-consume-once permit is required before casting."); + if(fishingAction!=null) { Audit("duplicate-suppressed",new{point="cast"}); return null; } + return fishingAction=new FishingAction(actor,Capture()); + } + internal static bool FishingCurrent(FishingAction action) => action!=null && ReferenceEquals(action,fishingAction) && + !action.Cancelled && Ready && failed==0 && ReferenceEquals(action.Owner,handle); + internal static void CancelFishing(FishingAction action,string reason) + { + if(action==null || action.Cancelled) return; + CancelReward(action.Actor,reason); + action.Cancelled=true; + if(Ready && !action.Continued) Fault("Fishing action cancelled: "+reason); + } + internal static bool ClaimFishingConsumption(FishingAction action) + { + if(!FishingCurrent(action)) throw Stop("Fishing action is no longer current."); + if(action.Consuming) { Audit("duplicate-suppressed",new{point="consume"}); return false; } + action.Consuming=true; return true; + } + internal static WriteBatch BeginFishingWrites(FishingAction action) + { + if(!FishingCurrent(action) || !action.Consuming || action.WritesStarted || collecting!=null || !ReferenceEquals(Capture(),action.Owner)) + throw Stop("Fishing write batch is not admissible."); + action.WritesStarted=true; + return collecting=new WriteBatch(action.Owner); + } + internal static async Task ConfirmFishingAsync(FishingAction action,WriteBatch batch,PlayerKVEditResult primary) + { + try + { + if(!FishingCurrent(action) || !ReferenceEquals(batch.Owner,action.Owner) || ReferenceEquals(collecting,batch)) + throw Stop("Fishing batch must be sealed on its original action."); + var last=batch.Records.GroupBy(r=>r.Key,StringComparer.Ordinal).ToDictionary(g=>g.Key,g=>g.Last(),StringComparer.Ordinal); + if(!last.TryGetValue("NewPlayerDataDic",out var main) || !ReferenceEquals(main.Edit,primary) || + last.Values.Any(r=>!r.Remote || r.Edit.Ticket==null)) throw Stop("Fishing final writes require original remote tickets."); + using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30))) + { + var flush=await PlayFabMgr.FlushKVAsync(batch.Owner,timeout.Token); + if(!FishingCurrent(action) || flush.Unknown>0 || flush.Blocked>0 || flush.TransportError!=PlayerKVError.None) + throw Stop("Fishing flush: "+flush.TransportError); + foreach(var record in batch.Records) + { + var ticket=record.Edit.Ticket; + if(ticket==null) continue; // Intermediate localOnly is not a remote acknowledgement. + var final=last[record.Key].Edit.Ticket; + if(ticket.OwnerInstanceId!=batch.Owner.OwnerInstanceId || ticket.Epoch!=batch.Owner.Epoch || + ticket.Key!=record.Key || final.OwnerInstanceId!=ticket.OwnerInstanceId || final.Epoch!=ticket.Epoch) + throw Stop("Fishing ticket identity mismatch."); + var result=await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,ticket,timeout.Token); + if(!FishingCurrent(action)) throw Stop("Fishing action changed while waiting."); + bool explained=result.Status==PlayerKVTicketStatus.Superseded && !ReferenceEquals(ticket,final) && final.Sequence>ticket.Sequence; + if(result.Status!=PlayerKVTicketStatus.Confirmed && !explained) throw Stop("Fishing ticket: "+result.Status); + } + } + action.Confirmed=true; + Audit("fishing-consume-confirmed",new{tickets=last.Values.Select(r=>new{key=r.Key,sequence=r.Edit.Ticket.Sequence,valueHash=r.ValueHash}),batch.Owner.OwnerInstanceId,batch.Owner.Epoch}); + } + catch(Exception e) { Fault(e.Message); throw; } + } + internal static void ContinueFishing(FishingAction action,Action continuation) + { + if(!FishingCurrent(action) || !action.Confirmed) throw Stop("Fishing cannot continue before confirmation."); + if(action.Continued) { Audit("duplicate-suppressed",new{point="continue"}); return; } + action.Continued=true; + continuation(); + Audit("fishing-consume-ready",new{action.Owner.OwnerInstanceId,action.Owner.Epoch}); + } + internal static async Task ConfirmEquipAsync(WriteBatch batch,PlayerKVEditResult primary) + { + if(primary?.Ticket==null || !batch.Edits.Contains(primary) || !ReferenceEquals(Capture(),batch.Owner)) + throw Stop("Missing original equipment ticket."); + var flush=await PlayFabMgr.FlushKVAsync(batch.Owner); + if(flush.Unknown>0 || flush.Blocked>0 || flush.TransportError!=PlayerKVError.None) throw Stop("Equipment flush: "+flush.TransportError); + foreach(var edit in batch.Edits) + { + if(edit.Ticket==null) continue; // localOnly is audited, never claimed as remotely confirmed. + var result=await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,edit.Ticket); + if(result.Status!=PlayerKVTicketStatus.Confirmed) throw Stop("Equipment ticket: "+result.Status); + } + if(!ReferenceEquals(Capture(),batch.Owner)) throw Stop("Equipment session changed."); + Audit("equipment-confirmed",new{tickets=batch.Edits.Count(e=>e.Ticket!=null)}); + } + internal static async Task FlushObservedAsync() + { + if(!Ready) return; + try + { + var result=await PlayFabMgr.FlushKVAsync(Capture()); + if(result.Unknown>0 || result.Blocked>0 || result.TransportError!=PlayerKVError.None) throw Stop("Periodic flush: "+result.TransportError); + } + catch(Exception e) { Fault(e.Message); } + } + internal static void Revoke() { Ready=false; } + internal static Task CloseAsync() { Revoke(); return onClose?.Invoke() ?? Task.CompletedTask; } + internal static InvalidOperationException Stop(string message) { Fault(message); return new InvalidOperationException(message); } + internal static void Fault(string message) + { + Ready=false; + if(Interlocked.Exchange(ref failed,1)!=0) return; + Error=message; + try { Audit("fault",new{message}); } catch(Exception e) { Debug.LogException(e); } + Debug.LogError("KV loop stopped: "+message); + if(Thread.CurrentThread.ManagedThreadId==mainThread) onFault?.Invoke(message); + else mainContext?.Post(_=>onFault?.Invoke(message),null); + } + internal static void Audit(string kind,object detail) + { + if(RunPath==null) return; + lock(auditGate) File.AppendAllText(Path.Combine(RunPath,"events.jsonl"),JsonConvert.SerializeObject(new{at=DateTime.UtcNow,kind,detail})+Environment.NewLine); + } + + // Fixed, compiled Editor test. No caller-supplied URL, code, reflection target, or real run permit. + static int validationRunning; + static object validationCleanupFailure; + internal static async Task VerifyEquipmentForEditorAsync() + { + if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || + UnityEngine.Resources.FindObjectsOfTypeAll().Length!=0 || PlayFabMgr.HasLiveInstanceForKVLoad || + Enabled || handle!=null || File.Exists(Path.Combine(Root,"permit.json")) || validationCleanupFailure!=null) + throw new InvalidOperationException("An idle editor without a real loop permit is required."); + if(Interlocked.CompareExchange(ref validationRunning,1,0)!=0) throw new InvalidOperationException("Validation already running."); + var original=(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext); + var folder=Path.Combine(Root,"fixed-equipment-runs",DateTime.UtcNow.ToString("yyyyMMddTHHmmss")+"_"+Guid.NewGuid().ToString("N")); + EquipmentFixture fixture=null; + Task pendingConfirmation=null; + string result=null; + int assertions=0, groups=0; + void Check(bool ok,string reason) { assertions++; if(!ok) throw new InvalidOperationException("Equipment validation: "+reason); } + async Task Start() + { + if(fixture!=null) await fixture.Close(); + fixture=new EquipmentFixture(Path.Combine(folder,groups.ToString())); + var owner=await fixture.Open(); + Enabled=true; Ready=false; Error=null; failed=0; handle=null; collecting=null; + Permit=new PlayerKVLoopPermit{PlayerId="101",AllowedKeys=new[]{"k","NewPlayerDataDic","late"}}; + RunPath=folder; mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current; + Attach(_=>fixture.Faults++,fixture.Close); Publish(owner); + groups++; + } + async Task Reject(Task task,string contains) + { + bool rejected=false; + try { await task; } catch(InvalidOperationException e) { rejected=e.Message.Contains(contains); } + Check(rejected,"expected rejection: "+contains); + Check(!Ready && fixture.Faults==1,"one fault revokes facade"); + } + try + { + if(!PlayerKVGameLoadGuard.TryEnter(out var probe)) throw new InvalidOperationException("Load guard occupied."); + probe.Dispose(); Directory.CreateDirectory(folder); + await Start(); + Check(Read("missing")==null,"valid missing key remains null"); + var batch=BeginEquip(); + // This nested callback represents the synchronous Publish subscriber in the equipment chain. + Action nested=()=>Write("k","nested",true); + nested(); var primary=Write("NewPlayerDataDic","whole-save",true); batch.Dispose(); + Check(batch.Edits.Count==2 && ReferenceEquals(primary,batch.Edits[1]),"all original edits collected"); + Check(fixture.Sets==0,"write acceptance is local only"); + await ConfirmEquipAsync(batch,primary); + foreach(var edit in batch.Edits) Check((await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,edit.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"each original ticket confirmed"); + Check(fixture.Remote["k"]=="nested" && fixture.Remote["NewPlayerDataDic"]=="whole-save" && Ready && fixture.Faults==0,"complete batch committed"); + + await Start(); batch=BeginEquip(); + var first=Write("k","first",true); Write("k","second",true); + primary=Write("NewPlayerDataDic","whole-save",true); batch.Dispose(); + await Reject(ConfirmEquipAsync(batch,primary),"Superseded"); + Check((await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,first.Ticket)).Status==PlayerKVTicketStatus.Superseded,"original nested ticket never replaced"); + + await Start(); batch=BeginEquip(); + Write("k","nested",true); primary=Write("NewPlayerDataDic","watermark",true); batch.Dispose(); + fixture.HoldSet=true; + var confirmation=ConfirmEquipAsync(batch,primary); + pendingConfirmation=confirmation; + var entered=await Task.WhenAny(fixture.SetEntered.Task,confirmation,Task.Delay(5000)); + Check(ReferenceEquals(entered,fixture.SetEntered.Task),"first set entered"); + var late=Write("late","later-write",true); + Check(batch.Edits.Count==2,"later write excluded from synchronous batch"); + fixture.ReleaseSet.TrySetResult(true); await confirmation; pendingConfirmation=null; + Check(!fixture.Remote.ContainsKey("late"),"flush watermark excludes later write"); + await PlayFabMgr.FlushKVAsync(batch.Owner); + Check((await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,late.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"later write needs its own flush"); + + await Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","unauthorized",true); batch.Dispose(); + fixture.Unauthorized=true; + await Reject(ConfirmEquipAsync(batch,primary),"Equipment flush"); + Check(fixture.Auth==1 && fixture.Sets==1,"401 never reauthenticates or retries"); + + await Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","lost-response",true); batch.Dispose(); + fixture.LoseResponse=true; + await Reject(ConfirmEquipAsync(batch,primary),"Equipment flush"); + Check(fixture.Remote["NewPlayerDataDic"]=="lost-response" && fixture.Sets==1,"unknown remote commit is not replayed"); + Check((await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,primary.Ticket)).Status==PlayerKVTicketStatus.Unknown,"unknown original ticket preserved"); + + await Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","closed",true); batch.Dispose(); + await fixture.Close(); + await Reject(ConfirmEquipAsync(batch,primary),"Equipment flush"); + Check(fixture.Sets==0,"close does not flush pending writes"); + + await Start(); bool denied=false; + try { Write("unbacked-key","x",true); } catch(InvalidOperationException e) { denied=e.Message.Contains("permission"); } + Check(denied && fixture.Sets==0 && !Ready,"key permission checked before writes"); + await Start(); await fixture.Close(); denied=false; + try { Read("k"); } catch(InvalidOperationException) { denied=true; } + Check(denied && !Ready,"stale read cannot become missing"); + + var originalContainer=GContext.container; + var container=new Container(); + try + { + var configPath=Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData")); + var tables=new cfg.Tables(name=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.Combine(configPath,name+".json")))); + container.RegisterInstance(tables); + EquipmentContextAccess.Use(container); + using(container.InjectionScope()) container.Register().AsSingleton(); + var shooting=container.Resolve(); + Check(shooting!=null && ReferenceEquals(shooting,container.Resolve()),"M24 container resolves one instance"); + shooting.ResetForKVLoad(); + Check(!shooting.KVLoadPresent,"M24 can enter the existing load transaction without activation"); + } + finally { EquipmentContextAccess.Use(originalContainer); container.Dispose(); } + Check(ReferenceEquals(GContext.container,originalContainer),"M24 global container restored"); + groups++; + result=JsonConvert.SerializeObject(new{success=true,groups,assertions,formalRequests=0,synthetic=true,restored=true,folder}); + } + finally + { + try + { + if(fixture!=null) await fixture.Close(); + if(pendingConfirmation!=null) + try { await pendingConfirmation; } catch(Exception e) { Debug.LogWarning("Observed failed synthetic confirmation during cleanup: "+e.GetType().Name); } + (Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext)=original; + } + catch { validationCleanupFailure=fixture; throw; } + finally { Interlocked.Exchange(ref validationRunning,0); } + } + File.WriteAllText(Path.Combine(folder,"result.json"),result); + return result; + } + + sealed class EquipmentContextAccess:GContext + { + protected override Task SetupAsync() => throw new InvalidOperationException("Not a gameplay context."); + internal static void Use(Container value) { container=value; } + } + internal static string InspectPFBackupForEditor(string path) + { + if(Enabled || validationRunning!=0 || UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || + UnityEngine.Resources.FindObjectsOfTypeAll().Length!=0) + throw new InvalidOperationException("Idle editor required for source inspection."); + path=Path.GetFullPath(path); + var allowed=Path.GetFullPath(Path.Combine(Root,"pf-source-backups"))+Path.DirectorySeparatorChar; + if(!path.StartsWith(allowed,StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("Expected local PF source backup."); + var bytes=File.ReadAllBytes(path); var source=JObject.Parse(Encoding.UTF8.GetString(bytes)); + var id=(string)source["customId"]; + if((string)source["source"]!="PlayFab" || (string)source["titleId"]!="108EE8" || (id!="101" && id!="102")) + throw new InvalidOperationException("Source identity mismatch."); + var raw=source["data"].ToObject>(); + var originalContainer=GContext.container; var isolated=new Container(); + try + { + var configPath=Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData")); + var tables=new cfg.Tables(name=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.Combine(configPath,name+".json")))); + isolated.RegisterInstance(tables); EquipmentContextAccess.Use(isolated); + var now=DateTime.UtcNow; + var snapshot=PlayerKVGameplayBindings.Snapshot(now); + // Diagnostic-only context: this is NOT a real KV Prepare/Open and cannot publish a handle. + var prepared=new PlayerKVLoginPrepareResult(PlayerKVLoginPrepareError.None,id,1,ownerId:"offline-PF-candidate",data:raw); + var account=new PlayerKVAccountDescriptor(id,1,0,false,DateTime.SpecifyKind(DateTime.MinValue,DateTimeKind.Utc),now); + var identity=new PlayerKVIdentitySnapshot((string)source["playFabId"],false,PlayerKVIdentityChannel.none,Array.Empty()); + var decoded=PlayerKVSaveSchema.Decode(prepared,account,id,1,true,snapshot,snapshot.Version,identity); + return JsonConvert.SerializeObject(new{sourceOnly=true,syntheticDescriptor=true,usesLocalUtc=true,customId=id,path,sha256=PlayerKVLoopPermit.Hash(bytes),keys=raw.Count, + verdict=decoded.Verdict.ToString(),errors=decoded.Diagnostics.TotalErrors, + details=decoded.Diagnostics.Details.Select(d=>new{module=d.Module,field=d.Field,code=d.Code.ToString()}), + configuredRods=decoded.Data?.Module(3).RodLevels.Keys.ToArray(), + gameplayPublished=false,writes=0}); + } + finally { EquipmentContextAccess.Use(originalContainer); isolated.Dispose(); } + } + sealed class EquipmentFixture + { + static readonly Uri Api=new Uri("http://kv-loop.invalid/"); + readonly string cache; + readonly game.Account.S101AccountSession session; + readonly PlayerKVGameSessionBridge bridge; + internal readonly Dictionary Remote=new Dictionary{{"k","original"},{"NewPlayerDataDic","original-save"}}; + internal readonly TaskCompletionSource SetEntered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + internal readonly TaskCompletionSource ReleaseSet=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + internal int Sets,Auth,Faults; + internal bool Unauthorized,LoseResponse,HoldSet,PartialCommit; + sealed class Handler:HttpMessageHandler + { + readonly Func> send; + internal Handler(Func> send) { this.send=send; } + protected override Task SendAsync(HttpRequestMessage request,CancellationToken token) => send(request); + } + static HttpResponseMessage Json(object body) => new HttpResponseMessage(HttpStatusCode.OK){Content=new StringContent(JsonConvert.SerializeObject(body))}; + internal EquipmentFixture(string cache) + { + this.cache=cache; + session=new game.Account.S101AccountSession(Api,jar=>new Handler(request=> + { + if(request.RequestUri!=new Uri(Api,"account/Login")) throw new InvalidOperationException("Unexpected synthetic login target."); + Auth++; jar.Add(Api,new Cookie("sid","101")); + return Task.FromResult(Json(new{state=0,internalId=101,newCreate=false,createTime="2026-01-01T00:00:00Z",svrTime="2026-01-01T00:00:00Z"})); + })); + bridge=new PlayerKVGameSessionBridge(jar=>new Handler(async request=> + { + if(request.RequestUri.Host!=Api.Host || jar.GetCookies(Api)["sid"]?.Value!="101") throw new InvalidOperationException("Unexpected synthetic KV identity."); + if(request.RequestUri.AbsolutePath=="/playerdata/Get") return Json(new{data=Remote}); + if(request.RequestUri.AbsolutePath!="/playerdata/Set") throw new InvalidOperationException("Unexpected synthetic operation."); + Sets++; SetEntered.TrySetResult(true); + if(HoldSet) await ReleaseSet.Task; + if(Unauthorized) return new HttpResponseMessage(HttpStatusCode.Unauthorized); + var body=JObject.Parse(await request.Content.ReadAsStringAsync()); + var values=((JObject)body["data"]).Properties(); + foreach(var pair in PartialCommit?values.Take(1):values) Remote[pair.Name]=(string)pair.Value; + if(LoseResponse) throw new HttpRequestException("Synthetic response lost after commit."); + return Json(new{}); + })); + } + internal async Task Open() + { + if(!(await session.LoginAsync("101")).Success) throw new InvalidOperationException("Synthetic login failed."); + var opened=await bridge.OpenAsync(session,cache,"101",Api,1,e=>e==1); + if(!opened.Success) throw new InvalidOperationException("Synthetic open failed: "+opened.Error); + return opened.Handle; + } + internal async Task Close() + { + ReleaseSet.TrySetResult(true); + await bridge.CloseAsync(); + session.Dispose(); + } + } + } +#endif +} + +#if !SERVICE_HOST +namespace game +{ + public partial class PlayFabUserService + { + PlayerKVGameSessionBridge loopBridge; + PlayerKVGameLoadGuard loopGuard; + Task loopClosing; + TaskCompletionSource loopSettled; + bool loopAttempted, loopStopping; + internal bool KVLoopLoading { get; private set; } + + async Task LoginKVGameplayLoopAsync(Action progress,int generation) + { + if(loopAttempted || loopStopping || CustomId!=PlayerKVGameplayLoop.Permit.PlayerId) return ELoginStep.Api; + loopAttempted=true; loopSettled=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + loopBridge=new PlayerKVGameSessionBridge(); + PlayerKVGameplayLoop.Attach(message=> + { + _=StopKVGameplayLoopAsync(); + var main=UnityEngine.Application.isPlaying; + if(main) _=ExitKVGameplayLoopAsync(); + },StopKVGameplayLoopAsync); + try + { + if(customServerMgr.ApiBaseUri.AbsoluteUri!=PlayerKVGameplayLoop.Permit.Api) + throw new InvalidOperationException("KV loop requires the fixed s101 API."); + if(!await LoginPlayFab(generation) || generation!=logoutGeneration) throw new InvalidOperationException("PF identity login failed/stale."); + if(!await LoginApiServer() || generation!=logoutGeneration) throw new InvalidOperationException("API login failed/stale."); + if(InternalId!=0 || IsLogin) throw new InvalidOperationException("Gameplay identity published before load."); + var opened=await loopBridge.OpenAsync(apiSession,PlayerKVGameplayLoop.CacheRoot,CustomId,customServerMgr.ApiBaseUri, + generation,e=>!loopStopping && e==logoutGeneration,LoginLifetime); + if(!opened.Success) throw new InvalidOperationException("KV open: "+opened.Error); + var read=opened.Handle.ReadLocalSnapshot(); + if(!read.Success) throw new InvalidOperationException("KV snapshot: "+read.Error); + var prepared=new PlayerKVLoginPrepareResult(PlayerKVLoginPrepareError.None,apiSession.PlayerId,generation, + syncStatus:opened.Handle.Status,ownerId:opened.Handle.OwnerInstanceId,data:read.Data); + var account=new PlayerKVAccountDescriptor(apiSession.PlayerId,generation,apiSession.InternalId, + apiSession.NewlyCreated,apiSession.CreateTimeUtc,apiSession.ServerTimeUtc); + var snapshot=PlayerKVGameplayBindings.Snapshot(apiSession.ServerTimeUtc); + var identity=new PlayerKVIdentitySnapshot(UserId,config.Get("NewlyCreated",false),PlayerKVIdentityChannel.none,Array.Empty()); + var decoded=PlayerKVSaveSchema.Decode(prepared,account,CustomId,generation,!loopStopping && generation==logoutGeneration,snapshot,snapshot.Version,identity); + if(decoded.Data==null) throw new InvalidOperationException("KV schema: "+decoded.Verdict); + if(!PlayerKVGameLoadGuard.TryEnter(out loopGuard)) throw new InvalidOperationException("Load guard occupied."); + KVLoopLoading=true; + var transaction=PlayerKVGameplayBindings.Bind(this); + var applied=transaction.Apply(decoded.Data,()=>!loopStopping && generation==logoutGeneration, + ()=>loopGuard.IsCurrent && loopGuard.Violations==0,()=>{}); + if(applied!=PlayerKVApplyError.None) throw new InvalidOperationException("KV apply: "+applied); + KVLoopLoading=false; loopGuard.Dispose(); loopGuard=null; + PlayerKVGameplayLoop.Publish(opened.Handle); + PlayFabMgr.Instance.StartKVGameplayLoopSync(); + InternalId=apiSession.InternalId; + GContext.Publish(new EvtAPISvrLoginSuccess{playerId=apiSession.PlayerId,internalId=InternalId}); + OnInitData?.Invoke(decoded.Data.Raw.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal)); + SetMMTestValue(); + if(!PlayerKVGameplayLoop.Ready || loopStopping || generation!=logoutGeneration) throw new InvalidOperationException("Initialization revoked loop."); + progress?.Invoke(2); return ELoginStep.Done; + } + catch(Exception e) { PlayerKVGameplayLoop.Fault(e.Message); return ELoginStep.Api; } + finally { KVLoopLoading=false; loopSettled.TrySetResult(true); } + } + async Task ExitKVGameplayLoopAsync() + { + try { await GContext.container.Resolve().SwitchStageAsync("QuitStage"); } + catch(Exception e) { Debug.LogException(e); } + } + internal Task StopKVGameplayLoopAsync() + { + if(loopClosing!=null) return loopClosing; + loopStopping=true; PlayerKVGameplayLoop.Revoke(); logoutGeneration++; apiLoginGeneration++; InternalId=0; + PlayFabMgr.Instance?.StopKVGameplayLoopSync(); + var completion=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + loopClosing=completion.Task; PlayerKVShutdownRegistry.Process.Register(loopClosing); + try { loginLifetime.Cancel(); } catch(Exception e) { Debug.LogException(e); } + var drain=loopBridge?.CloseAsync() ?? Task.CompletedTask; + _=FinishKVGameplayLoopAsync(drain,completion); return loopClosing; + } + async Task FinishKVGameplayLoopAsync(Task drain,TaskCompletionSource completion) + { + try + { + if(loopSettled!=null) await loopSettled.Task; + await drain; + if(loopBridge!=null) await loopBridge.CloseAsync(); + apiSession?.Dispose(); apiSession=null; customServerMgr.ClearAccountSession(); + loopGuard?.Dispose(); loopGuard=null; + UserId=null; DisplayName=null; AvatarUrl=null; InternalId=0; + PlayFab.PlayFabClientAPI.ForgetAllCredentials(); + PlayerKVGameplayLoop.Audit("closed",new{requiresProcessRestart=true}); completion.TrySetResult(true); + } + catch(Exception e) { completion.TrySetException(e); Debug.LogException(e); } + } + } +} +#endif +#endif diff --git a/Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs.meta b/Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs.meta new file mode 100644 index 000000000..ab6a3b56b --- /dev/null +++ b/Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5209b70ed0644051842744ab39fdf2f4 diff --git a/Client/Assets/Scripts/Services/UserService.KVLoad.cs b/Client/Assets/Scripts/Services/UserService.KVLoad.cs new file mode 100644 index 000000000..4246a0951 --- /dev/null +++ b/Client/Assets/Scripts/Services/UserService.KVLoad.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using game.PlayerDataStorage; + +namespace game +{ + public partial class PlayFabUserService + { + readonly bool kvControlledInstance; + PlayerKVGameLoadCoordinator kvCoordinator; + Action> kvReceiver; + int kvReceiverUsers; + internal PlayerKVGameLoadError KVLastError { get; private set; } + internal string CaptureSaveBackend() => config==null ? "playfab" : config.Get("SaveBackend","playfab"); + internal bool RejectSaveBackend(string backend) + { +#if UNITY_EDITOR && !SERVICE_HOST + if(PlayerKVGameplayLoop.Enabled && backend=="kv-loop" && !kvControlledInstance) + { KVLastError=PlayerKVGameLoadError.None; return false; } +#endif + KVLastError=backend=="kv" ? PlayerKVGameLoadError.BackendNotEnabled : backend!="playfab" ? PlayerKVGameLoadError.BackendUnsupported : + kvControlledInstance ? PlayerKVGameLoadError.Input : PlayerKVGameLoadError.None; + return KVLastError!=PlayerKVGameLoadError.None; + } + internal void ConfigureKVForValidation(PlayerKVGameLoadTransaction transaction,PlayFabMgr pf,CustomServerMgr server,Func unstarted) + { + if(!kvControlledInstance || kvCoordinator!=null || pf==null || !pf.IsUnstartedForKVLoad || PlayFabMgr.HasLiveInstanceForKVLoad || + server==null || !server.IsFreshForKVLoad || unstarted==null || !unstarted()) throw new InvalidOperationException("Fresh controlled services required."); + kvCoordinator=new PlayerKVGameLoadCoordinator(transaction,()=>pf.IsUnstartedForKVLoad && InternalId==0 && unstarted(), + NotifyKVLoaded,()=>KVIntents?.Count ?? 0,server.BindKVForValidation,server.ClearKVForValidation); + server.KVUnauthorized+=version=> { if(version==server.SessionVersion) { InternalId=0; _=kvCoordinator.Revoke(); } }; + } + internal virtual async Task LoadKVForValidationAsync(PlayerKVGameLoadCapability capability) + { + if(!kvControlledInstance || kvCoordinator==null || capability==null) + return new PlayerKVGameLoadResult(PlayerKVGameLoadError.Input,0); + try + { + int loginMs=config==null ? 10000 : PlayerKVSaveSchema.Int32(config.Get("KVLoginCloseObserveMs","10000")); + int quitMs=config==null ? 5000 : PlayerKVSaveSchema.Int32(config.Get("KVQuitCloseObserveMs","5000")); + if(loginMs!=capability.LoginObserveMs || quitMs!=capability.QuitObserveMs || loginMs<1000 || loginMs>15000 || quitMs<1000 || quitMs>15000) + return new PlayerKVGameLoadResult(PlayerKVGameLoadError.Input,0); + } + catch { return new PlayerKVGameLoadResult(PlayerKVGameLoadError.Input,0); } + var requestedId=CustomId; var generation=logoutGeneration; + var result=await kvCoordinator.LoadAsync(requestedId,capability,LoginLifetime,()=>requestedId==CustomId && generation==logoutGeneration); + if(generation!=logoutGeneration || requestedId!=CustomId) + { + _=kvCoordinator.Revoke(); + if(result.State==PlayerKVGameLoadState.LoadedForValidation) result=new PlayerKVGameLoadResult(PlayerKVGameLoadError.Stale,result.Epoch); + } + KVLastError=result.Error; return result; + } + internal Task CloseKVForValidationAsync() => kvCoordinator?.Revoke() ?? Task.CompletedTask; + internal Task ObserveKVQuitAsync() => kvCoordinator?.ObserveQuitAsync() ?? Task.FromResult(PlayerKVShutdownStatus.Ready); + internal bool KVLoadedForValidation => kvCoordinator?.LoadedForValidation ?? false; + internal int KVReceiverCountForValidation => kvReceiverUsers; + internal void EnterKVReceiver(Action> receiver) + { + if(!kvControlledInstance || receiver==null || (kvReceiver!=null && kvReceiver!=receiver)) throw new InvalidOperationException("Controlled receiver required."); + if(kvReceiverUsers++==0) { kvReceiver=receiver; OnInitData+=receiver; } + } + internal void ExitKVReceiver() + { if(--kvReceiverUsers==0) { OnInitData-=kvReceiver; kvReceiver=null; } } + void NotifyKVLoaded() + { + var listeners=OnInitData?.GetInvocationList(); + if(listeners!=null && (listeners.Length!=1 || kvReceiver==null || !listeners[0].Equals(kvReceiver))) + throw new InvalidOperationException("Unregistered controlled receiver."); + OnInitData?.Invoke(KVLoadingCopy.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal)); + } + internal int KVTapJoyBalance { get; private set; } + internal PlayerKVInitializationIntents KVIntents { get; private set; } + internal IReadOnlyDictionary KVLoadingCopy { get; private set; } + internal int MMTestForKVValidation => MMTestValue; + internal bool IsControlledKVInstance => kvControlledInstance; + + internal virtual void ResetForKVLoad() + { + bool admitted=kvControlledInstance, sessionAllowed=apiSession==null; +#if UNITY_EDITOR && !SERVICE_HOST + admitted=admitted || KVLoopLoading; + sessionAllowed=sessionAllowed || KVLoopLoading; +#endif + if(!admitted || InternalId!=0 || !sessionAllowed) + throw new InvalidOperationException("Unstarted controlled identity required."); + KVIntents?.Discard(); KVIntents=null; KVLoadingCopy=null; + MMTestValue=0; EAccoutUrl=new Dictionary(StringComparer.Ordinal); KVTapJoyBalance=0; + } + internal void ReplaceFromKVDecoded(PlayerKVLoginState state,long epoch,IReadOnlyDictionary raw) + { + bool admitted=kvControlledInstance; +#if UNITY_EDITOR && !SERVICE_HOST + admitted=admitted || KVLoopLoading; +#endif + if(!admitted || state==null || raw==null) throw new InvalidOperationException("Controlled login state required."); + var copy=raw.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal); + var intents=new PlayerKVInitializationIntents(epoch,state.Channel); + foreach(var pair in state.Normalizations) { copy[pair.Key]=pair.Value; intents.Add(epoch,pair.Key,pair.Value); } + KVIntents?.Discard(); KVIntents=intents; + KVLoadingCopy=new System.Collections.ObjectModel.ReadOnlyDictionary(copy); + MMTestValue=state.MMTest; KVTapJoyBalance=state.TapJoyBalance; + EAccoutUrl=state.AccountUrls.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal); + ReplaceKVLinked(state); + } + internal virtual void ReplaceKVLinked(PlayerKVLoginState state) { } + } +} diff --git a/Client/Assets/Scripts/Services/UserService.KVLoad.cs.meta b/Client/Assets/Scripts/Services/UserService.KVLoad.cs.meta new file mode 100644 index 000000000..6e5d8cc49 --- /dev/null +++ b/Client/Assets/Scripts/Services/UserService.KVLoad.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a676add461b14c90b0f8a6e420884fd6 diff --git a/Client/Assets/Scripts/Services/UserService.cs b/Client/Assets/Scripts/Services/UserService.cs index c8c2b8a8b..c0f2fd1e2 100644 --- a/Client/Assets/Scripts/Services/UserService.cs +++ b/Client/Assets/Scripts/Services/UserService.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using game.Account; using asap.core; using PlayFab; using asap.playfab.async; @@ -46,12 +47,13 @@ namespace game public uint internalId { get; set; } } - public class EvtAPISvrUnauthorized { } + public class EvtAPISvrUnauthorized { public int SessionVersion { get; set; } } public interface IUserService { Task Login(Action progress, bool tyrLoginByToken = true); void Logout(); + System.Threading.CancellationToken LoginLifetime { get; } bool IsLogin { get; } bool IsAuthorized { get; } DateTime LastLoginTime { get; } @@ -64,6 +66,7 @@ namespace game EntityKey entityKey { get; } Task UpdateAvatarUrl(string url); string UserId { get; } + string ApiPlayerId { get; } string CustomId { get; } uint InternalId { get; } string ContinentCode { get; } @@ -96,7 +99,7 @@ namespace game void UpdateProfileLv(); } - public class PlayFabUserService : IUserService + public partial class PlayFabUserService : IUserService { [Inject] public IConfig config { get; set; } @@ -104,9 +107,12 @@ namespace game [Inject] public ICustomServerMgr customServerMgr { get; set; } - public bool IsLogin => PlayFabClientAPI.IsClientLoggedIn(); + public bool IsLogin => !kvControlledInstance && PlayFabClientAPI.IsClientLoggedIn() && IsAuthorized; - public bool IsAuthorized => InternalId != 0; + public bool IsAuthorized => InternalId != 0 && apiSession != null && apiSession.IsAuthenticated; + + // UserId remains the legacy storage identity until the KV migration. + public string ApiPlayerId => IsAuthorized ? apiSession.PlayerId : null; public DateTime LastLoginTime { get; protected set; } @@ -140,13 +146,51 @@ namespace game return $"Angler#{id[0..5]}"; } - public PlayFabUserService() + public PlayFabUserService() : this(null) { } + + internal PlayFabUserService(PlayerDataStorage.PlayerKVControlledLoadCapability capability) { - GContext.OnEvent().Subscribe(_ => LoginApiServer()); + kvControlledInstance = capability != null; + if (kvControlledInstance) return; + GContext.OnEvent().Subscribe(evt => + { + if (evt.SessionVersion != customServerMgr.SessionVersion) return; +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault("API unauthorized; restart required."); return; } +#endif + InternalId = 0; + if (PlayFabClientAPI.IsClientLoggedIn()) _ = LoginApiServer(); + }); } - public virtual async Task Login(Action progress, bool tyrLoginByToken = true) + protected int logoutGeneration; + private System.Threading.CancellationTokenSource loginLifetime = new System.Threading.CancellationTokenSource(); + public System.Threading.CancellationToken LoginLifetime => loginLifetime.Token; + private readonly System.Threading.SemaphoreSlim loginGate = new System.Threading.SemaphoreSlim(1, 1); + public virtual Task Login(Action progress, bool tyrLoginByToken = true) => LoginWithBackend(progress,CaptureSaveBackend()); + + protected async Task LoginWithBackend(Action progress,string backend) { + var generation = logoutGeneration; + var requestedId = CustomId; + if(RejectSaveBackend(backend)) return ELoginStep.Api; + await loginGate.WaitAsync(); + try + { + if (generation != logoutGeneration || requestedId != CustomId) return ELoginStep.Api; + if (!long.TryParse(requestedId, System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out var numericId) || numericId <= 0) + return ELoginStep.Api; + return await LoginCore(progress, generation); + } + finally { loginGate.Release(); } + } + + private async Task LoginCore(Action progress, int generation) + { +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return await LoginKVGameplayLoopAsync(progress,generation); +#endif if (IsLogin) { progress?.Invoke(2); @@ -155,16 +199,19 @@ namespace game if (!PlayFabClientAPI.IsClientLoggedIn()) { - if (!await LoginPlayFab()) + if (!await LoginPlayFab(generation)) return ELoginStep.PlayFab; } + if (generation != logoutGeneration) return ELoginStep.Api; + SetMMTestValue(); GContext.container.Resolve().WaitingForLogin(); OnYinDunLogin(); - LoginApiServer(); + if (!await LoginApiServer() || generation != logoutGeneration) + return ELoginStep.Api; InitTapJoy(); progress?.Invoke(2); @@ -286,7 +333,7 @@ namespace game GEvent.SetUserProp("MMTestValue", ab, true); Debug.Log($"UserId:{UserId} MMTestValue :0b{Convert.ToString(MMTestValue, 2)} MMTestValueString:{ab}"); } - private async Task LoginPlayFab() + private async Task LoginPlayFab(int generation) { PlayFabSettings.TitleId = config.Get(GConstant.K_PlayFab_Title, GConstant.V_Debug_PF_Title); PlayFab.Internal.PlayFabWebRequest.SkipCertificateValidation(); @@ -303,7 +350,11 @@ namespace game var info_request_parameters = new GetPlayerCombinedInfoRequestParams { GetUserAccountInfo = true, +#if UNITY_EDITOR && !SERVICE_HOST + GetUserData = !game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled, +#else GetUserData = true, +#endif GetPlayerProfile = true, ProfileConstraints = new PlayerProfileViewConstraints() { @@ -371,6 +422,11 @@ namespace game #endif } sw.Stop(); + if (generation != logoutGeneration) + { + PlayFabClientAPI.ForgetAllCredentials(); + return false; + } if (loginResult == null) return false; Debug.Log($"LoginPlayFab elapsed={sw.Elapsed.TotalSeconds}"); @@ -401,6 +457,7 @@ namespace game } }; var data = await PlayFabClientAsyncAPI.GetPlayerProfileAsync(getPlayerProfileRequest); + if (generation != logoutGeneration) return false; //DisplayName = data.PlayerProfile.DisplayName; //AvatarUrl = data.PlayerProfile.AvatarUrl; ContinentCode = data?.PlayerProfile.Locations[0]?.ContinentCode?.ToString() ?? "UNKNOW"; @@ -416,7 +473,9 @@ namespace game if (string.IsNullOrEmpty(DisplayName)) { sw.Start(); - DisplayName = await ChangeDisplayName(NewPlayerName); + var newDisplayName = await ChangeDisplayName(NewPlayerName); + if (generation != logoutGeneration) return false; + DisplayName = newDisplayName; sw.Stop(); Debug.Log($"LoginPlayFab ChangeDisplayName elapsed={sw.Elapsed.TotalSeconds}"); sw.Reset(); @@ -427,6 +486,7 @@ namespace game sw.Start(); /*AvatarUrl = */ await UpdateAvatarUrl(NewPlayerAvatar); + if (generation != logoutGeneration) return false; sw.Stop(); Debug.Log($"LoginPlayFab update avatar elapsed={sw.Elapsed.TotalSeconds}"); sw.Reset(); @@ -434,6 +494,7 @@ namespace game //BuglyAgent.SetUserId(UserId); var setting = GContext.container.Resolve(); await UpdateLanguage(setting.Lang); + if (generation != logoutGeneration) return false; var playfabUserData = loginResult.InfoResultPayload?.UserData; @@ -456,69 +517,71 @@ namespace game //InitData should invoke after local time adjusted. //save login time to local storage. +#if UNITY_EDITOR && !SERVICE_HOST + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) +#endif InitData(playfabUserData, UserId); return true; } - private bool tryLoginApiSvr = false; + private S101AccountSession apiSession; + private Task apiLoginTask; + private int apiLoginGeneration; - private async void LoginApiServer() + private Task LoginApiServer() { - if (tryLoginApiSvr) - return; - tryLoginApiSvr = true; - - var sw = new System.Diagnostics.Stopwatch(); - - PlayerProfile playerProfile = new PlayerProfile() - { - Avatar = AvatarUrl, - DisplayName = DisplayName, - Level = GContext.container.Resolve().lv - }; - - LoginRequest loginRequest = new LoginRequest() - { - playerId = UserId, - playerProfile = playerProfile - }; + if (apiLoginTask != null && !apiLoginTask.IsCompleted) + return apiLoginTask; + apiLoginTask = LoginApiServerCore(); + return apiLoginTask; + } + private async Task LoginApiServerCore() + { + var generation = ++apiLoginGeneration; + var requestedId = CustomId; + InternalId = 0; + apiSession?.Dispose(); + apiSession = null; try { - var apiLoginResponse = await customServerMgr.AccountRequest("Login", loginRequest); - sw.Stop(); - Debug.Log($"LoginApiServer elapsed={sw.Elapsed.TotalSeconds}"); - - if (string.IsNullOrEmpty(apiLoginResponse)) + customServerMgr.ClearAccountSession(); + var session = new S101AccountSession(customServerMgr.ApiBaseUri); + apiSession = session; + var result = await session.LoginAsync(requestedId); + if (generation != apiLoginGeneration || requestedId != CustomId) + return false; + if (!result.Success) { - tryLoginApiSvr = false; - return; + Debug.LogWarning($"API login failed: {result.Error}, HTTP {result.StatusCode}"); + return false; } - - var result = JObject.Parse(apiLoginResponse); - if ((byte)result["state"] == 0) + customServerMgr.UseAccountSession(session); +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return true; // Identity is published only after all KV modules load. +#endif + InternalId = session.InternalId; + GContext.Publish(new EvtAPISvrLoginSuccess { - InternalId = (uint)result["internalId"]; - GContext.Publish(new EvtAPISvrLoginSuccess() - { - playerId = UserId, - internalId = InternalId - }); - } - else - { - InternalId = 0; - } + playerId = session.PlayerId, + internalId = InternalId + }); + return true; } catch (Exception e) { - Debug.LogError(e); - InternalId = 0; + if (generation == apiLoginGeneration) + { + InternalId = 0; + apiSession?.Dispose(); + apiSession = null; + customServerMgr.ClearAccountSession(); + } + Debug.LogError($"API login failed: {e.GetType().Name}"); + return false; } - - tryLoginApiSvr = false; } public async virtual Task<(int code, int userid)> LinkAccount(EAccoutType accoutType) @@ -608,22 +671,44 @@ namespace game public virtual void Logout() { +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { _=StopKVGameplayLoopAsync(); return; } +#endif + if(kvControlledInstance) + { + logoutGeneration++; InternalId=0; UserId=null; DisplayName=null; AvatarUrl=null; + _=kvCoordinator?.Revoke(); + var previous=loginLifetime; loginLifetime=new System.Threading.CancellationTokenSource(); + try { previous.Cancel(); } finally { previous.Dispose(); } + return; + } + logoutGeneration++; + apiLoginGeneration++; + apiLoginTask = null; + apiSession?.Dispose(); + apiSession = null; UserId = null; InternalId = 0; DisplayName = null; AvatarUrl = null; PlayFabClientAPI.ForgetAllCredentials(); + customServerMgr.ClearAccountSession(); + var previousLifetime = loginLifetime; + loginLifetime = new System.Threading.CancellationTokenSource(); + try { previousLifetime.Cancel(); } + finally { previousLifetime.Dispose(); } } public virtual async Task ChangeDisplayName(string newName) { + var generation = logoutGeneration; var originName = string.IsNullOrEmpty(DisplayName) ? NewPlayerName : DisplayName; var result = await PlayFabClientAsyncAPI.UpdateUserTitleDisplayNameAsync(new UpdateUserTitleDisplayNameRequest { DisplayName = newName }); - if (result == null) + if (generation != logoutGeneration || result == null) return originName; DisplayName = result.DisplayName; GContext.Publish(new ChangeAvatarEvent()); @@ -633,6 +718,7 @@ namespace game public virtual async Task UpdateAvatarUrl(string url) { + var generation = logoutGeneration; var originUrl = AvatarUrl; Debug.Log("UpdateAvatarUrlAsync originUrl:" + originUrl + " url " + url); @@ -647,7 +733,7 @@ namespace game ImageUrl = url }); Debug.Log("UpdateAvatarUrlAsync :" + result); - if (result == null) return originUrl; + if (generation != logoutGeneration || result == null) return originUrl; AvatarUrl = url; GContext.Publish(new ChangeAvatarEvent()); UpdateProfileAvatar(); @@ -656,6 +742,7 @@ namespace game public virtual async Task UpdateLanguage(string code) { + var generation = logoutGeneration; if (Language == code) { return Language; @@ -666,7 +753,7 @@ namespace game }; var result = await PlayFabProfilesAsyncAPI.SetProfileLanguageAsync(setProfileLanguageRequest); - if (result != null) + if (generation == logoutGeneration && result != null) Language = code; return Language; } @@ -683,32 +770,36 @@ namespace game } public void UpdateProfile() { + if (!IsAuthorized) return; PlayerProfile playerProfile = new PlayerProfile(); playerProfile.Avatar = AvatarUrl; playerProfile.DisplayName = DisplayName; playerProfile.Level = GContext.container.Resolve().lv; - LoginRequest loginRequest = new LoginRequest() { playerId = UserId, playerProfile = playerProfile }; + LoginRequest loginRequest = new LoginRequest() { playerId = ApiPlayerId, playerProfile = playerProfile }; customServerMgr.AccountRequest("UpdateProfile", loginRequest); } public void UpdateProfileLv() { + if (!IsAuthorized) return; PlayerProfile playerProfile = new PlayerProfile(); playerProfile.Level = GContext.container.Resolve().lv; - LoginRequest loginRequest = new LoginRequest() { playerId = UserId, playerProfile = playerProfile }; + LoginRequest loginRequest = new LoginRequest() { playerId = ApiPlayerId, playerProfile = playerProfile }; customServerMgr.AccountRequest("UpdateProfile", loginRequest); } void UpdateProfileName() { + if (!IsAuthorized) return; PlayerProfile playerProfile = new PlayerProfile(); playerProfile.DisplayName = DisplayName; - LoginRequest loginRequest = new LoginRequest() { playerId = UserId, playerProfile = playerProfile }; + LoginRequest loginRequest = new LoginRequest() { playerId = ApiPlayerId, playerProfile = playerProfile }; customServerMgr.AccountRequest("UpdateProfile", loginRequest); } void UpdateProfileAvatar() { + if (!IsAuthorized) return; PlayerProfile playerProfile = new PlayerProfile(); playerProfile.Avatar = AvatarUrl; - LoginRequest loginRequest = new LoginRequest() { playerId = UserId, playerProfile = playerProfile }; + LoginRequest loginRequest = new LoginRequest() { playerId = ApiPlayerId, playerProfile = playerProfile }; customServerMgr.AccountRequest("UpdateProfile", loginRequest); } #region 其他玩家头像昵称缓存 diff --git a/Client/Assets/Scripts/Sound/SoundService.cs b/Client/Assets/Scripts/Sound/SoundService.cs index 6d50409a0..fd9a3740d 100644 --- a/Client/Assets/Scripts/Sound/SoundService.cs +++ b/Client/Assets/Scripts/Sound/SoundService.cs @@ -52,6 +52,7 @@ namespace asap.core private AsyncOperationHandle mixerHandle; private AsyncOperationHandle soundPrefabHandle; + private int initializationVersion; private Dictionary SoundFadeTaskDic; private float bgmVol = 0; @@ -135,33 +136,56 @@ namespace asap.core public async Task InitAsync(string mixerAddr = "AudioMixer", string soundPrefabAddr = "sound", string bgmGroupId = "BGM", string fishingGroupId = "Fishing", string uiGroupId = "UI", string voiceGroupId = "Voice") { + if (mixerHandle.IsValid() || soundPrefabHandle.IsValid() || lowpassEvtDisposable != null) Release(); + var version = ++initializationVersion; SoundFadeTaskDic = new Dictionary(); + AsyncOperationHandle pendingMixer = default; + AsyncOperationHandle pendingPrefab = default; + IDisposable pendingSubscription = null; + bool transferred = false; + try + { + pendingMixer = Addressables.LoadAssetAsync(mixerAddr); + var loadedMixer = await pendingMixer.Task; + if (version != initializationVersion) return; + mixer = loadedMixer; + Assert.IsNotNull(mixer); + mixer.GetFloat(Bgm_Vol_Param_Name, out bgmVol); + mixer.GetFloat(Fishing_Vol_Param_Name, out fishingVol); - mixerHandle = Addressables.LoadAssetAsync(mixerAddr); - mixer = await mixerHandle.Task; - Assert.IsNotNull(mixer); - mixer.GetFloat(Bgm_Vol_Param_Name, out bgmVol); - mixer.GetFloat(Fishing_Vol_Param_Name, out fishingVol); + bgmGroup = FindMixerGroup(bgmGroupId); + fishingGroup = FindMixerGroup(fishingGroupId); + uiGroup = FindMixerGroup(uiGroupId); + voiceGroup = FindMixerGroup(voiceGroupId); + normal = mixer.FindSnapshot("Snapshot"); + Assert.IsNotNull(bgmGroup); + Assert.IsNotNull(fishingGroup); + Assert.IsNotNull(uiGroup); - bgmGroup = FindMixerGroup(bgmGroupId); - fishingGroup = FindMixerGroup(fishingGroupId); - uiGroup = FindMixerGroup(uiGroupId); - voiceGroup = FindMixerGroup(voiceGroupId); - normal = mixer.FindSnapshot("Snapshot");// ("normal"); - //lowpass = mixer.FindSnapshot("lowpass"); - Assert.IsNotNull(bgmGroup); - Assert.IsNotNull(fishingGroup); - Assert.IsNotNull(uiGroup); + pendingPrefab = Addressables.LoadAssetAsync(soundPrefabAddr); + var soundPrefab = await pendingPrefab.Task; + if (version != initializationVersion) return; + Assert.IsNotNull(soundPrefab); + var sound = soundPrefab.GetComponent(); + Assert.IsNotNull(sound); - soundPrefabHandle = Addressables.LoadAssetAsync(soundPrefabAddr); - var soundPrefab = await soundPrefabHandle.Task; - Assert.IsNotNull(soundPrefab); - var sound = soundPrefab.GetComponent(); - Assert.IsNotNull(sound); - - lowpassEvtDisposable = GContext.OnEvent().Subscribe(OnLowpassEffectEvt); - - poolService.CreatePool(sound, 10, 50); + poolService.CreatePool(sound, 10, 50); + pendingSubscription = GContext.OnEvent().Subscribe(OnLowpassEffectEvt); + mixerHandle = pendingMixer; + soundPrefabHandle = pendingPrefab; + lowpassEvtDisposable = pendingSubscription; + transferred = true; + } + finally + { + if (!transferred) + { + pendingSubscription?.Dispose(); + if (version == initializationVersion) Release(); + if (pendingPrefab.IsValid()) Addressables.Release(pendingPrefab); + if (pendingMixer.IsValid()) Addressables.Release(pendingMixer); + } + } } private void OnLowpassEffectEvt(LowpassEffectEvt evt) @@ -176,18 +200,24 @@ namespace asap.core public void Release() { - foreach (var source in SoundFadeTaskDic.Values) + initializationVersion++; + if (SoundFadeTaskDic != null) { - source.Cancel(); + foreach (var source in SoundFadeTaskDic.Values) + { + source.Cancel(); + } + SoundFadeTaskDic.Clear(); + SoundFadeTaskDic = null; } - SoundFadeTaskDic.Clear(); - SoundFadeTaskDic = null; - poolService.DestroyPool(); - Addressables.Release(mixerHandle); - Addressables.Release(soundPrefabHandle); + poolService?.DestroyPool(); + if (mixerHandle.IsValid()) Addressables.Release(mixerHandle); + mixerHandle = default; + if (soundPrefabHandle.IsValid()) Addressables.Release(soundPrefabHandle); + soundPrefabHandle = default; - lowpassEvtDisposable.Dispose(); + lowpassEvtDisposable?.Dispose(); lowpassEvtDisposable = null; } diff --git a/Client/Assets/Scripts/Stages/AggLoginAct.cs b/Client/Assets/Scripts/Stages/AggLoginAct.cs index 04c566b71..7151e51be 100644 --- a/Client/Assets/Scripts/Stages/AggLoginAct.cs +++ b/Client/Assets/Scripts/Stages/AggLoginAct.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Threading; using System.Threading.Tasks; using asap.core; using UnityEngine; @@ -38,9 +39,14 @@ namespace game private IDisposable disposable; private string[] loadingInfos; + private CancellationTokenSource loginLifetime; + private CancellationToken loginToken; + private CancellationTokenRegistration loginTypeCancellation; public override async Task StartAsync() { + loginLifetime = CancellationTokenSource.CreateLinkedTokenSource(userService.LoginLifetime); + loginToken = loginLifetime.Token; #if UNITY_IOS vkRecoveryBtn.transform.parent.gameObject.SetActive(false); googleRecoveryBtn.transform.parent.gameObject.SetActive(false); @@ -79,18 +85,26 @@ namespace game disposable = GContext.OnEvent().Subscribe(OnChooseLoginType); await base.StartAsync(); + if (loginToken.IsCancellationRequested) return false; DoLogin(); return true; } protected override void OnDestroy() { - disposable.Dispose(); + loginLifetime?.Cancel(); + loginLifetime?.Dispose(); + loginLifetime = null; + loginTypeCancellation.Dispose(); + disposable?.Dispose(); disposable = null; } private void OnChooseLoginType(LoginTypeEvt evt) { + if (loginToken.IsCancellationRequested) return; + loginTypeCancellation.Dispose(); + loginTypeCancellation = loginToken.Register(() => evt.loginType.TrySetCanceled()); OpenLoginPanel(evt); } @@ -123,66 +137,73 @@ namespace game loginPanel.SetActive(true); guestBtn.onClick.AddListener(() => { + if (loginToken.IsCancellationRequested || evt.loginType.Task.IsCompleted) return; using (var e = GEvent.RegistrationEvent()) { e.AddContent("login_type", EAccoutType.hwGuest.ToString()); } CloseLoginPanel(); - evt.loginType.SetResult(EAccoutType.hwGuest); + evt.loginType.TrySetResult(EAccoutType.hwGuest); }); guestRecoveryBtn.onClick.AddListener(() => { + if (loginToken.IsCancellationRequested || evt.loginType.Task.IsCompleted) return; using (var e = GEvent.RegistrationEvent()) { e.AddContent("login_type", EAccoutType.hwGuest.ToString()); } CloseLoginPanel(); - evt.loginType.SetResult(EAccoutType.hwGuest); + evt.loginType.TrySetResult(EAccoutType.hwGuest); }); googleRecoveryBtn.onClick.AddListener(() => { + if (loginToken.IsCancellationRequested || evt.loginType.Task.IsCompleted) return; using (var e = GEvent.RegistrationEvent()) { e.AddContent("login_type", EAccoutType.hwGoogle.ToString()); } CloseLoginPanel(); - evt.loginType.SetResult(EAccoutType.hwGoogle); + evt.loginType.TrySetResult(EAccoutType.hwGoogle); }); vkRecoveryBtn.onClick.AddListener(() => { + if (loginToken.IsCancellationRequested || evt.loginType.Task.IsCompleted) return; using (var e = GEvent.RegistrationEvent()) { e.AddContent("login_type", nameof(EAccoutType.hwVkid)); } CloseLoginPanel(); - evt.loginType.SetResult(EAccoutType.hwVkid); + evt.loginType.TrySetResult(EAccoutType.hwVkid); }); facebookRecoveryBtn.onClick.AddListener(() => { + if (loginToken.IsCancellationRequested || evt.loginType.Task.IsCompleted) return; using (var e = GEvent.RegistrationEvent()) { e.AddContent("login_type", EAccoutType.hwFacebook.ToString()); } CloseLoginPanel(); - evt.loginType.SetResult(EAccoutType.hwFacebook); + evt.loginType.TrySetResult(EAccoutType.hwFacebook); }); appleRecoveryBtn.onClick.AddListener(() => { + if (loginToken.IsCancellationRequested || evt.loginType.Task.IsCompleted) return; using (var e = GEvent.RegistrationEvent()) { e.AddContent("login_type", EAccoutType.Apple.ToString()); } CloseLoginPanel(); - evt.loginType.SetResult(EAccoutType.Apple); + evt.loginType.TrySetResult(EAccoutType.Apple); }); helpBtn.gameObject.SetActive(true); helpBtn.onClick.AddListener(() => { + if (loginToken.IsCancellationRequested || evt.loginType.Task.IsCompleted) return; AIHelp.AIHelpSupport.Show("E001"); using (var e = GEvent.TackEvent("click_support")) { @@ -191,10 +212,12 @@ namespace game }); recoverBtn.onClick.AddListener(() => { + if (loginToken.IsCancellationRequested || evt.loginType.Task.IsCompleted) return; recoveryDialog.SetActive(true); }); closeRecoveryBtn.onClick.AddListener(() => { + if (loginToken.IsCancellationRequested || evt.loginType.Task.IsCompleted) return; recoveryDialog.SetActive(false); }); // repairBtn.gameObject.SetActive(true); @@ -205,6 +228,14 @@ namespace game } private async void DoLogin() { + try { await LoginAsync(); } + catch (OperationCanceledException) when (loginToken.IsCancellationRequested) { } + catch (Exception ex) { Debug.LogException(ex); } + } + + private async Task LoginAsync() + { + loginToken.ThrowIfCancellationRequested(); using (var e = GEvent.TackEvent("loading")) { e.AddContent("step", 7); @@ -212,11 +243,13 @@ namespace game rtService.WaitForAPISvrLogin(); var step = await userService.Login(OnLoginStep); + loginToken.ThrowIfCancellationRequested(); if (step != ELoginStep.Done) { await ReytryDialog(userService.Login, LocalizationMgr.GetText("subtitle_loginfailed") , LocalizationMgr.GetText("msg_loginfailed"), step); } + loginToken.ThrowIfCancellationRequested(); /* try */ /* { */ @@ -237,22 +270,32 @@ namespace game { while (true) { + loginToken.ThrowIfCancellationRequested(); using (var e = GEvent.TackEvent("loginretry")) { e.AddContent("loginstep", step.ToString()); } var retry = new TaskCompletionSource(); + bool submitted = false; + async void Submit(bool byToken) + { + if (submitted || loginToken.IsCancellationRequested) return; + submitted = true; + try { retry.TrySetResult(await func(OnLoginStep, byToken)); } + catch (Exception ex) { retry.TrySetException(ex); } + } + using var cancellation = loginToken.Register(() => retry.TrySetCanceled()); dialog.Config(new SimpleDialog.Context() { message = title, message2 = message, - okAction = async () => retry.SetResult(await func(OnLoginStep, true)), - closeAction = async () => retry.SetResult(await func(OnLoginStep, false)) + okAction = () => Submit(true), + closeAction = () => Submit(false) }); step = await retry.Task; - await Task.Delay(1000); + await Task.Delay(1000, loginToken); if (step == ELoginStep.Done) return; } @@ -260,6 +303,7 @@ namespace game private void OnLoginStep(int step) { + if (loginToken.IsCancellationRequested) return; LoadingScreen.SetProgress(0.2f + step / 3.0f * 0.3f); } diff --git a/Client/Assets/Scripts/Stages/FishingAct.cs b/Client/Assets/Scripts/Stages/FishingAct.cs index a936da949..ffbe2cfe3 100644 --- a/Client/Assets/Scripts/Stages/FishingAct.cs +++ b/Client/Assets/Scripts/Stages/FishingAct.cs @@ -151,13 +151,16 @@ public class FishingAct : AGameAct public override async System.Threading.Tasks.Task StartAsync() { + var loginLifetime = GContext.container.Resolve().LoginLifetime; GContext.container.Resolve().CheckLoginPhishingBehavior(); await GContext.container.Resolve().GetRewards(); + if (this == null || loginLifetime.IsCancellationRequested) return false; //GContext.Publish(new TitleNewsEvent()); GContext.container.Unregister(); GContext.container.Unregister(); fishingData = new FishingData(); await fishingData.Init(); + if (this == null || loginLifetime.IsCancellationRequested) return false; //fishingData.BottleOpenDelay = BottleOpenDelay; GContext.container.RegisterInstance(fishingData); collectingData = new CollectingData(); @@ -242,6 +245,7 @@ public class FishingAct : AGameAct var playerData = GContext.container.Resolve(); SetRodData(playerData.equipRodID); await ChangeMapDataAsync(playerData.currentMapId); + if (this == null || loginLifetime.IsCancellationRequested) return false; LoadingScreen.SetProgress(0.99f); InitData(); @@ -294,11 +298,15 @@ public class FishingAct : AGameAct } async void NewEnterMap(PlayableDirector camera_timeline) { + if (this == null) return; + var container = GContext.container; + var loginLifetime = container.Resolve().LoginLifetime; camera_timeline.gameObject.SetActive(true); fishingBehavior.cameraAnimator.enabled = false; fishingBehavior.cameraAnimator.transform.localEulerAngles = Vector3.zero; fishingBehavior.cameraAnimator.transform.localPosition = Vector3.zero; await Awaiters.Seconds((float)camera_timeline.duration); + if (this == null || loginLifetime.IsCancellationRequested || !ReferenceEquals(container, GContext.container)) return; camera_timeline.gameObject.SetActive(false); fishingData.mainCamera.transform.localEulerAngles = Vector3.zero; fishingData.mainCamera.transform.localPosition = Vector3.zero; @@ -306,7 +314,9 @@ public class FishingAct : AGameAct GContext.Publish(new EventBGMSound(_curMapData.BGM)); fishingBehavior.cameraAnimator.Play("EnterMap", -1, 0); await OnCameraSpeedEvent(); + if (this == null || loginLifetime.IsCancellationRequested || !ReferenceEquals(container, GContext.container)) return; var go = await UIManager.Instance.RestartShowUI(UITypes.HomePanel); + if (this == null || loginLifetime.IsCancellationRequested || !ReferenceEquals(container, GContext.container)) return; HomePanel homePanel = go.GetComponent(); homePanel.OnStart(); } @@ -341,18 +351,24 @@ public class FishingAct : AGameAct } async void GetUI() { + if (this == null) return; + var container = GContext.container; + var loginLifetime = container.Resolve().LoginLifetime; HomePanel homePanel = null; if (!fishingData.isMapBuff && !IsHideHomeMap) { var go = await UIManager.Instance.RestartShowUI(UITypes.HomePanel); + if (this == null || loginLifetime.IsCancellationRequested || !ReferenceEquals(container, GContext.container)) return; homePanel = go.GetComponent(); } //实例 - GContext.container.Resolve(); + container.Resolve(); //这里确认补单 - await GContext.container.Resolve().ReleaseReplenishment(); + await container.Resolve().ReleaseReplenishment(); + if (this == null || loginLifetime.IsCancellationRequested || !ReferenceEquals(container, GContext.container)) return; await Awaiters.SecondsRealtime(0.5f); - GContext.container.Resolve().ShowRankTierChange(); + if (this == null || loginLifetime.IsCancellationRequested || !ReferenceEquals(container, GContext.container)) return; + container.Resolve().ShowRankTierChange(); if (fishingData.isMapBuff) { fishingData.isMapBuff = false; @@ -1070,6 +1086,7 @@ public class FishingAct : AGameAct } async System.Threading.Tasks.Task LoadMapByCurID() { + var loginLifetime = GContext.container.Resolve().LoginLifetime; #if AGG using (var e = GEvent.GameEvent("enter_map", gaSend: false)) @@ -1090,7 +1107,13 @@ public class FishingAct : AGameAct } - mapObjPrefab = await Addressables.LoadAssetAsync(_curMapData.EnvName).Task; + var loadedMapPrefab = await Addressables.LoadAssetAsync(_curMapData.EnvName).Task; + if (this == null || loginLifetime.IsCancellationRequested) + { + if (loadedMapPrefab != null) Addressables.Release(loadedMapPrefab); + return; + } + mapObjPrefab = loadedMapPrefab; if (mapObjPrefab != null) { _mapObj = Instantiate(mapObjPrefab); @@ -1111,12 +1134,14 @@ public class FishingAct : AGameAct fishingBehavior.gameObject.SetActive(true); fishingBehavior.Init(); await fishingBehavior.LoadHand(); + if (this == null || loginLifetime.IsCancellationRequested || fishingBehavior == null) return; fishingBehavior.cameraAnimator.Play("Preparing01", -1, 0); fishingBehavior.cameraAnimator.speed = 0; if (fishingData.fishRodData != null) { LoadingScreen.SetProgress(0.95f); await InitFishRodAsync(); + if (this == null || loginLifetime.IsCancellationRequested || fishingBehavior == null) return; } fishingBehavior?.OnFishingEnable(); @@ -1126,6 +1151,7 @@ public class FishingAct : AGameAct if (fishingData.isMapBuff) { await UIManager.Instance.ShowUI(UITypes.UnlockNewMapPopupPanel); + if (this == null || loginLifetime.IsCancellationRequested || fishingBehavior == null) return; fishingBehavior.cameraAnimator.Play("EnterMap", -1, 0); } GContext.Publish(new EndTransition()); @@ -1141,8 +1167,12 @@ public class FishingAct : AGameAct } async Task OnCameraSpeedEvent() { + if (this == null) return; + var container = GContext.container; + var loginLifetime = container.Resolve().LoginLifetime; fishingBehavior.cameraAnimator.speed = 1; await Awaiters.Seconds(fishingData.fishingBehaviorConf.rodEnterMapDelay); + if (this == null || loginLifetime.IsCancellationRequested || !ReferenceEquals(container, GContext.container)) return; fishingBehavior?.RodBehaviour.gameObject.SetActive(true); GContext.Publish(new ShowHandEvent() { isShow = true }); } diff --git a/Client/Assets/Scripts/Stages/FishingStage.cs b/Client/Assets/Scripts/Stages/FishingStage.cs index 1825225de..39f272ccc 100644 --- a/Client/Assets/Scripts/Stages/FishingStage.cs +++ b/Client/Assets/Scripts/Stages/FishingStage.cs @@ -138,17 +138,22 @@ namespace game bool rewardShowing = false; private AutoRestart _autoRestart; + private bool stopping; public override async Task StartAsync() { + if (stopping || this == null) return; + var loginLifetime = UserService.LoginLifetime; LoadingScreen.SetProgress(0.54f); await Awaiters.NextFrame; + if (stopping || this == null || loginLifetime.IsCancellationRequested) return; // wait authorize , 5 sec max var checkAuthorTimes = 0; while (checkAuthorTimes < 10 && !UserService.IsAuthorized) { await Task.Delay(500); + if (stopping || this == null || loginLifetime.IsCancellationRequested) return; checkAuthorTimes++; } @@ -159,9 +164,18 @@ namespace game if (GContext.container.Resolve().Get("DEBUG", "0") == "1") { - await UnityEngine.AddressableAssets.Addressables.InstantiateAsync("IngameDebugConsole"); + var console = await UnityEngine.AddressableAssets.Addressables.InstantiateAsync("IngameDebugConsole").Task; + if (stopping || this == null || loginLifetime.IsCancellationRequested) + { + if (console != null) UnityEngine.AddressableAssets.Addressables.ReleaseInstance(console); + return; + } } - await GContext.container.Resolve().Init((progress) => LoadingScreen.SetProgress(0.55f + progress * 0.2f)); + await GContext.container.Resolve().Init((progress) => + { + if (!stopping && this != null && !loginLifetime.IsCancellationRequested) LoadingScreen.SetProgress(0.55f + progress * 0.2f); + }); + if (stopping || this == null || loginLifetime.IsCancellationRequested) return; //GContext.Publish() stageData = new FishingStageData(); @@ -183,17 +197,23 @@ namespace game GContext.OnEvent().Subscribe(ToFishingStageAct).AddTo(disposable); GContext.OnEvent().Subscribe(OnUnauthorized).AddTo(disposable); await GContext.container.Resolve().LoadAtlas(); + if (stopping || this == null || loginLifetime.IsCancellationRequested) return; LoadingScreen.SetProgress(0.77f); //这里拿补单信息,拿到之后再进入主界面处理 await GContext.container.Resolve().GetReplenishmentData(); + if (stopping || this == null || loginLifetime.IsCancellationRequested) return; PlayerData playerData = GContext.container.Resolve(); //加载当前场景 var mapData = playerData.GetCurrentMapData(); ILoadResourceService loadResourceService = GContext.container.Resolve(); List mapNames = new List() { mapData.EnvName }; mapNames.AddRange(GContext.container.Resolve().AllPrefabs); - bool result = await loadResourceService.Loading(mapNames, (progress) => LoadingScreen.SetProgress(0.77f + progress * 0.13f)); + bool result = await loadResourceService.Loading(mapNames, (progress) => + { + if (!stopping && this != null && !loginLifetime.IsCancellationRequested) LoadingScreen.SetProgress(0.77f + progress * 0.13f); + }); + if (stopping || this == null || loginLifetime.IsCancellationRequested) return; if (!result) { playerData.SetCurrentMapId(GContext.container.Resolve().TbMapData.DataList[0].ID); @@ -201,7 +221,13 @@ namespace game LoadingScreen.SetLoadingInfo(LocalizationMgr.GetText("msg_loadingfinishing")); GContext.container.Resolve().SetLvData(); - await base.StartAsync(); + try { await base.StartAsync(); } + catch (System.Exception ex) when (stopping || this == null || loginLifetime.IsCancellationRequested) + { + Debug.LogWarning($"[FishingStage] Startup stopped, cancelled or stage destroyed: {ex.GetType().Name}: {ex.Message}"); + return; + } + if (stopping || this == null || loginLifetime.IsCancellationRequested) return; //静默加载后续场景 playerData.LoadNewMap(); //更新数据 @@ -516,17 +542,18 @@ namespace game public override async Task Stop() { + stopping = true; GContext.container.Unregister(); GContext.container.Unregister(); GContext.container.Resolve()?.DisposeSubscriptions(); GContext.container.Unregister(); - disposable.Dispose(); + disposable?.Dispose(); disposable = null; _autoRestart?.Dispose(); _autoRestart = null; await base.Stop(); } - protected override void OnDestroy() { } + protected override void OnDestroy() { stopping = true; } //protected override async Task OnActUnload(IAct act, bool requestNextAct) //{ diff --git a/Client/Assets/Scripts/Stages/LoginStage.cs b/Client/Assets/Scripts/Stages/LoginStage.cs index 553ffc4d3..56349dbdd 100644 --- a/Client/Assets/Scripts/Stages/LoginStage.cs +++ b/Client/Assets/Scripts/Stages/LoginStage.cs @@ -28,6 +28,10 @@ namespace game GContext.container.Register().AsSingleton(); GContext.container.Register().AsSingleton(); GContext.container.Register().AsSingleton(); +#if UNITY_EDITOR && !SERVICE_HOST + if(PlayerDataStorage.PlayerKVGameplayLoop.Enabled) + GContext.container.Register().AsSingleton(); +#endif GContext.container.Register().AsSingleton(); //GContext.container.Register().AsSingleton(); GContext.container.Register().AsSingleton(); @@ -88,6 +92,9 @@ namespace game startActId = "AggLoginAct"; #else startActId = "PFLoginAct"; +#endif +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) startActId="PFLoginAct"; #endif if (!LoadingScreen.isShowing()) { diff --git a/Client/Assets/Scripts/Stages/PFLoginAct.cs b/Client/Assets/Scripts/Stages/PFLoginAct.cs index 7a3cbb223..551b75402 100644 --- a/Client/Assets/Scripts/Stages/PFLoginAct.cs +++ b/Client/Assets/Scripts/Stages/PFLoginAct.cs @@ -30,9 +30,13 @@ namespace game [SerializeField] SimpleDialog dialog; private string[] loadingInfos; + private CancellationTokenSource loginLifetime; + private CancellationToken loginToken; public override async Task StartAsync() { + loginLifetime = CancellationTokenSource.CreateLinkedTokenSource(userService.LoginLifetime); + loginToken = loginLifetime.Token; Debug.Log("PFLoginAct StartAsync"); loadingInfos = new string[] { @@ -48,6 +52,7 @@ namespace game }.Select(key => LocalizationMgr.GetText(key)).ToArray(); await base.StartAsync(); + if (loginToken.IsCancellationRequested) return false; DoLogin(); return true; } @@ -55,17 +60,30 @@ namespace game //CancellationTokenSource source; private async void DoLogin() { + try { await LoginAsync(); } + catch (OperationCanceledException) when (loginToken.IsCancellationRequested) { } + catch (Exception ex) { Debug.LogException(ex); } + finally { StopLoadingInfo(); } + } + + private async Task LoginAsync() + { + loginToken.ThrowIfCancellationRequested(); UpdateLoadingInfo(); var configCustomId = config.Get("CustomID", string.Empty); +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) configCustomId=game.PlayerDataStorage.PlayerKVGameplayLoop.Permit.PlayerId; +#endif if (configCustomId == @"{{input}}") { LoadingScreen.HideProgressBar(); StopLoadingInfo(); await InputID(); + loginToken.ThrowIfCancellationRequested(); LoadingScreen.ShowProgressBar(); UpdateLoadingInfo(); } - else if (string.IsNullOrEmpty(configCustomId)) + else { playFabUserService.CustomId = configCustomId; } @@ -75,13 +93,18 @@ namespace game rtService.WaitForAPISvrLogin(); var step = await userService.Login(OnLoginStep); + loginToken.ThrowIfCancellationRequested(); if (step != ELoginStep.Done) { +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Login failed; restart the Unity process before another attempt."); +#endif //source.Cancel(); //source.Dispose(); await ReytryDialog(userService.Login, LocalizationMgr.GetText("subtitle_loginfailed") , LocalizationMgr.GetText("msg_loginfailed")); } + loginToken.ThrowIfCancellationRequested(); StopLoadingInfo(); //source.Cancel(); @@ -105,9 +128,19 @@ namespace game customInput.SetActive(true); var tempInput = PlayerPrefs.GetString("CustomID", string.Empty); var inputTask = new TaskCompletionSource(); - inputOkBtn.onClick.AddListener(() => inputTask.SetResult(customIdInput.text)); + UnityEngine.Events.UnityAction accept = () => + { + if (!loginToken.IsCancellationRequested) inputTask.TrySetResult(customIdInput.text); + }; + inputOkBtn.onClick.AddListener(accept); customIdInput.text = tempInput; - tempInput = await inputTask.Task; + try + { + using (loginToken.Register(() => inputTask.TrySetCanceled())) + tempInput = await inputTask.Task; + } + finally { if (inputOkBtn != null) inputOkBtn.onClick.RemoveListener(accept); } + loginToken.ThrowIfCancellationRequested(); if (!string.IsNullOrEmpty(tempInput)) { PlayerPrefs.SetString("CustomID", tempInput); @@ -124,22 +157,41 @@ namespace game { while (true) { + loginToken.ThrowIfCancellationRequested(); //source = new CancellationTokenSource(); var retry = new TaskCompletionSource(); + bool submitted = false; + async void Submit() + { + if (submitted || loginToken.IsCancellationRequested) return; + submitted = true; + try + { + if (config.Get("CustomID", string.Empty) == @"{{input}}") + { + LoadingScreen.HideProgressBar(); + StopLoadingInfo(); + await InputID(); + loginToken.ThrowIfCancellationRequested(); + LoadingScreen.ShowProgressBar(); + UpdateLoadingInfo(); + } + retry.TrySetResult(await func(OnLoginStep, true)); + } + catch (Exception ex) { retry.TrySetException(ex); } + } + using var cancellation = loginToken.Register(() => retry.TrySetCanceled()); //LoadingAutoInc(source.Token, 1f); dialog.Config(new SimpleDialog.Context() { message = title, message2 = message, - okAction = async () => - { - retry.SetResult(await func(OnLoginStep, true)); - }, - closeAction = async () => retry.SetResult(await func(OnLoginStep, true)) + okAction = Submit, + closeAction = Submit }); var result = await retry.Task; //LoadingAutoInc(source.Token, 1f); - await Task.Delay(1000); + await Task.Delay(1000, loginToken); if (result == ELoginStep.Done) return; //else @@ -152,6 +204,7 @@ namespace game private void OnLoginStep(int step) { + if (loginToken.IsCancellationRequested) return; LoadingScreen.SetProgress(0.2f + step / 2.0f * 0.3f); } @@ -168,7 +221,7 @@ namespace game isUpdatingLoadingInfo = true; var deltaTime = 0f; var info = string.Empty; - while (isUpdatingLoadingInfo) + while (isUpdatingLoadingInfo && !loginToken.IsCancellationRequested) { info = GetTextByDeltaTime(deltaTime, info); LoadingScreen.SetLoadingInfo(info); @@ -235,7 +288,13 @@ namespace game isUpdatingLoadingInfo = false; } - protected override void OnDestroy() { } + protected override void OnDestroy() + { + StopLoadingInfo(); + loginLifetime?.Cancel(); + loginLifetime?.Dispose(); + loginLifetime = null; + } //protected async void LoadingAutoInc(CancellationToken token, float total) diff --git a/Client/Assets/Scripts/Stages/QuitAct.cs b/Client/Assets/Scripts/Stages/QuitAct.cs index 450313ee4..66bb31921 100644 --- a/Client/Assets/Scripts/Stages/QuitAct.cs +++ b/Client/Assets/Scripts/Stages/QuitAct.cs @@ -34,12 +34,31 @@ namespace game public override async Task StartAsync() { +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) _=game.PlayerDataStorage.PlayerKVGameplayLoop.CloseAsync(); +#endif + if(userService is PlayFabUserService closingKVUser) _=closingKVUser.CloseKVForValidationAsync(); + // Invalidate identity before any await can allow old login/HTTP callbacks to resume. + if (QuitStage.Logout) + { + QuitStage.Logout = false; + try { userService.Logout(); } + catch (System.Exception ex) { Debug.LogException(ex); } + } LoadingScreen.Hide(); GameObject.Destroy(UIManager.Instance.gameObject); config.Rest(); - rtService.Release(); + try { await rtService.ReleaseAsync(); } + catch (System.Exception ex) { Debug.LogException(ex); } + + // Observation is bounded; the process registry still owns any unfinished close. +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) await PlayerDataStorage.PlayerKVShutdownRegistry.Process.ObserveAsync(5000); + else +#endif + if(userService is PlayFabUserService kvUser) await kvUser.ObserveKVQuitAsync(); adsService.Release(); @@ -49,6 +68,7 @@ namespace game heartBeat?.Dispose(); #endif GameCore.Timer.CancelAllRegisteredTimers(); + GameCore.Timer.Destroy(); soundMgr.Release(); RedPointManager.Instance.Init(); @@ -66,12 +86,17 @@ namespace game GameObject.DestroyImmediate(go); } } - if (QuitStage.Logout) - { - userService.Logout(); - QuitStage.Logout = false; - } base.OnStarted(); +#if UNITY_EDITOR && !SERVICE_HOST + if (game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) + { + var status = PlayerDataStorage.PlayerKVShutdownRegistry.Process.Status; + Debug.Log("KV loop ended; restart Unity before another run. Shutdown: " + status); + if (status == PlayerDataStorage.PlayerKVShutdownStatus.Ready) + UnityEditor.EditorApplication.isPlaying = false; + return; // Keep the process barrier; never return this run to PreBoot. + } +#endif Invoke("Unload", 2f); } } diff --git a/Client/Assets/Scripts/SubBoostrap.cs b/Client/Assets/Scripts/SubBoostrap.cs index e29c31ecb..8dd0843c5 100644 --- a/Client/Assets/Scripts/SubBoostrap.cs +++ b/Client/Assets/Scripts/SubBoostrap.cs @@ -84,6 +84,9 @@ public class SubBoostrap GameCore.GameEventMgr.Init(true); #else GameCore.GameEventMgr.Init(); +#endif +#if UNITY_EDITOR && !SERVICE_HOST + game.PlayerDataStorage.PlayerKVGameplayLoop.BeforeBootstrap(); #endif rootCtxGo.AddComponent(); // TODO Replace DataCenter with seprated data class diff --git a/Client/Assets/Scripts/UI/FishingReward/FishingRewardPanel.cs b/Client/Assets/Scripts/UI/FishingReward/FishingRewardPanel.cs index 496eaeeca..d6f3a5e97 100644 --- a/Client/Assets/Scripts/UI/FishingReward/FishingRewardPanel.cs +++ b/Client/Assets/Scripts/UI/FishingReward/FishingRewardPanel.cs @@ -17,6 +17,24 @@ using UnityEngine.UI; public class FishingRewardPanel : BasePanel { +#if UNITY_EDITOR && !SERVICE_HOST + game.PlayerDataStorage.PlayerKVGameplayLoop.FishingRewardAction kvReward; + System.Func kvPresentation; + bool KVRewardViewCurrent() => !game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled || + (this!=null && isActiveAndEnabled && game.PlayerDataStorage.PlayerKVGameplayLoop.RewardPanelCurrent(kvReward,this)); + async System.Threading.Tasks.Task ConfirmKVRewardAsync(game.PlayerDataStorage.PlayerKVEditResult primary) + { + try + { + await game.PlayerDataStorage.PlayerKVGameplayLoop.ConfirmRewardAsync(kvReward,primary); + if(!KVRewardViewCurrent()) return; + game.PlayerDataStorage.PlayerKVGameplayLoop.PresentReward(kvReward,()=>{canvasGroup.alpha=1;canvasGroup.blocksRaycasts=true;canvasGroup.interactable=true;}); + if(kvPresentation!=null) await kvPresentation(); + } + catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); } + } + void OnDisable() => game.PlayerDataStorage.PlayerKVGameplayLoop.CancelReward(this,"reward panel disabled"); +#endif Tables _tables; private CanvasGroup canvasGroup; private CanvasGroup root_success; @@ -145,6 +163,9 @@ public class FishingRewardPanel : BasePanel dragImage = transform.Find("root_success/UIDrag").GetComponent(); canvasGroup = GetComponent(); +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { canvasGroup.alpha=0; canvasGroup.blocksRaycasts=false; canvasGroup.interactable=false; } +#endif root_success = transform.Find("root_success").GetComponent(); @@ -242,6 +263,9 @@ public class FishingRewardPanel : BasePanel } if (GContext.container.Resolve().IsOpenDrop) { +#if UNITY_EDITOR && !SERVICE_HOST + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) +#endif CheckResource(); } } @@ -260,8 +284,12 @@ public class FishingRewardPanel : BasePanel fishingData = GContext.container.Resolve(); Magnification = playerData.GetMagnification(); - go_fishPhoto.OnBackClick = OnBackClick; playButton.onClick.AddListener(OnClickInHome); +#if UNITY_EDITOR && !SERVICE_HOST + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) +#endif + { + go_fishPhoto.OnBackClick = OnBackClick; targetBtn.onClick.AddListener(OnTargetClosePanel); oneVOneBattleBtn.onClick.AddListener(OnTargetClosePanel); btn_green.onClick.AddListener(OnSucceed); @@ -291,6 +319,7 @@ public class FishingRewardPanel : BasePanel dragImage.OnDragCall += OnDragFish; dragImage.OnEndDragCall += StartDoRotate; + } OnShow(); } void StartDoRotate() @@ -309,6 +338,31 @@ public class FishingRewardPanel : BasePanel } void OnShow() + { +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) + { + try + { + var bound=game.PlayerDataStorage.PlayerKVGameplayLoop.BindRewardPanel(this); + if(bound==null) return; + kvReward=bound; + game.PlayerDataStorage.PlayerKVEditResult primary; + using(game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardSegment("reward")) + { + OnShowCore(); + playerFishData.SaveCaughtFishForKVLoop(fishID); + primary=playerData.SaveFishingRewardForKVLoop(); + } + _=ConfirmKVRewardAsync(primary); + } + catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); } + return; + } +#endif + OnShowCore(); + } + void OnShowCore() { var normalRank = _tables.TbGlobalConfig.Rank; var spRank = _tables.TbGlobalConfig.RankForSpFish; @@ -371,13 +425,23 @@ public class FishingRewardPanel : BasePanel if (fishingData.fishingResult == FishingResult.Success) { +#if UNITY_EDITOR && !SERVICE_HOST + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) +#endif AudioRewardPanel(); root_fail.SetActive(false); root_success.gameObject.SetActive(fishingData.IsGeneralFish()); root_target.SetActive(fishingData.IsKeepsakeFish() || fishingData.IsPackItemFish()); root_1v1battle.SetActive(fishingData.fishItem.IsSpinachItem()); float pointBonus = playerFishData.GetRodAscendStats(); +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) kvPresentation=ApplySuccess(pointBonus); + else +#endif Success(pointBonus); +#if UNITY_EDITOR && !SERVICE_HOST + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) +#endif StartCoroutine(Blur()); #if AGG @@ -418,6 +482,9 @@ public class FishingRewardPanel : BasePanel } #endif GContext.Publish(new OnEventTriggerGuide(GetType().Name)); +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) game.PlayerDataStorage.PlayerKVGameplayLoop.RecordRewardGuideNotification(); +#endif fishingData.IsRetry = false; } else @@ -481,11 +548,19 @@ public class FishingRewardPanel : BasePanel //Pulling 结束后 float time = fishingData.fishingBehaviorConf.blurDelay; yield return new WaitForSeconds(time); +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVRewardViewCurrent()) yield break; +#endif EnableFishingBlur(true); } //钓鱼成功 async void Success(float pointBonus) + { + var present=ApplySuccess(pointBonus); + if(present!=null) await present(); + } + System.Func ApplySuccess(float pointBonus) { go_info.gameObject.SetActive(false); GContext.container.Resolve().SaveFishingFrequency(); @@ -497,6 +572,9 @@ public class FishingRewardPanel : BasePanel GContext.container.Resolve().AddItemCount(curCollectingID, addCollecting, null); MaterialChange(); } +#if UNITY_EDITOR && !SERVICE_HOST + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) +#endif PlayAudioRewardFish(); GContext.container.Resolve().EndCollectionTargetWeight(fishingData.fishItem.ID); fishingEventData.AddProgress(fishingData.fishItem.Quality, Magnification); @@ -566,6 +644,9 @@ public class FishingRewardPanel : BasePanel } GContext.Publish(new TargetAddData(dropPackageList.DropIDList[1], 0, add)); var isSpecialFish = fishingData.curFishData.GeneralType == FishGeneralType.SpMapFish || fishingData.curFishData.GeneralType == FishGeneralType.EventFish; +#if UNITY_EDITOR && !SERVICE_HOST + if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) +#endif SetTextTitleAsync(proficiencyExtra, isSpecialFish); SetFishWeight(fishingData.fishWeight); MMTService mmService = GContext.container.Resolve(); @@ -580,6 +661,28 @@ public class FishingRewardPanel : BasePanel } } bonusReward.Show(Magnification); +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) + return async ()=> + { + if(!KVRewardViewCurrent()) return; + AudioRewardPanel(); PlayAudioRewardFish(); SetTextTitleAsync(proficiencyExtra,isSpecialFish); + _ = StartCoroutine(Blur()); + await PresentGeneralRewardAsync(glodBuff); + }; +#endif + return ()=>PresentGeneralRewardAsync(glodBuff); + } + else if (fishingData.fishItem.IsSpinachItem()) + { + //todo 1v1battle + root_1v1battle_text_info.text=LocalizationMgr.GetText("UI_GiftBargainPopupPanel_8"); + } + return null; + } + + async System.Threading.Tasks.Task PresentGeneralRewardAsync(float glodBuff) + { _ = StartCoroutine(AddCash()); //Show Fish Info UI go_info.gameObject.SetActive(true); @@ -593,7 +696,11 @@ public class FishingRewardPanel : BasePanel anim_panel.Play("info_show_2"); await new WaitForSeconds(waitTime1 + duration1 + waitTime2); - if (!isEnable) + if (!isEnable +#if UNITY_EDITOR && !SERVICE_HOST + || !KVRewardViewCurrent() +#endif + ) { return; } @@ -605,6 +712,9 @@ public class FishingRewardPanel : BasePanel var addCash = add - newCash; DOTween.To(() => progress, x => progress = x, 1f, duration2).OnUpdate(() => { +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVRewardViewCurrent()) return; +#endif text_gold_num.text = ConvertTools.GetNumberString2((int)(newCash + addCash * progress)); text_num_buff.text = text_gold_num.text; }).SetId("AddCash"); @@ -620,17 +730,14 @@ public class FishingRewardPanel : BasePanel } VibrationData(); fishingRewardSocial.ShowSocial(isSocial); - } - else if (fishingData.fishItem.IsSpinachItem()) - { - //todo 1v1battle - root_1v1battle_text_info.text=LocalizationMgr.GetText("UI_GiftBargainPopupPanel_8"); - } } async void VibrationData() { await Awaiters.Seconds(2.5f); +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVRewardViewCurrent()) return; +#endif if (curRateIndex >= _tables.TbGlobalConfig.RankVibration.Count) curRateIndex = _tables.TbGlobalConfig.RankVibration.Count - 1; int hapticType = _tables.TbGlobalConfig.RankVibration[curRateIndex]; @@ -717,8 +824,14 @@ public class FishingRewardPanel : BasePanel var progress = 0f; int cash = add / Magnification; yield return Awaiters.Seconds(waitTime1); +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVRewardViewCurrent()) yield break; +#endif DOTween.To(() => progress, x => progress = x, 1, duration1).OnUpdate(() => { +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVRewardViewCurrent()) return; +#endif text_weight_num.text = LocalizationMgr.GetFormatTextValue("UI_FishingRewardPanel_101014", ConvertTools.GetNumberString2((int)(_fishScore * progress))); text_pound.text = LocalizationMgr.GetWeight(fishingData.fishWeight * progress, true); @@ -729,6 +842,9 @@ public class FishingRewardPanel : BasePanel .SetId("AddCash") .OnComplete(() => { +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVRewardViewCurrent()) return; +#endif text_pound.text = LocalizationMgr.GetWeight(fishingData.fishWeight); } ); @@ -753,7 +869,10 @@ public class FishingRewardPanel : BasePanel curRate = etor.Current.Key; } while (etor.MoveNext()); - await Addressables.InstantiateAsync(curRate, icon_rate).Task; + var rateObject=await Addressables.InstantiateAsync(curRate, icon_rate).Task; +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVRewardViewCurrent() && rateObject!=null) Addressables.ReleaseInstance(rateObject); +#endif } bool _newRecord; @@ -1057,6 +1176,10 @@ public class FishingRewardPanel : BasePanel protected override void OnDestroy() { +#if UNITY_EDITOR && !SERVICE_HOST + game.PlayerDataStorage.PlayerKVGameplayLoop.CancelReward(this,"reward panel destroyed"); +#endif + //ConvertTools.SetFSBlur(false); EnableFishingBlur(false); GContext.Publish(new PlayNewItemFxEvent() { type = 2 }); @@ -1153,6 +1276,9 @@ public class FishingRewardPanel : BasePanel if (!string.IsNullOrEmpty(fishingData.AudioRewardFish)) { NewItemAudio = await Addressables.LoadAssetAsync(fishingData.AudioRewardFish).Task; +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVRewardViewCurrent()) { if(NewItemAudio!=null) Addressables.Release(NewItemAudio); NewItemAudio=null; return; } +#endif PlayNewItemAudio(); fishingData.AudioRewardFish = ""; } @@ -1214,6 +1340,9 @@ public class FishingRewardPanel : BasePanel await GContext.container.Resolve().PlayFishingSound(fishingData.curFishData.AudioRewardPanel); } await Awaiters.NextFrame; +#if UNITY_EDITOR && !SERVICE_HOST + if(!KVRewardViewCurrent()) return; +#endif if (this != null && !string.IsNullOrEmpty(fishingData.curFishData.AudioRewardPanelLoop)) { GContext.Publish(new EventBGMSound(fishingData.curFishData.AudioRewardPanelLoop)); @@ -1236,6 +1365,10 @@ public class FishingRewardPanel : BasePanel string fishName = "0"; private void OnGUI() { +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return; +#endif + int fontSize = 40; GUI.skin.textField.fontSize = fontSize; GUI.skin.button.fontSize = fontSize; diff --git a/Client/Assets/Scripts/UI/Home/HomeBtnBattlepass.cs b/Client/Assets/Scripts/UI/Home/HomeBtnBattlepass.cs index 67cbe9734..525e91c69 100644 --- a/Client/Assets/Scripts/UI/Home/HomeBtnBattlepass.cs +++ b/Client/Assets/Scripts/UI/Home/HomeBtnBattlepass.cs @@ -60,6 +60,7 @@ public class HomeBtnBattlepass : EventButtonResource } void RefreshBattlePassBtn() { + disposable?.Dispose(); var dataProvider = GContext.container.Resolve(); btn_battlePass.transform.Find("lock").gameObject.SetActive(!dataProvider.IsActivating); if (!dataProvider.IsActivating) diff --git a/Client/Assets/Scripts/UI/Home/HomePanel.cs b/Client/Assets/Scripts/UI/Home/HomePanel.cs index 76e0832a6..b18a8dba9 100644 --- a/Client/Assets/Scripts/UI/Home/HomePanel.cs +++ b/Client/Assets/Scripts/UI/Home/HomePanel.cs @@ -987,12 +987,15 @@ public partial class HomePanel : BasePanel { try { - var showFishBuffIds = GContext.container.Resolve().showFishBuffIds; + if (this == null) return; + var container = GContext.container; + var showFishBuffIds = container.Resolve().showFishBuffIds; if (showFishBuffIds.Count == 0) { //Buff 之后的拍脸 await Awaiters.Seconds(0.5f); - GContext.container.Resolve().ShowPostFaceUI(); + if (this == null || !ReferenceEquals(container, GContext.container)) return; + container.Resolve().ShowPostFaceUI(); } } catch (Exception ex) diff --git a/Client/Assets/Scripts/UI/PinataPack/EventPinataPackDataManager.cs b/Client/Assets/Scripts/UI/PinataPack/EventPinataPackDataManager.cs index 2d63d8f9d..19256aebf 100644 --- a/Client/Assets/Scripts/UI/PinataPack/EventPinataPackDataManager.cs +++ b/Client/Assets/Scripts/UI/PinataPack/EventPinataPackDataManager.cs @@ -10,6 +10,32 @@ namespace game { public class EventPinataPackDataManager:IOrderReplenishment,IOrderSuccess { + private readonly EventPinataPackData _kvTarget; + private EventPinataPackDataManager(EventPinataPackData target) { _kvTarget=target; } + internal static EventPinataPackDataManager CreateForKVLoad() + { + var target=GContext.container.Resolve(); + if(target==null) throw new System.InvalidOperationException("KV Pinata target is not registered."); + return new EventPinataPackDataManager(target); + } + private EventPinataPackData RequireKVTarget() + { + var target=GContext.container.Resolve(); + if(target==null || (_kvTarget!=null && !ReferenceEquals(target,_kvTarget))) + throw new System.InvalidOperationException("KV Pinata target changed."); + return target; + } + internal static PlayerDataStorage.PlayerKVPinataState DecodeForKVLoad(IReadOnlyDictionary raw, + PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => PlayerDataStorage.PlayerKVSaveSchema.DecodePinata(raw,errors); + internal void ResetForKVLoad() + { + var target=RequireKVTarget(); target.EventID=0; target.PinataMainID=0; target.PurchasedCount=0; + } + internal void ReplaceFromKVDecoded(PlayerDataStorage.PlayerKVPinataState state) + { + if(state==null) throw new System.ArgumentNullException(nameof(state)); + var target=RequireKVTarget(); target.EventID=state.EventID; target.PinataMainID=state.PinataMainID; target.PurchasedCount=state.PurchasedCount; + } #if UNITY_EDITOR private bool m_IsCheckCondition = true; #else @@ -173,4 +199,4 @@ namespace game data?.AddPurchasedCount(1); } } -} \ No newline at end of file +} diff --git a/Client/Assets/Scripts/UI/Rod/FishingRodPanel_Level.cs b/Client/Assets/Scripts/UI/Rod/FishingRodPanel_Level.cs index a33e8625e..72512cd7d 100644 --- a/Client/Assets/Scripts/UI/Rod/FishingRodPanel_Level.cs +++ b/Client/Assets/Scripts/UI/Rod/FishingRodPanel_Level.cs @@ -228,7 +228,12 @@ public partial class FishingRodPanel : BasePanel fx_rodpanel_upgrade_change.SetActive(false); } +#if UNITY_EDITOR && !SERVICE_HOST + bool kvEquipInFlight; + async void OnClickEquip() +#else void OnClickEquip() +#endif { if (data.config.ID == GContext.container.Resolve().equipRodID || data.isLock) { @@ -239,6 +244,17 @@ public partial class FishingRodPanel : BasePanel ToastPanel.Show(LocalizationMgr.GetText("UI_ToastPanel_26")); return; } +#if UNITY_EDITOR && !SERVICE_HOST + if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) + { + if(kvEquipInFlight) return; + kvEquipInFlight=true; + try { await GContext.container.Resolve().SetEquipRodIDForKVLoopAsync(data.config.ID); } + catch(System.Exception e) { Debug.LogException(e); return; } + finally { kvEquipInFlight=false; } + } + else +#endif GContext.container.Resolve().SetEquipRodID(data.config.ID); btn_equip_go.gameObject.SetActive(false); equipped.SetActive(true); diff --git a/Client/Assets/Scripts/UI/ShootingRange/ShootingRangeData.cs b/Client/Assets/Scripts/UI/ShootingRange/ShootingRangeData.cs index 8e9e825b0..52c1fffae 100644 --- a/Client/Assets/Scripts/UI/ShootingRange/ShootingRangeData.cs +++ b/Client/Assets/Scripts/UI/ShootingRange/ShootingRangeData.cs @@ -9,6 +9,41 @@ using GameCore; public class EventShootingRangeData : IChainPackData { private RawData _data = new RawData(); + + private bool _kvLoadPresent; + internal bool KVLoadPresent => _kvLoadPresent; + internal static game.PlayerDataStorage.PlayerKVShootingRangeState DecodeForKVLoad( + IReadOnlyDictionary raw, game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot, + game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + game.PlayerDataStorage.PlayerKVSaveSchema.DecodeShootingRange(raw, snapshot, errors); + + internal void ResetForKVLoad() + { + _kvLoadPresent = false; + _data = default(RawData); + } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVShootingRangeState state) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + if (!state.IsPresent) { ResetForKVLoad(); return; } + var replacement = new RawData + { + EventId = state.EventId, + CycleId = state.CycleId, + RedirectId = state.RedirectId, + AmmoCount = state.AmmoCount, + CurrentStageIdx = state.CurrentStageIdx, + ChainProgress = state.ChainProgress, + RewardFlag = state.RewardFlag, + TargetFlag = state.TargetFlag, + HasBeenOpened = state.HasBeenOpened, + Inflation = state.Inflation, + }; + _data = replacement; + _kvLoadPresent = true; + } + internal game.PlayerDataStorage.PlayerKVShootingRangeState SnapshotForKVValidation() => + new game.PlayerDataStorage.PlayerKVShootingRangeState(_kvLoadPresent, _data.EventId, _data.CycleId, _data.RedirectId, _data.AmmoCount, _data.CurrentStageIdx, _data.ChainProgress, _data.RewardFlag, _data.TargetFlag, _data.HasBeenOpened, _data.Inflation); private readonly Tables _tables = GContext.container.Resolve(); private readonly TbPack _pack = GContext.container.Resolve().TbPack; private readonly TbEventPackManager _eventPackManager = GContext.container.Resolve().TbEventPackManager; @@ -357,4 +392,4 @@ public class EventShootingRangeData : IChainPackData public bool HasBeenOpened; public float Inflation; } -} \ No newline at end of file +} diff --git a/Client/Assets/Scripts/UI/Shop/BlackFridayCouponData.cs b/Client/Assets/Scripts/UI/Shop/BlackFridayCouponData.cs index 22cfacd9d..a3806787d 100644 --- a/Client/Assets/Scripts/UI/Shop/BlackFridayCouponData.cs +++ b/Client/Assets/Scripts/UI/Shop/BlackFridayCouponData.cs @@ -41,6 +41,11 @@ public class BlackFridayCouponData ZZTimeHelper.UtcNow() <= ActivateTime + TimeSpan.FromSeconds(EventDuration) && !IsBought; public int Multiplier => GContext.container.Resolve().TbEventCoupon[RedirectId].Multiple; public int EventDuration => GContext.container.Resolve().TbEventCoupon[RedirectId].Time; + internal void ResetForKVLoad() + { ActivateTime=default; EventId=0; RedirectId=0; _isBought=false; DoesPop=true; } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVCouponState state) + { if(state==null) throw new ArgumentNullException(nameof(state)); ActivateTime=state.ActivateTime; EventId=state.EventId; RedirectId=state.RedirectId; _isBought=state.Bought; } + public void LoadData(string s) { int[] tokens = s.Split(',').Select(int.Parse).ToArray(); diff --git a/Client/Assets/Scripts/UI/Shop/ChainPackWithProgressData.cs b/Client/Assets/Scripts/UI/Shop/ChainPackWithProgressData.cs index 22b873b70..b88e79f86 100644 --- a/Client/Assets/Scripts/UI/Shop/ChainPackWithProgressData.cs +++ b/Client/Assets/Scripts/UI/Shop/ChainPackWithProgressData.cs @@ -11,6 +11,9 @@ namespace GameCore } public class ChainPackWithProgressMigrationData : ThanksGivingPackData { + internal new static game.PlayerDataStorage.PlayerKVProgressPackState DecodeForKVLoad(System.Collections.Generic.IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + game.PlayerDataStorage.PlayerKVSaveSchema.DecodeProgressPack(raw,true,errors); public new const string Key = "ThanksGivingPackMigrationData"; public override string RedPointKey => "thanks_giving"; protected override string key => Key; diff --git a/Client/Assets/Scripts/UI/Shop/ThanksGivingPackData.cs b/Client/Assets/Scripts/UI/Shop/ThanksGivingPackData.cs index ed5018a51..519f1044d 100644 --- a/Client/Assets/Scripts/UI/Shop/ThanksGivingPackData.cs +++ b/Client/Assets/Scripts/UI/Shop/ThanksGivingPackData.cs @@ -19,6 +19,19 @@ namespace GameCore public class ThanksGivingPackData : IProgressChainPackData { protected Data _data = new Data(0, 0, 0, 0); + + internal static game.PlayerDataStorage.PlayerKVProgressPackState DecodeForKVLoad(IReadOnlyDictionary raw, + game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => + game.PlayerDataStorage.PlayerKVSaveSchema.DecodeProgressPack(raw,false,errors); + internal void ResetForKVLoad() { _data = null; } + internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVProgressPackState state) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + _data = state.IsPresent ? new Data(state.EventId,state.RedirectId,state.TokenProgress,state.ChainProgress) : null; + } + internal game.PlayerDataStorage.PlayerKVProgressPackState SnapshotForKVValidation() => _data == null + ? new game.PlayerDataStorage.PlayerKVProgressPackState(false,0,0,0,0) + : new game.PlayerDataStorage.PlayerKVProgressPackState(true,_data.EventId,_data.RedirectId,_data.TokenProgress,_data.ChainProgress); /// /// Starts with 0, representing the first unclaimed reward. /// diff --git a/Client/Assets/Scripts/UI/Social/ClubApplyPopupPanel.cs b/Client/Assets/Scripts/UI/Social/ClubApplyPopupPanel.cs index 0eda4716e..d3a2131b7 100644 --- a/Client/Assets/Scripts/UI/Social/ClubApplyPopupPanel.cs +++ b/Client/Assets/Scripts/UI/Social/ClubApplyPopupPanel.cs @@ -97,7 +97,7 @@ class ApplyItem : MonoBehaviour //entityKey = data.entityKey; //head.SetData(data.avatarUrl); IUserService userService = GContext.container.Resolve(); - if (data.playFabId == userService.UserId) + if (data.playFabId == userService.ApiPlayerId) { text_name.text = userService.DisplayName; head.SetData(userService.AvatarUrl); diff --git a/Client/Assets/Scripts/UI/Social/ClubChooseHeirPopupPanel.cs b/Client/Assets/Scripts/UI/Social/ClubChooseHeirPopupPanel.cs index 5f241c2f4..ef6b17055 100644 --- a/Client/Assets/Scripts/UI/Social/ClubChooseHeirPopupPanel.cs +++ b/Client/Assets/Scripts/UI/Social/ClubChooseHeirPopupPanel.cs @@ -54,7 +54,7 @@ public class ClubChooseHeirPopupPanel : MonoBehaviour for (int i = 0; i < datas.Count; i++) { SocialInfoItemData clubPlayerInfoData = datas[i]; - if (clubPlayerInfoData.playFabId == userService.UserId) + if (clubPlayerInfoData.playFabId == userService.ApiPlayerId) { continue; } diff --git a/Client/Assets/Scripts/UI/Social/ClubInfoPopupPanel.cs b/Client/Assets/Scripts/UI/Social/ClubInfoPopupPanel.cs index f06af6fa2..c5e7c45dd 100644 --- a/Client/Assets/Scripts/UI/Social/ClubInfoPopupPanel.cs +++ b/Client/Assets/Scripts/UI/Social/ClubInfoPopupPanel.cs @@ -126,7 +126,7 @@ public class ClubInfoPopupPanel : MonoBehaviour for (int i = 0; i < data.Count; i++) { bool isMore = showMore; - if (data[i].playFabId != userService.UserId) + if (data[i].playFabId != userService.ApiPlayerId) { if (_socialData.memberRole == EMemberRole.Vice) { @@ -170,8 +170,8 @@ public class ClubInfoPopupPanel : MonoBehaviour more_tips.transform.position = memberItem.btn_more.transform.position; more_tips.SetActive(true); - btn_leave.transform.parent.gameObject.SetActive(curSocialInfoItemData.playFabId == userService.UserId); - bool iskickout = curSocialInfoItemData.playFabId != userService.UserId;/*_socialData.ClubItemData.id == _socialData.myClubInfo.id*/; + btn_leave.transform.parent.gameObject.SetActive(curSocialInfoItemData.playFabId == userService.ApiPlayerId); + bool iskickout = curSocialInfoItemData.playFabId != userService.ApiPlayerId;/*_socialData.ClubItemData.id == _socialData.myClubInfo.id*/; bool isPromote = false; bool isDemote = false; EMemberRole role = curSocialInfoItemData.role; diff --git a/Client/Assets/Scripts/UI/Social/SocialClubJoined.cs b/Client/Assets/Scripts/UI/Social/SocialClubJoined.cs index af5fb2b20..7e9c10ac9 100644 --- a/Client/Assets/Scripts/UI/Social/SocialClubJoined.cs +++ b/Client/Assets/Scripts/UI/Social/SocialClubJoined.cs @@ -171,9 +171,9 @@ public class SocialClubJoined : MonoBehaviour void SetSignCD() { var timer = ZZTimeHelper.UtcNow().UtcNowOffset(); - btn_sign.gameObject.SetActive(_socialData.signTime?.DayOfYear != timer.DayOfYear); - sign_cd.SetActive(_socialData.signTime?.DayOfYear == timer.DayOfYear); - if (_socialData.signTime?.DayOfYear == timer.DayOfYear) + btn_sign.gameObject.SetActive(_socialData.signTime?.Date != timer.Date); + sign_cd.SetActive(_socialData.signTime?.Date == timer.Date); + if (_socialData.signTime?.Date == timer.Date) { StartTimer(); } @@ -258,11 +258,21 @@ public class SocialClubJoined : MonoBehaviour { emojio_tips.SetActive(!emojio_tips.activeSelf); } - void OnSign() + async void OnSign() { - //签到 - _socialData.ClubSingin(); - SetSignCD(); + if (!btn_sign.interactable) return; + var version = _socialData.customServerMgr.SessionVersion; + btn_sign.interactable = false; + try { await _socialData.ClubSingin(); } + catch (Exception ex) { Debug.LogException(ex); } + finally + { + if (this != null && version == _socialData.customServerMgr.SessionVersion) + { + btn_sign.interactable = true; + SetSignCD(); + } + } } void ClickHelpEnergyGray() {