diff --git a/CodexOutput/upserver/B3DesignAudit/B3DesignAudit.csproj b/CodexOutput/upserver/B3DesignAudit/B3DesignAudit.csproj
new file mode 100644
index 000000000..f1081d6e7
--- /dev/null
+++ b/CodexOutput/upserver/B3DesignAudit/B3DesignAudit.csproj
@@ -0,0 +1,11 @@
+
+
+ Exenet9.0
+ enabledisable
+ $(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config
+
+
+ $(MSBuildSDKsPath)/../Roslyn/bincore/Microsoft.CodeAnalysis.dll
+ $(MSBuildSDKsPath)/../Roslyn/bincore/Microsoft.CodeAnalysis.CSharp.dll
+
+
diff --git a/CodexOutput/upserver/B3DesignAudit/Program.cs b/CodexOutput/upserver/B3DesignAudit/Program.cs
new file mode 100644
index 000000000..f9ed62bd5
--- /dev/null
+++ b/CodexOutput/upserver/B3DesignAudit/Program.cs
@@ -0,0 +1,81 @@
+// Design inventory only. Parses source; never loads or executes game code.
+using System.Text.Json;
+using System.Security.Cryptography;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+var root = Path.GetFullPath(args[0]);
+var output = Path.GetFullPath(args[1]);
+var configs = new Dictionary {
+ ["editor-standalone"] = new[]{"UNITY_EDITOR","UNITY_STANDALONE_WIN"},
+ ["editor-android-agg"] = new[]{"UNITY_EDITOR","UNITY_ANDROID","AGG"},
+ ["player-android-agg"] = new[]{"UNITY_ANDROID","AGG"},
+ ["player-ios-agg"] = new[]{"UNITY_IOS","AGG"},
+ ["player-standalone"] = new[]{"UNITY_STANDALONE_WIN"}
+};
+var calls = new Dictionary();
+var refs = new Dictionary>();
+var files = new Dictionary();
+foreach (var path in Directory.EnumerateFiles(Path.Combine(root,"Client/Assets/Scripts"),"*.cs",SearchOption.AllDirectories).Order()) {
+ var rel = Path.GetRelativePath(root,path).Replace('\\','/');
+ var source = File.ReadAllText(path);
+ files[rel] = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
+ foreach (var config in configs) {
+ var tree = CSharpSyntaxTree.ParseText(source,new CSharpParseOptions(preprocessorSymbols:config.Value));
+ var node = tree.GetRoot();
+ foreach (var inv in node.DescendantNodes().OfType()) {
+ string name = inv.Expression switch {
+ MemberAccessExpressionSyntax m => m.Name.Identifier.ValueText,
+ MemberBindingExpressionSyntax m => m.Name.Identifier.ValueText,
+ SimpleNameSyntax s => s.Identifier.ValueText,
+ _ => ""
+ };
+ int line=tree.GetLineSpan(inv.Span).StartLinePosition.Line+1;
+ if(!refs.TryGetValue(name,out var refsForName)) refs[name]=refsForName=new();
+ refsForName.Add(rel+":"+line);
+ var full = inv.ToString();
+ var conditional=inv.Ancestors().OfType().FirstOrDefault();
+ var receiver = inv.Expression.ToString()+" "+conditional?.Expression.ToString();
+ bool facade=receiver.Contains("PlayFabMgr") && new[]{"GetLocalData","UpdateUserDataValue","SyncDataImmidiate","CompareDatas"}.Contains(name);
+ bool sdk=receiver.Contains("PlayFabClient") && (name.Contains("UserData"));
+ // Include unqualified internal facade calls, which the old text scan omitted.
+ bool internalFacade=rel.EndsWith("/Core/PlayFabMgr.cs") && new[]{"GetLocalData","UpdateUserDataValue","SyncDataImmidiate","CompareDatas","SyncData"}.Contains(name);
+ if(!facade && !sdk && !internalFacade) continue;
+ string key=rel+":"+line+":"+inv.SpanStart;
+ if(calls.TryGetValue(key,out var previous)){previous.Configs.Add(config.Key);continue;}
+ var member=inv.Ancestors().FirstOrDefault(n=>n is BaseMethodDeclarationSyntax || n is PropertyDeclarationSyntax);
+ string memberName=member switch {
+ MethodDeclarationSyntax m => m.Identifier.ValueText,
+ ConstructorDeclarationSyntax c => c.Identifier.ValueText,
+ PropertyDeclarationSyntax p => p.Identifier.ValueText,
+ _=>""
+ };
+ var type=inv.Ancestors().OfType().FirstOrDefault()?.Identifier.ValueText;
+ var e=new Entry { Path=rel,Line=line,Member=type+"."+memberName,MemberName=memberName,
+ MemberLine=member==null?line:tree.GetLineSpan(member.Span).StartLinePosition.Line+1,
+ Kind=sdk?"sdk":internalFacade?"facade-internal":"facade-call",Method=name,
+ Arguments=inv.ArgumentList.Arguments.Select(x=>x.ToString()).ToArray(),
+ CallbackAncestor=inv.Ancestors().Any(x=>x is AnonymousFunctionExpressionSyntax),
+ AwaitLines=member?.DescendantNodes().OfType().Select(x=>tree.GetLineSpan(x.Span).StartLinePosition.Line+1).ToArray()??Array.Empty(),
+ MemberSource=member?.ToString()??full, Configs=new(){config.Key}, Source=full };
+ calls[key]=e;
+ }
+ }
+}
+var ordered=calls.Values.OrderBy(x=>x.Path).ThenBy(x=>x.Line).ToArray();
+int i=0;
+foreach(var entry in ordered){entry.Id="B3-C"+(++i).ToString("D3");entry.CallerNameCandidates=refs.GetValueOrDefault(entry.MemberName)?.Order().ToArray()??Array.Empty();}
+Directory.CreateDirectory(output);
+File.WriteAllText(Path.Combine(output,"calls.json"),JsonSerializer.Serialize(ordered,new JsonSerializerOptions{WriteIndented=true}));
+File.WriteAllText(Path.Combine(output,"source-hashes.json"),JsonSerializer.Serialize(files,new JsonSerializerOptions{WriteIndented=true}));
+Console.WriteLine(JsonSerializer.Serialize(new {calls=ordered.Length,files=ordered.Select(x=>x.Path).Distinct().Count(),sourceFiles=files.Count,groups=ordered.GroupBy(x=>x.Kind).ToDictionary(g=>g.Key,g=>g.Count())}));
+
+class Entry {
+ public string Id{get;set;} public string Path{get;set;} public int Line{get;set;}
+ public string Member{get;set;} public string MemberName{get;set;} public int MemberLine{get;set;}
+ public string Kind{get;set;} public string Method{get;set;} public string[] Arguments{get;set;}
+ public bool CallbackAncestor{get;set;} public int[] AwaitLines{get;set;}
+ public List Configs{get;set;} public string[] CallerNameCandidates{get;set;}
+ public string MemberSource{get;set;} public string Source{get;set;}
+}
diff --git a/CodexOutput/upserver/B3DesignAudit/render_inventory.py b/CodexOutput/upserver/B3DesignAudit/render_inventory.py
new file mode 100644
index 000000000..9170e41cc
--- /dev/null
+++ b/CodexOutput/upserver/B3DesignAudit/render_inventory.py
@@ -0,0 +1,89 @@
+"""Render design facts only; does not modify or execute game code."""
+import json
+from pathlib import Path
+
+base = Path(__file__).resolve().parent
+calls = json.loads((base / 'calls.json').read_text(encoding='utf-8-sig'))
+def assign(c):
+ n = int(c['Id'].split('C')[-1])
+ if c['Path'].endswith('/Core/PlayFabMgr.cs'):
+ return 'A保留旧区;C另审生命周期', 'PF原流程;本次不改'
+ if c['Method'] == 'SyncDataImmidiate' or n == 38:
+ return '4C消费合同;B先审归属', '消费/等待;不得KV启用'
+ if n == 190:
+ return '身份/外围专项保留PF', '读取他人Lv;不转本人KV'
+ if n in (168, 183, 184, 185, 186, 187, 188, 189):
+ return 'B归属+C初始化发布', '登录/渠道/GM;不复用PF初始化'
+ if n in (126, 194):
+ return 'B调试入口边界', '默认禁止KV调试写;不默授权限'
+ if n in (1, 2, 3, 127, 128, 129):
+ return 'B仓库与事件上游详审', '泛型/继承;捕获异常默认值须拆开'
+ if n in (213, 214):
+ return 'B渠道回调+身份专项', 'await后UI/奖励也须旧epoch拒绝'
+ if n in (63,64,65,66,67,151,152,171,176,182):
+ return 'B归属;奖励/支付结果另裁决', '先变内存/奖励风险;禁自动重试'
+ return 'B逐业务完整详审', '同步末端不等于上游无延后回调'
+
+header = '''# 阶段4B3调用事实与分期附件
+
+日期:2026-09-24。配套[分期与4B3A方案](阶段4B3分期与4B3A固定存档入口方案-待评审-2026-09-24.md)。
+
+**本附件是语法调用事实和阶段归属,不是4B3B完成的回调隔离合同。** 所有B/C行当前均禁止凭此编码;它们既不进入A白名单,也不获得KV运行资格。A只新增四个尚无业务调用者的静态入口。
+
+## 1. 可复现盘点和限制
+
+使用本地.NET 9.0.310自带Roslyn,读取Client/Assets/Scripts全部2101个.cs文件,仅解析语法,不编译或执行游戏。五种条件符号合并去重:Editor Standalone、Editor Android+AGG、Player Android+AGG、Player iOS+AGG、Player Standalone。223个位置/81文件:外部facade 212、facade内部8、SDK3。原文本234行/84文件还包含注释、声明及B2配置引用,统计口径不同。
+
+`B3DesignAudit/calls.json`逐项含源行、所在类型/方法/属性、完整参数、方法体、局部await行、编译条件、按方法名匹配的上游位置。`source-hashes.json`封存2101个源文件hash。工具源码也纳入受审基线,运行仅产生设计分析数据。
+
+**重要限制**:CallerNameCandidates是名称候选,可能把不同类型的Save/Load混在一起;数量0也不能证明无UnityEvent/方法组/反射调用。AwaitLines只观察包含本调用的方法;空列表不是整条链同步的证据。未枚举的自定义编译符号和包内/持久化UnityEvent另需B审查。此处不输出未经语义验证的“全部上游已覆盖”结论。
+
+可复现命令(项目内NuGet配置,无新包):`dotnet run --project CodexOutput/upserver/B3DesignAudit/B3DesignAudit.csproj -- C:/projects/ft C:/projects/ft/CodexOutput/upserver/B3DesignAudit`。送审后复跑需另给输出目录,不覆盖受审快照。
+
+## 2. 已人工核对的调用链与设计约束
+
+| 位置 | 已确认链/副作用 | 捕获和取消的待审合同 |
+|---|---|---|
+| C050 SavePlayerData | 17个按名候选,方法自身先Publish(this),再改NewPlayerDataDic并从Fish/Achievement/Box/Event读取;remote参数可传递 | 必须从最早内存变更者起捕获,不能到此方法末端才验epoch;需要保护整个跨模块序列化对象集合 |
+| C038/C223 RewardTurntableRoll→ConsumeOnPlayfab | Panel:530传false;方法先Publish、扣券、更新过渡数据与红点,后localOnly保存;随后立即远端bool | B只隔离归属;4C设计未知结果与防重入;不能把两次保存当事务 |
+| C051 OpenAppraisePopupPanel | FishingRewardPanel:963调用,await ShowUI后改IsOpenAppraise并存档 | 从打开UI前捕获,await后先检查;关闭旧面板和取消等待仍不能替代epoch检查 |
+| C063~067 OnBuy/Replenishment→BuySuccess | OnBuy含SDK/支付await;Replenishment亦进入BuySuccess及奖励/事件 | 支付开始时绑定账号及业务单据,不能补单回调取当前账号;B处理归属,支付恢复语义另审 |
+| C151/152 OnBuySuccess | LuckyADTaskPopupPanel:87将方法组交给Item.Init;即使方法内无await也有延后来源 | 在Item回调注册处捕获;面板退出退订/失效;进入回调即检查,早于领奖/计数 |
+| C171 ClubSingin | 自身已有API身份/版本捕获与await后检查,随后signTime、保存、聊天及奖励 | 复用现有API防护并证明与KV Owner/epoch绑定;避免削弱既有ClubService边界 |
+| C176 OnReceiveLinkAward;C182 GetSurveyCheckAnswer | 先AddItem/Publish后标记领取并保存;Survey catch会吞掉异常继续由调用者操作 | 防止“只看保存结果”方案;失败恢复/奖励契约单独裁决,不能写失败后重发奖励 |
+| C001~003 EventDataRepository | ActivityManager:34注册单例;Activity.Data/AEventDataManager有Repository.Load??new T、Save后Publish、RefreshData中AutoSaveOnCreate | 仓库与上游默认值链一起改才有效;账号关闭必须使旧事件与实例归属失效 |
+| C127~129 game.AEventDataManager | 构造时Subscribe(TargetEvent),Dispose释放;GetCacheData从全局facade读、缺档new T | 注册订阅时绑定账号归属,旧事件入队仍应拒绝;继承类和公开Data可变引用须另外追踪 |
+| C213 OnLinkSns | 四个按钮方法await OnLinkSns后仍改按钮/发奖;OnLinkSns内部还有账号切换UI await | 外层及内层同一操作归属;不能仅在UpdateUserDataValue前检查;finally关闭等待UI也须限制原面板实例 |
+| C190 GetPlayLv | 请求PlayFabId=参数playFabId,Keys=[Lv],写PlayLvs[playFabId]缓存 | 属他人PF资料;不接本人KV。个人身份切换及缓存清理另审 |
+| C177/178 ShowNotice | 属性get惰性读缓存,setter会写存档 | 账号换代必须清旧缓存或绑定新实例;无await也不能默认为账号无关设置 |
+
+## 3. 逐调用位置与拟归属
+
+以下“局部await”列只描述所在方法语法。每行的上下文原文与上游候选保存在同ID JSON。key为源码表达式,不把泛型名/变量/插值key误记为一个固定键。
+
+| ID | 源文件:行(相对Client/Assets/Scripts) | 所在方法/属性 | 调用 / key表达式 | 局部await | 拟归属 | 处理约束 |
+|---|---|---|---|---|---|---|
+'''
+def cell(x):
+ return str(x).replace('|', '|').replace('\r', '').replace('\n', ' ')
+rows=[]
+classified=[]
+for c in calls:
+ phase,note=assign(c)
+ args=c['Arguments']
+ key=args[0] if args else '—'
+ # Full SDK request remains in JSON; keep the table readable.
+ if c['Kind']=='sdk': key='见JSON完整请求'
+ rows.append('| '+' | '.join(map(cell,[c['Id'],c['Path'].replace('Client/Assets/Scripts/','')+':'+str(c['Line']),c['Member'],c['Method']+' / '+key,','.join(map(str,c['AwaitLines'])) or '无(仅局部)',phase,note]))+' |')
+ classified.append(dict(id=c['Id'],path=c['Path'],line=c['Line'],phaseProposal=phase,implementationApproved=False,upstreamIsolationComplete=False))
+tail='''
+
+## 4. B3B完整合同的进入条件
+
+本轮不把B3B未知项隐藏到实施阶段。B方案送审前,每个拟启用的业务入口必须给出已验证的上游调用/事件注册位置、身份捕获点、首个字段/集合修改点、取消/退订点、共享对象生命周期、localOnly/奖励副作用、错误处置以及对应验收用例。整个B单元的启用白名单必须闭合;任何新增文件先入方案,不能从本81文件统计推导允许批量改动。
+
+在此之前,旧业务路径维持原PF;公共kv登录仍BackendNotEnabled。主方案A验收不以该待完成工作为已解决。外部应对“可先实施A”单独裁决,若不同意则退回分期。
+'''
+(base.parent/'阶段4B3调用事实与分期附件-2026-09-24.md').write_text(header+'\n'.join(rows)+tail,encoding='utf-8')
+(base/'classification.json').write_text(json.dumps(classified,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
+print(json.dumps({'renderedCalls':len(calls),'allImplementationApproved':False}))
diff --git a/CodexOutput/upserver/ClubRegression/ClubRegression.csproj b/CodexOutput/upserver/ClubRegression/ClubRegression.csproj
new file mode 100644
index 000000000..178760a4e
--- /dev/null
+++ b/CodexOutput/upserver/ClubRegression/ClubRegression.csproj
@@ -0,0 +1 @@
+Exenet9.0enable$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll
diff --git a/CodexOutput/upserver/ClubRegression/Program.cs b/CodexOutput/upserver/ClubRegression/Program.cs
new file mode 100644
index 000000000..b1ba687af
--- /dev/null
+++ b/CodexOutput/upserver/ClubRegression/Program.cs
@@ -0,0 +1,49 @@
+void Assert(bool ok){if(!ok)throw new Exception("Club regression failed");}
+int passed=0;
+foreach(var body in new string[]{null,"","[]","{}","bad","{\"code\":\"0\"}","{\"code\":2}","{\"code\":1,\"clubInfo\":{\"id\":8}}","{\"code\":0}","{\"code\":0,\"clubInfo\":{\"id\":0}}"}){
+ var s=new Subject();s.customServerMgr.Response=body;var old=s.myClubInfo;
+ Assert(await s.CreateClub(new ClubSettingData())==null && ReferenceEquals(old,s.myClubInfo) && s.Effects==0 && s.chatService.Effects==0);passed++;
+}
+foreach(var body in new string[]{null,"","[]","{}","bad","{\"code\":\"0\"}","{\"code\":2}"}){
+ var s=new Subject();s.customServerMgr.Response=body;var old=s.myClubInfo;var toasts=ToastPanel.Count;
+ s.QuitClub();Assert(ReferenceEquals(old,s.myClubInfo) && s.Effects==0 && s.chatService.Effects==0 && ToastPanel.Count==toasts);passed++;
+}
+{
+ var s=new Subject();s.customServerMgr.Response="{\"code\":0,\"clubInfo\":{\"id\":8}}";
+ Assert((await s.CreateClub(new ClubSettingData())).id==8 && ((CreateClubRequest)s.customServerMgr.Body).playerId=="101" && s.internalIdToPlayerId[42]=="101" && s.memberRole==EMemberRole.Owner);passed++;
+}
+foreach(var code in new[]{0,1}){var s=new Subject();s.customServerMgr.Response=$"{{\"code\":{code}}}";s.QuitClub("102");Assert(s.myClubInfo==null && s.chatService.Effects==1 && ((QuitClubRequest)s.customServerMgr.Body).appointId=="102" && ((QuitClubRequest)s.customServerMgr.Body).playerId=="101");passed++;}
+{
+ var s=new Subject();var old=s.myClubInfo;s.customServerMgr.Gate=new();var pending=s.CreateClub(new ClubSettingData());s.customServerMgr.SessionVersion++;s.userService.ApiPlayerId="102";s.customServerMgr.Gate.SetResult("{\"code\":0,\"clubInfo\":{\"id\":8}}");Assert(await pending==null && ReferenceEquals(old,s.myClubInfo) && s.Effects==0);passed++;
+}
+{
+ var s=new Subject();var old=s.myClubInfo;s.customServerMgr.Gate=new();s.QuitClub();s.customServerMgr.SessionVersion++;s.customServerMgr.Gate.SetResult("{\"code\":0}");Assert(ReferenceEquals(old,s.myClubInfo) && s.Effects==0);passed++;
+}
+{
+ var s=new Subject();s.userService.IsAuthorized=false;Assert(await s.CreateClub(new ClubSettingData())==null);s.QuitClub();s.Demote("102");s.Promote("102",EMemberRole.Elder);s.Kickout("102");Assert(s.customServerMgr.Calls==0);passed++;
+}
+{
+ var s=new Subject();s.QuitClub("101");s.QuitClub("HEX");s.Demote("101");s.Promote("HEX",EMemberRole.Elder);s.Kickout("101");Assert(s.customServerMgr.Calls==0);s.Demote("102");Assert(((DemoteClubRequest)s.customServerMgr.Body).demoteId=="102" && ((DemoteClubRequest)s.customServerMgr.Body).playerId=="101");passed++;
+}
+{
+ var s=new Subject();s.customServerMgr.Response="{\"code\":2}";s.Kickout("102");Assert(s.myClubInfo.curMembers==3);s.customServerMgr.Response="{\"code\":0}";s.Kickout("102");Assert(s.myClubInfo.curMembers==2);passed++;
+}
+
+{
+ var s=new Subject();s.customServerMgr.Response="{\"code\":4}";s.HandleApply(false,"");Assert(s.clubPlayerInfos.Count==0 && s.myClubInfo.curMembers==3);passed++;
+ s=new Subject();s.customServerMgr.Response="{\"code\":0}";s.HandleApply(false,"");Assert(s.clubPlayerInfos.Count==1);passed++;
+ s=new Subject();s.HandleApply(true,"");Assert(s.customServerMgr.Calls==0);passed++;
+}
+{
+ var s=new Subject();s.customServerMgr.Gate=new();var rewards=PlayerItemData.Rewards;var writes=PlayFabMgr.Instance.Writes;
+ var first=s.ClubSingin();await s.ClubSingin();Assert(s.customServerMgr.Calls==1 && s.signTime==null);
+ s.customServerMgr.Gate.SetResult("{\"code\":0}");await first;await s.ClubSingin();Assert(s.customServerMgr.Calls==1 && PlayerItemData.Rewards==rewards+1 && PlayFabMgr.Instance.Writes==writes+1);passed++;
+}
+{
+ var s=new Subject();var rewards=PlayerItemData.Rewards;s.customServerMgr.Response=null;await s.ClubSingin();Assert(s.signTime==null && PlayerItemData.Rewards==rewards);
+ s.customServerMgr.Response="{\"code\":0}";await s.ClubSingin();Assert(s.signTime!=null && s.customServerMgr.Calls==2 && PlayerItemData.Rewards==rewards+1);passed++;
+}
+{
+ var s=new Subject();var rewards=PlayerItemData.Rewards;var writes=PlayFabMgr.Instance.Writes;s.customServerMgr.Gate=new();var old=s.ClubSingin();s.customServerMgr.SessionVersion++;s.userService.ApiPlayerId="102";s.customServerMgr.Gate.SetResult("{\"code\":0}");await old;Assert(s.signTime==null && PlayerItemData.Rewards==rewards && PlayFabMgr.Instance.Writes==writes);passed++;
+}
+Console.WriteLine($"PASS {passed} club cases: failure preserves state, explicit business success, decimal self/target identity, stale responses, no duplicate creation charge path");
diff --git a/CodexOutput/upserver/ClubRegression/generate.py b/CodexOutput/upserver/ClubRegression/generate.py
new file mode 100644
index 000000000..2a9567c5c
--- /dev/null
+++ b/CodexOutput/upserver/ClubRegression/generate.py
@@ -0,0 +1,42 @@
+from pathlib import Path
+s=Path('Client/Assets/Scripts/Services/ClubService.cs').read_text(encoding='utf-8-sig')
+models=Path('Client/Assets/Scripts/Services/ClubModel.cs').read_text(encoding='utf-8-sig')
+def block(text,marker):
+ start=text.index(marker);a=text.index('{',start);depth=1;b=a+1
+ while depth:
+ depth+=(text[b]=='{')-(text[b]=='}');b+=1
+ return text[start:b]
+helper=s[s.index(' private static bool IsDecimalPlayerId'):s.index(' public async Task> GetClubDetails')]
+methods='\n'.join(block(s,m) for m in ['public async Task CreateClub(', 'public async void QuitClub(', 'public async void Demote(', 'public async void Promote(', 'public async void Kickout(', 'public async void HandleApply(', 'public async Task ClubSingin('])
+dtos='\n'.join(block(models,'public class '+name+'\n') for name in ['ClubInfo','ClubSettingData','CreateClubRequest','CreateClubResp','QuitClubRequest','QuitClubResp','DemoteClubRequest','PromoteClubRequest','KickoutClubRequest','KickoutClubResp','HandleApplyRequest','HandleApplyResp','ClubSinginRequest','ClubSinginResp'])
+code='''// Generated from production methods; do not edit. Engine and transport are controlled test doubles.
+using System;using System.Collections.Generic;using System.ComponentModel;using System.Linq;using System.Threading.Tasks;using Newtonsoft.Json.Linq;
+public enum EMemberRole:byte {Owner,Vice,Member,Elder}
+'''+dtos+'''
+public class Subject {
+ public User userService=new();public Server customServerMgr=new();public Chat chatService=new();public ClubInfo myClubInfo=new(){id=9,curMembers=3};public EMemberRole memberRole=EMemberRole.Member;public Dictionary internalIdToPlayerId=new();public int Effects;
+ private int? signingSession; public DateTime? signTime; public List clubPlayerInfos=new(){new Person{playFabId="102"}}; public bool CanAccepted=true; void SetClubRed(){} void ShowPromote(){Effects++;}void MyClubInfoChange(bool join=false){Effects++;}void ClearLikeCursorIds(){Effects++;}
+'''+helper+methods+'''
+}
+public class User{public string UserId="8BDE34F732DD4210";public string ApiPlayerId="101";public bool IsAuthorized=true;public uint InternalId=42;}
+public class Server{public int SessionVersion;public int Calls;public object Body;public string Response;public TaskCompletionSource Gate;public Task ClubRequest(string path,object data){Calls++;Body=data;return Gate?.Task??Task.FromResult(Response);}}
+public class Chat{public int Effects;public void SendSystemMessage(string text){Effects++;}public void OnQuit(){Effects++;}public void GetConversationList(long id){Effects++;}}
+public class ClubSttingUpdateEvent{} public static class GContext {public static Container container=new();public static int Events;public static void Publish(object value){Events++;}}
+public static class ToastPanel{public static int Count;public static void Show(string text){Count++;}}
+public static class LocalizationMgr{public static string GetText(string key)=>key;}
+'''
+Path('CodexOutput/upserver/ClubRegression/Generated.cs').write_text(code)
+
+p=Path('CodexOutput/upserver/ClubRegression/Generated.cs')
+with p.open('a') as f:f.write("""
+public class Person{public string playFabId;}
+public enum RedPointName {ApplyList}
+public class RedPointManager {public static RedPointManager Instance=new();public void SetRedPointState(RedPointName n,bool b){}}
+public static class ZZTimeHelper {public static DateTime UtcNow()=>new DateTime(2026,9,21);public static DateTime UtcNowOffset(this DateTime time)=>time;}
+public class PlayFabMgr {public static PlayFabMgr Instance=new();public int Writes;public void UpdateUserDataValue(string k,string v){Writes++;}}
+public enum MessageChatType {SI}
+public class Container {public T Resolve() where T:new()=>new T();}
+public class PlayerItemData {public static int Rewards;public void AddItemByDrop(int id,object ctx){Rewards++;}}
+public class ShowData{}
+namespace cfg {public class Tables {public Config TbServerConfig=new();}public class Config{public int ClubSignReward=1;}}
+""")
diff --git a/CodexOutput/upserver/ExitTests/ExitTests.csproj b/CodexOutput/upserver/ExitTests/ExitTests.csproj
new file mode 100644
index 000000000..8ccbb5bc7
--- /dev/null
+++ b/CodexOutput/upserver/ExitTests/ExitTests.csproj
@@ -0,0 +1 @@
+Exenet9.0enable$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config
diff --git a/CodexOutput/upserver/ExitTests/Program.cs b/CodexOutput/upserver/ExitTests/Program.cs
new file mode 100644
index 000000000..5932315a8
--- /dev/null
+++ b/CodexOutput/upserver/ExitTests/Program.cs
@@ -0,0 +1,30 @@
+using game;
+void Assert(bool ok){if(!ok)throw new Exception("Exit assertion failed");}
+foreach(var logout in new[]{true,false}) foreach(var failure in new[]{true,false}) {
+ GameCore.Timer.Reset();
+ var user=new User();var hub=new Hub();var ads=new Ads();
+ var act=new QuitAct{userService=user,rtService=hub,config=new Config(),adsService=ads,soundMgr=new SoundMgr()};
+ QuitStage.Logout=logout;var pending=act.StartAsync();
+ Assert(!pending.IsCompleted && user.Logouts==(logout?1:0) && user.Valid==!logout && !QuitStage.Logout);
+ user.DeliverOldHttpAndLogin();Assert(user.Applied==(logout?0:2));
+ if(failure)hub.Gate.SetException(new Exception("Stop failed"));else hub.Gate.SetResult(true);
+ await pending;Assert(ads.Released && GameCore.Timer.Destroyed==1 && GameCore.Timer.Cancelled==1);
+ await act.StartAsync();Assert(user.Logouts==(logout?1:0));
+ Console.WriteLine($"PASS actual QuitAct: logout={logout}, stopFailure={failure}, identity invalidated before stop wait and no duplicate logout");
+}
+class User:IUserService {public bool Valid=true;public int Logouts,Applied;public void Logout(){Valid=false;Logouts++;} public void DeliverOldHttpAndLogin(){if(Valid)Applied+=2;} }
+class Hub:IRTService {public TaskCompletionSource Gate=new();public Task ReleaseAsync()=>Gate.Task;}
+class Ads:IAdsService {public bool Released;public void Release(){Released=true;}}
+class Config:asap.core.IConfig{public void Rest(){}}
+public interface IRTService{Task ReleaseAsync();}public interface IAdsService{void Release();}
+namespace asap.core { public class InjectAttribute:Attribute{} public interface IConfig{void Rest();} }
+namespace game {public interface IUserService{void Logout();}public static class QuitStage{public static bool Logout;} }
+namespace Game {public class AGameAct {protected virtual void OnDestroy(){} protected virtual void OnStarted(){}public virtual Task StartAsync()=>Task.FromResult(true);public void Invoke(string s,float n){} } }
+namespace GameCore {public static class Timer{public static int Cancelled,Destroyed;public static void Reset(){Cancelled=Destroyed=0;}public static void CancelAllRegisteredTimers(){Cancelled++;}public static void Destroy(){if(Cancelled!=Destroyed+1)throw new Exception("Timer destroyed before cancellation");Destroyed++;}} }
+namespace UnityEngine {public class GameObject {public static void Destroy(object x){}public static void DestroyImmediate(object x){}public static GameObject Find(string x)=>null;}public static class Debug{public static void LogException(Exception e){}}}
+public class SoundMgr {public void Release(){}}
+public static class LoadingScreen{public static void Hide(){}}
+public class UIManager{public static UIManager Instance=new();public UnityEngine.GameObject gameObject=new();}
+public class GameEventMgr{public static GameEventMgr Instance=new();public void Clear(){}}
+public class RedPointManager{public static RedPointManager Instance=new();public void Init(){}}
+public static class RootCtx{public static void DestoryRootCtx(){}}
diff --git a/CodexOutput/upserver/Gameplay101Validation/guide-live-inspect.cs.txt b/CodexOutput/upserver/Gameplay101Validation/guide-live-inspect.cs.txt
new file mode 100644
index 000000000..f1bd99fe0
--- /dev/null
+++ b/CodexOutput/upserver/Gameplay101Validation/guide-live-inspect.cs.txt
@@ -0,0 +1,11 @@
+using asap.core;
+using GameCore;
+using game;
+if(!GContext.Ready)return "GContext not ready; "+KVGameplayLoopSetup.State();
+var u=GContext.container.Resolve();
+var p=GContext.container.Resolve();
+var g=GContext.container.Resolve();
+var cfg=GContext.container.Resolve();
+var e=GContext.container.Resolve();
+GuideSaveData potion;g.GuideSaveDataDic.TryGetValue("EventPotion01",out potion);
+return new {loggedIn=u.IsLogin, account=u.CustomId, internalId=u.InternalId,backend=cfg.Get("SaveBackend",""),api=cfg.Get(GConstant.K_Event_API_URL,""),level=p.lv,gold=p.gold,energy=p.Energy,guideGroup=g.curGroupDefine==null?null:g.curGroupDefine.groupName.ToString(),guideNode=g.curDefine!=null,guideSavePointer=g.curSaveData!=null,potionEvent=e.GetEvent(4,5),potionSaved=potion==null?"missing":potion.groupName+"|"+potion.guideSubIndex+"|"+potion.isFinish,state=KVGameplayLoopSetup.State()}.ToString();
diff --git a/CodexOutput/upserver/Gameplay101Validation/guide-recovered-values.cs.txt b/CodexOutput/upserver/Gameplay101Validation/guide-recovered-values.cs.txt
new file mode 100644
index 000000000..3ab539c13
--- /dev/null
+++ b/CodexOutput/upserver/Gameplay101Validation/guide-recovered-values.cs.txt
@@ -0,0 +1,18 @@
+using asap.core;
+using GameCore;
+using game;
+using LitJson;
+if(!GContext.Ready || GContext.container==null) return "Bootstrap pending; "+KVGameplayLoopSetup.State();
+var u=GContext.container.Resolve();
+if(!u.IsLogin) return "Login pending; "+KVGameplayLoopSetup.State();
+var p=GContext.container.Resolve();
+var f=GContext.container.Resolve();
+var guide=GContext.container.Resolve();
+var source=SimpleJSON.JSON.Parse(System.IO.File.ReadAllText("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/backups/20260928T092418Z/101.json"))["data"];
+var mismatches=new System.Collections.Generic.List();
+foreach(var key in source.Keys)if(!string.Equals(PlayFabMgr.Instance.GetLocalData(key),source[key].Value,System.StringComparison.Ordinal))mismatches.Add(key);
+var rawMap=JsonMapper.ToObject>(PlayFabMgr.Instance.GetLocalData("Map_5_102"));
+var saved=guide.GuideSaveDataDic["EventPotion01"];
+var api=GContext.container.Resolve().ApiBaseUri.ToString();
+bool valuesPass=u.CustomId=="101" && api=="http://s101.local:18080/" && (ulong)p.gold==36552317UL && (int)p.Energy==7300 && p.GetEnergyRM()==7236 && rawMap["10231"]=="6|9.23|54|5|False" && System.Math.Abs(f.GetDataMaxWeight(10231)-9.23f)<0.001f && f.GetDataLevel(10231)==6 && saved.guideSubIndex==4 && !saved.isFinish && guide.curDefine==null && guide.curGroupDefine==null && guide.curSaveData==null;
+return JsonMapper.ToJson(new {valuesPass,account=u.CustomId,internalId=u.InternalId,api,gold=(ulong)p.gold,energy=(int)p.Energy,energyRM=p.GetEnergyRM(),core=p.PlayerDataDic["newData"],fishId=10231,fishRaw=rawMap["10231"],liveMaxWeight=f.GetDataMaxWeight(10231),liveLevel=f.GetDataLevel(10231),liveNewFish=f.GetDataNewFish(10231),guide=saved.groupName+"|"+saved.guideSubIndex+"|"+saved.isFinish,comparedKeys=source.Count,mismatches,state=KVGameplayLoopSetup.State()});
diff --git a/CodexOutput/upserver/Gameplay101Validation/guide-regressions.cs.txt b/CodexOutput/upserver/Gameplay101Validation/guide-regressions.cs.txt
new file mode 100644
index 000000000..648897d77
--- /dev/null
+++ b/CodexOutput/upserver/Gameplay101Validation/guide-regressions.cs.txt
@@ -0,0 +1,4 @@
+var consume=await KVGameplayLoopSetup.RunValidationAsync("fishing-consume");
+var guides=await KVGameLoadValidation.RunAsync("M09-M10-M12");
+var full=await KVGameLoadValidation.RunAsync("B2-20");
+return "consume="+consume+"\nguides="+guides+"\nfull="+full;
diff --git a/CodexOutput/upserver/Gameplay101Validation/inspect-source101.cs.txt b/CodexOutput/upserver/Gameplay101Validation/inspect-source101.cs.txt
new file mode 100644
index 000000000..db7b2aa98
--- /dev/null
+++ b/CodexOutput/upserver/Gameplay101Validation/inspect-source101.cs.txt
@@ -0,0 +1 @@
+return KVGameplayLoopSetup.InspectPFBackup("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/pf-source-backups/20260924T041802Z/101.json");
\ No newline at end of file
diff --git a/CodexOutput/upserver/Gameplay101Validation/prepare-guide-fixed-v3.cs.txt b/CodexOutput/upserver/Gameplay101Validation/prepare-guide-fixed-v3.cs.txt
new file mode 100644
index 000000000..efeea53b6
--- /dev/null
+++ b/CodexOutput/upserver/Gameplay101Validation/prepare-guide-fixed-v3.cs.txt
@@ -0,0 +1,4 @@
+if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || UnityEditor.EditorApplication.isCompiling || UnityEditor.EditorApplication.isUpdating) throw new System.InvalidOperationException("Idle required");
+if(System.IO.File.Exists("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/permit.json")) throw new System.InvalidOperationException("Active permit exists");
+using(var p=System.Diagnostics.Process.GetCurrentProcess()){var id=p.Id+"_"+p.StartTime.ToUniversalTime().Ticks;if(System.IO.File.Exists("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/run-control/"+id+".claimed") || System.IO.Directory.Exists("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/runs/"+id))throw new System.InvalidOperationException("Process already used");}
+return KVGameplayLoopSetup.PrepareFishingRewardPermit("101","C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/backups/20260928T091527Z/101.json",new string[]{"AquariumEventDataNewKey","BattlePassRecordTask","BombGameCount","BuffDataList","BuyData","ChainPackDataNew","DeferredRewardStashService","epbdk","epdk","Event1v1BattleSlapData","EventBingo","EventBoostPackData","EventBossFightData","EventCubeMeltData","EventLuckGameRecord","EventLuckMagicPlayfabData","EventLuckyTowerData","EventOfferDevourFishData","EventPackData","EventPartnerStatueData","EventTransitionTokenDic","FishHuntGameCount","FishingChallengeData","FixedTimeShopDic","GeneralEventPackData","GuideSaveDataNew","IsOpenAppraise","IsOpenClubRed","LastPaymentTime","Leadboard_LastContinent","LuckyTaskADKey","Map_5_101","Map_5_102","MiniBPKeyNew","MMTest","MonthlySignEvent","NewPlayerDataDic","NewRodList","OfferChainsChestData","OfferDiscoPartyManager","OfferStoryChain","OfferSweepPackData","Pack1A1Data","Pack1A2Data","PiggyBankPackData","RodData","RodLevelData","SeasonStampData","ShopPackBuyCount","SoloDataKey","TargetBuffDataList","TransitionData","TriggerPackDataList","VIPAdPackData","WelcomeBack"});
\ No newline at end of file
diff --git a/CodexOutput/upserver/Gameplay101Validation/prepare-guide-recovery-v1.cs.txt b/CodexOutput/upserver/Gameplay101Validation/prepare-guide-recovery-v1.cs.txt
new file mode 100644
index 000000000..b0cb48ab9
--- /dev/null
+++ b/CodexOutput/upserver/Gameplay101Validation/prepare-guide-recovery-v1.cs.txt
@@ -0,0 +1,4 @@
+if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || UnityEditor.EditorApplication.isCompiling || UnityEditor.EditorApplication.isUpdating) throw new System.InvalidOperationException("Idle required");
+if(System.IO.File.Exists("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/permit.json")) throw new System.InvalidOperationException("Active permit exists");
+using(var p=System.Diagnostics.Process.GetCurrentProcess()){var id=p.Id+"_"+p.StartTime.ToUniversalTime().Ticks;if(System.IO.File.Exists("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/run-control/"+id+".claimed") || System.IO.Directory.Exists("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/runs/"+id))throw new System.InvalidOperationException("Process already used");}
+return KVGameplayLoopSetup.PreparePermit("101","C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/backups/20260928T092418Z/101.json",new string[]{"AquariumEventDataNewKey","BattlePassRecordTask","BombGameCount","BuffDataList","BuyData","ChainPackDataNew","DeferredRewardStashService","epbdk","epdk","Event1v1BattleSlapData","EventBingo","EventBoostPackData","EventBossFightData","EventCubeMeltData","EventLuckGameRecord","EventLuckMagicPlayfabData","EventLuckyTowerData","EventOfferDevourFishData","EventPackData","EventPartnerStatueData","EventTransitionTokenDic","FishHuntGameCount","FishingChallengeData","FixedTimeShopDic","GeneralEventPackData","GuideSaveDataNew","IsOpenAppraise","IsOpenClubRed","LastPaymentTime","Leadboard_LastContinent","LuckyTaskADKey","Map_5_101","Map_5_102","MiniBPKeyNew","MMTest","MonthlySignEvent","NewPlayerDataDic","NewRodList","OfferChainsChestData","OfferDiscoPartyManager","OfferStoryChain","OfferSweepPackData","Pack1A1Data","Pack1A2Data","PiggyBankPackData","RodData","RodLevelData","SeasonStampData","ShopPackBuyCount","SoloDataKey","TargetBuffDataList","TransitionData","TriggerPackDataList","VIPAdPackData","WelcomeBack"});
\ No newline at end of file
diff --git a/CodexOutput/upserver/Gameplay101Validation/prepare-manual-v3.cs.txt b/CodexOutput/upserver/Gameplay101Validation/prepare-manual-v3.cs.txt
new file mode 100644
index 000000000..c50149d79
--- /dev/null
+++ b/CodexOutput/upserver/Gameplay101Validation/prepare-manual-v3.cs.txt
@@ -0,0 +1,4 @@
+if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || UnityEditor.EditorApplication.isCompiling || UnityEditor.EditorApplication.isUpdating) throw new System.InvalidOperationException("Idle required");
+if(System.IO.File.Exists("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/permit.json")) throw new System.InvalidOperationException("Active permit exists");
+using(var p=System.Diagnostics.Process.GetCurrentProcess()){var id=p.Id+"_"+p.StartTime.ToUniversalTime().Ticks;if(System.IO.File.Exists("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/run-control/"+id+".claimed") || System.IO.Directory.Exists("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/runs/"+id))throw new System.InvalidOperationException("Process already used");}
+return KVGameplayLoopSetup.PrepareFishingRewardPermit("101","C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/backups/20260928T081406Z/101.json",new string[]{"AquariumEventDataNewKey","BattlePassRecordTask","BombGameCount","BuffDataList","BuyData","ChainPackDataNew","DeferredRewardStashService","epbdk","epdk","Event1v1BattleSlapData","EventBingo","EventBoostPackData","EventBossFightData","EventCubeMeltData","EventLuckGameRecord","EventLuckMagicPlayfabData","EventLuckyTowerData","EventOfferDevourFishData","EventPackData","EventPartnerStatueData","EventTransitionTokenDic","FishHuntGameCount","FishingChallengeData","FixedTimeShopDic","GeneralEventPackData","GuideSaveDataNew","IsOpenAppraise","IsOpenClubRed","LastPaymentTime","Leadboard_LastContinent","LuckyTaskADKey","Map_5_101","Map_5_102","MiniBPKeyNew","MMTest","MonthlySignEvent","NewPlayerDataDic","NewRodList","OfferChainsChestData","OfferDiscoPartyManager","OfferStoryChain","OfferSweepPackData","Pack1A1Data","Pack1A2Data","PiggyBankPackData","RodData","RodLevelData","SeasonStampData","ShopPackBuyCount","SoloDataKey","TargetBuffDataList","TransitionData","TriggerPackDataList","VIPAdPackData","WelcomeBack"});
\ No newline at end of file
diff --git a/CodexOutput/upserver/Gameplay101Validation/prepare-v3.cs.txt b/CodexOutput/upserver/Gameplay101Validation/prepare-v3.cs.txt
new file mode 100644
index 000000000..4965644f3
--- /dev/null
+++ b/CodexOutput/upserver/Gameplay101Validation/prepare-v3.cs.txt
@@ -0,0 +1,4 @@
+if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || UnityEditor.EditorApplication.isCompiling || UnityEditor.EditorApplication.isUpdating) throw new System.InvalidOperationException("Idle required");
+if(System.IO.File.Exists("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/permit.json")) throw new System.InvalidOperationException("Active permit exists");
+using(var p=System.Diagnostics.Process.GetCurrentProcess()){var id=p.Id+"_"+p.StartTime.ToUniversalTime().Ticks;if(System.IO.File.Exists("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/run-control/"+id+".claimed") || System.IO.Directory.Exists("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/runs/"+id))throw new System.InvalidOperationException("Process already used");}
+return KVGameplayLoopSetup.PrepareFishingRewardPermit("101","C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/backups/20260928T075611Z/101.json",new string[]{"AquariumEventDataNewKey","BattlePassRecordTask","BombGameCount","BuffDataList","BuyData","ChainPackDataNew","DeferredRewardStashService","epbdk","epdk","Event1v1BattleSlapData","EventBingo","EventBoostPackData","EventBossFightData","EventCubeMeltData","EventLuckGameRecord","EventLuckMagicPlayfabData","EventLuckyTowerData","EventOfferDevourFishData","EventPackData","EventPartnerStatueData","EventTransitionTokenDic","FishHuntGameCount","FishingChallengeData","FixedTimeShopDic","GeneralEventPackData","GuideSaveDataNew","IsOpenAppraise","IsOpenClubRed","LastPaymentTime","Leadboard_LastContinent","LuckyTaskADKey","Map_5_101","Map_5_102","MiniBPKeyNew","MMTest","MonthlySignEvent","NewPlayerDataDic","NewRodList","OfferChainsChestData","OfferDiscoPartyManager","OfferStoryChain","OfferSweepPackData","Pack1A1Data","Pack1A2Data","PiggyBankPackData","RodData","RodLevelData","SeasonStampData","ShopPackBuyCount","SoloDataKey","TargetBuffDataList","TransitionData","TriggerPackDataList","VIPAdPackData","WelcomeBack"});
\ No newline at end of file
diff --git a/CodexOutput/upserver/Gameplay101Validation/reconcile-guide-final.ps1 b/CodexOutput/upserver/Gameplay101Validation/reconcile-guide-final.ps1
new file mode 100644
index 000000000..54bfc7f5e
--- /dev/null
+++ b/CodexOutput/upserver/Gameplay101Validation/reconcile-guide-final.ps1
@@ -0,0 +1,30 @@
+$ErrorActionPreference='Stop'
+$root='C:/projects/ft/CodexOutput/upserver'
+$rounds=@('20260928T091527Z','20260928T092018Z','20260928T092245Z','20260928T092418Z','20260928T092948Z')
+function Compare-Values($before,$after){
+ $names=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ foreach($p in $before.PSObject.Properties){[void]$names.Add($p.Name)}
+ foreach($p in $after.PSObject.Properties){[void]$names.Add($p.Name)}
+ $differences=@(foreach($key in $names){
+ $a=$before.PSObject.Properties[$key];$b=$after.PSObject.Properties[$key]
+ if($null -eq $a -or $null -eq $b -or ![string]::Equals([string]$a.Value,[string]$b.Value,[StringComparison]::Ordinal)){$key}
+ })
+ [ordered]@{beforeKeys=@($before.PSObject.Properties).Count;afterKeys=@($after.PSObject.Properties).Count;differentKeys=$differences;ordinalEqual=$differences.Count -eq 0}
+}
+$comparison=@()
+foreach($account in @('101','102')){
+ $data=@($rounds|ForEach-Object {(Get-Content -LiteralPath "$root/KVGameplayLoopTests/backups/$_/$account.json" -Raw|ConvertFrom-Json).data})
+ for($i=1;$i -lt $rounds.Count;$i++){$comparison+=[ordered]@{account=$account;before=$rounds[$i-1];after=$rounds[$i];comparison=(Compare-Values $data[$i-1] $data[$i])}}
+}
+$runFacts=@()
+foreach($prefix in @('55088','130200')){
+ $run=Get-ChildItem "$root/KVGameplayLoopTests/runs" -Directory -Filter ($prefix+'_*')
+ $events=@(Get-Content ($run.FullName+'/events.jsonl')|ForEach-Object {$_|ConvertFrom-Json})
+ $state=Get-Content (Get-ChildItem ($run.FullName+'/cache') -Recurse -Filter state.json).FullName -Raw|ConvertFrom-Json
+ $final=(Get-Content "$root/KVGameplayLoopTests/backups/20260928T092948Z/101.json" -Raw|ConvertFrom-Json).data
+ $runFacts+=[ordered]@{run=$run.Name;pending=@($state.pending).Count;activeBatch=$state.activeBatch;unknownBatch=$state.unknownBatch;cacheVsFinal=(Compare-Values $state.baseline $final);counts=@($events|Group-Object kind|ForEach-Object {[ordered]@{kind=$_.Name;count=$_.Count}});closed=($events|Where-Object kind -eq 'closed').at}
+}
+$result=[ordered]@{createdUtc=[DateTime]::UtcNow.ToString('o');comparisons=$comparison;runs=$runFacts;activePermit=Test-Path "$root/KVGameplayLoopTests/permit.json";claims=@(Get-ChildItem "$root/KVGameplayLoopTests/run-control" -Filter '*.claimed'|ForEach-Object Name);scope='Ordinal comparison of local captured data; event counts are application records, not HTTP request totals'}
+$result|ConvertTo-Json -Depth 12|Set-Content "$root/Gameplay101Validation/guide-final-reconciliation.json" -Encoding utf8
+$result.comparisons|ConvertTo-Json -Depth 7
+$result.runs|ConvertTo-Json -Depth 7
diff --git a/CodexOutput/upserver/HomeExitTests/HomeExitTests.csproj b/CodexOutput/upserver/HomeExitTests/HomeExitTests.csproj
new file mode 100644
index 000000000..8333f5f9e
--- /dev/null
+++ b/CodexOutput/upserver/HomeExitTests/HomeExitTests.csproj
@@ -0,0 +1 @@
+Exenet9.0enable$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config
diff --git a/CodexOutput/upserver/HomeExitTests/Program.cs b/CodexOutput/upserver/HomeExitTests/Program.cs
new file mode 100644
index 000000000..297fa21a7
--- /dev/null
+++ b/CodexOutput/upserver/HomeExitTests/Program.cs
@@ -0,0 +1,29 @@
+void Check(bool value,string message){if(!value)throw new Exception(message);}
+foreach(var scenario in new[]{"normal","destroyed","replaced"}){
+ GContext.container=new Container();Awaiters.Gate=new();var old=GContext.container;var panel=new Subject();
+ var pending=panel.LastShowPanel();Check(!pending.IsCompleted,"expected delay");
+ if(scenario=="destroyed")panel.Destroyed=true;
+ if(scenario=="replaced")GContext.container=new Container();
+ Awaiters.Gate.SetResult(true);await pending;
+ Check(old.Face.Shows==(scenario=="normal"?1:0),"old flow applied after exit");
+ if(scenario=="replaced")Check(GContext.container.Face.Shows==0,"old callback opened new account UI");
+ Check(Debug.Errors==0,"callback logged an error");
+ Console.WriteLine("PASS real LastShowPanel body: "+scenario);
+}
+public partial class Subject{
+ public bool Destroyed;
+ public static bool operator ==(Subject a,Subject b)=>ReferenceEquals(b,null)?ReferenceEquals(a,null)||a.Destroyed:ReferenceEquals(a,b);
+ public static bool operator !=(Subject a,Subject b)=>!(a==b);
+ public override bool Equals(object other)=>ReferenceEquals(this,other);
+ public override int GetHashCode()=>base.GetHashCode();
+}
+public static class GContext{public static Container container=new();}
+public class Container{
+ public Face Face=new();public BuffDataCenter Buff=new();
+ public T Resolve()=>(T)(object)(typeof(T)==typeof(BuffDataCenter)?(object)Buff:Face);
+}
+public class BuffDataCenter{public List showFishBuffIds=new();}
+public interface IFaceUIService{void ShowPostFaceUI();}
+public class Face:IFaceUIService{public int Shows;public void ShowPostFaceUI()=>Shows++;}
+public static class Awaiters{public static TaskCompletionSource Gate;public static Task Seconds(float n)=>Gate.Task;}
+public static class Debug{public static int Errors;public static void LogError(object e)=>Errors++;}
diff --git a/CodexOutput/upserver/HomeExitTests/generate.py b/CodexOutput/upserver/HomeExitTests/generate.py
new file mode 100644
index 000000000..df00fae57
--- /dev/null
+++ b/CodexOutput/upserver/HomeExitTests/generate.py
@@ -0,0 +1,7 @@
+from pathlib import Path
+s=Path('Client/Assets/Scripts/UI/Home/HomePanel.cs').read_text(encoding='utf-8-sig')
+start=s.index('async void LastShowPanel()'); a=s.index('{',start); n=1; end=a+1
+while n:
+ n+=(s[end]=='{')-(s[end]=='}'); end+=1
+method=s[start:end].replace('async void LastShowPanel()', 'public async Task LastShowPanel()')
+Path('CodexOutput/upserver/HomeExitTests/Generated.cs').write_text('using System;using System.Threading.Tasks;public partial class Subject {\n'+method+'\n}',encoding='utf-8')
diff --git a/CodexOutput/upserver/HomeLoginTests/HomeLoginTests.csproj b/CodexOutput/upserver/HomeLoginTests/HomeLoginTests.csproj
new file mode 100644
index 000000000..178760a4e
--- /dev/null
+++ b/CodexOutput/upserver/HomeLoginTests/HomeLoginTests.csproj
@@ -0,0 +1 @@
+Exenet9.0enable$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll
diff --git a/CodexOutput/upserver/HomeLoginTests/Program.cs b/CodexOutput/upserver/HomeLoginTests/Program.cs
new file mode 100644
index 000000000..488c5cbca
--- /dev/null
+++ b/CodexOutput/upserver/HomeLoginTests/Program.cs
@@ -0,0 +1,17 @@
+void Assert(bool ok,string name){if(!ok)throw new Exception(name);}
+foreach(var json in new string[]{null,"","[]","{}","bad","{\"clubInfo\":false}","{\"clubInfo\":{\"id\":0}}","{\"clubInfo\":{\"id\":8}}"}){
+ var s=new Subject();var old=s.myClubInfo;s.customServerMgr.Response=json;s.GetSelfClub();Assert(ReferenceEquals(old,s.myClubInfo)&&s.Effects==0,"Invalid response must preserve known state: "+json);
+}
+{
+ var s=new Subject();s.userService.IsAuthorized=false;s.GetSelfClub();Assert(s.customServerMgr.Calls==0,"No unauthorized request");
+}
+{
+ var s=new Subject();s.customServerMgr.Response="{\"clubInfo\":null}";s.GetSelfClub();Assert(s.myClubInfo==null && s.customServerMgr.Request.playerId=="101","Explicit no club and correct identity");
+}
+{
+ var s=new Subject();var old=s.myClubInfo;s.customServerMgr.Gate=new();s.GetSelfClub();s.customServerMgr.SessionVersion++;s.customServerMgr.Gate.SetResult("{\"clubInfo\":null}");Assert(ReferenceEquals(old,s.myClubInfo)&&s.Effects==0,"Old response ignored");
+}
+{
+ var s=new Subject();s.customServerMgr.Response="{\"clubInfo\":{\"id\":8},\"role\":2,\"playerIDs\":{\"42\":\"101\"}}";s.GetSelfClub();Assert(s.myClubInfo.id==8 && s.memberRole==EMemberRole.Member && s.chatService.Calls==1 && s.internalIdToPlayerId[42]=="101","Valid member response applied");
+}
+Console.WriteLine("PASS home GetSelfClub: identity, unauthorized, failed/missing responses, explicit no-club, old response isolation");
diff --git a/CodexOutput/upserver/HomeLoginTests/generate.py b/CodexOutput/upserver/HomeLoginTests/generate.py
new file mode 100644
index 000000000..ca4dd9131
--- /dev/null
+++ b/CodexOutput/upserver/HomeLoginTests/generate.py
@@ -0,0 +1,17 @@
+from pathlib import Path
+def block(s,marker):
+ start=s.index(marker);a=s.index('{',start);n=1;b=a+1
+ while n:n+=(s[b]=='{')-(s[b]=='}');b+=1
+ return s[start:b]
+s=Path('Client/Assets/Scripts/Services/ClubService.cs').read_text(encoding='utf-8-sig');m=Path('Client/Assets/Scripts/Services/ClubModel.cs').read_text(encoding='utf-8-sig')
+code='using System;using System.Collections.Generic;using System.ComponentModel;using System.Threading.Tasks;using Newtonsoft.Json.Linq;\n'
+code+=block(m,'public enum EMemberRole')+'\n'+'\n'.join(block(m,'public class '+x+'\n') for x in ['ClubInfo','ClubSettingData','GetSelfClubRequest','GetSelfClubResp','ClubPlayerProfile'])
+code+='''
+public class Subject {public User userService=new();public Server customServerMgr=new();public Chat chatService=new();public ClubInfo myClubInfo=new(){id=9};public EMemberRole memberRole=EMemberRole.Owner;public Dictionary internalIdToPlayerId=new();public int Effects;void SetClubPlayerProfile(List p){Effects++;}void SetClubRed(){Effects++;}void MyClubInfoChange(){Effects++;}
+'''+block(s,'public async void GetSelfClub()')+'\n}\n'
+code+='''
+public class User {public bool IsAuthorized=true;public string ApiPlayerId="101";}
+public class Server {public int SessionVersion,Calls;public string Response;public GetSelfClubRequest Request;public TaskCompletionSource Gate;public Task ClubRequest(string path,object data){Calls++;Request=(GetSelfClubRequest)data;return Gate?.Task??Task.FromResult(Response);}}
+public class Chat{public int Calls;public void GetConversationList(long id){Calls++;}}
+'''
+Path('CodexOutput/upserver/HomeLoginTests/Generated.cs').write_text(code)
diff --git a/CodexOutput/upserver/KVContractTests/KVContractTests.csproj b/CodexOutput/upserver/KVContractTests/KVContractTests.csproj
new file mode 100644
index 000000000..aef6e7f08
--- /dev/null
+++ b/CodexOutput/upserver/KVContractTests/KVContractTests.csproj
@@ -0,0 +1,16 @@
+
+
+ Exe
+ net9.0
+ enable
+ disable
+ $(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config
+
+
+
+
+
+ ../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll
+
+
+
diff --git a/CodexOutput/upserver/KVContractTests/Program.cs b/CodexOutput/upserver/KVContractTests/Program.cs
new file mode 100644
index 000000000..d6fa8ae28
--- /dev/null
+++ b/CodexOutput/upserver/KVContractTests/Program.cs
@@ -0,0 +1,175 @@
+using System.Net;
+using System.Text;
+using game.PlayerDataStorage;
+using Newtonsoft.Json.Linq;
+
+var passed = 0;
+async Task Check(string name, Func action)
+{
+ await action();
+ Console.WriteLine("PASS " + name);
+ passed++;
+}
+void Assert(bool ok, string message) { if (!ok) throw new Exception(message); }
+async Task<(PlayerKVResult result, Probe handler)> Get(string body, int status = 200)
+{
+ var handler = new Probe(body, status);
+ using var http = new HttpClient(handler);
+ var result = await new PlayerKVClient(http, new Uri("http://example.test/api")).GetAsync();
+ return (result, handler);
+}
+
+await Check("Get preserves string/empty/JSON values and full-read request", async () => {
+ var (r, h) = await Get("{\"data\":{\"empty\":\"\",\"json\":\"{\\\"v\\\":1}\"}}");
+ Assert(r.Success && r.Data["empty"] == "" && r.Data["json"] == "{\"v\":1}", "Values changed");
+ Assert(h.Url == "http://example.test/api/playerdata/Get" && h.Body == "{}", "Wrong route/body");
+});
+await Check("Empty data is valid", async () => Assert((await Get("{\"data\":{}}")).result.Success, "Empty rejected"));
+foreach (var body in new[] { "", "null", "{}", "{\"data\":null}", "{\"data\":[]}", "{\"data\":{\"x\":1}}", "{\"data\":{\"x\":null}}", "not-json" })
+ await Check("Invalid Get response " + body, async () => {
+ var (r, _) = await Get(body);
+ Assert(!r.Success && r.Error == PlayerKVError.Protocol && r.Data == null, "Invalid response accepted");
+ });
+await Check("Filtered read preserves keys", async () => {
+ var h = new Probe("{\"data\":{}}"); using var http = new HttpClient(h);
+ await new PlayerKVClient(http, new Uri("http://example.test/")).GetAsync(new[] { "missing", "Lv" });
+ Assert(JObject.Parse(h.Body)["keys"].Count() == 2, "Missing keys");
+});
+await Check("Set/Delete accept empty 200 and preserve payload", async () => {
+ var h = new Probe(""); using var http = new HttpClient(h);
+ var kv = new PlayerKVClient(http, new Uri("http://example.test/"));
+ var r = await kv.SetAsync(new Dictionary { ["key"] = "{\"v\":1}" });
+ Assert(r.Success && h.Url.EndsWith("/Set") && (string)JObject.Parse(h.Body)["data"]["key"] == "{\"v\":1}", "Set failed");
+ Assert((await kv.DeleteAsync(new[] { "key" })).Success && h.Url.EndsWith("/Delete"), "Delete failed");
+});
+foreach (var status in new[] { 401, 403, 400, 500, 302 })
+ await Check("HTTP failure " + status, async () => {
+ var (r, _) = await Get("", status);
+ Assert(!r.Success && r.StatusCode == status && r.Error == (status == 401 || status == 403 ? PlayerKVError.Unauthorized : PlayerKVError.Http), "Wrong HTTP result");
+ });
+await Check("Invalid write/key rejected before HTTP", async () => {
+ var h = new Probe(""); using var http = new HttpClient(h);
+ var kv = new PlayerKVClient(http, new Uri("http://example.test/"));
+ Assert((await kv.SetAsync(null)).Error == PlayerKVError.Validation, "Null write");
+ Assert((await kv.SetAsync(new Dictionary { ["x"] = null })).Error == PlayerKVError.Validation, "Null value");
+ Assert((await kv.GetAsync(new[] { new string('a',129) })).Error == PlayerKVError.Validation, "Long key");
+ Assert((await kv.DeleteAsync(null)).Error == PlayerKVError.Validation && h.Calls == 0, "Unexpected request");
+});
+foreach (var mode in new[] { "network", "timeout", "cancel" })
+ await Check(mode + " failure", async () => {
+ var h = new Probe("") { Mode = mode }; using var http = new HttpClient(h);
+ using var cts = new CancellationTokenSource(); if(mode == "cancel") cts.Cancel();
+ var r = await new PlayerKVClient(http,new Uri("http://example.test/")).GetAsync(null,cts.Token);
+ var expected = mode == "network" ? PlayerKVError.Network : mode == "timeout" ? PlayerKVError.Timeout : PlayerKVError.Cancelled;
+ Assert(!r.Success && r.Error == expected, "Wrong exception mapping");
+ });
+foreach (var body in new[] { "{\"data\":{}} {}", "{\"data\":{\"x\":\"a\",\"x\":\"b\"}}", "{\"data\":{},\"data\":{}}", "{\"data\":{\"x\":true}}" })
+ await Check("Reject ambiguous or non-string response", async () => {
+ var (r, _) = await Get(body);
+ Assert(r.Error == PlayerKVError.Protocol && r.Data == null, "Ambiguous data accepted");
+ });
+await Check("Date-shaped values remain strings; key comparison is ordinal", async () => {
+ var (r, _) = await Get("{\"data\":{\"A\":\"2026-09-22T00:00:00Z\",\"a\":\"different\"}}");
+ Assert(r.Success && r.Data["A"] == "2026-09-22T00:00:00Z" && r.Data["a"] == "different", "Value coercion");
+ try { ((IDictionary)r.Data).Add("mutated", "x"); throw new Exception("Mutable result"); }
+ catch (NotSupportedException) { }
+});
+await Check("Empty operations preserve server authentication and full-read semantics", async () => {
+ var h = new Probe("",401); using var http = new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/"));
+ Assert((await kv.GetAsync(Array.Empty())).Error==PlayerKVError.Unauthorized && h.Body=="{}", "Empty Get");
+ Assert((await kv.SetAsync(new Dictionary())).Error==PlayerKVError.Unauthorized && h.Body=="{\"data\":{}}", "Empty Set bypassed auth");
+ Assert((await kv.DeleteAsync(Array.Empty())).Error==PlayerKVError.Unauthorized && h.Body=="{\"keys\":[]}" && h.Calls==3, "Empty Delete bypassed auth");
+});
+await Check("128-char keys and empty strings allowed; empty/null/129-char keys rejected", async () => {
+ var h=new Probe("");using var http=new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/"));
+ Assert((await kv.SetAsync(new Dictionary{{new string('k',128),""}})).Success,"Valid boundary rejected");
+ foreach(var key in new[]{"",null,new string('k',129)}) {
+ Assert((await kv.GetAsync(new[]{key})).Error==PlayerKVError.Validation,"Invalid read key");
+ Assert((await kv.DeleteAsync(new[]{key})).Error==PlayerKVError.Validation,"Invalid delete key");
+ }
+ Assert(h.Calls==1,"Validation sent requests");
+});
+foreach(var action in new[]{"Get","Set","Delete"})
+ await Check(action+" payload is snapshotted before caller mutation",async()=>{
+ var h=new DeferredProbe();using var http=new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/api/"));
+ var keys=new List{"original"};var data=new Dictionary{{"original","old"}};
+ var pending=action=="Get"?kv.GetAsync(keys):action=="Set"?kv.SetAsync(data):kv.DeleteAsync(keys);
+ keys[0]="changed";data["original"]="new";data["added"]="extra";
+ h.Gate.SetResult(true);var r=await pending;
+ Assert(r.Success && h.Body.Contains("original") && !h.Body.Contains("changed") && !h.Body.Contains("extra") && !h.Body.Contains("new"),"Mutable request leaked");
+ Assert(h.Url=="http://example.test/api/playerdata/"+action,"Wrong absolute route");
+ });
+foreach(var action in new[]{"Set","Delete"})
+ foreach(var failure in new[]{"network","timeout","http"})
+ await Check(action+" "+failure+" is not retried and has unknown commit outcome",async()=>{
+ var h=new Probe("",failure=="http"?500:200){Mode=failure};using var http=new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/"));
+ var r=action=="Set"?await kv.SetAsync(new Dictionary{{"x","y"}}):await kv.DeleteAsync(new[]{"x"});
+ Assert(!r.Success && r.MayHaveCommitted && r.Data==null && h.Calls==1,"Unsafe failure/retry semantics");
+ });
+await Check("Pre-cancelled write never dispatches; in-flight cancellation may have committed",async()=>{
+ using var cts=new CancellationTokenSource();cts.Cancel();var h=new DeferredProbe();using var http=new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/"));
+ var before=await kv.DeleteAsync(new[]{"x"},cts.Token);
+ Assert(before.Error==PlayerKVError.Cancelled && !before.MayHaveCommitted && h.Calls==0,"Pre-cancel dispatch");
+ using var running=new CancellationTokenSource();var task=kv.SetAsync(new Dictionary{{"x","y"}},running.Token);
+ running.Cancel();h.Gate.SetResult(true);var after=await task;
+ Assert(after.Error==PlayerKVError.Cancelled && after.MayHaveCommitted && h.Calls==1,"Cancelled mutation reported success");
+});
+await Check("Cancelled read cannot publish a late successful result",async()=>{
+ using var cts=new CancellationTokenSource();var h=new DeferredProbe();using var http=new HttpClient(h);var kv=new PlayerKVClient(http,new Uri("http://example.test/"));
+ var task=kv.GetAsync(null,cts.Token);cts.Cancel();h.Gate.SetResult(true);var r=await task;
+ Assert(r.Error==PlayerKVError.Cancelled && r.Data==null && !r.MayHaveCommitted,"Late read published");
+});
+await Check("Adapter preserves injected client configuration and ownership; disposes messages",async()=>{
+ var h=new DeferredProbe();using var http=new HttpClient(h){BaseAddress=new Uri("http://other.test/")};http.DefaultRequestHeaders.Add("X-Test","preserve");
+ var kv=new PlayerKVClient(http,new Uri("http://example.test/"));var task=kv.GetAsync();h.Gate.SetResult(true);await task;
+ Assert(http.BaseAddress.AbsoluteUri=="http://other.test/" && http.DefaultRequestHeaders.GetValues("X-Test").Single()=="preserve","Shared configuration mutated");
+ Assert(h.ResponseBody.Disposed,"Response not disposed");
+ try{await h.Request.Content.ReadAsStringAsync();throw new Exception("Request not disposed");}catch(ObjectDisposedException){}
+ using var stillAlive=await http.GetAsync("probe");Assert(stillAlive.IsSuccessStatusCode,"Injected client disposed");
+});
+await Check("Disposed owner transport returns cancellation without logging payload",async()=>{
+ var http=new HttpClient(new Probe(""));var kv=new PlayerKVClient(http,new Uri("http://example.test/"));http.Dispose();
+ Assert((await kv.GetAsync()).Error==PlayerKVError.Cancelled,"Disposed owner failed classification");
+});
+await Check("Reject invalid base URIs before any request",()=>{
+ using var http=new HttpClient(new Probe(""));
+ foreach(var uri in new Uri[]{null,new Uri("relative",UriKind.Relative),new Uri("ftp://example.test/"),new Uri("http://example.test/?q=x"),new Uri("http://example.test/#x"),new Uri("http://user:secret@example.test/")}) {
+ try{new PlayerKVClient(http,uri);throw new Exception("Invalid base allowed");}catch(ArgumentException){}
+ }
+ return Task.CompletedTask;
+});
+Console.WriteLine($"Passed {passed} tests");
+
+sealed class Probe : HttpMessageHandler
+{
+ public string Url, Body, Mode;
+ public int Calls;
+ readonly string response;
+ readonly int status;
+ public Probe(string response, int status = 200) { this.response=response; this.status=status; }
+ protected override async Task SendAsync(HttpRequestMessage req, CancellationToken token)
+ {
+ Calls++; Url=req.RequestUri.AbsoluteUri; Body=await req.Content.ReadAsStringAsync();
+ if(Mode == "network") throw new HttpRequestException("simulated");
+ if(Mode == "timeout" || Mode == "cancel") throw new TaskCanceledException();
+ return new HttpResponseMessage((HttpStatusCode)status) { Content=new StringContent(response,Encoding.UTF8,"application/json") };
+ }
+}
+
+sealed class DeferredProbe : HttpMessageHandler
+{
+ public readonly TaskCompletionSource Gate=new();
+ public int Calls;public string Body,Url;public HttpRequestMessage Request;public TrackedContent ResponseBody;
+ protected override async Task SendAsync(HttpRequestMessage request,CancellationToken token)
+ {
+ Calls++;Request=request;await Gate.Task; // deliberately returns even if the caller cancelled
+ Url=request.RequestUri.AbsoluteUri;Body=request.Content==null?"":await request.Content.ReadAsStringAsync();
+ ResponseBody=new TrackedContent(Url.EndsWith("/Get")?"{\"data\":{}}":"");
+ return new HttpResponseMessage(HttpStatusCode.OK){Content=ResponseBody};
+ }
+}
+sealed class TrackedContent(string body) : StringContent(body)
+{
+ public bool Disposed;
+ protected override void Dispose(bool disposing){Disposed=true;base.Dispose(disposing);}
+}
diff --git a/CodexOutput/upserver/KVFishingConsumeTests/ControlSlice.cs.txt b/CodexOutput/upserver/KVFishingConsumeTests/ControlSlice.cs.txt
new file mode 100644
index 000000000..56325f7e5
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingConsumeTests/ControlSlice.cs.txt
@@ -0,0 +1,55 @@
+using System; using System.Linq; using System.Threading.Tasks;
+public class ControlSlice {
+public bool isActiveAndEnabled=true; public int Continues,LegacyCalls;
+public FishingData fishingData=new(); public Tables _tables=new();
+public game.PlayerDataStorage.PlayerKVGameplayLoop.FishingAction kvFishingAction=new();
+public void Run()=>ConsumeBait(); public Task RunAwaited()=>ConsumeBaitOnKVAsync();
+void WaitForFishBiteED()=>Continues++; void DoConsumeBait()=>LegacyCalls++;
+bool KVFishingCurrent()
+ {
+ if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return true;
+ return this!=null && isActiveAndEnabled && game.PlayerDataStorage.PlayerKVGameplayLoop.FishingCurrent(kvFishingAction);
+ }
+async Task ConsumeBaitOnKVAsync()
+ {
+ try
+ {
+ if(!KVFishingCurrent()) return;
+ if(!game.PlayerDataStorage.PlayerKVGameplayLoop.ClaimFishingConsumption(kvFishingAction)) return;
+ if(fishingData.fishItem==null || !fishingData.IsGeneralFish() || fishingData.IsFree || fishingData.IsRetry ||
+ fishingData.IsDuel || fishingData.IsPackItemFish() || fishingData.curFishData==null)
+ throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Only one ordinary paid fishing action is permitted.");
+ if(Application.internetReachability==NetworkReachability.NotReachable)
+ throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Fishing stopped before consumption: offline.");
+ if(_tables.TbFishingEvent.DataList.Any(e=>e.ConditionList.Any(c=>c.Condition==ConditionType.ConsumeEnergy)))
+ throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("ConsumeEnergy activity activation requires separate review.");
+ var player=GContext.container.Resolve();
+ int mag=player.magnification;
+ if(mag<0 || mag>=_tables.TbEnergyDef.DataList.Count)
+ throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Invalid fishing magnification.");
+ int fish=fishingData.curFishData.ID, rod=fishingData.RodId, map=player.lastMapId;
+ int cost=_tables.TbEnergyDef.DataList[mag].Mag;
+ await player.ConsumeEnergyForKVLoopAsync(cost,kvFishingAction);
+ if(!KVFishingCurrent()) return;
+ if(fishingData.curFishData==null || fishingData.curFishData.ID!=fish || fishingData.RodId!=rod ||
+ player.lastMapId!=map || (int)player.magnification!=mag || fishingData.IsFree || fishingData.IsRetry || fishingData.IsDuel)
+ throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Fishing context changed during confirmation.");
+ game.PlayerDataStorage.PlayerKVGameplayLoop.ContinueFishing(kvFishingAction,WaitForFishBiteED);
+ }
+ catch(Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
+ }
+private void ConsumeBait()
+ {
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { _=ConsumeBaitOnKVAsync(); return; }
+#endif
+ if (fishingData.IsFree)
+ {
+ WaitForFishBiteED();
+ }
+ else
+ {
+ DoConsumeBait();
+ }
+ }
+}
diff --git a/CodexOutput/upserver/KVFishingConsumeTests/ControlTests.cs.txt b/CodexOutput/upserver/KVFishingConsumeTests/ControlTests.cs.txt
new file mode 100644
index 000000000..f19ebf3ba
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingConsumeTests/ControlTests.cs.txt
@@ -0,0 +1,56 @@
+using Loop=game.PlayerDataStorage.PlayerKVGameplayLoop;
+int checks=0;
+void Check(bool ok,string text){checks++;if(!ok)throw new Exception(text);}
+ControlSlice Fresh(){Loop.Enabled=true;Loop.Ready=true;Loop.Faults=0;GContext.container.Player=new();Application.internetReachability=NetworkReachability.Reachable;return new();}
+var s=Fresh();var player=GContext.container.Player;var first=s.RunAwaited();
+Check(s.Continues==0 && player.Calls==1,"actual method waits for response");
+s.Run();await s.RunAwaited();Check(player.Calls==1 && s.Continues==0,"duplicate consume and callback cannot repeat");
+player.Response.SetResult();await first;Check(s.Continues==1,"actual success continuation exactly once");
+await s.RunAwaited();Check(s.Continues==1 && player.Calls==1,"post-success duplicate suppressed");
+foreach(var kind in new[]{"free","retry","duel","pack","special","missing-fish","missing-item","offline","activity","bad-mag"}){
+ s=Fresh();player=GContext.container.Player;
+ switch(kind){
+ case "free":s.fishingData.IsFree=true;break;case "retry":s.fishingData.IsRetry=true;break;
+ case "duel":s.fishingData.IsDuel=true;break;case "pack":s.fishingData.Pack=true;break;
+ case "special":s.fishingData.General=false;break;case "missing-fish":s.fishingData.curFishData=null;break;
+ case "missing-item":s.fishingData.fishItem=null;break;case "offline":Application.internetReachability=NetworkReachability.NotReachable;break;
+ case "activity":s._tables.TbFishingEvent.DataList.Add(new());break;case "bad-mag":player.magnification=-1;break;}
+ await s.RunAwaited();Check(player.Calls==0 && s.Continues==0 && Loop.Faults>0,"pre-consumption gate "+kind);
+}
+foreach(var kind in new[]{"fish","rod","map","mag","free","retry","duel","disabled","cancelled","fault"}){
+ s=Fresh();player=GContext.container.Player;var task=s.RunAwaited();
+ switch(kind){case "fish":s.fishingData.curFishData.ID++;break;case "rod":s.fishingData.RodId++;break;
+ case "map":player.lastMapId++;break;case "mag":player.magnification++;break;case "free":s.fishingData.IsFree=true;break;
+ case "retry":s.fishingData.IsRetry=true;break;case "duel":s.fishingData.IsDuel=true;break;
+ case "disabled":s.isActiveAndEnabled=false;break;case "cancelled":s.kvFishingAction.Cancelled=true;break;
+ case "fault":Loop.Ready=false;break;}
+ player.Response.SetResult();await task;Check(player.Calls==1 && s.Continues==0,"no stale continuation "+kind);
+}
+s=Fresh();player=GContext.container.Player;var failed=s.RunAwaited();player.Response.SetException(new InvalidOperationException("Unknown"));await failed;
+Check(s.Continues==0 && player.Calls==1 && !Loop.Ready,"async exception observed, no retry");
+s=Fresh();Loop.Enabled=false;s.Run();Check(s.LegacyCalls==1 && GContext.container.Player.Calls==0,"default paid PF path unchanged");
+s.fishingData.IsFree=true;s.Run();Check(s.Continues==1,"default free PF path unchanged");
+Console.WriteLine($"PASS {checks} assertions; compiled actual source methods; visual continuation and storage boundaries are spies; no network");
+public enum NetworkReachability{NotReachable,Reachable}
+public static class Application{public static NetworkReachability internetReachability;}
+public enum ConditionType{ConsumeEnergy}
+public sealed class Fish{public int ID=1;}
+public sealed class FishingData{public object fishItem=new();public Fish curFishData=new();public int RodId=1;public bool IsFree,IsRetry,IsDuel,Pack;public bool General=true;public bool IsGeneralFish()=>General;public bool IsPackItemFish()=>Pack;}
+public sealed class EnergyDef{public int Mag=3;}
+public sealed class EnergyTable{public List DataList=new(){new()};}
+public sealed class ConditionRule{public ConditionType Condition=ConditionType.ConsumeEnergy;}
+public sealed class Activity{public List ConditionList=new(){new()};}
+public sealed class ActivityTable{public List DataList=new();}
+public sealed class Tables{public EnergyTable TbEnergyDef=new();public ActivityTable TbFishingEvent=new();}
+public sealed class PlayerData{public int Calls,magnification,lastMapId;public TaskCompletionSource Response=new();public async Task ConsumeEnergyForKVLoopAsync(int n,Loop.FishingAction a){Calls++;await Response.Task;a.Confirmed=true;}}
+public sealed class Container{public PlayerData Player=new();public T Resolve()=>(T)(object)Player;}
+public static class GContext{public static Container container=new();}
+namespace game.PlayerDataStorage{public static class PlayerKVGameplayLoop{
+ public static bool Enabled,Ready;public static int Faults;
+ public sealed class FishingAction{public bool Consuming,Cancelled,Confirmed,Continued;}
+ public static bool FishingCurrent(FishingAction a)=>Ready && !a.Cancelled;
+ public static bool ClaimFishingConsumption(FishingAction a){if(a.Consuming)return false;a.Consuming=true;return true;}
+ public static InvalidOperationException Stop(string m){Fault(m);return new(m);}
+ public static void Fault(string m){Ready=false;Faults++;}
+ public static void ContinueFishing(FishingAction a,Action next){if(!FishingCurrent(a)||!a.Confirmed)throw Stop("invalid");if(a.Continued)return;a.Continued=true;next();}
+}}
diff --git a/CodexOutput/upserver/KVFishingConsumeTests/ControlTests.csproj b/CodexOutput/upserver/KVFishingConsumeTests/ControlTests.csproj
new file mode 100644
index 000000000..a99e17200
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingConsumeTests/ControlTests.csproj
@@ -0,0 +1,4 @@
+
+ Exenet9.0falseenableUNITY_EDITORfalse$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config
+ ../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll
+
diff --git a/CodexOutput/upserver/KVFishingConsumeTests/LegacyRed.cs.txt b/CodexOutput/upserver/KVFishingConsumeTests/LegacyRed.cs.txt
new file mode 100644
index 000000000..b4bb46f2b
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingConsumeTests/LegacyRed.cs.txt
@@ -0,0 +1,22 @@
+using Newtonsoft.Json;
+var slice=new LegacySlice(); slice.Run();
+int failed=0;
+void Check(bool ok,string name) { Console.WriteLine((ok?"PASS ":"FAIL ")+name); if(!ok)failed++; }
+Check(slice.Continues==0,"must not continue while original save response is pending");
+var sent=JsonConvert.DeserializeObject>(PlayFabMgr.Instance.Sent);
+Check(sent["newData"].Split(',')[3]==sent["energy"],"canonical and alias energy must agree");
+Console.WriteLine($"LEGACY SOURCE PROBE failed={failed}; no network; extracted actual methods from reviewed snapshot");
+return failed==0?0:1;
+public enum NetworkReachability { NotReachable,Reachable }
+public static class Application { public static NetworkReachability internetReachability=NetworkReachability.Reachable; }
+public sealed class FishingData { public bool IsRetry; public bool IsPackItemFish()=>false; }
+public sealed class EnergyDef { public int Mag=3; public int RetryCost=2; }
+public sealed class EnergyTable { public List DataList=new(){new()}; }
+public sealed class Tables { public EnergyTable TbEnergyDef=new(); }
+public sealed class PlayerData { public int Energy=403; public int magnification; public Dictionary PlayerDataDic=new(){{"newData","60,213645,400,403,445,4,30003"}}; public void ReduceEnergy(int cost,bool remote) { Energy-=cost; } }
+public sealed class Container { readonly PlayerData p=new(); public T Resolve()=>(T)(object)p; }
+public static class GContext { public static Container container=new(); public static void Publish(object e){} }
+public enum ConditionType { ConsumeEnergy }
+public sealed class ConditionTypeEvent { public ConditionTypeEvent(ConditionType t,int n){} }
+public static class Debug { public static void Log(object x){} }
+public sealed class PlayFabMgr { public static PlayFabMgr Instance=new(); public string Sent; public Task SyncDataImmidiate(string key,string data) { Sent=data; return new TaskCompletionSource().Task; } }
diff --git a/CodexOutput/upserver/KVFishingConsumeTests/LegacyRed.csproj b/CodexOutput/upserver/KVFishingConsumeTests/LegacyRed.csproj
new file mode 100644
index 000000000..667b59143
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingConsumeTests/LegacyRed.csproj
@@ -0,0 +1,4 @@
+
+ Exenet9.0falseenablefalse$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config
+ ../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll
+
diff --git a/CodexOutput/upserver/KVFishingConsumeTests/LegacySlice.cs.txt b/CodexOutput/upserver/KVFishingConsumeTests/LegacySlice.cs.txt
new file mode 100644
index 000000000..a5874d93f
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingConsumeTests/LegacySlice.cs.txt
@@ -0,0 +1,36 @@
+using System; using System.Collections.Generic; using System.Threading.Tasks;
+public class LegacySlice {
+public int Continues; public FishingData fishingData=new(); public Tables _tables=new(); public void Run()=>DoConsumeBait(); void NoInternet(){} void WaitForFishBiteED()=>Continues++;
+private void DoConsumeBait()
+ {
+ if (Application.internetReachability == NetworkReachability.NotReachable)
+ {
+ NoInternet();
+ }
+ else
+ {
+ ConsumeBaitOnPlayfab();
+ WaitForFishBiteED();
+ }
+ }
+private async void ConsumeBaitOnPlayfab()
+ {
+ var playerData = GContext.container.Resolve();
+ EnergyDef energyDef = _tables.TbEnergyDef.DataList[playerData.magnification];
+ var energy = energyDef.Mag;
+ if (fishingData.IsRetry)
+ {
+ energy = energyDef.RetryCost;
+ }
+ var commonDatas = new Dictionary(playerData.PlayerDataDic);
+ commonDatas["energy"] = (playerData.Energy - energy).ToString();
+ playerData.ReduceEnergy(energy, false);
+ if (!fishingData.IsPackItemFish())
+ {
+ GContext.Publish(new ConditionTypeEvent(ConditionType.ConsumeEnergy, energy));
+ }
+ var success = await PlayFabMgr.Instance.SyncDataImmidiate("NewPlayerDataDic",
+ Newtonsoft.Json.JsonConvert.SerializeObject(commonDatas));
+ Debug.Log($"[FishBehaviourB::ConsumeBaitOnPlayfab] {success}");
+ }
+}
\ No newline at end of file
diff --git a/CodexOutput/upserver/KVFishingConsumeTests/PermitTests.cs.txt b/CodexOutput/upserver/KVFishingConsumeTests/PermitTests.cs.txt
new file mode 100644
index 000000000..6e9b9b822
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingConsumeTests/PermitTests.cs.txt
@@ -0,0 +1,15 @@
+using game.PlayerDataStorage;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+var root=Path.GetFullPath("CodexOutput/upserver/KVFishingConsumeTests/permit-runs/"+Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root);
+var backup=Path.Combine(root,"backup.json"); File.WriteAllText(backup,JsonConvert.SerializeObject(new{playerId="102",api="http://s101.local:18080/",data=new Dictionary{{"NewPlayerDataDic","{}"}}}));
+int count=0,failed=0;
+void Case(int version,string operation,bool accept) {
+ var o=JObject.FromObject(new{Version=version,Enabled=true,PlayerId="102",Api="http://s101.local:18080/",BackupPath=backup,BackupSha256=PlayerKVLoopPermit.Hash(File.ReadAllBytes(backup)),AllowedKeys=new[]{"NewPlayerDataDic"}});
+ if(operation!=null)o["Operation"]=operation;
+ var file=Path.Combine(root,"permit.json"); File.WriteAllText(file,o.ToString()); bool accepted;
+ try { PlayerKVLoopPermit.Read(file); accepted=true; }catch(InvalidOperationException){accepted=false;}
+ count++; if(accepted!=accept)failed++; Console.WriteLine($"{(accepted==accept?"PASS":"FAIL")} version={version} operation={operation??"absent"} accepted={accepted}");
+}
+Case(1,null,true); Case(1,"fishing-consume-once",false); Case(1,"equipment",false); Case(2,null,false); Case(2,"equipment",true); Case(2,"fishing-consume-once",true); Case(2,"Fishing",false); Case(3,"equipment",false);
+Console.WriteLine($"RESULT cases={count} failed={failed}"); return failed==0?0:1;
diff --git a/CodexOutput/upserver/KVFishingConsumeTests/PermitTests.csproj b/CodexOutput/upserver/KVFishingConsumeTests/PermitTests.csproj
new file mode 100644
index 000000000..d04ecb90a
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingConsumeTests/PermitTests.csproj
@@ -0,0 +1,4 @@
+
+ Exenet9.0falseenablefalseUNITY_EDITOR;SERVICE_HOST$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config
+ ../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll
+
diff --git a/CodexOutput/upserver/KVFishingConsumeTests/unity-fixed.cs.txt b/CodexOutput/upserver/KVFishingConsumeTests/unity-fixed.cs.txt
new file mode 100644
index 000000000..daca9df7d
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingConsumeTests/unity-fixed.cs.txt
@@ -0,0 +1 @@
+return await KVGameplayLoopSetup.RunValidationAsync("fishing-consume");
diff --git a/CodexOutput/upserver/KVFishingConsumeTests/unity-live-gates.cs.txt b/CodexOutput/upserver/KVFishingConsumeTests/unity-live-gates.cs.txt
new file mode 100644
index 000000000..c9967151d
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingConsumeTests/unity-live-gates.cs.txt
@@ -0,0 +1,8 @@
+using asap.core;
+using System.Linq;
+var p=GContext.container.Resolve();
+var t=GContext.container.Resolve();
+var manager=GContext.container.Resolve();
+var scopes=GContext.container.Resolve();
+var events=t.TbFishingEvent.DataList.GroupBy(e=>new {e.Type,e.SubType}).Select(g=>g.First()).Select(e=>new {e.Type,e.SubType,current=manager.GetSentry(e)?.CurrentEventConfig?.ID,scope=scopes.HasActiveScope((e.Type,e.SubType))}).ToArray();
+return new {at=System.DateTime.UtcNow,serverNow=ZZTimeHelper.UtcNow(),energy=(int)p.Energy,maxEnergy=(int)p.MaxEnergy,energyRM=p.GetEnergyRM(),mag=(int)p.magnification,cost=t.TbEnergyDef.DataList[(int)p.magnification].Mag,equipRod=(int)p.equipRodID,map=(int)p.lastMapId,lv=(int)p.lv,gold=(ulong)p.gold,pearl=(int)p.pearl,diamond=(int)p.diamond,recoverySeconds=p.GetEnergyRecoveryTimeMAndS().TotalSeconds,consumeActivations=string.Join(",",t.TbFishingEvent.DataList.Where(e=>e.ConditionList.Any(c=>c.Condition==cfg.ConditionType.ConsumeEnergy)).Select(e=>e.ID)),consumeTriggerPacks=string.Join(",",t.TbTriggerPackManager.DataList.Where(e=>e.Condition==cfg.ConditionType.ConsumeEnergy).Select(e=>e.ID)),events=string.Join(";",events.Select(e=>e.Type+":"+e.SubType+":"+e.current+":"+e.scope)),state=KVGameplayLoopSetup.State()};
diff --git a/CodexOutput/upserver/KVFishingConsumeTests/unity-prepare-fishing.cs.txt b/CodexOutput/upserver/KVFishingConsumeTests/unity-prepare-fishing.cs.txt
new file mode 100644
index 000000000..a2a693428
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingConsumeTests/unity-prepare-fishing.cs.txt
@@ -0,0 +1 @@
+return KVGameplayLoopSetup.PrepareFishingPermit("102","C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/backups/20260924T074727Z/102.json",new string[]{"AquariumEventDataNewKey","BattlePassRecordTask","BombGameCount","BuffDataList","BuyData","CampDataDic","ChainPackDataNew","DigCount","DigGridData","epbdk","epdk","Event1v1BattleSlapData","EventBoostPackData","EventCubeMeltData","EventLuckGameRecord","EventLuckMagicPlayfabData","EventPartnerStatueData","FishHuntGameCount","FixedTimeShopDic","GuideSaveDataNew","IsOpenClubRed","LastPaymentTime","Leadboard_LastContinent","Map_5_101","Map_5_102","MiniBPKeyNew","MMTest","MonthlySignEvent","NewPlayerDataDic","NewRodList","OfferDiscoPartyManager","OfferStoryChain","OfferSweepPackData","Pack1A1Data","PiggyBankPackData","RodData","RodLevelData","SeasonStampData","ShopPackBuyCount","SoloDataKey","TargetBuffDataList","TransitionData","TriggerPackDataList","VIPAdPackData","WelcomeBack"});
diff --git a/CodexOutput/upserver/KVFishingConsumeTests/unity-prepare-recovery.cs.txt b/CodexOutput/upserver/KVFishingConsumeTests/unity-prepare-recovery.cs.txt
new file mode 100644
index 000000000..0d0c3c435
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingConsumeTests/unity-prepare-recovery.cs.txt
@@ -0,0 +1 @@
+return KVGameplayLoopSetup.PreparePermit("102","C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/backups/20260924T075711Z/102.json",new string[]{"AquariumEventDataNewKey","BattlePassRecordTask","BombGameCount","BuffDataList","BuyData","CampDataDic","ChainPackDataNew","DigCount","DigGridData","epbdk","epdk","Event1v1BattleSlapData","EventBoostPackData","EventCubeMeltData","EventLuckGameRecord","EventLuckMagicPlayfabData","EventPartnerStatueData","FishHuntGameCount","FixedTimeShopDic","GuideSaveDataNew","IsOpenClubRed","LastPaymentTime","Leadboard_LastContinent","Map_5_101","Map_5_102","MiniBPKeyNew","MMTest","MonthlySignEvent","NewPlayerDataDic","NewRodList","OfferDiscoPartyManager","OfferStoryChain","OfferSweepPackData","Pack1A1Data","PiggyBankPackData","RodData","RodLevelData","SeasonStampData","ShopPackBuyCount","SoloDataKey","TargetBuffDataList","TransitionData","TriggerPackDataList","VIPAdPackData","WelcomeBack"});
diff --git a/CodexOutput/upserver/KVFishingConsumeTests/unity-quit-request.cs.txt b/CodexOutput/upserver/KVFishingConsumeTests/unity-quit-request.cs.txt
new file mode 100644
index 000000000..2d531b64a
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingConsumeTests/unity-quit-request.cs.txt
@@ -0,0 +1,3 @@
+using asap.core;
+_ = GContext.container.Resolve().SwitchStageAsync("QuitStage");
+return "normal QuitStage requested; inspect state separately";
diff --git a/CodexOutput/upserver/KVFishingConsumeTests/unity-recovered-values.cs.txt b/CodexOutput/upserver/KVFishingConsumeTests/unity-recovered-values.cs.txt
new file mode 100644
index 000000000..5df4fde97
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingConsumeTests/unity-recovered-values.cs.txt
@@ -0,0 +1,6 @@
+using asap.core;
+using System.Linq;
+var p=GContext.container.Resolve();
+var a=GContext.container.Resolve();
+var b=GContext.container.Resolve();
+return new {energy=(int)p.Energy,energyRM=p.GetEnergyRM(),achievement=a.GetAchievementDatas(cfg.ConditionType.ConsumeEnergy),daily=string.Join(";",b.BattleTask.dic_dailyTasks.Select(x=>x.Key+":"+x.Value)),weekly=string.Join(";",b.BattleTask.dic_weeklyTasks.Select(x=>x.Key+":"+x.Value)),equipRod=(int)p.equipRodID,gold=(ulong)p.gold,diamond=(int)p.diamond,pearl=(int)p.pearl,state=KVGameplayLoopSetup.State()};
diff --git a/CodexOutput/upserver/KVFishingRewardTests/ConsumeControlSlice.cs.txt b/CodexOutput/upserver/KVFishingRewardTests/ConsumeControlSlice.cs.txt
new file mode 100644
index 000000000..5097b2b8a
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingRewardTests/ConsumeControlSlice.cs.txt
@@ -0,0 +1,56 @@
+using System; using System.Linq; using System.Threading.Tasks;
+public class ControlSlice {
+public bool isActiveAndEnabled=true; public int Continues,LegacyCalls;
+public FishingData fishingData=new(); public Tables _tables=new();
+public game.PlayerDataStorage.PlayerKVGameplayLoop.FishingAction kvFishingAction=new();
+public void Run()=>ConsumeBait(); public Task RunAwaited()=>ConsumeBaitOnKVAsync();
+void WaitForFishBiteED()=>Continues++; void DoConsumeBait()=>LegacyCalls++;
+bool KVFishingCurrent()
+ {
+ if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return true;
+ return this!=null && isActiveAndEnabled && game.PlayerDataStorage.PlayerKVGameplayLoop.FishingCurrent(kvFishingAction);
+ }
+async Task ConsumeBaitOnKVAsync()
+ {
+ try
+ {
+ if(!KVFishingCurrent()) return;
+ if(!game.PlayerDataStorage.PlayerKVGameplayLoop.ClaimFishingConsumption(kvFishingAction)) return;
+ if(fishingData.fishItem==null || !fishingData.IsGeneralFish() || fishingData.IsFree || fishingData.IsRetry ||
+ fishingData.IsDuel || fishingData.IsPackItemFish() || fishingData.curFishData==null)
+ throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Only one ordinary paid fishing action is permitted. fishItem.ID="+(fishingData.fishItem==null?"null":fishingData.fishItem.ID.ToString())+", fishItem.SubType="+(fishingData.fishItem==null?"null":fishingData.fishItem.SubType.ToString())+", curFishData.ID="+(fishingData.curFishData==null?"null":fishingData.curFishData.ID.ToString())+", IsFree="+fishingData.IsFree+", IsRetry="+fishingData.IsRetry+", IsDuel="+fishingData.IsDuel);
+ if(Application.internetReachability==NetworkReachability.NotReachable)
+ throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Fishing stopped before consumption: offline.");
+ if(_tables.TbFishingEvent.DataList.Any(e=>e.ConditionList.Any(c=>c.Condition==ConditionType.ConsumeEnergy)))
+ throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("ConsumeEnergy activity activation requires separate review.");
+ var player=GContext.container.Resolve();
+ int mag=player.magnification;
+ if(mag<0 || mag>=_tables.TbEnergyDef.DataList.Count)
+ throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Invalid fishing magnification.");
+ int fish=fishingData.curFishData.ID, rod=fishingData.RodId, map=player.lastMapId;
+ int cost=_tables.TbEnergyDef.DataList[mag].Mag;
+ game.PlayerDataStorage.PlayerKVGameplayLoop.PrepareFishingReward(kvFishingAction,fishingData);
+ await player.ConsumeEnergyForKVLoopAsync(cost,kvFishingAction);
+ if(!KVFishingCurrent()) return;
+ if(fishingData.curFishData==null || fishingData.curFishData.ID!=fish || fishingData.RodId!=rod ||
+ player.lastMapId!=map || (int)player.magnification!=mag || fishingData.IsFree || fishingData.IsRetry || fishingData.IsDuel)
+ throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Fishing context changed during confirmation.");
+ game.PlayerDataStorage.PlayerKVGameplayLoop.ContinueFishing(kvFishingAction,WaitForFishBiteED);
+ }
+ catch(Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
+ }
+private void ConsumeBait()
+ {
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { _=ConsumeBaitOnKVAsync(); return; }
+#endif
+ if (fishingData.IsFree)
+ {
+ WaitForFishBiteED();
+ }
+ else
+ {
+ DoConsumeBait();
+ }
+ }
+}
diff --git a/CodexOutput/upserver/KVFishingRewardTests/ConsumeControlTests.cs.txt b/CodexOutput/upserver/KVFishingRewardTests/ConsumeControlTests.cs.txt
new file mode 100644
index 000000000..3e0362003
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingRewardTests/ConsumeControlTests.cs.txt
@@ -0,0 +1,58 @@
+using Loop=game.PlayerDataStorage.PlayerKVGameplayLoop;
+int checks=0;
+void Check(bool ok,string text){checks++;if(!ok)throw new Exception(text);}
+ControlSlice Fresh(){Loop.Enabled=true;Loop.Ready=true;Loop.Faults=0;GContext.container.Player=new();Application.internetReachability=NetworkReachability.Reachable;return new();}
+var s=Fresh();var player=GContext.container.Player;var first=s.RunAwaited();
+Check(s.Continues==0 && player.Calls==1,"actual method waits for response");
+s.Run();await s.RunAwaited();Check(player.Calls==1 && s.Continues==0,"duplicate consume and callback cannot repeat");
+player.Response.SetResult();await first;Check(s.Continues==1,"actual success continuation exactly once");
+await s.RunAwaited();Check(s.Continues==1 && player.Calls==1,"post-success duplicate suppressed");
+foreach(var kind in new[]{"free","retry","duel","pack","special","missing-fish","missing-item","offline","activity","bad-mag"}){
+ s=Fresh();player=GContext.container.Player;
+ switch(kind){
+ case "free":s.fishingData.IsFree=true;break;case "retry":s.fishingData.IsRetry=true;break;
+ case "duel":s.fishingData.IsDuel=true;break;case "pack":s.fishingData.Pack=true;break;
+ case "special":s.fishingData.General=false;break;case "missing-fish":s.fishingData.curFishData=null;break;
+ case "missing-item":s.fishingData.fishItem=null;break;case "offline":Application.internetReachability=NetworkReachability.NotReachable;break;
+ case "activity":s._tables.TbFishingEvent.DataList.Add(new());break;case "bad-mag":player.magnification=-1;break;}
+ await s.RunAwaited();Check(player.Calls==0 && s.Continues==0 && Loop.Faults>0,"pre-consumption gate "+kind);
+}
+foreach(var kind in new[]{"fish","rod","map","mag","free","retry","duel","disabled","cancelled","fault"}){
+ s=Fresh();player=GContext.container.Player;var task=s.RunAwaited();
+ switch(kind){case "fish":s.fishingData.curFishData.ID++;break;case "rod":s.fishingData.RodId++;break;
+ case "map":player.lastMapId++;break;case "mag":player.magnification++;break;case "free":s.fishingData.IsFree=true;break;
+ case "retry":s.fishingData.IsRetry=true;break;case "duel":s.fishingData.IsDuel=true;break;
+ case "disabled":s.isActiveAndEnabled=false;break;case "cancelled":s.kvFishingAction.Cancelled=true;break;
+ case "fault":Loop.Ready=false;break;}
+ player.Response.SetResult();await task;Check(player.Calls==1 && s.Continues==0,"no stale continuation "+kind);
+}
+s=Fresh();player=GContext.container.Player;var failed=s.RunAwaited();player.Response.SetException(new InvalidOperationException("Unknown"));await failed;
+Check(s.Continues==0 && player.Calls==1 && !Loop.Ready,"async exception observed, no retry");
+s=Fresh();Loop.Enabled=false;s.Run();Check(s.LegacyCalls==1 && GContext.container.Player.Calls==0,"default paid PF path unchanged");
+s.fishingData.IsFree=true;s.Run();Check(s.Continues==1,"default free PF path unchanged");
+Console.WriteLine($"PASS {checks} assertions; compiled actual source methods; visual continuation and storage boundaries are spies; no network");
+public enum NetworkReachability{NotReachable,Reachable}
+public static class Application{public static NetworkReachability internetReachability;}
+public enum ConditionType{ConsumeEnergy}
+public sealed class Fish{public int ID=1;}
+public sealed class Item{public int ID=1;public int SubType=1;}
+public sealed class FishingData{public Item fishItem=new();public Fish curFishData=new();public int RodId=1;public bool IsFree,IsRetry,IsDuel,Pack;public bool General=true;public bool IsGeneralFish()=>General;public bool IsPackItemFish()=>Pack;}
+public sealed class EnergyDef{public int Mag=3;}
+public sealed class EnergyTable{public List DataList=new(){new()};}
+public sealed class ConditionRule{public ConditionType Condition=ConditionType.ConsumeEnergy;}
+public sealed class Activity{public List ConditionList=new(){new()};}
+public sealed class ActivityTable{public List DataList=new();}
+public sealed class Tables{public EnergyTable TbEnergyDef=new();public ActivityTable TbFishingEvent=new();}
+public sealed class PlayerData{public int Calls,magnification,lastMapId;public TaskCompletionSource Response=new();public async Task ConsumeEnergyForKVLoopAsync(int n,Loop.FishingAction a){Calls++;await Response.Task;a.Confirmed=true;}}
+public sealed class Container{public PlayerData Player=new();public T Resolve()=>(T)(object)Player;}
+public static class GContext{public static Container container=new();}
+namespace game.PlayerDataStorage{public static class PlayerKVGameplayLoop{
+public static void PrepareFishingReward(FishingAction a,FishingData d) {}
+ public static bool Enabled,Ready;public static int Faults;
+ public sealed class FishingAction{public bool Consuming,Cancelled,Confirmed,Continued;}
+ public static bool FishingCurrent(FishingAction a)=>Ready && !a.Cancelled;
+ public static bool ClaimFishingConsumption(FishingAction a){if(a.Consuming)return false;a.Consuming=true;return true;}
+ public static InvalidOperationException Stop(string m){Fault(m);return new(m);}
+ public static void Fault(string m){Ready=false;Faults++;}
+ public static void ContinueFishing(FishingAction a,Action next){if(!FishingCurrent(a)||!a.Confirmed)throw Stop("invalid");if(a.Continued)return;a.Continued=true;next();}
+}}
diff --git a/CodexOutput/upserver/KVFishingRewardTests/ConsumeControlTests.csproj b/CodexOutput/upserver/KVFishingRewardTests/ConsumeControlTests.csproj
new file mode 100644
index 000000000..41f92589f
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingRewardTests/ConsumeControlTests.csproj
@@ -0,0 +1,4 @@
+
+ Exenet9.0falseenableUNITY_EDITORfalse$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config
+ ../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll
+
diff --git a/CodexOutput/upserver/KVFishingRewardTests/ControllerTests.cs.txt b/CodexOutput/upserver/KVFishingRewardTests/ControllerTests.cs.txt
new file mode 100644
index 000000000..d0057e652
--- /dev/null
+++ b/CodexOutput/upserver/KVFishingRewardTests/ControllerTests.cs.txt
@@ -0,0 +1,99 @@
+using System;using System.Threading.Tasks;using game.PlayerDataStorage;
+int checks=0;void Check(bool b,string m){checks++;if(!b)throw new Exception(m);}
+Controller Fresh(){PlayerKVGameplayLoop.Reset();UIManager.Instance=new();return new();}
+var c=Fresh();c.PiercingFish(3);c.PiercingFish(3);c.End();c.End();
+Check(c.Strikes==1&&c.Endings==1&&UIManager.Instance.Calls==1,"actual wrappers suppress repeated drawing/end");
+Check(PlayerKVGameplayLoop.Scope==null,"UI await never holds ending scope");
+c.Disable();UIManager.Instance.Response.SetResult(new object());await Task.Delay(20);
+Check(c.Destroyed==1&&PlayerKVGameplayLoop.Faults==1,"actual Disable during UI await disposes stale returned panel");
+c=Fresh();c.PiercingFish(3);c.Dead();Check(PlayerKVGameplayLoop.Cancelled,"actual Destroy revokes controller");
+c=Fresh();c.PiercingFish(3);c.fishingData.IsCaughtFish=false;c.End();Check(c.Endings==0&&UIManager.Instance.Calls==0,"natural failure cannot invoke reward core");
+foreach(int version in new[]{1,2}){c=Fresh();PlayerKVGameplayLoop.Version=version;c.PiercingFish(3);Check(c.Strikes==0&&PlayerKVGameplayLoop.Faults==1,"old permits do not draw");}
+c=Fresh();c.PiercingFish(3);PlayerKVGameplayLoop.Confirmed=true;c.Disable();Check(PlayerKVGameplayLoop.Faults==0,"confirmed controller normal disable no second fault");
+c=Fresh();PlayerKVGameplayLoop.Enabled=false;c.PiercingFish(3);c.End();Check(c.Strikes==1&&c.Endings==1,"ordinary PF wrapper takes original core");
+Console.WriteLine($"PASS actual controller source wrappers/lifecycle {checks} assertions; core/render/storage services are spies; no gameplay or network");
+public class Controller {
+public int Strikes,Endings,Destroyed;public FishingData fishingData=new();bool isFail;IDisposable disposables,_comboDisposable;object _hpcoroutine;
+public void End()=>FishEnd();public void Disable()=>OnDisable();public void Dead()=>OnDestroy();
+void PiercingFishCore(int zone){Strikes++;}void FishEndCore(){Endings++;}void Destroy(object p){Destroyed++;}
+void StopSoundCombo(){}void StopRodAudio(){}void StopNotReeling(){}void StopDrawingSplash(){}void AudioRelease(){}void SpSkillFxRelease(){}void StopAllCoroutines(){}
+public void PiercingFish(int waitingZone)
+ {
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
+ {
+ try
+ {
+ if(!game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardDrawing(this)) return;
+ using(game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardSegment("strike")) PiercingFishCore(waitingZone);
+ }
+ catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
+ return;
+ }
+#endif
+ PiercingFishCore(waitingZone);
+ }
+void FishEnd()
+ {
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
+ {
+ try
+ {
+ if(!game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardEnding(this,fishingData.IsCaughtFish && !isFail)) return;
+ using(game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardSegment("ending")) FishEndCore();
+ _=ShowKVRewardPanelAsync();
+ }
+ catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
+ return;
+ }
+#endif
+ FishEndCore();
+ }
+async Task ShowKVRewardPanelAsync()
+ {
+ try
+ {
+ var panel=await UIManager.Instance.ShowUI(UITypes.FishingRewardPanel);
+ if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardRunCurrent && panel!=null) Destroy(panel);
+ }
+ catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
+ }
+private void OnDisable()
+ {
+#if UNITY_EDITOR && !SERVICE_HOST
+ game.PlayerDataStorage.PlayerKVGameplayLoop.CancelReward(this,"controller disabled");
+#endif
+
+ StopSoundCombo();
+ StopRodAudio();
+ StopNotReeling();
+ StopDrawingSplash();
+ GContext.container.Resolve().StopActiveFishingLoopSounds(0.05f);
+ AudioRelease();
+ SpSkillFxRelease();
+ }
+private void OnDestroy()
+ {
+#if UNITY_EDITOR && !SERVICE_HOST
+ game.PlayerDataStorage.PlayerKVGameplayLoop.CancelReward(this,"controller destroyed");
+#endif
+
+ disposables?.Dispose();
+ _comboDisposable?.Dispose();
+ _hpcoroutine = null;
+ StopAllCoroutines();
+ }
+}
+public class FishingData{public bool IsCaughtFish=true;}public enum UITypes{FishingRewardPanel}
+public class UIManager{public static UIManager Instance=new();public int Calls;public TaskCompletionSource