test(upserver): 归档存档迁移与玩法闭环回归工具
This commit is contained in:
11
CodexOutput/upserver/B3DesignAudit/B3DesignAudit.csproj
Normal file
11
CodexOutput/upserver/B3DesignAudit/B3DesignAudit.csproj
Normal 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>
|
||||||
81
CodexOutput/upserver/B3DesignAudit/Program.cs
Normal file
81
CodexOutput/upserver/B3DesignAudit/Program.cs
Normal 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;}
|
||||||
|
}
|
||||||
89
CodexOutput/upserver/B3DesignAudit/render_inventory.py
Normal file
89
CodexOutput/upserver/B3DesignAudit/render_inventory.py
Normal 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('|', '|').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}))
|
||||||
@@ -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>
|
||||||
49
CodexOutput/upserver/ClubRegression/Program.cs
Normal file
49
CodexOutput/upserver/ClubRegression/Program.cs
Normal 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");
|
||||||
42
CodexOutput/upserver/ClubRegression/generate.py
Normal file
42
CodexOutput/upserver/ClubRegression/generate.py
Normal 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;}}
|
||||||
|
""")
|
||||||
1
CodexOutput/upserver/ExitTests/ExitTests.csproj
Normal file
1
CodexOutput/upserver/ExitTests/ExitTests.csproj
Normal 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>
|
||||||
30
CodexOutput/upserver/ExitTests/Program.cs
Normal file
30
CodexOutput/upserver/ExitTests/Program.cs
Normal 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(){}}
|
||||||
@@ -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();
|
||||||
@@ -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()});
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
return KVGameplayLoopSetup.InspectPFBackup("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/pf-source-backups/20260924T041802Z/101.json");
|
||||||
@@ -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"});
|
||||||
@@ -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"});
|
||||||
@@ -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"});
|
||||||
@@ -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"});
|
||||||
@@ -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
|
||||||
1
CodexOutput/upserver/HomeExitTests/HomeExitTests.csproj
Normal file
1
CodexOutput/upserver/HomeExitTests/HomeExitTests.csproj
Normal 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>
|
||||||
29
CodexOutput/upserver/HomeExitTests/Program.cs
Normal file
29
CodexOutput/upserver/HomeExitTests/Program.cs
Normal 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++;}
|
||||||
7
CodexOutput/upserver/HomeExitTests/generate.py
Normal file
7
CodexOutput/upserver/HomeExitTests/generate.py
Normal 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')
|
||||||
@@ -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>
|
||||||
17
CodexOutput/upserver/HomeLoginTests/Program.cs
Normal file
17
CodexOutput/upserver/HomeLoginTests/Program.cs
Normal 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");
|
||||||
17
CodexOutput/upserver/HomeLoginTests/generate.py
Normal file
17
CodexOutput/upserver/HomeLoginTests/generate.py
Normal 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)
|
||||||
16
CodexOutput/upserver/KVContractTests/KVContractTests.csproj
Normal file
16
CodexOutput/upserver/KVContractTests/KVContractTests.csproj
Normal 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>
|
||||||
175
CodexOutput/upserver/KVContractTests/Program.cs
Normal file
175
CodexOutput/upserver/KVContractTests/Program.cs
Normal 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);}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();}
|
||||||
|
}}
|
||||||
@@ -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>
|
||||||
22
CodexOutput/upserver/KVFishingConsumeTests/LegacyRed.cs.txt
Normal file
22
CodexOutput/upserver/KVFishingConsumeTests/LegacyRed.cs.txt
Normal 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; } }
|
||||||
@@ -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>
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
return await KVGameplayLoopSetup.RunValidationAsync("fishing-consume");
|
||||||
@@ -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()};
|
||||||
@@ -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"});
|
||||||
@@ -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"});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using asap.core;
|
||||||
|
_ = GContext.container.Resolve<IStageService>().SwitchStageAsync("QuitStage");
|
||||||
|
return "normal QuitStage requested; inspect state separately";
|
||||||
@@ -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()};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();}
|
||||||
|
}}
|
||||||
@@ -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>
|
||||||
@@ -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);}
|
||||||
|
}}
|
||||||
@@ -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>
|
||||||
123
CodexOutput/upserver/KVFishingRewardTests/FormulaTests.cs.txt
Normal file
123
CodexOutput/upserver/KVFishingRewardTests/FormulaTests.cs.txt
Normal 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;
|
||||||
|
}}
|
||||||
@@ -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>
|
||||||
17
CodexOutput/upserver/KVFishingRewardTests/LegacyRed.cs.txt
Normal file
17
CodexOutput/upserver/KVFishingRewardTests/LegacyRed.cs.txt
Normal 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++;} }
|
||||||
@@ -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>
|
||||||
49
CodexOutput/upserver/KVFishingRewardTests/LegacySlice.cs.txt
Normal file
49
CodexOutput/upserver/KVFishingRewardTests/LegacySlice.cs.txt
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();}
|
||||||
@@ -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>
|
||||||
56
CodexOutput/upserver/KVFishingRewardTests/PanelSlice.cs.txt
Normal file
56
CodexOutput/upserver/KVFishingRewardTests/PanelSlice.cs.txt
Normal 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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
31
CodexOutput/upserver/KVFishingRewardTests/PanelTests.cs.txt
Normal file
31
CodexOutput/upserver/KVFishingRewardTests/PanelTests.cs.txt
Normal 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(){}}
|
||||||
|
}}
|
||||||
@@ -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>
|
||||||
16
CodexOutput/upserver/KVFishingRewardTests/PermitTests.cs.txt
Normal file
16
CodexOutput/upserver/KVFishingRewardTests/PermitTests.cs.txt
Normal 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;
|
||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
@@ -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))
|
||||||
@@ -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')
|
||||||
@@ -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))};
|
||||||
@@ -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))));
|
||||||
@@ -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"};
|
||||||
@@ -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)));
|
||||||
@@ -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
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
return KVGameplayLoopSetup.State();
|
||||||
@@ -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()
|
||||||
|
});
|
||||||
@@ -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;}
|
||||||
@@ -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"});
|
||||||
|
|
||||||
@@ -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"});
|
||||||
|
|
||||||
@@ -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"});
|
||||||
@@ -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"});
|
||||||
@@ -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()};
|
||||||
|
|
||||||
@@ -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};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
return await KVGameplayLoopSetup.RunValidationAsync("fishing-reward");
|
||||||
@@ -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()};
|
||||||
@@ -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()};
|
||||||
@@ -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()};
|
||||||
@@ -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>
|
||||||
380
CodexOutput/upserver/KVGameBridgeTests/Program.cs
Normal file
380
CodexOutput/upserver/KVGameBridgeTests/Program.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
141
CodexOutput/upserver/KVGameLoadTests/ActivitySchemaTests.cs
Normal file
141
CodexOutput/upserver/KVGameLoadTests/ActivitySchemaTests.cs
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
83
CodexOutput/upserver/KVGameLoadTests/AggregateTests.cs
Normal file
83
CodexOutput/upserver/KVGameLoadTests/AggregateTests.cs
Normal 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;});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
58
CodexOutput/upserver/KVGameLoadTests/CampEventSchemaTests.cs
Normal file
58
CodexOutput/upserver/KVGameLoadTests/CampEventSchemaTests.cs
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
59
CodexOutput/upserver/KVGameLoadTests/ClubPackSchemaTests.cs
Normal file
59
CodexOutput/upserver/KVGameLoadTests/ClubPackSchemaTests.cs
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
190
CodexOutput/upserver/KVGameLoadTests/CoordinatorTests.cs
Normal file
190
CodexOutput/upserver/KVGameLoadTests/CoordinatorTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
CodexOutput/upserver/KVGameLoadTests/CubeSchemaTests.cs
Normal file
31
CodexOutput/upserver/KVGameLoadTests/CubeSchemaTests.cs
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
220
CodexOutput/upserver/KVGameLoadTests/D3SchemaTests.cs
Normal file
220
CodexOutput/upserver/KVGameLoadTests/D3SchemaTests.cs
Normal 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
44
CodexOutput/upserver/KVGameLoadTests/EventBaseSchemaTests.cs
Normal file
44
CodexOutput/upserver/KVGameLoadTests/EventBaseSchemaTests.cs
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
98
CodexOutput/upserver/KVGameLoadTests/FishSchemaTests.cs
Normal file
98
CodexOutput/upserver/KVGameLoadTests/FishSchemaTests.cs
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
32
CodexOutput/upserver/KVGameLoadTests/KVGameLoadTests.csproj
Normal file
32
CodexOutput/upserver/KVGameLoadTests/KVGameLoadTests.csproj
Normal 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>
|
||||||
|
|
||||||
42
CodexOutput/upserver/KVGameLoadTests/LoginSchemaTests.cs
Normal file
42
CodexOutput/upserver/KVGameLoadTests/LoginSchemaTests.cs
Normal 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;});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
43
CodexOutput/upserver/KVGameLoadTests/OfferSchemaTests.cs
Normal file
43
CodexOutput/upserver/KVGameLoadTests/OfferSchemaTests.cs
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
56
CodexOutput/upserver/KVGameLoadTests/PartnerSchemaTests.cs
Normal file
56
CodexOutput/upserver/KVGameLoadTests/PartnerSchemaTests.cs
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
26
CodexOutput/upserver/KVGameLoadTests/PinataSchemaTests.cs
Normal file
26
CodexOutput/upserver/KVGameLoadTests/PinataSchemaTests.cs
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
40
CodexOutput/upserver/KVGameLoadTests/PlayerSchemaTests.cs
Normal file
40
CodexOutput/upserver/KVGameLoadTests/PlayerSchemaTests.cs
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
54
CodexOutput/upserver/KVGameLoadTests/PotionSchemaTests.cs
Normal file
54
CodexOutput/upserver/KVGameLoadTests/PotionSchemaTests.cs
Normal 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
Reference in New Issue
Block a user