feat(upserver): 接入认证与KV存档并完成限定钓鱼闭环

This commit is contained in:
Liubing\LB
2026-09-28 18:14:01 +08:00
parent f40a816cc4
commit 0eb43b2b40
129 changed files with 11612 additions and 573 deletions

View File

@@ -0,0 +1,291 @@
#if UNITY_EDITOR && !SERVICE_HOST
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using asap.core;
using cfg;
using GameCore;
using Newtonsoft.Json;
using UnityEngine;
namespace game.PlayerDataStorage
{
internal static partial class PlayerKVGameplayLoop
{
internal sealed class FishingRewardPlan
{
internal FishingData Data;
internal int Fish, Item, Area, Rod, Map, Mag;
internal string Activities;
internal string MapKey => "Map_5_"+Area;
}
internal sealed class FishingRewardAction
{
internal readonly FishingAction Consumption;
internal readonly object Controller;
internal readonly FishingRewardPlan Plan;
internal readonly List<WriteRecord> Records=new List<WriteRecord>();
internal readonly HashSet<string> Segments=new HashSet<string>(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<string> rewardTaskKeys=new HashSet<string>(StringComparer.Ordinal)
{"BattlePassRecordTask","MiniBPKeyNew","EventLuckGameRecord","EventLuckMagicPlayfabData"};
static string ActivityStamp(Tables tables)
{
var manager=GContext.container.Resolve<Activity.ActivityManager>();
var scopes=GContext.container.Resolve<Activity.ActivityScopeManager>();
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<string> specialRewardGuides=new HashSet<string>(StringComparer.Ordinal)
{"FishSkill01","Pollution01","FishSkill02","FishSkill03","FishSkill04","Egyptian01","EasterFish01"};
internal static void CheckRewardGuide(FishingData data=null)
{
var guide=GContext.container.Resolve<GuideDataCenter>();
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<Tables>();
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<PlayerData>();
var fish=GContext.container.Resolve<PlayerFishData>();
var events=GContext.container.Resolve<FishingEventData>();
var challenge=GContext.container.Resolve<FishingChallengeManager>();
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<Tables>();
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<CollectingData>().GetAddCollecting()!=0 ||
GContext.container.Resolve<PiggyBankPackData>().IsPackActivated || GContext.container.Resolve<BargainPackData>().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<PlayerData>();
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<Tables>())};
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<PlayerData>();
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<Tables>())) 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<PlayerFishData>();
var map=JsonConvert.DeserializeObject<Dictionary<int,string>>(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<PlayerData>().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