From edb8a056c742b677ad985d9b3548e940cd0f878d Mon Sep 17 00:00:00 2001 From: "Liubing\\LB" Date: Tue, 13 Oct 2026 20:38:41 +0800 Subject: [PATCH] =?UTF-8?q?refactor(kv):=20=E6=95=B4=E7=90=86=E5=AD=98?= =?UTF-8?q?=E6=A1=A3=E6=8E=A5=E7=BA=BF=E5=B9=B6=E6=B8=85=E7=90=86=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E5=86=97=E4=BD=99=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client/Assets/Editor/KVGameLoadValidation.cs | 31 - .../Editor/KVGameLoadValidation.cs.meta | 2 - Client/Assets/Editor/KVGameplayLoopSetup.cs | 74 - .../Assets/Editor/KVGameplayLoopSetup.cs.meta | 2 - .../Assets/Editor/KVPromptStateValidation.cs | 632 --- .../Editor/KVPromptStateValidation.cs.meta | 2 - Client/Assets/Editor/S101LoginWindow.cs | 74 - .../Activity/Data/EventDataRepository.cs | 4 +- .../Activity/Services/EventTokenService.cs | 4 +- .../Aquarium/DataCenter/AquariumManager.cs | 8 +- Client/Assets/Scripts/Camp/CampDataMM.cs | 2 +- Client/Assets/Scripts/Core/CustomServerMgr.cs | 89 +- .../GameServerAccountSession.cs} | 232 +- .../Core/GameServerAccountSession.cs.meta} | 2 +- .../{Services/Account.meta => Core/KV.meta} | 2 +- .../Scripts/Core/KV/IPlayerSaveStore.cs | 76 + .../KV}/IPlayerSaveStore.cs.meta | 0 Client/Assets/Scripts/Core/KV/KvCacheStore.cs | 132 + .../KV/KvCacheStore.cs.meta} | 0 .../PlayerKVClient.cs => Core/KV/KvClient.cs} | 131 +- .../KV/KvClient.cs.meta} | 0 .../Assets/Scripts/Core/KV/KvSessionOwner.cs | 461 ++ .../KV/KvSessionOwner.cs.meta} | 0 .../KV/KvSyncCoordinator.cs} | 424 +- .../KV/KvSyncCoordinator.cs.meta} | 0 .../KV/KvSyncModels.cs} | 346 +- .../KV/KvSyncModels.cs.meta} | 0 .../KV/KvSyncState.cs} | 407 +- .../KV/KvSyncState.cs.meta} | 0 Client/Assets/Scripts/Core/KVMgr.cs | 231 - Client/Assets/Scripts/Core/KvMgr.cs | 329 ++ .../Core/{KVMgr.cs.meta => KvMgr.cs.meta} | 0 Client/Assets/Scripts/Core/PlayFabMgr.cs | 13 +- .../Scripts/DataCenter/BargainPackData.cs | 2 +- .../Scripts/DataCenter/BattlePassData.cs | 2 +- .../Scripts/DataCenter/BuffDataCenter.cs | 2 +- .../DataCenter/DataCenter.FishingEventData.cs | 4 +- .../DataCenter/DataCenter.GuideData.cs | 2 +- .../DataCenter/DataCenter.LeadboardData.cs | 15 +- .../DataCenter/DataCenter.PlayerData.cs | 2 +- .../DataCenter/DataCenter.PlayerFishData.cs | 2 +- .../DataCenter/DataCenter.PlayerShopData.cs | 4 +- .../DataCenter/DataCenter.SignupData.cs | 2 +- .../DataCenter/DataCenter.SmallGame.cs | 2 +- .../DataCenter/DataCenter.TriggerPackData.cs | 2 +- .../Scripts/DataCenter/EventPackData.cs | 2 +- .../DataCenter/EventPartnerGatherManager.cs | 2 +- .../DataCenter/EventPinballDataManager.cs | 2 +- .../DataCenter/EventPotionDataManager.cs | 2 +- .../EventScratchTicketDataManager.cs | 2 +- .../DataCenter/FishingChallengeManager.cs | 2 +- .../DataCenter/OfferChainsChestManager.cs | 2 +- .../DataCenter/OfferDiscoPartyManager.cs | 2 +- .../Scripts/DataCenter/PiggyBankPackData.cs | 2 +- .../DataCenter/TotalRechargeManager.cs | 2 +- Client/Assets/Scripts/DebugFunc.cs | 10 +- .../Scripts/Duel/UI/EventFishingDuelPanel.cs | 14 +- .../Assets/Scripts/Event/AEventDataManager.cs | 2 +- .../Scripts/EventBingo/EventBingoData.cs | 4 +- .../Scripts/EventBreak/EventBreakData.cs | 2 +- .../Assets/Scripts/EventCan/EventCanData.cs | 2 +- .../Data/EventCubeMeltManager.cs | 2 +- .../EventGatherCore/EventGatherCoreData.cs | 4 +- .../EventLuckGame/EventLuckyGameModel.cs | 2 +- .../EventOfferJourneyData.cs | 4 +- .../EventOfferJourneyLvlTriggerData.cs | 4 +- .../EventPartnerGather/PartnerInfoManager.cs | 1 - .../EventStrikeLucky/EventLuckMagicData.cs | 2 +- .../EventWelcomeBackManager.cs | 2 +- .../GampPlay/FishingB/FishingBehaviorB.cs | 2 +- .../InfiniteBuilding/InfiniteBuildingData.cs | 2 +- .../LuckyTask/UI/LuckyADTaskPopupPanel.cs | 4 +- .../Scripts/LuckyTask/UI/LuckyTaskBtn.cs | 4 +- .../Scripts/LuckyTask/UI/LuckyTaskPanel.cs | 4 +- .../Scripts/MatchGame/MatchGameManager.cs | 2 +- .../MiniBP/MiniBattlePassDataProvider.cs | 2 +- .../Scripts/SandDig/model/DiggingGameModel.cs | 2 +- .../Account/S101AccountSession.cs.meta | 11 - ...ggKVUserService.cs => AggKvUserService.cs} | 6 +- ...rvice.cs.meta => AggKvUserService.cs.meta} | 0 .../Assets/Scripts/Services/AggUserService.cs | 2 +- Client/Assets/Scripts/Services/ClubService.cs | 48 +- .../Services/DeferredRewardStashService.cs | 2 +- .../Scripts/Services/DevKVUserService.cs | 6 - .../Scripts/Services/DevKvUserService.cs | 6 + ...rvice.cs.meta => DevKvUserService.cs.meta} | 0 .../Scripts/Services/FootPrintService.cs | 2 +- .../Assets/Scripts/Services/FriendService.cs | 4 +- .../Services/ILocalNotificationService.cs | 26 +- .../{KVUserService.cs => KvUserService.cs} | 345 +- ...rService.cs.meta => KvUserService.cs.meta} | 0 .../Services/PlayerData/IKVUserLifecycle.cs | 12 - .../PlayerData/IKVUserLifecycle.cs.meta | 11 - .../Services/PlayerData/IPlayerSaveStore.cs | 51 - .../Services/PlayerData/KvAccountLease.cs | 285 + ...Context.cs.meta => KvAccountLease.cs.meta} | 0 .../PlayerData/KvGameLoadCoordinator.cs | 1054 ++++ ....cs.meta => KvGameLoadCoordinator.cs.meta} | 0 .../Services/PlayerData/KvRuntimeSelection.cs | 67 + ...ion.cs.meta => KvRuntimeSelection.cs.meta} | 0 .../PlayerData/Module02OriginalLoadTests.cs | 609 -- .../Module02OriginalLoadTests.cs.meta | 11 - .../Services/PlayerData/PlayFabSaveStore.cs | 61 + ...tores.cs.meta => PlayFabSaveStore.cs.meta} | 0 .../Services/PlayerData/PlayerKVCacheStore.cs | 105 - .../PlayerKVControlledLoadCapability.cs | 162 - .../PlayerKVControlledLoadCapability.cs.meta | 2 - .../PlayerKVEditorValidationHost.cs | 4911 ----------------- .../PlayerKVEditorValidationHost.cs.meta | 2 - .../PlayerData/PlayerKVGameLoadCoordinator.cs | 456 -- .../PlayerData/PlayerKVGameLoadGuard.cs | 43 - .../PlayerData/PlayerKVGameLoadGuard.cs.meta | 2 - .../PlayerData/PlayerKVGameSessionBridge.cs | 322 -- .../PlayerKVGameSessionBridge.cs.meta | 2 - .../PlayerData/PlayerKVKeyReadResult.cs | 35 - .../PlayerData/PlayerKVKeyReadResult.cs.meta | 2 - .../PlayerData/PlayerKVLocalReadResult.cs | 30 - .../PlayerKVLocalReadResult.cs.meta | 2 - .../PlayerData/PlayerKVLoginContext.cs | 542 -- .../PlayerData/PlayerKVLoginPrepareResult.cs | 57 - .../PlayerKVLoginPrepareResult.cs.meta | 2 - .../Services/PlayerData/PlayerKVModels.cs | 39 - .../PlayerData/PlayerKVModels.cs.meta | 11 - .../PlayerData/PlayerKVRuntimeSelection.cs | 43 - .../PlayerData/PlayerKVSessionOwner.cs | 409 -- .../PlayerData/PlayerKVShutdownRegistry.cs | 92 - .../PlayerKVShutdownRegistry.cs.meta | 2 - .../Services/PlayerData/PlayerSaveStores.cs | 180 - .../Assets/Scripts/Services/SettingService.cs | 49 +- .../Assets/Scripts/Services/SurveyService.cs | 6 +- .../Scripts/Services/TileNewsManager.cs | 2 +- .../Services/UserService.KVFishingReward.cs | 273 - .../UserService.KVFishingReward.cs.meta | 2 - .../UserService.KVFishingRewardValidation.cs | 1226 ---- ...rService.KVFishingRewardValidation.cs.meta | 11 - .../UserService.KVFishingValidation.cs | 780 --- .../UserService.KVFishingValidation.cs.meta | 11 - .../Services/UserService.KVGameplayLoop.cs | 638 --- .../UserService.KVGameplayLoop.cs.meta | 2 - Client/Assets/Scripts/Services/UserService.cs | 6 +- Client/Assets/Scripts/Stages/FishingStage.cs | 12 +- Client/Assets/Scripts/Stages/LoginStage.cs | 8 +- Client/Assets/Scripts/Stages/PFLoginAct.cs | 4 +- Client/Assets/Scripts/Stages/QuitAct.cs | 9 +- Client/Assets/Scripts/SubBoostrap.cs | 39 +- .../Scripts/UI/AdWheel/AdWheelManager.cs | 2 +- .../Assets/Scripts/UI/Advert/AdvertPanel.cs | 4 +- Client/Assets/Scripts/UI/DeBugPanel.cs | 8 +- .../UI/JigsawPuzzle/JigsawPuzzleManager.cs | 2 +- .../OfferStoryChain/OfferStoryChainManager.cs | 2 +- .../UI/OfferSweep/OfferSweepManager.cs | 2 +- .../OfferTreasureAscentManager.cs | 2 +- .../UI/PartnerFishBowl/EventPartnerData.cs | 4 +- .../EventPartnerEntryButton.cs | 4 +- .../EventPartnerStatuePlayfabData.cs | 4 +- .../EventPartnerStatueTokenExchangeData.cs | 2 +- .../PinataPack/EventPinataPackDataManager.cs | 2 +- .../PinballUncountableManager.cs | 2 +- .../UI/Setting/BindAccountPopupPanel.cs | 33 +- .../UI/Setting/PlayerHeadPopupPanel.cs | 4 +- .../UI/ShootingRange/ShootingRangeData.cs | 2 +- .../Scripts/UI/Shop/BlackFridayCouponData.cs | 4 +- .../Scripts/UI/Shop/ThanksGivingPackData.cs | 4 +- .../UI/Social/Friend/FishingFriendAddPanel.cs | 4 +- .../UI/SocialRush/SocialRushArchitecture.cs | 2 +- .../UI/Turntable/FishingTurntablePanel.cs | 4 +- 166 files changed, 3828 insertions(+), 13267 deletions(-) delete mode 100644 Client/Assets/Editor/KVGameLoadValidation.cs delete mode 100644 Client/Assets/Editor/KVGameLoadValidation.cs.meta delete mode 100644 Client/Assets/Editor/KVGameplayLoopSetup.cs delete mode 100644 Client/Assets/Editor/KVGameplayLoopSetup.cs.meta delete mode 100644 Client/Assets/Editor/KVPromptStateValidation.cs delete mode 100644 Client/Assets/Editor/KVPromptStateValidation.cs.meta delete mode 100644 Client/Assets/Editor/S101LoginWindow.cs rename Client/Assets/Scripts/{Services/Account/S101AccountSession.cs => Core/GameServerAccountSession.cs} (50%) rename Client/Assets/{Editor/S101LoginWindow.cs.meta => Scripts/Core/GameServerAccountSession.cs.meta} (83%) rename Client/Assets/Scripts/{Services/Account.meta => Core/KV.meta} (77%) create mode 100644 Client/Assets/Scripts/Core/KV/IPlayerSaveStore.cs rename Client/Assets/Scripts/{Services/PlayerData => Core/KV}/IPlayerSaveStore.cs.meta (100%) create mode 100644 Client/Assets/Scripts/Core/KV/KvCacheStore.cs rename Client/Assets/Scripts/{Services/PlayerData/PlayerKVCacheStore.cs.meta => Core/KV/KvCacheStore.cs.meta} (100%) rename Client/Assets/Scripts/{Services/PlayerData/PlayerKVClient.cs => Core/KV/KvClient.cs} (59%) rename Client/Assets/Scripts/{Services/PlayerData/PlayerKVClient.cs.meta => Core/KV/KvClient.cs.meta} (100%) create mode 100644 Client/Assets/Scripts/Core/KV/KvSessionOwner.cs rename Client/Assets/Scripts/{Services/PlayerData/PlayerKVSessionOwner.cs.meta => Core/KV/KvSessionOwner.cs.meta} (100%) rename Client/Assets/Scripts/{Services/PlayerData/PlayerKVSyncCoordinator.cs => Core/KV/KvSyncCoordinator.cs} (52%) rename Client/Assets/Scripts/{Services/PlayerData/PlayerKVSyncCoordinator.cs.meta => Core/KV/KvSyncCoordinator.cs.meta} (100%) rename Client/Assets/Scripts/{Services/PlayerData/PlayerKVSyncModels.cs => Core/KV/KvSyncModels.cs} (69%) rename Client/Assets/Scripts/{Services/PlayerData/PlayerKVSyncModels.cs.meta => Core/KV/KvSyncModels.cs.meta} (100%) rename Client/Assets/Scripts/{Services/PlayerData/PlayerKVSyncState.cs => Core/KV/KvSyncState.cs} (60%) rename Client/Assets/Scripts/{Services/PlayerData/PlayerKVSyncState.cs.meta => Core/KV/KvSyncState.cs.meta} (100%) delete mode 100644 Client/Assets/Scripts/Core/KVMgr.cs create mode 100644 Client/Assets/Scripts/Core/KvMgr.cs rename Client/Assets/Scripts/Core/{KVMgr.cs.meta => KvMgr.cs.meta} (100%) delete mode 100644 Client/Assets/Scripts/Services/Account/S101AccountSession.cs.meta rename Client/Assets/Scripts/Services/{AggKVUserService.cs => AggKvUserService.cs} (97%) rename Client/Assets/Scripts/Services/{AggKVUserService.cs.meta => AggKvUserService.cs.meta} (100%) delete mode 100644 Client/Assets/Scripts/Services/DevKVUserService.cs create mode 100644 Client/Assets/Scripts/Services/DevKvUserService.cs rename Client/Assets/Scripts/Services/{DevKVUserService.cs.meta => DevKvUserService.cs.meta} (100%) rename Client/Assets/Scripts/Services/{KVUserService.cs => KvUserService.cs} (77%) rename Client/Assets/Scripts/Services/{KVUserService.cs.meta => KvUserService.cs.meta} (100%) delete mode 100644 Client/Assets/Scripts/Services/PlayerData/IKVUserLifecycle.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/IKVUserLifecycle.cs.meta delete mode 100644 Client/Assets/Scripts/Services/PlayerData/IPlayerSaveStore.cs create mode 100644 Client/Assets/Scripts/Services/PlayerData/KvAccountLease.cs rename Client/Assets/Scripts/Services/PlayerData/{PlayerKVLoginContext.cs.meta => KvAccountLease.cs.meta} (100%) create mode 100644 Client/Assets/Scripts/Services/PlayerData/KvGameLoadCoordinator.cs rename Client/Assets/Scripts/Services/PlayerData/{PlayerKVGameLoadCoordinator.cs.meta => KvGameLoadCoordinator.cs.meta} (100%) create mode 100644 Client/Assets/Scripts/Services/PlayerData/KvRuntimeSelection.cs rename Client/Assets/Scripts/Services/PlayerData/{PlayerKVRuntimeSelection.cs.meta => KvRuntimeSelection.cs.meta} (100%) delete mode 100644 Client/Assets/Scripts/Services/PlayerData/Module02OriginalLoadTests.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/Module02OriginalLoadTests.cs.meta create mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayFabSaveStore.cs rename Client/Assets/Scripts/Services/PlayerData/{PlayerSaveStores.cs.meta => PlayFabSaveStore.cs.meta} (100%) delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVControlledLoadCapability.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVControlledLoadCapability.cs.meta delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs.meta delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadGuard.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadGuard.cs.meta delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVGameSessionBridge.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVGameSessionBridge.cs.meta delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVKeyReadResult.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVKeyReadResult.cs.meta delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVLocalReadResult.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVLocalReadResult.cs.meta delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginPrepareResult.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginPrepareResult.cs.meta delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs.meta delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVRuntimeSelection.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVSessionOwner.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVShutdownRegistry.cs delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerKVShutdownRegistry.cs.meta delete mode 100644 Client/Assets/Scripts/Services/PlayerData/PlayerSaveStores.cs delete mode 100644 Client/Assets/Scripts/Services/UserService.KVFishingReward.cs delete mode 100644 Client/Assets/Scripts/Services/UserService.KVFishingReward.cs.meta delete mode 100644 Client/Assets/Scripts/Services/UserService.KVFishingRewardValidation.cs delete mode 100644 Client/Assets/Scripts/Services/UserService.KVFishingRewardValidation.cs.meta delete mode 100644 Client/Assets/Scripts/Services/UserService.KVFishingValidation.cs delete mode 100644 Client/Assets/Scripts/Services/UserService.KVFishingValidation.cs.meta delete mode 100644 Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs delete mode 100644 Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs.meta diff --git a/Client/Assets/Editor/KVGameLoadValidation.cs b/Client/Assets/Editor/KVGameLoadValidation.cs deleted file mode 100644 index a35d82dc1..000000000 --- a/Client/Assets/Editor/KVGameLoadValidation.cs +++ /dev/null @@ -1,31 +0,0 @@ -#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" && caseId != "M01-Immediate" && caseId != "Module02-OriginalLoad" && caseId != "M03B-LoginRetry" && caseId != "M03B-LoginRetry-Cleanup" && caseId != "M03C-CustomId" && caseId != "M03D-Destroy") - { - if (caseId != "P6A-InitializationLastLogin" && caseId != "P6B-OrdinarySave" && caseId != "P6B-OrdinarySave-Cleanup" && caseId != "P8A-Leaderboard" && caseId != "P8B-Friends" && caseId != "P8C-TitleNews" && caseId != "P8C-TitleNews-Cleanup" && caseId != "P8D-Matchmaking" && caseId != "P12A-OrdinaryCadence" && caseId != "P12B-FormalReadAdapters" && caseId != "P12C-StartupSubscriptions" && caseId != "P14-FormalRealtime" && caseId != "P15-FormalMatchmaking" && caseId != "P15-FormalMatchState") - { - 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 deleted file mode 100644 index 0281eef21..000000000 --- a/Client/Assets/Editor/KVGameLoadValidation.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4ce05f2f22c2492f8a3c51a82a208647 diff --git a/Client/Assets/Editor/KVGameplayLoopSetup.cs b/Client/Assets/Editor/KVGameplayLoopSetup.cs deleted file mode 100644 index 42f354aa8..000000000 --- a/Client/Assets/Editor/KVGameplayLoopSetup.cs +++ /dev/null @@ -1,74 +0,0 @@ -#if UNITY_EDITOR -using System; -using System.IO; -using System.Linq; -using System.Reflection; -using Newtonsoft.Json; -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) - { - string methodName; - switch (caseId) - { - case "equipment-chain": - methodName = "VerifyEquipmentForEditorAsync"; - break; - case "fishing-consume": - methodName = "VerifyFishingForEditorAsync"; - break; - case "fishing-reward": - methodName = "VerifyFishingRewardForEditorAsync"; - break; - case "fishing-original-reward": - methodName = "VerifyOriginalFishingRewardForEditorAsync"; - break; - case "fishing-original-consume": - methodName = "VerifyOriginalFishingForEditorAsync"; - break; - default: - 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(methodName, 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 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; } - } - - -} -#endif diff --git a/Client/Assets/Editor/KVGameplayLoopSetup.cs.meta b/Client/Assets/Editor/KVGameplayLoopSetup.cs.meta deleted file mode 100644 index 2ef7e0971..000000000 --- a/Client/Assets/Editor/KVGameplayLoopSetup.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ff2728a480374b609aaed9ea0742215e diff --git a/Client/Assets/Editor/KVPromptStateValidation.cs b/Client/Assets/Editor/KVPromptStateValidation.cs deleted file mode 100644 index cc9b3b661..000000000 --- a/Client/Assets/Editor/KVPromptStateValidation.cs +++ /dev/null @@ -1,632 +0,0 @@ -#if UNITY_EDITOR -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Reflection; -using System.Runtime.ExceptionServices; -using System.Threading; -using System.Threading.Tasks; -using game; -using game.Account; -using game.PlayerDataStorage; -using Newtonsoft.Json; -using UnityEditor; -using UnityEngine; - -// Fixed B01 synthetic cases only. No global container replacement, permission UI or Play mode. -public static class KVPromptStateValidation -{ - private static bool running; - private static object failedCleanup; - private static readonly BindingFlags PrivateInstance = BindingFlags.Instance | BindingFlags.NonPublic; - - public static async Task RunAsync(string caseId) - { - if ((caseId != "B01" && caseId != "CP3A" && caseId != "P6D") || EditorApplication.isPlayingOrWillChangePlaymode || running || failedCleanup != null) - { - throw new InvalidOperationException("B01 requires an idle Editor and completed prior cleanup."); - } - running = true; - var run = new FixedRun(); - var staticField = typeof(RedPointManager).GetField("instance", BindingFlags.Static | BindingFlags.NonPublic); - object previous = null; - var isolated = false; - var errors = new List(); - string result = null; - try - { - previous = staticField.GetValue(null); - staticField.SetValue(null, new RedPointManager()); - isolated = true; - result = caseId == "P6D" ? await run.ExecuteP6D() : caseId == "B01" ? await run.Execute() : await run.ExecuteCP3A(); - } - catch (Exception ex) - { - errors.Add(ex); - } - finally - { - try - { - await run.Close(); - } - catch (Exception ex) - { - failedCleanup = run; - errors.Add(ex); - } - try - { - if (isolated) - { - staticField.SetValue(null, previous); - if (!ReferenceEquals(previous, staticField.GetValue(null))) - { - throw new InvalidOperationException("Red point instance was not restored."); - } - } - } - catch (Exception ex) - { - failedCleanup = run; - errors.Add(ex); - } - finally - { - running = false; - } - } - if (errors.Count != 0) - { - throw new AggregateException(errors); - } - var report = Newtonsoft.Json.Linq.JObject.Parse(result); - report["restored"] = true; - report["remainingTestObjects"] = 0; - return report.ToString(Formatting.None); - } - - sealed class Handler : HttpMessageHandler - { - readonly Func send; - internal Handler(Func send) { this.send = send; } - protected override Task SendAsync(HttpRequestMessage r, CancellationToken t) => Task.FromResult(send(r)); - } - static HttpResponseMessage Json(object value) => new HttpResponseMessage(HttpStatusCode.OK) - { Content = new StringContent(JsonConvert.SerializeObject(value)) }; - - sealed class FixedRun - { - private readonly List children = new List(); - private readonly List restoreFaults = new List(); - readonly Uri api = new Uri("http://b01-editor.invalid/"); - readonly string cache = Path.GetFullPath(Path.Combine(Application.dataPath, - "../../CodexOutput/upserver/B3BatchMigration/B01Tests/unity-runs", Guid.NewGuid().ToString("N"))); - readonly List sessions = new List(); - readonly List objects = new List(); - PlayerKVGameSessionBridge bridge; - long epoch = 1; - int assertions, requests; - void Check(bool value, string label) { assertions++; if (!value) throw new InvalidOperationException("B01: " + label); } - static object Call(object target, string method, params object[] args) - { - try { return target.GetType().GetMethod(method, PrivateInstance | BindingFlags.Public).Invoke(target, args); } - catch (TargetInvocationException ex) { ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); throw; } - } - static void Field(object target, string name, object value) => target.GetType().GetField(name, PrivateInstance).SetValue(target, value); - static T Bound(PlayerKVGameSessionHandle handle) => (T)typeof(T).GetConstructor(PrivateInstance, - null, new[] { typeof(PlayerKVGameSessionHandle) }, null).Invoke(new object[] { handle }); - void Reject(Action action) - { - try { action(); } - catch (InvalidOperationException) { assertions++; return; } - throw new InvalidOperationException("B01: expected owner rejection"); - } - async Task Open() - { - var session = new S101AccountSession(api, jar => new Handler(r => - { - Check(r.RequestUri == new Uri(api, "account/Login"), "synthetic login only"); - jar.Add(api, new Cookie("sid", "101")); - return Json(new { state = 0, internalId = 101, newCreate = false, - createTime = "2026-01-01T00:00:00Z", svrTime = "2026-01-01T00:00:00Z" }); - })); - sessions.Add(session); - Check((await session.LoginAsync("101")).Success, "synthetic login"); - var opened = await bridge.OpenAsync(session, cache, "101", api, epoch, e => e == epoch); - Check(opened.Success, "synthetic owner open"); - return opened.Handle; - } - T Panel() where T : Component - { - var go = new GameObject("B01-" + typeof(T).Name) { hideFlags = HideFlags.HideAndDontSave }; - go.SetActive(false); objects.Add(go); - return go.AddComponent(); - } - internal async Task Execute() - { - Func factory = jar => new Handler(r => - { - Check(r.RequestUri == new Uri(api, "playerdata/Get"), "no write/no real transport"); - requests++; - return Json(new { data = new Dictionary() }); - }); - bridge = (PlayerKVGameSessionBridge)typeof(PlayerKVGameSessionBridge).GetConstructor(PrivateInstance, - null, new[] { typeof(Func), typeof(bool) }, null).Invoke(new object[] { factory, false }); - var handle = await Open(); - var settings = Bound(handle); - var club = Bound(handle); - Check(!settings.ShowNotice, "valid missing key"); - settings.ShowNotice = true; - Check(settings.ShowNotice && requests == 1, "accepted local only"); - var original = settings.GetType().GetProperty("LastPromptEdit", PrivateInstance).GetValue(settings); - settings.ShowNotice = true; - Check(ReferenceEquals(original, settings.GetType().GetProperty("LastPromptEdit", PrivateInstance).GetValue(settings)), "same value no duplicate"); - // A fresh real ClubService retains its original initialized flag. - Check(club.IsOpenClubRed, "fresh real club retains original flag"); - await bridge.CloseAsync(); - Reject(() => settings.ShowNotice = false); - Reject(club.SetOpenClubRed); - Check(club.IsOpenClubRed, "stale real club preserves flag"); - - // Notification constructor validates the current owner. Create a fresh fixed instance, then revoke it. - epoch++; - var nextHandle = await Open(); - var nextSettings = Bound(nextHandle); - var tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(File.ReadAllText( - Path.Combine(Application.dataPath, "../../cfgData", name + ".json")))); - var notifications = (LocalNotificationService)typeof(LocalNotificationService).GetConstructor(PrivateInstance, - null, new[] { typeof(DefaultSettingService), typeof(cfg.Tables) }, null).Invoke(new object[] { nextSettings, tables }); - var pendingInit = notifications.Init(); - await bridge.CloseAsync(); - try { await pendingInit; throw new InvalidOperationException("B01: stale init succeeded"); } - catch (InvalidOperationException ex) { Check(!ex.Message.Contains("stale init succeeded"), "stale Editor permission continuation rejected"); } - - var grade = Panel(); - Field(grade, "settings", nextSettings); Field(grade, "notifications", notifications); - var popup = Panel(); Field(popup, "notifications", notifications); - var settingPanel = Panel(); Field(settingPanel, "settingService", nextSettings); Field(settingPanel, "notificationService", notifications); - var gift = Panel(); Field(gift, "notificationService", notifications); - var social = Panel(); Field(social, "_socialData", club); - var errors = new List(); - void Capture(string message, string stack, LogType type) { if (type == LogType.Error || type == LogType.Exception) errors.Add(message); } - Application.logMessageReceived += Capture; - try - { - Reject(() => Call(grade, "DestroyUI")); - Reject(() => Call(grade, "OnBtnOk")); - Reject(() => Call(popup, "OnBtnOk")); - Reject(() => Call(popup, "DestroyUI")); - Reject(() => Call(settingPanel, "OnToggleNotification", true)); - Reject(() => Call(settingPanel, "OnToggleNotification", false)); - Reject(() => Call(gift, "OnClickYES")); - Call(social, "Start"); - Check(errors.Count == 1, "only unchanged social entry logs its owner rejection"); - Check(errors.TrueForAll(e => e.Contains("ShowNotice owner=") || e.Contains("IsOpenClubRed owner=")), "no unexpected exception"); - foreach (var go in objects) Check(go != null, "stale callback did not destroy another UI"); - } - finally { Application.logMessageReceived -= Capture; } - Check(requests == 2, "read-only synthetic transport"); - return JsonConvert.SerializeObject(new { success = true, assertions, requests, formalRequests = 0, - playing = EditorApplication.isPlaying, globalContainerChanged = false, caseId = "B01", cache }); - } - internal async Task ExecuteP6D() - { - foreach (var value in new[] { null, "0", "1", "" }) - { - var fixture = new FixedRun(); - children.Add(fixture); - await fixture.HealthyPrompt(value); - } - foreach (var club in new[] { false, true }) - { - foreach (var stage in new[] { "BeforeWrite", "AfterReplace" }) - { - var fixture = new FixedRun(); - children.Add(fixture); - await fixture.FaultPrompt(club, stage); - } - var revoked = new FixedRun(); - children.Add(revoked); - await revoked.RevokedDuringPrompt(club); - } - var callback = new FixedRun(); - children.Add(callback); - await callback.RevokedDuringClubCallback(); - foreach (var fixture in children) - { - assertions += fixture.assertions; - requests += fixture.requests; - } - return JsonConvert.SerializeObject(new { success = true, caseId = "P6D", assertions, - cases = children.Count, requests, formalRequests = 0, playing = EditorApplication.isPlaying, - globalContainerChanged = false }); - } - - private async Task OpenPrompt() - { - Func factory = jar => new Handler(r => - { - Check(r.RequestUri == new Uri(api, "playerdata/Get"), "P6D synthetic Get only"); - requests++; - return Json(new { data = new Dictionary() }); - }); - bridge = (PlayerKVGameSessionBridge)typeof(PlayerKVGameSessionBridge).GetConstructor(PrivateInstance, - null, new[] { typeof(Func), typeof(bool) }, null).Invoke(new object[] { factory, false }); - return await Open(); - } - - private static PlayerKVEditResult Edit(object service) - { - return (PlayerKVEditResult)service.GetType().GetProperty("LastPromptEdit", PrivateInstance).GetValue(service); - } - - private static bool? Cached(DefaultSettingService settings) - { - return (bool?)typeof(DefaultSettingService).GetField("_showNotice", PrivateInstance).GetValue(settings); - } - - private sealed class RedProbe : IUIRedPoint - { - private readonly Action callback; - internal RedProbe(Action callback) - { - this.callback = callback; - } - public void SetRedPointState(bool state) - { - callback(state); - } - } - - private void ObserveRed(Action callback) - { - RedPointManager.Instance.RemoveRedPoint(RedPointName.Home_Club); - RedPointManager.Instance.AddRedPoint(RedPointName.Home_Club, new RedProbe(callback)); - } - - private static Delegate FaultDelegate(Action callback) - { - return new Action(stage => callback(stage.ToString())); - } - - private void InjectFault(PlayerKVGameSessionHandle handle, Action callback) - { - var owner = handle.GetType().GetProperty("Owner", PrivateInstance).GetValue(handle); - var state = owner.GetType().GetField("state", PrivateInstance).GetValue(owner); - var store = state.GetType().GetField("store", PrivateInstance).GetValue(state); - var field = store.GetType().GetField("fault", PrivateInstance); - var previous = field.GetValue(store); - var method = typeof(FixedRun).GetMethod("FaultDelegate", BindingFlags.Static | BindingFlags.NonPublic) - .MakeGenericMethod(field.FieldType.GetGenericArguments()[0]); - var fault = method.Invoke(null, new object[] { callback }); - restoreFaults.Add(() => field.SetValue(store, previous)); - field.SetValue(store, fault); - Check(ReferenceEquals(field.GetValue(store), fault), "actual cache fault installed"); - } - - private async Task HealthyPrompt(string initial) - { - var handle = await OpenPrompt(); - if (initial != null) - { - Check(handle.Update("ShowNotice", initial).Accepted, "seed initial value"); - } - var settings = Bound(handle); - var expected = initial == "1"; - Check(settings.ShowNotice == expected, "first read follows original string comparison"); - Check(handle.Update("ShowNotice", expected ? "0" : "1").Accepted, "other consumer updates actual cache"); - Check(settings.ShowNotice == expected, "first-read nullable cache survives other consumer writes"); - var fresh = Bound(handle); - var target = !expected; - var observedWrites = 0; - InjectFault(handle, stage => - { - if (stage == "BeforeWrite") - { - observedWrites++; - Check(Cached(fresh) == target, "nullable cache assigned before actual disk write"); - } - }); - fresh.ShowNotice = target; - Check(Edit(fresh)?.Accepted == true && observedWrites == 1, "setter before getter writes even when stored value matches"); - var receipt = Edit(fresh); - fresh.ShowNotice = target; - Check(ReferenceEquals(receipt, Edit(fresh)) && observedWrites == 1, "same cached value does not write"); - target = !target; - fresh.ShowNotice = target; - Check(observedWrites == 2 && !ReferenceEquals(receipt, Edit(fresh)), "changed cached value writes"); - RestoreFaults(); - - Check(handle.Update("IsOpenClubRed", "0").Accepted, "seed present club value"); - var club = Bound(handle); - var calls = 0; - ObserveRed(red => - { - calls++; - Check(!red && !club.IsOpenClubRed, "flag clears before red callback"); - Check(handle.ReadLocalSnapshot().Data["IsOpenClubRed"] == (calls == 1 ? "0" : "1"), "red callback precedes write"); - }); - club.SetOpenClubRed(); - Check(calls == 1 && Edit(club)?.Accepted == true && handle.ReadLocalSnapshot().Data["IsOpenClubRed"] == "1", "present key does not replace original flag gate"); - var clubReceipt = Edit(club); - club.SetOpenClubRed(); - Check(calls == 1 && ReferenceEquals(clubReceipt, Edit(club)), "false flag skips red and write"); - club.IsOpenClubRed = true; - club.SetOpenClubRed(); - Check(calls == 2 && !ReferenceEquals(clubReceipt, Edit(club)), "true flag writes existing value"); - - // A diagnostic result is not a second owner state. Do not unlock a real failed owner. - var rejected = handle.Update(null, "1"); - Check(!rejected.Accepted, "actual rejected diagnostic"); - fresh.GetType().GetProperty("LastPromptEdit", PrivateInstance).SetValue(fresh, rejected); - club.GetType().GetProperty("LastPromptEdit", PrivateInstance).SetValue(club, rejected); - Check(fresh.ShowNotice == target, "healthy owner ignores old rejected diagnostic"); - club.SetOpenClubRed(); - Check(calls == 2, "healthy false club ignores diagnostic without business effects"); - await bridge.CloseAsync(); - Reject(() => - { - _ = fresh.ShowNotice; - }); - Reject(() => fresh.ShowNotice = target); - Reject(club.SetOpenClubRed); - Check(calls == 2 && Cached(fresh) == target && !club.IsOpenClubRed, "stale cache-hit entries cause no effects"); - } - - private async Task FaultPrompt(bool useClub, string failureStage) - { - var handle = await OpenPrompt(); - var settings = Bound(handle); - var club = Bound(handle); - var redCalls = 0; - ObserveRed(red => - { - redCalls++; - Check(!red && !club.IsOpenClubRed, "failed write still follows original red order"); - }); - var faults = 0; - InjectFault(handle, stage => - { - if (stage == failureStage) - { - faults++; - Check(useClub ? !club.IsOpenClubRed && redCalls == 1 : Cached(settings) == true, - "business state precedes actual storage failure"); - throw new IOException("P6D synthetic " + failureStage); - } - }); - Reject(() => - { - if (useClub) - { - club.SetOpenClubRed(); - } - else - { - settings.ShowNotice = true; - } - }); - var edit = Edit(useClub ? (object)club : settings); - Check(faults == 1 && edit != null && !edit.Accepted, "actual rejected write preserved"); - Check(edit.StateResult.Error == PlayerKVChangeError.StorageError && - edit.StateResult.LocalCommitMayHaveSucceeded == (failureStage == "AfterReplace"), "actual uncertainty preserved"); - Check(handle.Status == PlayerKVSyncStatus.StorageError, "actual storage terminal state retained"); - Reject(() => - { - _ = settings.ShowNotice; - }); - Reject(() => settings.ShowNotice = true); - Reject(club.SetOpenClubRed); - Check(faults == 1 && redCalls == (useClub ? 1 : 0), "actual unreadable owner blocks repeat effects"); - Check(useClub ? !club.IsOpenClubRed : Cached(settings) == true, "no added rollback"); - } - - private async Task RevokedDuringPrompt(bool useClub) - { - var handle = await OpenPrompt(); - var settings = Bound(handle); - var club = Bound(handle); - var redCalls = 0; - ObserveRed(red => - { - redCalls++; - }); - InjectFault(handle, stage => - { - if (stage == "AfterReplace") - { - epoch++; - } - }); - Reject(() => - { - if (useClub) - { - club.SetOpenClubRed(); - } - else - { - settings.ShowNotice = true; - } - }); - Check(Edit(useClub ? (object)club : settings)?.Accepted == true, "completed disk write not disguised as unaccepted"); - Check(useClub ? !club.IsOpenClubRed && redCalls == 1 : Cached(settings) == true, "revocation does not roll back original front effects"); - Check(!handle.ReadLocalSnapshot().Success && requests == 1, "revoked old owner has no replay or transport write"); - } - - private async Task RevokedDuringClubCallback() - { - var handle = await OpenPrompt(); - var club = Bound(handle); - var redCalls = 0; - ObserveRed(red => - { - redCalls++; - epoch++; - }); - var writes = 0; - InjectFault(handle, stage => - { - writes++; - }); - Reject(club.SetOpenClubRed); - Check(!club.IsOpenClubRed && redCalls == 1, "callback revocation preserves front effects"); - Check(Edit(club)?.Accepted == false && writes == 0 && requests == 1, "callback revocation refuses old write without replay"); - } - internal async Task ExecuteCP3A() - { - Func factory = jar => new Handler(r => - { - Check(r.RequestUri == new Uri(api, "playerdata/Get"), "CP3A synthetic Get only"); - requests++; - return Json(new { data = new Dictionary() }); - }); - bridge = (PlayerKVGameSessionBridge)typeof(PlayerKVGameSessionBridge).GetConstructor(PrivateInstance, - null, new[] { typeof(Func), typeof(bool) }, null).Invoke(new object[] { factory, false }); - var tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(File.ReadAllText( - Path.Combine(Application.dataPath, "../../cfgData", name + ".json")))); - var settingCtor = typeof(DefaultSettingService).GetConstructor(PrivateInstance, - null, new[] { typeof(bool) }, null); - var clubCtor = typeof(ClubService).GetConstructor(PrivateInstance, - null, new[] { typeof(bool) }, null); - var notificationCtor = typeof(LocalNotificationService).GetConstructor(PrivateInstance, - null, new[] { typeof(cfg.Tables) }, null); - Check(settingCtor != null && clubCtor != null && notificationCtor != null, - "formal service constructors exist"); - var settings = (DefaultSettingService)settingCtor.Invoke(new object[] { true }); - var club = (ClubService)clubCtor.Invoke(new object[] { true }); - var notifications = (LocalNotificationService)notificationCtor.Invoke(new object[] { tables }); - var pendingNotifications = (LocalNotificationService)notificationCtor.Invoke(new object[] { tables }); - var pendingInit = pendingNotifications.Init(); - Reject(() => Call(pendingNotifications, "PreflightFormalPromptSettings", settings)); - await pendingInit; - await notifications.Init(); - Reject(() => { _ = settings.ShowNotice; }); - Reject(() => settings.ShowNotice = true); - Reject(club.SetOpenClubRed); - var handle = await Open(); - Call(settings, "BindFormalPromptOwner", handle); - Call(club, "BindFormalPromptOwner", handle); - Call(notifications, "BindFormalPromptSettings", settings); - Call(settings, "BindFormalPromptOwner", handle); - Call(club, "BindFormalPromptOwner", handle); - Call(notifications, "BindFormalPromptSettings", settings); - Check(!settings.ShowNotice, "bound missing ShowNotice"); - settings.ShowNotice = true; - club.SetOpenClubRed(); - Check(settings.ShowNotice && !club.IsOpenClubRed, "bound prompt effects"); - Check(requests == 1, "one synthetic KV Get and no transport write"); - await bridge.CloseAsync(); - Reject(() => { _ = settings.ShowNotice; }); - Reject(club.SetOpenClubRed); - epoch++; - var nextHandle = await Open(); - Reject(() => Call(settings, "BindFormalPromptOwner", nextHandle)); - Reject(() => Call(club, "BindFormalPromptOwner", nextHandle)); - var partialSetting = (DefaultSettingService)settingCtor.Invoke(new object[] { true }); - var partialClub = (ClubService)clubCtor.Invoke(new object[] { true }); - Call(partialSetting, "BindFormalPromptOwner", nextHandle); - await bridge.CloseAsync(); - Reject(() => Call(partialClub, "BindFormalPromptOwner", nextHandle)); - Reject(() => { _ = partialSetting.ShowNotice; }); - epoch++; - var thirdHandle = await Open(); - Reject(() => Call(partialSetting, "BindFormalPromptOwner", thirdHandle)); - Check(requests == 3, "same owner binds and partial failure add no transport requests"); - return JsonConvert.SerializeObject(new { success = true, caseId = "CP3A", assertions, - requests, formalRequests = 0, globalContainerChanged = false }); - } - internal async Task Close() - { - var errors = new List(); - foreach (var child in children) - { - try - { - await child.Close(); - } - catch (Exception ex) - { - errors.Add(ex); - } - } - try - { - RestoreFaults(); - } - catch (Exception ex) - { - errors.Add(ex); - } - try - { - if (bridge != null) - { - await bridge.CloseAsync(); - } - } - catch (Exception ex) - { - errors.Add(ex); - } - foreach (var session in sessions) - { - try - { - session.Dispose(); - } - catch (Exception ex) - { - errors.Add(ex); - } - } - foreach (var go in objects) - { - try - { - if (go != null) - { - UnityEngine.Object.DestroyImmediate(go); - } - if (go != null) - { - throw new InvalidOperationException("Test object survived cleanup."); - } - } - catch (Exception ex) - { - errors.Add(ex); - } - } - if (errors.Count != 0) - { - throw new AggregateException(errors); - } - } - - private void RestoreFaults() - { - var errors = new List(); - foreach (var restore in restoreFaults) - { - try - { - restore(); - } - catch (Exception ex) - { - errors.Add(ex); - } - } - if (errors.Count != 0) - { - throw new AggregateException(errors); - } - restoreFaults.Clear(); - } - } -} -#endif diff --git a/Client/Assets/Editor/KVPromptStateValidation.cs.meta b/Client/Assets/Editor/KVPromptStateValidation.cs.meta deleted file mode 100644 index 162c65d87..000000000 --- a/Client/Assets/Editor/KVPromptStateValidation.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 67d5bde18c99423aa2168a5af1c2b0da diff --git a/Client/Assets/Editor/S101LoginWindow.cs b/Client/Assets/Editor/S101LoginWindow.cs deleted file mode 100644 index b158c1aed..000000000 --- a/Client/Assets/Editor/S101LoginWindow.cs +++ /dev/null @@ -1,74 +0,0 @@ -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/Scripts/Activity/Data/EventDataRepository.cs b/Client/Assets/Scripts/Activity/Data/EventDataRepository.cs index 8b55a0e42..0d469c3b6 100644 --- a/Client/Assets/Scripts/Activity/Data/EventDataRepository.cs +++ b/Client/Assets/Scripts/Activity/Data/EventDataRepository.cs @@ -9,8 +9,8 @@ namespace Activity.Data /// public class EventDataRepository : IEventDataRepository { - private readonly game.PlayerDataStorage.IPlayerSaveStore store; - public EventDataRepository(game.PlayerDataStorage.IPlayerSaveStore store) + private readonly Core.KV.IPlayerSaveStore store; + public EventDataRepository(Core.KV.IPlayerSaveStore store) { this.store = store ?? throw new ArgumentNullException(nameof(store)); } public void Save(T data) where T : IEventData diff --git a/Client/Assets/Scripts/Activity/Services/EventTokenService.cs b/Client/Assets/Scripts/Activity/Services/EventTokenService.cs index 04ab69caa..0a11ebe62 100644 --- a/Client/Assets/Scripts/Activity/Services/EventTokenService.cs +++ b/Client/Assets/Scripts/Activity/Services/EventTokenService.cs @@ -28,8 +28,8 @@ namespace Activity.Services /// public class EventTokenService : IEventTokenService { - private readonly game.PlayerDataStorage.IPlayerSaveStore store; - public EventTokenService(game.PlayerDataStorage.IPlayerSaveStore store) + private readonly Core.KV.IPlayerSaveStore store; + public EventTokenService(Core.KV.IPlayerSaveStore store) { this.store = store ?? throw new ArgumentNullException(nameof(store)); } #region 常量与字段 diff --git a/Client/Assets/Scripts/Aquarium/DataCenter/AquariumManager.cs b/Client/Assets/Scripts/Aquarium/DataCenter/AquariumManager.cs index b12921a86..083be4c21 100644 --- a/Client/Assets/Scripts/Aquarium/DataCenter/AquariumManager.cs +++ b/Client/Assets/Scripts/Aquarium/DataCenter/AquariumManager.cs @@ -12,7 +12,7 @@ using UnityEngine; public class AquariumManager { public const string AquariumEventDataNewKey = "AquariumEventDataNewKey"; - readonly game.PlayerDataStorage.IPlayerSaveStore saveStore; + readonly Core.KV.IPlayerSaveStore saveStore; readonly IUserService loginUser; private cfg.Tables _tables; private ICustomServerMgr customServerMgr; @@ -23,14 +23,14 @@ public class AquariumManager public List sellList; public AquariumManager() { - saveStore = GContext.container.Resolve(); + saveStore = GContext.container.Resolve(); loginUser = GContext.container.Resolve(); _tables = GContext.container.Resolve(); customServerMgr = GContext.container.Resolve(); } - public static AquariumManagerData GetAquariumManagerData(game.PlayerDataStorage.IPlayerSaveStore store = null) + public static AquariumManagerData GetAquariumManagerData(Core.KV.IPlayerSaveStore store = null) { - var source = store ?? GContext.container.Resolve(); + var source = store ?? GContext.container.Resolve(); string dataStr = source.Read(AquariumEventDataNewKey).ValueOrThrow(); if (!string.IsNullOrEmpty(dataStr)) { diff --git a/Client/Assets/Scripts/Camp/CampDataMM.cs b/Client/Assets/Scripts/Camp/CampDataMM.cs index 72f5c4f9c..3fc1cc852 100644 --- a/Client/Assets/Scripts/Camp/CampDataMM.cs +++ b/Client/Assets/Scripts/Camp/CampDataMM.cs @@ -10,7 +10,7 @@ using UnityEngine; public class CampDataMM { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore SaveStore { get; set; } private cfg.Tables _tables; MMTService _MMService; diff --git a/Client/Assets/Scripts/Core/CustomServerMgr.cs b/Client/Assets/Scripts/Core/CustomServerMgr.cs index 1a38fbb46..f7dbc2fc6 100644 --- a/Client/Assets/Scripts/Core/CustomServerMgr.cs +++ b/Client/Assets/Scripts/Core/CustomServerMgr.cs @@ -8,7 +8,7 @@ using System.Threading; using System.Threading.Tasks; using UnityEngine; using game.Account; -using game.PlayerDataStorage; +using game.KV; public interface ICustomServerMgr { @@ -27,12 +27,14 @@ public interface ICustomServerMgr Uri ApiBaseUri { get; } int SessionVersion { get; } event Action SessionChanged; - void UseAccountSession(S101AccountSession session); + void UseAccountSession(GameServerAccountSession session); void ClearAccountSession(); } public class CustomServerMgr : ICustomServerMgr { + public const string DefaultApiUrl = "http://s101.local:18080/"; + IConfig config { get; set; } //网络客户端 @@ -43,34 +45,31 @@ public class CustomServerMgr : ICustomServerMgr private readonly SynchronizationContext ownerContext; public int SessionVersion { get; private set; } public event Action SessionChanged; - PlayerKVAccountLease kvLease; - bool kvOnly; - bool kvBusinessReady; - bool kvBusinessPublished; - int pfRequests; + + private KvAccountLease kvLease; + private bool kvOnly; + private bool kvBusinessReady; + private bool kvBusinessPublished; + private int pfRequests; internal event Action KVUnauthorized; internal bool IsFreshForKVLoad => pfRequests==0 && kvLease==null; - internal void BindKVForValidation(PlayerKVAccountLease lease) + internal void BindKVForGame(KvAccountLease lease) { - if(lease==null || lease.IsFormal || !lease.IsCurrent || lease.ApiBaseUri!=ApiBaseUri || !IsFreshForKVLoad) - throw new InvalidOperationException("Fresh matching KV lease required."); - kvOnly=true; kvBusinessReady=false; kvBusinessPublished=false; SessionVersion++; kvLease=lease; _cookie=null; - var previous=client; client=null; previous?.Dispose(); SessionChanged?.Invoke(); - } - internal void BindKVForGame(PlayerKVAccountLease lease) - { - if(lease==null || !lease.IsFormal || !lease.IsCurrent || lease.ApiBaseUri.AbsoluteUri!=PlayerKVRuntimeSelection.FormalApiUrl || - ApiBaseUri.AbsoluteUri!=PlayerKVRuntimeSelection.FormalApiUrl || !IsFreshForKVLoad) + if (lease == null || !lease.IsCurrent || + lease.ApiBaseUri.AbsoluteUri != ApiBaseUri.AbsoluteUri || !IsFreshForKVLoad) + { throw new InvalidOperationException("Fresh formal KV lease required."); + } kvOnly=true; kvBusinessReady=false; kvBusinessPublished=false; SessionVersion++; kvLease=lease; _cookie=null; var previous=client; client=null; previous?.Dispose(); SessionChanged?.Invoke(); } internal void EnableKVBusiness(long epoch) { - if(!kvOnly || kvBusinessReady || kvBusinessPublished || kvLease==null || !kvLease.IsFormal || !kvLease.IsCurrent || - kvLease.Epoch!=epoch || kvLease.ApiBaseUri.AbsoluteUri!=PlayerKVRuntimeSelection.FormalApiUrl || - ApiBaseUri.AbsoluteUri!=PlayerKVRuntimeSelection.FormalApiUrl) + if (!kvOnly || kvBusinessReady || kvBusinessPublished || kvLease == null || !kvLease.IsCurrent || + kvLease.Epoch != epoch || kvLease.ApiBaseUri.AbsoluteUri != ApiBaseUri.AbsoluteUri) + { throw new InvalidOperationException("Current formal KV lease required for business transport."); + } kvBusinessPublished=true; kvBusinessReady=true; } internal void DisableKVBusiness() @@ -80,30 +79,21 @@ public class CustomServerMgr : ICustomServerMgr try { SessionChanged?.Invoke(); } catch(Exception ex) { Debug.LogError($"KV session change failed: {ex.GetType().Name}"); } } - internal Task ClearKVForGame() => ClearKVForValidation(); - internal Task ClearKVForValidation() + internal Task ClearKVForGame() { - kvBusinessReady=false; SessionVersion++; var previous=kvLease; kvLease=null; _cookie=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; + kvBusinessReady = false; + SessionVersion++; + var previous = kvLease; + kvLease = null; + _cookie = null; + var closing = previous?.CloseAsync() ?? Task.CompletedTask; + SessionChanged?.Invoke(); + return closing; } public CookieContainer Cookie => kvOnly - ? (kvBusinessReady && kvLease?.IsFormal==true ? kvLease.FormalCookies : null) ?? throw new NullReferenceException() + ? (kvBusinessReady && kvLease != null ? kvLease.FormalCookies : null) ?? throw new NullReferenceException() : _cookie ?? throw new NullReferenceException(); public Uri ApiBaseUri { get; } @@ -118,13 +108,16 @@ public class CustomServerMgr : ICustomServerMgr }); this.config = config; - var url = config.Get(GConstant.K_Event_API_URL, S101AccountSession.DefaultApiUrl); - if (string.IsNullOrWhiteSpace(url)) url = S101AccountSession.DefaultApiUrl; + var url = config.Get(GConstant.K_Event_API_URL, DefaultApiUrl); + if (string.IsNullOrWhiteSpace(url)) + { + url = DefaultApiUrl; + } ApiBaseUri = new Uri(url.TrimEnd('/') + "/"); ClearAccountSession(); } - public void UseAccountSession(S101AccountSession session) + public void UseAccountSession(GameServerAccountSession session) { if(kvOnly) throw new InvalidOperationException("Controlled container cannot switch to PlayFab."); if (session == null || !session.IsAuthenticated || session.ApiBaseUri != ApiBaseUri) @@ -134,7 +127,11 @@ public class CustomServerMgr : ICustomServerMgr public void ClearAccountSession() { - if(kvOnly) { PlayerKVShutdownRegistry.Process.Register(ClearKVForValidation()); return; } + if (kvOnly) + { + KvShutdownRegistry.Process.Register(ClearKVForGame()); + return; + } ReplaceTransport(new CookieContainer()); } @@ -170,7 +167,10 @@ public class CustomServerMgr : ICustomServerMgr } if(!kvBusinessReady) throw new InvalidOperationException("Formal KV business transport is not ready."); var lease=kvLease; var kvVersion=SessionVersion; - if(lease==null || !lease.IsFormal || !lease.IsCurrent) return null; + if (lease == null || !lease.IsCurrent) + { + return null; + } try { var response=await lease.SendAsync(method,url,body,contentType); @@ -286,3 +286,4 @@ public class CustomServerMgr : ICustomServerMgr } } } + diff --git a/Client/Assets/Scripts/Services/Account/S101AccountSession.cs b/Client/Assets/Scripts/Core/GameServerAccountSession.cs similarity index 50% rename from Client/Assets/Scripts/Services/Account/S101AccountSession.cs rename to Client/Assets/Scripts/Core/GameServerAccountSession.cs index f036c7e0c..4676fb35a 100644 --- a/Client/Assets/Scripts/Services/Account/S101AccountSession.cs +++ b/Client/Assets/Scripts/Core/GameServerAccountSession.cs @@ -11,54 +11,101 @@ using Newtonsoft.Json.Linq; namespace game.Account { - public enum AccountLoginError { None, Validation, Http, Rejected, Protocol, Network, Timeout, Cancelled } + 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; } + public AccountLoginError Error + { + get; + } + public int? StatusCode + { + get; + } internal AccountLoginResult(AccountLoginError error, int? status = null) - { Error = error; StatusCode = status; } + { + 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 sealed class GameServerAccountSession : 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; + private readonly object gate = new object(); + private readonly Uri api; + private readonly Func handlerFactory; + private HttpClient http; + private int generation; + private 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 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) + // KV supplies its owned transport; the PF path uses the default handler. + public GameServerAccountSession(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 + CookieContainer = jar, + UseCookies = true, + AllowAutoRedirect = false }); } @@ -70,7 +117,10 @@ namespace game.Account string id; lock (gate) { - if (disposed) return Result(AccountLoginError.Cancelled); + if (disposed) + { + return Result(AccountLoginError.Cancelled); + } generation++; ClearIdentity(); http?.Dispose(); @@ -78,11 +128,19 @@ namespace game.Account 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); + } + if (token.IsCancellationRequested) + { + return Result(AccountLoginError.Cancelled); + } id = numericId.ToString(CultureInfo.InvariantCulture); cookies = new CookieContainer(); - client = new HttpClient(handlerFactory(cookies)) { Timeout = TimeSpan.FromSeconds(15) }; + client = new HttpClient(handlerFactory(cookies)) + { + Timeout = TimeSpan.FromSeconds(15) + }; http = client; Cookies = cookies; requestGeneration = generation; @@ -91,24 +149,40 @@ namespace game.Account int? status = null; try { - using (var request = Request("account/Login", new JObject { ["playerId"] = id })) + 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); + 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["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; @@ -122,82 +196,59 @@ namespace game.Account 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); + return Result(disposed || generation != requestGeneration || token.IsCancellationRequested + ? AccountLoginError.Cancelled : AccountLoginError.Timeout, status); } } - catch (OperationCanceledException) + catch (ObjectDisposedException) { - lock (gate) - return Result(disposed || generation != requestGeneration || token.IsCancellationRequested - ? AccountLoginError.Cancelled : AccountLoginError.Timeout, status); + 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); } - 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) + private HttpRequestMessage Request(string path, JObject body) => new HttpRequestMessage(HttpMethod.Post, new Uri(api, path)) { - using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None }) + Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json") + }; + + private 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."); + if (reader.Read()) + { + throw new JsonReaderException("Trailing JSON content."); + } return value; } } - static bool ReadUtc(JToken value, out DateTime date) + private 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() + private static AccountLoginResult Result(AccountLoginError error, int? status = null) => new AccountLoginResult(error, status); + private void ClearIdentity() { IsAuthenticated = false; PlayerId = null; @@ -211,7 +262,10 @@ namespace game.Account { lock (gate) { - if (disposed) return; + if (disposed) + { + return; + } disposed = true; generation++; ClearIdentity(); diff --git a/Client/Assets/Editor/S101LoginWindow.cs.meta b/Client/Assets/Scripts/Core/GameServerAccountSession.cs.meta similarity index 83% rename from Client/Assets/Editor/S101LoginWindow.cs.meta rename to Client/Assets/Scripts/Core/GameServerAccountSession.cs.meta index af0cd00bc..67fcd9ff9 100644 --- a/Client/Assets/Editor/S101LoginWindow.cs.meta +++ b/Client/Assets/Scripts/Core/GameServerAccountSession.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: a8f49f7440c141e89d8519ff3aa1c034 +guid: 47cad15649b346799afbe5e5cafb4bb3 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Client/Assets/Scripts/Services/Account.meta b/Client/Assets/Scripts/Core/KV.meta similarity index 77% rename from Client/Assets/Scripts/Services/Account.meta rename to Client/Assets/Scripts/Core/KV.meta index cf0098488..11946ea98 100644 --- a/Client/Assets/Scripts/Services/Account.meta +++ b/Client/Assets/Scripts/Core/KV.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 4930c4a417ba4c908416717d26695848 +guid: f3f810de2c56488e979e3e585c94b35a folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Client/Assets/Scripts/Core/KV/IPlayerSaveStore.cs b/Client/Assets/Scripts/Core/KV/IPlayerSaveStore.cs new file mode 100644 index 000000000..eeba50c2c --- /dev/null +++ b/Client/Assets/Scripts/Core/KV/IPlayerSaveStore.cs @@ -0,0 +1,76 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Core.KV +{ + // A store belongs to one login. Callers retain this instance across awaits. + public interface IPlayerSaveStore + { + bool IsCurrent + { + get; + } + PlayerSaveReadResult Read(string key); + PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true); + Task SyncDataImmidiate(string key, string value); + } + + public sealed class PlayerSaveReadResult + { + public bool Success => Error == null; + public bool? Found + { + get; + } + public string Value + { + get; + } + public string Error + { + get; + } + internal PlayerSaveReadResult(bool found, string value) + { + Found = found; + Value = value; + } + internal PlayerSaveReadResult(string error) + { + Error = error ?? "ReadFailed"; + } + public string ValueOrThrow() + { + if (!Success) + { + throw new InvalidOperationException("Player save read failed: " + Error); + } + return Value; + } + } + + public sealed class PlayerSaveWriteResult + { + public bool Accepted => Error == null; + public string Error + { + get; + } + internal PlayerSaveWriteResult() + { + } + internal PlayerSaveWriteResult(string error) + { + Error = error ?? "WriteRejected"; + } + public void RequireAccepted() + { + if (!Accepted) + { + throw new InvalidOperationException("Player save write rejected: " + Error); + } + } + } + +} diff --git a/Client/Assets/Scripts/Services/PlayerData/IPlayerSaveStore.cs.meta b/Client/Assets/Scripts/Core/KV/IPlayerSaveStore.cs.meta similarity index 100% rename from Client/Assets/Scripts/Services/PlayerData/IPlayerSaveStore.cs.meta rename to Client/Assets/Scripts/Core/KV/IPlayerSaveStore.cs.meta diff --git a/Client/Assets/Scripts/Core/KV/KvCacheStore.cs b/Client/Assets/Scripts/Core/KV/KvCacheStore.cs new file mode 100644 index 000000000..fecebdca7 --- /dev/null +++ b/Client/Assets/Scripts/Core/KV/KvCacheStore.cs @@ -0,0 +1,132 @@ +using System; +using System.IO; +using System.Security; +using System.Text; +using Newtonsoft.Json; + +namespace Core.KV +{ + internal sealed class KvCacheStore : IDisposable + { + private const long MaxBytes = 64L * 1024 * 1024; + private readonly KvIdentity identity; + private readonly FileStream lease; + private readonly string main, temporary, backup; + private bool disposed; + + private KvCacheStore(KvIdentity identity, FileStream lease) + { + this.identity = identity; + this.lease = lease; + main = Path.Combine(identity.DirectoryPath, "state.json"); + temporary = main + ".tmp"; + backup = main + ".bak"; + } + + internal static KVOpenError Open(KvIdentity identity, + out KvCacheStore 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 KVOpenError.Busy; + } + store = new KvCacheStore(identity, lease); + 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 KVOpenError.None; + } + catch (Exception ex) when (ex is JsonException || ex is OverflowException || ex is FormatException || ex is DecoderFallbackException) + { + store?.Dispose(); + lease?.Dispose(); + store = null; + return KVOpenError.RecoveryRequired; + } + catch (Exception ex) when (DiskError(ex)) + { + store?.Dispose(); + lease?.Dispose(); + store = null; + return KVOpenError.StorageError; + } + } + + internal bool Save(KvSnapshot snapshot, out bool mayHaveCommitted) + { + mayHaveCommitted = false; + try + { + if (disposed) + { + // Preserve the existing diagnostic ObjectName when renaming the implementation. + throw new ObjectDisposedException("PlayerKVCacheStore"); + } + snapshot.Validate(); + var bytes = new UTF8Encoding(false, true).GetBytes(snapshot.Encode(identity)); + if (bytes.LongLength > MaxBytes) + { + throw new IOException("KV cache capacity exceeded."); + } + using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None)) + { + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + 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); + } + mayHaveCommitted = false; + return true; + } + catch (Exception ex) when (DiskError(ex) || ex is JsonException || ex is EncoderFallbackException) + { + return false; + } + } + + private 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/Core/KV/KvCacheStore.cs.meta similarity index 100% rename from Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs.meta rename to Client/Assets/Scripts/Core/KV/KvCacheStore.cs.meta diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs b/Client/Assets/Scripts/Core/KV/KvClient.cs similarity index 59% rename from Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs rename to Client/Assets/Scripts/Core/KV/KvClient.cs index f23c3eb23..83daf02c1 100644 --- a/Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs +++ b/Client/Assets/Scripts/Core/KV/KvClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Globalization; using System.IO; using System.Net; @@ -10,7 +11,7 @@ using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace game.PlayerDataStorage +namespace Core.KV { /// /// Private KV protocol for one fixed authenticated account/environment. @@ -18,67 +19,85 @@ namespace game.PlayerDataStorage /// 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 + public sealed class KvClient { - readonly HttpClient http; - readonly Uri api; + private readonly HttpClient http; + private readonly Uri api; - public PlayerKVClient(HttpClient http, Uri apiBaseUri) + public KvClient(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) + public Task GetAsync(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) + public Task SetAsync(IDictionary data, CancellationToken token = default) { - if (data == null) return Invalid(); + if (data == null) + { + return Invalid(); + } var snapshot = new JObject(); foreach (var pair in data) { - if (!ValidKey(pair.Key) || pair.Value == null) return Invalid(); + 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) + public Task DeleteAsync(IEnumerable keys, CancellationToken token = default) { - if (!CopyKeys(keys, false, out var snapshot)) return Invalid(); + if (!CopyKeys(keys, 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; + private static bool ValidKey(string key) => !string.IsNullOrEmpty(key) && key.Length <= 128; - static bool CopyKeys(IEnumerable keys, bool allowNull, out List snapshot) + private static bool CopyKeys(IEnumerable keys, out List snapshot) { snapshot = new List(); - if (keys == null) return allowNull; + if (keys == null) + { + return false; + } foreach (var key in keys) { - if (!ValidKey(key)) return false; + if (!ValidKey(key)) + { + return false; + } snapshot.Add(key); } return true; } - static Task Invalid() => Task.FromResult(new PlayerKVResult(PlayerKVError.Validation)); + private static Task Invalid() => Task.FromResult(new KvResult(KVError.Validation)); - async Task SendAsync(string action, JObject body, bool mutation, CancellationToken token) + private async Task SendAsync(string action, JObject body, bool mutation, CancellationToken token) { - if (token.IsCancellationRequested) return new PlayerKVResult(PlayerKVError.Cancelled); + if (token.IsCancellationRequested) + { + return new KvResult(KVError.Cancelled); + } int? status = null; bool enteredTransport = false; try @@ -93,37 +112,40 @@ namespace game.PlayerDataStorage 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); + { + return new KvResult(status == 401 || status == 403 + ? KVError.Unauthorized : KVError.Http, mayHaveCommitted: mutation); + } + if (mutation) + { + return new KvResult(KVError.None); + } var text = await response.Content.ReadAsStringAsync(); token.ThrowIfCancellationRequested(); var data = ParseData(text, out var updatedAtUtc); - return new PlayerKVResult(PlayerKVError.None, status, data, updatedAtUtc: updatedAtUtc); + return new KvResult(KVError.None, data, updatedAtUtc: updatedAtUtc); } } } catch (OperationCanceledException) { - return new PlayerKVResult(token.IsCancellationRequested ? PlayerKVError.Cancelled : PlayerKVError.Timeout, - status, mayHaveCommitted: mutation && enteredTransport); + return new KvResult(token.IsCancellationRequested ? KVError.Cancelled : KVError.Timeout, mayHaveCommitted: mutation && enteredTransport); } catch (ObjectDisposedException) { - return new PlayerKVResult(PlayerKVError.Cancelled, status, mayHaveCommitted: mutation && enteredTransport); + return new KvResult(KVError.Cancelled, mayHaveCommitted: mutation && enteredTransport); } catch (HttpRequestException) { - return new PlayerKVResult(PlayerKVError.Network, status, mayHaveCommitted: mutation && enteredTransport); + return new KvResult(KVError.Network, mayHaveCommitted: mutation && enteredTransport); } catch (IOException) { - return new PlayerKVResult(PlayerKVError.Network, status, mayHaveCommitted: mutation && enteredTransport); + return new KvResult(KVError.Network, mayHaveCommitted: mutation && enteredTransport); } catch (JsonException) { - return new PlayerKVResult(PlayerKVError.Protocol, status, mayHaveCommitted: mutation && enteredTransport); + return new KvResult(KVError.Protocol, mayHaveCommitted: mutation && enteredTransport); } } @@ -175,4 +197,51 @@ namespace game.PlayerDataStorage } } } + + public enum KVError + { + None, + Validation, + Unauthorized, + Http, + Network, + Timeout, + Cancelled, + Protocol + } + + public sealed class KvResult + { + public bool Success => Error == KVError.None; + public KVError Error + { + get; + } + // Only a successful Get has Data. Failure must never look like an empty save. + public IReadOnlyDictionary Data + { + get; + } + // Null means the response did not supply timestamp metadata; never substitute read time. + public IReadOnlyDictionary UpdatedAtUtc + { + 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 KvResult(KVError error, + Dictionary data = null, bool mayHaveCommitted = false, + Dictionary updatedAtUtc = null) + { + Error = error; + Data = data == null ? null : new ReadOnlyDictionary(data); + UpdatedAtUtc = updatedAtUtc == null ? null : new ReadOnlyDictionary(updatedAtUtc); + MayHaveCommitted = mayHaveCommitted; + } + } } diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs.meta b/Client/Assets/Scripts/Core/KV/KvClient.cs.meta similarity index 100% rename from Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs.meta rename to Client/Assets/Scripts/Core/KV/KvClient.cs.meta diff --git a/Client/Assets/Scripts/Core/KV/KvSessionOwner.cs b/Client/Assets/Scripts/Core/KV/KvSessionOwner.cs new file mode 100644 index 000000000..35495d3a8 --- /dev/null +++ b/Client/Assets/Scripts/Core/KV/KvSessionOwner.cs @@ -0,0 +1,461 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using game.Account; + +namespace Core.KV +{ + public enum KVSessionOpenError + { + None, + Validation, + Unauthenticated, + Busy, + RecoveryRequired, + StorageError, + ReadFailed, + Unauthorized, + Stale, + Cancelled + } + + public sealed class KvSessionOpenResult + { + public bool Success => Error == KVSessionOpenError.None; + public KVSessionOpenError Error + { + get; + } + public KvSessionOwner Owner + { + get; + } + internal KvSessionOpenResult(KVSessionOpenError error, KvSessionOwner owner = null) + { + Error = error; + Owner = owner; + } + } + + /// Owns one immutable account/epoch/cache/transport tuple. + public sealed class KvSessionOwner + { + private sealed class OperationLease : IDisposable + { + private KvSessionOwner owner; + internal OperationLease(KvSessionOwner owner) + { + this.owner = owner; + } + public void Dispose() + { + Interlocked.Exchange(ref owner, null)?.ExitOperation(); + } + } + + private readonly object admission = new object(); + private readonly GameServerAccountSession session; + private readonly CookieContainer cookies; + private readonly CancellationTokenSource lifetime; + private readonly Func isCurrent; + private readonly KvSyncState state; + + private readonly KvSyncCoordinator coordinator; + private readonly TaskCompletionSource drained = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly string ownerId = Guid.NewGuid().ToString("N"); + private int activeOperations; + private bool closing, closed; + private TaskCompletionSource closeCompletion; + + public string OwnerInstanceId => ownerId; + public Uri ApiBaseUri + { + get; + } + public string PlayerId + { + get; + } + public long Epoch + { + get; + } + public KVSyncStatus Status => state.Status; + + private KvSessionOwner(GameServerAccountSession session, CookieContainer cookies, Uri api, string playerId, + long epoch, Func isCurrent, CancellationTokenSource lifetime, + KvSyncState 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 KvSyncCoordinator(state, new KvClient(http, api), http, + IsCapturedOwnerCurrent, lifetime.Token); + } + + internal static async Task CreateAsync(GameServerAccountSession session, string cacheRoot, + string expectedPlayerId, Uri expectedApiBaseUri, long epoch, Func isCurrent, + CancellationToken lifetimeToken, Func handlerFactory) + { + 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 KvSessionOpenResult(KVSessionOpenError.Validation); + } + var playerId = numericId.ToString(CultureInfo.InvariantCulture); + Uri expectedApi; + try + { + expectedApi = NormalizeApi(expectedApiBaseUri); + } + catch (ArgumentException) + { + return new KvSessionOpenResult(KVSessionOpenError.Validation); + } + if (lifetimeToken.IsCancellationRequested) + { + return new KvSessionOpenResult(KVSessionOpenError.Cancelled); + } + if (!session.IsAuthenticated || session.Cookies == null) + { + return new KvSessionOpenResult(KVSessionOpenError.Unauthenticated); + } + var capturedCookies = session.Cookies; + var capturedApi = NormalizeApi(session.ApiBaseUri); + if (session.PlayerId != playerId || capturedApi != expectedApi || !isCurrent(epoch)) + { + return new KvSessionOpenResult(KVSessionOpenError.Stale); + } + + var opened = KvSyncState.Open(cacheRoot, capturedApi, playerId); + if (!opened.Success) + { + return new KvSessionOpenResult(MapOpen(opened.Error)); + } + HttpClient http = null; + CancellationTokenSource lifetime = null; + KvSessionOwner owner = null; + try + { + var handler = handlerFactory(capturedCookies); + if (handler == null) + { + throw new ArgumentException("Transport factory returned null."); + } + http = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(15) + }; + lifetime = CancellationTokenSource.CreateLinkedTokenSource(lifetimeToken); + owner = new KvSessionOwner(session, capturedCookies, capturedApi, playerId, epoch, + isCurrent, lifetime, opened.State, http); + http = null; + lifetime = null; + KvResult read; + using (var lease = owner.TryEnter()) + { + if (lease == null) + { + return await owner.FailCreate(KVSessionOpenError.Cancelled); + } + read = await owner.coordinator.InitializeAsync(); + } + if (!read.Success) + { + return await owner.FailCreate(read.Error == KVError.Unauthorized + ? KVSessionOpenError.Unauthorized + : read.Error == KVError.Cancelled ? KVSessionOpenError.Cancelled + : KVSessionOpenError.ReadFailed); + } + if (!owner.IsCapturedOwnerCurrent()) + { + return await owner.FailCreate(KVSessionOpenError.Stale); + } + return new KvSessionOpenResult(KVSessionOpenError.None, owner: owner); + } + catch (OperationCanceledException) + { + if (owner != null) + { + return await owner.FailCreate(KVSessionOpenError.Cancelled); + } + opened.State.Dispose(); + return new KvSessionOpenResult(KVSessionOpenError.Cancelled); + } + catch (Exception) + { + if (owner != null) + { + return await owner.FailCreate(KVSessionOpenError.Validation); + } + http?.Dispose(); + lifetime?.Dispose(); + opened.State.Dispose(); + return new KvSessionOpenResult(KVSessionOpenError.Validation); + } + } + + /// Reads the existing state under an operation lease; does not open another cache. + public KvLocalReadResult ReadLocalSnapshot() + { + using (var lease = TryEnter()) + { + if (lease == null) + { + return ReadFailure(KVLocalReadError.StaleOrClosing); + } + try + { + var data = state.ReadLocal(); + lock (admission) + { + if (!IsCapturedOwnerCurrent() || closing || closed) + { + return ReadFailure(KVLocalReadError.StaleOrClosing); + } + return new KvLocalReadResult(KVLocalReadError.None, data); + } + } + catch (InvalidOperationException) + { + lock (admission) + return ReadFailure(!IsCapturedOwnerCurrent() || closing || closed + ? KVLocalReadError.StaleOrClosing : KVLocalReadError.NotReady); + } + } + } + + private KvLocalReadResult ReadFailure(KVLocalReadError error) => + new KvLocalReadResult(error); + + internal KvStateResult CompareDatas(long nowTicks) + { + using (var lease = TryEnter()) + { + return lease == null ? new KvStateResult(KVChangeError.Blocked) : coordinator.CompareDatas(nowTicks); + } + } + + internal KvStateResult Update(string key, string value, bool remoteStore, long updateTicks) + { + using (var lease = TryEnter()) + { + return lease == null ? ClosedEdit() : coordinator.Update(key, value, remoteStore, updateTicks); + } + } + + public Task SyncDataAsync() + { + var lease = TryEnter(); + if (lease == null) + { + return Task.FromResult(new KvFlushResult(1, 0, KVError.Cancelled)); + } + return RunOrdinarySyncAsync(lease); + } + + private async Task RunOrdinarySyncAsync(OperationLease lease) + { + using (lease) + { + try + { + return await coordinator.SyncDataAsync(); + } + catch (Exception) + { + return new KvFlushResult(1, 0, KVError.Protocol); + } + } + } + + public async Task SyncDataImmidiate(string key, string value) + { + using (var lease = TryEnter()) + { + if (lease == null) + { + return false; + } + return await coordinator.SyncDataImmidiate(key, value); + } + } + + internal async Task SyncDataImmidiate(IReadOnlyDictionary values) + { + using (var lease = TryEnter()) + { + if (lease == null) + { + return false; + } + return await coordinator.SyncDataImmidiate(values); + } + } + + private OperationLease TryEnter() + { + lock (admission) + { + if (closing || closed || !IsCapturedOwnerCurrent()) + { + return null; + } + activeOperations++; + return new OperationLease(this); + } + } + + private 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; + } + + private 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); + } + } + + private async Task FailCreate(KVSessionOpenError error) + { + await CloseAsync(); + return new KvSessionOpenResult(error); + } + + private bool IsCapturedOwnerCurrent() + { + if (lifetime.IsCancellationRequested || !isCurrent(Epoch) || state.AuthenticationInvalid) + { + return false; + } + return session.IsAuthenticated && ReferenceEquals(session.Cookies, cookies) && + session.PlayerId == PlayerId && NormalizeApi(session.ApiBaseUri) == ApiBaseUri; + } + + private 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('/') + "/"); + } + + private static KVSessionOpenError MapOpen(KVOpenError error) + { + if (error == KVOpenError.Busy) + { + return KVSessionOpenError.Busy; + } + if (error == KVOpenError.RecoveryRequired) + { + return KVSessionOpenError.RecoveryRequired; + } + if (error == KVOpenError.StorageError) + { + return KVSessionOpenError.StorageError; + } + return KVSessionOpenError.Validation; + } + + private KvStateResult ClosedEdit() + { + return new KvStateResult(KVChangeError.Blocked); + } + + } + + public enum KVLocalReadError + { + None, + StaleOrClosing, + NotReady + } + + /// A detached snapshot. Failed reads have no data, never a pretend empty save. + public sealed class KvLocalReadResult + { + public bool Success => Error == KVLocalReadError.None; + public KVLocalReadError Error + { + get; + } + public IReadOnlyDictionary Data + { + get; + } + + internal KvLocalReadResult(KVLocalReadError error, IReadOnlyDictionary data = null) + { + Error = error; + if (error != KVLocalReadError.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); + } + } +} diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSessionOwner.cs.meta b/Client/Assets/Scripts/Core/KV/KvSessionOwner.cs.meta similarity index 100% rename from Client/Assets/Scripts/Services/PlayerData/PlayerKVSessionOwner.cs.meta rename to Client/Assets/Scripts/Core/KV/KvSessionOwner.cs.meta diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs b/Client/Assets/Scripts/Core/KV/KvSyncCoordinator.cs similarity index 52% rename from Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs rename to Client/Assets/Scripts/Core/KV/KvSyncCoordinator.cs index 30d099284..330bf755f 100644 --- a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs +++ b/Client/Assets/Scripts/Core/KV/KvSyncCoordinator.cs @@ -4,123 +4,128 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -namespace game.PlayerDataStorage +namespace Core.KV { - public enum PlayerKVTicketStatus { Pending, Confirmed, Superseded, Blocked, Unknown, Cancelled } - - public sealed class PlayerKVSyncTicket + public enum KVTicketStatus { - 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; } + Pending, + Confirmed, + Superseded, + Blocked, + Unknown, + Cancelled } - public sealed class PlayerKVTicketResult + public sealed class KvTicketResult { - 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 int Buffered + public KVTicketStatus Status { get; } - public PlayerKVError TransportError { get; } - public PlayerKVSyncStatus CacheStatus { get; } - internal PlayerKVFlushResult(int confirmed, int superseded, int blocked, int unknown, - PlayerKVError transportError, PlayerKVSyncStatus cacheStatus, int buffered = 0) + public KVError TransportError { - Buffered = buffered; - Confirmed = confirmed; Superseded = superseded; Blocked = blocked; Unknown = unknown; - TransportError = transportError; CacheStatus = cacheStatus; + get; + } + internal KvTicketResult(KVTicketStatus status, KVError transportError) + { + Status = status; + TransportError = transportError; + } + } + + public sealed class KvFlushResult + { + public int Blocked + { + get; + } + public int Unknown + { + get; + } + public KVError TransportError + { + get; + } + internal KvFlushResult(int blocked, int unknown, KVError transportError) + { + Blocked = blocked; + Unknown = unknown; + TransportError = transportError; } } /// One fixed owner's serialized Get/Set/Delete channel. No timers or automatic retries. - public sealed class PlayerKVSyncCoordinator : IDisposable + public sealed class KvSyncCoordinator : IDisposable { - sealed class TicketRecord + private 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; } + internal KvTicketResult Result; + internal bool Dispatched; } - 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; + private readonly object ticketGate = new object(); + private readonly Dictionary tickets = new Dictionary(); + private readonly SemaphoreSlim dispatch = new SemaphoreSlim(1, 1); + private readonly KvSyncState state; + private readonly KvClient client; + private readonly System.Net.Http.HttpClient http; + private readonly Func isOwnerCurrent; + private readonly CancellationToken ownerToken; private bool disposed; private bool ordinaryDraining; - private TaskCompletionSource<(PlayerKVError Error, Dictionary Results)> ordinaryDrainCompletion; + private TaskCompletionSource<(KVError Error, Dictionary Results)> ordinaryDrainCompletion; - internal PlayerKVSyncCoordinator(PlayerKVSyncState state, PlayerKVClient client, - System.Net.Http.HttpClient http, string ownerId, long epoch, Func isOwnerCurrent, + internal KvSyncCoordinator(KvSyncState state, KvClient client, + System.Net.Http.HttpClient http, 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; + this.state = state; + this.client = client; + this.http = http; + this.isOwnerCurrent = isOwnerCurrent; + this.ownerToken = ownerToken; } - internal async Task InitializeAsync(bool prepareOriginalComparison = false) + internal async Task InitializeAsync() { await dispatch.WaitAsync(ownerToken); try { - if (!Current()) return CancelledResult(); + if (!Current()) + { + return CancelledResult(); + } if (state.HasPendingComparison) { - return new PlayerKVResult(PlayerKVError.Protocol); + return new KvResult(KVError.Protocol); + } + var result = await client.GetAsync(ownerToken); + if (!Current()) + { + return CancelledResult(); } - var result = await client.GetAsync(null, ownerToken); - if (!Current()) return CancelledResult(); if (result.Success) { - var reconciled = state.Reconcile(result.Data, result.UpdatedAtUtc, prepareOriginalComparison); - return reconciled.Success ? result : new PlayerKVResult(PlayerKVError.Protocol); + var reconciled = state.Reconcile(result.Data, result.UpdatedAtUtc); + return reconciled.Success ? result : new KvResult(KVError.Protocol); } - state.RecordReadFailure(result.Error == PlayerKVError.Unauthorized); + state.RecordReadFailure(result.Error == KVError.Unauthorized); return result; } - finally { dispatch.Release(); } + finally + { + dispatch.Release(); + } } - internal PlayerKVStateResult CompareDatas(long nowTicks) + internal KvStateResult CompareDatas(long nowTicks) { lock (ticketGate) { if (!Current()) { - return new PlayerKVStateResult(PlayerKVChangeError.Blocked); + return new KvStateResult(KVChangeError.Blocked); } var previous = Unsent(state.Inspect()).Select(item => item.Sequence).ToArray(); var result = state.CompareDatas(nowTicks); @@ -132,7 +137,7 @@ namespace game.PlayerDataStorage } } - internal PlayerKVEditResult Update(string key, string value, bool remoteStore, long? updateTicks = null) + internal KvStateResult Update(string key, string value, bool remoteStore, long updateTicks) { lock (ticketGate) { @@ -140,37 +145,19 @@ namespace game.PlayerDataStorage var result = state.Update(key, value, remoteStore, updateTicks); if (!result.Success) { - return new PlayerKVEditResult(result); + return result; } CompleteRemovedSources(previous); if (!remoteStore || !Unsent(state.Inspect()).Any(item => item.Sequence == result.AttemptSequence)) { - return new PlayerKVEditResult(result); + return result; } - var ticket = new PlayerKVSyncTicket(ownerId, epoch, key, result.AttemptSequence); - tickets.Add(ticket.Sequence, new TicketRecord(ticket)); - return new PlayerKVEditResult(result, ticket); + tickets.Add(result.AttemptSequence, new TicketRecord()); + return result; } } - internal PlayerKVEditResult Delete(string key) - { - lock (ticketGate) - { - var previous = Unsent(state.Inspect()).Select(item => item.Sequence).ToArray(); - var result = state.Delete(key); - if (!result.Success) - { - return new PlayerKVEditResult(result); - } - CompleteRemovedSources(previous); - var ticket = new PlayerKVSyncTicket(ownerId, epoch, key, result.AttemptSequence); - tickets.Add(ticket.Sequence, new TicketRecord(ticket)); - return new PlayerKVEditResult(result, ticket); - } - } - - private static IEnumerable Unsent(PlayerKVDiagnosticSnapshot value) + private static IEnumerable Unsent(KvDiagnosticSnapshot value) { return value.OrdinaryBuffers.SelectMany(bucket => bucket.Values) .Concat(value.LegacyPending.Values).Concat(value.ExplicitDeletes.Values); @@ -182,16 +169,16 @@ namespace game.PlayerDataStorage foreach (var sequence in previous) { if (!remaining.Contains(sequence) && tickets.TryGetValue(sequence, out var record) && - !record.Dispatched && !record.Completion.Task.IsCompleted) + !record.Dispatched && record.Result == null) { - Complete(record, PlayerKVTicketStatus.Superseded, PlayerKVError.None); + Complete(record, KVTicketStatus.Superseded, KVError.None); } } } internal Task SyncDataImmidiate(string key, string value) { - if (!KVSnapshot.ValidKey(key) || value == null) + if (!KvSnapshot.ValidKey(key) || value == null) { return Task.FromResult(false); } @@ -205,7 +192,7 @@ namespace game.PlayerDataStorage return false; } var data = values.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); - IReadOnlyList operations; + IReadOnlyList operations; lock (ticketGate) { var previous = Unsent(state.Inspect()).Select(item => item.Sequence).ToArray(); @@ -221,7 +208,7 @@ namespace game.PlayerDataStorage { await dispatch.WaitAsync(ownerToken); acquired = true; - if (!Current() || state.Status != PlayerKVSyncStatus.Ready) + if (!Current() || state.Status != KVSyncStatus.Ready) { return false; } @@ -235,7 +222,7 @@ namespace game.PlayerDataStorage { return state.CompleteImmediate(operations).Success; } - if (result.Error == PlayerKVError.Unauthorized) + if (result.Error == KVError.Unauthorized) { state.RecordReadFailure(true); } @@ -258,53 +245,32 @@ namespace game.PlayerDataStorage } } - internal long CaptureWatermark(out long[] targetSequences) - { - lock (ticketGate) - { - var snapshot = state.Inspect(); - var watermark = snapshot.NextSequence <= 1 ? 0 : snapshot.NextSequence - 1; - var target = new HashSet(Outstanding(snapshot).Where(item => item.Sequence <= watermark).Select(item => item.Sequence)); - foreach (var record in tickets.Values) - { - if (record.Ticket.Sequence <= watermark && !record.ClaimedByFlush) - { - record.ClaimedByFlush = true; - target.Add(record.Ticket.Sequence); - } - } - targetSequences = target.ToArray(); - return watermark; - } - } - - private static IEnumerable Outstanding(PlayerKVDiagnosticSnapshot snapshot) + private static IEnumerable Outstanding(KvDiagnosticSnapshot snapshot) { return Unsent(snapshot).Concat(snapshot.PreparedOrdinaryBatches.SelectMany(batch => batch.Items)) .Concat(snapshot.OrdinaryUnknownBatches.SelectMany(batch => batch.Items)) - .Concat(snapshot.ActiveBatch == null ? Enumerable.Empty() : snapshot.ActiveBatch.Items) - .Concat(snapshot.UnknownBatch == null ? Enumerable.Empty() : snapshot.UnknownBatch.Items); + .Concat(snapshot.ActiveBatch == null ? Enumerable.Empty() : snapshot.ActiveBatch.Items) + .Concat(snapshot.UnknownBatch == null ? Enumerable.Empty() : snapshot.UnknownBatch.Items); } - // This explicit channel only sends imported legacy intents and explicit deletes, never ordinary buckets. - internal async Task FlushThroughAsync(long watermark, long[] targetSequences, - bool legacyOnly = false, int maximumBatches = int.MaxValue) + // This channel sends one imported legacy batch, never ordinary buckets. + internal async Task FlushThroughAsync(long watermark, long[] targetSequences) { - var error = PlayerKVError.None; - var observed = new Dictionary(); + var error = KVError.None; + var observed = new Dictionary(); var acquired = false; try { await dispatch.WaitAsync(ownerToken); acquired = true; var sent = 0; - while (Current() && sent < maximumBatches) + while (Current() && sent < 1) { - PlayerKVStateResult begin; - PlayerKVBatch batch; + KvStateResult begin; + KvBatch batch; lock (ticketGate) { - begin = state.BeginBatch(watermark, legacyOnly, out batch); + begin = state.BeginBatch(watermark, out batch); if (begin.Success && batch != null) { MarkDispatched(batch); @@ -312,7 +278,7 @@ namespace game.PlayerDataStorage } if (!begin.Success) { - MarkExplicitBlocked(watermark, legacyOnly, PlayerKVError.None, observed); + MarkExplicitBlocked(watermark, KVError.None, observed); break; } if (batch == null) @@ -323,13 +289,13 @@ namespace game.PlayerDataStorage var result = await SendAsync(batch); if (!Current() && result.Success) { - result = new PlayerKVResult(PlayerKVError.Cancelled, mayHaveCommitted: true); + result = new KvResult(KVError.Cancelled, mayHaveCommitted: true); } if (result.Success) { var confirmed = state.ConfirmBatch(batch.Id); - var outcome = confirmed.Success ? PlayerKVTicketStatus.Confirmed : PlayerKVTicketStatus.Unknown; - RecordBatch(batch, outcome, PlayerKVError.None, observed); + var outcome = confirmed.Success ? KVTicketStatus.Confirmed : KVTicketStatus.Unknown; + RecordBatch(batch, outcome, KVError.None, observed); if (!confirmed.Success) { break; @@ -337,27 +303,27 @@ namespace game.PlayerDataStorage continue; } error = result.Error; - state.FailBatch(batch.Id, result.MayHaveCommitted, result.Error == PlayerKVError.Unauthorized); - var failedStatus = result.MayHaveCommitted ? PlayerKVTicketStatus.Unknown : - ownerToken.IsCancellationRequested ? PlayerKVTicketStatus.Cancelled : PlayerKVTicketStatus.Blocked; + state.FailBatch(batch.Id, result.MayHaveCommitted, result.Error == KVError.Unauthorized); + var failedStatus = result.MayHaveCommitted ? KVTicketStatus.Unknown : + ownerToken.IsCancellationRequested ? KVTicketStatus.Cancelled : KVTicketStatus.Blocked; RecordBatch(batch, failedStatus, result.Error, observed); break; } if (!Current()) { - error = PlayerKVError.Cancelled; + error = KVError.Cancelled; CloseTickets(); } } catch (OperationCanceledException) { - error = PlayerKVError.Cancelled; + error = KVError.Cancelled; CloseTickets(); } catch (Exception) { - error = PlayerKVError.Protocol; - MarkExplicitBlocked(watermark, legacyOnly, error, observed); + error = KVError.Protocol; + MarkExplicitBlocked(watermark, error, observed); } finally { @@ -369,9 +335,9 @@ namespace game.PlayerDataStorage return Summary(targetSequences ?? Array.Empty(), error, observed); } - internal Task SyncDataAsync() + internal Task SyncDataAsync() { - TaskCompletionSource<(PlayerKVError Error, Dictionary Results)> completion; + TaskCompletionSource<(KVError Error, Dictionary Results)> completion; bool start; long[] targets; lock (ticketGate) @@ -379,12 +345,12 @@ namespace game.PlayerDataStorage var snapshot = state.Inspect(); if (!Current()) { - return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, state.Status)); + return Task.FromResult(new KvFlushResult(1, 0, KVError.Cancelled)); } if (snapshot.LegacyPending.Count != 0) { var watermark = snapshot.NextSequence - 1; - return FlushThroughAsync(watermark, snapshot.LegacyPending.Values.Select(item => item.Sequence).ToArray(), true, 1); + return FlushThroughAsync(watermark, snapshot.LegacyPending.Values.Select(item => item.Sequence).ToArray()); } var captured = state.CaptureOrdinaryBatch(out var batch); if (!captured.Success) @@ -393,28 +359,28 @@ namespace game.PlayerDataStorage { if (tickets.TryGetValue(item.Sequence, out var record)) { - Complete(record, PlayerKVTicketStatus.Blocked, PlayerKVError.None); + Complete(record, KVTicketStatus.Blocked, KVError.None); } } - return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.None, state.Status)); + return Task.FromResult(new KvFlushResult(1, 0, KVError.None)); } snapshot = state.Inspect(); if (batch == null && ordinaryDraining) { // The existing caller owns the queued work; an empty tick has no new result to observe. - return Task.FromResult(Summary(Array.Empty(), PlayerKVError.None)); + return Task.FromResult(Summary(Array.Empty(), KVError.None)); } targets = batch != null ? batch.Items.Select(item => item.Sequence).ToArray() : snapshot.PreparedOrdinaryBatches.SelectMany(item => item.Items).Select(item => item.Sequence).ToArray(); if (snapshot.PreparedOrdinaryBatches.Count == 0) { - return Task.FromResult(Summary(targets, PlayerKVError.None)); + return Task.FromResult(Summary(targets, KVError.None)); } start = !ordinaryDraining; if (start) { ordinaryDraining = true; - ordinaryDrainCompletion = new TaskCompletionSource<(PlayerKVError Error, Dictionary Results)>(TaskCreationOptions.RunContinuationsAsynchronously); + ordinaryDrainCompletion = new TaskCompletionSource<(KVError Error, Dictionary Results)>(TaskCreationOptions.RunContinuationsAsynchronously); } completion = ordinaryDrainCompletion; } @@ -426,31 +392,31 @@ namespace game.PlayerDataStorage return ObserveOrdinaryAsync(completion.Task, targets); } - private async Task ObserveOrdinaryAsync(Task<(PlayerKVError Error, Dictionary Results)> worker, long[] targets) + private async Task ObserveOrdinaryAsync(Task<(KVError Error, Dictionary Results)> worker, long[] targets) { var completed = await worker; var error = targets.Where(completed.Results.ContainsKey).Select(sequence => completed.Results[sequence].TransportError) - .FirstOrDefault(value => value != PlayerKVError.None); - if (error == PlayerKVError.None && targets.Any(sequence => !completed.Results.ContainsKey(sequence))) + .FirstOrDefault(value => value != KVError.None); + if (error == KVError.None && targets.Any(sequence => !completed.Results.ContainsKey(sequence))) { error = completed.Error; } return Summary(targets, error, completed.Results.ToDictionary(pair => pair.Key, pair => pair.Value.Status)); } - private async Task DrainOrdinaryAsync(TaskCompletionSource<(PlayerKVError Error, Dictionary Results)> completion) + private async Task DrainOrdinaryAsync(TaskCompletionSource<(KVError Error, Dictionary Results)> completion) { - var error = PlayerKVError.None; - var observed = new Dictionary(); - void RecordOrdinaryBatch(PlayerKVBatch batch, PlayerKVTicketStatus status, PlayerKVError transportError) + var error = KVError.None; + var observed = new Dictionary(); + void RecordOrdinaryBatch(KvBatch batch, KVTicketStatus status, KVError transportError) { CompleteBatch(batch, status, transportError); foreach (var item in batch.Items) { - observed[item.Sequence] = new PlayerKVTicketResult(status, transportError, state.Status); + observed[item.Sequence] = new KvTicketResult(status, transportError); } } - PlayerKVBatch active = null; + KvBatch active = null; var acquired = false; var dispatched = false; try @@ -459,7 +425,7 @@ namespace game.PlayerDataStorage { await dispatch.WaitAsync(ownerToken); acquired = true; - PlayerKVStateResult begin; + KvStateResult begin; lock (ticketGate) { begin = state.BeginOrdinaryBatch(out active); @@ -473,7 +439,7 @@ namespace game.PlayerDataStorage { if (tickets.TryGetValue(item.Sequence, out var record)) { - Complete(record, PlayerKVTicketStatus.Blocked, PlayerKVError.None); + Complete(record, KVTicketStatus.Blocked, KVError.None); } } } @@ -485,12 +451,12 @@ namespace game.PlayerDataStorage var result = await SendAsync(active); if (!Current() && result.Success) { - result = new PlayerKVResult(PlayerKVError.Cancelled, mayHaveCommitted: true); + result = new KvResult(KVError.Cancelled, mayHaveCommitted: true); } if (result.Success) { var confirmed = state.ConfirmBatch(active.Id); - RecordOrdinaryBatch(active, confirmed.Success ? PlayerKVTicketStatus.Confirmed : PlayerKVTicketStatus.Unknown, PlayerKVError.None); + RecordOrdinaryBatch(active, confirmed.Success ? KVTicketStatus.Confirmed : KVTicketStatus.Unknown, KVError.None); if (!confirmed.Success) { break; @@ -499,10 +465,10 @@ namespace game.PlayerDataStorage else { error = result.Error; - var failed = state.FailBatch(active.Id, result.MayHaveCommitted, result.Error == PlayerKVError.Unauthorized); - RecordOrdinaryBatch(active, result.MayHaveCommitted ? PlayerKVTicketStatus.Unknown : - ownerToken.IsCancellationRequested ? PlayerKVTicketStatus.Cancelled : PlayerKVTicketStatus.Blocked, result.Error); - if (!failed.Success || state.Status != PlayerKVSyncStatus.Ready) + var failed = state.FailBatch(active.Id, result.MayHaveCommitted, result.Error == KVError.Unauthorized); + RecordOrdinaryBatch(active, result.MayHaveCommitted ? KVTicketStatus.Unknown : + ownerToken.IsCancellationRequested ? KVTicketStatus.Cancelled : KVTicketStatus.Blocked, result.Error); + if (!failed.Success || state.Status != KVSyncStatus.Ready) { break; } @@ -515,20 +481,20 @@ namespace game.PlayerDataStorage } catch (OperationCanceledException) { - error = PlayerKVError.Cancelled; + error = KVError.Cancelled; if (active != null) { state.FailBatch(active.Id, dispatched); - RecordOrdinaryBatch(active, dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Cancelled, error); + RecordOrdinaryBatch(active, dispatched ? KVTicketStatus.Unknown : KVTicketStatus.Cancelled, error); } } catch (Exception) { - error = PlayerKVError.Protocol; + error = KVError.Protocol; if (active != null) { state.FailBatch(active.Id, dispatched); - RecordOrdinaryBatch(active, dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Blocked, error); + RecordOrdinaryBatch(active, dispatched ? KVTicketStatus.Unknown : KVTicketStatus.Blocked, error); } } finally @@ -553,14 +519,14 @@ namespace game.PlayerDataStorage } catch (Exception) { - error = PlayerKVError.Protocol; + error = KVError.Protocol; } } completion.TrySetResult((error, observed)); } } - private Task SendAsync(PlayerKVBatch batch) + private Task SendAsync(KvBatch batch) { if (batch.IsDelete) { @@ -569,8 +535,8 @@ namespace game.PlayerDataStorage return client.SetAsync(batch.Items.ToDictionary(item => item.Key, item => item.Target.Value, StringComparer.Ordinal), ownerToken); } - private void RecordBatch(PlayerKVBatch batch, PlayerKVTicketStatus status, PlayerKVError error, - Dictionary observed) + private void RecordBatch(KvBatch batch, KVTicketStatus status, KVError error, + Dictionary observed) { CompleteBatch(batch, status, error); foreach (var item in batch.Items) @@ -579,19 +545,19 @@ namespace game.PlayerDataStorage } } - private void MarkExplicitBlocked(long watermark, bool legacyOnly, PlayerKVError error, - Dictionary observed) + private void MarkExplicitBlocked(long watermark, KVError error, + Dictionary observed) { lock (ticketGate) { var snapshot = state.Inspect(); - var source = legacyOnly ? snapshot.LegacyPending.Values : snapshot.LegacyPending.Values.Concat(snapshot.ExplicitDeletes.Values); + var source = snapshot.LegacyPending.Values; foreach (var item in source.Where(item => item.Sequence <= watermark)) { - var outcome = PlayerKVTicketStatus.Blocked; + var outcome = KVTicketStatus.Blocked; if (tickets.TryGetValue(item.Sequence, out var record)) { - outcome = record.Dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Blocked; + outcome = record.Dispatched ? KVTicketStatus.Unknown : KVTicketStatus.Blocked; Complete(record, outcome, error); } observed[item.Sequence] = outcome; @@ -599,58 +565,55 @@ namespace game.PlayerDataStorage } } - 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); + { + if (record.Result == null) + { + Complete(record, record.Dispatched ? KVTicketStatus.Unknown : KVTicketStatus.Cancelled, + KVError.Cancelled); + } + } + } } - void MarkDispatched(PlayerKVBatch batch) + private void MarkDispatched(KvBatch batch) { lock (ticketGate) foreach (var item in batch.Items) - if (tickets.TryGetValue(item.Sequence, out var record)) record.Dispatched = true; + if (tickets.TryGetValue(item.Sequence, out var record)) + { + record.Dispatched = true; + } } - void CompleteBatch(PlayerKVBatch batch, PlayerKVTicketStatus status, PlayerKVError error) + private void CompleteBatch(KvBatch batch, KVTicketStatus status, KVError error) { lock (ticketGate) foreach (var item in batch.Items) - if (tickets.TryGetValue(item.Sequence, out var record)) Complete(record, status, error); + if (tickets.TryGetValue(item.Sequence, out var record)) + { + Complete(record, status, error); + } } - void Complete(TicketRecord record, PlayerKVTicketStatus status, PlayerKVError error) => - record.Completion.TrySetResult(new PlayerKVTicketResult(status, error, state.Status)); + private void Complete(TicketRecord record, KVTicketStatus status, KVError error) + { + if (record.Result == null) + { + record.Result = new KvTicketResult(status, error); + } + } - private PlayerKVFlushResult Summary(IEnumerable targetSequences, PlayerKVError error, - Dictionary observed = null) + private KvFlushResult Summary(IEnumerable targetSequences, KVError error, + Dictionary observed = null) { lock (ticketGate) { - var counts = new Dictionary(); + var counts = new Dictionary(); var snapshot = state.Inspect(); var unknown = new HashSet(snapshot.OrdinaryUnknownBatches.SelectMany(batch => batch.Items).Select(item => item.Sequence)); if (snapshot.UnknownBatch != null) @@ -660,10 +623,10 @@ namespace game.PlayerDataStorage var outstanding = new HashSet(Outstanding(snapshot).Select(item => item.Sequence)); foreach (var sequence in targetSequences.Distinct()) { - PlayerKVTicketStatus outcome; - if (tickets.TryGetValue(sequence, out var record) && record.Completion.Task.IsCompleted) + KVTicketStatus outcome; + if (tickets.TryGetValue(sequence, out var record) && record.Result != null) { - outcome = record.Completion.Task.GetAwaiter().GetResult().Status; + outcome = record.Result.Status; } else if (observed != null && observed.TryGetValue(sequence, out var actual)) { @@ -671,11 +634,11 @@ namespace game.PlayerDataStorage } else if (unknown.Contains(sequence)) { - outcome = PlayerKVTicketStatus.Unknown; + outcome = KVTicketStatus.Unknown; } else if (outstanding.Contains(sequence) || tickets.ContainsKey(sequence)) { - outcome = PlayerKVTicketStatus.Pending; + outcome = KVTicketStatus.Pending; } else { @@ -683,22 +646,23 @@ namespace game.PlayerDataStorage } counts[outcome] = counts.TryGetValue(outcome, out var count) ? count + 1 : 1; } - int Count(PlayerKVTicketStatus status) + int Count(KVTicketStatus status) { return counts.TryGetValue(status, out var count) ? count : 0; } - return new PlayerKVFlushResult(Count(PlayerKVTicketStatus.Confirmed), Count(PlayerKVTicketStatus.Superseded), - Count(PlayerKVTicketStatus.Blocked) + Count(PlayerKVTicketStatus.Cancelled), Count(PlayerKVTicketStatus.Unknown), - error, state.Status, Count(PlayerKVTicketStatus.Pending)); + return new KvFlushResult(Count(KVTicketStatus.Blocked) + Count(KVTicketStatus.Cancelled), Count(KVTicketStatus.Unknown), error); } } - bool Current() => !ownerToken.IsCancellationRequested && isOwnerCurrent(); - static PlayerKVResult CancelledResult() => new PlayerKVResult(PlayerKVError.Cancelled); + private bool Current() => !ownerToken.IsCancellationRequested && isOwnerCurrent(); + private static KvResult CancelledResult() => new KvResult(KVError.Cancelled); public void Dispose() { - if (disposed) return; + if (disposed) + { + return; + } disposed = true; http.Dispose(); dispatch.Dispose(); diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs.meta b/Client/Assets/Scripts/Core/KV/KvSyncCoordinator.cs.meta similarity index 100% rename from Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs.meta rename to Client/Assets/Scripts/Core/KV/KvSyncCoordinator.cs.meta diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs b/Client/Assets/Scripts/Core/KV/KvSyncModels.cs similarity index 69% rename from Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs rename to Client/Assets/Scripts/Core/KV/KvSyncModels.cs index 55c7a90f0..3a79af31b 100644 --- a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs +++ b/Client/Assets/Scripts/Core/KV/KvSyncModels.cs @@ -9,67 +9,145 @@ using System.Text; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace game.PlayerDataStorage +namespace Core.KV { - 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 enum KVSyncStatus { - 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; } + NotLoaded, + Ready, + ReadFailed, + Conflict, + UnknownOutcome, + Unauthorized, + StorageError, + Closed + } + public enum KVOpenError + { + None, + Validation, + Busy, + StorageError, + RecoveryRequired + } + public enum KVChangeError + { + None, + Validation, + Blocked, + StorageError } - public sealed class PlayerKVOpenResult + public sealed class KvStateResult { - 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; } + public bool Success => Error == KVChangeError.None; + public KVChangeError Error + { + get; + } + public long AttemptSequence + { + get; + } + public bool LocalCommitMayHaveSucceeded + { + get; + } + internal KvStateResult(KVChangeError error, long sequence = 0, bool uncertain = false) + { + Error = error; + AttemptSequence = sequence; + LocalCommitMayHaveSucceeded = uncertain; + } + } + + public sealed class KvOpenResult + { + public bool Success => Error == KVOpenError.None; + public KVOpenError Error + { + get; + } + public KvSyncState State + { + get; + } + internal KvOpenResult(KVOpenError error, KvSyncState state = null) + { + Error = error; + State = state; + } } // Existence is separate from value: an empty string is not a deletion. - public sealed class PlayerKVValue : IEquatable + public sealed class KvValue : 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 static readonly KvValue Missing = new KvValue(false, null); + public bool Exists + { + get; + } + public string Value + { + get; + } + private KvValue(bool exists, string value) + { + Exists = exists; + Value = value; + } + public static KvValue Present(string value) => new KvValue(true, value ?? throw new ArgumentNullException(nameof(value))); + public bool Equals(KvValue other) => other != null && Exists == other.Exists && Value == other.Value; + public override bool Equals(object obj) => Equals(obj as KvValue); public override int GetHashCode() => Exists ? (Value.GetHashCode() ^ 397) : 0; - internal static PlayerKVValue At(IReadOnlyDictionary data, string key) => + internal static KvValue At(IReadOnlyDictionary data, string key) => data.TryGetValue(key, out var value) ? Present(value) : Missing; } - public sealed class PlayerKVChange + public sealed class KvChange { - 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 string Key + { + get; + } + public long Sequence + { + get; + } + public KvValue BaseValue + { + get; + } + public KvValue Target + { + get; + } + internal KvChange(string key, long sequence, KvValue baseline, KvValue target) + { + Key = key; + Sequence = sequence; + BaseValue = baseline; + Target = target; + } + internal KvChange WithBase(KvValue baseline) => new KvChange(Key, Sequence, baseline, Target); } - public sealed class PlayerKVBatch + public sealed class KvBatch { - public string Id { get; } - public IReadOnlyList Items { get; } + public string Id + { + get; + } + public IReadOnlyList Items + { + get; + } public bool IsDelete => !Items[0].Target.Exists; public bool IsOrdinary { get; } - internal PlayerKVBatch(string id, IEnumerable items, bool ordinary = false) + internal KvBatch(string id, IEnumerable items, bool ordinary = false) { Id = id; Items = Array.AsReadOnly(items.ToArray()); @@ -77,74 +155,66 @@ namespace game.PlayerDataStorage } } - public sealed class PlayerKVDiagnosticSnapshot + public sealed class KvDiagnosticSnapshot { - 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; } - public IReadOnlyDictionary LocalData + public long NextSequence { get; } - public IReadOnlyDictionary LocalUpdateTicks + public KvBatch ActiveBatch { get; } - public IReadOnlyList> OrdinaryBuffers + public KvBatch UnknownBatch { get; } - public IReadOnlyList PreparedOrdinaryBatches + public IReadOnlyList> OrdinaryBuffers { get; } - public IReadOnlyList OrdinaryUnknownBatches + public IReadOnlyList PreparedOrdinaryBatches { get; } - public IReadOnlyDictionary LegacyPending + public IReadOnlyList OrdinaryUnknownBatches { get; } - public IReadOnlyDictionary ExplicitDeletes + public IReadOnlyDictionary LegacyPending { get; } - internal PlayerKVDiagnosticSnapshot(KVSnapshot source) + public IReadOnlyDictionary ExplicitDeletes { - 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; - LocalData = ReadOnly(source.LocalData); - LocalUpdateTicks = ReadOnly(source.LocalUpdateTicks); + get; + } + internal KvDiagnosticSnapshot(KvSnapshot source) + { + NextSequence = source.NextSequence; + ActiveBatch = source.Active; + UnknownBatch = source.Unknown; OrdinaryBuffers = Array.AsReadOnly(source.OrdinaryBuffers.Select(bucket => ReadOnly(bucket)).ToArray()); PreparedOrdinaryBatches = Array.AsReadOnly(source.PreparedOrdinaryBatches.ToArray()); OrdinaryUnknownBatches = Array.AsReadOnly(source.OrdinaryUnknownBatches.ToArray()); LegacyPending = ReadOnly(source.LegacyPending); ExplicitDeletes = ReadOnly(source.ExplicitDeletes); } - static IReadOnlyDictionary ReadOnly(Dictionary source) => + private static IReadOnlyDictionary ReadOnly(Dictionary source) => new ReadOnlyDictionary(new Dictionary(source, StringComparer.Ordinal)); } - internal sealed class KVIdentity + internal sealed class KvIdentity { internal readonly string Api, PlayerId, DirectoryPath; - internal KVIdentity(string root, Uri api, string playerId) + 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()) @@ -156,7 +226,7 @@ namespace game.PlayerDataStorage } } - internal sealed class KVSnapshot + internal sealed class KvSnapshot { internal long Revision, NextSequence = 1; internal bool HasBaseline, AuthenticationInvalid; @@ -169,34 +239,23 @@ namespace game.PlayerDataStorage internal HashSet ImmediatePreservedKeys = new HashSet(StringComparer.Ordinal); // Only v1/v2 conversion can populate this lower layer; the first successful Get removes it. internal Dictionary LegacyLocalOnlyUnderImmediate = new Dictionary(StringComparer.Ordinal); - internal List> OrdinaryBuffers = Enumerable.Range(0, 5) - .Select(_ => new Dictionary(StringComparer.Ordinal)).ToList(); - internal List PreparedOrdinaryBatches = new List(); - internal List OrdinaryUnknownBatches = new List(); - internal Dictionary LegacyPending = new Dictionary(StringComparer.Ordinal); - internal Dictionary ExplicitDeletes = new Dictionary(StringComparer.Ordinal); - internal Dictionary Conflicts = new Dictionary(StringComparer.Ordinal); - internal Dictionary RemoteOnlyWrites = new Dictionary(); - internal PlayerKVBatch Active, Unknown; + internal List> OrdinaryBuffers = Enumerable.Range(0, 5) + .Select(_ => new Dictionary(StringComparer.Ordinal)).ToList(); + internal List PreparedOrdinaryBatches = new List(); + internal List OrdinaryUnknownBatches = new List(); + internal Dictionary LegacyPending = new Dictionary(StringComparer.Ordinal); + internal Dictionary ExplicitDeletes = new Dictionary(StringComparer.Ordinal); + internal Dictionary Conflicts = new Dictionary(StringComparer.Ordinal); + internal Dictionary RemoteOnlyWrites = new Dictionary(); + internal KvBatch Active, Unknown; - // Compatibility diagnostics only. A key projection loses cross-bucket sequence history. - internal Dictionary Pending => Unsent().GroupBy(item => item.Key, StringComparer.Ordinal) - .ToDictionary(group => group.Key, group => group.OrderBy(item => item.Sequence).Last(), StringComparer.Ordinal); - internal Dictionary LocalOnly => LocalOrigins.Where(pair => pair.Value.LocalOnly && LocalData.ContainsKey(pair.Key)) - .ToDictionary(pair => pair.Key, pair => LocalData[pair.Key], StringComparer.Ordinal); - internal IEnumerable Unsent() + internal IEnumerable Unsent() { return OrdinaryBuffers.SelectMany(bucket => bucket.Values).Concat(LegacyPending.Values).Concat(ExplicitDeletes.Values); } - internal IEnumerable OrdinaryOutstanding() + internal KvSnapshot Clone() { - return OrdinaryBuffers.SelectMany(bucket => bucket.Values) - .Concat(PreparedOrdinaryBatches.SelectMany(batch => batch.Items)) - .Concat(Active != null && Active.IsOrdinary ? Active.Items : Enumerable.Empty()); - } - internal KVSnapshot Clone() - { - return new KVSnapshot + return new KvSnapshot { Revision = Revision, NextSequence = NextSequence, HasBaseline = HasBaseline, AuthenticationInvalid = AuthenticationInvalid, Baseline = new Dictionary(Baseline, StringComparer.Ordinal), @@ -205,19 +264,19 @@ namespace game.PlayerDataStorage LocalOrigins = new Dictionary(LocalOrigins, StringComparer.Ordinal), ImmediatePreservedKeys = new HashSet(ImmediatePreservedKeys, StringComparer.Ordinal), LegacyLocalOnlyUnderImmediate = new Dictionary(LegacyLocalOnlyUnderImmediate, StringComparer.Ordinal), - OrdinaryBuffers = OrdinaryBuffers.Select(bucket => new Dictionary(bucket, StringComparer.Ordinal)).ToList(), - PreparedOrdinaryBatches = new List(PreparedOrdinaryBatches), - OrdinaryUnknownBatches = new List(OrdinaryUnknownBatches), - LegacyPending = new Dictionary(LegacyPending, StringComparer.Ordinal), - ExplicitDeletes = new Dictionary(ExplicitDeletes, StringComparer.Ordinal), - Conflicts = new Dictionary(Conflicts, StringComparer.Ordinal), + OrdinaryBuffers = OrdinaryBuffers.Select(bucket => new Dictionary(bucket, StringComparer.Ordinal)).ToList(), + PreparedOrdinaryBatches = new List(PreparedOrdinaryBatches), + OrdinaryUnknownBatches = new List(OrdinaryUnknownBatches), + LegacyPending = new Dictionary(LegacyPending, StringComparer.Ordinal), + ExplicitDeletes = new Dictionary(ExplicitDeletes, StringComparer.Ordinal), + Conflicts = new Dictionary(Conflicts, StringComparer.Ordinal), Active = Active, Unknown = Unknown, - RemoteOnlyWrites = new Dictionary(RemoteOnlyWrites) + RemoteOnlyWrites = new Dictionary(RemoteOnlyWrites) }; } internal static bool ValidKey(string key) => !string.IsNullOrEmpty(key) && key.Length <= 128; - internal string Encode(KVIdentity identity) + internal string Encode(KvIdentity identity) { var conflicts = new JObject(); foreach (var pair in Conflicts) @@ -245,20 +304,20 @@ namespace game.PlayerDataStorage ["remoteOnlyWrites"] = ChangesJson(RemoteOnlyWrites.Values) }.ToString(Formatting.None); } - private static JArray ChangesJson(IEnumerable values) + private static JArray ChangesJson(IEnumerable values) { return new JArray(values.OrderBy(item => item.Sequence).Select(ChangeJson)); } - private static JObject ValueJson(PlayerKVValue value) + private static JObject ValueJson(KvValue value) { return new JObject { ["exists"] = value.Exists, ["value"] = value.Value }; } - private static JObject ChangeJson(PlayerKVChange item) + private static JObject ChangeJson(KvChange item) { return new JObject { ["key"] = item.Key, ["sequence"] = item.Sequence, ["base"] = ValueJson(item.BaseValue), ["target"] = ValueJson(item.Target) }; } - private static JToken BatchJson(PlayerKVBatch batch) + private static JToken BatchJson(KvBatch batch) { if (batch == null) { @@ -267,7 +326,7 @@ namespace game.PlayerDataStorage return new JObject { ["id"] = batch.Id, ["ordinary"] = batch.IsOrdinary, ["items"] = ChangesJson(batch.Items) }; } - internal static KVSnapshot Decode(string text, KVIdentity identity) + internal static KvSnapshot Decode(string text, KvIdentity identity) { JObject root; using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None }) @@ -283,7 +342,7 @@ namespace game.PlayerDataStorage { throw Invalid(); } - var result = new KVSnapshot + var result = new KvSnapshot { Revision = Integer(root, "revision"), NextSequence = Integer(root, "nextSequence"), HasBaseline = Boolean(root, "hasBaseline"), AuthenticationInvalid = Boolean(root, "authenticationInvalid"), @@ -350,13 +409,13 @@ namespace game.PlayerDataStorage result.Validate(); return result; } - private static Dictionary Changes(JToken array) + private static Dictionary Changes(JToken array) { if (array.Type != JTokenType.Array) { throw Invalid(); } - var values = new Dictionary(StringComparer.Ordinal); + var values = new Dictionary(StringComparer.Ordinal); foreach (var token in array) { var item = Change(token); @@ -368,9 +427,9 @@ namespace game.PlayerDataStorage } return values; } - private static List ReadBatches(JToken root, string name) + private static List ReadBatches(JToken root, string name) { - var result = new List(); + var result = new List(); foreach (var item in Field(root, name, JTokenType.Array)) { var wrapper = new JObject { ["batch"] = item.DeepClone() }; @@ -384,11 +443,11 @@ namespace game.PlayerDataStorage return result; } - private static void DecodeLegacy(JObject root, long version, KVSnapshot result) + private static void DecodeLegacy(JObject root, long version, KvSnapshot result) { var pending = Changes(Field(root, "pending", JTokenType.Array)); var localOnly = Strings(Field(root, "localOnly", JTokenType.Object)); - var overrides = new Dictionary(StringComparer.Ordinal); + var overrides = new Dictionary(StringComparer.Ordinal); if (version == 2) { foreach (var property in ((JObject)Field(root, "remoteLocalOverrides", JTokenType.Object)).Properties()) @@ -459,7 +518,7 @@ namespace game.PlayerDataStorage } } } - private static void ApplyValue(Dictionary values, string key, PlayerKVValue value) + private static void ApplyValue(Dictionary values, string key, KvValue value) { if (value.Exists) { @@ -483,8 +542,8 @@ namespace game.PlayerDataStorage { throw Invalid(); } - var sequences = new Dictionary(); - void Add(PlayerKVChange item, bool sourceReference = false) + var sequences = new Dictionary(); + void Add(KvChange item, bool sourceReference = false) { if (!ValidKey(item.Key) || item.Sequence < 1 || item.Sequence >= NextSequence) { @@ -537,49 +596,70 @@ namespace game.PlayerDataStorage throw Invalid(); } } - static Dictionary Strings(JToken value) + private 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(); + 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) + private static KvChange 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))); + if (!ValidKey(key)) + { + throw Invalid(); + } + return new KvChange(key, Integer(item, "sequence"), Value(Field(item, "base", JTokenType.Object)), Value(Field(item, "target", JTokenType.Object))); } - static PlayerKVValue Value(JToken item) + private static KvValue 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; + return exists ? KvValue.Present((string)value) : KvValue.Missing; } - static PlayerKVBatch Batch(JToken parent, string name, bool withKind = false) + private static KvBatch Batch(JToken parent, string name, bool withKind = false) { var node = parent[name]; - if (node == null) throw Invalid(); - if (node.Type == JTokenType.Null) return null; + 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(); + 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, withKind && Boolean(node, "ordinary")); + !items.Select(i => i.Sequence).SequenceEqual(items.Select(i => i.Sequence).OrderBy(s => s))) + { + throw Invalid(); + } + return new KvBatch(id, items, withKind && Boolean(node, "ordinary")); } - static JToken Field(JToken parent, string name, JTokenType type) + private static JToken Field(JToken parent, string name, JTokenType type) { - if (!(parent is JObject obj) || obj[name] == null || obj[name].Type != type) throw Invalid(); + 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); + private static string String(JToken parent, string name) => (string)Field(parent, name, JTokenType.String); + private static long Integer(JToken parent, string name) => (long)Field(parent, name, JTokenType.Integer); + private 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/Core/KV/KvSyncModels.cs.meta similarity index 100% rename from Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs.meta rename to Client/Assets/Scripts/Core/KV/KvSyncModels.cs.meta diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs b/Client/Assets/Scripts/Core/KV/KvSyncState.cs similarity index 60% rename from Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs rename to Client/Assets/Scripts/Core/KV/KvSyncState.cs index 3586dbc00..0faccf5d5 100644 --- a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs +++ b/Client/Assets/Scripts/Core/KV/KvSyncState.cs @@ -4,23 +4,23 @@ using System.Collections.ObjectModel; using System.IO; using System.Linq; -namespace game.PlayerDataStorage +namespace Core.KV { /// /// 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 + public sealed class KvSyncState : IDisposable { private readonly object gate = new object(); - private readonly PlayerKVCacheStore store; - private KVSnapshot snapshot; - private PlayerKVSyncStatus status; + private readonly KvCacheStore store; + private KvSnapshot snapshot; + private KVSyncStatus status; private readonly HashSet recoveredImmediate; private bool recoverImmediateView = true; private bool recoveredOrdinaryUnknown; - private KVSnapshot comparisonLocal; + private KvSnapshot comparisonLocal; private Dictionary comparisonRemote; private Dictionary comparisonTimes; private Dictionary comparisonView; @@ -45,13 +45,19 @@ namespace game.PlayerDataStorage } } } - public PlayerKVSyncStatus Status { get { lock (gate) return status; } } - public string ApiBaseUri { get; } - public string PlayerId { get; } - - PlayerKVSyncState(KVIdentity identity, PlayerKVCacheStore store, KVSnapshot snapshot) + public KVSyncStatus Status { - ApiBaseUri = identity.Api; PlayerId = identity.PlayerId; this.store = store; this.snapshot = snapshot; + get + { + lock (gate) + return status; + } + } + + private KvSyncState(KvCacheStore store, KvSnapshot snapshot) + { + this.store = store; + this.snapshot = snapshot; recoveredImmediate = new HashSet(snapshot.RemoteOnlyWrites.Keys); // Authentication belongs to the old owner. A new owner must obtain a fresh // successful Get before Ready; this never clears a persisted unknown batch. @@ -71,39 +77,46 @@ namespace game.PlayerDataStorage } recoveredOrdinaryUnknown = snapshot.OrdinaryUnknownBatches.Count != 0; status = snapshot.Unknown != null || recoveredOrdinaryUnknown - ? PlayerKVSyncStatus.UnknownOutcome : PlayerKVSyncStatus.NotLoaded; + ? KVSyncStatus.UnknownOutcome : KVSyncStatus.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) + public static KvOpenResult Open(string root, Uri api, string playerId) { - KVIdentity identity; - try { identity = new KVIdentity(root, api, playerId); } + 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); + { + return new KvOpenResult(KVOpenError.Validation); + } + var error = KvCacheStore.Open(identity, out var store, out var snapshot); + return error == KVOpenError.None ? new KvOpenResult(error, new KvSyncState(store, snapshot)) : new KvOpenResult(error); } - public PlayerKVDiagnosticSnapshot Inspect() { lock (gate) return new PlayerKVDiagnosticSnapshot(snapshot); } + public KvDiagnosticSnapshot Inspect() + { + lock (gate) + return new KvDiagnosticSnapshot(snapshot); + } public IReadOnlyDictionary ReadLocal() { lock (gate) { - if (status != PlayerKVSyncStatus.Ready) throw new InvalidOperationException("KV data is not ready."); + if (status != KVSyncStatus.Ready) + { + throw new InvalidOperationException("KV data is not ready."); + } return new ReadOnlyDictionary(ReadView()); } } // The caller must supply a successful complete Get. Failed reads must use RecordReadFailure. - public PlayerKVStateResult Reconcile(IReadOnlyDictionary remote) - { - return Reconcile(remote, null, false); - } - internal PlayerKVStateResult Reconcile(IReadOnlyDictionary remote, - IReadOnlyDictionary updatedAtUtc, bool prepareOriginalComparison) + internal KvStateResult Reconcile(IReadOnlyDictionary remote, + IReadOnlyDictionary updatedAtUtc) { lock (gate) { @@ -111,55 +124,42 @@ namespace game.PlayerDataStorage { return Blocked(); } - if (remote == null || remote.Any(pair => !KVSnapshot.ValidKey(pair.Key) || pair.Value == null)) + if (remote == null || remote.Any(pair => !KvSnapshot.ValidKey(pair.Key) || pair.Value == null)) { return Invalid(); } - if (prepareOriginalComparison && (!recoverImmediateView || updatedAtUtc == null || + if (!recoverImmediateView || updatedAtUtc == null || updatedAtUtc.Count != remote.Count || remote.Keys.Any(key => !updatedAtUtc.ContainsKey(key)) || - updatedAtUtc.Values.Any(time => time.Kind != DateTimeKind.Utc))) + updatedAtUtc.Values.Any(time => time.Kind != DateTimeKind.Utc)) { return Invalid(); } var original = snapshot; var copy = snapshot.Clone(); copy.Conflicts.Clear(); - if (copy.Unknown != null || (recoveredOrdinaryUnknown && !prepareOriginalComparison)) + if (copy.Unknown != null) { foreach (var item in copy.Unsent()) { - copy.Conflicts[item.Key] = PlayerKVValue.At(remote, item.Key); + copy.Conflicts[item.Key] = KvValue.At(remote, item.Key); } - return Commit(copy, PlayerKVSyncStatus.UnknownOutcome); + return Commit(copy, KVSyncStatus.UnknownOutcome); } - var recoveringKeys = recoverImmediateView - ? new HashSet(copy.ImmediatePreservedKeys, StringComparer.Ordinal) - : new HashSet(StringComparer.Ordinal); - if (recoverImmediateView) + var recoveringKeys = new HashSet(copy.ImmediatePreservedKeys, StringComparer.Ordinal); + foreach (var sequence in recoveredImmediate) { - foreach (var sequence in recoveredImmediate) - { - var source = copy.RemoteOnlyWrites[sequence]; - Rebase(copy, source.Key, PlayerKVValue.At(remote, source.Key)); - if (!prepareOriginalComparison) - { - copy.RemoteOnlyWrites.Remove(sequence); - } - } - if (!prepareOriginalComparison) - { - copy.ImmediatePreservedKeys.Clear(); - } + var source = copy.RemoteOnlyWrites[sequence]; + Rebase(copy, source.Key, KvValue.At(remote, source.Key)); } foreach (var source in Sources(copy)) { - if (prepareOriginalComparison && copy.OrdinaryBuffers.Contains(source)) + if (copy.OrdinaryBuffers.Contains(source)) { continue; } foreach (var item in source.Values.ToArray()) { - var observed = PlayerKVValue.At(remote, item.Key); + var observed = KvValue.At(remote, item.Key); if (observed.Equals(item.Target)) { source.Remove(item.Key); @@ -172,7 +172,7 @@ namespace game.PlayerDataStorage } foreach (var item in copy.PreparedOrdinaryBatches.SelectMany(batch => batch.Items)) { - var observed = PlayerKVValue.At(remote, item.Key); + var observed = KvValue.At(remote, item.Key); if (!observed.Equals(item.Target) && !observed.Equals(item.BaseValue)) { copy.Conflicts[item.Key] = observed; @@ -183,13 +183,13 @@ namespace game.PlayerDataStorage { if (pair.Value.LocalOnly) { - if (recoverImmediateView && copy.LegacyLocalOnlyUnderImmediate.TryGetValue(pair.Key, out var lower)) + if (copy.LegacyLocalOnlyUnderImmediate.TryGetValue(pair.Key, out var lower)) { visible[pair.Key] = lower; } else { - Apply(visible, pair.Key, PlayerKVValue.At(copy.LocalData, pair.Key)); + Apply(visible, pair.Key, KvValue.At(copy.LocalData, pair.Key)); } } else @@ -197,14 +197,10 @@ namespace game.PlayerDataStorage var source = copy.Unsent().Concat(copy.PreparedOrdinaryBatches.SelectMany(batch => batch.Items)) .Concat(copy.OrdinaryUnknownBatches.SelectMany(batch => batch.Items)) .FirstOrDefault(item => item.Sequence == pair.Value.Sequence); - if (!recoverImmediateView) - { - Apply(visible, pair.Key, PlayerKVValue.At(copy.LocalData, pair.Key)); - } - else if (source != null) + if (source != null) { Apply(visible, pair.Key, recoveringKeys.Contains(pair.Key) - ? source.Target : PlayerKVValue.At(copy.LocalData, pair.Key)); + ? source.Target : KvValue.At(copy.LocalData, pair.Key)); } else { @@ -212,41 +208,17 @@ namespace game.PlayerDataStorage } } } - // An immediate write preserves its view until a new owner performs its initial Get. - foreach (var key in copy.ImmediatePreservedKeys) - { - if (prepareOriginalComparison && recoveringKeys.Contains(key)) - { - continue; - } - Apply(visible, key, PlayerKVValue.At(copy.LocalData, key)); - } - if (recoverImmediateView && !prepareOriginalComparison) - { - copy.LegacyLocalOnlyUnderImmediate.Clear(); - } - if (!prepareOriginalComparison) - { - copy.LocalData = visible; - } copy.Baseline = remote.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); copy.HasBaseline = true; - var reconciled = Commit(copy, copy.Conflicts.Count == 0 ? PlayerKVSyncStatus.Ready : PlayerKVSyncStatus.Conflict); + var reconciled = Commit(copy, copy.Conflicts.Count == 0 ? KVSyncStatus.Ready : KVSyncStatus.Conflict); if (reconciled.Success) { recoverImmediateView = false; - if (prepareOriginalComparison) - { - comparisonLocal = original; - comparisonRemote = remote.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); - comparisonTimes = updatedAtUtc.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); - comparisonView = visible; - comparisonImmediateKeys = new HashSet(StringComparer.Ordinal); - } - else - { - recoveredImmediate.Clear(); - } + comparisonLocal = original; + comparisonRemote = remote.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + comparisonTimes = updatedAtUtc.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + comparisonView = visible; + comparisonImmediateKeys = new HashSet(StringComparer.Ordinal); } return reconciled; } @@ -263,17 +235,17 @@ namespace game.PlayerDataStorage { if (pair.Value.Sequence >= comparisonLocal.NextSequence) { - Apply(view, pair.Key, PlayerKVValue.At(snapshot.LocalData, pair.Key)); + Apply(view, pair.Key, KvValue.At(snapshot.LocalData, pair.Key)); } } return view; } - internal PlayerKVStateResult CompareDatas(long nowTicks) + internal KvStateResult CompareDatas(long nowTicks) { lock (gate) { - if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid || snapshot.Active != null || + if (status != KVSyncStatus.Ready || snapshot.AuthenticationInvalid || snapshot.Active != null || snapshot.Unknown != null || comparisonLocal == null) { return Blocked(); @@ -314,7 +286,7 @@ namespace game.PlayerDataStorage var visible = ReadView(); foreach (var key in protectedKeys) { - Apply(copy.LocalData, key, PlayerKVValue.At(visible, key)); + Apply(copy.LocalData, key, KvValue.At(visible, key)); } foreach (var key in comparisonLocal.LocalData.Keys.Concat(comparisonRemote.Keys).Distinct(StringComparer.Ordinal)) { @@ -373,7 +345,7 @@ namespace game.PlayerDataStorage copy.ImmediatePreservedKeys.Remove(key); } } - var result = Commit(copy, PlayerKVSyncStatus.Ready); + var result = Commit(copy, KVSyncStatus.Ready); if (result.Success) { recoveredOrdinaryUnknown = false; @@ -393,59 +365,51 @@ namespace game.PlayerDataStorage comparisonImmediateKeys = null; } - public PlayerKVStateResult RecordReadFailure(bool unauthorized = false) + public KvStateResult 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) - { - return Update(key, value, remoteStore, null); - } - - internal PlayerKVStateResult Update(string key, string value, bool remoteStore, long? updateTicks) - { - lock (gate) - { - if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid) + if (Terminal || snapshot.Active != null) { return Blocked(); } - if (!KVSnapshot.ValidKey(key) || value == null || snapshot.NextSequence == long.MaxValue || - (updateTicks.HasValue && (updateTicks.Value < 0 || updateTicks.Value > DateTime.MaxValue.Ticks))) + if (unauthorized) + { + var copy = snapshot.Clone(); + copy.AuthenticationInvalid = true; + return Commit(copy, copy.Unknown == null ? KVSyncStatus.Unauthorized : KVSyncStatus.UnknownOutcome); + } + status = snapshot.Unknown != null ? KVSyncStatus.UnknownOutcome : KVSyncStatus.ReadFailed; + return new KvStateResult(KVChangeError.None); + } + } + + internal KvStateResult Update(string key, string value, bool remoteStore, long updateTicks) + { + lock (gate) + { + if (status != KVSyncStatus.Ready || snapshot.AuthenticationInvalid) + { + return Blocked(); + } + if (!KvSnapshot.ValidKey(key) || value == null || snapshot.NextSequence == long.MaxValue || + (updateTicks < 0 || updateTicks > DateTime.MaxValue.Ticks)) { return Invalid(); } var copy = snapshot.Clone(); var sequence = ApplyUpdate(copy, key, value, remoteStore, updateTicks); - return Commit(copy, PlayerKVSyncStatus.Ready, sequence); + return Commit(copy, KVSyncStatus.Ready, sequence); } } - private static long ApplyUpdate(KVSnapshot copy, string key, string value, bool remoteStore, long? updateTicks) + private static long ApplyUpdate(KvSnapshot copy, string key, string value, bool remoteStore, long updateTicks) { var sequence = copy.NextSequence++; copy.LocalData[key] = value; copy.ImmediatePreservedKeys.Remove(key); copy.LegacyLocalOnlyUnderImmediate.Remove(key); - if (updateTicks.HasValue) - { - copy.LocalUpdateTicks[key] = updateTicks.Value; - } - else - { - copy.LocalUpdateTicks.Remove(key); - } + copy.LocalUpdateTicks[key] = updateTicks; var acceptedRemote = false; if (remoteStore) { @@ -456,8 +420,8 @@ namespace game.PlayerDataStorage continue; } var baseline = bucket.TryGetValue(key, out var previous) - ? previous.BaseValue : PlayerKVValue.At(copy.Baseline, key); - bucket[key] = new PlayerKVChange(key, sequence, baseline, PlayerKVValue.Present(value)); + ? previous.BaseValue : KvValue.At(copy.Baseline, key); + bucket[key] = new KvChange(key, sequence, baseline, KvValue.Present(value)); copy.LegacyPending.Remove(key); copy.ExplicitDeletes.Remove(key); acceptedRemote = true; @@ -468,52 +432,26 @@ namespace game.PlayerDataStorage return sequence; } - public PlayerKVStateResult Delete(string key) - { - lock (gate) - { - if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid) - { - return Blocked(); - } - if (!KVSnapshot.ValidKey(key) || snapshot.NextSequence == long.MaxValue) - { - return Invalid(); - } - var copy = snapshot.Clone(); - var sequence = copy.NextSequence++; - var prior = copy.ExplicitDeletes.TryGetValue(key, out var previous) ? previous.BaseValue : PlayerKVValue.At(copy.Baseline, key); - RemoveSources(copy, key); - copy.ExplicitDeletes[key] = new PlayerKVChange(key, sequence, prior, PlayerKVValue.Missing); - copy.LocalData.Remove(key); - copy.LocalUpdateTicks.Remove(key); - copy.LocalOrigins[key] = (false, sequence); - copy.ImmediatePreservedKeys.Remove(key); - copy.LegacyLocalOnlyUnderImmediate.Remove(key); - return Commit(copy, PlayerKVSyncStatus.Ready, sequence); - } - } - - internal PlayerKVStateResult CaptureOrdinaryBatch(out PlayerKVBatch batch) + internal KvStateResult CaptureOrdinaryBatch(out KvBatch batch) { lock (gate) { batch = null; - if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid) + if (status != KVSyncStatus.Ready || snapshot.AuthenticationInvalid) { return Blocked(); } if (snapshot.OrdinaryBuffers[0].Count == 0) { - return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision); + return new KvStateResult(KVChangeError.None); } var copy = snapshot.Clone(); var first = copy.OrdinaryBuffers[0]; - var captured = new PlayerKVBatch(Guid.NewGuid().ToString("N"), first.Values.OrderBy(item => item.Sequence), true); + var captured = new KvBatch(Guid.NewGuid().ToString("N"), first.Values.OrderBy(item => item.Sequence), true); copy.PreparedOrdinaryBatches.Add(captured); copy.OrdinaryBuffers.RemoveAt(0); - copy.OrdinaryBuffers.Add(new Dictionary(StringComparer.Ordinal)); - var result = Commit(copy, PlayerKVSyncStatus.Ready); + copy.OrdinaryBuffers.Add(new Dictionary(StringComparer.Ordinal)); + var result = Commit(copy, KVSyncStatus.Ready); if (result.Success) { batch = captured; @@ -522,23 +460,23 @@ namespace game.PlayerDataStorage } } - internal PlayerKVStateResult BeginOrdinaryBatch(out PlayerKVBatch batch) + internal KvStateResult BeginOrdinaryBatch(out KvBatch batch) { lock (gate) { batch = null; - if (status != PlayerKVSyncStatus.Ready || snapshot.Active != null || snapshot.Unknown != null || snapshot.AuthenticationInvalid) + if (status != KVSyncStatus.Ready || snapshot.Active != null || snapshot.Unknown != null || snapshot.AuthenticationInvalid) { return Blocked(); } if (snapshot.PreparedOrdinaryBatches.Count == 0) { - return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision); + return new KvStateResult(KVChangeError.None); } var copy = snapshot.Clone(); copy.Active = copy.PreparedOrdinaryBatches[0]; copy.PreparedOrdinaryBatches.RemoveAt(0); - var result = Commit(copy, PlayerKVSyncStatus.Ready); + var result = Commit(copy, KVSyncStatus.Ready); if (result.Success) { batch = snapshot.Active; @@ -548,17 +486,13 @@ namespace game.PlayerDataStorage } // Explicit requests retain their original source until the outcome is known. Ordinary buckets are never selected here. - public PlayerKVStateResult BeginBatch(long throughSequence, out PlayerKVBatch batch) - { - return BeginBatch(throughSequence, false, out batch); - } - internal PlayerKVStateResult BeginBatch(long throughSequence, bool legacyOnly, out PlayerKVBatch batch) + internal KvStateResult BeginBatch(long throughSequence, out KvBatch batch) { lock (gate) { batch = null; - if (status != PlayerKVSyncStatus.Ready || snapshot.Active != null || snapshot.Unknown != null || snapshot.AuthenticationInvalid) + if (status != KVSyncStatus.Ready || snapshot.Active != null || snapshot.Unknown != null || snapshot.AuthenticationInvalid) { return Blocked(); } @@ -566,15 +500,15 @@ namespace game.PlayerDataStorage { return Invalid(); } - var source = legacyOnly ? snapshot.LegacyPending.Values : snapshot.LegacyPending.Values.Concat(snapshot.ExplicitDeletes.Values); + var source = snapshot.LegacyPending.Values; var ordered = source.Where(item => item.Sequence <= throughSequence).OrderBy(item => item.Sequence).ToArray(); if (ordered.Length == 0) { - return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision); + return new KvStateResult(KVChangeError.None); } var copy = snapshot.Clone(); - copy.Active = new PlayerKVBatch(Guid.NewGuid().ToString("N"), ordered.TakeWhile(item => item.Target.Exists == ordered[0].Target.Exists).Take(10)); - var result = Commit(copy, PlayerKVSyncStatus.Ready); + copy.Active = new KvBatch(Guid.NewGuid().ToString("N"), ordered.TakeWhile(item => item.Target.Exists == ordered[0].Target.Exists).Take(10)); + var result = Commit(copy, KVSyncStatus.Ready); if (result.Success) { batch = snapshot.Active; @@ -583,11 +517,11 @@ namespace game.PlayerDataStorage } } - public PlayerKVStateResult ConfirmBatch(string batchId) + public KvStateResult ConfirmBatch(string batchId) { lock (gate) { - if (status != PlayerKVSyncStatus.Ready || snapshot.Active == null || snapshot.Active.Id != batchId) + if (status != KVSyncStatus.Ready || snapshot.Active == null || snapshot.Active.Id != batchId) { return Blocked(); } @@ -608,12 +542,12 @@ namespace game.PlayerDataStorage Rebase(copy, item.Key, item.Target); } copy.Active = null; - return Commit(copy, PlayerKVSyncStatus.Ready); + return Commit(copy, KVSyncStatus.Ready); } } // A definitely undispatched ordinary batch retires, as the original SDK call did; explicit source references stay. - public PlayerKVStateResult FailBatch(string batchId, bool mayHaveCommitted, bool unauthorized = false) + public KvStateResult FailBatch(string batchId, bool mayHaveCommitted, bool unauthorized = false) { lock (gate) { @@ -636,27 +570,27 @@ namespace game.PlayerDataStorage } } copy.Active = null; - var next = copy.AuthenticationInvalid ? PlayerKVSyncStatus.Unauthorized : ordinary ? PlayerKVSyncStatus.Ready : PlayerKVSyncStatus.ReadFailed; + var next = copy.AuthenticationInvalid ? KVSyncStatus.Unauthorized : ordinary ? KVSyncStatus.Ready : KVSyncStatus.ReadFailed; if (copy.Unknown != null) { - next = PlayerKVSyncStatus.UnknownOutcome; + next = KVSyncStatus.UnknownOutcome; } return Commit(copy, next); } } - private static IEnumerable> Sources(KVSnapshot value) + private static IEnumerable> Sources(KvSnapshot value) { return value.OrdinaryBuffers.Concat(new[] { value.LegacyPending, value.ExplicitDeletes }); } - private static void RemoveSources(KVSnapshot value, string key) + private static void RemoveSources(KvSnapshot value, string key) { foreach (var source in Sources(value)) { source.Remove(key); } } - private static void Rebase(KVSnapshot value, string key, PlayerKVValue baseline) + private static void Rebase(KvSnapshot value, string key, KvValue baseline) { foreach (var source in Sources(value)) { @@ -667,52 +601,33 @@ namespace game.PlayerDataStorage } } - internal PlayerKVStateResult PrepareImmediate(string key, string value, out PlayerKVChange operation) - { - operation = null; - if (!KVSnapshot.ValidKey(key) || value == null) - { - // Preserve the single-key state admission order and validation result. - lock (gate) - { - return status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid ? Blocked() : Invalid(); - } - } - var result = PrepareImmediate(new Dictionary { [key] = value }, out var operations); - if (result.Success) - { - operation = operations[0]; - } - return result; - } - - internal PlayerKVStateResult PrepareImmediate(IReadOnlyDictionary data, - out IReadOnlyList operations) + internal KvStateResult PrepareImmediate(IReadOnlyDictionary data, + out IReadOnlyList operations) { lock (gate) { operations = null; - if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid) + if (status != KVSyncStatus.Ready || snapshot.AuthenticationInvalid) { return Blocked(); } if (data == null || data.Count == 0 || data.Count > long.MaxValue - snapshot.NextSequence || - data.Any(pair => !KVSnapshot.ValidKey(pair.Key) || pair.Value == null)) + data.Any(pair => !KvSnapshot.ValidKey(pair.Key) || pair.Value == null)) { return Invalid(); } var copy = snapshot.Clone(); - var prepared = new List(); + var prepared = new List(); foreach (var pair in data) { copy.ImmediatePreservedKeys.Add(pair.Key); - var operation = new PlayerKVChange(pair.Key, copy.NextSequence++, - PlayerKVValue.At(copy.Baseline, pair.Key), PlayerKVValue.Present(pair.Value)); + var operation = new KvChange(pair.Key, copy.NextSequence++, + KvValue.At(copy.Baseline, pair.Key), KvValue.Present(pair.Value)); copy.RemoteOnlyWrites.Add(operation.Sequence, operation); RemoveSources(copy, pair.Key); prepared.Add(operation); } - var result = Commit(copy, PlayerKVSyncStatus.Ready, prepared[0].Sequence); + var result = Commit(copy, KVSyncStatus.Ready, prepared[0].Sequence); if (result.Success) { operations = prepared.AsReadOnly(); @@ -725,16 +640,11 @@ namespace game.PlayerDataStorage } } - internal PlayerKVStateResult CompleteImmediate(PlayerKVChange operation) - { - return CompleteImmediate(new[] { operation }); - } - - internal PlayerKVStateResult CompleteImmediate(IReadOnlyList operations) + internal KvStateResult CompleteImmediate(IReadOnlyList operations) { lock (gate) { - if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid || snapshot.Active != null || + if (status != KVSyncStatus.Ready || snapshot.AuthenticationInvalid || snapshot.Active != null || snapshot.Unknown != null || operations.Any(operation => !snapshot.RemoteOnlyWrites.ContainsKey(operation.Sequence))) { return Blocked(); @@ -747,16 +657,11 @@ namespace game.PlayerDataStorage copy.RemoteOnlyWrites.Remove(operation.Sequence); copy.ImmediatePreservedKeys.Add(operation.Key); } - return Commit(copy, PlayerKVSyncStatus.Ready); + return Commit(copy, KVSyncStatus.Ready); } } - internal void CancelUndispatchedImmediate(PlayerKVChange operation) - { - CancelUndispatchedImmediate(new[] { operation }); - } - - internal void CancelUndispatchedImmediate(IReadOnlyList operations) + internal void CancelUndispatchedImmediate(IReadOnlyList operations) { lock (gate) { @@ -780,36 +685,52 @@ namespace game.PlayerDataStorage } } - private static Dictionary LocalView(KVSnapshot source) + private static Dictionary LocalView(KvSnapshot source) { return new Dictionary(source.LocalData, StringComparer.Ordinal); } - bool Terminal => status == PlayerKVSyncStatus.Closed || status == PlayerKVSyncStatus.StorageError; - PlayerKVStateResult Commit(KVSnapshot copy, PlayerKVSyncStatus nextStatus, long sequence = 0) + private bool Terminal => status == KVSyncStatus.Closed || status == KVSyncStatus.StorageError; + private KvStateResult Commit(KvSnapshot copy, KVSyncStatus nextStatus, long sequence = 0) { if (snapshot.Revision == long.MaxValue) - { status = PlayerKVSyncStatus.StorageError; return new PlayerKVStateResult(PlayerKVChangeError.StorageError, sequence, snapshot.Revision); } + { + status = KVSyncStatus.StorageError; + return new KvStateResult(KVChangeError.StorageError, sequence); + } copy.Revision = snapshot.Revision + 1; if (!store.Save(copy, out var uncertain)) { - status = PlayerKVSyncStatus.StorageError; - return new PlayerKVStateResult(PlayerKVChangeError.StorageError, sequence, copy.Revision, uncertain); + status = KVSyncStatus.StorageError; + return new KvStateResult(KVChangeError.StorageError, sequence, uncertain); } - snapshot = copy; status = nextStatus; - return new PlayerKVStateResult(PlayerKVChangeError.None, sequence, copy.Revision); + snapshot = copy; + status = nextStatus; + return new KvStateResult(KVChangeError.None, sequence); } - 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); + private static void Apply(Dictionary data, string key, KvValue value) + { + if (value.Exists) + { + data[key] = value.Value; + } + else + { + data.Remove(key); + } + } + private KvStateResult Blocked() => new KvStateResult(KVChangeError.Blocked); + private KvStateResult Invalid() => new KvStateResult(KVChangeError.Validation); public void Dispose() { lock (gate) { - if (status == PlayerKVSyncStatus.Closed) return; - status = PlayerKVSyncStatus.Closed; + if (status == KVSyncStatus.Closed) + { + return; + } + status = KVSyncStatus.Closed; ClearComparison(); store.Dispose(); } diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs.meta b/Client/Assets/Scripts/Core/KV/KvSyncState.cs.meta similarity index 100% rename from Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs.meta rename to Client/Assets/Scripts/Core/KV/KvSyncState.cs.meta diff --git a/Client/Assets/Scripts/Core/KVMgr.cs b/Client/Assets/Scripts/Core/KVMgr.cs deleted file mode 100644 index f7dffb602..000000000 --- a/Client/Assets/Scripts/Core/KVMgr.cs +++ /dev/null @@ -1,231 +0,0 @@ -using System; -using UnityEngine; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using game.PlayerDataStorage; - -internal class KVMgr : MonoBehaviour -{ - private static KVMgr instance; - private KVPlayerSaveStore store; - private CancellationTokenSource tokenSource; - - private void Awake() - { - instance = this; - tokenSource = new CancellationTokenSource(); - UpdateUserDataValue(tokenSource.Token); - } - - internal static void Bind(KVPlayerSaveStore original) - { - if (instance != null) - { - instance.store = original; - } - } - - internal static void Unbind(KVPlayerSaveStore original) - { - if (instance != null && ReferenceEquals(instance.store, original)) - { - instance.store = null; - } - } - - internal static void Stop(KVPlayerSaveStore original) - { - if (instance != null && ReferenceEquals(instance.store, original)) - { - instance.Stop(); - } - } - - private void Stop() - { - tokenSource?.Cancel(); - tokenSource?.Dispose(); - tokenSource = null; - } - - private async void UpdateUserDataValue(CancellationToken token) - { - try - { - while (!token.IsCancellationRequested) - { - SyncData(); - await Task.Delay(40000); - } - } - catch (Exception ex) - { - Debug.LogError("KV save schedule failed: " + ex.GetType().Name); - } - } - - private void SyncData() - { - var original = store; - if (original != null && original.IsCurrent) - { - ObserveSync(original.SyncDataAsync()); - } - } - - private static async void ObserveSync(Task task) - { - try - { - var result = await task; - if (result.TransportError != PlayerKVError.None || result.Blocked != 0 || result.Unknown != 0) - { - Debug.LogError("KV ordinary save failed: " + result.TransportError); - } - } - catch (Exception ex) - { - Debug.LogError("KV ordinary save failed: " + ex.GetType().Name); - } - } - - private void OnApplicationPause(bool paused) - { - if (paused) - { - Stop(); - SyncData(); - } - else - { - tokenSource = new CancellationTokenSource(); - UpdateUserDataValue(tokenSource.Token); - } - } - - private void OnApplicationQuit() - { - Stop(); - } - - private void OnDestroy() - { - Stop(); - SyncData(); - store = null; - if (ReferenceEquals(instance, this)) - { - instance = null; - } - } - - internal static Task SyncKVAsync(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.SyncDataAsync(waitToken); - } - - 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, ZZTimeHelper.UtcNow().Ticks); - } - - internal static async Task SyncDataImmidiate(PlayerKVGameSessionHandle handle, string key, string value) - { - var success = false; - if (!PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Immediate) && handle != null && - KVSnapshot.ValidKey(key) && value != null) - { - success = await handle.SyncDataImmidiate(key, value); - } - if (!success) - { - UnityEngine.Debug.LogError("[KVMgr::SyncDataImmidiate] Save failed."); - } - return success; - } - - internal static async Task SyncDataImmidiate(PlayerKVGameSessionHandle handle, - IReadOnlyDictionary values) - { - var success = false; - if (!PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Immediate) && handle != null && values != null && values.Count > 0) - { - success = await handle.SyncDataImmidiate(values); - } - if (!success) - { - UnityEngine.Debug.LogError("[KVMgr::SyncDataImmidiate] Save failed."); - } - return success; - } - - 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); - } -} diff --git a/Client/Assets/Scripts/Core/KvMgr.cs b/Client/Assets/Scripts/Core/KvMgr.cs new file mode 100644 index 000000000..bd6ea01b4 --- /dev/null +++ b/Client/Assets/Scripts/Core/KvMgr.cs @@ -0,0 +1,329 @@ +using Core.KV; +using game.KV; +using System; +using System.Linq; +using UnityEngine; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +internal class KvMgr : MonoBehaviour +{ + private static KvMgr instance; + private KvSaveStore store; + private CancellationTokenSource tokenSource; + + private void Awake() + { + instance = this; + tokenSource = new CancellationTokenSource(); + UpdateUserDataValue(tokenSource.Token); + } + + internal static void Bind(KvSaveStore original) + { + if (instance != null) + { + instance.store = original; + } + } + + internal static void Unbind(KvSaveStore original) + { + if (instance != null && ReferenceEquals(instance.store, original)) + { + instance.store = null; + } + } + + internal static void Stop(KvSaveStore original) + { + if (instance != null && ReferenceEquals(instance.store, original)) + { + instance.Stop(); + } + } + + private void Stop() + { + tokenSource?.Cancel(); + tokenSource?.Dispose(); + tokenSource = null; + } + + private async void UpdateUserDataValue(CancellationToken token) + { + try + { + while (!token.IsCancellationRequested) + { + SyncData(); + await Task.Delay(40000); + } + } + catch (Exception ex) + { + Debug.LogError("KV save schedule failed: " + ex.GetType().Name); + } + } + + private void SyncData() + { + var original = store; + if (original != null && original.IsCurrent) + { + ObserveSync(original.SyncDataAsync()); + } + } + + private static async void ObserveSync(Task task) + { + try + { + var result = await task; + if (result.TransportError != KVError.None || result.Blocked != 0 || result.Unknown != 0) + { + Debug.LogError("KV ordinary save failed: " + result.TransportError); + } + } + catch (Exception ex) + { + Debug.LogError("KV ordinary save failed: " + ex.GetType().Name); + } + } + + private void OnApplicationPause(bool paused) + { + if (paused) + { + Stop(); + SyncData(); + } + else + { + tokenSource = new CancellationTokenSource(); + UpdateUserDataValue(tokenSource.Token); + } + } + + private void OnApplicationQuit() + { + Stop(); + } + + private void OnDestroy() + { + Stop(); + SyncData(); + store = null; + if (ReferenceEquals(instance, this)) + { + instance = null; + } + } + +} + +namespace game.KV +{ + public sealed class KvSaveStore : IPlayerSaveStore, IDisposable + { + private KvSessionOwner handle; + private CancellationToken lifetime; + private bool bound, disposed; + private readonly object receiptsGate = new object(); + public bool IsCurrent => bound && !disposed && !lifetime.IsCancellationRequested && handle.Status != KVSyncStatus.Closed; + + internal void Bind(KvSessionOwner original, CancellationToken loginLifetime) + { + if (bound || disposed || original == null || loginLifetime.IsCancellationRequested) + { + throw new InvalidOperationException("Player save store cannot be rebound."); + } + if (!ReadKV(original, "LastLoginTimeUtc").Success) + { + throw new InvalidOperationException("Player save handle is not ready."); + } + handle = original; + lifetime = loginLifetime; + bound = true; + } + + internal void ReleaseFailedBinding(KvSessionOwner original) + { + lock (receiptsGate) + { + if (!bound) + { + return; + } + if (disposed || !ReferenceEquals(handle, original) || original.Status != KVSyncStatus.Closed) + { + throw new InvalidOperationException("Failed save binding has not closed."); + } + handle = null; + bound = false; + } + } + + public PlayerSaveReadResult Read(string key) + { + if (!IsCurrent) + { + return new PlayerSaveReadResult("StaleOrNotReady"); + } + return ReadKV(handle, key); + } + + public PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true) + { + lock (receiptsGate) + { + if (!IsCurrent) + { + var rejected = new PlayerSaveWriteResult("StaleOrNotReady"); + return rejected; + } + var result = WriteKV(handle, key, value, remoteStore); + var write = result.Success ? new PlayerSaveWriteResult() : + new PlayerSaveWriteResult(result.Error.ToString()); + return write; + } + } + + public async Task SyncDataImmidiate(string key, string value) + { + KvSessionOwner original; + CancellationToken originalLifetime; + Task save; + lock (receiptsGate) + { + if (!IsCurrent) + { + return false; + } + // Preserve the former current-store dictionary lookup's null-key behavior. + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + original = handle; + originalLifetime = lifetime; + save = SyncDataImmidiate(original, key, value); + } + var success = await save; + lock (receiptsGate) + { + return success && IsCurrent && !originalLifetime.IsCancellationRequested && ReferenceEquals(handle, original); + } + } + + internal async Task SyncDataImmidiate(IReadOnlyDictionary values) + { + KvSessionOwner original; + CancellationToken originalLifetime; + Task save; + lock (receiptsGate) + { + if (!IsCurrent || values == null || values.Count == 0) + { + return false; + } + var data = values.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + original = handle; + originalLifetime = lifetime; + save = SyncDataImmidiate(original, data); + } + var success = await save; + lock (receiptsGate) + { + return success && IsCurrent && !originalLifetime.IsCancellationRequested && ReferenceEquals(handle, original); + } + } + + internal Task SyncDataAsync() + { + lock (receiptsGate) + { + var original = handle; + if (!IsCurrent) + { + return Task.FromResult(new KvFlushResult(1, 0, KVError.Cancelled)); + } + if (original == null) + { + return Task.FromResult(new KvFlushResult(1, 0, KVError.Validation)); + } + return original.SyncDataAsync(); + } + } + + public void Dispose() + { + lock (receiptsGate) + { + disposed = true; + } + } + + internal static PlayerSaveReadResult ReadKV(KvSessionOwner handle, string key) + { + if (handle == null || !KvSnapshot.ValidKey(key)) + { + return new PlayerSaveReadResult("InvalidInput"); + } + var read = handle.ReadLocalSnapshot(); + if (!read.Success) + { + return new PlayerSaveReadResult(read.Error == KVLocalReadError.StaleOrClosing + ? "StaleOrClosing" : "NotReady"); + } + var found = read.Data.TryGetValue(key, out var value); + if (found && value == null) + { + throw new ArgumentException("A successful key observation requires an existence verdict."); + } + return new PlayerSaveReadResult(found, found ? value : null); + } + + internal static KvStateResult WriteKV(KvSessionOwner handle, + string key, string value, bool remoteStore = true) + { + if (handle == null) + { + return new KvStateResult(KVChangeError.Validation); + } + return handle.Update(key, value, remoteStore, ZZTimeHelper.UtcNow().Ticks); + } + + internal static async Task SyncDataImmidiate(KvSessionOwner handle, string key, string value) + { + var success = false; + if (handle != null && + KvSnapshot.ValidKey(key) && value != null) + { + success = await handle.SyncDataImmidiate(key, value); + } + if (!success) + { + UnityEngine.Debug.LogError("[KVMgr::SyncDataImmidiate] Save failed."); + } + return success; + } + + internal static async Task SyncDataImmidiate(KvSessionOwner handle, + IReadOnlyDictionary values) + { + var success = false; + if (handle != null && values != null && values.Count > 0) + { + success = await handle.SyncDataImmidiate(values); + } + if (!success) + { + UnityEngine.Debug.LogError("[KVMgr::SyncDataImmidiate] Save failed."); + } + return success; + } + } +} diff --git a/Client/Assets/Scripts/Core/KVMgr.cs.meta b/Client/Assets/Scripts/Core/KvMgr.cs.meta similarity index 100% rename from Client/Assets/Scripts/Core/KVMgr.cs.meta rename to Client/Assets/Scripts/Core/KvMgr.cs.meta diff --git a/Client/Assets/Scripts/Core/PlayFabMgr.cs b/Client/Assets/Scripts/Core/PlayFabMgr.cs index c80c6a978..b6596c1a0 100644 --- a/Client/Assets/Scripts/Core/PlayFabMgr.cs +++ b/Client/Assets/Scripts/Core/PlayFabMgr.cs @@ -7,7 +7,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using UnityEngine; -using game.PlayerDataStorage; public class PlayFabMgr : MonoBehaviour { @@ -43,7 +42,6 @@ public class PlayFabMgr : MonoBehaviour public Dictionary CompareDatas(Dictionary remoteDatas, string userId) { - 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); @@ -117,7 +115,6 @@ public class PlayFabMgr : MonoBehaviour void Awake() { - if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return; _instance = this; InitBuffer(); tokenSource = new CancellationTokenSource(); @@ -136,7 +133,6 @@ public class PlayFabMgr : MonoBehaviour void OnApplicationPause(bool is_pause) { - if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return; if (is_pause) { tokenSource.Cancel(); @@ -152,7 +148,6 @@ public class PlayFabMgr : MonoBehaviour void OnApplicationQuit() { - if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return; tokenSource.Cancel(); tokenSource = null; SyncData(); @@ -160,16 +155,14 @@ public class PlayFabMgr : MonoBehaviour void OnDestroy() { - if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return; tokenSource?.Cancel(); tokenSource = null; - if(!PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.PrefsSave)) PlayerPrefs.Save(); + PlayerPrefs.Save(); SyncData(); } private async void UpdateUserDataValue(CancellationToken token) { - if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return; try { while (!token.IsCancellationRequested) @@ -183,7 +176,6 @@ public class PlayFabMgr : MonoBehaviour public void UpdateUserDataValue(string key, string data, bool remoteStore = true) { - PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.Update); if (remoteStore) { var cbuff = dataBuffer.First; @@ -213,7 +205,6 @@ public class PlayFabMgr : MonoBehaviour /// public string GetLocalData(string key) { - PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.ReadLocal); if (localData.ContainsKey(key)) { return localData[key]; @@ -226,7 +217,6 @@ public class PlayFabMgr : MonoBehaviour private void SyncData() { - 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; @@ -282,7 +272,6 @@ public class PlayFabMgr : MonoBehaviour public async Task SyncDataImmidiate(string key, string data) { - if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Immediate)) return false; foreach (var buff in dataBuffer) { if (buff.ContainsKey(key)) diff --git a/Client/Assets/Scripts/DataCenter/BargainPackData.cs b/Client/Assets/Scripts/DataCenter/BargainPackData.cs index f470b404a..97a19e349 100644 --- a/Client/Assets/Scripts/DataCenter/BargainPackData.cs +++ b/Client/Assets/Scripts/DataCenter/BargainPackData.cs @@ -8,7 +8,7 @@ using System.Collections.Generic; public class BargainPackData { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore SaveStore { get; set; } private readonly Tables _tables = GContext.container.Resolve(); private readonly TbEventPackManager _epm = GContext.container.Resolve().TbEventPackManager; diff --git a/Client/Assets/Scripts/DataCenter/BattlePassData.cs b/Client/Assets/Scripts/DataCenter/BattlePassData.cs index 099c13228..4f2f068f0 100644 --- a/Client/Assets/Scripts/DataCenter/BattlePassData.cs +++ b/Client/Assets/Scripts/DataCenter/BattlePassData.cs @@ -33,7 +33,7 @@ public struct BpVipUnlock public class BattlePassData { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore playerSaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore playerSaveStore { get; set; } public BattlePassRecord BattlePassRecord; public BattleTask BattleTask; diff --git a/Client/Assets/Scripts/DataCenter/BuffDataCenter.cs b/Client/Assets/Scripts/DataCenter/BuffDataCenter.cs index c0f81eeeb..3eab2c5ea 100644 --- a/Client/Assets/Scripts/DataCenter/BuffDataCenter.cs +++ b/Client/Assets/Scripts/DataCenter/BuffDataCenter.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; public class BuffDataCenter { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore SaveStore { get; set; } private cfg.Tables _tables; public BuffDataCenter(cfg.Tables tables) diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.FishingEventData.cs b/Client/Assets/Scripts/DataCenter/DataCenter.FishingEventData.cs index 850f5beea..20ca2a0bd 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.FishingEventData.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.FishingEventData.cs @@ -56,8 +56,8 @@ namespace GameCore { public partial class FishingEventData : IDisposable { - game.PlayerDataStorage.IPlayerSaveStore boundSaveStore; - [Inject] public game.PlayerDataStorage.IPlayerSaveStore playerSaveStore + Core.KV.IPlayerSaveStore boundSaveStore; + [Inject] public Core.KV.IPlayerSaveStore playerSaveStore { get => boundSaveStore; set diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.GuideData.cs b/Client/Assets/Scripts/DataCenter/DataCenter.GuideData.cs index 290d7eb21..27e8f3290 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.GuideData.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.GuideData.cs @@ -41,7 +41,7 @@ namespace GameCore } public class GuideDataCenter : IDisposable { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore playerSaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore playerSaveStore { get; set; } Tables _tables; PlayerData playerData; public GuideDataCenter(cfg.Tables tables, PlayerData playerData) diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.LeadboardData.cs b/Client/Assets/Scripts/DataCenter/DataCenter.LeadboardData.cs index f4368202c..b87dfb81e 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.LeadboardData.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.LeadboardData.cs @@ -1,8 +1,9 @@ +using Core.KV; using asap.core; using asap.playfab.async; using cfg; using game; -using game.PlayerDataStorage; +using game.KV; using PlayFab.ClientModels; using System; using System.Collections.Generic; @@ -164,9 +165,9 @@ namespace GameCore { Statistics.Add(new StatisticUpdate() { StatisticName = names[i], Value = value[i] }); } - if (userService is KVUserService kvUser) + if (userService is KvUserService kvUser) { - var store = kvUser.SaveStore as KVPlayerSaveStore; + var store = kvUser.SaveStore as KvSaveStore; var writtenNames = new HashSet(StringComparer.Ordinal); var data = new Dictionary(StringComparer.Ordinal); foreach (var statistic in Statistics) @@ -175,7 +176,7 @@ namespace GameCore { continue; } - if (!KVSnapshot.ValidKey(statistic.StatisticName)) + if (!KvSnapshot.ValidKey(statistic.StatisticName)) { Debug.LogError("KV statistic write failed: " + statistic.StatisticName + ": Validation"); continue; @@ -199,7 +200,7 @@ namespace GameCore } public async Task GetLeadboardData(string leaderboardNaem, int maxCount = 50) { - if (userService is KVUserService) + if (userService is KvUserService) { var json = await customServerMgr.CustomServerPost("leaderboard/GetLeaderboard", Newtonsoft.Json.JsonConvert.SerializeObject(new @@ -214,7 +215,7 @@ namespace GameCore } try { - var response = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + var response = Newtonsoft.Json.JsonConvert.DeserializeObject(json); if (response == null || response.Code != 1 || response.Leaderboard == null || response.Leaderboard.Any(entry => entry == null || entry.Profile == null)) { @@ -244,7 +245,7 @@ namespace GameCore return await PlayFabClientAsyncAPI.GetLeaderboardAsync(reques); } - private sealed class KVLeaderboardResponse + private sealed class KvLeaderboardResponse { public int Code { diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.PlayerData.cs b/Client/Assets/Scripts/DataCenter/DataCenter.PlayerData.cs index 6fbc2bcef..2043d688e 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.PlayerData.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.PlayerData.cs @@ -17,7 +17,7 @@ namespace GameCore { public class PlayerData { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore playerSaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore playerSaveStore { get; set; } private cfg.Tables _tables; MMTService _MMService; public PlayerData(cfg.Tables tables, MMTService mmService) diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.PlayerFishData.cs b/Client/Assets/Scripts/DataCenter/DataCenter.PlayerFishData.cs index c163376aa..033abce67 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.PlayerFishData.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.PlayerFishData.cs @@ -52,7 +52,7 @@ namespace GameCore } public partial class PlayerFishData { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore playerSaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore playerSaveStore { get; set; } private cfg.Tables _tables; private ICustomServerMgr customServerMgr; public PlayerFishData(cfg.Tables tables, ICustomServerMgr customServerMgr) diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.PlayerShopData.cs b/Client/Assets/Scripts/DataCenter/DataCenter.PlayerShopData.cs index 342645e21..631fb0afc 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.PlayerShopData.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.PlayerShopData.cs @@ -73,8 +73,8 @@ namespace GameCore } public partial class PlayerShopData : IDisposable { - game.PlayerDataStorage.IPlayerSaveStore boundSaveStore; - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore SaveStore + Core.KV.IPlayerSaveStore boundSaveStore; + [asap.core.Inject] public Core.KV.IPlayerSaveStore SaveStore { get => boundSaveStore; set { CouponData.BindSaveStore(value); boundSaveStore = value; } diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.SignupData.cs b/Client/Assets/Scripts/DataCenter/DataCenter.SignupData.cs index 306f4798b..f1b999a98 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.SignupData.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.SignupData.cs @@ -29,7 +29,7 @@ namespace GameCore } public class SigninData { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore SaveStore { get; set; } private cfg.Tables _tables; public SigninData(cfg.Tables tables) diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.SmallGame.cs b/Client/Assets/Scripts/DataCenter/DataCenter.SmallGame.cs index ff056ec6f..bda22594f 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.SmallGame.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.SmallGame.cs @@ -20,7 +20,7 @@ namespace GameCore } public class SmallGameData { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore playerSaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore playerSaveStore { get; set; } private cfg.Tables _tables; IUserService userService; ICustomServerMgr customServerMgr; diff --git a/Client/Assets/Scripts/DataCenter/DataCenter.TriggerPackData.cs b/Client/Assets/Scripts/DataCenter/DataCenter.TriggerPackData.cs index 945add617..24becc6c0 100644 --- a/Client/Assets/Scripts/DataCenter/DataCenter.TriggerPackData.cs +++ b/Client/Assets/Scripts/DataCenter/DataCenter.TriggerPackData.cs @@ -38,7 +38,7 @@ namespace GameCore } public class TriggerPackData { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore SaveStore { get; set; } Tables _tables; PlayerShopData playerShopData; diff --git a/Client/Assets/Scripts/DataCenter/EventPackData.cs b/Client/Assets/Scripts/DataCenter/EventPackData.cs index c3a59ff51..5d67619bd 100644 --- a/Client/Assets/Scripts/DataCenter/EventPackData.cs +++ b/Client/Assets/Scripts/DataCenter/EventPackData.cs @@ -3,7 +3,7 @@ using GameCore; using cfg; public class EventPackData { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore playerSaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore playerSaveStore { get; set; } private Tables _tables = GContext.container.Resolve(); private struct Data { diff --git a/Client/Assets/Scripts/DataCenter/EventPartnerGatherManager.cs b/Client/Assets/Scripts/DataCenter/EventPartnerGatherManager.cs index 38ae023ac..0f6980f0f 100644 --- a/Client/Assets/Scripts/DataCenter/EventPartnerGatherManager.cs +++ b/Client/Assets/Scripts/DataCenter/EventPartnerGatherManager.cs @@ -100,7 +100,7 @@ namespace DataCenter // EventPartnerGather 管理器 public class EventPartnerGatherManager : IAutoMatchManagerContext { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore SaveStore { get; set; } private const bool EnableTraceLogs = false; public const string SaveKey = "EventPartnerGatherData"; diff --git a/Client/Assets/Scripts/DataCenter/EventPinballDataManager.cs b/Client/Assets/Scripts/DataCenter/EventPinballDataManager.cs index 78462c355..4e9dc7e08 100644 --- a/Client/Assets/Scripts/DataCenter/EventPinballDataManager.cs +++ b/Client/Assets/Scripts/DataCenter/EventPinballDataManager.cs @@ -11,7 +11,7 @@ namespace game { public class EventPinballDataManager { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore playerSaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore playerSaveStore { get; set; } #if UNITY_EDITOR private bool m_IsCheckCondition = true; diff --git a/Client/Assets/Scripts/DataCenter/EventPotionDataManager.cs b/Client/Assets/Scripts/DataCenter/EventPotionDataManager.cs index 2d87cd241..cefba77ba 100644 --- a/Client/Assets/Scripts/DataCenter/EventPotionDataManager.cs +++ b/Client/Assets/Scripts/DataCenter/EventPotionDataManager.cs @@ -13,7 +13,7 @@ namespace game { public class EventPotionDataManager { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore playerSaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore playerSaveStore { get; set; } #if UNITY_EDITOR private bool m_IsCheckCondition = true; private bool m_IsSyncData = true; diff --git a/Client/Assets/Scripts/DataCenter/EventScratchTicketDataManager.cs b/Client/Assets/Scripts/DataCenter/EventScratchTicketDataManager.cs index 89bfe10ef..c969aa9d7 100644 --- a/Client/Assets/Scripts/DataCenter/EventScratchTicketDataManager.cs +++ b/Client/Assets/Scripts/DataCenter/EventScratchTicketDataManager.cs @@ -14,7 +14,7 @@ namespace game { public class EventScratchTicketDataManager { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore playerSaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore playerSaveStore { get; set; } #if UNITY_EDITOR private bool m_IsCheckCondition = true; private bool m_IsSyncData = true; diff --git a/Client/Assets/Scripts/DataCenter/FishingChallengeManager.cs b/Client/Assets/Scripts/DataCenter/FishingChallengeManager.cs index aadaeb62a..6b3108b46 100644 --- a/Client/Assets/Scripts/DataCenter/FishingChallengeManager.cs +++ b/Client/Assets/Scripts/DataCenter/FishingChallengeManager.cs @@ -40,7 +40,7 @@ public class FishingChallengeData public class FishingChallengeManager { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore SaveStore { get; set; } // private const string SaveKey = "FishingChallengeData"; diff --git a/Client/Assets/Scripts/DataCenter/OfferChainsChestManager.cs b/Client/Assets/Scripts/DataCenter/OfferChainsChestManager.cs index d759af2ce..722eeef08 100644 --- a/Client/Assets/Scripts/DataCenter/OfferChainsChestManager.cs +++ b/Client/Assets/Scripts/DataCenter/OfferChainsChestManager.cs @@ -41,7 +41,7 @@ namespace DataCenter public class OfferChainsChestManager { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore playerSaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore playerSaveStore { get; set; } public const string SaveKey = "OfferChainsChestData"; [Inject] public Tables _tables { get; set; } diff --git a/Client/Assets/Scripts/DataCenter/OfferDiscoPartyManager.cs b/Client/Assets/Scripts/DataCenter/OfferDiscoPartyManager.cs index b48f543dd..13b3eda8a 100644 --- a/Client/Assets/Scripts/DataCenter/OfferDiscoPartyManager.cs +++ b/Client/Assets/Scripts/DataCenter/OfferDiscoPartyManager.cs @@ -44,7 +44,7 @@ namespace DataCenter // Disco Manager public class OfferDiscoPartyManager { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore playerSaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore playerSaveStore { get; set; } public const string SaveKey = "OfferDiscoPartyManager"; // 免费抽奖次数 diff --git a/Client/Assets/Scripts/DataCenter/PiggyBankPackData.cs b/Client/Assets/Scripts/DataCenter/PiggyBankPackData.cs index f72b7aa9f..910687b69 100644 --- a/Client/Assets/Scripts/DataCenter/PiggyBankPackData.cs +++ b/Client/Assets/Scripts/DataCenter/PiggyBankPackData.cs @@ -8,7 +8,7 @@ using game; using System.Linq; public class PiggyBankPackData { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore playerSaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore playerSaveStore { get; set; } private readonly Tables _tables = GContext.container.Resolve(); private readonly TbEventPackManager _epm = GContext.container.Resolve().TbEventPackManager; diff --git a/Client/Assets/Scripts/DataCenter/TotalRechargeManager.cs b/Client/Assets/Scripts/DataCenter/TotalRechargeManager.cs index a4a34b505..bb3d4b73a 100644 --- a/Client/Assets/Scripts/DataCenter/TotalRechargeManager.cs +++ b/Client/Assets/Scripts/DataCenter/TotalRechargeManager.cs @@ -17,7 +17,7 @@ namespace GameCore } public class TotalRechargeManager { - readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve(); + readonly Core.KV.IPlayerSaveStore saveStore = GContext.container.Resolve(); public const string TotalRechargeDataKey = "EventTotalRecharge"; public TotalRechargeData GetEventData() diff --git a/Client/Assets/Scripts/DebugFunc.cs b/Client/Assets/Scripts/DebugFunc.cs index 85f8469cb..e3df2c84a 100644 --- a/Client/Assets/Scripts/DebugFunc.cs +++ b/Client/Assets/Scripts/DebugFunc.cs @@ -474,15 +474,15 @@ public class DebugFunc private static void LoginTime(int hours) { var userService = GContext.container.Resolve(); - if (userService is not game.PlayFabUserService && userService is not game.KVUserService) + if (userService is not game.PlayFabUserService && userService is not game.KvUserService) { return; } var loginTime = userService.LoginTime; var newLoginTime = loginTime.AddHours(hours); - var prop = userService is game.KVUserService - ? typeof(game.KVUserService).GetProperty("LoginTime") + var prop = userService is game.KvUserService + ? typeof(game.KvUserService).GetProperty("LoginTime") : userService.GetType().GetProperty("LoginTime"); prop.SetValue(userService, newLoginTime); Debug.Log($"loginTime: {loginTime} newLoginTime: {newLoginTime} prop: {userService.LoginTime}"); @@ -491,7 +491,7 @@ public class DebugFunc private static void AccoutInfo() { var userService = GContext.container.Resolve(); - if (userService is not game.PlayFabUserService && userService is not game.KVUserService) + if (userService is not game.PlayFabUserService && userService is not game.KvUserService) { return; } @@ -728,7 +728,7 @@ public class DebugFunc } static void ClearLastPaymentTime() { - GContext.container.Resolve().Write("LastPaymentTime", "").RequireAccepted(); + GContext.container.Resolve().Write("LastPaymentTime", "").RequireAccepted(); } static void HookWheelForecActive() { diff --git a/Client/Assets/Scripts/Duel/UI/EventFishingDuelPanel.cs b/Client/Assets/Scripts/Duel/UI/EventFishingDuelPanel.cs index 1f8fd8960..458dd6d00 100644 --- a/Client/Assets/Scripts/Duel/UI/EventFishingDuelPanel.cs +++ b/Client/Assets/Scripts/Duel/UI/EventFishingDuelPanel.cs @@ -608,7 +608,7 @@ public class EventFishingDuelPanel : BasePanel void CreateMatchmakingTicket() { if (GContext.container.Resolve() is not PlayFabUserService && - GContext.container.Resolve() is not KVUserService) + GContext.container.Resolve() is not KvUserService) { Debug.LogWarning("KV matchmaking service is not connected."); FDM.MachingEnd(null); @@ -654,7 +654,7 @@ public class EventFishingDuelPanel : BasePanel Debug.LogError("创建匹配请求失败: " + error); FDM.MachingEnd(null); }; - if (userService is KVUserService) + if (userService is KvUserService) { RequestKVMatch("CreateMatchmakingTicket", new { @@ -733,7 +733,7 @@ public class EventFishingDuelPanel : BasePanel Debug.LogError("检查匹配状态失败: " + error); MachingEnd(); }; - if (GContext.container.Resolve() is KVUserService) + if (GContext.container.Resolve() is KvUserService) { RequestKVMatch("GetMatchmakingTicket", new { @@ -750,7 +750,7 @@ public class EventFishingDuelPanel : BasePanel void GetMatchDetails(string matchId) { if (GContext.container.Resolve() is not PlayFabUserService && - GContext.container.Resolve() is not KVUserService) + GContext.container.Resolve() is not KvUserService) { Debug.LogWarning("KV matchmaking service is not connected."); FDM.MachingEnd(null); @@ -783,7 +783,7 @@ public class EventFishingDuelPanel : BasePanel for (int i = 0; i < result.Members.Count; i++) { MatchmakingPlayerWithTeamAssignment member = result.Members[i]; - if (member.Entity.Id != (userService is KVUserService ? userService.UserId : ((PlayFabUserService)userService).entityKey.Id)) + if (member.Entity.Id != (userService is KvUserService ? userService.UserId : ((PlayFabUserService)userService).entityKey.Id)) { JObject dataObject = JObject.FromObject(result.Members[i].Attributes.DataObject); string[] tierArray = dataObject["Tier"].ToObject(); @@ -809,7 +809,7 @@ public class EventFishingDuelPanel : BasePanel Debug.LogError("获取匹配详情失败: " + error); MachingEnd(); }; - if (GContext.container.Resolve() is KVUserService) + if (GContext.container.Resolve() is KvUserService) { RequestKVMatch("GetMatch", new { @@ -862,7 +862,7 @@ public class EventFishingDuelPanel : BasePanel { Debug.LogError("取消匹配失败: " + error); }; - if (GContext.container.Resolve() is KVUserService) + if (GContext.container.Resolve() is KvUserService) { RequestKVMatch("CancelMatchmakingTicket", new { diff --git a/Client/Assets/Scripts/Event/AEventDataManager.cs b/Client/Assets/Scripts/Event/AEventDataManager.cs index cb97c78a9..f95468a55 100644 --- a/Client/Assets/Scripts/Event/AEventDataManager.cs +++ b/Client/Assets/Scripts/Event/AEventDataManager.cs @@ -16,7 +16,7 @@ namespace game GContext.OnEvent().Subscribe(OnEvent_TargetEvent).AddTo(Disposables); } private Tables _tables; - [Inject] public PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; } + [Inject] public Core.KV.IPlayerSaveStore SaveStore { get; set; } bool disposed; [Inject] diff --git a/Client/Assets/Scripts/EventBingo/EventBingoData.cs b/Client/Assets/Scripts/EventBingo/EventBingoData.cs index 0e82ae5a2..f7568ce35 100644 --- a/Client/Assets/Scripts/EventBingo/EventBingoData.cs +++ b/Client/Assets/Scripts/EventBingo/EventBingoData.cs @@ -410,7 +410,7 @@ public class EventBingoStepState public class EventBingoPlayfabData { - readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve(); + readonly Core.KV.IPlayerSaveStore saveStore = GContext.container.Resolve(); public int EventId, TicketCount, RoundCount, StepInRound, Seed, ChainProgress, ChainTaskTokenProgress; public float InflationRate; @@ -480,7 +480,7 @@ public class EventBingoPlayfabData public static EventBingoPlayfabData Load() { - var s = GContext.container.Resolve().Read(Key).ValueOrThrow(); + var s = GContext.container.Resolve().Read(Key).ValueOrThrow(); return Deserialize(s); } } diff --git a/Client/Assets/Scripts/EventBreak/EventBreakData.cs b/Client/Assets/Scripts/EventBreak/EventBreakData.cs index 98e777ccb..51e3f4dab 100644 --- a/Client/Assets/Scripts/EventBreak/EventBreakData.cs +++ b/Client/Assets/Scripts/EventBreak/EventBreakData.cs @@ -152,7 +152,7 @@ public class DrillModel : IHasChainPack public class EventBreakPfData { - readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve(); + readonly Core.KV.IPlayerSaveStore saveStore = GContext.container.Resolve(); public int EventId { get; set; } public int TicketCount { get; set; } diff --git a/Client/Assets/Scripts/EventCan/EventCanData.cs b/Client/Assets/Scripts/EventCan/EventCanData.cs index 57a2e46c8..7e0af1a84 100644 --- a/Client/Assets/Scripts/EventCan/EventCanData.cs +++ b/Client/Assets/Scripts/EventCan/EventCanData.cs @@ -350,7 +350,7 @@ public class EventCanProgressTaskInfo public class EventCanPlayfabData { - readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve(); + readonly Core.KV.IPlayerSaveStore saveStore = GContext.container.Resolve(); public const string PlayFabKey = "EventCan"; private const char Splitter = '|', Comma = ','; diff --git a/Client/Assets/Scripts/EventCubeMelt/Data/EventCubeMeltManager.cs b/Client/Assets/Scripts/EventCubeMelt/Data/EventCubeMeltManager.cs index 87605d425..7317a3eaa 100644 --- a/Client/Assets/Scripts/EventCubeMelt/Data/EventCubeMeltManager.cs +++ b/Client/Assets/Scripts/EventCubeMelt/Data/EventCubeMeltManager.cs @@ -22,7 +22,7 @@ using Random = UnityEngine.Random; public class EventCubeMeltManager { - [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; } + [asap.core.Inject] public Core.KV.IPlayerSaveStore SaveStore { get; set; } // public const string SaveKey = "EventCubeMeltData"; diff --git a/Client/Assets/Scripts/EventGatherCore/EventGatherCoreData.cs b/Client/Assets/Scripts/EventGatherCore/EventGatherCoreData.cs index ed98bb1f1..83bde7bd3 100644 --- a/Client/Assets/Scripts/EventGatherCore/EventGatherCoreData.cs +++ b/Client/Assets/Scripts/EventGatherCore/EventGatherCoreData.cs @@ -265,7 +265,7 @@ public class EventGatherCoreRewardInfo public class EventGatherCorePlayfabData { - readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve(); + readonly Core.KV.IPlayerSaveStore saveStore = GContext.container.Resolve(); public int EventId { get; set; } public int TicketCount { get; set; } @@ -319,7 +319,7 @@ public class EventGatherCorePlayfabData public static EventGatherCorePlayfabData Load() { - var s = GContext.container.Resolve().Read(Key).ValueOrThrow(); + var s = GContext.container.Resolve().Read(Key).ValueOrThrow(); var res = new EventGatherCorePlayfabData(); try { diff --git a/Client/Assets/Scripts/EventLuckGame/EventLuckyGameModel.cs b/Client/Assets/Scripts/EventLuckGame/EventLuckyGameModel.cs index fcd45b28f..b2536d569 100644 --- a/Client/Assets/Scripts/EventLuckGame/EventLuckyGameModel.cs +++ b/Client/Assets/Scripts/EventLuckGame/EventLuckyGameModel.cs @@ -11,7 +11,7 @@ using UnityEngine; /// public class EventLuckyGameModel : ILuckyTaskGetData { - readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve(); + readonly Core.KV.IPlayerSaveStore saveStore = GContext.container.Resolve(); public static EventLuckyGameModel Get() { diff --git a/Client/Assets/Scripts/EventOfferJourney/EventOfferJourneyData.cs b/Client/Assets/Scripts/EventOfferJourney/EventOfferJourneyData.cs index 21c073ff3..a2eee952b 100644 --- a/Client/Assets/Scripts/EventOfferJourney/EventOfferJourneyData.cs +++ b/Client/Assets/Scripts/EventOfferJourney/EventOfferJourneyData.cs @@ -261,7 +261,7 @@ public class EventOfferJourneyBlockData public class EventOfferJourneyPlayfabData { - readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve(); + readonly Core.KV.IPlayerSaveStore saveStore = GContext.container.Resolve(); public int EventId, Progress, TicketCount, VipLvlOnTrigger, RoundCount; public float InflationRate; @@ -321,7 +321,7 @@ public class EventOfferJourneyPlayfabData public static EventOfferJourneyPlayfabData Load() { - var s = GContext.container.Resolve().Read(Key).ValueOrThrow(); + var s = GContext.container.Resolve().Read(Key).ValueOrThrow(); return Deserialize(s); } diff --git a/Client/Assets/Scripts/EventOfferJourney/EventOfferJourneyLvlTriggerData.cs b/Client/Assets/Scripts/EventOfferJourney/EventOfferJourneyLvlTriggerData.cs index 9bff8fc4f..5cfe8799f 100644 --- a/Client/Assets/Scripts/EventOfferJourney/EventOfferJourneyLvlTriggerData.cs +++ b/Client/Assets/Scripts/EventOfferJourney/EventOfferJourneyLvlTriggerData.cs @@ -156,7 +156,7 @@ public class EventOfferJourneyLvlTriggerData : AEventOfferJourneyData public class EventOfferJourneyLvlTriggerPlayfabData { - readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve(); + readonly Core.KV.IPlayerSaveStore saveStore = GContext.container.Resolve(); public int Progress, TicketCount, RoundCount; private const string Key = "EventOfferJourneyLvlTriggerData"; @@ -197,7 +197,7 @@ public class EventOfferJourneyLvlTriggerPlayfabData public static EventOfferJourneyLvlTriggerPlayfabData Load() { - var s = GContext.container.Resolve().Read(Key).ValueOrThrow(); + var s = GContext.container.Resolve().Read(Key).ValueOrThrow(); return Deserialize(s); } diff --git a/Client/Assets/Scripts/EventPartnerGather/PartnerInfoManager.cs b/Client/Assets/Scripts/EventPartnerGather/PartnerInfoManager.cs index 4cafb9d4c..dcff82704 100644 --- a/Client/Assets/Scripts/EventPartnerGather/PartnerInfoManager.cs +++ b/Client/Assets/Scripts/EventPartnerGather/PartnerInfoManager.cs @@ -49,7 +49,6 @@ namespace EventPartnerGather { BuildPartners = new BuildPartner[_tables.TbEventPartnerConfig.InvitationCount[1]]; } - internal bool HasSubscriptionsForKVLoad => disposables != null; // public bool IsFullyMatched => BuildPartners.All(b => b != null && b.IsValid()); diff --git a/Client/Assets/Scripts/EventStrikeLucky/EventLuckMagicData.cs b/Client/Assets/Scripts/EventStrikeLucky/EventLuckMagicData.cs index 3b67e08a2..e2b7c1999 100644 --- a/Client/Assets/Scripts/EventStrikeLucky/EventLuckMagicData.cs +++ b/Client/Assets/Scripts/EventStrikeLucky/EventLuckMagicData.cs @@ -564,7 +564,7 @@ public class RewardItemNewFlyToStashRequest public class EventLuckMagicPlayfabData { - readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve(); + readonly Core.KV.IPlayerSaveStore saveStore = GContext.container.Resolve(); public int EventId; public int TicketCount; diff --git a/Client/Assets/Scripts/EventWelcomeBack/EventWelcomeBackManager.cs b/Client/Assets/Scripts/EventWelcomeBack/EventWelcomeBackManager.cs index f7395e9be..43b85af8c 100644 --- a/Client/Assets/Scripts/EventWelcomeBack/EventWelcomeBackManager.cs +++ b/Client/Assets/Scripts/EventWelcomeBack/EventWelcomeBackManager.cs @@ -15,7 +15,7 @@ public class WelcomeBackData } public class EventWelcomeBackManager { - readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve(); + readonly Core.KV.IPlayerSaveStore saveStore = GContext.container.Resolve(); string DataKey_WelcomeBack = "WelcomeBack"; Tables tables; diff --git a/Client/Assets/Scripts/GampPlay/FishingB/FishingBehaviorB.cs b/Client/Assets/Scripts/GampPlay/FishingB/FishingBehaviorB.cs index 2837eaac7..bc483dff3 100644 --- a/Client/Assets/Scripts/GampPlay/FishingB/FishingBehaviorB.cs +++ b/Client/Assets/Scripts/GampPlay/FishingB/FishingBehaviorB.cs @@ -1,4 +1,4 @@ -using game.PlayerDataStorage; +using Core.KV; using asap.core; using cfg; using DG.Tweening; diff --git a/Client/Assets/Scripts/InfiniteBuilding/InfiniteBuildingData.cs b/Client/Assets/Scripts/InfiniteBuilding/InfiniteBuildingData.cs index 1f0bad926..23a8dbf93 100644 --- a/Client/Assets/Scripts/InfiniteBuilding/InfiniteBuildingData.cs +++ b/Client/Assets/Scripts/InfiniteBuilding/InfiniteBuildingData.cs @@ -6,7 +6,7 @@ using UnityEngine; public class InfiniteBuildingData { - readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve(); + readonly Core.KV.IPlayerSaveStore saveStore = GContext.container.Resolve(); public int Lvl, Step, StepLvl = 1, LastHangingBlock = -1; public List Record = new List(); diff --git a/Client/Assets/Scripts/LuckyTask/UI/LuckyADTaskPopupPanel.cs b/Client/Assets/Scripts/LuckyTask/UI/LuckyADTaskPopupPanel.cs index 6ed73801c..55ddf011a 100644 --- a/Client/Assets/Scripts/LuckyTask/UI/LuckyADTaskPopupPanel.cs +++ b/Client/Assets/Scripts/LuckyTask/UI/LuckyADTaskPopupPanel.cs @@ -9,7 +9,7 @@ using UnityEngine; public class LuckyADTaskPopupPanel : LuckyTaskPanelBase { - game.PlayerDataStorage.IPlayerSaveStore saveStore; + Core.KV.IPlayerSaveStore saveStore; List taskList; List taskItems = new List(); @@ -20,7 +20,7 @@ public class LuckyADTaskPopupPanel : LuckyTaskPanelBase System.DateTime refreshTime; protected override void Awake() { - saveStore = GContext.container.Resolve(); + saveStore = GContext.container.Resolve(); base.Awake(); luckyTaskItem = transform.Find("root/hook_missions_object").GetComponent(); text_time = transform.Find("root/text_time").GetComponent(); diff --git a/Client/Assets/Scripts/LuckyTask/UI/LuckyTaskBtn.cs b/Client/Assets/Scripts/LuckyTask/UI/LuckyTaskBtn.cs index d341e8cde..7e6ec1aa3 100644 --- a/Client/Assets/Scripts/LuckyTask/UI/LuckyTaskBtn.cs +++ b/Client/Assets/Scripts/LuckyTask/UI/LuckyTaskBtn.cs @@ -7,7 +7,7 @@ using UnityEngine.UI; public class LuckyTaskBtn : MonoBehaviour, IUIRedPoint { - game.PlayerDataStorage.IPlayerSaveStore saveStore; + Core.KV.IPlayerSaveStore saveStore; Button btn; ILuckyTaskGetData model; @@ -18,7 +18,7 @@ public class LuckyTaskBtn : MonoBehaviour, IUIRedPoint public TMP_Text text_redpoint_big; void Awake() { - saveStore = GContext.container.Resolve(); + saveStore = GContext.container.Resolve(); btn = GetComponent