#if UNITY_EDITOR && !SERVICE_HOST using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using asap.core; using GameCore; using Newtonsoft.Json; using UniRx; using DG.Tweening; using UnityEngine; namespace game.PlayerDataStorage { internal static partial class PlayerKVGameplayLoop { // Only a fixed idle-editor test; the handler inherited from EquipmentFixture never forwards requests. internal static async Task VerifyFishingForEditorAsync() { if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || Resources.FindObjectsOfTypeAll().Length!=0 || PlayFabMgr.HasLiveInstanceForKVLoad || Enabled || handle!=null || File.Exists(Path.Combine(Root,"permit.json")) || validationCleanupFailure!=null) throw new InvalidOperationException("Idle editor without a real permit required."); if(Interlocked.CompareExchange(ref validationRunning,1,0)!=0) throw new InvalidOperationException("Validation running."); var saved=(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction); var oldContainer=GContext.container; var oldEvents=FishingContextAccess.Events; var folder=Path.Combine(Root,"fishing-fixed-runs",DateTime.UtcNow.ToString("yyyyMMddTHHmmss")+"_"+Guid.NewGuid().ToString("N")); EquipmentFixture fixture=null; Task pending=null; Container container=null; PlayerData player=null; IDisposable subscription=null; var oldLanguages=LocalizationMgr.enumLanguage; var textTable=typeof(LocalizationMgr).GetField("table",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic); var oldTextTable=textTable.GetValue(null); FishingSubscribers subscribers=null; int checks=0,groups=0; var passed=new List(); string result=null; void Check(bool yes,string reason) { checks++; if(!yes) throw new InvalidOperationException("Fishing validation: "+reason); } async Task Start(string name) { subscribers?.Dispose(); subscribers=null; player?.StopRecoveryEnergy(); player=null; subscription?.Dispose(); subscription=null; if(fixture!=null) await fixture.Close(); if(pending!=null) { try { await pending; } catch { } pending=null; } FishingContextAccess.Use(oldContainer); FishingContextAccess.Events=oldEvents; container?.Dispose(); container=null; fixture=new EquipmentFixture(Path.Combine(folder,(++groups).ToString())); var owner=await fixture.Open(); Enabled=true; Ready=false; Error=null; failed=0; handle=null; collecting=null; fishingAction=null; Permit=new PlayerKVLoopPermit{Version=2,Operation="fishing-consume-once",PlayerId="101",AllowedKeys=new[]{"k","NewPlayerDataDic","late"}}; RunPath=folder; mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current; Attach(_=>fixture.Faults++,fixture.Close); Publish(owner); passed.Add(name); } (FishingAction action,WriteBatch batch,PlayerKVEditResult primary) Batch(bool duplicates=false) { var action=BeginFishing(new object()); Check(ClaimFishingConsumption(action),"first consume admitted"); var batch=BeginFishingWrites(action); if(duplicates) { Write("NewPlayerDataDic","intermediate-local",false); Write("NewPlayerDataDic","intermediate-remote",true); } Write("k","task",true); var primary=Write("NewPlayerDataDic","final",true); batch.Dispose(); return(action,batch,primary); } async Task Reject(Task task,string label) { bool rejected=false; try { await task; } catch(InvalidOperationException) { rejected=true; } Check(rejected && !Ready && fixture.Faults==1,label); } try { Directory.CreateDirectory(folder); await Start("duplicate-state-writes-and-original-final-tickets"); var b=Batch(true); Check(b.batch.Records.Count==4 && !b.batch.Records[0].Remote && b.batch.Records[0].Edit.Ticket==null,"local intermediate captured"); await ConfirmFishingAsync(b.action,b.batch,b.primary); Check(fixture.Remote["NewPlayerDataDic"]=="final" && fixture.Remote["k"]=="task","both final values persisted"); int continued=0; ContinueFishing(b.action,()=>continued++); ContinueFishing(b.action,()=>continued++); Check(continued==1 && !ClaimFishingConsumption(b.action) && BeginFishing(new object())==null,"continue and consume/cast only once"); await Start("pending-response-does-not-continue"); b=Batch(); fixture.HoldSet=true; pending=ConfirmFishingAsync(b.action,b.batch,b.primary); Check(ReferenceEquals(await Task.WhenAny(fixture.SetEntered.Task,pending,Task.Delay(5000)),fixture.SetEntered.Task),"Set entered"); Check(!b.action.Confirmed && !b.action.Continued,"no continuation during pending response"); fixture.ReleaseSet.TrySetResult(true); await pending; pending=null; Check(b.action.Confirmed,"original response confirms"); await Start("final-localOnly-rejected-before-flush"); b=Batch(); // A sealed record ledger from actual writes with a localOnly final key. collecting=b.batch; Write("k","local-tail",false); b.batch.Dispose(); await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"local final cannot report remote success"); Check(fixture.Sets==0,"no flush of rejected batch"); await Start("external-final-supersession"); b=Batch(); Write("NewPlayerDataDic","later-action",true); await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"never substitute later ticket"); await Start("lost-response-after-commit"); b=Batch(); fixture.LoseResponse=true; await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"Unknown stops run"); Check(fixture.Sets==1 && fixture.Remote["NewPlayerDataDic"]=="final","one uncertain commit, no replay"); await Start("unauthorized-does-not-reauthenticate"); b=Batch(); fixture.Unauthorized=true; await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"401 stops run"); Check(fixture.Auth==1 && fixture.Sets==1,"single auth and Set"); await Start("cancelled-object-does-not-continue-after-commit"); b=Batch(); fixture.HoldSet=true; pending=ConfirmFishingAsync(b.action,b.batch,b.primary); await fixture.SetEntered.Task; CancelFishing(b.action,"synthetic disable"); fixture.ReleaseSet.TrySetResult(true); await Reject(pending,"disabled original action cannot continue"); pending=null; Check(!b.action.Continued && b.action.Cancelled,"no stale continuation"); await Start("close-before-confirmation"); b=Batch(); await fixture.Close(); await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"closed owner rejected"); Check(fixture.Sets==0,"close does not invent flush"); await Start("partial-commit-is-not-atomic-success"); b=Batch(); fixture.PartialCommit=true; fixture.LoseResponse=true; await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"partial unknown stops run"); Check(fixture.Sets==1 && fixture.Remote["k"]=="task" && fixture.Remote["NewPlayerDataDic"]=="original-save","partial commit retained without compensation"); await Start("storage-error-before-send"); var stateFile=Directory.GetFiles(Path.Combine(folder,groups.ToString()),"state.json",SearchOption.AllDirectories).Single(); using(var locked=new FileStream(stateFile,FileMode.Open,FileAccess.Read,FileShare.None)) { bool storage=false; try { Write("NewPlayerDataDic","locked",true); } catch(InvalidOperationException e) { storage=e.Message.Contains("StorageError"); } Check(storage && !Ready && fixture.Faults==1 && fixture.Sets==0,"storage failure cannot be accepted or retried"); } foreach(bool complete in new[]{false,true}) { await Start(complete?"actual-tween-autoKill-after-complete":"actual-tween-kill-before-complete"); var shell=new GameObject("KV inactive fishing tween fixture"); shell.SetActive(false); shell.hideFlags=HideFlags.HideAndDontSave; Tween tween=null; // DOTween.Init deliberately does nothing in EditMode; Kill otherwise returns // before reaching the real tween engine. Scope this fixed fixture gate to this // synchronous block and restore it even when an assertion fails. var initialized=typeof(DOTween).GetField("initialized",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic|System.Reflection.BindingFlags.Public); var wasInitialized=initialized.GetValue(null); try { initialized.SetValue(null,true); var behavior=shell.AddComponent(); var action=BeginFishing(behavior); typeof(FishingBehaviorB).GetField("kvFishingAction",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).SetValue(behavior,action); float value=0; tween=DOTween.To(()=>value,v=>value=v,1,100).SetAutoKill(true).SetUpdate(UpdateType.Manual); typeof(FishingBehaviorB).GetMethod("TrackKVCastTween",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).Invoke(behavior,new object[]{tween}); Check(tween.onKill!=null && tween.onComplete!=null,"actual callbacks installed"); if(complete) tween.Complete(); else tween.Kill(false); Check(action.Cancelled!=complete && !action.Continued,"actual Tween Kill boundary complete="+complete+" cancelled="+action.Cancelled+" enabled="+Enabled+" active="+tween.IsActive()); Check(fixture.Faults==(complete?0:1) && fixture.Sets==0,"autoKill is not a cancellation"); } finally { try { tween?.Kill(false); UnityEngine.Object.DestroyImmediate(shell); } finally { initialized.SetValue(null,wasInitialized); } } } await Start("completed-action-normal-disable-is-not-failure"); var completedShell=new GameObject("KV completed disable fixture");completedShell.SetActive(false);completedShell.hideFlags=HideFlags.HideAndDontSave; try { var behavior=completedShell.AddComponent(); var action=BeginFishing(behavior);Check(ClaimFishingConsumption(action),"completed action admitted"); var batch=BeginFishingWrites(action);var primary=Write("NewPlayerDataDic","completed",true);batch.Dispose(); await ConfirmFishingAsync(action,batch,primary);ContinueFishing(action,()=>{}); typeof(FishingBehaviorB).GetField("kvFishingAction",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).SetValue(behavior,action); typeof(FishingBehaviorB).GetMethod("OnDisable",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).Invoke(behavior,null); Check(action.Cancelled && action.Continued && Ready && fixture.Faults==0,"normal disable after completed consumption must not Fault"); Check(!FishingCurrent(action) && BeginFishing(behavior)==null,"normal disable still revokes callbacks and never restores quota"); } finally { UnityEngine.Object.DestroyImmediate(completedShell); } await Start("observation-timeout-does-not-replay"); b=Batch(); fixture.HoldSet=true; pending=ConfirmFishingAsync(b.action,b.batch,b.primary); await Reject(pending,"30 second observer deadline stops continuation"); pending=null; Check(fixture.Sets==1 && !b.action.Continued,"timeout leaves one admitted write"); fixture.ReleaseSet.TrySetResult(true); await Start("equipment-permit-cannot-cast"); Permit.Version=1; Permit.Operation=null; bool denied=false; try { BeginFishing(new object()); } catch(InvalidOperationException) { denied=true; } Check(denied && fixture.Sets==0,"old permit cannot consume"); foreach(var scenario in new[]{"above-limit","cross-limit","below-limit","recovery-during-pending"}) { await Start("actual-PlayerData-"+scenario); int initialEnergy=scenario=="above-limit"?400:scenario=="cross-limit"?300:250; var tables=new cfg.Tables(n=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData",n+".json"))))); container=new Container(); FishingContextAccess.Use(container); FishingContextAccess.Events=new EventAggregator(); container.RegisterInstance(tables); LocalizationMgr.enumLanguage=tables.TbLanguageConfig.DataList.Select(x=>x.ID).ToList(); textTable.SetValue(null,null); player=new PlayerData(tables,null); var small=new SmallGameData(tables,null,null); var fish=new PlayerFishData(tables,null); var achievements=new AchievementDataManager(tables); var box=new FishingBoxData(); var events=new FishingEventData(tables); container.RegisterInstance(player); container.RegisterInstance(small); container.RegisterInstance(fish); container.RegisterInstance(achievements); container.RegisterInstance(box); container.RegisterInstance(events); container.RegisterInstance(new FishingNotifications()); fish.ResetForKVLoad(); small.ResetForKVLoad(); achievements.ResetForKVLoad(); box.ResetForKVLoad(); achievements.Init(); int rod=tables.TbRodData.DataMap.Keys.First(),map=tables.TbMapData.DataMap.Keys.First(); var core=new PlayerKVCoreState(new object[]{1,10UL,0,initialEnergy,5,0,rod,new DateTime(2026,1,1),0,7,10UL,2,map,map,0,0,0,0,0}); player.ReplaceFromKVDecoded(new PlayerKVPlayerState(core,new Dictionary(),new Dictionary{{-1,1}},new List(),0,false,"0")); // Fixed backing property only, no caller-controlled reflection or gameplay registration. typeof(PlayerData).GetProperty("benefitsEnergyLimit").SetValue(player,300); typeof(PlayerData).GetProperty("benefitsEnergyRecover").SetValue(player,1); if(scenario=="above-limit") { Permit.AllowedKeys=Permit.AllowedKeys.Concat(new[]{"BattlePassRecordTask","MiniBPKeyNew","EventLuckGameRecord","EventLuckMagicPlayfabData"}).ToArray(); subscribers=new FishingSubscribers(); subscribers.Bind(tables,events,container); Check(!tables.TbFishingEvent.DataList.Any(e=>e.ConditionList.Any(c=>c.Condition==cfg.ConditionType.ConsumeEnergy)) && !tables.TbTriggerPackManager.DataList.Any(t=>t.Condition==cfg.ConditionType.ConsumeEnergy),"real config excludes async activation and trigger-pack writes"); } int consumed=0,notified=0; subscription=GContext.OnEvent().Subscribe(e=>{if(e.type==cfg.ConditionType.ConsumeEnergy){consumed+=e.count; Write("k","task-"+e.count,true);}}); using(GContext.OnEvent().Subscribe(_=>notified++)) { var action=BeginFishing(new object()); Check(ClaimFishingConsumption(action),"real action claimed"); fixture.HoldSet=scenario=="recovery-during-pending"; var consumption=player.ConsumeEnergyForKVLoopAsync(3,action); pending=consumption; if(fixture.HoldSet) { await fixture.SetEntered.Task; Check(!action.Confirmed && !action.Continued,"real player pending cannot continue"); // Drive the actual due recovery tick while the original Set is still held. // No replacement serialization, no paused production recovery algorithm. player.CoRecoveryEnergy(tables.TbGlobalConfig.InitEnergyRestorationSpeed*60); Check((int)player.Energy==initialEnergy-2 && !action.Confirmed,"real recovery can write during pending"); fixture.ReleaseSet.TrySetResult(true); } await consumption; pending=null; Check((int)player.Energy==initialEnergy-(fixture.HoldSet?2:3) && player.GetEnergyRM()==10 && consumed==3,"one real deduction and event"); Check(notified==(scenario=="cross-limit"?2:fixture.HoldSet?3:1),"original notification count preserved"); var modern=JsonConvert.DeserializeObject>(fixture.Remote["NewPlayerDataDic"]); Check(modern["newData"].Split(',')[3]==(initialEnergy-3).ToString() && !modern.ContainsKey("energy"),"one canonical representation"); Check(fixture.Remote["k"]=="task-3" && action.Confirmed,"synchronous task write confirmed"); Check(achievements.GetAchievementDatas(cfg.ConditionType.ConsumeEnergy)==3 && modern["ad"].Contains(":") && modern["ad"]==achievements.AchievementDataToJson(),"actual achievement subscriber captured after event"); var savedTime=DateTime.Parse(modern["newData"].Split(',')[7]); Check(scenario=="cross-limit"?savedTime>new DateTime(2026,1,1):savedTime==new DateTime(2026,1,1),"recovery timestamp crossing behavior preserved"); if(fixture.HoldSet) { await KVMgr.FlushKVAsync(action.Owner); var recovered=JsonConvert.DeserializeObject>(fixture.Remote["NewPlayerDataDic"]); Check(recovered["newData"].Split(',')[3]==(initialEnergy-2).ToString(),"later recovery has separate flush, not original action confirmation"); } if(subscribers!=null) { Check(subscribers.Battle.Data.BattleTask.dic_dailyTasks.Values.Single()==3 && subscribers.Lucky.DicTasks.Values.Single()==3 && subscribers.Magic.DicTasks.Values.Single()==3,"actual task subscriptions each receive one consume event"); Check(subscribers.Mini.Data.Exp==3,"actual mini BP subscription receives the event"); Check(new[]{"BattlePassRecordTask","MiniBPKeyNew","EventLuckGameRecord","EventLuckMagicPlayfabData"}.All(fixture.Remote.ContainsKey),"all actual subscriber save methods confirmed in original action"); } var parsed=new PlayerKVSchemaSnapshot("fixed-fishing",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys, new Dictionary(),System.Globalization.CultureInfo.CurrentCulture,new DateTime(2026,9,24,0,0,0,DateTimeKind.Utc),0); var errors=new PlayerKVSchemaDiagnostics(); Check(PlayerKVSaveSchema.DecodeCore(new Dictionary(),modern,parsed,errors)!=null && errors.TotalErrors==0,"actual serialized core loads without alias conflict"); } } result=JsonConvert.SerializeObject(new{success=true,groups,assertions=checks,passed,formalRequests=0,synthetic=true,restored=true,folder}); } finally { try { subscribers?.Dispose(); subscribers=null; player?.StopRecoveryEnergy(); subscription?.Dispose(); if(fixture!=null) await fixture.Close(); if(pending!=null) { try { await pending; } catch(Exception e) { Debug.LogWarning("Observed fishing test failure: "+e.GetType().Name); } } FishingContextAccess.Use(oldContainer); FishingContextAccess.Events=oldEvents; container?.Dispose(); LocalizationMgr.enumLanguage=oldLanguages; textTable.SetValue(null,oldTextTable); (Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction)=saved; } catch { validationCleanupFailure=fixture; throw; } finally { Interlocked.Exchange(ref validationRunning,0); } } File.WriteAllText(Path.Combine(folder,"result.json"),result); return result; } // Fixed real subscriber fixture. Reflection targets are compiled constants, never supplied by CLI. sealed class FishingSubscribers:IDisposable { const System.Reflection.BindingFlags Fields=System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic; static readonly System.Reflection.FieldInfo PF=typeof(PlayFabMgr).GetField("_instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic); static readonly System.Reflection.FieldInfo Red=typeof(RedPointManager).GetField("instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic); readonly object oldRed=Red.GetValue(null),oldPF=PF.GetValue(null); GameObject shell; LuckyTaskData magicTask; MiniBattlePassDataProvider provider; internal BattlePassDataProvider Battle; internal EventLuckyGameModel Lucky; internal EventLuckMagicModel Magic; internal MiniBattlePassDataModel Mini; internal void Bind(cfg.Tables tables,FishingEventData events,Container container) { Red.SetValue(null,new RedPointManager()); shell=new GameObject("KV inactive subscriber storage fixture");shell.SetActive(false);shell.hideFlags=HideFlags.HideAndDontSave; var pf=shell.AddComponent(); if(!pf.IsUnstartedForKVLoad)throw new InvalidOperationException("No PF lifecycle may start in this fixture."); PF.SetValue(null,pf); var items=new PlayerItemData(tables);container.RegisterInstance(items); var data=new BattlePassData(tables); data.BattlePassRecord.ID=tables.TbFishingEvent.DataList.First(e=>tables.TbBattlePassMain.GetOrDefault(e.RedirectID)!=null).ID; data.BattlePassRecord.Level=1; data.BattleTask.dic_dailyTasks.Add(tables.TbBattlePassTask.DataList.First(t=>t.Type==cfg.ConditionType.ConsumeEnergy).TaskID,0); Battle=new BattlePassDataProvider(tables,data,items);container.RegisterInstance(Battle); var common=tables.TbCommonTask.DataList.First(t=>t.TaskType==cfg.ConditionType.ConsumeEnergy).Id; Lucky=new EventLuckyGameModel(events,tables);Lucky.DicTasks.Add(common,0); Magic=new EventLuckMagicModel{DicTasks=new Dictionary{{common,0}},HigherRingData=EventLuckMagicHigherRingData.CreateEmpty()}; var rings=(List)typeof(EventLuckMagicModel).GetField("_rings",Fields).GetValue(Magic); for(int i=1;i<=3;i++)rings.Add(EventLuckMagicRing.CreateEmpty(1,0,(EEventLuckMagicRingType)i)); magicTask=new LuckyTaskData(Magic);typeof(EventLuckMagicModel).GetField("_taskData",Fields).SetValue(Magic,magicTask); provider=new MiniBattlePassDataProvider(tables,items); var table=tables.TbMiniBattlePass.DataList.First(t=>t.TaskType==cfg.ConditionType.ConsumeEnergy && t.Param==0); var activity=tables.TbFishingEvent.DataList.First(t=>t.Type==9 && t.RedirectID==table.Id); var type=(MiniBattlePassType)table.Type; Mini=provider.GetModel(type); provider.GetMiniBattlePassRecord("BP_"+type).Id=activity.ID; typeof(MiniBattlePassDataModel).GetField("fishingEvent",Fields).SetValue(Mini,activity); typeof(MiniBattlePassDataModel).GetMethod("InitData",Fields).Invoke(Mini,new object[]{table.Id}); // Synthetic fixture has an active window; real-run time is never modified. Mini.startTime=ZZTimeHelper.UtcNow().AddDays(-1); typeof(MiniBattlePassDataModel).GetProperty("endTime").SetValue(Mini,ZZTimeHelper.UtcNow().AddDays(1)); } public void Dispose() { Battle?.Dispose();Lucky?.Dispose();magicTask?.Dispose(); if(Mini!=null)(typeof(MiniBattlePassDataModel).GetField("disposables",Fields).GetValue(Mini) as IDisposable)?.Dispose(); PF.SetValue(null,oldPF);Red.SetValue(null,oldRed); if(shell!=null)UnityEngine.Object.DestroyImmediate(shell); } } sealed class FishingContextAccess:GContext { protected override Task SetupAsync()=>throw new InvalidOperationException("Test-only context."); internal static void Use(Container value) { container=value; } internal static IEventAggregator Events { get=>eventAggregator; set=>eventAggregator=value; } } sealed class FishingNotifications:ILocalNotificationService { public Task Init()=>Task.CompletedTask; public bool IsNoticeOpen=>false; public void OpenSettings(){} public void ScheduleOneTimeNotification(int id,DateTime time,string title,string text){} public void ScheduleDailyNotification(int id,DateTime time,string title,string text){} public void ClearAllNotification(){} public void ClearNotification(int id){} public void ScheduleDefaultNotification(){} } } } #endif