#if UNITY_EDITOR && !SERVICE_HOST using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using asap.core; using cfg; using GameCore; using Newtonsoft.Json; using UnityEngine; namespace game.PlayerDataStorage { internal static partial class PlayerKVGameplayLoop { internal sealed class FishingRewardPlan { internal FishingData Data; internal int Fish, Item, Area, Rod, Map, Mag; internal string Activities; internal string MapKey => "Map_5_"+Area; } internal sealed class FishingRewardAction { internal readonly FishingAction Consumption; internal readonly object Controller; internal readonly FishingRewardPlan Plan; internal readonly List Records=new List(); internal readonly HashSet Segments=new HashSet(StringComparer.Ordinal); internal object Panel; internal WriteBatch Segment; internal string SegmentName; internal bool Ending,Applying,Sealed,Confirmed,Presented,Cancelled; internal int GuideNotifications,BeforeFishCount; internal FishingRewardAction(FishingAction consumption,object controller,FishingRewardPlan plan) { Consumption=consumption; Controller=controller; Plan=plan; } } static FishingRewardPlan rewardPlan; static FishingRewardAction rewardAction; static readonly ConditionType[] rewardConditions={ConditionType.ConsumeEnergy,ConditionType.PerfectStrikeCount, ConditionType.GetFishCount,ConditionType.CashFromFishing,ConditionType.MaxCashFromFishing, ConditionType.GetFishWeight,ConditionType.GetFishID,ConditionType.GetBoosFish,ConditionType.GetBiggerThanHugeFishCount}; static readonly HashSet rewardTaskKeys=new HashSet(StringComparer.Ordinal) {"BattlePassRecordTask","MiniBPKeyNew","EventLuckGameRecord","EventLuckMagicPlayfabData"}; static string ActivityStamp(Tables tables) { var manager=GContext.container.Resolve(); var scopes=GContext.container.Resolve(); return string.Join(";",tables.TbFishingEvent.DataList.GroupBy(e=>(e.Type,e.SubType)).Select(g=>g.First()) .Select(e=>e.Type+":"+e.SubType+":"+manager.GetSentry(e)?.CurrentEventConfig?.ID+":"+scopes.HasActiveScope((e.Type,e.SubType)))); } static readonly HashSet specialRewardGuides=new HashSet(StringComparer.Ordinal) {"FishSkill01","Pollution01","FishSkill02","FishSkill03","FishSkill04","Egyptian01","EasterFish01"}; internal static void CheckRewardGuide(FishingData data=null) { var guide=GContext.container.Resolve(); if(guide.curDefine!=null || guide.curGroupDefine!=null || guide.guidanceGroupDefines==null || guide.guidanceGroupDefines.Any(g=>g==null || g.guidanceDefines==null || g.guidanceDefines.Any(n=>n==null))) throw Stop("Reward requires inactive and complete guide definitions."); var groups=guide.guidanceGroupDefines.Where(g=>g.guidanceDefines.Any(n=>n.panelName=="FishingPanel" || n.panelName=="FishingPanel_Advanced" || n.panelName=="FishingRewardPanel")) .OrderBy(g=>g.groupName.ToString(),StringComparer.Ordinal).ToArray(); var asset=Path.Combine(Application.dataPath,"ABPackage/pkgPrepare/Export/GuidanceConfig.asset"); var baseline=File.ReadAllBytes(Path.GetFullPath(Path.Combine(Application.dataPath,"../../CodexOutput/upserver/KVFishingRewardTests/guide-baseline-projection.jsonl"))); if(PlayerKVLoopPermit.Hash(File.ReadAllBytes(asset))!="5926124E5633166CC1B91569B8A71AB2C388AA3446A736B3E7E42B3EBD9F3D3A" || PlayerKVLoopPermit.Hash(baseline)!="A3D056313FE0F40D395FE92F97BEDE31BA47D8D79A3C4C28781395A43118E89C" || groups.Length!=11 || string.Join("\n",groups.Select(g=>JsonUtility.ToJson(g)))!=System.Text.Encoding.UTF8.GetString(baseline)) throw Stop("Reward guide configuration differs from the reviewed full-node projection."); foreach(var g in groups) if(!guide.GetIsFinish(g.groupName.ToString()) && (!specialRewardGuides.Contains(g.groupName.ToString()) || g.TriggerConditionsType!=GuideTriggerConditions.None || g.guidanceDefines.Length==0 || g.guidanceDefines[0].panelName!="FishingPanel_Advanced")) throw Stop("Reward requires completed ordinary fishing guides."); data=data??rewardPlan?.Data; if(data?.curFishData?.FishSKillList==null || data.curFishData.FishEscape==null || data.curFishData.FishSKillList.Any(x=>x==null) || data.fishSpSkillData!=null || data.superDashSpSkillData!=null || data.dirtyStuckSpSkillData!=null) throw Stop("Reward requires an ordinary fish without active special skills."); var tables=GContext.container.Resolve(); var queried=data.curFishData.FishSKillList.Concat(new[]{data.curFishData.FishEscape}).Select(x=>x.SpSkillID).Distinct().OrderBy(x=>x).ToArray(); bool invalid=false; foreach(var id in queried) { var resolved=tables.TbFishSpSkill.GetOrDefault(id); if(id>0 || resolved!=null)invalid=true; } Audit("fishing-reward-special-skill-check",new{queriedIds=queried,invalid}); if(invalid) throw Stop("Reward selected fish has a special skill; no consumption is allowed."); } static string EventSignature(FishingEvent e) => JsonConvert.SerializeObject(new{e.ID,e.Type,e.SubType,e.RedirectID, e.IsABTest,e.Channel,e.ConditionRelation,e.PopupPriority,e.PopupType, conditions=e.ConditionList.Select(c=>new{c.Condition,c.Param}),time=e.TimeDefinition.ToString()}); static void CheckRewardConfig(Tables tables) { var path=Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData/tbfishingevent.json")); var bytes=File.ReadAllBytes(path); if(PlayerKVLoopPermit.Hash(bytes)!="C8A4A9794E9390C94B9C8F1927B3E718B3EB9F784F0889508E1E576DD4B77A2D") throw Stop("Reward event configuration differs from the reviewed baseline."); var baseline=new TbFishingEvent(SimpleJSON.JSON.Parse(System.Text.Encoding.UTF8.GetString(bytes))); var relevant=tables.TbFishingEvent.DataList.Where(e=>e.ConditionList.Any(c=>rewardConditions.Contains(c.Condition))).ToArray(); if(relevant.Length!=19 || tables.TbTriggerPackManager.DataList.Any(t=>rewardConditions.Contains(t.Condition))) throw Stop("Reward activates an unreviewed event or trigger pack."); foreach(var e in relevant) { var old=baseline.GetOrDefault(e.ID); if(old==null || EventSignature(old)!=EventSignature(e)) throw Stop("Reward event fields changed."); if(e.ID==10100000 || e.ID==10200000 || e.ID==11300000) continue; if(e.ID<30200350 || e.ID>30200365 || !(e.TimeDefinition is LimitedTime time) || !DateTime.TryParse(time.EndTime,CultureInfo.InvariantCulture,DateTimeStyles.None,out var end) || ZZTimeHelper.UtcNow() < end.AddSeconds(120)) throw Stop("Reward event must be outside its reviewed activity window."); } } static void CheckRewardEnvironment(FishingData data,bool checkConfig) { var player=GContext.container.Resolve(); var fish=GContext.container.Resolve(); var events=GContext.container.Resolve(); var challenge=GContext.container.Resolve(); if(data==null || data.curFishData==null || data.fishItem==null || !data.IsGeneralFish() || data.IsFree || data.IsRetry || data.IsDuel || data.fixedFishList!=null || data.IsPackItemFish() || data.IsEventGameFish()) throw Stop("Reward supports one ordinary paid fish only."); var tables=GContext.container.Resolve(); if(tables.TbEventSlapSpinach.TryGetByItemID(data.fishItem.ID,out _)) throw Stop("Slap reward is outside this run."); var cruise=fish.GetFishSwimToMap(data.MapId); if((cruise!=-1 && cruise!=0) || events.collectingTargetInit!=null || GContext.container.Resolve().GetAddCollecting()!=0 || GContext.container.Resolve().IsPackActivated || GContext.container.Resolve().IsPackActivated) throw Stop("Additional collection, cruise or pack rewards require separate review."); if(challenge.fishingChallengeData!=null && challenge.ChallengeState()!=0 && challenge.ChallengeState()!=3 && challenge.IsTimeAllow()) throw Stop("Active challenge rewards require separate review."); if(player._fishingFrequency<3 || !player.IsOpenMagnification || !fish.IsOpenPiercing) throw Stop("Reward requires already unlocked basic fishing."); CheckRewardGuide(data); if(checkConfig) CheckRewardConfig(tables); } internal static void PrepareFishingReward(FishingAction action,FishingData data) { if(Permit?.AllowsReward!=true) return; if(rewardPlan!=null || !FishingCurrent(action)) throw Stop("Reward preflight already used or stale."); CheckRewardEnvironment(data,true); var p=GContext.container.Resolve(); var plan=new FishingRewardPlan{Data=data,Fish=data.curFishData.ID,Item=data.fishItem.ID,Area=data.curFishData.AreaID, Rod=data.RodId,Map=p.lastMapId,Mag=p.magnification,Activities=ActivityStamp(GContext.container.Resolve())}; if(!Permit.AllowedKeys.Contains(plan.MapKey) || Read(plan.MapKey)==null) throw Stop("Reward requires an existing backed-up fish map key."); rewardPlan=plan; Audit("fishing-reward-preflight",new{plan.Fish,plan.Item,plan.Area,plan.Rod,plan.Map,plan.Mag}); } static void CheckRewardContext() { var p=rewardPlan; var player=GContext.container.Resolve(); if(p==null || p.Data.curFishData==null || p.Data.fishItem==null || p.Data.curFishData.ID!=p.Fish || p.Data.fishItem.ID!=p.Item || p.Data.curFishData.AreaID!=p.Area || p.Data.RodId!=p.Rod || player.lastMapId!=p.Map || player.magnification!=p.Mag) throw Stop("Reward fishing context changed."); CheckRewardEnvironment(p.Data,false); if(p.Activities!=ActivityStamp(GContext.container.Resolve())) throw Stop("Activity changed during fishing reward."); } internal static bool RewardRunCurrent => RewardCurrent(rewardAction); internal static bool RewardCanAdvance => !Enabled || (FishingCurrent(fishingAction) && (rewardAction==null || (!rewardAction.Cancelled && !rewardAction.Sealed))); internal static bool BeginRewardDrawing(object controller) { if(Permit?.AllowsReward!=true || !FishingCurrent(fishingAction) || !fishingAction.Confirmed || !fishingAction.Continued) throw Stop("A confirmed v3 fishing action is required before drawing."); if(rewardAction!=null) { Audit("duplicate-suppressed",new{point="draw"}); return false; } CheckRewardContext(); rewardAction=new FishingRewardAction(fishingAction,controller,rewardPlan); return true; } internal static bool BeginRewardEnding(object controller,bool success) { var a=rewardAction; if(a==null || !ReferenceEquals(controller,a.Controller) || !RewardCurrent(a)) throw Stop("Reward ending is stale."); if(a.Ending) { Audit("duplicate-suppressed",new{point="fish-end"}); return false; } a.Ending=true; if(!success) throw Stop("Fishing failed; reward run stops without retry or refund."); CheckRewardContext(); return true; } internal static FishingRewardAction BindRewardPanel(object panel) { var a=rewardAction; if(!RewardCurrent(a) || !a.Ending) throw Stop("Reward panel has no completed catch."); if(a.Applying) { Audit("duplicate-suppressed",new{point="reward-panel"}); return null; } CheckRewardContext(); a.Applying=true; a.Panel=panel; var fish=GContext.container.Resolve(); var map=JsonConvert.DeserializeObject>(Read(a.Plan.MapKey)); a.BeforeFishCount=map.TryGetValue(a.Plan.Fish,out var old)?FishWeightData.Deserialize(old).TotalNum:0; return a; } internal static bool RewardCurrent(FishingRewardAction a) => a!=null && ReferenceEquals(a,rewardAction) && !a.Cancelled && FishingCurrent(a.Consumption); internal static bool RewardPanelCurrent(FishingRewardAction a,object panel) => RewardCurrent(a) && ReferenceEquals(a.Panel,panel); sealed class RewardSegment : IDisposable { readonly FishingRewardAction action; internal RewardSegment(FishingRewardAction a) { action=a; } public void Dispose() { if(action.Segment==null) return; action.Segment.Dispose(); action.Records.AddRange(action.Segment.Records); Audit("fishing-reward-segment",new{segment=action.SegmentName,writes=action.Segment.Records.Count}); action.Segment=null;action.SegmentName=null; } } internal static IDisposable BeginRewardSegment(string name) { var a=rewardAction; if(!RewardCurrent(a) || a.Sealed || collecting!=null || a.Segment!=null || (name!="strike" && name!="ending" && name!="reward") || !a.Segments.Add(name)) throw Stop("Reward segment is not admissible."); if((name=="ending" && !a.Ending) || (name=="reward" && !a.Applying)) throw Stop("Reward segment order is invalid."); a.SegmentName=name; a.Segment=collecting=new WriteBatch(a.Consumption.Owner); return new RewardSegment(a); } internal static void CheckRewardWrite(string key,string value) { var a=rewardAction; if(Permit?.AllowsReward!=true || a==null || a.Cancelled || a.Confirmed) return; if(!RewardCurrent(a)) throw Stop("Reward write belongs to a stale action."); if(a.Segment==null) { Audit("fishing-reward-outside-segment",new{key}); if(!string.Equals(Read(key),value,StringComparison.Ordinal)) throw Stop("Value changed outside a fishing reward segment: "+key); } else if(key!="NewPlayerDataDic" && key!=a.Plan.MapKey && !rewardTaskKeys.Contains(key)) throw Stop("Unexpected fishing reward key: "+key); } internal static void CheckCaughtFishSave(int fish,int count) { var a=rewardAction; if(!RewardCurrent(a) || a.SegmentName!="reward" || a.Plan.Fish!=fish || count!=checked(a.BeforeFishCount+GContext.container.Resolve().GetMagnification())) throw Stop("Caught fish final save must contain exactly this catch's increment."); } internal static void CheckRewardMainSave() { if(!RewardCurrent(rewardAction) || rewardAction.SegmentName!="reward") throw Stop("Final reward save outside reward segment."); } internal static void RecordRewardGuideNotification() { var a=rewardAction; if(!RewardCurrent(a) || a.SegmentName!="reward" || ++a.GuideNotifications!=1) throw Stop("Duplicate reward guide notification."); CheckRewardGuide(); Audit("fishing-reward-guide",new{notifications=a.GuideNotifications,activated=0}); } internal static async Task ConfirmRewardAsync(FishingRewardAction a,PlayerKVEditResult primary) { try { if(!RewardCurrent(a) || a.Segment!=null || collecting!=null || a.Sealed || a.Segments.Count!=3 || a.GuideNotifications!=1) throw Stop("Reward records must be complete and sealed once."); a.Sealed=true; var last=a.Records.GroupBy(r=>r.Key,StringComparer.Ordinal).ToDictionary(g=>g.Key,g=>g.Last(),StringComparer.Ordinal); if(!last.TryGetValue("NewPlayerDataDic",out var main) || !ReferenceEquals(main.Edit,primary) || !last.ContainsKey(a.Plan.MapKey) || last.Values.Any(r=>!r.Remote || r.Edit.Ticket==null)) throw Stop("Reward final writes require the original map/main and remote tickets."); using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30))) { var owner=a.Consumption.Owner; var flush=await PlayFabMgr.FlushKVAsync(owner,timeout.Token); if(!RewardCurrent(a) || flush.Unknown>0 || flush.Blocked>0 || flush.TransportError!=PlayerKVError.None) throw Stop("Reward flush: "+flush.TransportError); foreach(var r in a.Records) { var t=r.Edit.Ticket;if(t==null)continue; var final=last[r.Key].Edit.Ticket; if(t.OwnerInstanceId!=owner.OwnerInstanceId || t.Epoch!=owner.Epoch || t.Key!=r.Key || final.OwnerInstanceId!=t.OwnerInstanceId || final.Epoch!=t.Epoch) throw Stop("Reward ticket identity mismatch."); var result=await PlayFabMgr.WaitForKVTicketAsync(owner,t,timeout.Token); if(!RewardCurrent(a)) throw Stop("Reward action changed while waiting."); bool explained=result.Status==PlayerKVTicketStatus.Superseded && !ReferenceEquals(t,final) && final.Sequence>t.Sequence; if(result.Status!=PlayerKVTicketStatus.Confirmed && !explained) throw Stop("Reward ticket: "+result.Status); } } a.Confirmed=true; Audit("fishing-reward-confirmed",new{tickets=last.Values.Select(r=>new{key=r.Key,sequence=r.Edit.Ticket.Sequence,valueHash=r.ValueHash})}); } catch(Exception e) { Fault(e.Message);throw; } } internal static void PresentReward(FishingRewardAction a,Action show) { if(!RewardCurrent(a) || !a.Confirmed) throw Stop("Reward cannot be presented before confirmation."); if(a.Presented)return;a.Presented=true;show();Audit("fishing-reward-presented",new{a.Plan.Fish}); } internal static void CancelReward(object actor,string reason) { var a=rewardAction; if(a==null || a.Cancelled || (!ReferenceEquals(actor,a.Controller) && !ReferenceEquals(actor,a.Panel) && !ReferenceEquals(actor,a.Consumption.Actor)))return; if(a.Ending && !a.Confirmed && ReferenceEquals(actor,a.Controller))return; a.Cancelled=true; if(Ready && !a.Confirmed) Fault("Fishing reward cancelled: "+reason); } } } #endif