test(upserver): 归档存档迁移与玩法闭环回归工具

This commit is contained in:
Liubing\LB
2026-09-28 18:16:14 +08:00
parent c4ae02def9
commit a6f5e107f8
232 changed files with 12903 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings><Nullable>disable</Nullable>
<RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.CodeAnalysis"><HintPath>$(MSBuildSDKsPath)/../Roslyn/bincore/Microsoft.CodeAnalysis.dll</HintPath></Reference>
<Reference Include="Microsoft.CodeAnalysis.CSharp"><HintPath>$(MSBuildSDKsPath)/../Roslyn/bincore/Microsoft.CodeAnalysis.CSharp.dll</HintPath></Reference>
</ItemGroup>
</Project>

View File

@@ -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<string,string[]> {
["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<string,Entry>();
var refs = new Dictionary<string, HashSet<string>>();
var files = new Dictionary<string,string>();
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<InvocationExpressionSyntax>()) {
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<ConditionalAccessExpressionSyntax>().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,
_=>"<initializer>"
};
var type=inv.Ancestors().OfType<TypeDeclarationSyntax>().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<AwaitExpressionSyntax>().Select(x=>tree.GetLineSpan(x.Span).StartLinePosition.Line+1).ToArray()??Array.Empty<int>(),
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<string>();}
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<string> Configs{get;set;} public string[] CallerNameCandidates{get;set;}
public string MemberSource{get;set;} public string Source{get;set;}
}

View File

@@ -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('|', '&#124;').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}))

View File

@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup><ItemGroup><Reference Include="Newtonsoft.Json"><HintPath>../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath></Reference></ItemGroup></Project>

View File

@@ -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");

View File

@@ -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<List<SocialInfoItemData>> GetClubDetails')]
methods='\n'.join(block(s,m) for m in ['public async Task<ClubInfo> 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<long,string> internalIdToPlayerId=new();public int Effects;
private int? signingSession; public DateTime? signTime; public List<Person> 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<string> Gate;public Task<string> 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<T>() 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;}}
""")

View File

@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup><ItemGroup><Compile Include="../../../Client/Assets/Scripts/Stages/QuitAct.cs"/></ItemGroup></Project>

View File

@@ -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<bool> 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<bool> 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(){}}

View File

@@ -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<IUserService>();
var p=GContext.container.Resolve<PlayerData>();
var g=GContext.container.Resolve<GuideDataCenter>();
var cfg=GContext.container.Resolve<IConfig>();
var e=GContext.container.Resolve<FishingEventData>();
GuideSaveData potion;g.GuideSaveDataDic.TryGetValue("EventPotion01",out potion);
return new {loggedIn=u.IsLogin, account=u.CustomId, internalId=u.InternalId,backend=cfg.Get<string>("SaveBackend",""),api=cfg.Get<string>(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();

View File

@@ -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<IUserService>();
if(!u.IsLogin) return "Login pending; "+KVGameplayLoopSetup.State();
var p=GContext.container.Resolve<PlayerData>();
var f=GContext.container.Resolve<PlayerFishData>();
var guide=GContext.container.Resolve<GuideDataCenter>();
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<string>();
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<System.Collections.Generic.Dictionary<string,string>>(PlayFabMgr.Instance.GetLocalData("Map_5_102"));
var saved=guide.GuideSaveDataDic["EventPotion01"];
var api=GContext.container.Resolve<ICustomServerMgr>().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()});

View File

@@ -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;

View File

@@ -0,0 +1 @@
return KVGameplayLoopSetup.InspectPFBackup("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/pf-source-backups/20260924T041802Z/101.json");

View File

@@ -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"});

View File

@@ -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"});

View File

@@ -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"});

View File

@@ -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"});

View File

@@ -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

View File

@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup></Project>

View File

@@ -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>()=>(T)(object)(typeof(T)==typeof(BuffDataCenter)?(object)Buff:Face);
}
public class BuffDataCenter{public List<int> 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<bool> Gate;public static Task Seconds(float n)=>Gate.Task;}
public static class Debug{public static int Errors;public static void LogError(object e)=>Errors++;}

View File

@@ -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')

View File

@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup><ItemGroup><Reference Include="Newtonsoft.Json"><HintPath>../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath></Reference></ItemGroup></Project>

View File

@@ -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");

View File

@@ -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<long,string> internalIdToPlayerId=new();public int Effects;void SetClubPlayerProfile(List<ClubPlayerProfile> 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<string> Gate;public Task<string> 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)

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs" Link="Production/PlayerKVModels.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs" Link="Production/PlayerKVClient.cs" />
<Reference Include="Newtonsoft.Json">
<HintPath>../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@@ -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<Task> 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<string,string> { ["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<string,string> { ["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<string,string>)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<string>())).Error==PlayerKVError.Unauthorized && h.Body=="{}", "Empty Get");
Assert((await kv.SetAsync(new Dictionary<string,string>())).Error==PlayerKVError.Unauthorized && h.Body=="{\"data\":{}}", "Empty Set bypassed auth");
Assert((await kv.DeleteAsync(Array.Empty<string>())).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<string,string>{{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<string>{"original"};var data=new Dictionary<string,string>{{"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<string,string>{{"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<string,string>{{"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<HttpResponseMessage> 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<bool> Gate=new();
public int Calls;public string Body,Url;public HttpRequestMessage Request;public TrackedContent ResponseBody;
protected override async Task<HttpResponseMessage> 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);}
}

View File

@@ -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<PlayerData>();
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();
}
}
}

View File

@@ -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<EnergyDef> DataList=new(){new()};}
public sealed class ConditionRule{public ConditionType Condition=ConditionType.ConsumeEnergy;}
public sealed class Activity{public List<ConditionRule> ConditionList=new(){new()};}
public sealed class ActivityTable{public List<Activity> 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>()=>(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();}
}}

View File

@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><EnableDefaultCompileItems>false</EnableDefaultCompileItems><ImplicitUsings>enable</ImplicitUsings><DefineConstants>UNITY_EDITOR</DefineConstants><UseAppHost>false</UseAppHost><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup>
<ItemGroup><Compile Include="ControlSlice.cs.txt"/><Compile Include="ControlTests.cs.txt"/><Reference Include="Newtonsoft.Json"><HintPath>../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath></Reference></ItemGroup>
</Project>

View File

@@ -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<Dictionary<string,string>>(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<EnergyDef> DataList=new(){new()}; }
public sealed class Tables { public EnergyTable TbEnergyDef=new(); }
public sealed class PlayerData { public int Energy=403; public int magnification; public Dictionary<string,string> 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>()=>(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<bool> SyncDataImmidiate(string key,string data) { Sent=data; return new TaskCompletionSource<bool>().Task; } }

View File

@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><EnableDefaultCompileItems>false</EnableDefaultCompileItems><ImplicitUsings>enable</ImplicitUsings><UseAppHost>false</UseAppHost><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup>
<ItemGroup><Compile Include="LegacySlice.cs.txt"/><Compile Include="LegacyRed.cs.txt"/><Reference Include="Newtonsoft.Json"><HintPath>../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath></Reference></ItemGroup>
</Project>

View File

@@ -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<PlayerData>();
EnergyDef energyDef = _tables.TbEnergyDef.DataList[playerData.magnification];
var energy = energyDef.Mag;
if (fishingData.IsRetry)
{
energy = energyDef.RetryCost;
}
var commonDatas = new Dictionary<string, string>(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}");
}
}

View File

@@ -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<string,string>{{"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;

View File

@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><EnableDefaultCompileItems>false</EnableDefaultCompileItems><ImplicitUsings>enable</ImplicitUsings><UseAppHost>false</UseAppHost><DefineConstants>UNITY_EDITOR;SERVICE_HOST</DefineConstants><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup>
<ItemGroup><Compile Include="PermitTests.cs.txt"/><Compile Include="../../../Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs"/><Reference Include="Newtonsoft.Json"><HintPath>../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath></Reference></ItemGroup>
</Project>

View File

@@ -0,0 +1 @@
return await KVGameplayLoopSetup.RunValidationAsync("fishing-consume");

View File

@@ -0,0 +1,8 @@
using asap.core;
using System.Linq;
var p=GContext.container.Resolve<GameCore.PlayerData>();
var t=GContext.container.Resolve<cfg.Tables>();
var manager=GContext.container.Resolve<Activity.ActivityManager>();
var scopes=GContext.container.Resolve<Activity.ActivityScopeManager>();
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()};

View File

@@ -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"});

View File

@@ -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"});

View File

@@ -0,0 +1,3 @@
using asap.core;
_ = GContext.container.Resolve<IStageService>().SwitchStageAsync("QuitStage");
return "normal QuitStage requested; inspect state separately";

View File

@@ -0,0 +1,6 @@
using asap.core;
using System.Linq;
var p=GContext.container.Resolve<GameCore.PlayerData>();
var a=GContext.container.Resolve<AchievementDataManager>();
var b=GContext.container.Resolve<BattlePassData>();
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()};

View File

@@ -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<PlayerData>();
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();
}
}
}

View File

@@ -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<EnergyDef> DataList=new(){new()};}
public sealed class ConditionRule{public ConditionType Condition=ConditionType.ConsumeEnergy;}
public sealed class Activity{public List<ConditionRule> ConditionList=new(){new()};}
public sealed class ActivityTable{public List<Activity> 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>()=>(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();}
}}

View File

@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><EnableDefaultCompileItems>false</EnableDefaultCompileItems><ImplicitUsings>enable</ImplicitUsings><DefineConstants>UNITY_EDITOR</DefineConstants><UseAppHost>false</UseAppHost><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup>
<ItemGroup><Compile Include="ConsumeControlSlice.cs.txt"/><Compile Include="ConsumeControlTests.cs.txt"/><Reference Include="Newtonsoft.Json"><HintPath>../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath></Reference></ItemGroup>
</Project>

View File

@@ -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<SoundMgr>().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<object> Response=new();public Task<object> ShowUI(UITypes type){Calls++;return Response.Task;}}
public static class GContext{public static Container container=new();}public class Container{public T Resolve<T>() where T:new()=>new();}public class SoundMgr{public void StopActiveFishingLoopSounds(float x){}}
namespace game.PlayerDataStorage {public static class PlayerKVGameplayLoop{
public static bool Enabled,Drawing,Ending,Cancelled,Confirmed;public static int Version,Faults;public static string Scope;
public static void Reset(){Enabled=true;Drawing=Ending=Cancelled=Confirmed=false;Version=3;Faults=0;Scope=null;}
public static bool RewardRunCurrent=>!Cancelled;
public static bool BeginRewardDrawing(object o){if(Version!=3||Cancelled)throw new Exception("no draw");if(Drawing)return false;Drawing=true;return true;}
public static bool BeginRewardEnding(object o,bool success){if(Ending)return false;if(!success)throw new Exception("failed catch");Ending=true;return true;}
public static IDisposable BeginRewardSegment(string n){Scope=n;return new Batch();}class Batch:IDisposable{public void Dispose(){Scope=null;}}
public static void Fault(string s){Faults++;}public static void CancelReward(object o,string r){if(Cancelled)return;Cancelled=true;if(!Confirmed)Fault(r);}
}}

View File

@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><EnableDefaultCompileItems>false</EnableDefaultCompileItems><DefineConstants>UNITY_EDITOR</DefineConstants><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup><ItemGroup><Compile Include="ControllerTests.cs.txt"/></ItemGroup></Project>

View File

@@ -0,0 +1,123 @@
using System;
foreach(var c in new[]{(10f,0.25f,10,0.5f,0.5f,0.2f,0.1f,122),(0f,0.25f,10,0.5f,0.5f,0.2f,0.1f,0),(10f,0f,1,1f,0f,0f,0f,10),(1f,0f,1,0.5f,0f,0f,0f,0)}){
var old=new OldFormula();var next=new NewFormula();
foreach(var x in new FormulaBase[]{old,next}){x._tables.TbDrop[0].DropList.DropCountList[1]=c.Item1;x.pointBonus=c.Item2;x.Magnification=c.Item3;x.fishingData.random=c.Item4;x.fishingData.WeightIncEvent=c.Item5;x.playerData.benefitsCashMag=c.Item6;GContext.container.Buff.Gold=c.Item7;}
int a=old.Run(),b=next.Run();if(a!=c.Item8||b!=a)throw new Exception($"Formula {a}/{b} expected {c.Item8}");Console.WriteLine($"PASS original/current cash={b}");
}
public class FormulaBase {public Tables _tables=new();public Fishing fishingData=new();public Player playerData=new();public float pointBonus,extraPointEvent;public int Magnification;protected float _fishScore;protected Text text_gold_num=new(),text_num_buff=new();}
public class Tables {public Drop[] TbDrop={new()};}public class Drop{public DropPackageList DropList=new();}public class DropPackageList {public float[] DropCountList={20,10};}
public class Fishing{public Fish curFishData=new(),fishItem=new();public float random,fishWeight,WeightIncEvent;public int MapId;public bool IsNewItemFish()=>false;}
public class Fish{public int DropID,ID,Quality=2;}public class Player{public float benefitsCashMag,benefitsEventFishCashMag;}
public class CollectingData{public float ExtraCashDrop()=>0;}public class BuffDataCenter{public float Gold;public float GetExtraGoldBuffValue(int m,int q)=>Gold;}
public class FishingChallengeManager{public void AddScoreByQuality(int a,float b,int c){}}
public class Container{public BuffDataCenter Buff=new();public T Resolve<T>() where T:new()=>typeof(T)==typeof(BuffDataCenter)?(T)(object)Buff:new T();}
public static class GContext{public static Container container=new();}public class Text{public Color color;}public struct Color{public static Color black,white;}
public static class Mathf{public static int RoundToInt(float x)=>(int)MathF.Round(x,MidpointRounding.ToEven);}
public class OldFormula : FormulaBase { public int Run(){DropPackageList dropPackageList = _tables.TbDrop[fishingData.curFishData.DropID].DropList;
float proficiency = dropPackageList.DropCountList[0] * fishingData.random;
var proficiencyExtra = SetProficiency(proficiency, extraPointEvent, fishingData.curFishData.ID);//额外的Buff加成
var endWeight = fishingData.fishWeight;
float extraCashDrop = GContext.container.Resolve<CollectingData>().ExtraCashDrop();
//Data
//金币各种加成
float gold = (dropPackageList.DropCountList[1] + extraCashDrop)
* (1 + pointBonus)
* Magnification
* fishingData.random;
//杆加成
//解锁地图Buff加成
float glodBuff = GContext.container.Resolve<BuffDataCenter>().GetExtraGoldBuffValue(fishingData.MapId, fishingData.fishItem.Quality);
if (glodBuff > 0.001f)
{
text_gold_num.color = Color.black;
text_num_buff.color = Color.white;
}
int Quality = fishingData.curFishData.Quality;
if (Quality > 6)
{
Quality = 6;
}
//权益全局加成
var ExtraPoint = playerData.benefitsCashMag + glodBuff;
int cash = Mathf.RoundToInt(gold * proficiencyExtra * (1 + ExtraPoint));
return cash;}float SetProficiency(float proficiency, float proficiencyExtra, int fishId)
{
int Quality = fishingData.curFishData.Quality;
if (Quality > 6)
{
Quality = 6;
}
if (!fishingData.IsNewItemFish())
{
proficiencyExtra = (1 + fishingData.WeightIncEvent) * (1 + proficiencyExtra);
}
else
{
proficiencyExtra = (1 + proficiencyExtra) * (1 + playerData.benefitsEventFishCashMag);
}
proficiency *= proficiencyExtra;
proficiency = Mathf.RoundToInt(proficiency);
GContext.container.Resolve<FishingChallengeManager>().AddScoreByQuality(Quality, proficiency, fishId);
_fishScore = proficiency;
return proficiencyExtra;
}}
public class NewFormula : FormulaBase { public int Run(){DropPackageList dropPackageList = _tables.TbDrop[fishingData.curFishData.DropID].DropList;
float proficiency = dropPackageList.DropCountList[0] * fishingData.random;
var proficiencyExtra = SetProficiency(proficiency, extraPointEvent, fishingData.curFishData.ID);//额外的Buff加成
var endWeight = fishingData.fishWeight;
float extraCashDrop = GContext.container.Resolve<CollectingData>().ExtraCashDrop();
//Data
//金币各种加成
float gold = (dropPackageList.DropCountList[1] + extraCashDrop)
* (1 + pointBonus)
* Magnification
* fishingData.random;
//杆加成
//解锁地图Buff加成
float glodBuff = GContext.container.Resolve<BuffDataCenter>().GetExtraGoldBuffValue(fishingData.MapId, fishingData.fishItem.Quality);
if (glodBuff > 0.001f)
{
text_gold_num.color = Color.black;
text_num_buff.color = Color.white;
}
int Quality = fishingData.curFishData.Quality;
if (Quality > 6)
{
Quality = 6;
}
//权益全局加成
var ExtraPoint = playerData.benefitsCashMag + glodBuff;
int cash = Mathf.RoundToInt(gold * proficiencyExtra * (1 + ExtraPoint));
return cash;}float SetProficiency(float proficiency, float proficiencyExtra, int fishId)
{
int Quality = fishingData.curFishData.Quality;
if (Quality > 6)
{
Quality = 6;
}
if (!fishingData.IsNewItemFish())
{
proficiencyExtra = (1 + fishingData.WeightIncEvent) * (1 + proficiencyExtra);
}
else
{
proficiencyExtra = (1 + proficiencyExtra) * (1 + playerData.benefitsEventFishCashMag);
}
proficiency *= proficiencyExtra;
proficiency = Mathf.RoundToInt(proficiency);
GContext.container.Resolve<FishingChallengeManager>().AddScoreByQuality(Quality, proficiency, fishId);
_fishScore = proficiency;
return proficiencyExtra;
}}

View File

@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><EnableDefaultCompileItems>false</EnableDefaultCompileItems><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup><ItemGroup><Compile Include="FormulaTests.cs.txt"/></ItemGroup></Project>

View File

@@ -0,0 +1,17 @@
using System; using System.Collections.Generic;
var fish=new ActualFish(); fish.fishData[7]=new FishWeightData {TotalNum=20,MaxG=10};
fish.SetGetFishCount(7); fish.SetWeight(7,5);
bool ok=fish.fishData[7].TotalNum==30 && PlayFabMgr.Instance.Writes==1;
Console.WriteLine($"{(ok?"PASS":"FAIL")} non-record catch: total={fish.fishData[7].TotalNum}, mapWrites={PlayFabMgr.Instance.Writes}; expected total=30 and one persisted map.");
return ok?0:1;
public class FishWeightData { public int TotalNum; public float MaxG; public bool NewFish; public string Serialize()=>TotalNum+"|"+MaxG; }
public enum ConditionType {GetFishWeight,GetFishID}
public record ConditionTypeEvent(ConditionType type,int count);
public class PlayerData {public int GetMagnification()=>10;}
public static class GContext {public static Container container=new(); public static void Publish(object x) {} }
public class Container {public T Resolve<T>() where T:new()=>new();}
public class Tables {public FishTable TbFishData=new();}
public class FishTable {public FishConfig GetOrDefault(int id)=>new();}
public class FishConfig {public int AreaID=102;}
public static class JsonMapper {public static string ToJson(object x)=>System.Text.Json.JsonSerializer.Serialize(x);}
public class PlayFabMgr {public static PlayFabMgr Instance=new(); public int Writes; public void UpdateUserDataValue(string key,string value) {Writes++;} }

View File

@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><EnableDefaultCompileItems>false</EnableDefaultCompileItems><ImplicitUsings>enable</ImplicitUsings><UseAppHost>false</UseAppHost><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup><ItemGroup><Compile Include="LegacySlice.cs.txt"/><Compile Include="LegacyRed.cs.txt"/></ItemGroup></Project>

View File

@@ -0,0 +1,49 @@
using System; using System.Collections.Generic; using System.Linq;
public class ActualFish { public Dictionary<int,FishWeightData> fishData=new(); public Dictionary<int,Dictionary<int,FishWeightData>> mapFishData=new(); public Tables _tables=new();
public void SetWeight(int fishId, float weight)
{
//获取鱼重量记录为g
GContext.Publish(new ConditionTypeEvent(ConditionType.GetFishWeight, (int)(weight * 1000)));
CheckFishData(fishId);
if (fishData[fishId].MaxG < weight)
{
fishData[fishId].MaxG = weight;
SetFishData(fishId, fishData[fishId]);
}
GContext.Publish(new ConditionTypeEvent(ConditionType.GetFishID, fishId));
}
public void SetGetFishCount(int fishId)
{
int count = GContext.container.Resolve<PlayerData>().GetMagnification();
CheckFishData(fishId);
fishData[fishId].TotalNum += count;
}
void CheckFishData(int fishId)
{
if (!fishData.ContainsKey(fishId))
{
fishData.Add(fishId, new FishWeightData() { NewFish = true });
}
}
void SetFishData(int id, FishWeightData data)
{
int mapID = _tables.TbFishData.GetOrDefault(id).AreaID;
if (mapFishData.ContainsKey(mapID))
{
mapFishData[mapID][id] = data;
}
else
{
mapFishData[mapID] = new Dictionary<int, FishWeightData>
{
[id] = data
};
}
MapNewUpdateUserDataValue(mapID, mapFishData[mapID]);
}
void MapNewUpdateUserDataValue(int mapID, Dictionary<int, FishWeightData> keyValuePairs)
{
Dictionary<int, string> mapData = keyValuePairs.ToDictionary(_ => _.Key, _ => _.Value.Serialize());
PlayFabMgr.Instance.UpdateUserDataValue($"Map_5_{mapID}", JsonMapper.ToJson(mapData));
}
}

View File

@@ -0,0 +1,175 @@
using System;using System.Threading.Tasks;using System.Runtime.CompilerServices;
var panel=new OriginalPanel();panel.Run();
bool ok=panel.Animations==0&&!panel.go_info.active;
Console.WriteLine($"{(ok?"PASS":"FAIL")} original full Success method displays before pending storage response: writes={Storage.Writes}, confirmed={Storage.Confirmed}, animations={panel.Animations}, info={panel.go_info.active}");
return ok?0:1;
public class OriginalPanel{
public int Animations;public Node go_info=new(),go_weight=new(),fx_ui_blingbling_01=new(),bg_target=new();
int addCollecting,curCollectingID,add;float extraPointEvent;bool isSocial,isEnable=true;int Magnification=10;float waitTime1,duration1,waitTime2,duration2;
Fishing fishingData=new();FishingEventData fishingEventData=new();Tables _tables=new();PlayerData playerData=new();string PanelName="reward";
Text root_target_text_info=new(),root_1v1battle_text_info=new(),text_gold_num=new(),text_num_buff=new(),txt_magnification=new();
Bonus bonusReward=new();UI uiService=new();Social fishingRewardSocial=new();Animator anim_panel;
public OriginalPanel(){anim_panel=new Animator(()=>Animations++);}public void Run()=>Success(0);
void MaterialChange(){}void PlayAudioRewardFish(){}void SetTextTitleAsync(float x,bool s){}void SetFishWeight(float w){}void VibrationData(){}
float SetProficiency(float a,float b,int c)=>1;object AddCash()=>new();object StartCoroutine(object o){Animations++;return o;}
async void Success(float pointBonus)
{
go_info.gameObject.SetActive(false);
GContext.container.Resolve<PlayerData>().SaveFishingFrequency();
if (addCollecting > 0)
{
extraPointEvent = fishingEventData.OnFishReward(fishingData.MapId, fishingData.NewIndex, Magnification);
addCollecting *= Magnification;
GContext.container.Resolve<PlayerItemData>().AddItemCount(curCollectingID, addCollecting, null);
MaterialChange();
}
PlayAudioRewardFish();
GContext.container.Resolve<BuffDataCenter>().EndCollectionTargetWeight(fishingData.fishItem.ID);
fishingEventData.AddProgress(fishingData.fishItem.Quality, Magnification);
GContext.container.Resolve<GuideDataCenter>().AddIndex(true);
if (fishingData.IsKeepsakeFish())
{
root_target_text_info.text = LocalizationMgr.GetText("UI_FishingTargetPopupPanel_Advanced_9");
}
else if (fishingData.IsPiggyBankFish())
{
fishingEventData.curActivateItemId = fishingData.fishItem.ID;
root_target_text_info.text = LocalizationMgr.GetText("UI_GiftPiggyBankPopupPanel_10");
PiggyBankPackData piggyBankPackData = GContext.container.Resolve<PiggyBankPackData>();
piggyBankPackData.TriggerPack();
}
else if (fishingData.IsBarginPackFish())
{
fishingEventData.curActivateItemId = fishingData.fishItem.ID;
root_target_text_info.text = LocalizationMgr.GetText("UI_GiftBargainPopupPanel_7");
BargainPackData piggyBankPackData = GContext.container.Resolve<BargainPackData>();
piggyBankPackData.TriggerPack();
}
else if (fishingData.IsGeneralFish())
{
isSocial = false;
DropPackageList dropPackageList = _tables.TbDrop[fishingData.curFishData.DropID].DropList;
float proficiency = dropPackageList.DropCountList[0] * fishingData.random;
var proficiencyExtra = SetProficiency(proficiency, extraPointEvent, fishingData.curFishData.ID);//额外的Buff加成
var endWeight = fishingData.fishWeight;
float extraCashDrop = GContext.container.Resolve<CollectingData>().ExtraCashDrop();
//Data
//金币各种加成
float gold = (dropPackageList.DropCountList[1] + extraCashDrop)
* (1 + pointBonus)
* Magnification
* fishingData.random;
//杆加成
//解锁地图Buff加成
float glodBuff = GContext.container.Resolve<BuffDataCenter>().GetExtraGoldBuffValue(fishingData.MapId, fishingData.fishItem.Quality);
if (glodBuff > 0.001f)
{
text_gold_num.color = Color.black;
text_num_buff.color = Color.white;
}
int Quality = fishingData.curFishData.Quality;
if (Quality > 6)
{
Quality = 6;
}
//权益全局加成
var ExtraPoint = playerData.benefitsCashMag + glodBuff;
int cash = Mathf.RoundToInt(gold * proficiencyExtra * (1 + ExtraPoint));
add = cash;
GContext.Publish(new ConditionTypeEvent(ConditionType.CashFromFishing, add));
GContext.Publish(new ConditionTypeEvent(ConditionType.MaxCashFromFishing, add / Magnification));
//AddEventData
if (add > 0)
{
playerData.AddGold((ulong)add);
}
GContext.Publish(new TargetAddData(dropPackageList.DropIDList[1], 0, add));
var isSpecialFish = fishingData.curFishData.GeneralType == FishGeneralType.SpMapFish || fishingData.curFishData.GeneralType == FishGeneralType.EventFish;
SetTextTitleAsync(proficiencyExtra, isSpecialFish);
SetFishWeight(fishingData.fishWeight);
MMTService mmService = GContext.container.Resolve<MMTService>();
if (addCollecting > 0)
{
var collectingTargetInit = fishingEventData.collectingTargetInit;
string BgRewardAdd = collectingTargetInit.BgRewardAdd;
if (!string.IsNullOrEmpty(BgRewardAdd))
{
bg_target.gameObject.SetActive(true);
uiService.SetImageSprite(bg_target, BgRewardAdd, PanelName);
}
}
bonusReward.Show(Magnification);
_ = StartCoroutine(AddCash());
//Show Fish Info UI
go_info.gameObject.SetActive(true);
go_weight.SetActive(false);
//Show Magnification Animation
if (Magnification > 1)
{
txt_magnification.text = "x" + Magnification;
anim_panel.Play("info_show_2");
await new WaitForSeconds(waitTime1 + duration1 + waitTime2);
if (!isEnable)
{
return;
}
bonusReward.DOTweenAdd(Magnification, duration2);
var progress = 0f;
var newCash = add / Magnification;
var addCash = add - newCash;
DOTween.To(() => progress, x => progress = x, 1f, duration2).OnUpdate(() =>
{
text_gold_num.text = ConvertTools.GetNumberString2((int)(newCash + addCash * progress));
text_num_buff.text = text_gold_num.text;
}).SetId("AddCash");
}
else
{
anim_panel.Play("info_show_1");
}
if (glodBuff > 0.001f)
{
fx_ui_blingbling_01.gameObject.SetActive(true);
}
VibrationData();
fishingRewardSocial.ShowSocial(isSocial);
}
else if (fishingData.fishItem.IsSpinachItem())
{
//todo 1v1battle
root_1v1battle_text_info.text=LocalizationMgr.GetText("UI_GiftBargainPopupPanel_8");
}
}}
public static class Storage{public static int Writes;public static bool Confirmed=false;}
public class Node{public bool active;public Node gameObject=>this;public void SetActive(bool b){active=b;}}
public class Text{public string text;public Color color;}public struct Color{public static Color black,white;}
public class PlayerData{public float benefitsCashMag;public void SaveFishingFrequency(){Storage.Writes++;}public void AddGold(ulong v){Storage.Writes++;}}
public class PlayerItemData{public void AddItemCount(int a,int b,object c){}}public class PiggyBankPackData{public void TriggerPack(){}}public class BargainPackData{public void TriggerPack(){}}
public class BuffDataCenter{public void EndCollectionTargetWeight(int x){}public float GetExtraGoldBuffValue(int a,int b)=>0;}public class CollectingData{public float ExtraCashDrop()=>0;}
public class GuideDataCenter{public void AddIndex(bool b){}}public class MMTService{}
public class Tables{public Drop[] TbDrop={new()};}public class Drop{public DropPackageList DropList=new();}public class DropPackageList{public float[] DropCountList={1,10};public int[] DropIDList={0,1001};}
public class Fishing{public Fish curFishData=new(),fishItem=new();public int MapId,NewIndex;public float random=1,fishWeight=1;public bool IsKeepsakeFish()=>false;public bool IsPiggyBankFish()=>false;public bool IsBarginPackFish()=>false;public bool IsGeneralFish()=>true;}
public class Fish{public int ID,DropID,Quality=2;public FishGeneralType GeneralType;public bool IsSpinachItem()=>false;}
public enum FishGeneralType{Normal,SpMapFish,EventFish}public enum ConditionType{CashFromFishing,MaxCashFromFishing}
public record ConditionTypeEvent(ConditionType c,int count);public record TargetAddData(int a,int b,int c);
public class FishingEventData{public int curActivateItemId;public Target collectingTargetInit=new();public float OnFishReward(int a,int b,int c)=>0;public void AddProgress(int a,int b){}}public class Target{public string BgRewardAdd;}
public static class GContext{public static Container container=new();public static void Publish(object e){}}public class Container{public T Resolve<T>() where T:new()=>new();}
public class Bonus{public void Show(int n){}public void DOTweenAdd(int n,float t){}}public class UI{public void SetImageSprite(object a,string b,string c){}}public class Social{public void ShowSocial(bool b){}}
public class Animator{Action play;public Animator(Action a){play=a;}public void Play(string s){play();}}
public static class LocalizationMgr{public static string GetText(string s)=>s;}public static class Mathf{public static int RoundToInt(float v)=>(int)MathF.Round(v);}
public static class ConvertTools{public static string GetNumberString2(int n)=>n.ToString();}public static class DOTween{public static Tween To(Func<float>a,Action<float>b,float c,float d)=>new();}public class Tween{public Tween OnUpdate(Action a)=>this;public Tween SetId(string s)=>this;}
public class WaitForSeconds{static TaskCompletionSource<bool> wait=new();public WaitForSeconds(float t){}public TaskAwaiter<bool> GetAwaiter()=>wait.Task.GetAwaiter();}

View File

@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><EnableDefaultCompileItems>false</EnableDefaultCompileItems><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup><ItemGroup><Compile Include="OldPresentationTests.cs.txt"/></ItemGroup></Project>

View File

@@ -0,0 +1,56 @@
using System;using System.Threading.Tasks;
public class ActualPanel {
game.PlayerDataStorage.PlayerKVGameplayLoop.FishingRewardAction kvReward; Func<Task> kvPresentation;
public bool isActiveAndEnabled=true; public Canvas canvasGroup=new(); public Fish playerFishData=new();public Player playerData=new(); int fishID=7;public int Applied;
bool KVRewardViewCurrent()=>this!=null && isActiveAndEnabled && game.PlayerDataStorage.PlayerKVGameplayLoop.RewardPanelCurrent(kvReward,this);
public Button playButton=new();public bool NonKvOnly;public int HomeClicks;
public void StartTail()
{
playButton.onClick.AddListener(OnClickInHome);
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
#endif
{
NonKvOnly=true;
}
OnShow();
}
void OnClickInHome()=>HomeClicks++;
void OnShowCore(){Applied++;}public void Run()=>OnShow();
void OnShow()
{
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
{
try
{
var bound=game.PlayerDataStorage.PlayerKVGameplayLoop.BindRewardPanel(this);
if(bound==null) return;
kvReward=bound;
game.PlayerDataStorage.PlayerKVEditResult primary;
using(game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardSegment("reward"))
{
OnShowCore();
playerFishData.SaveCaughtFishForKVLoop(fishID);
primary=playerData.SaveFishingRewardForKVLoop();
}
_=ConfirmKVRewardAsync(primary);
}
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
return;
}
#endif
OnShowCore();
}
async System.Threading.Tasks.Task ConfirmKVRewardAsync(game.PlayerDataStorage.PlayerKVEditResult primary)
{
try
{
await game.PlayerDataStorage.PlayerKVGameplayLoop.ConfirmRewardAsync(kvReward,primary);
if(!KVRewardViewCurrent()) return;
game.PlayerDataStorage.PlayerKVGameplayLoop.PresentReward(kvReward,()=>{canvasGroup.alpha=1;canvasGroup.blocksRaycasts=true;canvasGroup.interactable=true;});
if(kvPresentation!=null) await kvPresentation();
}
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
}
}

View File

@@ -0,0 +1,31 @@
using System;using System.Threading.Tasks;using game.PlayerDataStorage;
var panel=new ActualPanel();panel.Run();panel.Run();
if(panel.Applied!=1)throw new Exception("duplicate applies rewards");
if(panel.canvasGroup.alpha!=0)throw new Exception("pending reward already visible");
bool pendingBlocked=panel.canvasGroup.blocksRaycasts==false && panel.canvasGroup.interactable==false;
PlayerKVGameplayLoop.Pending.SetResult(true);await Task.Delay(25);
bool presented=panel.canvasGroup.alpha==1 && PlayerKVGameplayLoop.Shows==1;
bool interactRestored=panel.canvasGroup.blocksRaycasts && panel.canvasGroup.interactable;
var s2=new ActualPanel();s2.StartTail();bool kvListener=s2.playButton.onClick.Count==1 && !s2.NonKvOnly;
s2.playButton.onClick.Invoke();bool kvClick=s2.HomeClicks==1;
PlayerKVGameplayLoop.Enabled=false;var s3=new ActualPanel();s3.StartTail();bool pfListener=s3.playButton.onClick.Count==1 && s3.NonKvOnly;
bool ok=presented && interactRestored && kvListener && kvClick && pfListener && pendingBlocked;
Console.WriteLine($"{(ok?"PASS":"FAIL")} presentation restores interaction; KV listener registered; raycasts={panel.canvasGroup.blocksRaycasts}, kvListener={kvListener}, pfListener={pfListener}");return ok?0:1;
public class Canvas {public float alpha;public bool blocksRaycasts,interactable;}
public class ButtonEvent{System.Action h;public int Count=>h==null?0:1;public void AddListener(System.Action a)=>h+=a;public void Invoke()=>h?.Invoke();}
public class Button{public ButtonEvent onClick=new();}
public class Fish {public void SaveCaughtFishForKVLoop(int id){}}
public class Player {public PlayerKVEditResult SaveFishingRewardForKVLoop()=>new();}
namespace game.PlayerDataStorage {
public class PlayerKVEditResult{}
public static class PlayerKVGameplayLoop {
public class FishingRewardAction{}
public static bool Enabled=true;static FishingRewardAction action;public static int Shows;public static TaskCompletionSource<bool> Pending=new();
public static FishingRewardAction BindRewardPanel(object p) {if(action!=null)return null;return action=new();}
public static bool RewardPanelCurrent(FishingRewardAction a,object p)=>a!=null && ReferenceEquals(a,action);
public static IDisposable BeginRewardSegment(string name)=>new Scope();
public static Task ConfirmRewardAsync(FishingRewardAction a,PlayerKVEditResult p)=>Pending.Task;
public static void PresentReward(FishingRewardAction a,Action show){Shows++;show();}
public static void Fault(string message)=>throw new Exception(message);
class Scope:IDisposable{public void Dispose(){}}
}}

View File

@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><EnableDefaultCompileItems>false</EnableDefaultCompileItems><ImplicitUsings>enable</ImplicitUsings><UseAppHost>false</UseAppHost><DefineConstants>UNITY_EDITOR</DefineConstants><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup><ItemGroup><Compile Include="PanelSlice.cs.txt"/><Compile Include="PanelTests.cs.txt"/></ItemGroup></Project>

View File

@@ -0,0 +1,16 @@
using game.PlayerDataStorage;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
var root=Path.GetFullPath("CodexOutput/upserver/KVFishingRewardTests/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<string,string>{{"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);
Case(3,"fishing-reward-once",true); Case(2,"fishing-reward-once",false); Case(3,"fishing-consume-once",false); Case(4,"fishing-reward-once",false);
Console.WriteLine($"RESULT cases={count} failed={failed}"); return failed==0?0:1;

View File

@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><EnableDefaultCompileItems>false</EnableDefaultCompileItems><ImplicitUsings>enable</ImplicitUsings><UseAppHost>false</UseAppHost><DefineConstants>UNITY_EDITOR;SERVICE_HOST</DefineConstants><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup>
<ItemGroup><Compile Include="PermitTests.cs.txt"/><Compile Include="../../../Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs"/><Reference Include="Newtonsoft.Json"><HintPath>../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath></Reference></ItemGroup>
</Project>

View File

@@ -0,0 +1,44 @@
from pathlib import Path
r=Path(__file__).parent
s=Path('Client/Assets/Scripts/GampPlay/FishingB/FishingUpdateSys.cs').read_text(encoding='utf-8-sig')
def method(sig):
a=s.index(sig);i=s.index('{',a)+1;n=1
while n:n+=(s[i]=='{')-(s[i]=='}');i+=1
return s[a:i]
code='''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(){}
'''
for sig in ['public void PiercingFish(int waitingZone)','void FishEnd()','async Task ShowKVRewardPanelAsync()','private void OnDisable()','private void OnDestroy()']:code+=method(sig)+'\n'
code+='''}
public class FishingData{public bool IsCaughtFish=true;}public enum UITypes{FishingRewardPanel}
public class UIManager{public static UIManager Instance=new();public int Calls;public TaskCompletionSource<object> Response=new();public Task<object> ShowUI(UITypes type){Calls++;return Response.Task;}}
public static class GContext{public static Container container=new();}public class Container{public T Resolve<T>() where T:new()=>new();}public class SoundMgr{public void StopActiveFishingLoopSounds(float x){}}
namespace game.PlayerDataStorage {public static class PlayerKVGameplayLoop{
public static bool Enabled,Drawing,Ending,Cancelled,Confirmed;public static int Version,Faults;public static string Scope;
public static void Reset(){Enabled=true;Drawing=Ending=Cancelled=Confirmed=false;Version=3;Faults=0;Scope=null;}
public static bool RewardRunCurrent=>!Cancelled;
public static bool BeginRewardDrawing(object o){if(Version!=3||Cancelled)throw new Exception("no draw");if(Drawing)return false;Drawing=true;return true;}
public static bool BeginRewardEnding(object o,bool success){if(Ending)return false;if(!success)throw new Exception("failed catch");Ending=true;return true;}
public static IDisposable BeginRewardSegment(string n){Scope=n;return new Batch();}class Batch:IDisposable{public void Dispose(){Scope=null;}}
public static void Fault(string s){Faults++;}public static void CancelReward(object o,string r){if(Cancelled)return;Cancelled=true;if(!Confirmed)Fault(r);}
}}
'''
(r/'ControllerTests.cs.txt').write_text(code,encoding='utf-8')
(r/'ControllerTests.csproj').write_text('''<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><EnableDefaultCompileItems>false</EnableDefaultCompileItems><DefineConstants>UNITY_EDITOR</DefineConstants><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup><ItemGroup><Compile Include="ControllerTests.cs.txt"/></ItemGroup></Project>''',encoding='utf-8')

View File

@@ -0,0 +1,34 @@
from pathlib import Path
import json
r=Path(__file__).parent
before=(r/'before/FishingRewardPanel.cs').read_text(encoding='utf-8-sig')
current=Path('Client/Assets/Scripts/UI/FishingReward/FishingRewardPanel.cs').read_text(encoding='utf-8-sig')
def method(src,signature):
start=src.index(signature);a=src.index('{',start);depth=1;i=a+1
while depth:
depth += (src[i]=='{')-(src[i]=='}');i+=1
return src[start:i]
def block(src):
start=src.index('DropPackageList dropPackageList =');end=src.index(' add = cash;',start)
return src[start:end]
def formula(src,name):
return 'public class '+name+' : FormulaBase { public int Run(){'+block(src)+'return cash;}'+method(src,'float SetProficiency(')+'}\n'
text='''using System;
foreach(var c in new[]{(10f,0.25f,10,0.5f,0.5f,0.2f,0.1f,122),(0f,0.25f,10,0.5f,0.5f,0.2f,0.1f,0),(10f,0f,1,1f,0f,0f,0f,10),(1f,0f,1,0.5f,0f,0f,0f,0)}){
var old=new OldFormula();var next=new NewFormula();
foreach(var x in new FormulaBase[]{old,next}){x._tables.TbDrop[0].DropList.DropCountList[1]=c.Item1;x.pointBonus=c.Item2;x.Magnification=c.Item3;x.fishingData.random=c.Item4;x.fishingData.WeightIncEvent=c.Item5;x.playerData.benefitsCashMag=c.Item6;GContext.container.Buff.Gold=c.Item7;}
int a=old.Run(),b=next.Run();if(a!=c.Item8||b!=a)throw new Exception($"Formula {a}/{b} expected {c.Item8}");Console.WriteLine($"PASS original/current cash={b}");
}
public class FormulaBase {public Tables _tables=new();public Fishing fishingData=new();public Player playerData=new();public float pointBonus,extraPointEvent;public int Magnification;protected float _fishScore;protected Text text_gold_num=new(),text_num_buff=new();}
public class Tables {public Drop[] TbDrop={new()};}public class Drop{public DropPackageList DropList=new();}public class DropPackageList {public float[] DropCountList={20,10};}
public class Fishing{public Fish curFishData=new(),fishItem=new();public float random,fishWeight,WeightIncEvent;public int MapId;public bool IsNewItemFish()=>false;}
public class Fish{public int DropID,ID,Quality=2;}public class Player{public float benefitsCashMag,benefitsEventFishCashMag;}
public class CollectingData{public float ExtraCashDrop()=>0;}public class BuffDataCenter{public float Gold;public float GetExtraGoldBuffValue(int m,int q)=>Gold;}
public class FishingChallengeManager{public void AddScoreByQuality(int a,float b,int c){}}
public class Container{public BuffDataCenter Buff=new();public T Resolve<T>() where T:new()=>typeof(T)==typeof(BuffDataCenter)?(T)(object)Buff:new T();}
public static class GContext{public static Container container=new();}public class Text{public Color color;}public struct Color{public static Color black,white;}
public static class Mathf{public static int RoundToInt(float x)=>(int)MathF.Round(x,MidpointRounding.ToEven);}
'''
(r/'FormulaTests.cs.txt').write_text(text+formula(before,'OldFormula')+formula(current,'NewFormula'),encoding='utf-8')
(r/'FormulaTests.csproj').write_text('''<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><EnableDefaultCompileItems>false</EnableDefaultCompileItems><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup><ItemGroup><Compile Include="FormulaTests.cs.txt"/></ItemGroup></Project>''',encoding='utf-8')
(r/'formula-source-check.json').write_text(json.dumps({'cashBlockUnchanged':block(before)==block(current),'proficiencyUnchanged':method(before,'float SetProficiency(')==method(current,'float SetProficiency('),'fishWeightUnchanged':method(before,'void SetFishWeight(')==method(current,'void SetFishWeight(')},indent=2))

View File

@@ -0,0 +1,42 @@
from pathlib import Path
r=Path(__file__).parent
s=(r/'before/FishingRewardPanel.cs').read_text(encoding='utf-8-sig');a=s.index(' async void Success(float pointBonus)');i=s.index('{',a)+1;n=1
while n:n+=(s[i]=='{')-(s[i]=='}');i+=1
method=s[a:i]
text='''using System;using System.Threading.Tasks;using System.Runtime.CompilerServices;
var panel=new OriginalPanel();panel.Run();
bool ok=panel.Animations==0&&!panel.go_info.active;
Console.WriteLine($"{(ok?"PASS":"FAIL")} original full Success method displays before pending storage response: writes={Storage.Writes}, confirmed={Storage.Confirmed}, animations={panel.Animations}, info={panel.go_info.active}");
return ok?0:1;
public class OriginalPanel{
public int Animations;public Node go_info=new(),go_weight=new(),fx_ui_blingbling_01=new(),bg_target=new();
int addCollecting,curCollectingID,add;float extraPointEvent;bool isSocial,isEnable=true;int Magnification=10;float waitTime1,duration1,waitTime2,duration2;
Fishing fishingData=new();FishingEventData fishingEventData=new();Tables _tables=new();PlayerData playerData=new();string PanelName="reward";
Text root_target_text_info=new(),root_1v1battle_text_info=new(),text_gold_num=new(),text_num_buff=new(),txt_magnification=new();
Bonus bonusReward=new();UI uiService=new();Social fishingRewardSocial=new();Animator anim_panel;
public OriginalPanel(){anim_panel=new Animator(()=>Animations++);}public void Run()=>Success(0);
void MaterialChange(){}void PlayAudioRewardFish(){}void SetTextTitleAsync(float x,bool s){}void SetFishWeight(float w){}void VibrationData(){}
float SetProficiency(float a,float b,int c)=>1;object AddCash()=>new();object StartCoroutine(object o){Animations++;return o;}
'''+method+'''}
public static class Storage{public static int Writes;public static bool Confirmed=false;}
public class Node{public bool active;public Node gameObject=>this;public void SetActive(bool b){active=b;}}
public class Text{public string text;public Color color;}public struct Color{public static Color black,white;}
public class PlayerData{public float benefitsCashMag;public void SaveFishingFrequency(){Storage.Writes++;}public void AddGold(ulong v){Storage.Writes++;}}
public class PlayerItemData{public void AddItemCount(int a,int b,object c){}}public class PiggyBankPackData{public void TriggerPack(){}}public class BargainPackData{public void TriggerPack(){}}
public class BuffDataCenter{public void EndCollectionTargetWeight(int x){}public float GetExtraGoldBuffValue(int a,int b)=>0;}public class CollectingData{public float ExtraCashDrop()=>0;}
public class GuideDataCenter{public void AddIndex(bool b){}}public class MMTService{}
public class Tables{public Drop[] TbDrop={new()};}public class Drop{public DropPackageList DropList=new();}public class DropPackageList{public float[] DropCountList={1,10};public int[] DropIDList={0,1001};}
public class Fishing{public Fish curFishData=new(),fishItem=new();public int MapId,NewIndex;public float random=1,fishWeight=1;public bool IsKeepsakeFish()=>false;public bool IsPiggyBankFish()=>false;public bool IsBarginPackFish()=>false;public bool IsGeneralFish()=>true;}
public class Fish{public int ID,DropID,Quality=2;public FishGeneralType GeneralType;public bool IsSpinachItem()=>false;}
public enum FishGeneralType{Normal,SpMapFish,EventFish}public enum ConditionType{CashFromFishing,MaxCashFromFishing}
public record ConditionTypeEvent(ConditionType c,int count);public record TargetAddData(int a,int b,int c);
public class FishingEventData{public int curActivateItemId;public Target collectingTargetInit=new();public float OnFishReward(int a,int b,int c)=>0;public void AddProgress(int a,int b){}}public class Target{public string BgRewardAdd;}
public static class GContext{public static Container container=new();public static void Publish(object e){}}public class Container{public T Resolve<T>() where T:new()=>new();}
public class Bonus{public void Show(int n){}public void DOTweenAdd(int n,float t){}}public class UI{public void SetImageSprite(object a,string b,string c){}}public class Social{public void ShowSocial(bool b){}}
public class Animator{Action play;public Animator(Action a){play=a;}public void Play(string s){play();}}
public static class LocalizationMgr{public static string GetText(string s)=>s;}public static class Mathf{public static int RoundToInt(float v)=>(int)MathF.Round(v);}
public static class ConvertTools{public static string GetNumberString2(int n)=>n.ToString();}public static class DOTween{public static Tween To(Func<float>a,Action<float>b,float c,float d)=>new();}public class Tween{public Tween OnUpdate(Action a)=>this;public Tween SetId(string s)=>this;}
public class WaitForSeconds{static TaskCompletionSource<bool> wait=new();public WaitForSeconds(float t){}public TaskAwaiter<bool> GetAwaiter()=>wait.Task.GetAwaiter();}
'''
(r/'OldPresentationTests.cs.txt').write_text(text,encoding='utf-8')
(r/'OldPresentationTests.csproj').write_text('''<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><EnableDefaultCompileItems>false</EnableDefaultCompileItems><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup><ItemGroup><Compile Include="OldPresentationTests.cs.txt"/></ItemGroup></Project>''',encoding='utf-8')

View File

@@ -0,0 +1,5 @@
var s=UnityEditor.AssetDatabase.LoadAssetAtPath<GuidanceDefineSetting>("Assets/ABPackage/pkgPrepare/Export/GuidanceConfig.asset");
var groups=s.guidanceGroupDefines.Where(g=>g.guidanceDefines.Any(n=>n.panelName=="FishingPanel"||n.panelName=="FishingPanel_Advanced"||n.panelName=="FishingRewardPanel")).OrderBy(g=>g.groupName.ToString(),System.StringComparer.Ordinal).ToArray();
var projection=string.Join("\n",groups.Select(g=>UnityEngine.JsonUtility.ToJson(g)));
System.IO.File.WriteAllText("C:/projects/ft/CodexOutput/upserver/KVFishingRewardTests/guide-baseline-projection.jsonl",projection,new System.Text.UTF8Encoding(false));
return new{count=groups.Length,nodeCounts=string.Join(",",groups.Select(g=>g.groupName+":"+g.guidanceDefines.Length))};

View File

@@ -0,0 +1,2 @@
var s=UnityEditor.AssetDatabase.LoadAssetAtPath<GuidanceDefineSetting>("Assets/ABPackage/pkgPrepare/Export/GuidanceConfig.asset");
return string.Join("\n",System.Linq.Enumerable.Select(System.Linq.Enumerable.Where(s.guidanceGroupDefines,g=>new[]{"FishSkill01","Pollution01","FishSkill02","FishSkill03","FishSkill04","Egyptian01","EasterFish01"}.Contains(g.groupName.ToString())),g=>g.groupName+" | "+g.TriggerConditionsType+" | "+g.TriggerConditionsParameter+" | "+string.Join(",",System.Linq.Enumerable.Select(g.guidanceDefines,x=>x.panelName))));

View File

@@ -0,0 +1,7 @@
var settings=UnityEditor.AssetDatabase.LoadAssetAtPath<GuidanceDefineSetting>("Assets/ABPackage/pkgPrepare/Export/GuidanceConfig.asset");
var backup=SimpleJSON.JSON.Parse(System.IO.File.ReadAllText("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/backups/20260924T084836Z/102.json"));
var entries=backup["data"]["GuideSaveDataNew"].Value.Split(';');
var completed=new System.Collections.Generic.HashSet<string>(System.Linq.Enumerable.Select(System.Linq.Enumerable.Where(entries,x=>x.EndsWith("|1")),x=>x.Split('|')[0]));
var groups=System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Where(settings.guidanceGroupDefines,g=>System.Linq.Enumerable.Any(g.guidanceDefines,s=>s.panelName=="FishingPanel" || s.panelName=="FishingPanel_Advanced" || s.panelName=="FishingRewardPanel")));
var unfinished=System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Where(groups,g=>!completed.Contains(g.groupName.ToString())));
return new{count=groups.Length,required=string.Join(",",System.Linq.Enumerable.Select(groups,g=>g.groupName.ToString())),unfinished=string.Join(",",System.Linq.Enumerable.Select(unfinished,g=>g.groupName.ToString())),settings.SkipLevel,missingNotSkipped=string.Join(",",System.Linq.Enumerable.Select(System.Linq.Enumerable.Where(unfinished,g=>!settings.SkipGroupNames.Contains(g.groupName)),g=>g.groupName.ToString())),scope="read-only asset and backup; no runtime guide state assumed"};

View File

@@ -0,0 +1,3 @@
var s=UnityEditor.AssetDatabase.LoadAssetAtPath<GuidanceDefineSetting>("Assets/ABPackage/pkgPrepare/Export/GuidanceConfig.asset");
var groups=s.guidanceGroupDefines.Where(g=>g.guidanceDefines.Any(n=>n.panelName=="FishingPanel"||n.panelName=="FishingPanel_Advanced"||n.panelName=="FishingRewardPanel")).OrderBy(g=>g.groupName.ToString(),System.StringComparer.Ordinal).ToArray();
return string.Join("\n",groups.Select(g=>UnityEngine.JsonUtility.ToJson(g)));

View File

@@ -0,0 +1,75 @@
$ErrorActionPreference='Stop'
$root='C:/projects/ft/CodexOutput/upserver'
function Read-Backup($stamp) { Get-Content -Raw -LiteralPath "$root/KVGameplayLoopTests/backups/$stamp/102.json" | ConvertFrom-Json }
function Changed-Keys($left,$right) {
@(@($left.PSObject.Properties.Name)+@($right.PSObject.Properties.Name) | Sort-Object -Unique | Where-Object { $left.$_ -cne $right.$_ })
}
function Hash-Value([string]$value) {
$sha=[Security.Cryptography.SHA256]::Create()
try { [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($value))).Replace('-','') } finally { $sha.Dispose() }
}
$before=Read-Backup '20260928T024346Z'
$reward=Read-Backup '20260928T024903Z'
$closed=Read-Backup '20260928T025001Z'
$final=Read-Backup '20260928T025514Z'
$events=@(Get-Content -LiteralPath "$root/KVGameplayLoopTests/runs/44848_639261601944579366/events.jsonl" | ForEach-Object { $_ | ConvertFrom-Json })
$recoveryEvents=@(Get-Content -LiteralPath "$root/KVGameplayLoopTests/runs/103828_639261606481142886/events.jsonl" | ForEach-Object { $_ | ConvertFrom-Json })
$recovered=(Get-Content -Raw -LiteralPath "$root/KVFishingRewardTests/fourth-recovered-values.json" | ConvertFrom-Json).Result | ConvertFrom-Json
$preMain=$before.data.NewPlayerDataDic | ConvertFrom-Json
$postMain=$final.data.NewPlayerDataDic | ConvertFrom-Json
$preMap=$before.data.Map_5_102 | ConvertFrom-Json
$postMap=$final.data.Map_5_102 | ConvertFrom-Json
$rewardConfirmation=@($events | Where-Object kind -EQ 'fishing-reward-confirmed')[0]
$presentation=@($events | Where-Object kind -EQ 'fishing-reward-presented')[0]
$ticketMatches=@($rewardConfirmation.detail.tickets | ForEach-Object { [pscustomobject]@{key=$_.key;sequence=$_.sequence;matchesFinalServer=((Hash-Value $final.data.($_.key)) -ceq $_.valueHash)} })
$cacheResults=@('44848_639261601944579366','103828_639261606481142886' | ForEach-Object {
$cacheFile=Get-ChildItem -LiteralPath "$root/KVGameplayLoopTests/runs/$_/cache" -Recurse -Filter state.json
$cache=Get-Content -Raw -LiteralPath $cacheFile.FullName | ConvertFrom-Json
[pscustomobject]@{run=$_;pending=@($cache.pending).Count;unknown=($null -ne $cache.unknownBatch);active=($null -ne $cache.activeBatch);baselineChangedKeys=@(Changed-Keys $cache.baseline $final.data)}
})
$report=[ordered]@{
phase='4C2-fourth-real-run-and-fresh-process-recovery-awaiting-independent-review'
api=$recovered.api
backups=@('20260928T024346Z','20260928T024903Z','20260928T025001Z','20260928T025514Z')
countBefore=@($before.data.PSObject.Properties).Count;countAfter=@($final.data.PSObject.Properties).Count
changedKeys=@(Changed-Keys $before.data $final.data)
changedFishIds=@(Changed-Keys $preMap $postMap)
fishBefore=$preMap.'10223';fishAfter=$postMap.'10223'
mainBefore=$preMain;mainAfter=$postMain
rewardToQuitChangedKeys=@(Changed-Keys $reward.data $closed.data)
quitToRecoveryFinalChangedKeys=@(Changed-Keys $closed.data $final.data)
consumeCount=@($events | Where-Object kind -EQ 'fishing-energy-applied').Count
rewardConfirmedCount=@($events | Where-Object kind -EQ 'fishing-reward-confirmed').Count
rewardPresentedCount=@($events | Where-Object kind -EQ 'fishing-reward-presented').Count
confirmedBeforePresented=([DateTime]$rewardConfirmation.at -lt [DateTime]$presentation.at)
rewardTickets=$ticketMatches
faults=@($events | Where-Object kind -EQ 'fault').Count
recoveryFishingEvents=@($recoveryEvents | Where-Object kind -Like 'fishing-*').Count
recoveryWrites=@($recoveryEvents | Where-Object kind -EQ 'accepted').Count
recovered=$recovered
recoveredCoreEqualsFinalServer=($recovered.core -ceq $postMain.newData)
recoveredFishRawEqualsFinalServer=($recovered.fishRaw -ceq $postMap.'10223')
cacheResults=$cacheResults
signupGold=16200;fishingGold=2516;totalGold=18716
independentFormulaRecomputed=$false
precastIndependentBackupCaptured=$false
boundary='用户在主会话尚未完成动作前快照时手动签到并抛竿;precast/live-gates实际采样晚于扣费,不能充当动作前基线。保存完整审计和服务器前后值,交独立复核裁决证据是否充分,不自动重跑。鱼TotalNum由新进程KV有效值读回验证;活对象直接验证重量/等级/NewFish,未通过私有反射读取TotalNum。'
}
$report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath "$root/KVFishingRewardTests/fourth-reconciliation.json" -Encoding utf8
$checks=@(
$report.api -ceq 'http://s101.local:18080/'
$report.consumeCount -eq 1
$report.rewardConfirmedCount -eq 1
$report.rewardPresentedCount -eq 1
$report.confirmedBeforePresented
$report.faults -eq 0
$report.recoveryFishingEvents -eq 0
$report.rewardToQuitChangedKeys.Count -eq 0
$report.quitToRecoveryFinalChangedKeys.Count -eq 0
$report.recoveredCoreEqualsFinalServer
$report.recoveredFishRawEqualsFinalServer
@($ticketMatches | Where-Object { -not $_.matchesFinalServer }).Count -eq 0
@($cacheResults | Where-Object { $_.pending -ne 0 -or $_.unknown -or $_.active -or $_.baselineChangedKeys.Count -ne 0 }).Count -eq 0
)
if($checks -contains $false) { throw 'Reconciliation assertion failed; inspect fourth-reconciliation.json' }
[pscustomobject]@{checks=$checks.Count;passed=$true;changedKeys=$report.changedKeys;changedFishIds=$report.changedFishIds;recoveryUnchanged=$true} | ConvertTo-Json -Depth 4

View File

@@ -0,0 +1,7 @@
using UnityEngine;
using UnityEngine.UI;
var panel=GameObject.Find("UIRoot/EventSignupPopupPanel_2");
if(panel==null)return "No signup popup";
var button=panel.transform.Find("btn_close").GetComponent<Button>();
button.onClick.Invoke();
return "Invoked existing signup close callback only; no claim callback";

View File

@@ -0,0 +1 @@
return KVGameplayLoopSetup.State();

View File

@@ -0,0 +1,17 @@
using asap.core;
using System.Linq;
using LitJson;
var p=GContext.container.Resolve<GameCore.PlayerData>();
var f=GContext.container.Resolve<GameCore.PlayerFishData>();
var b=GContext.container.Resolve<BattlePassData>();
var map=JsonMapper.ToObject<System.Collections.Generic.Dictionary<string,string>>(PlayFabMgr.Instance.GetLocalData("Map_5_102"));
return JsonMapper.ToJson(new {
api=GContext.container.Resolve<ICustomServerMgr>().ApiBaseUri.ToString(),
core=p.PlayerDataDic["newData"],
gold=(ulong)p.gold,energy=(int)p.Energy,energyRM=p.GetEnergyRM(),frequency=p._fishingFrequency,
fishId=10223,fishRaw=map["10223"],
liveMaxWeight=f.GetDataMaxWeight(10223),liveLevel=f.GetDataLevel(10223),liveNewFish=f.GetDataNewFish(10223),
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)),
state=KVGameplayLoopSetup.State()
});

View File

@@ -0,0 +1,6 @@
using asap.core;
if(GContext.container==null)return "Game container unavailable after stop";
try {
var d=GContext.container.Resolve<FishingData>();
return new{postStopObservationOnly=true,ready=GContext.Ready,item=d.fishItem?.ID,subtype=d.fishItem?.SubType,fish=d.curFishData?.ID,free=d.IsFree,retry=d.IsRetry,duel=d.IsDuel,map=d.MapId};
} catch(System.Exception e){return "Post-stop game data unavailable: "+e.Message;}

View File

@@ -0,0 +1,2 @@
return KVGameplayLoopSetup.PrepareFishingRewardPermit("102","C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/backups/20260928T024346Z/102.json",new string[]{"AquariumEventDataNewKey","BattlePassRecordTask","BombGameCount","BuffDataList","BuyData","CampDataDic","ChainPackDataNew","DigCount","DigGridData","Event1v1BattleSlapData","EventBoostPackData","EventCubeMeltData","EventLuckGameRecord","EventLuckMagicPlayfabData","EventPartnerStatueData","FishHuntGameCount","FixedTimeShopDic","GuideSaveDataNew","IsOpenClubRed","LastPaymentTime","Leadboard_LastContinent","MMTest","Map_5_101","Map_5_102","MiniBPKeyNew","MonthlySignEvent","NewPlayerDataDic","NewRodList","OfferDiscoPartyManager","OfferStoryChain","OfferSweepPackData","Pack1A1Data","PiggyBankPackData","RodData","RodLevelData","SeasonStampData","ShopPackBuyCount","SoloDataKey","TargetBuffDataList","TransitionData","TriggerPackDataList","VIPAdPackData","WelcomeBack","epbdk","epdk"});

View File

@@ -0,0 +1,2 @@
return KVGameplayLoopSetup.PreparePermit("102","C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/backups/20260928T025001Z/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"});

View File

@@ -0,0 +1 @@
return KVGameplayLoopSetup.PrepareFishingRewardPermit("102","C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/backups/20260928T023420Z/102.json",new string[]{"AquariumEventDataNewKey","BattlePassRecordTask","BombGameCount","BuffDataList","BuyData","CampDataDic","ChainPackDataNew","DigCount","DigGridData","Event1v1BattleSlapData","EventBoostPackData","EventCubeMeltData","EventLuckGameRecord","EventLuckMagicPlayfabData","EventPartnerStatueData","FishHuntGameCount","FixedTimeShopDic","GuideSaveDataNew","IsOpenClubRed","LastPaymentTime","Leadboard_LastContinent","MMTest","Map_5_101","Map_5_102","MiniBPKeyNew","MonthlySignEvent","NewPlayerDataDic","NewRodList","OfferDiscoPartyManager","OfferStoryChain","OfferSweepPackData","Pack1A1Data","PiggyBankPackData","RodData","RodLevelData","SeasonStampData","ShopPackBuyCount","SoloDataKey","TargetBuffDataList","TransitionData","TriggerPackDataList","VIPAdPackData","WelcomeBack","epbdk","epdk"});

View File

@@ -0,0 +1 @@
return KVGameplayLoopSetup.PrepareFishingRewardPermit("102","C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/backups/20260924T093341Z/102.json",new string[]{"AquariumEventDataNewKey","BattlePassRecordTask","BombGameCount","BuffDataList","BuyData","CampDataDic","ChainPackDataNew","DigCount","DigGridData","Event1v1BattleSlapData","EventBoostPackData","EventCubeMeltData","EventLuckGameRecord","EventLuckMagicPlayfabData","EventPartnerStatueData","FishHuntGameCount","FixedTimeShopDic","GuideSaveDataNew","IsOpenClubRed","LastPaymentTime","Leadboard_LastContinent","MMTest","Map_5_101","Map_5_102","MiniBPKeyNew","MonthlySignEvent","NewPlayerDataDic","NewRodList","OfferDiscoPartyManager","OfferStoryChain","OfferSweepPackData","Pack1A1Data","PiggyBankPackData","RodData","RodLevelData","SeasonStampData","ShopPackBuyCount","SoloDataKey","TargetBuffDataList","TransitionData","TriggerPackDataList","VIPAdPackData","WelcomeBack","epbdk","epdk"});

View File

@@ -0,0 +1,8 @@
using asap.core;
using System.Linq;
var p=GContext.container.Resolve<GameCore.PlayerData>();
var g=GContext.container.Resolve<GameCore.GuideDataCenter>();
var f=GContext.container.Resolve<GameCore.PlayerFishData>();
var c=GContext.container.Resolve<FishingChallengeManager>();
return new{time=ZZTimeHelper.UtcNow(),offset=ZZTimeHelper.LocalTimeOffsetInSec,timeDiff=ZZTimeHelper.timeDiff.TotalSeconds,unlockedMag=p.IsOpenMagnification,unlockedStrike=f.IsOpenPiercing,frequency=p._fishingFrequency,cruise=f.GetFishSwimToMap(p.lastMapId),collect=GContext.container.Resolve<CollectingData>().GetAddCollecting(),collectingTarget=GContext.container.Resolve<GameCore.FishingEventData>().collectingTargetInit!=null,piggy=GContext.container.Resolve<PiggyBankPackData>().IsPackActivated,bargain=GContext.container.Resolve<BargainPackData>().IsPackActivated,challenge=c.fishingChallengeData!=null,guideNode=g.curDefine!=null,guideGroup=g.curGroupDefine!=null,groups=string.Join(";",g.guidanceGroupDefines.Where(x=>x.guidanceDefines.Any(n=>n.panelName=="FishingPanel"||n.panelName=="FishingPanel_Advanced"||n.panelName=="FishingRewardPanel")).Select(x=>x.groupName+":"+g.GetIsFinish(x.groupName.ToString()))),state=KVGameplayLoopSetup.State()};

View File

@@ -0,0 +1,6 @@
var consume=await KVGameplayLoopSetup.RunValidationAsync("fishing-consume");
var core=await KVGameLoadValidation.RunAsync("M01-M02-M21-M22-M24");
var full=await KVGameLoadValidation.RunAsync("B2-20");
var route=await KVSaveRouteValidation.RunAsync("fixed-route");
var equipment=await KVGameplayLoopSetup.RunValidationAsync("equipment-chain");
return new {consume,core,full,route,equipment};

View File

@@ -0,0 +1 @@
return await KVGameplayLoopSetup.RunValidationAsync("fishing-reward");

View File

@@ -0,0 +1,12 @@
using asap.core;
using System.Linq;
using System.Reflection;
var p=GContext.container.Resolve<GameCore.PlayerData>();
var t=GContext.container.Resolve<cfg.Tables>();
var guide=GContext.container.Resolve<GameCore.GuideDataCenter>();
var sys=UnityEngine.Object.FindObjectOfType<FishingUpdateSys>();
var d=sys==null?null:(FishingData)typeof(FishingUpdateSys).GetField("fishingData",BindingFlags.Instance|BindingFlags.Public|BindingFlags.NonPublic).GetValue(sys);
var loop=typeof(GameCore.PlayerData).Assembly.GetType("game.PlayerDataStorage.PlayerKVGameplayLoop");
var a=loop.GetField("rewardAction",BindingFlags.Static|BindingFlags.NonPublic).GetValue(null);
object Field(object o,string n)=>o?.GetType().GetField(n,BindingFlags.Instance|BindingFlags.Public|BindingFlags.NonPublic)?.GetValue(o);
return new{utc=System.DateTime.UtcNow,server=ZZTimeHelper.UtcNow(),energy=(int)p.Energy,energyRM=p.GetEnergyRM(),gold=(ulong)p.gold,frequency=p._fishingFrequency,core=p.PlayerDataDic.GetValueOrDefault("newData"),map=(int)p.lastMapId,mag=p.GetMagnification(),guideNode=guide.curDefine?.panelName,guideGroup=guide.curGroupDefine?.groupName.ToString(),controller=sys!=null,fish=d?.curFishData?.ID,area=d?.curFishData?.AreaID,item=d?.fishItem?.ID,subtype=d?.fishItem?.SubType,draw=d?.IsDrawing,caught=d?.IsCaughtFish,result=d?.fishingResult.ToString(),random=d?.random,weight=d?.fishWeight,weightBonus=d?.WeightIncEvent,tension=Field(sys,"_linePower"),rewardConfirmed=Field(a,"Confirmed"),rewardPresented=Field(a,"Presented"),rewardCancelled=Field(a,"Cancelled"),state=KVGameplayLoopSetup.State()};

View File

@@ -0,0 +1,5 @@
using asap.core;
using System.Linq;
var p=GContext.container.Resolve<GameCore.PlayerData>();
var guide=GContext.container.Resolve<GameCore.GuideDataCenter>();
return new{utc=System.DateTime.UtcNow,server=ZZTimeHelper.UtcNow(),energy=(int)p.Energy,energyRM=p.GetEnergyRM(),gold=(ulong)p.gold,frequency=p._fishingFrequency,core=p.PlayerDataDic["newData"],map=(int)p.lastMapId,mag=p.GetMagnification(),guideNode=guide.curDefine?.panelName,guideGroup=guide.curGroupDefine?.groupName.ToString(),controller=UnityEngine.Object.FindObjectOfType<FishingUpdateSys>()!=null,state=KVGameplayLoopSetup.State()};

View File

@@ -0,0 +1,3 @@
var reward=await KVGameplayLoopSetup.RunValidationAsync("fishing-reward");
var consume=await KVGameplayLoopSetup.RunValidationAsync("fishing-consume");
return new {reward,consume,state=KVGameplayLoopSetup.State()};

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings><Nullable>disable</Nullable>
<RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVGameSessionBridge.cs" Link="Production/PlayerKVGameSessionBridge.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs" Link="Production/PlayerKVModels.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs" Link="Production/PlayerKVClient.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs" Link="Production/PlayerKVSyncModels.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs" Link="Production/PlayerKVCacheStore.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs" Link="Production/PlayerKVSyncState.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs" Link="Production/PlayerKVSyncCoordinator.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSessionOwner.cs" Link="Production/PlayerKVSessionOwner.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVLocalReadResult.cs" Link="Production/PlayerKVLocalReadResult.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/Account/S101AccountSession.cs" Link="Production/S101AccountSession.cs" />
<Reference Include="Newtonsoft.Json"><HintPath>../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath></Reference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,380 @@
using System.Net;
using System.Reflection;
using System.Text;
using game.Account;
using game.PlayerDataStorage;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
static class Tests
{
static readonly Uri Api = new("http://synthetic.invalid/");
static readonly string RunId = DateTime.UtcNow.ToString("yyyyMMddHHmmss") + "_" + Guid.NewGuid().ToString("N");
static readonly string Root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../runs", RunId));
static readonly List<object> Results = new();
static int assertions, failures;
static async Task<int> Main()
{
Directory.CreateDirectory(Root);
await Run("A1-ready-merged-detached-snapshot", ReadyRead);
await Run("A2-validation-cancellation-and-reopen", RejectAndReopen);
await Run("A3-initial-read-failure-releases-slot", InitialFailure);
await Run("A3-busy-recovery-storage-errors", OpenErrors);
await Run("A3-conflict-unknown-no-empty-save", NonReady);
await Run("A3-close-failure-blocks-reopen", CloseFailure);
await Run("A3-create-cleanup-failure-blocks-reopen", CreateCleanupFailure);
await Run("A4-A6-A7-close-drain-queue-cancellation-stale-handle", LateClose);
await Run("A5-repeat-open-close-single-owner", Repeat);
await Run("A8-read-during-flush-preserves-batch", ReadDuringFlush);
await Run("A8-epoch-invalidated-after-read", ReadEpoch);
await Run("A8-stale-priority-over-not-ready", NonReadyPriority);
await Run("A8-read-close-reentry-drains-lease", ReadCloseReentry);
await Run("A3-write-unknown-retains-ticket-semantics", WriteUnknown);
await Run("A9-login-session-dispose-or-relogin", SessionInvalidation);
await Run("A10-pending-reopen-no-implicit-flush", PendingReopen);
await Run("A11-default-transport-configuration", DefaultTransport);
await Run("A2-close-during-initial-get", CloseDuringCreate);
var report = new { runId = RunId, failures, assertions, accessedFormalServer = false, tests = Results };
var json = JsonConvert.SerializeObject(report, Formatting.Indented);
foreach (var forbidden in new[] { "secret-fixture", "fixture-key", "sid=", "s101.local", @"C:\Users\" })
Check(!json.Contains(forbidden, StringComparison.OrdinalIgnoreCase), "sanitized");
await File.WriteAllTextAsync(Path.Combine(Root, "result.json"), json);
Console.WriteLine($"RESULT tests={Results.Count} failed={failures} assertions={assertions} run={RunId}");
return failures == 0 ? 0 : 1;
}
static async Task Run(string name, Func<Task> test)
{
var before = assertions;
try { await test(); Results.Add(new { name, passed = true, assertions = assertions - before }); Console.WriteLine("PASS " + name); }
catch (Exception ex) { failures++; Results.Add(new { name, passed = false, error = ex.GetType().Name }); Console.WriteLine("FAIL " + name + " " + ex); }
}
static void Check(bool value, string label) { assertions++; if (!value) throw new InvalidOperationException(label); }
static string Cache() { var path = Path.Combine(Root, "cache", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); return path; }
static TaskCompletionSource<bool> Gate() => new(TaskCreationOptions.RunContinuationsAsynchronously);
static HttpResponseMessage Json(HttpStatusCode status, string body) => new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
sealed class Handler : HttpMessageHandler
{
readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> send;
readonly Action dispose;
internal Handler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> send, Action dispose = null)
{ this.send = send; this.dispose = dispose; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) => send(request, token);
protected override void Dispose(bool disposing) { if (disposing) dispose?.Invoke(); base.Dispose(disposing); }
}
static async Task<S101AccountSession> Login(string id)
{
var s = new S101AccountSession(Api, jar => new Handler((r, _) =>
{
Check(r.RequestUri.Host == Api.Host && r.RequestUri.AbsolutePath == "/account/Login", "synthetic login only");
jar.Add(Api, new Cookie("sid", id));
return Task.FromResult(Json(HttpStatusCode.OK,
"{\"state\":0,\"internalId\":1,\"newCreate\":false,\"createTime\":\"2026-01-01T00:00:00Z\",\"svrTime\":\"2026-01-01T00:00:00Z\"}"));
}));
Check((await s.LoginAsync(id)).Success, "synthetic login");
return s;
}
sealed class Fake
{
internal readonly Dictionary<string, Dictionary<string, string>> Remote = new() { ["101"] = new(), ["102"] = new() };
internal int Gets, Writes, Active, MaxActive, Factories;
internal bool FailGet, FailWrite, ThrowDispose;
internal Func<string, HttpRequestMessage, CancellationToken, Task> Before;
internal PlayerKVGameSessionBridge Bridge => new(Create);
internal HttpMessageHandler Create(CookieContainer jar)
{
Factories++; Active++; MaxActive = Math.Max(MaxActive, Active);
var id = jar.GetCookies(Api)["sid"]?.Value;
Check(id is "101" or "102", "fixed synthetic cookie");
return new Handler(async (r, ct) =>
{
Check(r.Method == HttpMethod.Post && r.RequestUri.Host == Api.Host && r.RequestUri.Port == 80, "no formal transport");
var path = r.RequestUri.AbsolutePath;
Check(path is "/playerdata/Get" or "/playerdata/Set" or "/playerdata/Delete", "KV path");
if (path.EndsWith("/Get")) Gets++; else Writes++;
if (Before != null) await Before(id, r, ct);
if ((path.EndsWith("/Get") && FailGet) || (!path.EndsWith("/Get") && FailWrite))
return Json(HttpStatusCode.InternalServerError, "{}");
var body = JObject.Parse(await r.Content.ReadAsStringAsync());
var remote = Remote[id];
if (path.EndsWith("/Get")) return Json(HttpStatusCode.OK, new JObject { ["data"] = JObject.FromObject(remote) }.ToString());
if (path.EndsWith("/Set")) foreach (var p in ((JObject)body["data"]).Properties()) remote[p.Name] = (string)p.Value;
else foreach (var key in body["keys"].Values<string>()) remote.Remove(key);
return Json(HttpStatusCode.OK, "{}");
}, () => { Active--; if (ThrowDispose) throw new IOException("synthetic dispose fault"); });
}
}
static Task<PlayerKVGameOpenResult> Open(PlayerKVGameSessionBridge b, S101AccountSession s, string root,
long epoch = 1, Func<long, bool> current = null, CancellationToken token = default) =>
b.OpenAsync(s, root, s.PlayerId, Api, epoch, current ?? (_ => true), token);
static PlayerKVGameSessionHandle Good(PlayerKVGameOpenResult r) { Check(r.Success && r.Handle != null, "open succeeded"); return r.Handle; }
static async Task ReadyRead()
{
using var s = await Login("101"); var f = new Fake(); f.Remote["101"]["fixture-key-base"] = "secret-fixture-base";
var b = f.Bridge; var h = Good(await Open(b, s, Cache()));
try
{
Check(h.Update("fixture-key-base", "secret-fixture-pending").Accepted, "pending");
Check(h.Update("fixture-key-base", "secret-fixture-overlay", false).Accepted, "local overlay");
var read = h.ReadLocalSnapshot();
Check(read.Success && read.Data["fixture-key-base"] == "secret-fixture-overlay" && read.OwnerInstanceId == h.OwnerInstanceId &&
read.PlayerId == "101" && read.Epoch == h.Epoch, "merged identity");
var rejected = false;
try { ((IDictionary<string, string>)read.Data).Add("fixture-key-injection", "secret-fixture"); }
catch (NotSupportedException) { rejected = true; }
Check(rejected, "immutable dictionary");
h.Update("fixture-key-base", "secret-fixture-new");
Check(read.Data["fixture-key-base"] == "secret-fixture-overlay" &&
h.ReadLocalSnapshot().Data["fixture-key-base"] == "secret-fixture-new", "detached");
h.Delete("fixture-key-base");
Check(!h.ReadLocalSnapshot().Data.ContainsKey("fixture-key-base") && f.Writes == 0, "delete overlay; no implicit send");
}
finally { await b.CloseAsync(); }
Check(s.IsAuthenticated, "bridge does not own login");
}
static async Task RejectAndReopen()
{
using var s = await Login("101"); var f = new Fake(); var b = f.Bridge; var root = Cache();
using var unauth = new S101AccountSession(Api);
Check((await b.OpenAsync(unauth, root, "101", Api, 1, _ => true)).OwnerError == PlayerKVSessionOpenError.Unauthenticated, "unauth");
Check((await b.OpenAsync(s, root, "102", Api, 1, _ => true)).OwnerError == PlayerKVSessionOpenError.Stale, "id mismatch");
Check((await b.OpenAsync(s, root, "101", new Uri("http://other.invalid/"), 1, _ => true)).OwnerError == PlayerKVSessionOpenError.Stale, "API mismatch");
using var cancel = new CancellationTokenSource(); cancel.Cancel();
Check(!(await Open(b, s, root, token: cancel.Token)).Success && f.Factories == 0 && f.Writes == 0, "pre-cancel no handler");
Good(await Open(b, s, root)); await b.CloseAsync();
}
static async Task InitialFailure()
{
using var s = await Login("101"); var f = new Fake { FailGet = true }; var b = f.Bridge; var root = Cache();
Check((await Open(b, s, root)).OwnerError == PlayerKVSessionOpenError.ReadFailed && f.Active == 0, "initial failure drained");
f.FailGet = false; Good(await Open(b, s, root)); await b.CloseAsync();
}
static async Task OpenErrors()
{
using var s = await Login("101"); var f = new Fake(); var root = Cache();
var first = f.Bridge; Good(await Open(first, s, root));
var b = f.Bridge;
Check((await Open(b, s, root)).OwnerError == PlayerKVSessionOpenError.Busy, "busy");
await first.CloseAsync(); Good(await Open(b, s, root)); await b.CloseAsync();
var broken = Cache();
var setup = PlayerKVSyncState.Open(broken, Api, "101"); Check(setup.Success, "corrupt setup");
setup.State.Reconcile(new Dictionary<string, string>()); setup.State.Dispose();
File.WriteAllText(Directory.GetFiles(broken, "state.json", SearchOption.AllDirectories).Single(), "{");
Check((await Open(b, s, broken)).OwnerError == PlayerKVSessionOpenError.RecoveryRequired, "corrupt no snapshot");
var path = Path.Combine(Cache(), "file"); File.WriteAllText(path, "synthetic");
Check((await Open(b, s, path)).OwnerError == PlayerKVSessionOpenError.StorageError, "storage error no snapshot");
Good(await Open(b, s, Cache())); await b.CloseAsync();
}
static void Seed(string root, bool unknown)
{
var state = PlayerKVSyncState.Open(root, Api, "101"); Check(state.Success, "seed");
using (state.State)
{
state.State.Reconcile(new Dictionary<string, string> { ["fixture-key"] = "secret-fixture-base" });
state.State.Update("fixture-key", "secret-fixture-pending");
if (unknown)
{
state.State.BeginBatch(long.MaxValue, out var batch);
state.State.FailBatch(batch.Id, true);
}
}
}
static async Task NonReady()
{
using var s = await Login("101");
foreach (var unknown in new[] { false, true })
{
var root = Cache(); Seed(root, unknown);
var f = new Fake(); f.Remote["101"]["fixture-key"] = "secret-fixture-other"; var b = f.Bridge;
var h = Good(await Open(b, s, root));
Check(h.Status == (unknown ? PlayerKVSyncStatus.UnknownOutcome : PlayerKVSyncStatus.Conflict), "non-ready preserved");
var read = h.ReadLocalSnapshot();
Check(!read.Success && read.Error == PlayerKVLocalReadError.NotReady && read.Data == null, "no pretend empty save");
await b.CloseAsync();
}
var fake = new Fake(); var bridge = fake.Bridge; var cache = Cache();
var handle = Good(await Open(bridge, s, cache));
var main = Directory.GetFiles(cache, "state.json", SearchOption.AllDirectories).Single();
Directory.CreateDirectory(main + ".tmp");
Check(!handle.Update("fixture-key", "secret-fixture").Accepted && handle.Status == PlayerKVSyncStatus.StorageError, "disk fault");
Check(handle.ReadLocalSnapshot().Error == PlayerKVLocalReadError.NotReady, "storage refuses read");
await bridge.CloseAsync();
}
static async Task CloseFailure()
{
using var s = await Login("101"); var f = new Fake { ThrowDispose = true }; var b = f.Bridge; var root = Cache();
var h = Good(await Open(b, s, root)); var closing = b.CloseAsync();
try { await closing; Check(false, "close should fault"); } catch (IOException) { }
Check(ReferenceEquals(closing, b.CloseAsync()) && h.ReadLocalSnapshot().Error == PlayerKVLocalReadError.StaleOrClosing, "failed close retained");
Check((await Open(b, s, root)).Error == PlayerKVGameOpenError.CloseFailed && f.Factories == 1, "failed close blocks open");
var probe = PlayerKVSyncState.Open(root, Api, "101");
Check(!probe.Success && probe.Error == PlayerKVOpenError.Busy, "failed close does not pretend released");
}
static async Task CreateCleanupFailure()
{
using var s = await Login("101"); var f = new Fake { FailGet = true, ThrowDispose = true }; var b = f.Bridge; var root = Cache();
Check((await Open(b, s, root)).Error == PlayerKVGameOpenError.CloseFailed, "create cleanup failure");
f.FailGet = false;
Check((await Open(b, s, root)).Error == PlayerKVGameOpenError.CloseFailed && f.Factories == 1, "create failure latch");
}
static async Task LateClose()
{
using var s = await Login("101"); using var s2 = await Login("102");
var f = new Fake(); var entered = Gate(); var release = Gate(); var cancellation = Gate();
f.Before = async (id, r, token) =>
{
if (id == "101" && r.RequestUri.AbsolutePath.EndsWith("/Set"))
{ using var reg = token.Register(() => cancellation.TrySetResult(true)); entered.TrySetResult(true); await release.Task; }
};
var b = f.Bridge; var root = Cache(); var old = Good(await Open(b, s, root));
var edit = old.Update("fixture-key", "secret-fixture");
var flush = old.FlushAsync(); await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var close = b.CloseAsync(); Check(ReferenceEquals(close, b.CloseAsync()), "idempotent close");
using var queuedCancel = new CancellationTokenSource();
var cancelledOpen = Open(b, s2, root, 2, token: queuedCancel.Token);
queuedCancel.Cancel();
Check((await cancelledOpen.WaitAsync(TimeSpan.FromSeconds(5))).Error == PlayerKVGameOpenError.Cancelled, "queued cancel returns promptly");
var next = Open(b, s2, root, 3);
try
{
await cancellation.Task.WaitAsync(TimeSpan.FromSeconds(5));
Check(!close.IsCompleted && !next.IsCompleted && f.Factories == 1, "drain barrier");
Check(!old.Update("fixture-key", "secret-fixture-late").Accepted &&
old.ReadLocalSnapshot().Error == PlayerKVLocalReadError.StaleOrClosing, "revoked synchronously");
}
finally { release.TrySetResult(true); }
await close; await flush;
var current = Good(await next);
var writes = f.Writes;
Check(!old.Update("fixture-key", "secret-fixture-late").Accepted && !old.Delete("fixture-key").Accepted, "old edits refused");
Check((await old.FlushAsync()).Blocked > 0 && (await old.WaitForTicketAsync(edit.Ticket)).Status == PlayerKVTicketStatus.Blocked, "old async calls refused");
Check(current.PlayerId == "102" && current.ReadLocalSnapshot().Data.Count == 0 && f.Writes == writes, "102 unaffected");
Check(f.MaxActive == 1, "single owner transport");
await b.CloseAsync();
}
static async Task Repeat()
{
using var s = await Login("101"); var f = new Fake(); var b = f.Bridge; var root = Cache();
var a = Open(b, s, root); var c = Open(b, s, root);
var both = await Task.WhenAll(a, c);
var old = Good(both[0]); var latest = Good(both[1]);
Check(old.OwnerInstanceId != latest.OwnerInstanceId && !old.ReadLocalSnapshot().Success &&
latest.ReadLocalSnapshot().Success && f.MaxActive == 1, "repeat open serialized");
var x = b.CloseAsync(); var y = b.CloseAsync(); Check(ReferenceEquals(x, y), "same close task"); await x;
}
static async Task ReadDuringFlush()
{
using var s = await Login("101"); var f = new Fake(); f.Remote["101"]["fixture-key-base"] = "secret-fixture-base";
var entered = Gate(); var release = Gate();
f.Before = async (_, r, _) => { if (r.RequestUri.AbsolutePath.EndsWith("/Set")) { entered.TrySetResult(true); await release.Task; } };
var b = f.Bridge; var h = Good(await Open(b, s, Cache()));
h.Update("fixture-key", "secret-fixture-pending");
var flush = h.FlushAsync(); await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
try
{
h.Update("fixture-key", "secret-fixture-overlay", false);
var writes = f.Writes;
var read = h.ReadLocalSnapshot();
Check(read.Success && read.Data["fixture-key"] == "secret-fixture-overlay" &&
read.Data["fixture-key-base"] == "secret-fixture-base", "inflight merged view");
Check(f.Writes == writes && !flush.IsCompleted, "read does not dispatch or finish batch");
}
finally { release.TrySetResult(true); }
Check((await flush).Confirmed == 1, "original batch confirmed");
Check(f.Remote["101"]["fixture-key"] == "secret-fixture-pending" &&
h.ReadLocalSnapshot().Data["fixture-key"] == "secret-fixture-overlay", "read never altered batch");
await b.CloseAsync();
}
static async Task ReadEpoch()
{
using var s = await Login("101"); var f = new Fake(); var b = f.Bridge;
var armed = false; var checks = 0;
var h = Good(await Open(b, s, Cache(), current: _ => !armed || ++checks < 2));
armed = true;
Check(h.ReadLocalSnapshot().Error == PlayerKVLocalReadError.StaleOrClosing && checks == 2, "read revalidates after snapshot");
await b.CloseAsync();
}
static async Task NonReadyPriority()
{
using var s = await Login("101"); var f = new Fake(); var b = f.Bridge; var root = Cache(); Seed(root, true);
var armed = false; var checks = 0;
var h = Good(await Open(b, s, root, current: _ => !armed || ++checks < 2));
armed = true;
Check(h.ReadLocalSnapshot().Error == PlayerKVLocalReadError.StaleOrClosing && checks == 2, "stale outranks NotReady exception");
await b.CloseAsync();
}
static async Task ReadCloseReentry()
{
using var s = await Login("101"); var f = new Fake(); var b = f.Bridge;
var armed = false; var checks = 0; Task close = null;
var h = Good(await Open(b, s, Cache(), current: _ =>
{
if (armed && ++checks == 2)
{
close = b.CloseAsync();
Check(!close.IsCompleted, "close must wait active read lease");
}
return true;
}));
armed = true;
Check(h.ReadLocalSnapshot().Error == PlayerKVLocalReadError.StaleOrClosing, "read cannot publish after reentrant close");
Check(close != null, "close boundary exercised"); await close;
Check(f.Active == 0 && !h.ReadLocalSnapshot().Success, "read lease released for close");
}
static async Task WriteUnknown()
{
using var s = await Login("101"); var f = new Fake { FailWrite = true }; var b = f.Bridge;
var h = Good(await Open(b, s, Cache()));
var edit = h.Update("fixture-key", "secret-fixture");
Check((await h.FlushAsync()).Unknown == 1, "structured unknown");
Check((await h.WaitForTicketAsync(edit.Ticket)).Status == PlayerKVTicketStatus.Unknown, "unknown ticket preserved");
Check(h.ReadLocalSnapshot().Error == PlayerKVLocalReadError.NotReady &&
h.ReadLocalSnapshot().Data == null, "unknown cannot masquerade as empty");
await h.FlushAsync(); Check(f.Writes == 1, "no unknown retry");
await b.CloseAsync();
}
static async Task SessionInvalidation()
{
foreach (var dispose in new[] { false, true })
{
using var s = await Login("101"); var f = new Fake(); var b = f.Bridge;
var h = Good(await Open(b, s, Cache())); var edit = h.Update("fixture-key", "secret-fixture");
if (dispose) s.Dispose(); else Check((await s.LoginAsync("101")).Success, "replace cookie by relogin");
Check(!h.ReadLocalSnapshot().Success && !h.Update("fixture-key", "secret-fixture-late").Accepted, "session invalidates handle");
Check((await h.WaitForTicketAsync(edit.Ticket)).Status == PlayerKVTicketStatus.Blocked, "invalid session ticket denied");
await b.CloseAsync(); Check(f.Writes == 0 && f.Active == 0, "invalid session drains without writes");
}
}
static async Task PendingReopen()
{
using var s = await Login("101"); var f = new Fake(); var b = f.Bridge; var root = Cache();
var h = Good(await Open(b, s, root)); h.Update("fixture-key", "secret-fixture-pending");
await b.CloseAsync(); Check(f.Writes == 0, "close never flushes");
using var next = await Login("101"); var reopened = Good(await Open(b, next, root, 2));
Check(reopened.ReadLocalSnapshot().Data["fixture-key"] == "secret-fixture-pending", "pending recovered");
Check((await reopened.FlushAsync()).Confirmed == 1, "recovered intent confirmed");
await b.CloseAsync();
}
static Task DefaultTransport()
{
var b = new PlayerKVGameSessionBridge();
var factory = (Func<CookieContainer, HttpMessageHandler>)typeof(PlayerKVGameSessionBridge)
.GetField("handlerFactory", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(b);
var jar = new CookieContainer();
using var h = (HttpClientHandler)factory(jar); // Inspect configuration only, never send.
Check(h.UseCookies && ReferenceEquals(h.CookieContainer, jar) && !h.AllowAutoRedirect, "production transport configuration");
return b.CloseAsync();
}
static async Task CloseDuringCreate()
{
using var s = await Login("101"); var f = new Fake(); var enter = Gate(); var release = Gate();
f.Before = async (_, _, _) => { enter.TrySetResult(true); await release.Task; };
var b = f.Bridge; var root = Cache(); var opening = Open(b, s, root);
await enter.Task.WaitAsync(TimeSpan.FromSeconds(5));
var close = b.CloseAsync();
try { Check(!opening.IsCompleted && !close.IsCompleted, "create drain cannot be detached"); }
finally { release.TrySetResult(true); }
Check(!(await opening).Success, "revoked initialization never published"); await close;
f.Before = null; Good(await Open(b, s, root)); await b.CloseAsync();
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>

View File

@@ -0,0 +1,141 @@
using game.PlayerDataStorage;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
static partial class Tests
{
static async Task RunActivitySchemaTests()
{
await Run("M15-activity-all-saved-fields-strict",() =>
{
var obj=JObject.FromObject(new { FirstOpen=true,EventId=11,RankTierLevel=11,Finished=11,RedirectID=11,Score=11,ProcessedScore=11,step=11,uiStep=11,index=11,StartTime="2026-09-23T00:00:00.0000000Z",EndTime="2026-09-23T00:00:00.0000000Z",eliminateNumber=new[] { 7,9 },robotAccountList=new[] { 7,9 },InflationRate=1.5f });
Dictionary<string,string> Raw(JObject value)=>new() { ["FishingChallengeData"]=value.ToString(Formatting.None) };
var state=PlayerKVSaveSchema.DecodeChallenge(Raw(obj),SchemaSnapshot(),new());
Check(state!=null && state.IsPresent && !state.IsExplicitNull,"valid complete object, not explicit null");
Check(state.FirstOpen==true,"typed FirstOpen");
Check(state.EventId==11,"typed EventId");
Check(state.RankTierLevel==11,"typed RankTierLevel");
Check(state.Finished==11,"typed Finished");
Check(state.RedirectID==11,"typed RedirectID");
Check(state.Score==11,"typed Score");
Check(state.ProcessedScore==11,"typed ProcessedScore");
Check(state.step==11,"typed step");
Check(state.uiStep==11,"typed uiStep");
Check(state.index==11,"typed index");
Check(state.StartTime==new DateTime(2026,9,23,0,0,0,DateTimeKind.Utc),"typed StartTime");
Check(state.EndTime==new DateTime(2026,9,23,0,0,0,DateTimeKind.Utc),"typed EndTime");
Check(state.eliminateNumber.SequenceEqual(new[] { 7,9 }),"typed eliminateNumber");
Check(state.robotAccountList.SequenceEqual(new[] { 7,9 }),"typed robotAccountList");
Check(state.InflationRate==1.5f,"typed InflationRate");
foreach(var property in obj.Properties())
{
var bad=(JObject)obj.DeepClone(); bad.Remove(property.Name);
Check(PlayerKVSaveSchema.DecodeChallenge(Raw(bad),SchemaSnapshot(),new())==null,"missing saved field rejects");
bad=(JObject)obj.DeepClone(); bad[property.Name]=JValue.CreateNull();
Check(PlayerKVSaveSchema.DecodeChallenge(Raw(bad),SchemaSnapshot(),new())==null,"null saved field rejects");
}
var missing = PlayerKVSaveSchema.DecodeChallenge(new Dictionary<string,string>(),SchemaSnapshot(),new());
Check(!missing.IsPresent && !missing.IsExplicitNull,"missing separate");
var empty = PlayerKVSaveSchema.DecodeChallenge(new Dictionary<string,string> { ["FishingChallengeData"]="null" },SchemaSnapshot(),new());
Check(empty != null && empty.IsPresent && empty.IsExplicitNull,"explicit null is a present inactive challenge");
return Task.CompletedTask;
});
await Run("M15-explicit-null-raw-and-strict-boundaries", async () =>
{
foreach (var text in new[] { "null", " \r\n\tnull \t" })
{
var raw = ModernRaw();
raw["FishingChallengeData"] = text;
raw["outside-null-compat-probe"] = " untouched \r\n";
var original = new Dictionary<string,string>(raw);
var result = Aggregate(raw);
Check(result.Verdict == PlayerKVSchemaVerdict.Valid, "explicit null aggregate valid");
Check(result.Data.Module<PlayerKVChallengeState>(15).IsExplicitNull, "aggregate preserves explicit null marker");
Check(result.Data.Raw.Count == original.Count, "raw key count unchanged");
foreach (var pair in original)
{
Check(System.Text.Encoding.UTF8.GetBytes(result.Data.Raw[pair.Key]).SequenceEqual(
System.Text.Encoding.UTF8.GetBytes(pair.Value)), "raw UTF8 bytes preserved including null whitespace");
Check(raw[pair.Key] == pair.Value, "decode did not edit input");
}
raw["FishingChallengeData"] = "changed-after-decode";
Check(result.Data.Raw["FishingChallengeData"] == text, "decoded raw is a separate copy");
}
Check(!Aggregate(ModernRaw()).Data.Raw.ContainsKey("FishingChallengeData"), "missing raw stays missing");
foreach (var text in new[] { null, "", " \t", "\"null\"", "0", "true", "[]", "[null]", "{", "null null",
"\vnull\v", "\fnull\f", "\u0085null", "\u00a0null\u00a0", "\ufeffnull", "/*x*/null", "null//x", "null,", "null,\"x\":1", "null}" })
{
var errors = new PlayerKVSchemaDiagnostics();
var state = PlayerKVSaveSchema.DecodeChallenge(new Dictionary<string,string> { ["FishingChallengeData"] = text }, SchemaSnapshot(), errors);
Check(state == null && errors.Count(15, PlayerKVSchemaError.Malformed) == 1, "only strict JSON null or complete object accepted");
}
await Throws<PlayerKVSchemaException>(() => { PlayerKVSaveSchema.Json("null"); return Task.CompletedTask; });
await Throws<ArgumentException>(() =>
{
_ = new PlayerKVChallengeState(false,false,0,0,0,0,0,0,0,0,0,default,default,new List<int>(),new List<int>(),0,true);
return Task.CompletedTask;
});
});
await Run("M26-activity-all-saved-fields-strict",() =>
{
var obj=JObject.FromObject(new { EventID=11,PinballMainID=11,SpinPoint=11,Scores=11,Multiple=11,Token=11,ConsumedToken=11,RewardIndex=11,Force=1.5f,LuckyValue=1.5f,InflationRate=1.5f });
Dictionary<string,string> Raw(JObject value)=>new() { ["epbdk"]=value.ToString(Formatting.None) };
var state=PlayerKVSaveSchema.DecodePinball(Raw(obj),SchemaSnapshot(),new());
Check(state!=null && state.IsPresent,"valid complete state");
Check(state.EventID==11,"typed EventID");
Check(state.PinballMainID==11,"typed PinballMainID");
Check(state.SpinPoint==11,"typed SpinPoint");
Check(state.Scores==11,"typed Scores");
Check(state.Multiple==11,"typed Multiple");
Check(state.Token==11,"typed Token");
Check(state.ConsumedToken==11,"typed ConsumedToken");
Check(state.RewardIndex==11,"typed RewardIndex");
Check(state.Force==1.5f,"typed Force");
Check(state.LuckyValue==1.5f,"typed LuckyValue");
Check(state.InflationRate==1.5f,"typed InflationRate");
foreach(var property in obj.Properties())
{
var bad=(JObject)obj.DeepClone(); bad.Remove(property.Name);
Check(PlayerKVSaveSchema.DecodePinball(Raw(bad),SchemaSnapshot(),new())==null,"missing saved field rejects");
bad=(JObject)obj.DeepClone(); bad[property.Name]=JValue.CreateNull();
Check(PlayerKVSaveSchema.DecodePinball(Raw(bad),SchemaSnapshot(),new())==null,"null saved field rejects");
}
Check(!PlayerKVSaveSchema.DecodePinball(new Dictionary<string,string>(),SchemaSnapshot(),new()).IsPresent,"missing separate");
Check(PlayerKVSaveSchema.DecodePinball(new Dictionary<string,string> { ["epbdk"]="null" },SchemaSnapshot(),new())==null,"null not missing");
return Task.CompletedTask;
});
await Run("M27-activity-all-saved-fields-strict",() =>
{
var obj=JObject.FromObject(new { EventID=11,ScratchMainID=11,ChainPackID=11,Token=11,ConsumedToken=11,RewardTaskConsumedToken=11,EventScratchRewardTaskID=11,EventScratchCardID=11,TotalConsumedToken=11,ChainPackProgress=11,magIndex=11,IsOver=true,LuckyValue=1.5f,InflationRate=1.5f,StampRewardList=new[] { 7,9 },StampRewardDict=new Dictionary<int,object> { [7]=new { EventScratchCardID=8,Count=9 } } });
Dictionary<string,string> Raw(JObject value)=>new() { ["estdk"]=value.ToString(Formatting.None) };
var state=PlayerKVSaveSchema.DecodeScratch(Raw(obj),SchemaSnapshot(),new());
Check(state!=null && state.IsPresent,"valid complete state");
Check(state.EventID==11,"typed EventID");
Check(state.ScratchMainID==11,"typed ScratchMainID");
Check(state.ChainPackID==11,"typed ChainPackID");
Check(state.Token==11,"typed Token");
Check(state.ConsumedToken==11,"typed ConsumedToken");
Check(state.RewardTaskConsumedToken==11,"typed RewardTaskConsumedToken");
Check(state.EventScratchRewardTaskID==11,"typed EventScratchRewardTaskID");
Check(state.EventScratchCardID==11,"typed EventScratchCardID");
Check(state.TotalConsumedToken==11,"typed TotalConsumedToken");
Check(state.ChainPackProgress==11,"typed ChainPackProgress");
Check(state.magIndex==11,"typed magIndex");
Check(state.IsOver==true,"typed IsOver");
Check(state.LuckyValue==1.5f,"typed LuckyValue");
Check(state.InflationRate==1.5f,"typed InflationRate");
Check(state.StampRewardList.SequenceEqual(new[] { 7,9 }),"typed StampRewardList");
Check(state.StampRewardDict[7].EventScratchCardID==8 && state.StampRewardDict[7].Count==9,"typed StampRewardDict");
foreach(var property in obj.Properties())
{
var bad=(JObject)obj.DeepClone(); bad.Remove(property.Name);
Check(PlayerKVSaveSchema.DecodeScratch(Raw(bad),SchemaSnapshot(),new())==null,"missing saved field rejects");
bad=(JObject)obj.DeepClone(); bad[property.Name]=JValue.CreateNull();
Check(PlayerKVSaveSchema.DecodeScratch(Raw(bad),SchemaSnapshot(),new())==null,"null saved field rejects");
}
Check(!PlayerKVSaveSchema.DecodeScratch(new Dictionary<string,string>(),SchemaSnapshot(),new()).IsPresent,"missing separate");
Check(PlayerKVSaveSchema.DecodeScratch(new Dictionary<string,string> { ["estdk"]="null" },SchemaSnapshot(),new())==null,"null not missing");
return Task.CompletedTask;
});
}
}

View File

@@ -0,0 +1,83 @@
using game.PlayerDataStorage;
static partial class Tests
{
static PlayerKVSchemaResult Aggregate(Dictionary<string,string> raw=null,bool fresh=false,bool current=true,string version="synthetic-v1")
{
var prepared=new PlayerKVLoginPrepareResult(PlayerKVLoginPrepareError.None,"101",7,data:raw ?? ModernRaw());
var account=new PlayerKVAccountDescriptor("101",7,17,fresh,SchemaSnapshot().NowUtc,SchemaSnapshot().NowUtc);
return PlayerKVSaveSchema.Decode(prepared,account,"101",7,current,SchemaSnapshot(),version,Identity());
}
static async Task RunAggregateTests()
{
await Run("transaction-every-reset-replace-revocation-boundary",()=>
{
foreach(int boundary in Enumerable.Range(1,31)) foreach(int mode in new[]{0,1,2,3})
{
bool current=true;int notified=0;var values=new int[31];
var modules=Enumerable.Range(1,31).Select(id=>(IPlayerKVLoadModule)new PlayerKVLoadModule(id,
()=>{values[id-1]=0;if(id==boundary && mode==0)throw new Exception();if(id==boundary && mode==1)current=false;},
_=>{values[id-1]=id;if(id==boundary && mode==2)current=false;if(id==boundary && mode==3)throw new Exception();}));
var transaction=new PlayerKVGameLoadTransaction(modules,Environment.CurrentManagedThreadId,()=>{});
var result=transaction.Apply(Aggregate().Data,()=>current,()=>true,()=>notified++);
Check(result==(mode==0?PlayerKVApplyError.Poisoned:mode==3?PlayerKVApplyError.Apply:PlayerKVApplyError.Stale),"failure classification at every module");
Check(notified==0 && values.All(v=>v==0) && transaction.AppliedModules==0,"no publication, all reset");
if(mode==0)Check(transaction.Apply(Aggregate().Data,()=>true,()=>true,()=>{})==PlayerKVApplyError.Poisoned,"failed reset cannot reuse container");
}
return Task.CompletedTask;
});
await Run("aggregate-31-source-priority-no-partial-state",()=>
{
var raw=ModernRaw(); raw["extension"]="uninterpreted";
var a=Aggregate(raw); Check(a.Verdict==PlayerKVSchemaVerdict.Valid && a.Data.ModuleCount==31,"all31 decoded");
Check(a.Data.UnconsumedCount==1 && a.Data.UnconsumedTopLevelCount==1,"unknown top-level retained and counted");
var extended=ModernRaw(); var inner=Newtonsoft.Json.JsonConvert.DeserializeObject<Dictionary<string,string>>(extended["NewPlayerDataDic"]);
inner["unknown"]="opaque"; extended["NewPlayerDataDic"]=Newtonsoft.Json.JsonConvert.SerializeObject(inner);extended["Map_not_a_number"]="opaque";
var counted=Aggregate(extended).Data;Check(counted.UnconsumedModernCount==1 && counted.UnconsumedTopLevelCount==1,"unknown modern and nonmatching map prefixes counted separately");
raw.Clear(); Check(a.Data.Raw.Count==2 && a.Data.Module<PlayerKVPlayerState>(1).Core==a.Data.Module<PlayerKVCoreState>(2),"source copied, consistent shared immutable core");
Check(Aggregate(new(),true).Verdict==PlayerKVSchemaVerdict.EmptyNew && Aggregate(new()).Verdict==PlayerKVSchemaVerdict.EmptyExisting,"S101 empty classification");
Check(Aggregate(new(),true,false).Verdict==PlayerKVSchemaVerdict.Stale,"stale before empty");
Check(Aggregate(version:"other").Verdict==PlayerKVSchemaVerdict.UnsupportedSchema,"version locked");
Check(Aggregate(new(){["FootPrint"]="1"}).Verdict==PlayerKVSchemaVerdict.MissingCore,"optional data not core");
raw=ModernRaw(); raw["EventPartnerGatherData"]="null"; raw["TapJoy_Diamond"]="bad";
var bad=Aggregate(raw); Check(bad.Data==null && bad.Diagnostics.Count(18,PlayerKVSchemaError.Malformed)==1 && bad.Diagnostics.Count(31,PlayerKVSchemaError.Malformed)==1,"last module visited despite earlier optional failure");
raw["NewPlayerDataDic"]="null"; Check(Aggregate(raw).Verdict==PlayerKVSchemaVerdict.Malformed,"outer parse failure");
return Task.CompletedTask;
});
await Run("transaction-all-reset-before-replace-failure-clears",()=>
{
var order=new List<string>(); var values=new int[31]; int failReplace=0,failReset=0,discard=0,notify=0;
var modules=Enumerable.Range(1,31).Select(id=>(IPlayerKVLoadModule)new PlayerKVLoadModule(id,
()=>{order.Add("R"+id); values[id-1]=0; if(failReset==id) throw new Exception();},
_=>{order.Add("A"+id); values[id-1]=id; if(failReplace==id) throw new Exception();})).ToArray();
var transaction=new PlayerKVGameLoadTransaction(modules,Environment.CurrentManagedThreadId,()=>discard++);
Check(transaction.Apply(Aggregate().Data,()=>true,()=>true,()=>notify++)==PlayerKVApplyError.None,"apply succeeded");
Check(order.Take(31).SequenceEqual(Enumerable.Range(1,31).Select(id=>"R"+id)) && order.Skip(31).SequenceEqual(Enumerable.Range(1,31).Select(id=>"A"+id)),"exact reset-then-replace sequence");
Check(transaction.AppliedModules==31 && notify==1 && discard==0,"notification only complete");
foreach(var failed in Enumerable.Range(1,31))
{
failReplace=failed; order.Clear();
Check(transaction.Apply(Aggregate().Data,()=>true,()=>true,()=>notify++)==PlayerKVApplyError.Apply && values.All(v=>v==0),"partial materialization cleared");
Check(order.TakeLast(31).SequenceEqual(Enumerable.Range(1,31).Select(id=>"R"+id)),"all resets attempted after failure");
}
failReplace=0; failReset=8;
Check(transaction.Apply(Aggregate().Data,()=>true,()=>true,()=>notify++)==PlayerKVApplyError.Poisoned && transaction.Poisoned,"reset failure poisons");
failReset=0; Check(transaction.Apply(Aggregate().Data,()=>true,()=>true,()=>notify++)==PlayerKVApplyError.Poisoned,"poison cannot retry");
Check(notify==1,"no failure notifications");
return Task.CompletedTask;
});
await Run("transaction-notification-stale-sideeffect-and-main-thread",async()=>
{
bool current=true,clean=true; int resets=0;
var modules=Enumerable.Range(1,31).Select(id=>(IPlayerKVLoadModule)new PlayerKVLoadModule(id,()=>resets++,_=>{})).ToArray();
var transaction=new PlayerKVGameLoadTransaction(modules,Environment.CurrentManagedThreadId,()=>{});
Check(transaction.Apply(Aggregate().Data,()=>current,()=>clean,()=>current=false)==PlayerKVApplyError.Stale,"notification revocation");
current=true; Check(transaction.Apply(Aggregate().Data,()=>current,()=>clean,()=>clean=false)==PlayerKVApplyError.SideEffect,"notification sideeffect");
clean=true; Check(transaction.Apply(Aggregate().Data,()=>current,()=>clean,()=>throw new Exception())==PlayerKVApplyError.Apply,"receiver failure");
int before=resets;
var other=new PlayerKVGameLoadTransaction(modules,-1,()=>{});
Check(other.Apply(Aggregate().Data,()=>true,()=>true,()=>{})==PlayerKVApplyError.Input && resets==before,"wrong thread never mutates");
await Throws<ArgumentException>(()=>{new PlayerKVGameLoadTransaction(modules.Take(30),Environment.CurrentManagedThreadId,()=>{});return Task.CompletedTask;});
});
}
}

View File

@@ -0,0 +1,45 @@
using game.PlayerDataStorage;
using Newtonsoft.Json;
static partial class Tests
{
static async Task RunBattlePassSchemaTests()
{
await Run("M11-battle-pass-two-layouts-conflict-and-ticks",() =>
{
var raw=new Dictionary<string,string> { ["BattlePassRecordTask"]="1|2|3|4|1|5,6|7,8#9:10|11:12|13,14|15|16" };
var state=PlayerKVSaveSchema.DecodeBattlePass(raw,SchemaSnapshot(),new());
var r=state.Record; var t=state.Task;
Check(r.ID==1 && r.Level==2 && r.Exp==3 && r.BuyVipToastIndex==4 && r.IsUnlockVip==1 &&
r.Normal.SequenceEqual(new[] { 5,6 }) && r.Vip.SequenceEqual(new[] { 7,8 }) && r.LastDrops.Count==0 && r.Inflation==0,"all compact record fields");
Check(t.Daily[9]==10 && t.Weekly[11]==12 && t.LoginDays.SequenceEqual(new[] { 13,14 }) &&
t.DailyEnd.Ticks==15 && t.WeeklyEnd.Ticks==16,"all compact task fields");
raw["BattlePassRecord"]=JsonConvert.SerializeObject(new { ID=1,Level=2,Exp=3,BuyVipToastIndex=4,IsUnlockVip=1,
NormalRewardGet=new[] { 5,6 },VipRewardGet=new[] { 7,8 },LastDropIDs=new[] { 99 },InflationRate=2.5f });
raw["BattleTask"]=JsonConvert.SerializeObject(new { dic_dailyTasks=new Dictionary<int,int> { [9]=10 },
dic_weeklyTasks=new Dictionary<int,int> { [11]=12 },loginDays=new[] { 13,14 },DailyTaskEndTime=new DateTime(15),WeeklyTaskEndTime=new DateTime(16) });
state=PlayerKVSaveSchema.DecodeBattlePass(raw,SchemaSnapshot(),new());
Check(state!=null && state.Record.LastDrops.Count==0,"new whole record with absent old-only fields");
var legacy=new Dictionary<string,string>(raw); legacy.Remove("BattlePassRecordTask");
state=PlayerKVSaveSchema.DecodeBattlePass(legacy,SchemaSnapshot(),new());
Check(state.Record.LastDrops.SequenceEqual(new[] { 99 }) && state.Record.Inflation==2.5f,"old-only fields retained when old selected");
raw["BattlePassRecordTask"]="1|2|3|4|1|5,6|7,8#9:99|11:12|13,14|15|16";
var e=new PlayerKVSchemaDiagnostics();
Check(PlayerKVSaveSchema.DecodeBattlePass(raw,SchemaSnapshot(),e)==null && e.Count(11,PlayerKVSchemaError.ConflictingRepresentations)==1,"task conflict");
foreach(var text in new[] { "1|2|3|4|1||#|||0|0#", "1|2|3|4|1|5,|#|||0|0", "1|2|3|4|1||#1:2,01:3|||0|0",
"1|2|3|4|1||#|||-1|0", "1|2|3|4|1||#|||3155378976000000000|0", "null" })
Check(PlayerKVSaveSchema.DecodeBattlePass(new Dictionary<string,string> { ["BattlePassRecordTask"]=text },SchemaSnapshot(),new())==null,"malformed compact/ticks reject");
raw["BattlePassRecordTask"]="1|2|3|4|1|5,6|7,8#9:10|11:12|13,14|15|16";
foreach(var key in new[] { "BattlePassRecord","BattleTask" })
{
var bad=new Dictionary<string,string>(raw) { [key]="null" };
Check(PlayerKVSaveSchema.DecodeBattlePass(bad,SchemaSnapshot(),new())==null,"bad old not hidden by new");
}
state=PlayerKVSaveSchema.DecodeBattlePass(new Dictionary<string,string>(),SchemaSnapshot(),new());
Check(state.Record.ID==0 && state.Record.Level==1 && state.Record.Normal.Count==0 && state.Record.Vip.Count==0 &&
state.Record.LastDrops.Count==0 && state.Task.Daily.Count==0 && state.Task.Weekly.Count==0 && state.Task.LoginDays.Count==0 &&
state.Task.DailyEnd==default && state.Task.WeeklyEnd==default,"missing constructor state");
return Task.CompletedTask;
});
}
}

View File

@@ -0,0 +1,57 @@
using game.PlayerDataStorage;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
static partial class Tests
{
static async Task RunBuffTriggerSchemaTests()
{
await Run("M06-four-buff-shapes-strict-and-missing", () =>
{
const string value="{\"SortID\":1,\"sourceID\":2,\"buffID\":3,\"isEnd\":false,\"buffEndTime\":\"2026-09-24T00:00:00.0000000Z\"}";
var raw=new Dictionary<string,string> { ["BuffDataList"]="{\"7\":"+value+"}",["TargetBuffDataList"]="["+value+"]",
["FishDailyBuff"]=value,["GlobalBuffDataList"]="{\"7\":"+value+"}" };
var state=PlayerKVSaveSchema.DecodeBuffs(raw,SchemaSnapshot(),new());
foreach(var item in new[] { state.Map[7],state.Target[0],state.Daily,state.Global[7] })
Check(item.SortID==1 && item.sourceID==2 && item.buffID==3 && !item.isEnd && item.buffEndTime.Day==24,"all saved buff fields");
var absent=PlayerKVSaveSchema.DecodeBuffs(new Dictionary<string,string>(),SchemaSnapshot(),new());
Check(absent.Map.Count==0 && absent.Target.Count==0 && absent.Global.Count==0 && absent.Daily==null,"all missing defaults");
foreach(var key in raw.Keys)
foreach(var bad in new[] { "null","{\"7\":null}","[null]","{}" })
{
// Empty maps are valid only for map containers.
if(bad=="{}" && (key=="BuffDataList" || key=="GlobalBuffDataList")) continue;
Check(PlayerKVSaveSchema.DecodeBuffs(new Dictionary<string,string>(raw) { [key]=bad },SchemaSnapshot(),new())==null,"bad present container fails");
}
foreach(var property in JObject.Parse(value).Properties())
{
var bad=JObject.Parse(value); bad[property.Name]=JValue.CreateNull();
Check(PlayerKVSaveSchema.DecodeBuffs(new Dictionary<string,string> { ["FishDailyBuff"]=bad.ToString(Formatting.None) },SchemaSnapshot(),new())==null,"all required buff fields");
}
Check(PlayerKVSaveSchema.DecodeBuffs(new Dictionary<string,string> { ["BuffDataList"]="{\"7\":"+value+",\"07\":"+value+"}" },SchemaSnapshot(),new())==null,"normalized duplicate rejected");
var allBad=new Dictionary<string,string>(raw); foreach(var key in raw.Keys) allBad[key]="null";
var errors=new PlayerKVSchemaDiagnostics(); Check(PlayerKVSaveSchema.DecodeBuffs(allBad,SchemaSnapshot(),errors)==null && errors.TotalErrors==4,"all bad keys counted");
return Task.CompletedTask;
});
await Run("M13-trigger-gift-fixed-shapes", () =>
{
var raw=new Dictionary<string,string> {
["TriggerPackDataList"]="[{\"ID\":1,\"index\":2,\"lastID\":3,\"vipLevel\":4,\"mapID\":5,\"time\":\"2026-09-24\"}]",
["GiftVIPDData"]="{\"A\":{\"oldVipLevel\":1,\"curVipLevel\":2,\"timer\":3}}",
["FixedTimeShopDic"]="{\"7\":{\"id\":8,\"endTime\":\"2026-09-24\"}}"
};
var state=PlayerKVSaveSchema.DecodeTriggers(raw,new()); var pack=state.Packs[0];
Check(pack.ID==1 && pack.index==2 && pack.lastID==3 && pack.vipLevel==4 && pack.mapID==5 && pack.time=="2026-09-24","pack fields");
Check(state.Gifts["A"].oldVipLevel==1 && state.Gifts["A"].curVipLevel==2 && state.Gifts["A"].timer==3 &&
state.Fixed[7].id==8 && state.Fixed[7].endTime=="2026-09-24","gift and fixed fields");
foreach(var key in raw.Keys)
foreach(var bad in new[] { "null","[null]","{\"7\":null}","{\"A\":{}}" })
Check(PlayerKVSaveSchema.DecodeTriggers(new Dictionary<string,string>(raw) { [key]=bad },new())==null,"invalid container or nested fails");
var missing=PlayerKVSaveSchema.DecodeTriggers(new Dictionary<string,string>(),new());
Check(missing.Packs.Count==0 && missing.Gifts.Count==0 && missing.Fixed.Count==0,"missing all empty");
var allBad=new Dictionary<string,string>(raw); foreach(var key in raw.Keys) allBad[key]="null";
var e=new PlayerKVSchemaDiagnostics(); Check(PlayerKVSaveSchema.DecodeTriggers(allBad,e)==null && e.TotalErrors==3,"all keys counted");
return Task.CompletedTask;
});
}
}

View File

@@ -0,0 +1,58 @@
using game.PlayerDataStorage;
using System.Globalization;
static partial class Tests
{
static async Task RunCampEventSchemaTests()
{
await Run("M07-camp-strict-fields-and-copy", () =>
{
var raw=new Dictionary<string,string> { ["IsFix"]="True",["IsOpenSkyscraper"]="1",["CampDataDic"]="{\"CampId\":205}" };
var state=PlayerKVSaveSchema.DecodeCamp(raw,new());
Check(state.IsFix && state.Skyscraper=="1" && state.Values["CampId"]==205,"all raw fields");
var missing=PlayerKVSaveSchema.DecodeCamp(new Dictionary<string,string>(),new());
Check(!missing.IsFix && missing.Skyscraper=="0" && missing.Values.Count==0,"missing defaults");
foreach(var key in raw.Keys)
{
foreach(var bad in new[] { "null",null,"[]","1.0" })
Check(PlayerKVSaveSchema.DecodeCamp(new Dictionary<string,string>(raw) { [key]=bad },new())==null,"bad optional fails");
}
Check(PlayerKVSaveSchema.DecodeCamp(new Dictionary<string,string> { ["IsOpenSkyscraper"]="true" },new())==null,"exact sky flag");
var source=new Dictionary<string,int> { ["CampId"]=205 };
var copied=new PlayerKVCampState(true,"1",source); source.Clear();
Check(copied.Values.Count==1,"independent map");
return Task.CompletedTask;
});
await Run("M20-event-pack-active-dormant-config", () =>
{
var events=new Dictionary<int,PlayerKVEventConfig> {
[1]=new(100,"2026-09-24T00:00:00.0000000Z"),[2]=new(999,"2026-09-22T00:00:00.0000000Z"),
[3]=new(200,"2026-09-24T00:00:00.0000000Z"),[4]=new(100,null)
};
var snapshot=new PlayerKVSchemaSnapshot("M20",Array.Empty<int>(),Array.Empty<int>(),new Dictionary<string,int>(),
CultureInfo.InvariantCulture,new DateTime(2026,9,23,0,0,0,DateTimeKind.Utc),0,
new Dictionary<int,int> { [100]=1,[200]=3,[4054001]=1 },events);
events.Clear();
Dictionary<string,string> Raw(int eventId,int redirect=0) => new() {
["EventPackData"]=$"{{\"currentEventID\":{eventId},\"purchaseCount\":2,\"VIPLvlWhenEventActivate\":3,\"redirectID\":{redirect}}}"
};
var state=PlayerKVSaveSchema.DecodeEventPack(Raw(1),snapshot,new());
Check(state.EventId==1 && state.PurchaseCount==2 && state.VipLevel==3 && state.RedirectId==4054001,"active old default normalized after copied config");
foreach(var eventId in new[] { 0,2,999 })
{
state=PlayerKVSaveSchema.DecodeEventPack(Raw(eventId),snapshot,new());
Check(state.EventId==eventId && state.RedirectId==0 && state.PurchaseCount==2,"dormant values retained");
}
foreach(var invalid in new[] { Raw(3),Raw(4),Raw(1,999) })
{
var e=new PlayerKVSchemaDiagnostics();
Check(PlayerKVSaveSchema.DecodeEventPack(invalid,snapshot,e)==null && e.Count(20,PlayerKVSchemaError.UnsupportedSchema)==1,"config dereference failure explicit");
}
foreach(var bad in new[] { "null","{}","{\"currentEventID\":1,\"purchaseCount\":\"2\",\"VIPLvlWhenEventActivate\":3,\"redirectID\":0}" })
Check(PlayerKVSaveSchema.DecodeEventPack(new Dictionary<string,string> { ["EventPackData"]=bad },snapshot,new())==null,"strict object");
state=PlayerKVSaveSchema.DecodeEventPack(new Dictionary<string,string>(),snapshot,new());
Check(state.EventId==0 && state.PurchaseCount==0 && state.VipLevel==0 && state.RedirectId==0,"missing all zero without normalization");
return Task.CompletedTask;
});
}
}

View File

@@ -0,0 +1,59 @@
using game.PlayerDataStorage;
static partial class Tests
{
static async Task RunClubPackSchemaTests()
{
await Run("M14-club-three-load-fields-only", () =>
{
var raw = new Dictionary<string,string> {
["ClubSignin"] = "2026-09-23T00:00:00.0000000Z",
["LureDonateData"] = "{\"DonateCountTime\":\"2026-09-22T00:00:00.0000000Z\",\"ClubLureDonateCount\":7,\"ClubLureRequestCDEnd\":\"2026-09-24T00:00:00.0000000Z\"}",
["IsOpenClubRed"] = ""
};
var state = PlayerKVSaveSchema.DecodeClub(raw,SchemaSnapshot(),new());
Check(state.SignTime.Value.Day == 23 && state.DonateTime.Day == 22 && state.DonateCount == 7 && state.RequestEnd.Day == 24 && !state.OpenRed,"all three load states");
var missing = PlayerKVSaveSchema.DecodeClub(new Dictionary<string,string>(),SchemaSnapshot(),new());
Check(missing.SignTime == null && missing.DonateTime == default && missing.DonateCount == 0 && missing.RequestEnd == default && missing.OpenRed,"missing exact zero state");
raw["ClubSignin"] = "";
Check(PlayerKVSaveSchema.DecodeClub(raw,SchemaSnapshot(),new()).SignTime == null,"empty signin supported");
foreach (var key in raw.Keys.ToArray())
{
var bad = new Dictionary<string,string>(raw) { [key] = null };
Check(PlayerKVSaveSchema.DecodeClub(bad,SchemaSnapshot(),new()) == null,"present null rejected");
}
foreach (var bad in new[] { "null", "{}", "{\"DonateCountTime\":0,\"ClubLureDonateCount\":1,\"ClubLureRequestCDEnd\":0}" })
{
raw["LureDonateData"] = bad;
Check(PlayerKVSaveSchema.DecodeClub(raw,SchemaSnapshot(),new()) == null,"bad donation DTO rejected");
}
raw = new() { ["ClubSignin"] = "03/04/2026" };
var errors = new PlayerKVSchemaDiagnostics();
Check(PlayerKVSaveSchema.DecodeClub(raw,SchemaSnapshot("en-GB"),errors) == null &&
errors.Count(14,PlayerKVSchemaError.UnsupportedSchema) == 1,"ambiguous date rejected");
return Task.CompletedTask;
});
await Run("M28-two-independent-four-slot-packs", () =>
{
var raw = new Dictionary<string,string> { ["ThanksGivingPackData"] = "1,2,3,4", ["ThanksGivingPackMigrationData"] = "5,6,7,8" };
foreach (bool migration in new[] { false,true })
{
var state = PlayerKVSaveSchema.DecodeProgressPack(raw,migration,new());
int offset = migration ? 4 : 0;
Check(state.IsPresent && state.EventId == 1 + offset && state.RedirectId == 2 + offset &&
state.TokenProgress == 3 + offset && state.ChainProgress == 4 + offset,"complete four slots");
var key = migration ? "ThanksGivingPackMigrationData" : "ThanksGivingPackData";
foreach (var bad in new[] { "", "null", "1,2,3", "1,2,3,4,5", "1,2,x,4", "1,2,3,2147483648", null })
{
var invalid = new Dictionary<string,string>(raw) { [key] = bad };
Check(PlayerKVSaveSchema.DecodeProgressPack(invalid,migration,new()) == null,"bad present pack rejected");
}
var absent = PlayerKVSaveSchema.DecodeProgressPack(new Dictionary<string,string>(),migration,new());
Check(!absent.IsPresent,"each missing is explicit");
var zero = PlayerKVSaveSchema.DecodeProgressPack(new Dictionary<string,string> { [key] = "0,0,0,0" },migration,new());
Check(zero.IsPresent && zero.EventId == 0 && zero.RedirectId == 0,"zero present not missing");
}
return Task.CompletedTask;
});
}
}

View File

@@ -0,0 +1,88 @@
using game.PlayerDataStorage;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
static partial class Tests
{
static async Task RunCollectionSchemaTests()
{
await Run("M09-two-formats-conflicts-empty-and-strict", () =>
{
var raw = new Dictionary<string,string> { ["FishingBoxData"] = "{\"FishingBoxOpenNum\":{\"1\":2,\"3\":4}}" };
var modern = new Dictionary<string,string> { ["bd"] = "1,2" };
var e = new PlayerKVSchemaDiagnostics();
var state = PlayerKVSaveSchema.DecodeFishingBox(raw,modern,e);
Check(state != null && state.Values.Count == 1 && state.Values[1] == 2, "new replaces whole map");
modern["bd"] = "";
Check(PlayerKVSaveSchema.DecodeFishingBox(raw,modern,new()).Values.Count == 0, "present empty replaces old");
modern["bd"] = "1,3"; e = new();
Check(PlayerKVSaveSchema.DecodeFishingBox(raw,modern,e) == null && e.Count(9,PlayerKVSchemaError.ConflictingRepresentations) == 1, "conflict rejected");
foreach (var bad in new[] { "1,2;", "1,2,3", "1,2;01,3", "1,2.0", "2147483648,0", "null" })
{
modern["bd"] = bad;
Check(PlayerKVSaveSchema.DecodeFishingBox(raw,modern,new()) == null, "bad compact never falls back");
}
modern["bd"] = "1,2";
foreach (var bad in new[] { "null", "{}", "{\"FishingBoxOpenNum\":null}", "{\"FishingBoxOpenNum\":{\"1\":2,\"01\":2}}",
"{\"FishingBoxOpenNum\":{\"1\":\"2\"}}", "{\"FishingBoxOpenNum\":{\"1\":2.0}}" })
{
raw["FishingBoxData"] = bad;
Check(PlayerKVSaveSchema.DecodeFishingBox(raw,modern,new()) == null, "bad old never hidden");
}
Check(PlayerKVSaveSchema.DecodeFishingBox(new Dictionary<string,string>(),new Dictionary<string,string>(),new()).Values.Count == 0,"missing empty");
return Task.CompletedTask;
});
await Run("M10-whole-guide-replacement-and-conflict", () =>
{
var raw = new Dictionary<string,string> {
["GuideSaveData"] = "{\"A\":{\"guideSubIndex\":2,\"isFinish\":true,\"groupName\":\"A\"},\"B\":{\"guideSubIndex\":3,\"isFinish\":false,\"groupName\":\"B\"}}",
["GuideSaveDataNew"] = "A|2|1"
};
var state = PlayerKVSaveSchema.DecodeGuides(raw,new());
Check(state.Values.Count == 1 && state.Values["A"].Index == 2 && state.Values["A"].Finished && state.Values["A"].Group == "A","new whole map selected");
foreach (var conflict in new[] { "A|3|1", "A|2|0" })
{
raw["GuideSaveDataNew"] = conflict; var e = new PlayerKVSchemaDiagnostics();
Check(PlayerKVSaveSchema.DecodeGuides(raw,e) == null && e.Count(10,PlayerKVSchemaError.ConflictingRepresentations) == 1,"common field conflict");
}
raw["GuideSaveDataNew"] = "";
Check(PlayerKVSaveSchema.DecodeGuides(raw,new()).Values.Count == 0,"new empty clears old groups");
foreach (var bad in new[] { "A|2|true", "A|2|2", "A|x|0", "A|2", "A|2|0;", "A|2|0;A|2|0" })
{
raw["GuideSaveDataNew"] = bad;
Check(PlayerKVSaveSchema.DecodeGuides(raw,new()) == null,"invalid compact rejected");
}
raw["GuideSaveDataNew"] = "A|2|1";
foreach (var bad in new[] { "null", "{\"A\":null}", "{\"A\":{\"guideSubIndex\":2,\"isFinish\":1,\"groupName\":\"A\"}}",
"{\"A\":{\"guideSubIndex\":2,\"isFinish\":true,\"groupName\":null}}" })
{
raw["GuideSaveData"] = bad;
Check(PlayerKVSaveSchema.DecodeGuides(raw,new()) == null,"invalid old not ignored");
}
raw["GuideSaveData"] = "{\"A\":{\"guideSubIndex\":2,\"isFinish\":true,\"groupName\":\"B\"}}";
Check(PlayerKVSaveSchema.DecodeGuides(raw,new()) == null,"groupName participates in comparison");
Check(PlayerKVSaveSchema.DecodeGuides(new Dictionary<string,string>(),new()).Values.Count == 0,"missing empty");
return Task.CompletedTask;
});
await Run("M12-nested-signin-all-fields-required", () =>
{
var valid = JObject.Parse("{\"index\":5,\"dailySignEvent\":{\"ID\":7,\"index\":2,\"lastID\":-1,\"time\":\"2026-09-23\"}}");
var raw = new Dictionary<string,string> { ["MonthlySignEvent"] = valid.ToString(Formatting.None) };
var state = PlayerKVSaveSchema.DecodeMonthlySign(raw,new());
Check(state.IsPresent && state.Index == 5 && state.DailyId == 7 && state.DailyIndex == 2 && state.LastId == -1 && state.Time == "2026-09-23","all fields decoded");
foreach (var path in new[] { "index", "dailySignEvent", "dailySignEvent.ID", "dailySignEvent.index", "dailySignEvent.lastID", "dailySignEvent.time" })
foreach (var bad in new JToken[] { JValue.CreateNull(), new JArray() })
{
var obj = (JObject)valid.DeepClone(); obj.SelectToken(path).Replace(bad);
raw["MonthlySignEvent"] = obj.ToString(Formatting.None);
Check(PlayerKVSaveSchema.DecodeMonthlySign(raw,new()) == null,"nested field null/shape rejected");
}
foreach (var bad in new[] { "null", "{}", "{\"index\":1}", "{\"index\":1,\"dailySignEvent\":{}}" })
{
raw["MonthlySignEvent"] = bad; Check(PlayerKVSaveSchema.DecodeMonthlySign(raw,new()) == null,"present malformed fails");
}
Check(!PlayerKVSaveSchema.DecodeMonthlySign(new Dictionary<string,string>(),new()).IsPresent,"missing explicit");
return Task.CompletedTask;
});
}
}

View File

@@ -0,0 +1,190 @@
using System.Collections.Concurrent;
using System.Net;
using game.PlayerDataStorage;
using Newtonsoft.Json;
static partial class Tests
{
sealed class MainLoop : SynchronizationContext
{
internal readonly BlockingCollection<(SendOrPostCallback,object)> Queue=new();
public override void Post(SendOrPostCallback callback,object state) => Queue.Add((callback,state));
}
static Task OnMain(Func<Task> body) => Task.Run(()=>
{
var old=SynchronizationContext.Current; var loop=new MainLoop(); SynchronizationContext.SetSynchronizationContext(loop);
try
{
var task=body();
_=task.ContinueWith(_=>loop.Post(_=>{},null),TaskScheduler.Default);
while(!task.IsCompleted) { var (callback,state)=loop.Queue.Take(); callback(state); }
task.GetAwaiter().GetResult();
}
finally { SynchronizationContext.SetSynchronizationContext(old); }
});
sealed class LoadFixture
{
internal readonly PlayerKVShutdownRegistry Registry;
internal readonly int[] Values=new int[31];
internal int Auth,Gets,Sends,Notify,Discard,AuthDisposed,LeaseDisposed;
internal bool Fresh=true,Unstarted=true,FailApply,NotifyViolation,AuthDisposeFails,LeaseDisposeFails;
internal Action OnNotify;
internal Func<Task<HttpResponseMessage>> GetOverride;
internal Func<int,CancellationToken,Task> Clock;
internal PlayerKVAccountLease Lease;
internal PlayerKVGameLoadCoordinator Coordinator;
internal LoadFixture(PlayerKVShutdownRegistry registry=null)
{
Registry=registry ?? new();
var modules=Enumerable.Range(1,31).Select(id=>(IPlayerKVLoadModule)new PlayerKVLoadModule(id,
()=>Values[id-1]=0,_=>{Values[id-1]=id;if(FailApply && id==18) throw new InvalidOperationException();})).ToArray();
var transaction=new PlayerKVGameLoadTransaction(modules,Environment.CurrentManagedThreadId,()=>Discard++);
Coordinator=new(transaction,()=>Unstarted,()=>{Notify++;OnNotify?.Invoke();if(NotifyViolation) PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Update);},()=>1,
lease=>Lease=lease,()=> {var lease=Lease;Lease=null;return lease?.CloseAsync() ?? Task.CompletedTask;},Registry);
}
internal PlayerKVGameLoadCapability Capability(Dictionary<string,string> raw=null,bool realKV=false)
{
CookieContainer jar=null;
return PlayerKVGameLoadCapability.CreateForValidation(Identity(),SchemaSnapshot(),Api,Path.Combine(Root,"load",Guid.NewGuid().ToString("N")),
cookies=>{ jar=cookies;return new Handler((r,t)=>
{
Auth++; Check(r.RequestUri.AbsolutePath=="/account/Login","one auth route"); jar.Add(Api,new Cookie("sid","synthetic"));
return Task.FromResult(Json(JsonConvert.SerializeObject(new{state=0,internalId=17,newCreate=Fresh,createTime="2026-01-01T00:00:00Z",svrTime="2026-01-02T00:00:00Z"})));
},()=>{AuthDisposed++;if(AuthDisposeFails)throw new InvalidOperationException("synthetic dispose");});},
cookies=>{Check(ReferenceEquals(cookies,jar),"KV cookie identity");if(realKV) return new HttpClientHandler();return new Handler((r,t)=>
{Gets++;Check(r.RequestUri.AbsolutePath=="/playerdata/Get","no mutation route");return GetOverride?.Invoke() ?? Task.FromResult(Json(JsonConvert.SerializeObject(new{data=raw ?? ModernRaw()})));});},
cookies=>{Check(ReferenceEquals(cookies,jar),"lease cookie identity");return new Handler((r,t)=>{Sends++;return Task.FromResult(Json("{}"));},()=>{LeaseDisposed++;if(LeaseDisposeFails)throw new InvalidOperationException("synthetic dispose");});},
delay:Clock);
}
}
static async Task RunCoordinatorTests()
{
await Run("coordinator-success-reload-revoke-and-one-auth",()=>OnMain(async()=>
{
var f=new LoadFixture();
var first=await f.Coordinator.LoadAsync("101",f.Capability());
Check(first.State==PlayerKVGameLoadState.LoadedForValidation && first.ModuleCount==31 && f.Coordinator.LoadedForValidation,"controlled success only");
Check(f.Auth==1 && f.Gets==1 && f.Notify==1 && f.Values.All(v=>v>0),"one auth/get and actual transaction");
var oldLease=f.Lease;
var second=await f.Coordinator.LoadAsync("102",f.Capability());
Check(second.State==PlayerKVGameLoadState.LoadedForValidation && second.Epoch>first.Epoch && f.Auth==2 && f.AuthDisposed==1 && !oldLease.IsCurrent,"previous account drained before reload");
var close=f.Coordinator.Revoke(); Check(!f.Coordinator.LoadedForValidation && f.Values.All(v=>v==0),"synchronous invalidation and module clearing");
Check(ReferenceEquals(close,f.Coordinator.Revoke()),"same close task"); await close;
Check(f.Registry.Status==PlayerKVShutdownStatus.Ready && f.AuthDisposed==2 && f.LeaseDisposed==2,"all ownership drained");
}));
await Run("coordinator-schema-apply-and-sideeffect-failures",()=>OnMain(async()=>
{
foreach(var mode in new[]{0,1,2})
{
var f=new LoadFixture{FailApply=mode==1,NotifyViolation=mode==2};
var result=await f.Coordinator.LoadAsync("101",f.Capability(mode==0 ? new Dictionary<string,string>() : null));
Check(result.Error==(mode==0 ? PlayerKVGameLoadError.Schema : mode==1 ? PlayerKVGameLoadError.Apply : PlayerKVGameLoadError.SideEffect),"stage error preserved");
Check(f.Values.All(v=>v==0) && !f.Coordinator.LoadedForValidation && f.AuthDisposed==1,"failure clears and closes");
Check(f.Notify==(mode==2 ? 1 : 0),"notification cannot precede complete apply");
}
var rejected=new LoadFixture(); var real=await rejected.Coordinator.LoadAsync("101",rejected.Capability(realKV:true));
Check(real.State==PlayerKVGameLoadState.Rejected && rejected.Auth==0 && rejected.Gets==0,"all handlers preflight before authentication");
}));
await Run("coordinator-cancel-during-notification-and-after-success",()=>OnMain(async()=>
{
using var cancel=new CancellationTokenSource(); var f=new LoadFixture(); f.OnNotify=()=>cancel.Cancel();
var result=await f.Coordinator.LoadAsync("101",f.Capability(),cancel.Token);
Check(result.Error==PlayerKVGameLoadError.Stale && !f.Coordinator.LoadedForValidation && f.Values.All(v=>v==0),"callback revocation clears after transaction unwinds");
using var later=new CancellationTokenSource(); var g=new LoadFixture();
Check((await g.Coordinator.LoadAsync("101",g.Capability(),later.Token)).State==PlayerKVGameLoadState.LoadedForValidation,"ready before later cancel");
later.Cancel(); await g.Coordinator.Revoke();
Check(!g.Coordinator.LoadedForValidation && g.Values.All(v=>v==0),"lifetime remains effective after result");
}));
await Run("coordinator-pending-close-cross-instance-and-late-prepare",()=>OnMain(async()=>
{
var response=new TaskCompletionSource<HttpResponseMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
var started=Signal(); var f=new LoadFixture(); f.GetOverride=()=>{started.TrySetResult(true);return response.Task;};
var deadlines=new List<TaskCompletionSource<bool>>();
var observedDurations=new List<int>();
f.Clock=(ms,token)=>{var s=Signal();lock(deadlines){deadlines.Add(s);observedDurations.Add(ms);}token.Register(()=>s.TrySetCanceled(token));return s.Task;};
using var cancel=new CancellationTokenSource(); var load=f.Coordinator.LoadAsync("101",f.Capability(),cancel.Token);
await started.Task; cancel.Cancel();
// Advance only the live observation clock; transport deliberately ignores cancellation.
while(true)
{
await Task.Yield(); TaskCompletionSource<bool> deadline; lock(deadlines) deadline=deadlines.Last();
if(!deadline.Task.IsCompleted) {deadline.TrySetResult(true);break;}
}
var result=await load;
Check(result.Error==PlayerKVGameLoadError.ClosePending && f.Registry.Status==PlayerKVShutdownStatus.Pending && f.AuthDisposed==0,"deadline is not drainage");
var g=new LoadFixture(f.Registry);int ticks=0;g.Clock=(ms,t)=>++ticks==1 ? Task.Delay(Timeout.Infinite,t) : Task.CompletedTask;
var blocked=await g.Coordinator.LoadAsync("102",g.Capability());
Check(blocked.Error==PlayerKVGameLoadError.PreviousClosePending && g.Auth==0,"new coordinator cannot bypass pending close");
int observed=0;f.Clock=null;
// Existing captured capability owns the quit clock, not a later mutable option.
var quit=f.Coordinator.ObserveQuitAsync();
while(!quit.IsCompleted){await Task.Yield();lock(deadlines) deadlines.Last().TrySetResult(true);observed++;}
Check(await quit==PlayerKVShutdownStatus.Pending && f.AuthDisposed==0 && observed>=0,"quit observation is not force disposal");
Check(observedDurations.Contains(10000) && observedDurations.Last()==5000,"captured login/quit observations are 10s/5s");
response.TrySetResult(Json(JsonConvert.SerializeObject(new{data=ModernRaw()})));
await f.Coordinator.Revoke();
Check(f.AuthDisposed==1 && f.Notify==0 && f.Values.All(v=>v==0) && !f.Coordinator.LoadedForValidation,"late success cannot apply");
}));
await Run("guard-exclusive-across-await-and-all-points",async()=>
{
Check(PlayerKVGameLoadGuard.TryEnter(out var guard),"first guard");
using(guard)
{
await Task.Yield(); Check(!PlayerKVGameLoadGuard.TryEnter(out _),"process lease crosses await");
foreach(var point in Enum.GetValues<PlayerKVGuardPoint>()) Check(PlayerKVGameLoadGuard.Reject(point) && guard.Count(point)==1,"all points counted");
await Throws<PlayerKVLoadSideEffectException>(()=>{PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.ReadLocal);return Task.CompletedTask;});
Check(guard.Violations==8,"total violations");
}
Check(!PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Update),"PF passthrough outside scope");
});
await Run("coordinator-gate-timeout-cancel-boundary-no-leak",()=>OnMain(async()=>
{
var f=new LoadFixture{Clock=(ms,t)=>Task.CompletedTask};
Check((await f.Coordinator.LoadAsync("101",f.Capability())).Error==PlayerKVGameLoadError.GateBusy && f.Auth==0,"simultaneous free lock and elapsed deadline rejects");
f.Clock=null;using var cancel=new CancellationTokenSource();cancel.Cancel();
Check((await f.Coordinator.LoadAsync("101",f.Capability(),cancel.Token)).Error==PlayerKVGameLoadError.Stale,"cancelled gate");
Check((await f.Coordinator.LoadAsync("101",f.Capability())).State==PlayerKVGameLoadState.LoadedForValidation,"rejected gate returned permit");await f.Coordinator.Revoke();
var g=new LoadFixture();bool current=false;
Check((await g.Coordinator.LoadAsync("102",g.Capability(),identityCurrent:()=>current)).State==PlayerKVGameLoadState.Rejected && g.Notify==0,"changed identity never applies");
}));
await Run("coordinator-occupied-gate-bounded-and-reusable",()=>OnMain(async()=>
{
var started=Signal();var response=new TaskCompletionSource<HttpResponseMessage>();var f=new LoadFixture();
f.GetOverride=()=>{started.TrySetResult(true);return response.Task;};var first=f.Coordinator.LoadAsync("101",f.Capability());await started.Task;
f.Clock=(ms,t)=>Task.CompletedTask;
Check((await f.Coordinator.LoadAsync("102",f.Capability())).Error==PlayerKVGameLoadError.GateBusy && f.Auth==1,"busy gate never authenticates next account");
response.SetResult(Json(Newtonsoft.Json.JsonConvert.SerializeObject(new{data=ModernRaw()})));Check((await first).State==PlayerKVGameLoadState.LoadedForValidation,"first untouched");await f.Coordinator.Revoke();
f.Clock=null;f.GetOverride=null;Check((await f.Coordinator.LoadAsync("102",f.Capability())).State==PlayerKVGameLoadState.LoadedForValidation,"permit reusable");await f.Coordinator.Revoke();
}));
await Run("coordinator-all-prepare-evidence-under-cleanup-failure",()=>OnMain(async()=>
{
// Layered mapping probe: 4B1 independently exercises the preparation causes; this invokes the real rejection path for every historical error.
var registry=new PlayerKVShutdownRegistry();registry.Register(Task.FromException(new InvalidOperationException("synthetic close")));
var fixture=new LoadFixture(registry);
var reject=typeof(PlayerKVGameLoadCoordinator).GetMethod("Reject",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic);
foreach(var error in Enum.GetValues<PlayerKVLoginPrepareError>().Where(e=>e!=PlayerKVLoginPrepareError.None))
{
var prepared=new PlayerKVLoginPrepareResult(error,"101",7,httpStatus:401,exceptionType:"SyntheticPrepare",cleanupExceptionType:"SyntheticClose",
originalError:error==PlayerKVLoginPrepareError.CloseFailed ? PlayerKVLoginPrepareError.AuthenticationFailed : error);
var task=(Task<PlayerKVGameLoadResult>)reject.Invoke(fixture.Coordinator,new object[]{PlayerKVGameLoadError.Prepare,7L,fixture.Capability(),prepared,null});
var result=await task;
Check(result.CleanupFailed && result.OriginalError==PlayerKVGameLoadError.Prepare && result.Prepare.Error==error && result.Prepare.OriginalError==prepared.OriginalError,"all historical errors preserved under CloseFailed");
Check(result.Prepare.HttpStatusCode==401 && result.Prepare.ExceptionType=="SyntheticPrepare" && result.Prepare.CleanupExceptionType=="SyntheticClose" && fixture.Auth==0,"stage metadata retained with zero new authentication");
}
}));
}
static async Task<int> IsolatedCleanupFailure(string mode)
{
await Run("coordinator-cleanup-failure-"+mode,()=>OnMain(async()=>
{
var f=new LoadFixture{FailApply=mode=="apply",LeaseDisposeFails=mode!="prepare",AuthDisposeFails=mode=="prepare"};
var result=await f.Coordinator.LoadAsync("101",f.Capability(mode=="schema" ? new Dictionary<string,string>() : null,mode=="prepare"));
var expected=mode=="prepare" ? PlayerKVGameLoadError.Prepare : mode=="schema" ? PlayerKVGameLoadError.Schema : PlayerKVGameLoadError.Apply;
Check(result.Error==PlayerKVGameLoadError.CloseFailed && result.OriginalError==expected && result.CleanupFailed,"cleanup failure preserves original stage");
Check(result.Prepare!=null && result.ModuleCount==0 && result.IntentCount==0 && !f.Coordinator.LoadedForValidation,"failure carries evidence only");
var next=new LoadFixture(f.Registry);Check((await next.Coordinator.LoadAsync("102",next.Capability())).Error==PlayerKVGameLoadError.PreviousCloseFailed && next.Auth==0,"failure persists across coordinators");
Check(!PlayerKVGameLoadGuard.TryEnter(out _),"failed cleanup cannot release guard");
}));
Console.WriteLine($"RESULT isolated mode={mode} failed={failures} assertions={assertions}");return failures==0?0:1;
}
}

View File

@@ -0,0 +1,31 @@
using game.PlayerDataStorage;
using Newtonsoft.Json.Linq;
static partial class Tests
{
const string CubeJson="{\"TicketCount\":1,\"FirstOpen\":true,\"IsFinished\":true,\"EventId\":2,\"IsInGuide\":true,\"StartTime\":\"2026-09-22T00:00:00.0000000Z\",\"EndTime\":\"2026-09-24T00:00:00.0000000Z\",\"CurrentStagetId\":3,\"CollectItems\":[{\"ItemId\":4,\"Count\":5,\"MaxCount\":6,\"ItemTrueId\":7}],\"BoardData\":[8,9],\"BoardViewSize\":{\"x\":10,\"y\":11},\"CollectRewardsId\":[12,13],\"IsFinalRewardGet\":true,\"PieceDatas\":[[14,15],[16,17]],\"Cycle\":18,\"InflationRate\":1.5}";
static async Task RunCubeSchemaTests()
{
await Run("M19-cube-all-fields-nested-collections",() =>
{
var obj=JObject.Parse(CubeJson);
Dictionary<string,string> Raw(JObject value)=>new() { ["EventCubeMeltData"]=value.ToString() };
var state=PlayerKVSaveSchema.DecodeCube(Raw(obj),SchemaSnapshot(),new());
Check(state.IsPresent && state.TicketCount==1 && state.FirstOpen && state.IsFinished && state.EventId==2 && state.IsInGuide &&
state.StartTime.Day==22 && state.EndTime.Day==24 && state.CurrentStagetId==3,"base fields");
Check(state.CollectItems[0].ItemId==4 && state.CollectItems[0].Count==5 && state.CollectItems[0].MaxCount==6 &&
state.CollectItems[0].ItemTrueId==7 && state.BoardData.SequenceEqual(new[] { 8,9 }),"items and board");
Check(state.Width==10 && state.Height==11 && state.CollectRewardsId.SequenceEqual(new[] { 12,13 }) &&
state.IsFinalRewardGet && state.Cycle==18 && state.InflationRate==1.5f,"dimensions and progress");
Check(state.PieceDatas[0].SequenceEqual(new[] { 14,15 }) && state.PieceDatas[1].SequenceEqual(new[] { 16,17 }),"nested pieces");
foreach(var path in obj.Properties().Select(p=>p.Name).Concat(new[] { "CollectItems[0]","CollectItems[0].Count","BoardViewSize.x","BoardViewSize.y","PieceDatas[0]","PieceDatas[0][0]" }))
{
var bad=(JObject)obj.DeepClone(); bad.SelectToken(path).Replace(JValue.CreateNull());
Check(PlayerKVSaveSchema.DecodeCube(Raw(bad),SchemaSnapshot(),new())==null,"all nested nulls reject");
}
Check(!PlayerKVSaveSchema.DecodeCube(new Dictionary<string,string>(),SchemaSnapshot(),new()).IsPresent,"missing explicit");
Check(PlayerKVSaveSchema.DecodeCube(new Dictionary<string,string> { ["EventCubeMeltData"]="null" },SchemaSnapshot(),new())==null,"null not missing");
return Task.CompletedTask;
});
}
}

View File

@@ -0,0 +1,220 @@
using System.Globalization;
using game.PlayerDataStorage;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
static partial class Tests
{
static PlayerKVSchemaSnapshot PackSnapshot(IDictionary<int,int> packs = null) => new("D3", new[] { 101 }, new[] { 1 },
new Dictionary<string,int>(), CultureInfo.InvariantCulture, new DateTime(2026,9,23), 0,
packs ?? new Dictionary<int,int> { [4055001] = 2, [300] = 3 });
static async Task RunD3Tests()
{
await Run("M21-strict-complete-value-state", () =>
{
var obj = JObject.FromObject(new { currentEventID = 11, purchaseCount = 11, VIPLvlWhenEventActivate = 11, savingProgress = 11, redirectID = 0, doNeedTrigger = true, isTriggered = true, inflation = 1.5f });
var raw = new Dictionary<string,string> { ["PiggyBankPackData"] = obj.ToString(Formatting.None) };
var e = new PlayerKVSchemaDiagnostics();
var state = PlayerKVSaveSchema.DecodePiggyBank(raw, PackSnapshot(), e);
Check(state != null && state.IsPresent && e.TotalErrors == 0, "present complete record");
Check(state.currentEventID == 11, "typed currentEventID retained");
Check(state.purchaseCount == 11, "typed purchaseCount retained");
Check(state.VIPLvlWhenEventActivate == 11, "typed VIPLvlWhenEventActivate retained");
Check(state.savingProgress == 11, "typed savingProgress retained");
Check(state.redirectID == 4055001, "typed redirectID retained");
Check(state.doNeedTrigger == true, "typed doNeedTrigger retained");
Check(state.isTriggered == true, "typed isTriggered retained");
Check(state.inflation == 1.5f, "typed inflation retained");
var missing = PlayerKVSaveSchema.DecodePiggyBank(new Dictionary<string,string>(), PackSnapshot(), new PlayerKVSchemaDiagnostics());
Check(!missing.IsPresent, "missing distinguished");
Check(missing.currentEventID == 0, "missing currentEventID empty");
Check(missing.purchaseCount == 0, "missing purchaseCount empty");
Check(missing.VIPLvlWhenEventActivate == 0, "missing VIPLvlWhenEventActivate empty");
Check(missing.savingProgress == 0, "missing savingProgress empty");
Check(missing.redirectID == 0, "missing redirectID empty");
Check(missing.doNeedTrigger == false, "missing doNeedTrigger empty");
Check(missing.isTriggered == false, "missing isTriggered empty");
Check(missing.inflation == 0, "missing inflation empty");
foreach (var property in obj.Properties().ToArray())
{
var bad = (JObject)obj.DeepClone(); bad.Remove(property.Name);
raw["PiggyBankPackData"] = bad.ToString(Formatting.None);
Check(PlayerKVSaveSchema.DecodePiggyBank(raw, PackSnapshot(), new PlayerKVSchemaDiagnostics()) == null, "every field required");
foreach (var invalid in new JToken[] { JValue.CreateNull(), new JValue("coercion"), new JObject() })
{
bad = (JObject)obj.DeepClone(); bad[property.Name] = invalid;
raw["PiggyBankPackData"] = bad.ToString(Formatting.None);
Check(PlayerKVSaveSchema.DecodePiggyBank(raw, PackSnapshot(), new PlayerKVSchemaDiagnostics()) == null, "no coercion or null");
}
}
foreach (var invalid in new[] { "null", "[]", "{}", "{", "{\\\"duplicate\\\":1,\\\"duplicate\\\":2}" })
{
raw["PiggyBankPackData"] = invalid;
e = new PlayerKVSchemaDiagnostics();
Check(PlayerKVSaveSchema.DecodePiggyBank(raw, PackSnapshot(), e) == null && e.Count(21, PlayerKVSchemaError.Malformed) == 1, "invalid optional is an error");
}
return Task.CompletedTask;
});
await Run("M22-strict-complete-value-state", () =>
{
var obj = JObject.FromObject(new { currentEventID = 11, redirectID = 300, VIPLvlWhenEventActivate = 11, purchaseCount = 11, savingProgress = 11, discountLvl = 11, visualProgress = 11, visualDiscountLvl = 11, rodID = 11, lastPackIDBought = 11, packID = 11, doNeedTrigger = true, isTriggered = true, inflation = 1.5f, selections = new[] { 7, 9 } });
var raw = new Dictionary<string,string> { ["GeneralEventPackData"] = obj.ToString(Formatting.None) };
var e = new PlayerKVSchemaDiagnostics();
var state = PlayerKVSaveSchema.DecodeBargain(raw, PackSnapshot(), e);
Check(state != null && state.IsPresent && e.TotalErrors == 0, "present complete record");
Check(state.currentEventID == 11, "typed currentEventID retained");
Check(state.redirectID == 300, "typed redirectID retained");
Check(state.VIPLvlWhenEventActivate == 11, "typed VIPLvlWhenEventActivate retained");
Check(state.purchaseCount == 11, "typed purchaseCount retained");
Check(state.savingProgress == 11, "typed savingProgress retained");
Check(state.discountLvl == 11, "typed discountLvl retained");
Check(state.visualProgress == 11, "typed visualProgress retained");
Check(state.visualDiscountLvl == 11, "typed visualDiscountLvl retained");
Check(state.rodID == 11, "typed rodID retained");
Check(state.lastPackIDBought == 11, "typed lastPackIDBought retained");
Check(state.packID == 11, "typed packID retained");
Check(state.doNeedTrigger == true, "typed doNeedTrigger retained");
Check(state.isTriggered == true, "typed isTriggered retained");
Check(state.inflation == 1.5f, "typed inflation retained");
Check(state.selections.SequenceEqual(new[] { 7, 9 }), "typed selections retained");
var missing = PlayerKVSaveSchema.DecodeBargain(new Dictionary<string,string>(), PackSnapshot(), new PlayerKVSchemaDiagnostics());
Check(!missing.IsPresent, "missing distinguished");
Check(missing.currentEventID == 0, "missing currentEventID empty");
Check(missing.redirectID == 0, "missing redirectID empty");
Check(missing.VIPLvlWhenEventActivate == 0, "missing VIPLvlWhenEventActivate empty");
Check(missing.purchaseCount == 0, "missing purchaseCount empty");
Check(missing.savingProgress == 0, "missing savingProgress empty");
Check(missing.discountLvl == 0, "missing discountLvl empty");
Check(missing.visualProgress == 0, "missing visualProgress empty");
Check(missing.visualDiscountLvl == 0, "missing visualDiscountLvl empty");
Check(missing.rodID == 0, "missing rodID empty");
Check(missing.lastPackIDBought == 0, "missing lastPackIDBought empty");
Check(missing.packID == 0, "missing packID empty");
Check(missing.doNeedTrigger == false, "missing doNeedTrigger empty");
Check(missing.isTriggered == false, "missing isTriggered empty");
Check(missing.inflation == 0, "missing inflation empty");
Check(missing.selections.Count == 0, "missing selections empty");
foreach (var property in obj.Properties().ToArray())
{
var bad = (JObject)obj.DeepClone(); bad.Remove(property.Name);
raw["GeneralEventPackData"] = bad.ToString(Formatting.None);
Check(PlayerKVSaveSchema.DecodeBargain(raw, PackSnapshot(), new PlayerKVSchemaDiagnostics()) == null, "every field required");
foreach (var invalid in new JToken[] { JValue.CreateNull(), new JValue("coercion"), new JObject() })
{
if (property.Name == "selections" && invalid.Type == JTokenType.Null) continue;
bad = (JObject)obj.DeepClone(); bad[property.Name] = invalid;
raw["GeneralEventPackData"] = bad.ToString(Formatting.None);
Check(PlayerKVSaveSchema.DecodeBargain(raw, PackSnapshot(), new PlayerKVSchemaDiagnostics()) == null, "no coercion or null");
}
}
foreach (var invalid in new[] { "null", "[]", "{}", "{", "{\\\"duplicate\\\":1,\\\"duplicate\\\":2}" })
{
raw["GeneralEventPackData"] = invalid;
e = new PlayerKVSchemaDiagnostics();
Check(PlayerKVSaveSchema.DecodeBargain(raw, PackSnapshot(), e) == null && e.Count(22, PlayerKVSchemaError.Malformed) == 1, "invalid optional is an error");
}
return Task.CompletedTask;
});
await Run("M22-null-empty-list-preserves-fields-and-strictness", () =>
{
var obj = JObject.FromObject(new { currentEventID = 1, redirectID = 300, VIPLvlWhenEventActivate = 2, purchaseCount = 3,
savingProgress = 4, discountLvl = 5, visualProgress = 6, visualDiscountLvl = 7, rodID = 8, lastPackIDBought = 9,
packID = 10, doNeedTrigger = true, isTriggered = false, inflation = 1.25f });
foreach (var selection in new JToken[] { JValue.CreateNull(), new JArray(), new JArray(7, 9) })
{
obj["selections"] = selection;
var text = obj.ToString(Formatting.None);
var raw = new Dictionary<string,string> { ["GeneralEventPackData"] = text };
var errors = new PlayerKVSchemaDiagnostics();
var state = PlayerKVSaveSchema.DecodeBargain(raw, PackSnapshot(), errors);
Check(state != null && state.IsPresent && errors.TotalErrors == 0, "all three list representations accepted");
Check(state.currentEventID == 1 && state.redirectID == 300 && state.VIPLvlWhenEventActivate == 2 && state.purchaseCount == 3 &&
state.savingProgress == 4 && state.discountLvl == 5 && state.visualProgress == 6 && state.visualDiscountLvl == 7 &&
state.rodID == 8 && state.lastPackIDBought == 9 && state.packID == 10 && state.doNeedTrigger && !state.isTriggered &&
state.inflation == 1.25f, "all fourteen other fields retained");
Check(selection.Type == JTokenType.Null ? state.selections == null : state.selections.SequenceEqual(selection.Values<int>()), "null is not empty list");
Check(raw["GeneralEventPackData"] == text, "raw string unchanged");
var wrongType = new PlayerKVSchemaDiagnostics();
Check(PlayerKVSaveSchema.DecodeBargain(raw, PackSnapshot(new Dictionary<int,int> { [300] = 2 }), wrongType) == null &&
wrongType.Count(22, PlayerKVSchemaError.UnsupportedSchema) == 1, "null does not bypass pack type check");
}
foreach (var selection in new JToken[] { new JArray(JValue.CreateNull()), new JArray(1.5), new JArray("7"),
new JArray(2147483648L), new JValue(false), new JValue("null"), new JObject() })
{
obj["selections"] = selection;
var errors = new PlayerKVSchemaDiagnostics();
Check(PlayerKVSaveSchema.DecodeBargain(new Dictionary<string,string> { ["GeneralEventPackData"] = obj.ToString(Formatting.None) },
PackSnapshot(), errors) == null && errors.Count(22, PlayerKVSchemaError.Malformed) == 1, "invalid list remains malformed");
}
var nullable = new PlayerKVBargainState(true,0,300,0,0,0,0,0,0,0,0,0,false,false,0,null);
Check(nullable.selections == null && nullable.IsPresent, "DTO null remains null");
var source = new List<int> { 7, 9 };
var copied = new PlayerKVBargainState(true,0,300,0,0,0,0,0,0,0,0,0,false,false,0,source);
source[0] = 99;
Check(copied.selections.SequenceEqual(new[] { 7,9 }) && copied.selections is not List<int>, "non-null DTO remains an independent read-only copy");
Check(!PlayerKVBargainState.Missing().IsPresent && PlayerKVBargainState.Missing().selections.Count == 0, "missing pack keeps original empty list contract");
return Task.CompletedTask;
});
await Run("M24-strict-complete-value-state", () =>
{
var obj = JObject.FromObject(new { EventId = 11, CycleId = 11, RedirectId = 11, AmmoCount = 11, CurrentStageIdx = 11, ChainProgress = 11, RewardFlag = long.MaxValue, TargetFlag = long.MinValue, HasBeenOpened = true, Inflation = 1.5f });
var raw = new Dictionary<string,string> { ["EventShootingRangeData"] = obj.ToString(Formatting.None) };
var e = new PlayerKVSchemaDiagnostics();
var state = PlayerKVSaveSchema.DecodeShootingRange(raw, PackSnapshot(), e);
Check(state != null && state.IsPresent && e.TotalErrors == 0, "present complete record");
Check(state.EventId == 11, "typed EventId retained");
Check(state.CycleId == 11, "typed CycleId retained");
Check(state.RedirectId == 11, "typed RedirectId retained");
Check(state.AmmoCount == 11, "typed AmmoCount retained");
Check(state.CurrentStageIdx == 11, "typed CurrentStageIdx retained");
Check(state.ChainProgress == 11, "typed ChainProgress retained");
Check(state.RewardFlag == long.MaxValue, "typed RewardFlag retained");
Check(state.TargetFlag == long.MinValue, "typed TargetFlag retained");
Check(state.HasBeenOpened == true, "typed HasBeenOpened retained");
Check(state.Inflation == 1.5f, "typed Inflation retained");
var missing = PlayerKVSaveSchema.DecodeShootingRange(new Dictionary<string,string>(), PackSnapshot(), new PlayerKVSchemaDiagnostics());
Check(!missing.IsPresent, "missing distinguished");
Check(missing.EventId == 0, "missing EventId empty");
Check(missing.CycleId == 0, "missing CycleId empty");
Check(missing.RedirectId == 0, "missing RedirectId empty");
Check(missing.AmmoCount == 0, "missing AmmoCount empty");
Check(missing.CurrentStageIdx == 0, "missing CurrentStageIdx empty");
Check(missing.ChainProgress == 0, "missing ChainProgress empty");
Check(missing.RewardFlag == 0, "missing RewardFlag empty");
Check(missing.TargetFlag == 0, "missing TargetFlag empty");
Check(missing.HasBeenOpened == false, "missing HasBeenOpened empty");
Check(missing.Inflation == 0, "missing Inflation empty");
foreach (var property in obj.Properties().ToArray())
{
var bad = (JObject)obj.DeepClone(); bad.Remove(property.Name);
raw["EventShootingRangeData"] = bad.ToString(Formatting.None);
Check(PlayerKVSaveSchema.DecodeShootingRange(raw, PackSnapshot(), new PlayerKVSchemaDiagnostics()) == null, "every field required");
foreach (var invalid in new JToken[] { JValue.CreateNull(), new JValue("coercion"), new JObject() })
{
bad = (JObject)obj.DeepClone(); bad[property.Name] = invalid;
raw["EventShootingRangeData"] = bad.ToString(Formatting.None);
Check(PlayerKVSaveSchema.DecodeShootingRange(raw, PackSnapshot(), new PlayerKVSchemaDiagnostics()) == null, "no coercion or null");
}
}
foreach (var invalid in new[] { "null", "[]", "{}", "{", "{\\\"duplicate\\\":1,\\\"duplicate\\\":2}" })
{
raw["EventShootingRangeData"] = invalid;
e = new PlayerKVSchemaDiagnostics();
Check(PlayerKVSaveSchema.DecodeShootingRange(raw, PackSnapshot(), e) == null && e.Count(24, PlayerKVSchemaError.Malformed) == 1, "invalid optional is an error");
}
return Task.CompletedTask;
});
await Run("D3-fixed-config-copy-and-signed-width", async () =>
{
var source = new Dictionary<int,int> { [4055001] = 2, [300] = 3 };
var snapshot = PackSnapshot(source); source.Clear();
Check(snapshot.HasPack(4055001) && snapshot.HasPackType(300,3), "configuration captured copy");
foreach (var text in new[] { "9223372036854775808", "-9223372036854775809", "1.0", "\"1\"" })
await Throws<PlayerKVSchemaException>(() => { PlayerKVSaveSchema.Signed64(PlayerKVSaveSchema.Json(text)); return Task.CompletedTask; });
var items = new List<int> { 7 };
var state = new PlayerKVBargainState(true, 0,300,0,0,0,0,0,0,0,0,0,false,false,0,items);
items.Clear(); Check(state.selections.Count == 1, "DTO owns collection");
Check(state.selections is not List<int>, "collection is not mutable list");
});
}
}

View File

@@ -0,0 +1,44 @@
using game.PlayerDataStorage;
static partial class Tests
{
static async Task RunEventBaseSchemaTests()
{
await Run("M05-frozen-time-conflicts-and-whole-replacement",() =>
{
var raw=new Dictionary<string,string> {
["LimitedTimeEventDic"]="{\"1\":{\"id\":2,\"duration\":60.123456789,\"startTime\":\"2026-09-23T00:00:00.0000000Z\"},\"3\":{\"id\":4,\"duration\":60,\"startTime\":\"2026-09-22T00:00:00.0000000Z\"}}",
["FixedTimeEventDic"]="{\"5\":{\"id\":6,\"endTime\":\"raw\"}}",
["ETDropAfterDropDic"]="{\"1\":2,\"3\":4}",["RankTargetCount"]="{\"5\":6,\"7\":8}",
["WheelLevelCount"]="{\"9\":10}",["TurntableRoll"]="11",["WheelOperateCount"]="12",["AllWheelOperateCount"]="13",["WheelLevelFreeCount"]="14",["PVPToken"]="15"
};
var modern=new Dictionary<string,string>{["ct"]="1:2",["rt"]="5:6"};
var a=PlayerKVSaveSchema.DecodeEventBase(raw,modern,SchemaSnapshot(),new());
Check(a!=null && a.Limited.Count==1 && a.Limited[1].Id==2 && a.Limited[1].Duration==60.123456789 && a.Fixed[5].id==6,"frozen filter double precision");
var offsetSnapshot=new PlayerKVSchemaSnapshot("offset",Array.Empty<int>(),Array.Empty<int>(),new Dictionary<string,int>(),System.Globalization.CultureInfo.InvariantCulture,
SchemaSnapshot().NowUtc,17,globalTimeOffset:9);
var boundary=new Dictionary<string,string> { ["LimitedTimeEventDic"]="{\"1\":{\"id\":2,\"duration\":60,\"startTime\":\"2026-09-22T15:00:00.0000000Z\"}}" };
Check(PlayerKVSaveSchema.DecodeEventBase(boundary,new Dictionary<string,string>(),offsetSnapshot,new()).Limited.Count==1 &&
PlayerKVSaveSchema.DecodeEventBase(boundary,new Dictionary<string,string>(),SchemaSnapshot(),new()).Limited.Count==0,"captured game time offset used");
Check(a.Target.Count==1 && a.Target[1]==2 && a.Rank.Count==1 && a.Rank[5]==6 && a.Wheel[9]==10,"whole new map wins");
Check(a.Coupon==11 && a.Operate==12 && a.AllOperate==13 && a.Free==14 && a.PvpToken==15,"all scalars");
foreach(var key in raw.Keys)
Check(PlayerKVSaveSchema.DecodeEventBase(new Dictionary<string,string>(raw){[key]="null"},modern,SchemaSnapshot(),new())==null,"each bad key rejected");
modern["ct"]="1:99";
var e=new PlayerKVSchemaDiagnostics();
Check(PlayerKVSaveSchema.DecodeEventBase(raw,modern,SchemaSnapshot(),e)==null && e.Count(5,PlayerKVSchemaError.ConflictingRepresentations)==1,"overlap mismatch fails");
modern["ct"]=""; modern["rt"]="";
a=PlayerKVSaveSchema.DecodeEventBase(raw,modern,SchemaSnapshot(),new());
Check(a.Target.Count==0 && a.Rank.Count==0,"empty new replaces old");
foreach(var invalid in new[]{"1:2,01:2","1:2,","null",null})
{
modern["ct"]=invalid;
Check(PlayerKVSaveSchema.DecodeEventBase(raw,modern,SchemaSnapshot(),new())==null,"bad compact fails");
}
a=PlayerKVSaveSchema.DecodeEventBase(new Dictionary<string,string>(),new Dictionary<string,string>(),SchemaSnapshot(),new());
Check(a.Limited.Count==0 && a.Fixed.Count==0 && a.Target.Count==0 && a.Rank.Count==0 && a.Wheel.Count==0 && a.Coupon==17 && a.Operate==0 && a.AllOperate==0 && a.Free==0 && a.PvpToken==0,"missing captured config default");
Check(PlayerKVSaveSchema.DecodeEventBase(new Dictionary<string,string>{["LimitedTimeEventDic"]="{\"1\":{\"id\":2,\"duration\":-1,\"startTime\":\"bad\"}}"},new Dictionary<string,string>(),SchemaSnapshot(),new())==null,"expired candidate malformed time fails");
return Task.CompletedTask;
});
}
}

View File

@@ -0,0 +1,98 @@
using System.Globalization;
using game.PlayerDataStorage;
using Newtonsoft.Json;
static partial class Tests
{
static PlayerKVSchemaSnapshot FishSnapshot(Dictionary<int,PlayerKVFishConfig> fish=null,Dictionary<int,PlayerKVFishCardConfig> cards=null) =>
new("fish-v1",new[]{101},new[]{1,2},new Dictionary<string,int>(),CultureInfo.InvariantCulture,SchemaSnapshot().NowUtc,17,
fish:fish ?? new() { [101]=new(101,1,5,11,2,1,.5f),[102]=new(102,2,6,12,2,1,.5f) },
cards:cards ?? new() { [11]=new(11,1,new[]{10},new[]{.25f,.5f}),[12]=new(12,1,new[]{20},Array.Empty<float>()) });
static Dictionary<string,string> FishRaw(string record="2|99|1|7|True") => new() { ["Map_5_1"]=JsonConvert.SerializeObject(new Dictionary<int,string>{[101]=record}) };
static PlayerKVFishState FishDecode(Dictionary<string,string> raw,PlayerKVSchemaSnapshot snapshot=null,Dictionary<string,string> modern=null) =>
PlayerKVSaveSchema.DecodeFish(raw,modern ?? new(),snapshot ?? FishSnapshot(),new());
static async Task RunFishSchemaTests()
{
await Run("M03-D5-raw-before-derived-and-config-snapshot",() =>
{
var a=FishDecode(FishRaw());
Check(a.RawMaps[1][101].Level==2 && a.RawMaps[1][101].Weight==99 && a.RawMaps[1][101].Cards==1,"raw values retained");
Check(a.Maps[1][101].Level==1 && a.Maps[1][101].Weight==4.5f && a.Maps[1][101].Cards==10 && a.Cards[11]==10,"weight before level cap, cards raised");
Check(ReferenceEquals(a.Fish[101],a.Maps[1][101]) && a.Fish[101].Number==7 && a.Fish[101].New,"pure index coherence");
var r=FishRaw(); r["Map_1"]="{\"101\":{\"Level\":2,\"MaxG\":98,\"TotalCard\":1,\"TotalNum\":7,\"NewFish\":true}}";
var errors=new PlayerKVSchemaDiagnostics();
Check(PlayerKVSaveSchema.DecodeFish(r,new Dictionary<string,string>(),FishSnapshot(),errors)==null && errors.Count(3,PlayerKVSchemaError.ConflictingRepresentations)==1,"different raw weights cannot normalize into equality");
r["Map_1"]=r["Map_1"].Replace("98","99"); Check(FishDecode(r)!=null,"matching formats accepted");
r["Map_5_1"]="{}"; Check(FishDecode(r).Maps[1].Count==0,"new empty replaces old map");
var required=new[]{10}; var weights=new[]{.5f}; var configs=new Dictionary<int,PlayerKVFishConfig>{[101]=new(101,1,5,11,2,1,0)};
var cards=new Dictionary<int,PlayerKVFishCardConfig>{[11]=new(11,1,required,weights)}; var snapshot=FishSnapshot(configs,cards);
required[0]=99; weights[0]=99; configs.Clear(); cards.Clear();
a=FishDecode(FishRaw("1|99|1|7|false"),snapshot);
Check(a.Fish[101].Weight==3 && a.Fish[101].Cards==10,"input configuration collections copied");
return Task.CompletedTask;
});
await Run("M03-D5-weight-directions-quality-and-invalid-config",() =>
{
Check(FishDecode(FishRaw("1|.125|1|7|false")).Fish[101].Weight==.125f,"P3-3 low weight not raised");
Check(FishDecode(FishRaw("0|-2|-3|-4|false")).Fish[101].Weight==-2,"finite negatives not banned");
Check(FishDecode(FishRaw("-1|99|-3|-4|false")).Fish[101].Level==-1,"nonpositive level preserved");
var high=new Dictionary<string,string>{["Map_5_2"]="{\"102\":\"5|99|1|7|False\"}"};
var a=FishDecode(high); Check(a.Fish[102].Level==1 && a.Fish[102].Weight==99 && a.Cards[12]==20,"quality6 skips weight but caps level");
foreach(var record in new[]{"3|99|1|7|true","2147483647|99|1|7|true"}) Check(FishDecode(FishRaw(record))==null,"weight index checked before cap");
foreach(var fish in new[]{new PlayerKVFishConfig(101,2,5,11,2,1,0),new(101,1,5,999,2,1,0),new(101,1,5,11,float.MaxValue,2,0),new(101,1,5,11,float.NaN,1,0)})
{
var e=new PlayerKVSchemaDiagnostics();
Check(PlayerKVSaveSchema.DecodeFish(FishRaw(),new Dictionary<string,string>(),FishSnapshot(new(){[101]=fish}),e)==null && e.Count(3,PlayerKVSchemaError.UnsupportedSchema)==1,"invalid config pre-apply rejection");
}
foreach(var card in new[]{new PlayerKVFishCardConfig(11,0,new[]{10},new[]{0f,0f}),new(11,1,Array.Empty<int>(),new[]{0f,0f}),new(11,1,new[]{10},new[]{0f,float.PositiveInfinity})})
Check(FishDecode(FishRaw(),FishSnapshot(cards:new(){[11]=card}))==null,"card bounds and finite weights");
Check(FishDecode(FishRaw(),FishSnapshot(fish:new Dictionary<int,PlayerKVFishConfig>()))==null,"unknown fish rejected");
return Task.CompletedTask;
});
await Run("M03-D5-all-fields-accessories-and-luck",() =>
{
var raw=FishRaw();
var intKeys=new[]{"RodData","RodLevelData","FishSwimToMap","FishSwimToMapCount","MapProgress","HonorLevelData"};
foreach(var k in intKeys) raw[k]="{\"-1\":7}";
raw["InitRodAccessoriesData"]="{\"GloveID\":1,\"RodToSkin\":{\"2\":3},\"GloveIds\":[4],\"SkinIds\":[5],\"GloveNewIds\":[6],\"SkinNewIds\":[7]}";
raw["LuckDataDic"]="{\"InitFishLuck\":1.25,\"InitBotLuck\":2.5,\"BotCount\":3,\"unknown\":4}";
var modern=new Dictionary<string,string>{["LDD"]=raw["LuckDataDic"],["ld"]="1.25|2.5|3"};
var a=FishDecode(raw,modern:modern);
Check(new[]{a.Rods,a.RodLevels,a.Swim,a.SwimCount,a.Progress,a.Honor}.All(m=>m[-1]==7),"six maps");
var acc=a.Accessories; Check(acc.Glove==1 && acc.RodSkin[2]==3 && acc.Gloves[0]==4 && acc.Skins[0]==5 && acc.NewGloves[0]==6 && acc.NewSkins[0]==7,"all six accessories fields");
Check(a.Luck.Count==3 && !a.Luck.ContainsKey("unknown"),"highest priority whole luck replaces");
foreach(var key in raw.Keys)
{
var bad=new Dictionary<string,string>(raw){[key]="null"}; Check(FishDecode(bad,modern:modern)==null,"present bad rejected");
bad[key]=null; Check(FishDecode(bad,modern:modern)==null,"present null rejected");
}
foreach(var invalid in new[]{"1|2|3.5","1|2|2147483648","1|2|-2147483904","NaN|2|3","1|2|3|4",null})
Check(FishDecode(new(),modern:new(){["ld"]=invalid})==null,"bad compact or lossy BotCount");
Check(FishDecode(new(),modern:new(){["ld"]="0|0|-2147483648"}).Luck["BotCount"]==int.MinValue,"exact lower bound allowed");
modern["ld"]="1.25|2.5|4"; var e=new PlayerKVSchemaDiagnostics();
Check(PlayerKVSaveSchema.DecodeFish(raw,modern,FishSnapshot(),e)==null && e.Count(3,PlayerKVSchemaError.ConflictingRepresentations)>0,"luck conflicts detected");
a=FishDecode(new()); Check(a.Maps.Count==0 && a.Fish.Count==0 && a.Cards.Count==0 && a.Accessories==null && a.Luck.Count==0 && a.Rods.Count==0,"missing explicit empty");
return Task.CompletedTask;
});
await Run("M03-D5-map-layout-compatibility-and-order",() =>
{
var a=FishDecode(FishRaw("2|99|ignored||compat|1|7|True|tail"));
Check(a.RawMaps[1][101].Compatibility.SequenceEqual(new[]{"ignored","","compat","tail"}),"all four ignored slots retained");
foreach(var record in new[]{"2|99|1|7","2|99|1|7|true|extra","2|NaN|1|7|true","2|99|1.5|7|true","2|99|1|7|1"})
Check(FishDecode(FishRaw(record))==null,"malformed consumed slots");
foreach(var key in new[]{"Map_5_01","Map_0","Map_5_0","Map_3","Map_2147483648"})
Check(FishDecode(new(){[key]="{}"})==null,"invalid map suffix");
Check(FishDecode(new(){["Map_other"]="not-json"})!=null,"nonmatching keys unconsumed");
var fish=new Dictionary<int,PlayerKVFishConfig>{[101]=new(101,1,6,11,1,1,0),[102]=new(102,2,6,11,1,1,0)};
var raw=new Dictionary<string,string>{["Map_5_1"]="{\"101\":\"0|1|1|1|true\"}",["Map_5_2"]="{\"102\":\"0|1|2|1|true\"}"};
foreach(var r in new[]{raw,raw.Reverse().ToDictionary(p=>p.Key,p=>p.Value)})
{
var e=new PlayerKVSchemaDiagnostics(); Check(PlayerKVSaveSchema.DecodeFish(r,new Dictionary<string,string>(),FishSnapshot(fish),e)==null && e.Count(3,PlayerKVSchemaError.UnsupportedSchema)==1,"card conflict order independent");
}
raw["Map_5_2"]="{\"102\":\"0|1|1|1|true\"}";
Check(FishDecode(raw,FishSnapshot(fish)).Cards[11]==1,"matching shared card quantity allowed");
Check(FishDecode(new(){["Map_5_1"]="{\"101\":\"0|1|1|1|true\",\"0101\":\"0|1|1|1|true\"}"})==null,"normalized duplicate fish ids");
return Task.CompletedTask;
});
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework>
<DefineConstants>UNITY_EDITOR</DefineConstants><ImplicitUsings>enable</ImplicitUsings><Nullable>disable</Nullable>
<RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile>
</PropertyGroup>
<ItemGroup>
<Compile Remove="ServiceHost/**/*.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVInitializationIntents.cs" Link="Production/PlayerKVInitializationIntents.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadTransaction.cs" Link="Production/PlayerKVGameLoadTransaction.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadGuard.cs" Link="Production/PlayerKVGameLoadGuard.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVControlledLoadCapability.cs" Link="Production/PlayerKVControlledLoadCapability.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs" Link="Production/PlayerKVGameLoadCoordinator.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveSchema.cs" Link="Production/PlayerKVSaveSchema.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVDecodedSave.cs" Link="Production/PlayerKVDecodedSave.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVShutdownRegistry.cs" Link="Production/PlayerKVShutdownRegistry.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs" Link="Production/PlayerKVLoginContext.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginPrepareResult.cs" Link="Production/PlayerKVLoginPrepareResult.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVGameSessionBridge.cs" Link="Production/PlayerKVGameSessionBridge.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs" Link="Production/PlayerKVModels.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs" Link="Production/PlayerKVClient.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs" Link="Production/PlayerKVSyncModels.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs" Link="Production/PlayerKVCacheStore.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs" Link="Production/PlayerKVSyncState.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs" Link="Production/PlayerKVSyncCoordinator.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSessionOwner.cs" Link="Production/PlayerKVSessionOwner.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVLocalReadResult.cs" Link="Production/PlayerKVLocalReadResult.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/Account/S101AccountSession.cs" Link="Production/S101AccountSession.cs" />
<Reference Include="Newtonsoft.Json"><HintPath>../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath></Reference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,42 @@
using game.PlayerDataStorage;
static partial class Tests
{
static PlayerKVIdentitySnapshot Identity(bool fresh=false,string id="FF",PlayerKVIdentityChannel channel=PlayerKVIdentityChannel.none) =>
new(id,fresh,channel,Array.Empty<PlayerKVIdentityChannel>());
static PlayerKVLoginState LoginDecode(Dictionary<string,string> raw,PlayerKVIdentitySnapshot identity=null) =>
PlayerKVSaveSchema.DecodeLogin(raw,identity ?? Identity(),new());
static async Task RunLoginSchemaTests()
{
await Run("M31-MMTest-identity-boundaries",()=>
{
Check(LoginDecode(new(){["MMTest"]="MMFailParam#-1"}).MMTest==-1,"declared signed value retained");
Check(LoginDecode(new(){["MMTest"]="MMFailParam#12"},Identity(true,"bad-id")).Normalizations.Count==0,"existing value does not use PF id");
foreach(var text in new[]{"old#123","", "something"}) { var a=LoginDecode(new(){["MMTest"]=text}); Check(a.MMTest==0 && a.Normalizations["MMTest"]=="MMFailParam#0","old format normalized"); }
Check(LoginDecode(new()).MMTest==0 && LoginDecode(new(),Identity(true)).MMTest==15,"PF newly created determines bucket");
Check(LoginDecode(new(),Identity(false,"not-hex")).MMTest==0,"old PF profile ignores id format");
foreach(var id in new[]{"",null," 1","-1","0xFF","10000000000000000","invalid"}) Check(LoginDecode(new(),Identity(true,id))==null,"invalid new PF id");
foreach(var mm in new[]{null,"MMFailParam","MMFailParam#","MMFailParam#1#2","MMFailParam#2147483648","MMFailParam#1.0"}) Check(LoginDecode(new(){["MMTest"]=mm})==null,"malformed current layout");
return Task.CompletedTask;
});
await Run("M31-channels-url-balance-and-intents",async()=>
{
var input=new Dictionary<string,string>{["hwGoogle"]="1",["hwFacebook"]="0",["Apple"]="1",["EAccoutUrl"]="{\"synthetic\":\"avatar\"}",["TapJoy_Diamond"]="-3"};
var a=LoginDecode(input,Identity(channel:PlayerKVIdentityChannel.hwVkid));
Check(a.Linked.SequenceEqual(new[]{PlayerKVIdentityChannel.hwGoogle,PlayerKVIdentityChannel.Apple,PlayerKVIdentityChannel.hwVkid}) && a.TapJoyBalance==-3 && a.AccountUrls["synthetic"]=="avatar","merged private channels, url and balance");
Check(a.Normalizations.Count==2 && a.Normalizations["hwVkid"]=="1" && !input.ContainsKey("MMTest"),"two normalized copies without input mutation");
input.Clear(); Check(a.AccountUrls.Count==1 && a.Normalizations.Count==2,"independent immutable data");
foreach(var key in new[]{"hwGoogle","hwFacebook","Apple","hwVkid"}) foreach(var value in new[]{null,"true","2","01"})
Check(LoginDecode(new(){[key]=value},Identity(channel:PlayerKVIdentityChannel.hwVkid))==null,"flags exact 0/1");
foreach(var key in new[]{"EAccoutUrl","TapJoy_Diamond"}) Check(LoginDecode(new(){[key]="null"})==null,"bad optional field not missing");
var missing=LoginDecode(new()); Check(missing.AccountUrls.Count==0 && missing.TapJoyBalance==0 && missing.Linked.Count==0,"empty state");
var intents=new PlayerKVInitializationIntents(7,PlayerKVIdentityChannel.hwVkid);
intents.Add(7,"MMTest","MMFailParam#1"); intents.Add(7,"MMTest","MMFailParam#2"); intents.Add(7,"hwVkid","1");
var captured=intents.Snapshot; Check(intents.Count==2 && captured["MMTest"]=="MMFailParam#2","dedupe and last value");
await Throws<InvalidOperationException>(()=>{intents.Add(8,"MMTest","x");return Task.CompletedTask;});
await Throws<InvalidOperationException>(()=>{intents.Add(7,"other","x");return Task.CompletedTask;});
intents.Discard(); intents.Discard(); Check(intents.Discarded && intents.Count==0 && intents.DiscardedCount==2 && captured.Count==2,"discard idempotent, historical immutable view");
await Throws<InvalidOperationException>(()=>{intents.Add(7,"MMTest","x");return Task.CompletedTask;});
});
}
}

View File

@@ -0,0 +1,43 @@
using game.PlayerDataStorage;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
static partial class Tests
{
static async Task RunOfferSchemaTests()
{
foreach(bool disco in new[] { false,true })
await Run(disco ? "M17-offer-nested-shape-and-enum" : "M16-offer-nested-shape-and-state",() =>
{
var obj=JObject.FromObject(new { FirstOpen=true,EventId=7,StartTime="2026-09-22T00:00:00.0000000Z",EndTime="2026-09-24T00:00:00.0000000Z",
ItemId=-1,LotteryNum=5,Items=new[] { new { Id=1,Item=2,Number=3,Status=2 } },RewardList=new[] { 8,9 },Finished=true,ChestReward=10 });
if (disco) { obj.Remove("Finished"); obj.Remove("ChestReward"); }
string key=disco ? "OfferDiscoPartyManager" : "OfferChainsChestData";
Dictionary<string,string> Raw(JObject value)=>new() { [key]=value.ToString(Formatting.None) };
var state=PlayerKVSaveSchema.DecodeOffer(Raw(obj),disco,SchemaSnapshot(),new());
Check(state.IsPresent && state.FirstOpen && state.EventId==7 && state.StartTime.Day==22 && state.EndTime.Day==24 &&
state.ItemId == -1 && state.LotteryNum==5,"all scalar fields");
Check(state.Items.Count==1 && state.Items[0].Id==1 && state.Items[0].Item==2 && state.Items[0].Number==3 && state.Items[0].Status==2 &&
state.RewardList.SequenceEqual(new[] { 8,9 }),"complete nested collections");
Check(disco || (state.Finished && state.ChestReward==10),"chains only fields");
Check(!PlayerKVSaveSchema.DecodeOffer(new Dictionary<string,string>(),disco,SchemaSnapshot(),new()).IsPresent,"missing explicit");
var paths=obj.Properties().Select(p=>p.Name).Concat(new[] { "Items[0]","Items[0].Id","Items[0].Item","Items[0].Number","Items[0].Status","RewardList[0]" });
foreach(var path in paths)
{
var bad=(JObject)obj.DeepClone(); bad.SelectToken(path).Replace(JValue.CreateNull());
Check(PlayerKVSaveSchema.DecodeOffer(Raw(bad),disco,SchemaSnapshot(),new())==null,"all consumed null fields rejected");
}
foreach(var path in new[] { "EventId","Items[0].Id","Items[0].Status","RewardList[0]" })
{
var bad=(JObject)obj.DeepClone(); bad.SelectToken(path).Replace(new JValue(1.5));
Check(PlayerKVSaveSchema.DecodeOffer(Raw(bad),disco,SchemaSnapshot(),new())==null,"integer coercion rejected");
}
if(disco)
{
var bad=(JObject)obj.DeepClone(); bad["Items"][0]["Status"]=3;
Check(PlayerKVSaveSchema.DecodeOffer(Raw(bad),true,SchemaSnapshot(),new())==null,"undefined enum rejected");
}
return Task.CompletedTask;
});
}
}

View File

@@ -0,0 +1,56 @@
using game.PlayerDataStorage;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
static partial class Tests
{
static JObject PartnerFixture() => JObject.FromObject(new
{
FirstOpen=false, EventId=18, StartTime="2026-09-23T00:00:00.0000000Z", EndTime="2026-09-24T00:00:00.0000000Z",
CurrentPoints=1,CurrentMiningProgress=2,Token=3,InvitedCount=4,TicketCount=5,
CompletedNodesList=new[]{new[]{6,7},Array.Empty<int>()},BuilderPartnerScore=new[]{new{Item1=8,Item2=9}},
PartnerBonus=new{Item1=1.25f,Item2="2026-09-23T01:00:00.0000000Z"},FreeSpinNum=10,
Bots=new Dictionary<int,object>{[11]=new{Id="synthetic-bot",ScorePlayer=12,ScoreBot=13,Grade=14,TargetScore=15,WakeStartTime="2026-09-23T02:00:00.0000000Z",WakingSeconds=16}},
IsRecvFinalReward=true,UIFinishedPartners=new[]{true,false},IsFinished=true,SpinState=2,InflationRate=2.5f,
IsEnableAutoMatch=true,LastAutoMatchTime="2026-09-23T03:00:00.0000000Z"
});
static PlayerKVPartnerState PartnerDecode(JObject o,PlayerKVSchemaDiagnostics errors=null) => PlayerKVSaveSchema.DecodePartner(
new Dictionary<string,string>{["EventPartnerGatherData"]=o.ToString(Formatting.None)},SchemaSnapshot(),errors ?? new());
static async Task RunPartnerSchemaTests()
{
await Run("M18-all-fields-missing-deep-copy",()=>
{
var o=PartnerFixture(); var a=PartnerDecode(o);
Check(a.Present && !a.FirstOpen && a.EventId==18 && a.StartTime.Day==23 && a.EndTime.Day==24,"identity and dates");
Check(a.CurrentPoints==1 && a.CurrentMiningProgress==2 && a.Token==3 && a.InvitedCount==4 && a.TicketCount==5,"counts");
Check(a.CompletedNodesList[0].SequenceEqual(new[]{6,7}) && a.CompletedNodesList[1].Count==0 && a.BuilderPartnerScore[0]==(8,9),"nested nodes and tuple");
Check(a.BonusPercent==1.25f && a.BonusExpireUtc.Hour==1 && a.FreeSpinNum==10,"bonus tuple");
var b=a.Bots[11]; Check(b.Id=="synthetic-bot" && b.ScorePlayer==12 && b.ScoreBot==13 && b.Grade==14 && b.TargetScore==15 && b.WakeStartTime.Hour==2 && b.WakingSeconds==16,"all bot data");
Check(a.IsRecvFinalReward && a.UIFinishedPartners.SequenceEqual(new[]{true,false}) && a.IsFinished && a.SpinState==2 && a.InflationRate==2.5f && a.IsEnableAutoMatch && a.LastAutoMatchTime.Hour==3,"remaining scalar/list fields");
o["CompletedNodesList"][0][0]=999; ((JObject)o["Bots"]).RemoveAll();
Check(a.CompletedNodesList[0][0]==6 && a.Bots.Count==1,"detached input");
var missing=PlayerKVSaveSchema.DecodePartner(new Dictionary<string,string>(),SchemaSnapshot(),new());
Check(!missing.Present && missing.Bots.Count==0 && missing.CompletedNodesList.Count==0 && missing.BuilderPartnerScore.Count==0 && missing.UIFinishedPartners.Count==0,"missing is empty");
return Task.CompletedTask;
});
await Run("M18-present-strict-fields-and-nested-values",()=>
{
foreach(var property in PartnerFixture().Properties())
{
var o=PartnerFixture(); o.Remove(property.Name); Check(PartnerDecode(o)==null,"each field required");
o=PartnerFixture(); o[property.Name]=JValue.CreateNull(); Check(PartnerDecode(o)==null,"each field rejects null");
}
foreach(var path in new[]{"PartnerBonus.Item1","PartnerBonus.Item2","BuilderPartnerScore[0].Item1","BuilderPartnerScore[0].Item2",
"CompletedNodesList[0]","CompletedNodesList[0][0]","UIFinishedPartners[0]","Bots.11.Id","Bots.11.ScorePlayer","Bots.11.ScoreBot",
"Bots.11.Grade","Bots.11.TargetScore","Bots.11.WakeStartTime","Bots.11.WakingSeconds"})
{ var o=PartnerFixture(); o.SelectToken(path).Replace(JValue.CreateNull()); Check(PartnerDecode(o)==null,"nested null rejected"); }
foreach(var spin in new[]{-1,3,int.MaxValue}) { var o=PartnerFixture(); o["SpinState"]=spin; Check(PartnerDecode(o)==null,"undefined enum rejected"); }
var invalid=PartnerFixture(); invalid["PartnerBonus"]["Item2"]="not-a-date"; Check(PartnerDecode(invalid)==null,"invalid date");
invalid=PartnerFixture(); invalid["InflationRate"]=double.MaxValue; Check(PartnerDecode(invalid)==null,"nonfinite float");
invalid=PartnerFixture(); invalid["Token"]=(long)int.MaxValue+1; Check(PartnerDecode(invalid)==null,"int overflow");
foreach(var raw in new[]{"null","[]","{\"EventId\":1,\"EventId\":2}"})
{ var e=new PlayerKVSchemaDiagnostics(); Check(PlayerKVSaveSchema.DecodePartner(new Dictionary<string,string>{["EventPartnerGatherData"]=raw},SchemaSnapshot(),e)==null && e.Count(18,PlayerKVSchemaError.Malformed)==1,"strict root"); }
return Task.CompletedTask;
});
}
}

View File

@@ -0,0 +1,26 @@
using game.PlayerDataStorage;
using Newtonsoft.Json.Linq;
static partial class Tests
{
static async Task RunPinataSchemaTests()
{
await Run("M29-pinata-three-integer-record",() =>
{
var valid=JObject.Parse("{\"EventID\":1,\"PinataMainID\":2,\"PurchasedCount\":3}");
Dictionary<string,string> Raw(JObject obj)=>new() { ["EventPinataPackData"]=obj.ToString() };
var a=PlayerKVSaveSchema.DecodePinata(Raw(valid),new());
Check(a.EventID==1 && a.PinataMainID==2 && a.PurchasedCount==3,"all scalars");
foreach(var p in valid.Properties())
foreach(var invalid in new JToken[] { JValue.CreateNull(),new JValue("1"),new JValue(1.5),new JValue(2147483648L) })
{
var bad=(JObject)valid.DeepClone(); bad[p.Name]=invalid;
Check(PlayerKVSaveSchema.DecodePinata(Raw(bad),new())==null,"strict integer fields");
}
var missing=PlayerKVSaveSchema.DecodePinata(new Dictionary<string,string>(),new());
Check(missing.EventID==0 && missing.PinataMainID==0 && missing.PurchasedCount==0,"all missing zeros");
Check(PlayerKVSaveSchema.DecodePinata(new Dictionary<string,string> { ["EventPinataPackData"]="null" },new())==null,"null not missing");
return Task.CompletedTask;
});
}
}

View File

@@ -0,0 +1,40 @@
using game.PlayerDataStorage;
using Newtonsoft.Json;
static partial class Tests
{
static async Task RunPlayerSchemaTests()
{
await Run("M01-player-additional-fields-and-ownership",() =>
{
var raw=ModernRaw(); raw["NewRodList"]="[7,8]"; raw["SeasonId"]="9"; raw["IsOpenAppraise"]=""; raw["InfiniteBuildingData"]="raw";
var modern=new Dictionary<string,string> { ["newData"]=string.Join(",",CompleteCore),["vipL"]="-1,5|0,6|1,7" };
var a=PlayerKVSaveSchema.DecodePlayer(raw,modern,SchemaSnapshot(),new());
Check(a!=null && a.VipScores[-1]==5 && a.VipScores[0]==6 && a.VipScores[1]==7 &&
a.NewRods.SequenceEqual(new[] { 7,8 }) && a.Season==9 && a.Appraise && a.Building=="raw","all extra fields");
modern["vipL"]="";
Check(a.Modern["vipL"]=="-1,5|0,6|1,7","modern copied");
var b=PlayerKVSaveSchema.DecodePlayer(ModernRaw(),modern,SchemaSnapshot(),new());
Check(b.NewRods.Count==0 && b.VipScores.Count==0 && b.Season==0 && !b.Appraise && b.Building=="0","missing building restores original zero default");
foreach(var text in new[]{"", "0", "23,7"})
{
var existing=ModernRaw(); existing["InfiniteBuildingData"]=text;
Check(PlayerKVSaveSchema.DecodePlayer(existing,modern,SchemaSnapshot(),new()).Building==text,"existing building string unchanged");
}
foreach(var vip in new[] { "0,1|00,2","0,1|","1,2,3","null",null })
{
modern["vipL"]=vip;
Check(PlayerKVSaveSchema.DecodePlayer(raw,modern,SchemaSnapshot(),new())==null,"bad vip map rejects");
}
modern["vipL"]="";
foreach(var key in new[] { "NewRodList","SeasonId","IsOpenAppraise","InfiniteBuildingData" })
{
var bad=new Dictionary<string,string>(raw) { [key]=null };
Check(PlayerKVSaveSchema.DecodePlayer(bad,modern,SchemaSnapshot(),new())==null,"present null extra fails");
}
var missingCore=new Dictionary<string,string>();
Check(PlayerKVSaveSchema.DecodePlayer(missingCore,new Dictionary<string,string>(),SchemaSnapshot(),new())==null,"no default account creation");
return Task.CompletedTask;
});
}
}

View File

@@ -0,0 +1,54 @@
using game.PlayerDataStorage;
using Newtonsoft.Json.Linq;
static partial class Tests
{
static async Task RunPotionSchemaTests()
{
await Run("M25-potion-complete-nested-and-enums",() =>
{
var item=new { name="synthetic",index=3,isOver=true,DataType=2,ColorTypeList=new[] { 7,9 },ShelfTypeList=new[] { 0,1 },GridStateList=new[] { 0,5 },FromDisplayAreaIndexList=new[] { 1,2 } };
var obj=JObject.FromObject(new { EventID=11,PotionID=11,StageID=11,Stage1TotalLevel=11,Stage2TotalLevel=11,LevelID=11,LevelIndex=11,CorrectNum=11,SubmitNum=11,Token=11,ConsumedToken=11,Rule=11,PreEventID=11,PrePotionID=11,PreStageID=11,PreLevelID=11,Round=11,FirstPlayedEventId=11,IsOver=true,Name="synthetic",InflationRate=1.5f,LevelTargetData=item,LevelDisplayData=item,LevelProcessedList=new[] { item } });
Dictionary<string,string> Raw(JObject value)=>new() { ["epdk"]=value.ToString() };
var state=PlayerKVSaveSchema.DecodePotion(Raw(obj),new());
Check(state!=null && state.IsPresent,"present valid");
Check(state.EventID==11,"EventID");
Check(state.PotionID==11,"PotionID");
Check(state.StageID==11,"StageID");
Check(state.Stage1TotalLevel==11,"Stage1TotalLevel");
Check(state.Stage2TotalLevel==11,"Stage2TotalLevel");
Check(state.LevelID==11,"LevelID");
Check(state.LevelIndex==11,"LevelIndex");
Check(state.CorrectNum==11,"CorrectNum");
Check(state.SubmitNum==11,"SubmitNum");
Check(state.Token==11,"Token");
Check(state.ConsumedToken==11,"ConsumedToken");
Check(state.Rule==11,"Rule");
Check(state.PreEventID==11,"PreEventID");
Check(state.PrePotionID==11,"PrePotionID");
Check(state.PreStageID==11,"PreStageID");
Check(state.PreLevelID==11,"PreLevelID");
Check(state.Round==11,"Round");
Check(state.FirstPlayedEventId==11,"FirstPlayedEventId");
Check(state.IsOver==true,"IsOver");
Check(state.Name=="synthetic","Name");
Check(state.InflationRate==1.5f,"InflationRate");
foreach(var entry in new[] { state.LevelTargetData,state.LevelDisplayData,state.LevelProcessedList[0] })
Check(entry.Name=="synthetic" && entry.Index==3 && entry.IsOver && entry.DataType==2 && entry.Colors.SequenceEqual(new[] { 7,9 }) &&
entry.Shelves.SequenceEqual(new[] { 0,1 }) && entry.Grids.SequenceEqual(new[] { 0,5 }) && entry.FromDisplay.SequenceEqual(new[] { 1,2 }),"all nested fields");
foreach(var path in obj.Properties().Select(p=>p.Name).Concat(new[] { "LevelTargetData.name","LevelTargetData.index","LevelTargetData.isOver","LevelTargetData.DataType",
"LevelTargetData.ColorTypeList","LevelTargetData.ShelfTypeList","LevelTargetData.GridStateList","LevelTargetData.FromDisplayAreaIndexList","LevelProcessedList[0]" }))
{
var bad=(JObject)obj.DeepClone(); bad.SelectToken(path).Replace(JValue.CreateNull());
Check(PlayerKVSaveSchema.DecodePotion(Raw(bad),new())==null,"all nested nulls rejected");
}
foreach(var path in new[] { "LevelTargetData.DataType","LevelTargetData.ShelfTypeList[0]","LevelTargetData.GridStateList[0]" })
{
var bad=(JObject)obj.DeepClone(); bad.SelectToken(path).Replace(new JValue(99));
Check(PlayerKVSaveSchema.DecodePotion(Raw(bad),new())==null,"undefined enum rejected");
}
Check(!PlayerKVSaveSchema.DecodePotion(new Dictionary<string,string>(),new()).IsPresent,"missing distinct");
return Task.CompletedTask;
});
}
}

Some files were not shown because too many files have changed in this diff Show More