#if UNITY_EDITOR && !SERVICE_HOST using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using asap.core; using GameCore; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UniRx; using UnityEngine; namespace game.PlayerDataStorage { internal static partial class PlayerKVGameplayLoop { internal static async Task VerifyFishingRewardForEditorAsync() { if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || Resources.FindObjectsOfTypeAll().Length!=0 || PlayFabMgr.HasLiveInstanceForKVLoad || Enabled || handle!=null || File.Exists(Path.Combine(Root,"permit.json")) || validationCleanupFailure!=null) throw new InvalidOperationException("Idle editor without a real permit required."); if(Interlocked.CompareExchange(ref validationRunning,1,0)!=0) throw new InvalidOperationException("Validation running."); var saved=(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction,rewardPlan,rewardAction); var oldContainer=GContext.container;var oldEvents=FishingContextAccess.Events; var folder=Path.Combine(Root,"reward-fixed-runs",DateTime.UtcNow.ToString("yyyyMMddTHHmmss")+"_"+Guid.NewGuid().ToString("N")); EquipmentFixture fixture=null;Task pending=null;Container container=null;PlayerData player=null; RewardSubscribers subscribers=null; var oldLanguages=LocalizationMgr.enumLanguage; var textTable=typeof(LocalizationMgr).GetField("table",BindingFlags.Static|BindingFlags.NonPublic); var oldTextTable=textTable.GetValue(null); GameObject pfShell=null;var pfField=typeof(PlayFabMgr).GetField("_instance",BindingFlags.Static|BindingFlags.NonPublic);var oldPF=pfField.GetValue(null); int checks=0,groups=0;var passed=new List();string result=null; var ioDiagnostics=new System.Collections.Concurrent.ConcurrentQueue(); EventHandler observeIO=(_,e)=> {if(e.Exception is IOException || e.Exception is UnauthorizedAccessException)ioDiagnostics.Enqueue(e.Exception.ToString());}; AppDomain.CurrentDomain.FirstChanceException+=observeIO; void Check(bool yes,string reason) {checks++;if(!yes)throw new InvalidOperationException("Reward validation: "+reason);} async Task Start(string name) { subscribers?.Dispose();subscribers=null; player?.StopRecoveryEnergy();player=null; if(fixture!=null)await fixture.Close();if(pending!=null){try{await pending;}catch{}pending=null;} pfField.SetValue(null,oldPF);if(pfShell!=null)UnityEngine.Object.DestroyImmediate(pfShell);pfShell=null; FishingContextAccess.Use(oldContainer);FishingContextAccess.Events=oldEvents;container?.Dispose();container=null; fixture=new EquipmentFixture(Path.Combine(folder,(++groups).ToString())); fixture.Remote["Map_5_102"]="{\"10211\":\"0|10|20|20|False\",\"10241\":\"2|20|30|40|False\"}"; var owner=await fixture.Open(); Enabled=true;Ready=false;Error=null;failed=0;handle=null;collecting=null; Permit=new PlayerKVLoopPermit{Version=3,Operation="fishing-reward-once",PlayerId="101", AllowedKeys=new[]{"NewPlayerDataDic","Map_5_102","BattlePassRecordTask","late"}}; RunPath=folder;mainThread=Thread.CurrentThread.ManagedThreadId;mainContext=SynchronizationContext.Current; Attach(_=>fixture.Faults++,fixture.Close);Publish(owner); fishingAction=new FishingAction(new object(),owner){Consuming=true,Confirmed=true,Continued=true}; rewardPlan=new FishingRewardPlan{Fish=10211,Area=102}; rewardAction=new FishingRewardAction(fishingAction,new object(),rewardPlan); passed.Add(name); } PlayerKVEditResult Batch(bool localFinal=false) { using(BeginRewardSegment("strike")) Write("BattlePassRecordTask","strike",true); rewardAction.Ending=true;using(BeginRewardSegment("ending")){} rewardAction.Applying=true; PlayerKVEditResult primary; using(BeginRewardSegment("reward")) { Write("BattlePassRecordTask","reward-task",true); Write("Map_5_102","fish",!localFinal); Write("NewPlayerDataDic","intermediate",false); primary=Write("NewPlayerDataDic","reward-main",true); } rewardAction.GuideNotifications=1; return primary; } async Task Reject(Task task,string label) { bool rejected=false;try{await task;}catch(InvalidOperationException){rejected=true;} Check(rejected && !Ready && fixture.Faults==1,label); } void RejectSync(Action body,string label) {bool rejected=false;try{body();}catch(InvalidOperationException){rejected=true;}Check(rejected,label);} try { Directory.CreateDirectory(folder); // Exercise the real async Init path, using isolated services and the actual loaded guide asset. var potionSource=JObject.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath, "../../CodexOutput/upserver/KVGameplayLoopTests/backups/20260928T082052Z/101.json"))))["data"].ToObject>(); var guideAssetHandle=UnityEngine.AddressableAssets.Addressables.LoadAssetAsync("GuidanceConfig"); var guideAsset=(GuidanceDefineSetting)guideAssetHandle.WaitForCompletion(); var savedGuideGroups=guideAsset.guidanceGroupDefines; var awaitAssembly=typeof(IEnumeratorAwaitExtensions).Assembly; var syncType=awaitAssembly.GetType("UnityAsyncAwaitUtil.SyncContextUtil",true); var syncField=syncType.GetField("k__BackingField",BindingFlags.Static|BindingFlags.NonPublic); var threadField=syncType.GetField("k__BackingField",BindingFlags.Static|BindingFlags.NonPublic); var runnerField=awaitAssembly.GetType("UnityAsyncAwaitUtil.AsyncCoroutineRunner",true).GetField("_instance",BindingFlags.Static|BindingFlags.NonPublic); var savedSync=syncField.GetValue(null);var savedThread=threadField.GetValue(null);var savedRunner=runnerField.GetValue(null); syncField.SetValue(null,SynchronizationContext.Current);threadField.SetValue(null,Thread.CurrentThread.ManagedThreadId); try { foreach(var scenario in new[]{"unavailable-real-101","available","query-failure","node-panel-mismatch", "other-group","finished","index-zero","missing-save","out-of-range","preserve-other-pointer"}) { await Start("potion-actual-init-"+scenario); rewardPlan=null;rewardAction=null; Permit.AllowedKeys=Permit.AllowedKeys.Concat(new[]{"GuideSaveDataNew"}).ToArray(); var tables=new cfg.Tables(n=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData",n+".json"))))); container=new Container();FishingContextAccess.Use(container);FishingContextAccess.Events=new EventAggregator(); player=new PlayerData(tables,null);player.lv=62; container.RegisterInstance(tables);container.RegisterInstance(player); var eventData=new FishingEventData(tables); if(scenario!="query-failure")container.RegisterInstance(eventData); if(scenario=="available") { var ev=tables.TbFishingEvent.DataList.First(e=>e.Type==4 && e.SubType==5); typeof(cfg.FishingEvent).GetProperty("TimeDefinition").SetValue(ev,new cfg.LimitedTime( ZZTimeHelper.UtcNow().AddHours(-1).ToString("yyyy/MM/dd HH:mm:ss",System.Globalization.CultureInfo.InvariantCulture), ZZTimeHelper.UtcNow().AddHours(1).ToString("yyyy/MM/dd HH:mm:ss",System.Globalization.CultureInfo.InvariantCulture))); typeof(cfg.FishingEvent).GetProperty("ConditionList").SetValue(ev,new List{ new cfg.EventCondition(cfg.ConditionType.AccountLevel,new List{"1"},"")}); Check(eventData.GetEvent(4,5)==ev.ID,"isolated actual query sees available potion event"); } else if(scenario!="query-failure")Check(eventData.GetEvent(4,5)==-1,"isolated actual query sees no potion event"); var guideState=new GuideDataCenter(tables,player);container.RegisterInstance(guideState); var diagnostics=new PlayerKVSchemaDiagnostics();guideState.ReplaceFromKVDecoded(GuideDataCenter.DecodeForKVLoad(potionSource,diagnostics)); Check(diagnostics.TotalErrors==0,"real 101 guide source decoded"); var potionGroup=JsonUtility.FromJson(JsonUtility.ToJson(savedGuideGroups.First(g=>g.groupName==GroupName.EventPotion01))); guideAsset.guidanceGroupDefines=savedGuideGroups.Select(g=>g.groupName==GroupName.EventPotion01?potionGroup:g).ToArray(); var key=GroupName.EventPotion01.ToString();var entry=guideState.GuideSaveDataDic[key]; Check(entry.guideSubIndex==4 && !entry.isFinish,"real historical potion progress is unfinished at four"); if(scenario=="node-panel-mismatch")potionGroup.guidanceDefines[entry.guideSubIndex].panelName="DifferentPanel"; if(scenario=="other-group") { guideState.GuideSaveDataDic.Remove(key);key=GroupName.EventPotion02.ToString(); entry.groupName=key;guideState.GuideSaveDataDic[key]=entry; guideAsset.guidanceGroupDefines=guideAsset.guidanceGroupDefines.Where(g=>g.groupName!=GroupName.EventPotion02).ToArray(); potionGroup.groupName=GroupName.EventPotion02; } if(scenario=="finished")entry.isFinish=true; if(scenario=="index-zero")entry.guideSubIndex=0; if(scenario=="missing-save")guideState.GuideSaveDataDic.Remove(key); if(scenario=="out-of-range")entry.guideSubIndex=potionGroup.guidanceDefines.Length; var otherGroup=savedGuideGroups.First(g=>g.groupName==GroupName.Fish_01); if(scenario=="preserve-other-pointer") { guideState.curGroupDefine=otherGroup;guideState.curDefine=otherGroup.guidanceDefines[0]; guideState.curSaveData=guideState.GuideSaveDataDic[otherGroup.groupName.ToString()]; } var before=JsonConvert.SerializeObject(guideState.GuideSaveDataDic); pfShell=new GameObject("KV inactive guide Init fixture");pfShell.SetActive(false);pfShell.hideFlags=HideFlags.HideAndDontSave; var pf=pfShell.AddComponent(); Check(pf.IsUnstartedForKVLoad,"guide fixture never starts PF lifecycle");pfField.SetValue(null,pf); var initErrors=new List();var initWarnings=new List();int completeEvents=0; Application.LogCallback onLog=(message,stack,type)=>{if(type==LogType.Exception || type==LogType.Error)initErrors.Add(message);if(type==LogType.Warning)initWarnings.Add(message);}; Application.logMessageReceived+=onLog; var completion=GContext.OnEvent().Subscribe(e=>{if(e.type==cfg.ConditionType.CompleteGuide)completeEvents++;}); bool initStarted=false; try { // Asset is preloaded above: Init has no outstanding asset operation when teardown begins. initStarted=true;guideState.Init(); var deadline=System.Diagnostics.Stopwatch.StartNew(); while(guideState.guidanceGroupDefines==null && deadline.ElapsedMilliseconds<10000)await Task.Delay(10); await Task.Delay(1); Check(guideState.guidanceGroupDefines!=null && initErrors.Count==0,"actual Init completed without asynchronous errors: "+scenario+"; "+string.Join(" | ",initErrors)); if(scenario=="unavailable-real-101") { Check(guideState.curDefine==null && guideState.curGroupDefine==null && guideState.curSaveData==null, "unavailable historical potion guide is not activated by actual Init"); Check(before==JsonConvert.SerializeObject(guideState.GuideSaveDataDic),"deferred actual Init preserves every guide entry"); guideState.AddIndex(true);GContext.Publish(new GuideNextEvent()); Check(before==JsonConvert.SerializeObject(guideState.GuideSaveDataDic) && completeEvents==0, "real AddIndex and GuideNext cannot finish deferred potion progress"); Check(!handle.ReadLocalSnapshot().Data.ContainsKey("GuideSaveDataNew") && fixture.Sets==0, "deferral and callbacks introduce no guide write or remote request"); } else if(scenario=="preserve-other-pointer") Check(ReferenceEquals(guideState.curGroupDefine,otherGroup) && ReferenceEquals(guideState.curDefine,otherGroup.guidanceDefines[0]),"deferral does not clear another active guide"); else if(scenario=="available" || scenario=="query-failure" || scenario=="node-panel-mismatch" || scenario=="other-group") Check(ReferenceEquals(guideState.curGroupDefine,potionGroup) && guideState.curDefine!=null,"non-deferred input retains original recovery: "+scenario); else Check(guideState.curGroupDefine==null && guideState.curDefine==null,"original non-resuming boundary retained: "+scenario); if(scenario!="missing-save")Check(ReferenceEquals(guideState.GuideSaveDataDic[key],entry),"saved progress object retained: "+scenario); if(scenario=="query-failure")Check(initWarnings.Any(w=>w.Contains("Potion guide availability")),"query failure is reported and not treated as unavailable"); } finally { completion.Dispose();Application.logMessageReceived-=onLog;guideState.Dispose(); guideAsset.guidanceGroupDefines=savedGuideGroups; if(initStarted)UnityEngine.AddressableAssets.Addressables.Release(guideAsset); } } } finally { guideAsset.guidanceGroupDefines=savedGuideGroups; UnityEngine.AddressableAssets.Addressables.Release(guideAssetHandle); var runner=runnerField.GetValue(null) as MonoBehaviour; if(runner!=null && !ReferenceEquals(runner,savedRunner))UnityEngine.Object.DestroyImmediate(runner.gameObject); runnerField.SetValue(null,savedRunner);syncField.SetValue(null,savedSync);threadField.SetValue(null,savedThread); } await Start("original-tickets-cross-segment-supersession");var primary=Batch(); Check(collecting==null && rewardAction.Segment==null && rewardAction.Records.Count==5,"three scopes disposed and all original writes captured"); await ConfirmRewardAsync(rewardAction,primary);int shows=0;PresentReward(rewardAction,()=>shows++);PresentReward(rewardAction,()=>shows++); Check(shows==1 && fixture.Remote["Map_5_102"]=="fish" && fixture.Remote["BattlePassRecordTask"]=="reward-task","final values and one presentation"); await Start("pending-cannot-present");primary=Batch();fixture.HoldSet=true; pending=ConfirmRewardAsync(rewardAction,primary);await fixture.SetEntered.Task; Check(!rewardAction.Confirmed && !rewardAction.Presented,"pending is not success"); fixture.ReleaseSet.TrySetResult(true);await pending;pending=null;Check(rewardAction.Confirmed,"response confirms original action"); await Start("localOnly-map-final-rejected");await Reject(ConfirmRewardAsync(rewardAction,Batch(true)),"local map not remote confirmed"); await Start("outside-segment-before-final-save-rejected"); using(BeginRewardSegment("strike"))Write("BattlePassRecordTask","strike",true); RejectSync(()=>Write("NewPlayerDataDic","external",true),"external changed write denied before later reward can hide it"); Check(fixture.Sets==0 && ReadRejected(),"stopped facade and no outgoing request"); await Start("same-value-external-supersession-still-rejected");primary=Batch(); Write("NewPlayerDataDic","reward-main",true); await Reject(ConfirmRewardAsync(rewardAction,primary),"same-value external ticket is not the original final ticket"); await Start("unexpected-backed-up-key-before-admission"); using(BeginRewardSegment("strike"))RejectSync(()=>Write("late","unrelated",true),"backed-up but unrelated reward key rejected"); Check(rewardAction.Records.Count==0 && fixture.Sets==0,"no unrelated admission"); await Start("lost-response-no-reward-replay");primary=Batch();fixture.LoseResponse=true; await Reject(ConfirmRewardAsync(rewardAction,primary),"unknown result stops reward");Check(fixture.Sets==1 && !rewardAction.Presented,"single request only"); await Start("unauthorized-no-reauthentication");primary=Batch();fixture.Unauthorized=true; await Reject(ConfirmRewardAsync(rewardAction,primary),"401 stops reward");Check(fixture.Auth==1 && fixture.Sets==1,"no relogin or replay"); await Start("partial-commit-is-not-success");primary=Batch();fixture.PartialCommit=true;fixture.LoseResponse=true; await Reject(ConfirmRewardAsync(rewardAction,primary),"partial result remains failure");Check(fixture.Sets==1 && !rewardAction.Confirmed,"no refund or second write"); await Start("cancel-after-consume-before-reward-confirmed");primary=Batch();fixture.HoldSet=true; pending=ConfirmRewardAsync(rewardAction,primary);await fixture.SetEntered.Task; CancelFishing(fishingAction,"actual consumed action cancellation");fixture.ReleaseSet.TrySetResult(true); await Reject(pending,"C1 Continued cannot exempt pending reward");pending=null;Check(rewardAction.Cancelled && !rewardAction.Presented,"old callbacks revoked"); await Start("confirmed-normal-cancel-no-second-exit");primary=Batch();await ConfirmRewardAsync(rewardAction,primary); CancelReward(rewardAction.Controller,"normal disable");CancelFishing(fishingAction,"normal actor destroy"); Check(rewardAction.Cancelled && fishingAction.Cancelled && fixture.Faults==0 && Ready,"confirmed normal disable never faults"); await Start("battle-controller-disable-still-cancels"); CancelReward(rewardAction.Controller,"controller disabled"); Check(rewardAction.Cancelled && !Ready && fixture.Faults==1,"battle controller disable still cancels"); await Start("ending-teardown-pre-confirm-not-cancel");primary=Batch(); CancelReward(rewardAction.Controller,"controller disabled");CancelReward(rewardAction.Controller,"controller destroyed"); Check(!rewardAction.Cancelled && Ready && fixture.Faults==0,"ending teardown window is not a cancellation"); await ConfirmRewardAsync(rewardAction,primary);shows=0;PresentReward(rewardAction,()=>shows++); Check(shows==1 && rewardAction.Confirmed && rewardAction.Presented,"reward completes and presents after teardown window"); await Start("repeated-segment-and-sealed-action-rejected"); using(BeginRewardSegment("strike")){} RejectSync(()=>BeginRewardSegment("strike"),"strike cannot repeat"); await Start("reward-storage-error-before-send"); var file=Directory.GetFiles(Path.Combine(folder,groups.ToString()),"state.json",SearchOption.AllDirectories).Single(); using(var locked=new FileStream(file,FileMode.Open,FileAccess.Read,FileShare.None)) using(BeginRewardSegment("strike"))RejectSync(()=>Write("NewPlayerDataDic","locked",true),"real state-file storage failure"); Check(fixture.Sets==0 && !Ready,"storage fault before network"); foreach(var scenario in new[]{"non-record","new-record","new-fish"}) { await Start("real-fish-final-save-"+scenario); var tables=new cfg.Tables(n=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData",n+".json"))))); container=new Container();FishingContextAccess.Use(container);FishingContextAccess.Events=new EventAggregator();container.RegisterInstance(tables); player=new PlayerData(tables,null);var fish=new PlayerFishData(tables,null);container.RegisterInstance(player);container.RegisterInstance(fish); var core=new PlayerKVCoreState(new object[]{60,100UL,0,393,5,4,30003,new DateTime(2026,1,1),0,112,7UL,2,102,102,13,102,0,0,0}); player.ReplaceFromKVDecoded(new PlayerKVPlayerState(core,new Dictionary(),new Dictionary{{-1,1}},new List(),0,false,"0")); var data=new Dictionary{{10241,FishWeightData.Deserialize("2|20|30|40|False")}}; if(scenario!="new-fish")data[10211]=FishWeightData.Deserialize("0|10|20|20|False"); var fields=BindingFlags.Instance|BindingFlags.NonPublic; typeof(PlayerFishData).GetField("fishData",fields).SetValue(fish,data); typeof(PlayerFishData).GetField("mapFishData",fields).SetValue(fish,new Dictionary>{{102,data}}); pfShell=new GameObject("KV reward unstarted PF fixture");pfShell.SetActive(false);pfShell.hideFlags=HideFlags.HideAndDontSave; var pf=pfShell.AddComponent();Check(pf.IsUnstartedForKVLoad,"PF lifecycle never starts");pfField.SetValue(null,pf); rewardAction.BeforeFishCount=scenario=="new-fish"?0:20; using(BeginRewardSegment("strike")){}rewardAction.Ending=true;using(BeginRewardSegment("ending")){} rewardAction.Applying=true; var events=new List();using(GContext.OnEvent().Subscribe(e=>events.Add(e.type))) using(BeginRewardSegment("reward")) { fish.SetGetFishCount(10211);fish.SetWeight(10211,scenario=="new-record"?12:5); fish.SaveCaughtFishForKVLoop(10211); primary=Write("NewPlayerDataDic","fixture-main",true); } rewardAction.GuideNotifications=1;await ConfirmRewardAsync(rewardAction,primary); var savedMap=JsonConvert.DeserializeObject>(fixture.Remote["Map_5_102"]); var value=FishWeightData.Deserialize(savedMap[10211]); Check(value.TotalNum==(scenario=="new-fish"?10:30),"original count increment exactly once"); Check(value.MaxG==(scenario=="new-record"?12:scenario=="new-fish"?5:10),"original maximum weight rule"); Check(savedMap[10241]=="2|20|30|40|False" && value.TotalCard==(scenario=="new-fish"?0:20) && value.Level==0,"other fish and card data unchanged"); Check(events.Count==2 && events.Contains(cfg.ConditionType.GetFishWeight) && events.Contains(cfg.ConditionType.GetFishID),"final save never republishes fish events"); var fishSchema=new PlayerKVSchemaSnapshot("reward-fish",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys,new Dictionary(),System.Globalization.CultureInfo.CurrentCulture,new DateTime(2026,9,24),0, fish:tables.TbFishData.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishConfig(p.Key,p.Value.AreaID,p.Value.Quality,p.Value.FishCardID,p.Value.BaseWeight,p.Value.PointExtraParam,p.Value.WeightRandom)), cards:tables.TbFishCard.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishCardConfig(p.Key,p.Value.MaxLevel,p.Value.CardsRequiredList,p.Value.WeightMagList))); var fishErrors=new PlayerKVSchemaDiagnostics(); var decodedFish=PlayerKVSaveSchema.DecodeFish(new Dictionary{{"Map_5_102",fixture.Remote["Map_5_102"]}},new Dictionary(),fishSchema,fishErrors); Check(decodedFish!=null && fishErrors.TotalErrors==0 && decodedFish.Maps[102][10211].Number==value.TotalNum && decodedFish.RawMaps[102][10211].Weight==value.MaxG,"real Map schema reads the saved catch"); } foreach(var cash in new[]{0,122}) { await Start("actual-main-reward-and-subscribers-cash-"+cash); var tables=new cfg.Tables(n=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData",n+".json"))))); container=new Container();FishingContextAccess.Use(container);FishingContextAccess.Events=new EventAggregator();container.RegisterInstance(tables); LocalizationMgr.enumLanguage=tables.TbLanguageConfig.DataList.Select(x=>x.ID).ToList();textTable.SetValue(null,null); player=new PlayerData(tables,null);var fish=new PlayerFishData(tables,null);var small=new SmallGameData(tables,null,null); var achievements=new AchievementDataManager(tables);var box=new FishingBoxData();var events=new FishingEventData(tables); container.RegisterInstance(player);container.RegisterInstance(fish);container.RegisterInstance(small);container.RegisterInstance(achievements);container.RegisterInstance(box);container.RegisterInstance(events); fish.ResetForKVLoad();small.ResetForKVLoad();achievements.ResetForKVLoad();box.ResetForKVLoad();achievements.Init(); var core=new PlayerKVCoreState(new object[]{60,100UL,0,393,5,4,30003,new DateTime(2026,1,1),0,112,7UL,2,102,102,13,102,0,0,0}); player.ReplaceFromKVDecoded(new PlayerKVPlayerState(core,new Dictionary(),new Dictionary{{-1,1}},new List(),0,false,"0")); subscribers=new RewardSubscribers();subscribers.Bind(tables,events,container); Permit.AllowedKeys=Permit.AllowedKeys.Concat(rewardTaskKeys).Distinct().ToArray(); int countEvents=0;var seen=new List(); using(GContext.OnEvent().Subscribe(e=>{seen.Add(e.type);if(e.type==cfg.ConditionType.GetFishCount)countEvents++;})) { using(BeginRewardSegment("strike"))GContext.Publish(new ConditionTypeEvent(cfg.ConditionType.PerfectStrikeCount,1)); rewardAction.Ending=true;using(BeginRewardSegment("ending")){} rewardAction.Applying=true; using(BeginRewardSegment("reward")) { // Actual unchanged data methods; visual/formula extraction is tested separately. player.SaveFishingFrequency(); GContext.Publish(new ConditionTypeEvent(cfg.ConditionType.CashFromFishing,cash)); GContext.Publish(new ConditionTypeEvent(cfg.ConditionType.MaxCashFromFishing,cash/10)); if(cash>0)player.AddGold((ulong)cash); foreach(var condition in new[]{cfg.ConditionType.GetFishWeight,cfg.ConditionType.GetFishID,cfg.ConditionType.GetBoosFish,cfg.ConditionType.GetBiggerThanHugeFishCount}) GContext.Publish(new ConditionTypeEvent(condition,2)); Write("Map_5_102",Read("Map_5_102"),true); primary=player.SaveFishingRewardForKVLoop(); } } rewardAction.GuideNotifications=1;await ConfirmRewardAsync(rewardAction,primary); var modern=JsonConvert.DeserializeObject>(fixture.Remote["NewPlayerDataDic"]); var coreValues=modern["newData"].Split(','); Check(coreValues[1]==(100+cash).ToString() && coreValues[14]=="14" && coreValues[15]=="112","cash and one catch with original x10 total frequency"); Check(coreValues[3]=="393" && coreValues[9]=="112" && countEvents==1,"reward never consumes again or repeats count event"); Check(modern["ad"]==achievements.AchievementDataToJson() && achievements.GetAchievementDatas(cfg.ConditionType.GetFishCount)==1,"last primary serializes real achievement subscriber"); Check(modern.ContainsKey("ld") && modern["ld"].Split('|').Length==3,"original luck serialization preserved"); Check(subscribers.Battle.Data.BattleTask.dic_dailyTasks.Any(k=>tables.TbBattlePassTask.GetOrDefault(k.Key).Type==cfg.ConditionType.PerfectStrikeCount && k.Value==1),"S1 real battle task writes"); Check(subscribers.Lucky.DicTasks.All(k=>k.Value==0) && subscribers.Magic.DicTasks.All(k=>k.Value==0) && subscribers.Mini.Data.Exp==0,"actual lucky/magic/mini ignore reward conditions absent from their real config"); foreach(var condition in rewardConditions.Where(x=>x!=cfg.ConditionType.ConsumeEnergy)) Check(seen.Count(x=>x==condition)==1,"reward condition once: "+condition); Check(fixture.Remote.ContainsKey("BattlePassRecordTask") && !fixture.Remote.ContainsKey("MiniBPKeyNew") && !fixture.Remote.ContainsKey("EventLuckGameRecord") && !fixture.Remote.ContainsKey("EventLuckMagicPlayfabData"),"only applicable original task saves confirmed; no invented task updates"); var schema=new PlayerKVSchemaSnapshot("reward-main",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys,new Dictionary(),System.Globalization.CultureInfo.CurrentCulture,new DateTime(2026,9,24),0); var errors=new PlayerKVSchemaDiagnostics();Check(PlayerKVSaveSchema.DecodeCore(new Dictionary(),modern,schema,errors)!=null && errors.TotalErrors==0,"main schema reads final original serializer"); } await Start("actual-reentry-and-old-permit-gates"); Check(!BeginRewardDrawing(rewardAction.Controller),"duplicate drawing never reaches original battle"); rewardAction.Ending=true;Check(!BeginRewardEnding(rewardAction.Controller,true),"duplicate fish end never opens another panel"); rewardAction.Applying=true;Check(BindRewardPanel(new object())==null,"second panel never reapplies rewards"); Permit.Version=2;Permit.Operation="fishing-consume-once";RejectSync(()=>BeginRewardDrawing(new object()),"v2 cannot draw"); await Start("v1-cannot-draw");Permit.Version=1;Permit.Operation=null;RejectSync(()=>BeginRewardDrawing(new object()),"v1 cannot draw"); await Start("natural-failure-stops-without-reward"); RejectSync(()=>BeginRewardEnding(rewardAction.Controller,false),"natural failure not converted to catch"); Check(fixture.Sets==0 && rewardAction.Records.Count==0 && !rewardAction.Applying,"failure has no reward or refund"); foreach(bool complete in new[]{false,true}) { await Start(complete?"actual-panel-disable-confirmed":"actual-panel-disable-pending"); primary=Batch();if(complete)await ConfirmRewardAsync(rewardAction,primary); var shell=new GameObject("KV inactive reward panel lifecycle fixture");shell.SetActive(false);shell.hideFlags=HideFlags.HideAndDontSave; try { var panel=shell.AddComponent();rewardAction.Panel=panel; typeof(FishingRewardPanel).GetMethod("OnDisable",BindingFlags.Instance|BindingFlags.NonPublic).Invoke(panel,null); Check(rewardAction.Cancelled && fixture.Faults==(complete?0:1),"actual panel OnDisable revokes pending only"); Check(!RewardPanelCurrent(rewardAction,panel),"old panel continuation is revoked"); } finally{UnityEngine.Object.DestroyImmediate(shell);} } await Start("real-backup-no-special-fish-guide-preflight"); container=new Container();FishingContextAccess.Use(container);FishingContextAccess.Events=new EventAggregator(); var guide=new GuideDataCenter(null,null);container.RegisterInstance(guide); var backup=JsonConvert.DeserializeObject>(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../CodexOutput/upserver/KVGameplayLoopTests/backups/20260924T084836Z/102.json")))); var raw=JsonConvert.DeserializeObject>(backup["data"].ToString()); var guideErrors=new PlayerKVSchemaDiagnostics();guide.ReplaceFromKVDecoded(GuideDataCenter.DecodeForKVLoad(raw,guideErrors)); guide.guidanceGroupDefines=UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/ABPackage/pkgPrepare/Export/GuidanceConfig.asset").guidanceGroupDefines; Check(guideErrors.TotalErrors==0,"real guide backup decodes"); var gateTables=new cfg.Tables(n=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData",n+".json"))))); container.RegisterInstance(gateTables); rewardPlan.Data=new FishingData{curFishData=gateTables.TbFishData.GetOrDefault(10211)}; var guideBefore=JsonConvert.SerializeObject(guide.GuideSaveDataDic); CheckRewardGuide(); Check(Ready && fixture.Sets==0 && guideBefore==JsonConvert.SerializeObject(guide.GuideSaveDataDic),"ordinary fish admitted without guide writes or consumption"); var originalGroups=guide.guidanceGroupDefines; foreach(var kind in new[]{"ordinary-incomplete","active-node","active-group","last-node","extra-node","missing-node","trigger","parameter","unknown-group","positive-skill","last-skill","escape-skill","active-skill","active-dash","active-dirty"}) { // Read-only fault probes share an isolated fixture; no real run is reset. var guideCopy=JsonUtility.FromJson(JsonUtility.ToJson(originalGroups.First(g=>g.groupName.ToString()=="FishSkill01"))); var baseFish=gateTables.TbFishData.GetOrDefault(10211); var fishJson=SimpleJSON.JSON.Parse(baseFish.json.ToString()); var testFish=new cfg.FishData(fishJson); var testData=new FishingData{curFishData=testFish}; guide.guidanceGroupDefines=originalGroups.Select(g=>g.groupName==guideCopy.groupName?guideCopy:g).ToArray(); guide.curDefine=null;guide.curGroupDefine=null; guide.GuideSaveDataDic["Fish_01"].isFinish=true; switch(kind) { case "ordinary-incomplete":guide.GuideSaveDataDic["Fish_01"].isFinish=false;break; case "active-node":guide.curDefine=guideCopy.guidanceDefines[0];break; case "active-group":guide.curGroupDefine=guideCopy;break; case "last-node":guideCopy.guidanceDefines.Last().panelName="FishingRewardPanel";break; case "extra-node":guideCopy.guidanceDefines=guideCopy.guidanceDefines.Concat(new[]{new GuidanceDefine{panelName="FishingPanel_Advanced"}}).ToArray();break; case "missing-node":guideCopy.guidanceDefines=Array.Empty();break; case "trigger":guideCopy.TriggerConditionsType=GuideTriggerConditions.Next;break; case "parameter":guideCopy.TriggerConditionsParameter="changed";break; case "unknown-group":guideCopy.groupName=(GroupName)999999;break; case "positive-skill":case "last-skill":case "escape-skill": var skillJson=SimpleJSON.JSON.Parse(baseFish.FishEscape.json.ToString());skillJson["SpSkillID"]=123456789; if(kind=="escape-skill")fishJson["FishEscape"]=skillJson; else {var list=new SimpleJSON.JSONArray();if(kind=="last-skill")list.Add(SimpleJSON.JSON.Parse(baseFish.FishEscape.json.ToString()));list.Add(skillJson);fishJson["FishSKillList"]=list;} testData.curFishData=new cfg.FishData(fishJson);break; case "active-skill":testData.fishSpSkillData=new CorruptSpSkillData();break; case "active-dash":testData.superDashSpSkillData=new SuperDashSpSkillData();break; case "active-dirty":testData.dirtyStuckSpSkillData=new DirtyStuckSpSkillData();break; } // Stop() is idempotent; all cases are read-only gate probes, not fresh consumption attempts. RejectSync(()=>CheckRewardGuide(testData),"guide/skill drift denied: "+kind); Check(fixture.Sets==0,"gate drift never writes: "+kind); } guide.guidanceGroupDefines=originalGroups;guide.curDefine=null;guide.curGroupDefine=null;guide.GuideSaveDataDic["Fish_01"].isFinish=true; foreach(var scenario in new[]{"ordinary","zero-cruise","cruise","invalid-cruise","free","retry","fixed","duel","special-item","missing-map","wrong-area","event-field","event-count","trigger-pack","activity-drift","guide-before-S1","guide-before-S2","guide-before-S3"}) { await Start("actual-full-reward-preflight-"+scenario); var tables=new cfg.Tables(n=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData",n+".json"))))); container=new Container();FishingContextAccess.Use(container);FishingContextAccess.Events=new EventAggregator();container.RegisterInstance(tables); player=new PlayerData(tables,null);var fish=new PlayerFishData(tables,null);fish.ResetForKVLoad(); var events=new FishingEventData(tables);var guideState=new GuideDataCenter(tables,player); var diag=new PlayerKVSchemaDiagnostics();guideState.ReplaceFromKVDecoded(GuideDataCenter.DecodeForKVLoad(raw,diag));guideState.guidanceGroupDefines=originalGroups; guideState.guidanceDefineSetting=UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/ABPackage/pkgPrepare/Export/GuidanceConfig.asset"); var scopes=new Activity.ActivityScopeManager();var manager=new Activity.ActivityManager(tables,scopes); container.RegisterInstance(player);container.RegisterInstance(fish);container.RegisterInstance(events);container.RegisterInstance(guideState); container.RegisterInstance(new CollectingData());container.RegisterInstance(new PiggyBankPackData());container.RegisterInstance(new BargainPackData());container.RegisterInstance(new FishingChallengeManager()); container.RegisterInstance(scopes);container.RegisterInstance(manager); player.ReplaceFromKVDecoded(new PlayerKVPlayerState(new PlayerKVCoreState(new object[]{60,100UL,0,393,5,4,30003,new DateTime(2026,1,1),0,112,7UL,2,102,102,13,102,0,0,0}),new Dictionary(),new Dictionary{{-1,1}},new List(),0,false,"0")); player.IsOpenMagnification=true;fish.IsOpenPiercing=true; var data=new FishingData{curFishData=tables.TbFishData.GetOrDefault(10211),fishItem=tables.TbItem.DataList.First(x=>x.RedirectID==10211 && x.SubType==1),RodId=30003,MapId=102}; rewardPlan=null;rewardAction=null; switch(scenario) { case "zero-cruise":case "cruise":case "invalid-cruise": typeof(PlayerFishData).GetField("fishSwimToMap",BindingFlags.Instance|BindingFlags.NonPublic).SetValue(fish,new Dictionary{{102,scenario=="zero-cruise"?0:scenario=="cruise"?10211:-2}});break; case "free":data.IsFree=true;break;case "retry":data.IsRetry=true;break;case "duel":data.IsDuel=true;break; case "fixed":data.fixedFishList=tables.TbFixedFishList.DataList.First();break; case "special-item":data.fishItem=tables.TbItem.DataList.First(x=>x.SubType==11);break; case "missing-map":Permit.AllowedKeys=new[]{"NewPlayerDataDic"};break; case "wrong-area":var fj=SimpleJSON.JSON.Parse(data.curFishData.json.ToString());fj["AreaID"]=999999;data.curFishData=new cfg.FishData(fj);break; case "event-field":var ev=tables.TbFishingEvent.DataList.First(x=>x.ID==10100000);typeof(cfg.FishingEvent).GetProperty("PopupPriority").SetValue(ev,ev.PopupPriority+1);break; case "event-count":tables.TbFishingEvent.DataList.Remove(tables.TbFishingEvent.DataList.First(x=>x.ID==10100000));break; case "trigger-pack":typeof(cfg.TriggerPackManager).GetProperty("Condition").SetValue(tables.TbTriggerPackManager.DataList.First(),cfg.ConditionType.GetFishCount);break; } if(scenario=="ordinary" || scenario=="zero-cruise" || scenario=="activity-drift" || scenario.StartsWith("guide-before-")) { PrepareFishingReward(fishingAction,data); Check(Ready && rewardPlan.Data==data && fixture.Sets==0,"actual full preflight admitted before any consumption"); if(scenario=="activity-drift") {scopes.CreateActivityScope((1,1));RejectSync(CheckRewardContext,"actual activity scope drift denied");} else if(scenario.StartsWith("guide-before-")) { var actor=new object(); if(scenario!="guide-before-S1") {Check(BeginRewardDrawing(actor),"first drawing");using(BeginRewardSegment("strike")){} } if(scenario=="guide-before-S3") {Check(BeginRewardEnding(actor,true),"first ending");using(BeginRewardSegment("ending")){} } guideState.curDefine=originalGroups.First().guidanceDefines.First(); if(scenario=="guide-before-S1")RejectSync(()=>BeginRewardDrawing(actor),"active guide before drawing"); else if(scenario=="guide-before-S2")RejectSync(()=>BeginRewardEnding(actor,true),"active guide before ending"); else RejectSync(()=>BindRewardPanel(new object()),"active guide before reward data"); } else { var actor=new object();Check(BeginRewardDrawing(actor),"actual prepare-draw");using(BeginRewardSegment("strike")){} Check(BeginRewardEnding(actor,true),"actual ending");using(BeginRewardSegment("ending")){} Check(BindRewardPanel(new object())!=null,"actual panel binding with before count"); using(GContext.OnEvent().Subscribe(guideState.TriggerGuideNext)) using(BeginRewardSegment("reward")){GContext.Publish(new OnEventTriggerGuide("FishingRewardPanel"));RecordRewardGuideNotification();} Check(rewardAction.GuideNotifications==1 && guideState.curDefine==null && guideState.curGroupDefine==null,"one original guide notification and no activated guide"); } } else RejectSync(()=>PrepareFishingReward(fishingAction,data),"full gate rejects "+scenario); Check(fixture.Sets==0,"preflight case no network or consumed energy: "+scenario); } await Start("reward-confirmation-timeout-no-replay");primary=Batch();fixture.HoldSet=true; pending=ConfirmRewardAsync(rewardAction,primary);await fixture.SetEntered.Task; await Reject(pending,"actual 30-second timeout");pending=null;fixture.ReleaseSet.TrySetResult(true); Check(fixture.Sets==1 && !rewardAction.Confirmed && !rewardAction.Presented,"timeout never repeats reward"); result=JsonConvert.SerializeObject(new{success=true,groups,assertions=checks,passed,formalRequests=0,synthetic=true,folder}); } finally { try { subscribers?.Dispose();subscribers=null; LocalizationMgr.enumLanguage=oldLanguages;textTable.SetValue(null,oldTextTable); player?.StopRecoveryEnergy();if(fixture!=null)await fixture.Close();if(pending!=null){try{await pending;}catch{}} pfField.SetValue(null,oldPF);if(pfShell!=null)UnityEngine.Object.DestroyImmediate(pfShell); FishingContextAccess.Use(oldContainer);FishingContextAccess.Events=oldEvents;container?.Dispose(); (Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction,rewardPlan,rewardAction)=saved; } catch {validationCleanupFailure=fixture;throw;} finally { AppDomain.CurrentDomain.FirstChanceException-=observeIO; if(Directory.Exists(folder))File.WriteAllText(Path.Combine(folder,"storage-diagnostics.json"),JsonConvert.SerializeObject(ioDiagnostics.ToArray())); Interlocked.Exchange(ref validationRunning,0); } } File.WriteAllText(Path.Combine(folder,"result.json"),result);return result; } sealed class RewardSubscribers:IDisposable { const System.Reflection.BindingFlags Fields=System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic; static readonly System.Reflection.FieldInfo PF=typeof(PlayFabMgr).GetField("_instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic); static readonly System.Reflection.FieldInfo Red=typeof(RedPointManager).GetField("instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic); readonly object oldRed=Red.GetValue(null),oldPF=PF.GetValue(null); GameObject shell; LuckyTaskData magicTask; MiniBattlePassDataProvider provider; internal BattlePassDataProvider Battle; internal EventLuckyGameModel Lucky; internal EventLuckMagicModel Magic; internal MiniBattlePassDataModel Mini; internal void Bind(cfg.Tables tables,FishingEventData events,Container container) { Red.SetValue(null,new RedPointManager()); shell=new GameObject("KV inactive subscriber storage fixture");shell.SetActive(false);shell.hideFlags=HideFlags.HideAndDontSave; var pf=shell.AddComponent(); if(!pf.IsUnstartedForKVLoad)throw new InvalidOperationException("No PF lifecycle may start in this fixture."); PF.SetValue(null,pf); var items=new PlayerItemData(tables);container.RegisterInstance(items); var data=new BattlePassData(tables); data.BattlePassRecord.ID=tables.TbFishingEvent.DataList.First(e=>tables.TbBattlePassMain.GetOrDefault(e.RedirectID)!=null).ID; data.BattlePassRecord.Level=1; foreach(var t in tables.TbBattlePassTask.DataList.Where(t=>rewardConditions.Contains(t.Type)))data.BattleTask.dic_dailyTasks.Add(t.TaskID,0); Battle=new BattlePassDataProvider(tables,data,items);container.RegisterInstance(Battle); var common=tables.TbCommonTask.DataList.Where(t=>rewardConditions.Contains(t.TaskType)).Select(t=>t.Id).ToArray(); Lucky=new EventLuckyGameModel(events,tables);foreach(var id in common)Lucky.DicTasks.Add(id,0); Magic=new EventLuckMagicModel{DicTasks=common.ToDictionary(x=>x,x=>0),HigherRingData=EventLuckMagicHigherRingData.CreateEmpty()}; var rings=(List)typeof(EventLuckMagicModel).GetField("_rings",Fields).GetValue(Magic); for(int i=1;i<=3;i++)rings.Add(EventLuckMagicRing.CreateEmpty(1,0,(EEventLuckMagicRingType)i)); magicTask=new LuckyTaskData(Magic);typeof(EventLuckMagicModel).GetField("_taskData",Fields).SetValue(Magic,magicTask); provider=new MiniBattlePassDataProvider(tables,items); var table=tables.TbMiniBattlePass.DataList.First(t=>t.TaskType==cfg.ConditionType.ConsumeEnergy && t.Param==0); var activity=tables.TbFishingEvent.DataList.First(t=>t.Type==9 && t.RedirectID==table.Id); var type=(MiniBattlePassType)table.Type; Mini=provider.GetModel(type); provider.GetMiniBattlePassRecord("BP_"+type).Id=activity.ID; typeof(MiniBattlePassDataModel).GetField("fishingEvent",Fields).SetValue(Mini,activity); typeof(MiniBattlePassDataModel).GetMethod("InitData",Fields).Invoke(Mini,new object[]{table.Id}); // Synthetic fixture has an active window; real-run time is never modified. Mini.startTime=ZZTimeHelper.UtcNow().AddDays(-1); typeof(MiniBattlePassDataModel).GetProperty("endTime").SetValue(Mini,ZZTimeHelper.UtcNow().AddDays(1)); } public void Dispose() { Battle?.Dispose();Lucky?.Dispose();magicTask?.Dispose(); if(Mini!=null)(typeof(MiniBattlePassDataModel).GetField("disposables",Fields).GetValue(Mini) as IDisposable)?.Dispose(); PF.SetValue(null,oldPF);Red.SetValue(null,oldRed); if(shell!=null)UnityEngine.Object.DestroyImmediate(shell); } } static bool ReadRejected() {try{Read("NewPlayerDataDic");return false;}catch(InvalidOperationException){return true;}} } } #endif