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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
using System.Security.Cryptography;
using Newtonsoft.Json;
static class PrecompiledAudit
{
internal static int Run()
{
var root=Path.GetFullPath(Path.Combine(AppContext.BaseDirectory,"../../../../../.."));
if(!Directory.Exists(Path.Combine(root,"Client/Assets"))) throw new InvalidOperationException("Project root required.");
var files=new[]{"Client/Assets","Client/Packages","Client/LocalPackages","Client/Library/PackageCache"}.SelectMany(p=>Directory.Exists(Path.Combine(root,p)) ?
Directory.EnumerateFiles(Path.Combine(root,p),"*.dll",SearchOption.AllDirectories) : Array.Empty<string>()).OrderBy(p=>p,StringComparer.Ordinal).ToArray();
var rows=new List<object>(); int blocked=0,managed=0,native=0;
foreach(var file in files)
{
string kind="managed"; var references=new List<string>(); var assemblies=new List<string>();
using var stream=File.OpenRead(file);
try
{
using var pe=new PEReader(stream);
if(!pe.HasMetadata) { native++; kind="native"; }
else
{
managed++; var reader=pe.GetMetadataReader();
foreach(var h in reader.AssemblyReferences)
{ var name=reader.GetString(reader.GetAssemblyReference(h).Name); if(name=="HotUpdate") assemblies.Add(name); }
foreach(var h in reader.MemberReferences)
{
var member=reader.GetMemberReference(h); string name=reader.GetString(member.Name);
if(name!="BuilderCapacity" || member.Parent.Kind!=HandleKind.TypeReference) continue;
var t=reader.GetTypeReference((TypeReferenceHandle)member.Parent);
if(reader.GetString(t.Namespace)=="EventPartnerGather" && reader.GetString(t.Name)=="PartnerInfoManager") references.Add("EventPartnerGather.PartnerInfoManager.BuilderCapacity");
}
if(references.Count>0) blocked++;
}
}
catch(BadImageFormatException) { kind="unreadable-PE"; blocked++; }
rows.Add(new { path=Path.GetRelativePath(root,file).Replace('\\','/'),sha256=Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(file))),kind,fieldReferences=references,hotUpdateReferences=assemblies });
}
var report=new { success=blocked==0,scope="Assets-Packages-LocalPackages-PackageCache-dlls",total=files.Length,managed,native,blocked,files=rows };
File.WriteAllText(Path.Combine(root,"CodexOutput/upserver/KVGameLoadTests/checkpoint2-D4-precompiled-audit.json"),JsonConvert.SerializeObject(report,Formatting.Indented));
Console.WriteLine($"PRECOMPILED total={files.Length} managed={managed} native={native} blocked={blocked}");
return blocked==0 ? 0 : 1;
}
}

View File

@@ -0,0 +1,304 @@
using System.Net;
using System.Reflection;
using System.Text;
using game.Account;
using game.PlayerDataStorage;
using Newtonsoft.Json;
static partial 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(string[] args)
{
if(args.Length==1 && args[0]=="--audit-precompiled") return PrecompiledAudit.Run();
if(args.Length==2 && args[0]=="--cleanup-failure") return await IsolatedCleanupFailure(args[1]);
Directory.CreateDirectory(Root);
await Run("B2-C1-metadata-one-auth-shared-cookie", SingleAuthentication);
await Run("B2-C1-lease-once-before-after-close", Admission);
await Run("B2-C1-denied-routes-zero-send", Routes);
await Run("B2-C1-real-handler-rejected", RealHandlerRejected);
await Run("B2-C1-cyclic-handler-rejected-without-recursive-dispose", CyclicHandlerRejected);
await Run("B2-C1-active-send-drains-before-session", Drain);
await Run("B2-C1-reentrant-cancel-same-task", Reentry);
await Run("B2-C1-dispose-failure-retains-session", DisposeFailure);
await Run("B2-C1-issuance-close-race", IssuanceRace);
await Run("B2-C1-factory-revokes-context", FactoryRevocation);
await Run("B2-C1-stale-success-body-discarded", StaleResponse);
await Run("B2-C1-401-and-redirect-evidence", Responses);
await Run("B2-C1-registry-timeout-then-real-drain", RegistryTimeout);
await Run("B2-C1-registry-failure-retained", RegistryFailure);
await Run("B2-C1-registry-fails-before-other-drains", RegistryEarlyFailure);
await Run("B2-C1-new-failure-wakes-existing-observer", RegistryNewFailure);
await Run("B2-C1-registry-registration-during-await", RegistryRegistration);
await Run("B2-C1-registry-invalid-cancel", RegistryCancellation);
await Run("B2-C1-process-registry-cross-caller", ProcessBarrier);
await RunSchemaTests();
await RunD3Tests();
await RunCollectionSchemaTests();
await RunClubPackSchemaTests();
await RunCampEventSchemaTests();
await RunOfferSchemaTests();
await RunBuffTriggerSchemaTests();
await RunActivitySchemaTests();
await RunPinataSchemaTests();
await RunCubeSchemaTests();
await RunBattlePassSchemaTests();
await RunPotionSchemaTests();
await RunPlayerSchemaTests();
await RunShopSchemaTests();
await RunEventBaseSchemaTests();
await RunFishSchemaTests();
await RunPartnerSchemaTests();
await RunLoginSchemaTests();
await RunAggregateTests();
await RunCoordinatorTests();
var report = new { runId = RunId, checkpoint = "4-validation", failures, assertions, accessedFormalServer = false, tests = Results };
await File.WriteAllTextAsync(Path.Combine(Root, "result.json"), JsonConvert.SerializeObject(report, Formatting.Indented));
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().WaitAsync(TimeSpan.FromSeconds(30)); 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 message) { Interlocked.Increment(ref assertions); if (!value) throw new InvalidOperationException(message); }
sealed class CyclicHandler : DelegatingHandler { }
static async Task CyclicHandlerRejected()
{
var first = new CyclicHandler();
var second = new CyclicHandler();
first.InnerHandler = second;
second.InnerHandler = first;
var capability = PlayerKVControlledLoadCapability.CreateForValidation(_ => first);
await Throws<InvalidOperationException>(() =>
{
capability.CreateHandler(new CookieContainer());
return Task.CompletedTask;
});
// No send occurred. Break the rejected graph before its owner disposes it.
second.InnerHandler = new Handler((_, _) => throw new InvalidOperationException("Must not send."));
first.Dispose();
}
static TaskCompletionSource<bool> Signal() => new(TaskCreationOptions.RunContinuationsAsynchronously);
static HttpResponseMessage Json(string text, HttpStatusCode code = HttpStatusCode.OK) => new(code) { Content = new StringContent(text, Encoding.UTF8, "application/json") };
static async Task Throws<T>(Func<Task> action) where T : Exception
{ try { await action(); } catch (T) { Check(true, "expected exception"); return; } throw new InvalidOperationException("Expected " + typeof(T).Name); }
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 r, CancellationToken t) => send(r, t);
protected override void Dispose(bool disposing) { if (disposing) dispose?.Invoke(); base.Dispose(disposing); }
}
sealed class Fixture
{
internal readonly List<string> Order = new();
internal int Auth, Gets, BorrowedSends, SessionDisposes, LeaseDisposes;
internal bool Current = true, FailLeaseDispose;
internal Action DuringFactory;
internal CookieContainer Jar;
internal Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> Borrowed = (_, _) => Task.FromResult(Json("{}"));
internal PlayerKVLoginContext Context;
internal void Record(string x) { lock (Order) Order.Add(x); }
internal Fixture()
{
Context = new PlayerKVLoginContext("101", Path.Combine(Root, "cache", Guid.NewGuid().ToString("N")), 7,
_ => Current, Api,
() => new S101AccountSession(Api, jar =>
{
Jar = jar;
return new Handler((r, t) =>
{
Check(r.RequestUri.AbsolutePath == "/account/Login", "one synthetic auth route");
Auth++;
jar.Add(Api, new Cookie("sid", "synthetic"));
return Task.FromResult(Json("{\"state\":0,\"internalId\":17,\"newCreate\":true,\"createTime\":\"2026-01-01T00:00:00Z\",\"svrTime\":\"2026-01-02T00:00:00Z\"}"));
}, () => { SessionDisposes++; Record("session"); });
}),
() => new PlayerKVGameSessionBridge(jar =>
{
Check(ReferenceEquals(Jar, jar), "bridge shares cookie identity");
return new Handler((r, t) =>
{
Check(r.RequestUri.AbsolutePath == "/playerdata/Get", "get only, zero KV mutation");
Gets++; return Task.FromResult(Json("{\"data\":{}}"));
}, () => Record("bridge"));
}));
}
internal PlayerKVControlledLoadCapability Capability() => PlayerKVControlledLoadCapability.CreateForValidation(jar =>
{
Check(ReferenceEquals(Jar, jar), "borrower shares exact cookie identity");
DuringFactory?.Invoke();
return new Handler((r, t) => { BorrowedSends++; return Borrowed(r, t); }, () =>
{ LeaseDisposes++; Record("lease"); if (FailLeaseDispose) throw new IOException("synthetic cleanup fault"); });
});
internal async Task<PlayerKVAccountLease> PreparedLease()
{
Check((await Context.PrepareAsync()).Prepared, "prepared");
Check(Context.TryAcquireAccountLease(Capability(), out var lease), "lease admitted");
return lease;
}
}
static async Task SingleAuthentication()
{
var f = new Fixture(); var lease = await f.PreparedLease();
var d = f.Context.AccountDescriptor;
Check(d.PlayerId == "101" && d.Epoch == 7 && d.InternalId == 17 && d.NewlyCreated, "metadata belongs to authentication");
Check(d.CreateTimeUtc.Year == 2026 && d.ServerTimeUtc.Day == 2, "metadata time");
Check((await lease.SendAsync(HttpMethod.Get, "__kv_validation/ok")).IsCurrent, "borrowed send");
Check(f.Auth == 1 && f.Gets == 1 && f.BorrowedSends == 1, "no second login");
await f.Context.CloseAsync();
Check(string.Join(",", f.Order) == "lease,bridge,session", "borrower drains before bridge and session");
Check(f.Context.AccountDescriptor == null && !lease.IsCurrent, "closed admission revoked");
}
static async Task Admission()
{
var f = new Fixture(); Check(!f.Context.TryAcquireAccountLease(f.Capability(), out _), "before prepared rejected");
var lease = await f.PreparedLease();
Check(!f.Context.TryAcquireAccountLease(f.Capability(), out _), "one lease only");
Check(!f.Context.TryAcquireAccountLease(null, out _), "capability required");
var close = f.Context.CloseAsync(); Check(!f.Context.TryAcquireAccountLease(f.Capability(), out _), "close synchronous");
await close;
Check(!(await lease.SendAsync(HttpMethod.Get, "__kv_validation/ok")).IsCurrent && f.BorrowedSends == 0, "after close no transport");
}
static async Task Routes()
{
var f = new Fixture(); var l = await f.PreparedLease();
foreach (var route in new[] { "account/Login", "playerdata/Get", "playerdata/Update", "http://synthetic.invalid/__kv_validation/ok", "//synthetic.invalid/x", "__kv_validation/../ok", "__kv_validation/ok?x=1", "%5f%5fkv_validation/ok", "__kv_validation/ok#f" })
await Throws<ArgumentException>(() => l.SendAsync(HttpMethod.Get, route));
Check(f.BorrowedSends == 0, "rejected routes never sent"); await f.Context.CloseAsync();
}
static async Task RealHandlerRejected()
{
var f = new Fixture(); await f.Context.PrepareAsync();
await Throws<InvalidOperationException>(() =>
{ f.Context.TryAcquireAccountLease(PlayerKVControlledLoadCapability.CreateForValidation(_ => new HttpClientHandler()), out _); return Task.CompletedTask; });
Check(f.BorrowedSends == 0, "real handler never sends"); await f.Context.CloseAsync();
}
static async Task Drain()
{
var f = new Fixture(); var entered = Signal(); var release = Signal(); var cancelled = Signal();
f.Borrowed = async (_, token) => { token.Register(() => cancelled.TrySetResult(true)); entered.TrySetResult(true); await release.Task; f.Record("send-ended"); return Json("late"); };
var l = await f.PreparedLease(); var send = l.SendAsync(HttpMethod.Get, "__kv_validation/ok"); await entered.Task;
var close = f.Context.CloseAsync(); await cancelled.Task;
Check(!close.IsCompleted && f.SessionDisposes == 0 && f.LeaseDisposes == 0, "no premature dispose despite cancellation");
release.TrySetResult(true); var response = await send; await close;
Check(!response.IsCurrent && response.Body == null, "late response withheld");
Check(string.Join(",", f.Order) == "send-ended,lease,bridge,session", "full resource order");
}
static async Task Reentry()
{
var f = new Fixture(); var entered = Signal(); var release = Signal(); var callback = Signal(); Task reentered = null;
f.Borrowed = async (_, token) => { token.Register(() => { reentered = f.Context.CloseAsync(); callback.TrySetResult(true); }); entered.TrySetResult(true); await release.Task; return Json("{}"); };
var l = await f.PreparedLease(); var send = l.SendAsync(HttpMethod.Get, "__kv_validation/ok"); await entered.Task;
var close = f.Context.CloseAsync(); await callback.Task;
Check(ReferenceEquals(close, reentered), "cancellation reentry same task, no cycle");
release.TrySetResult(true); await send; await close;
}
static async Task DisposeFailure()
{
var f = new Fixture { FailLeaseDispose = true }; var l = await f.PreparedLease();
var close = f.Context.CloseAsync(); await Throws<IOException>(() => close);
Check(f.SessionDisposes == 0 && !f.Order.Contains("bridge"), "failed borrower cleanup prevents owner disposal");
Check(ReferenceEquals(close, f.Context.CloseAsync()) && !f.Context.TryAcquireAccountLease(f.Capability(), out _), "failure cannot reopen");
var registry = new PlayerKVShutdownRegistry(); registry.Register(close, f.Context);
Check(registry.Status == PlayerKVShutdownStatus.Failed, "fault retained");
var retained = (Dictionary<Task, PlayerKVLoginContext>)typeof(PlayerKVShutdownRegistry)
.GetField("pending", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(registry)!;
Check(ReferenceEquals(retained[close], f.Context), "failed owner retained beyond caller lifetime");
}
static async Task IssuanceRace()
{
for (int i = 0; i < 24; i++)
{
var f = new Fixture(); await f.Context.PrepareAsync(); var go = Signal();
var issue = Task.Run(async () => { await go.Task; return f.Context.TryAcquireAccountLease(f.Capability(), out _); });
var close = Task.Run(async () => { await go.Task; await f.Context.CloseAsync(); });
go.TrySetResult(true); await Task.WhenAll(issue, close);
Check(f.SessionDisposes == 1 && !f.Context.IsCurrent, "race closes session once");
if (issue.Result) Check(f.LeaseDisposes == 1, "published borrower observed by close");
}
}
static async Task FactoryRevocation()
{
var f = new Fixture(); await f.Context.PrepareAsync(); f.DuringFactory = () => { _ = f.Context.CloseAsync(); };
Check(!f.Context.TryAcquireAccountLease(f.Capability(), out var l) && l == null, "reentrant factory cannot publish");
await f.Context.CloseAsync(); Check(f.LeaseDisposes == 1 && f.SessionDisposes == 1, "candidate still cleaned");
}
static async Task StaleResponse()
{
var f = new Fixture(); f.Borrowed = (_, _) => { f.Current = false; return Task.FromResult(Json("should-not-escape")); };
var l = await f.PreparedLease(); var r = await l.SendAsync(HttpMethod.Get, "__kv_validation/ok");
Check(!r.IsCurrent && r.Body == null && r.StatusCode == null, "200 does not restore old epoch"); await f.Context.CloseAsync();
}
static async Task Responses()
{
var f = new Fixture(); var l = await f.PreparedLease();
f.Borrowed = (_, _) => Task.FromResult(Json("{}", HttpStatusCode.Unauthorized));
Check((await l.SendAsync(HttpMethod.Get, "__kv_validation/unauthorized")).StatusCode == 401, "401 evidence for version-aware caller");
f.Borrowed = (_, _) => Task.FromResult(Json("{}", HttpStatusCode.Redirect));
await Throws<InvalidOperationException>(() => l.SendAsync(HttpMethod.Get, "__kv_validation/ok")); await f.Context.CloseAsync();
}
static async Task RegistryTimeout()
{
var registry = new PlayerKVShutdownRegistry(); var closing = Signal(); var deadline = Signal(); registry.Register(closing.Task);
var observe = registry.ObserveAsync(10000, delay: (_, _) => deadline.Task); deadline.TrySetResult(true);
Check(await observe == PlayerKVShutdownStatus.Pending && !closing.Task.IsCompleted, "timeout not force-close");
closing.TrySetResult(true); await closing.Task;
Check(registry.Status == PlayerKVShutdownStatus.Ready, "real completion opens barrier");
}
static async Task RegistryFailure()
{
var registry = new PlayerKVShutdownRegistry(); var fail = Signal(); registry.Register(fail.Task); fail.TrySetException(new IOException("synthetic"));
Check(await registry.ObserveAsync(10000) == PlayerKVShutdownStatus.Failed, "fault classified");
registry.Register(Task.CompletedTask); Check(registry.Status == PlayerKVShutdownStatus.Failed, "new task cannot erase failure");
Check(!typeof(PlayerKVShutdownRegistry).GetMethods(BindingFlags.Instance | BindingFlags.NonPublic).Any(m => m.Name == "Clear" || m.Name == "Reset"), "no reset API");
}
static async Task RegistryRegistration()
{
var registry = new PlayerKVShutdownRegistry(); var first = Signal(); var second = Signal(); var deadline = Signal();
registry.Register(first.Task); var observe = registry.ObserveAsync(10000, delay: (_, _) => deadline.Task);
registry.Register(second.Task); first.TrySetResult(true); deadline.TrySetResult(true);
Check(await observe == PlayerKVShutdownStatus.Pending, "newly registered work not missed"); second.TrySetResult(true);
}
static async Task RegistryEarlyFailure()
{
var registry = new PlayerKVShutdownRegistry(); var fail = Signal(); var slow = Signal(); var deadline = Signal();
registry.Register(fail.Task); registry.Register(slow.Task);
var observe = registry.ObserveAsync(10000, delay: (_, _) => deadline.Task);
fail.TrySetException(new IOException("synthetic"));
Check(await observe == PlayerKVShutdownStatus.Failed && !slow.Task.IsCompleted && !deadline.Task.IsCompleted,
"failure reported without waiting for unrelated cleanup or observation deadline");
slow.TrySetResult(true);
}
static async Task RegistryNewFailure()
{
var registry = new PlayerKVShutdownRegistry(); var slow = Signal(); var deadline = Signal();
registry.Register(slow.Task); var observe = registry.ObserveAsync(10000, delay: (_, _) => deadline.Task);
registry.Register(Task.FromException(new IOException("synthetic")));
Check(await observe == PlayerKVShutdownStatus.Failed && !slow.Task.IsCompleted && !deadline.Task.IsCompleted,
"new root failure wakes existing observer"); slow.TrySetResult(true);
}
static async Task RegistryCancellation()
{
var registry = new PlayerKVShutdownRegistry(); var close = Signal(); registry.Register(close.Task);
Check(await registry.ObserveAsync(0) == PlayerKVShutdownStatus.Input, "invalid timeout Input");
using var cts = new CancellationTokenSource(); var observed = registry.ObserveAsync(10000, cts.Token); cts.Cancel();
Check(await observed == PlayerKVShutdownStatus.Cancelled && !close.Task.IsCompleted, "observer cancellation does not cancel cleanup"); close.TrySetResult(true);
}
static async Task ProcessBarrier()
{
var firstRoot = PlayerKVShutdownRegistry.Process; var close = Signal(); firstRoot.Register(close.Task);
var secondRoot = PlayerKVShutdownRegistry.Process;
Check(ReferenceEquals(firstRoot, secondRoot) && secondRoot.Status == PlayerKVShutdownStatus.Pending, "new caller same process barrier");
close.TrySetResult(true); Check(await secondRoot.ObserveAsync(1000) == PlayerKVShutdownStatus.Ready, "drained across caller replacement");
}
}

View File

@@ -0,0 +1,36 @@
#if KV_EXIT_REGRESSION
namespace game
{
// Boundary double for the actual QuitAct. Real UserService is verified by the Unity service host.
public class PlayFabUserService : IUserService
{
internal int Revocations,Logouts;
internal readonly TaskCompletionSource<bool> Observing=new(TaskCreationOptions.RunContinuationsAsynchronously);
internal readonly TaskCompletionSource<bool> Observed=new(TaskCreationOptions.RunContinuationsAsynchronously);
public void Logout() { Logouts++; }
internal Task CloseKVForValidationAsync() { Revocations++; return Task.CompletedTask; }
internal Task ObserveKVQuitAsync() { Observing.TrySetResult(true); return Observed.Task; }
}
}
static class ControlledExitProbe
{
[System.Runtime.CompilerServices.ModuleInitializer]
internal static void Run()
{
foreach(bool logout in new[]{true,false})
{
GameCore.Timer.Reset();
var user=new game.PlayFabUserService(); var hub=new Hub(); var ads=new Ads();
var act=new game.QuitAct { userService=user,rtService=hub,config=new Config(),adsService=ads,soundMgr=new SoundMgr() };
game.QuitStage.Logout=logout;
// Module initialization cannot block on code executing in the same module on another thread.
// Complete the boundary doubles up front; the historical async Hub cases still run unchanged.
hub.Gate.TrySetResult(true); user.Observed.TrySetResult(true);
var task=act.StartAsync(); task.GetAwaiter().GetResult();
if(user.Revocations!=1 || user.Logouts!=(logout ? 1 : 0) || !user.Observing.Task.IsCompleted) throw new Exception("KV exit boundary was skipped.");
if(!ads.Released || GameCore.Timer.Destroyed!=1) throw new Exception("Scene exit must continue after observation.");
Console.WriteLine("PASS actual QuitAct controlled revoke / Hub / bounded-observer boundary, logout="+logout);
}
}
}
#endif

View File

@@ -0,0 +1,153 @@
using System.Globalization;
using game.PlayerDataStorage;
using Newtonsoft.Json;
static partial class Tests
{
static readonly string[] CompleteCore = { "3", "18446744073709551615", "4", "5", "6", "7", "101",
"2026-09-23T00:00:00.0000000Z", "-1", "8", "18446744073709551614", "9", "1", "0", "10", "11", "12", "13", "14" };
static PlayerKVSchemaSnapshot SchemaSnapshot(string culture = "en-US") => new("synthetic-v1", new[] { 101 }, new[] { 1, 2 },
new Dictionary<string, int> { ["None"] = 0, ["Fish"] = 1, ["Another"] = 2 }, CultureInfo.GetCultureInfo(culture),
new DateTime(2026, 9, 23, 0, 0, 0, DateTimeKind.Utc), 17);
static Dictionary<string, string> ModernRaw(string[] slots = null) => new()
{ ["NewPlayerDataDic"] = JsonConvert.SerializeObject(new Dictionary<string, string> { ["newData"] = string.Join(",", slots ?? CompleteCore) }) };
static PlayerKVCoreState Core(Dictionary<string, string> raw, out PlayerKVSchemaDiagnostics errors, string culture = "en-US")
{
errors = new(); var modern = PlayerKVSaveSchema.DecodeModern(raw, errors);
return PlayerKVSaveSchema.DecodeCore(raw, modern, SchemaSnapshot(culture), errors);
}
static PlayerKVAchievementState Achievement(Dictionary<string, string> raw, Dictionary<string, string> modern,
out PlayerKVSchemaDiagnostics errors)
{ errors = new(); return PlayerKVSaveSchema.DecodeAchievements(raw, modern, SchemaSnapshot(), errors); }
static async Task RunSchemaTests()
{
await Run("schema-primitive-json-and-finite-float", async () =>
{
Check(PlayerKVSaveSchema.Single("1.25") == 1.25f, "invariant finite number accepted");
foreach (var value in new[] { "NaN", "Infinity", "-Infinity", "1e999", "1,000" })
await Throws<PlayerKVSchemaException>(() => { PlayerKVSaveSchema.Single(value); return Task.CompletedTask; });
var token = PlayerKVSaveSchema.Json("{\"x\":[true,false,null,-0,1.25e2,\"a\\u0041\\n\"]}");
Check(token["x"].Count() == 6, "RFC literals numbers and escapes accepted by syntax layer");
Check(PlayerKVSaveSchema.StringMap("{\"x\":\"a\\u0041\"}")["x"] == "aA", "string escapes materialized");
foreach (var value in new[] { "[1,]", "01", "1.", ".1", "+1", "1e", "{\"x\":1e999}" })
await Throws<PlayerKVSchemaException>(() => { PlayerKVSaveSchema.Json(value); return Task.CompletedTask; });
});
await Run("M01-M02-modern-19-slots-currency-aliases", () =>
{
var state = Core(ModernRaw(), out var e);
Check(state != null && e.TotalErrors == 0, "complete modern accepted");
Check(state.Unsigned(1) == ulong.MaxValue && state.Unsigned(10) == ulong.MaxValue - 1, "unsigned values not rounded");
Check(state.Integer(4) == 6 && state.Integer(11) == 9 && state.Integer(8) == -1, "pearl, diamond and signed bit mask distinct");
Check(state.Integer(16) == 12 && state.Integer(17) == 13 && state.Integer(18) == 14, "all small game counts captured");
Check(state.EnergyTime.Kind == DateTimeKind.Utc, "roundtrip UTC retained"); return Task.CompletedTask;
});
await Run("M01-M02-legacy-complete-partial-and-conflict", () =>
{
string[] keys = { "Lv", "Gold", "vipAllScore", "Energy", "Diamond", "Magnification", "EquipRodID", "LastEnergyRecoveryTime",
"RewardNewMap", "EnergyRM", "GoldRM", "DiamondTrue", "CurrentMapId", "LastMapId", "FishingFrequency", "AllFishingFrequency",
"HeistGameCount", "BombGameCount", "FishHuntGameCount" };
var raw = keys.Select((k, i) => (k, v: CompleteCore[i])).ToDictionary(x => x.k, x => x.v);
Check(Core(raw, out var e) != null && e.TotalErrors == 0, "complete legacy accepted");
raw.Remove("Gold"); Check(Core(raw, out e) == null && e.Count(1, PlayerKVSchemaError.MissingCore) == 1, "cannot compose partial core");
raw["NewPlayerDataDic"] = ModernRaw()["NewPlayerDataDic"];
Check(Core(raw, out e) != null, "consistent overlapping legacy accepted");
raw["Diamond"] = "7"; Check(Core(raw, out e) == null && e.Count(1, PlayerKVSchemaError.ConflictingRepresentations) == 1, "partial legacy conflict rejected");
return Task.CompletedTask;
});
await Run("M01-M02-every-slot-malformed-and-boundaries", () =>
{
for (int i = 0; i < 19; i++)
{
var slots = (string[])CompleteCore.Clone(); slots[i] = "invalid";
Check(Core(ModernRaw(slots), out var e) == null && e.TotalErrors > 0, "invalid consumed slot rejected");
}
foreach (int count in new[] { 0, 18, 20 })
Check(Core(ModernRaw(Enumerable.Repeat("1", count).ToArray()), out _) == null, "exact length required");
var negative = (string[])CompleteCore.Clone(); negative[3] = "-1";
Check(Core(ModernRaw(negative), out _) == null, "energy cannot be clamped");
negative[3] = "0"; negative[0] = "0";
Check(Core(ModernRaw(negative), out _) == null, "level at least one");
var unknown = (string[])CompleteCore.Clone(); unknown[6] = "999";
Check(Core(ModernRaw(unknown), out var errors) == null && errors.Count(1, PlayerKVSchemaError.UnsupportedSchema) == 1, "unknown rod no fallback");
unknown = (string[])CompleteCore.Clone(); unknown[12] = "999";
Check(Core(ModernRaw(unknown), out _) == null, "map references captured table"); return Task.CompletedTask;
});
await Run("M01-modern-outer-strict-json-and-alias-conflicts", () =>
{
foreach (var text in new[] { "null", "[]", "{\"a\":null}", "{\"a\":1}", "{\"a\":\"x\",\"a\":\"y\"}", "{}{}", "/*comment*/{}", "{\"a\":NaN}",
"{'a':'b'}", "{a:\"b\"}", "{\"a\":\"b\",}", "{\"a\":\"\\x41\"}", "{\"a\":\"unterminated}" })
{
Check(Core(new() { ["NewPlayerDataDic"] = text }, out var e) == null && e.TotalErrors > 0, "outer format rejected");
}
var modern = new Dictionary<string, string> { ["newData"] = string.Join(",", CompleteCore), ["pearl"] = "77" };
Check(Core(new() { ["NewPlayerDataDic"] = JsonConvert.SerializeObject(modern) }, out var err) == null &&
err.Count(1, PlayerKVSchemaError.ConflictingRepresentations) == 1, "nested alias checked"); return Task.CompletedTask;
});
await Run("M01-date-culture-ambiguity-and-snapshot-copy", () =>
{
var slots = (string[])CompleteCore.Clone(); slots[7] = "01/02/2026";
Check(Core(ModernRaw(slots), out var e, "en-GB") == null && e.Count(1, PlayerKVSchemaError.UnsupportedSchema) == 1, "ambiguous day/month rejected");
var rods = new List<int> { 101 }; var conditions = new Dictionary<string, int> { ["Fish"] = 1 };
var culture = new CultureInfo("en-US"); var snapshot = new PlayerKVSchemaSnapshot("v1", rods, new[] { 1 }, conditions, culture, DateTime.MinValue, 17);
rods.Clear(); conditions.Clear(); culture.DateTimeFormat.ShortDatePattern = "dd/MM/yyyy";
Check(snapshot.HasRod(101) && snapshot.Condition("Fish") == 1 && snapshot.Culture.IsReadOnly, "snapshot owns copies");
Check(snapshot.InitWheelTicket == 17 && snapshot.Version == "v1", "M05 default comes from captured snapshot"); return Task.CompletedTask;
});
await Run("M08-three-representations-and-priority", () =>
{
var raw = new Dictionary<string, string> { ["AchievementData"] = "{\"Fish\":{\"Id\":2,\"Target\":18446744073709551615,\"day\":3}}" };
var modern = new Dictionary<string, string>();
var state = Achievement(raw, modern, out var e); Check(state.Values[1].Target == ulong.MaxValue && e.TotalErrors == 0, "object exact ulong");
modern["AD"] = "{\"Fish\":\"2|18446744073709551615|3\"}";
modern["ad"] = "1:2|18446744073709551615|3";
state = Achievement(raw, modern, out e); Check(state.Values.Count == 1 && e.TotalErrors == 0, "all agree");
modern["ad"] = ""; state = Achievement(raw, modern, out e);
Check(state.Values.Count == 0 && e.TotalErrors == 0, "whole newer empty set replaces old, not merged");
Check(Achievement(new(), new(), out e).Values.Count == 0, "missing explicit empty state"); return Task.CompletedTask;
});
await Run("M08-AD-strict-values-no-fallback", () =>
{
foreach (var text in new[] { "null", "{\"Fish\":null}", "{\"Fish\":{\"Id\":1,\"Target\":2,\"day\":3}}", "{\"Fish\":\"1|2\"}",
"{\"Fish\":\"1|2|3|4\"}", "{\"Fish\":\"1|18446744073709551616|3\"}", "{\"Fish\":\"1|-1|3\"}",
"{\"Fish\":\"1|2|3\",\"1\":\"1|2|3\"}", "{\"Fish\":\"1|2|3\",\"Fish\":\"1|2|3\"}", "{\"unknown\":\"1|2|3\"}" })
{
var state = Achievement(new(), new() { ["AD"] = text, ["ad"] = "1:1|2|3" }, out var e);
Check(state == null && e.Count(8, PlayerKVSchemaError.Malformed) > 0, "bad AD cannot be hidden by new representation");
}
Check(Achievement(new(), new() { ["AD"] = "{}" }, out var empty).Values.Count == 0 && empty.TotalErrors == 0, "empty object valid");
return Task.CompletedTask;
});
await Run("M08-object-token-types-compact-bad-records-and-conflict", () =>
{
foreach (var text in new[] { "{\"1\":{\"Id\":\"1\",\"Target\":2,\"day\":3}}", "{\"1\":{\"Id\":1.0,\"Target\":2,\"day\":3}}", "{\"1\":{\"Id\":1,\"Target\":2}}" })
Check(Achievement(new() { ["AchievementData"] = text }, new(), out _) == null, "no loose coercion/default");
foreach (var text in new[] { "1:1|2|3;", ";1:1|2|3", "1:1|2|3;Fish:1|2|3", "1:1|2", "unknown:1|2|3" })
Check(Achievement(new(), new() { ["ad"] = text }, out _) == null, "malformed compact rejected");
Check(Achievement(new(), new() { ["AD"] = "{\"1\":\"1|2|3\"}", ["ad"] = "1:1|3|3" }, out var e) == null &&
e.Count(8, PlayerKVSchemaError.ConflictingRepresentations) == 1, "conflicting targets rejected"); return Task.CompletedTask;
});
await Run("M23-full-unsigned-domain-missing-and-invalid", () =>
{
foreach (ulong value in new[] { 0UL, 255UL, 1UL << 31, 1UL << 32, ulong.MaxValue })
{
var e = new PlayerKVSchemaDiagnostics(); var state = PlayerKVSaveSchema.DecodeFootPrint(new Dictionary<string, string> { ["FootPrint"] = value.ToString(CultureInfo.InvariantCulture) }, e);
Check(state.Bits == value && e.TotalErrors == 0, "full bits retained in decoded value");
}
Check(PlayerKVSaveSchema.DecodeFootPrint(new Dictionary<string, string>(), new()).Bits == 0, "missing empty state");
foreach (var text in new[] { null, "", "-1", "18446744073709551616", "1.0", "1e3" })
{
var e = new PlayerKVSchemaDiagnostics(); Check(PlayerKVSaveSchema.DecodeFootPrint(new Dictionary<string, string> { ["FootPrint"] = text }, e) == null && e.TotalErrors == 1, "bad bits rejected");
}
return Task.CompletedTask;
});
await Run("schema-diagnostics-32-stable-aggregate-all", () =>
{
var e = new PlayerKVSchemaDiagnostics();
for (int i = 50; i >= 0; i--) e.Add(i % 2 == 0 ? 8 : 1, i, PlayerKVSchemaError.Malformed);
Check(e.TotalErrors == 51 && e.Details.Count == 32 && e.OmittedDetails == 19, "all errors counted with bounded storage");
Check(e.Count(8, PlayerKVSchemaError.Malformed) == 26 && e.Count(1, PlayerKVSchemaError.Malformed) == 25, "module code aggregate complete");
Check(e.Details.First().Module == 1 && e.Details.First().Field == 1 && e.Details.Last().Module == 8 && e.Details.Last().Field == 12, "stable module/field ordering");
return Task.CompletedTask;
});
}
}

View File

@@ -0,0 +1,33 @@
$ErrorActionPreference='Stop'
$root=(Resolve-Path (Join-Path $PSScriptRoot '../../..')).Path
Set-Location $root
& "$PSScriptRoot/VerifyBoundary.ps1"
if($LASTEXITCODE -ne 0){throw 'Boundary verification failed'}
$paths=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
$original=Get-Content 'CodexOutput/upserver/阶段4B2方案受审基线-修订2-2026-09-23.json' -Raw | ConvertFrom-Json
foreach($f in $original.files){[void]$paths.Add($f.path)}
foreach($file in Get-ChildItem 'Client/Assets/Scripts/Services/PlayerData/PlayerKV*' -File){[void]$paths.Add($file.FullName.Substring($root.Length+1).Replace('\','/'))}
foreach($path in @('Client/Assets/Scripts/Services/UserService.KVLoad.cs','Client/Assets/Editor/KVGameLoadValidation.cs','Client/Assets/Scripts/EventPartnerGather/PartnerInfoManager.cs')){
[void]$paths.Add($path);if(Test-Path -LiteralPath ($path+'.meta')){[void]$paths.Add($path+'.meta')}
}
foreach($file in Get-ChildItem $PSScriptRoot -Recurse -File){
$relative=$file.FullName.Substring($root.Length+1).Replace('\','/')
if($relative -notmatch '/(bin|obj|runs|unity-cache|\.appdata)/' -and $file.Extension -in @('.cs','.csproj','.targets','.ps1')){[void]$paths.Add($relative)}
}
$logs=@('checkpoint4-release-offline.log','checkpoint4-release-full.log','checkpoint4-final-unity-cases.log','checkpoint4-final4-compile.log','checkpoint4-cleanup-final-prepare.log','checkpoint4-cleanup-final-schema.log','checkpoint4-cleanup-final-apply.log','checkpoint4-service-release-false.log','checkpoint4-service-release-true.log','checkpoint4-noneditor-factories.log','checkpoint4-regression-KVLoginContextTests.log','checkpoint4-regression-KVGameBridgeTests.log','checkpoint4-regression-KVContractTests.log','checkpoint4-regression-KVSyncTests.log','checkpoint4-regression-KVSyncTests-serial.log','checkpoint4-regression-KVSyncCoordinatorTests.log','checkpoint4-regression-KVSyncCoordinatorReviewTests.log','checkpoint4-regression-KVStage3CTests.log','checkpoint4-regression-KVLiveTests.log','checkpoint4-network-build-final.log','checkpoint3-network-regression.log','checkpoint3-minimal-login-regression.log','checkpoint3-exit-final-regression.log','checkpoint2-D4-unity-real.log','checkpoint2-D4-unity-hot-real.log','checkpoint2-D5-rounding-real.log','checkpoint2-D4-imported-dependencies-final.log')
$privacyFlags=@()
foreach($name in $logs){
$path="CodexOutput/upserver/KVGameLoadTests/$name";[void]$paths.Add($path)
$text=Get-Content -LiteralPath $path -Raw
if($text -match '(?i)sid=|Authorization:\s*Bearer\s+\S+|Cookie:\s*\S+|C:\\Users\\LB'){$privacyFlags+=$path}
}
foreach($name in @('checkpoint4-boundary-and-evidence.json','checkpoint2-D4-precompiled-audit.json','checkpoint2-D4-dependency-conclusion.json','checkpoint2-D4-source-references.txt','baseline/ClubService.cs.txt','runs/20260923144334_75379546c3404789b03c7a27ddea7784/result.json')){[void]$paths.Add("CodexOutput/upserver/KVGameLoadTests/$name")}
foreach($name in @('阶段4B2交付与验收矩阵-2026-09-23.md','阶段4B2限定代码自审-2026-09-23.md','其他AI审查提示词-4B2实施-2026-09-23.md','阶段4B2实施记录送审快照-2026-09-23.md','阶段4B2当前TodoItem-2026-09-23.json','阶段4B2字段与模块加载合同-正式版3-2026-09-23.md','阶段4B2检查点2配置与派生审查裁决-2026-09-23.md','阶段4B2检查点2配置隔离与鱼数据派生独立审查-2026-09-23.md','阶段4B2方案受审基线-修订2-2026-09-23.json','阶段4B2检查点2配置与派生差异受审基线-2026-09-23.json')){[void]$paths.Add("CodexOutput/upserver/$name")}
$files=@(foreach($path in ($paths|Sort-Object)){
if(!(Test-Path -LiteralPath $path -PathType Leaf)){throw "Missing artifact: $path"}
[ordered]@{path=$path;sha256=(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash}
})
if($privacyFlags.Count){$privacyFlags;throw 'Review log privacy scan requires inspection'}
$head=git rev-parse HEAD
[ordered]@{schemaVersion=1;phase='4B2-implementation';status='awaiting-independent-implementation-review';generatedAt=(Get-Date).ToString('o');gitHead=$head;gitCommitCreated=$false;formalRequests=0;stageClosed=$false;B3Started=$false;privacy=@{reviewLogs=$logs.Count;credentialOrUserPathFlags=$privacyFlags.Count;scope='bounded pattern scan of selected review logs; synthetic fixtures, no real account data'};note='Shared entry/status/TODO/live logs are excluded; immutable review snapshot included. Author verification is not independent approval.';files=$files} | ConvertTo-Json -Depth 7 | Set-Content "$PSScriptRoot/checkpoint4-delivery-hashes.json"
Write-Output "Sealed $($files.Count) files; review-log privacy flags=$($privacyFlags.Count)."

View File

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

View File

@@ -0,0 +1,12 @@
using System.Reflection;
using game.PlayerDataStorage;
int checkedTypes=0;
foreach(var type in new[]{typeof(PlayerKVGameLoadCapability),typeof(PlayerKVControlledLoadCapability)})
{
if(type.GetMethod("CreateForValidation",BindingFlags.Static|BindingFlags.Public|BindingFlags.NonPublic)!=null || type.IsPublic ||
type.GetConstructors(BindingFlags.Public|BindingFlags.NonPublic|BindingFlags.Instance).Any(c=>!c.IsPrivate))
{Console.WriteLine("FAIL player build capability construction surface");return 1;}
checkedTypes++;
}
Console.WriteLine($"PASS non-editor production capability factories absent types={checkedTypes}; compile probe only, no Unity player/package produced");
return 0;

View File

@@ -0,0 +1,163 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Net;
using System.Reflection;
using game;
using game.Account;
using game.PlayerDataStorage;
using Newtonsoft.Json;
using PlayFab.ClientModels;
using tysdk;
static class Probe
{
static int assertions,groups;
static void Check(bool condition,string label){assertions++;if(!condition)throw new Exception(label);}
sealed class Loop:SynchronizationContext
{ internal BlockingCollection<(SendOrPostCallback,object)> Queue=new();public override void Post(SendOrPostCallback c,object s)=>Queue.Add((c,s)); }
static int Main() { try { RunHost(); return 0; } catch(Exception ex) { Console.WriteLine(ex); return 1; } }
static void RunHost()
{
var loop=new Loop();SynchronizationContext.SetSynchronizationContext(loop);
var task=Run();_=task.ContinueWith(_=>loop.Post(_=>{},null),TaskScheduler.Default);
while(!task.IsCompleted){var(c,s)=loop.Queue.Take();c(s);}task.GetAwaiter().GetResult();
#if UNITY_ANDROID
const string platform="ANDROID+AGG";
#else
const string platform="NON_ANDROID";
#endif
Console.WriteLine($"RESULT service-host {platform} groups={groups} assertions={assertions} formalRequests=0");
}
sealed class Handler:HttpMessageHandler
{
readonly Func<HttpRequestMessage,Task<HttpResponseMessage>> send;
internal Handler(Func<HttpRequestMessage,Task<HttpResponseMessage>> send)=>this.send=send;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage r,CancellationToken t)=>send(r);
}
static Task<HttpResponseMessage> Json(object value,HttpStatusCode status=HttpStatusCode.OK)=>Task.FromResult(new HttpResponseMessage(status){Content=new StringContent(JsonConvert.SerializeObject(value))});
static readonly Uri Api=new("http://synthetic.invalid/");
static Config Configuration(){var c=new Config();c.Set("EventApiURL",Api.AbsoluteUri);c.Set("DEBUG","1");return c;}
static readonly string[] Core={"3","4","4","5","6","7","101","2026-09-23T00:00:00Z","-1","8","9","9","1","0","10","11","12","13","14"};
static Dictionary<string,string> Raw()=>new(){["NewPlayerDataDic"]=JsonConvert.SerializeObject(new{newData=string.Join(",",Core)}),["TapJoy_Diamond"]="9"};
static PlayerKVSchemaSnapshot Snapshot()=>new("stub-v1",new[]{101},new[]{1,2},new Dictionary<string,int>{{"None",0}},CultureInfo.InvariantCulture,new DateTime(2026,9,23,0,0,0,DateTimeKind.Utc),17);
static PlayerKVIdentitySnapshot Identity()=>new("FF",false,PlayerKVIdentityChannel.hwGuest,Array.Empty<PlayerKVIdentityChannel>());
static object Private(object target,string method,params object[] args)=>target.GetType().GetMethod(method,BindingFlags.Instance|BindingFlags.NonPublic,null,args.Select(a=>a.GetType()).ToArray(),null).Invoke(target,args);
static void Pass(string name){groups++;Console.WriteLine("PASS "+name);}
static async Task Run()
{
foreach(bool agg in new[]{false,true})
{
var config=Configuration();PlayFabUserService user=agg?new AggUserService():new PlayFabUserService();user.config=config;user.CustomId="101";
foreach(var backend in new[]{"kv","KV","unknown","",null})
{
config.Set("SaveBackend",backend);Effects.Clear();
Check(await user.Login(null)==ELoginStep.Api,"public backend gate");
Check(user.KVLastError==(backend=="kv"?PlayerKVGameLoadError.BackendNotEnabled:PlayerKVGameLoadError.BackendUnsupported),"typed rejection");
Check(Effects.Total==0,"before all SDK/event access");
}
}
Pass("public-backend-rejection-before-AGG-shortcut");
var pf=new PlayFabMgr();Private(pf,"Awake");
var data=new Dictionary<string,UserDataRecord>{{"MMTest",new(){Value="MMFailParam#4",LastUpdated=DateTime.UtcNow}},{"TapJoy_Diamond",new(){Value="7",LastUpdated=DateTime.UtcNow}}};
pf.CompareDatas(data,"synthetic");pf.UpdateUserDataValue("local","value",false);
Check(pf.GetLocalData("local")=="value","default PF local write unchanged");
Check(await pf.SyncDataImmidiate("remote","value"),"default PF immediate result unchanged");
Pass("default-PF-storage-passthrough");
foreach(bool agg in new[]{false,true})
{
var config=Configuration();PlayFabUserService user=agg?new AggUserService():new PlayFabUserService();user.config=config;user.CustomId="101";
var server=new CustomServerMgr(config,_=>new Handler(r=>Json(new{})));user.customServerMgr=server;
using var session=new S101AccountSession(Api,jar=>new Handler(r=>{jar.Add(Api,new Cookie("sid","synthetic"));return Json(new{state=0,internalId=17,newCreate=false,createTime="2026-01-01T00:00:00Z",svrTime="2026-01-01T00:00:00Z"});}));
Check((await session.LoginAsync("101")).Success,"PF synthetic account");
typeof(PlayFabUserService).GetField("apiSession",BindingFlags.NonPublic|BindingFlags.Instance).SetValue(user,session);
typeof(PlayFabUserService).GetProperty("InternalId").SetValue(user,(uint)17);PlayFab.PlayFabClientAPI.Logged=true;
int progress=0;Check(await user.Login(v=>progress=v)==ELoginStep.Done && progress==(agg?3:2),"default PF authorized shortcut");
config.Set("SaveBackend","kv");Effects.Clear();Check(await user.Login(null)==ELoginStep.Api && Effects.Total==0,"authorized cannot bypass backend gate");
typeof(PlayFabUserService).GetField("apiSession",BindingFlags.NonPublic|BindingFlags.Instance).SetValue(user,null);
}
Pass("default-PF-and-AGG-shortcuts");
GContext.container.Values[typeof(ISettingService)]=new Setting();GContext.container.Values[typeof(IChatService)]=new Chat();
GContext.container.Values[typeof(PlayerData)]=new PlayerData();GContext.container.Values[typeof(MMTService)]=new MMTService();
asap.playfab.async.PlayFabClientAsyncAPI.Result=new LoginResult{PlayFabId="FF",NewlyCreated=false,
EntityToken=new EntityTokenResponse{Entity=new EntityKey()},InfoResultPayload=new GetPlayerCombinedInfoResultPayload{UserData=data,AccountInfo=new UserAccountInfo{Created=DateTime.UtcNow},PlayerProfile=new PlayerProfileModel{DisplayName="Synthetic",AvatarUrl="synthetic",Locations=new(){new LocationModel()}}}};
// Invalid scheme ensures legacy API stage fails before HttpClient; no real endpoint is contacted.
var normalConfig=Configuration();normalConfig.Set("EventApiURL","ftp://synthetic.invalid/");
var normal=new PlayFabUserService{config=normalConfig,CustomId="101",customServerMgr=new CustomServerMgr(normalConfig,_=>new Handler(r=>throw new Exception("no HTTP")))};
PlayFab.PlayFabClientAPI.Logged=false;Check(await normal.Login(null)==ELoginStep.Api,"normal PF reaches API boundary");
Check(((PlayerData)GContext.container.Values[typeof(PlayerData)]).Loads==1 && normal.MMTestForKVValidation==4,"real PF init after adjusted time");
var hotConfig=Configuration();hotConfig.Set("EventApiURL","ftp://synthetic.invalid/");
var hot=new AggUserService{config=hotConfig,customServerMgr=new CustomServerMgr(hotConfig,_=>new Handler(r=>throw new Exception("no HTTP")))};
var sdk=new TaskCompletionSource<LoginInfo>();TYSdkFacade.IsLoggedIn=false;TYSdkFacade.LoginOverride=()=>sdk.Task;PlayFab.PlayFabClientAPI.Logged=false;
var pending=hot.Login(null);hotConfig.Set("SaveBackend","kv");sdk.SetResult(new(){isSuccess=true,userId=101});
Check(await pending==ELoginStep.Api && hot.KVLastError==PlayerKVGameLoadError.None,"captured PF branch continues to API failure");
Check(((PlayerData)GContext.container.Values[typeof(PlayerData)]).Loads==2,"AGG real PF init window");TYSdkFacade.LoginOverride=null;
Pass("PF-original-init-order-and-backend-capture");
UnityEngine.GameObject.Found=new TapjoyUnity.Internal.TapjoyComponent();Effects.Clear();
typeof(PlayFabUserService).GetMethod("InitTapJoy",BindingFlags.Static|BindingFlags.NonPublic).Invoke(null,null);
#if UNITY_ANDROID
Check(Effects.Helpers==2,"PF Android creates and initializes helper");
#else
Check(Effects.Helpers==0,"PF non-Android no helper");
#endif
Pass("PF-original-TapJoy-platform-branch");
UniRx.Stream<EvtAPISvrUnauthorized>.Handlers=null;
var unauthorizedUser=new PlayFabUserService{config=normalConfig,CustomId="101",customServerMgr=new CustomServerMgr(normalConfig,_=>new Handler(r=>throw new Exception("no HTTP")))};
typeof(PlayFabUserService).GetProperty("InternalId").SetValue(unauthorizedUser,(uint)17);PlayFab.PlayFabClientAPI.Logged=true;
int version=unauthorizedUser.customServerMgr.SessionVersion;
GContext.Publish(new EvtAPISvrUnauthorized{SessionVersion=version-1});Check(unauthorizedUser.InternalId==17,"PF stale 401 ignored");
GContext.Publish(new EvtAPISvrUnauthorized{SessionVersion=version});
Check(unauthorizedUser.InternalId==0 && unauthorizedUser.customServerMgr.SessionVersion>version,"PF current 401 retains old reauthentication branch, invalid API prevents HTTP");
UniRx.Stream<EvtAPISvrUnauthorized>.Handlers=null;
Pass("PF-current-and-stale-401-branches");
Private(pf,"OnDestroy"); typeof(PlayFabMgr).GetField("_instance",BindingFlags.Static|BindingFlags.NonPublic).SetValue(null,null);
var fresh=new PlayFabMgr();Check(fresh.IsUnstartedForKVLoad,"fresh PF component");
Check(PlayerKVGameLoadGuard.TryEnter(out var guard),"guard entered");
using(guard)
{
Effects.Clear();
foreach(Action attempt in new Action[]{()=>fresh.CompareDatas(data,"x"),()=>fresh.GetLocalData("x"),()=>fresh.UpdateUserDataValue("x","y"),()=>fresh.UpdateUserDataValue("x","y",false)})
{try{attempt();throw new Exception("guard missed");}catch(PlayerKVLoadSideEffectException){assertions++;}}
Check(!await fresh.SyncDataImmidiate("x","y"),"immediate rejected");
Private(fresh,"SyncData");Private(fresh,"Awake");Private(fresh,"OnApplicationPause",true);Private(fresh,"OnApplicationPause",false);Private(fresh,"OnApplicationQuit");Private(fresh,"OnDestroy");Private(fresh,"UpdateUserDataValue",CancellationToken.None);
Check(Effects.Total==0 && fresh.IsUnstartedForKVLoad && guard.Violations==12,"all PF entrances reject before buffer/Prefs/SDK");
}
Pass("all-real-PF-guard-entrances");await Controlled();
}
sealed class Chat:IChatService {public void WaitingForLogin(){} }
sealed class Setting:ISettingService {public string Lang=>"en";}
static async Task Controlled()
{
int auth=0,get=0,borrow=0,unauthorizedEvents=0;Dictionary<string,string> raw=Raw();
Task<HttpResponseMessage> delayedBorrow=null;
var config=Configuration();var user=new AggUserService(PlayerKVControlledLoadCapability.CreateForValidation(_=>new Handler(r=>throw new Exception()))){config=config,CustomId="101"};
var server=new CustomServerMgr(config,_=>new Handler(r=>throw new Exception("PF transport forbidden")));
server.KVUnauthorized+=_=>unauthorizedEvents++;
var modules=Enumerable.Range(1,31).Select(id=>(IPlayerKVLoadModule)new PlayerKVLoadModule(id,()=>{if(id==31)user.ResetForKVLoad();},decoded=>{if(id==31)user.ReplaceFromKVDecoded(decoded.Module<PlayerKVLoginState>(31),decoded.Epoch,decoded.Raw);})).ToArray();
user.ConfigureKVForValidation(new PlayerKVGameLoadTransaction(modules,Environment.CurrentManagedThreadId,()=>user.KVIntents?.Discard()),new PlayFabMgr(),server,()=>true);
PlayerKVGameLoadCapability Capability()=>PlayerKVGameLoadCapability.CreateForValidation(Identity(),Snapshot(),Api,Path.Combine(Path.GetTempPath(),"ft-kv-service-probe",Guid.NewGuid().ToString("N")),
jar=>new Handler(r=>{auth++;jar.Add(Api,new Cookie("sid","synthetic"));return Json(new{state=0,internalId=17,newCreate=false,createTime="2026-01-01T00:00:00Z",svrTime="2026-01-01T00:00:00Z"});}),
jar=>new Handler(r=>{get++;return Json(new{data=raw});}),jar=>new Handler(r=>{borrow++;return delayedBorrow ?? Json(new{},HttpStatusCode.Unauthorized);}));
Effects.Clear();
foreach(var invalid in new[]{"0","999","15001","bad","1.5"})
{config.Set("KVLoginCloseObserveMs",invalid);Check((await user.LoadKVForValidationAsync(Capability())).Error==PlayerKVGameLoadError.Input,"invalid option rejected");Check(auth==0 && user.KVReceiverCountForValidation==0,"invalid no send/subscription leak");}
config.Values.Remove("KVLoginCloseObserveMs");
var result=await user.LoadKVForValidationAsync(Capability());
Check(result.State==PlayerKVGameLoadState.LoadedForValidation && user.KVLoadedForValidation,"real service controlled success: "+result.Error);
Check(auth==1 && get==1 && user.KVNotificationCount==1 && user.KVReceiverCountForValidation==0,"one auth/Get/windowed receiver");
Check(!user.IsLogin && !user.IsAuthorized && user.InternalId==0 && user.KVTapJoyBalance==9 && Effects.Total==0,"zero SDK/Prefs/reward/UI/helpers/events in KV");
var intents=user.KVIntents;await server.SendKVForValidationAsync("__kv_validation/unauthorized");await user.CloseKVForValidationAsync();
Check(!user.KVLoadedForValidation && user.KVIntents==null && intents.Discarded && borrow==1 && auth==1,"401 revokes without reauth");
raw.Clear();var failed=await user.LoadKVForValidationAsync(Capability());
Check(failed.Error==PlayerKVGameLoadError.Schema && user.KVReceiverCountForValidation==0 && user.KVNotificationCount==1,"failure removes receiver without notification");
Check(Effects.Total==0,"all failure paths zero effects");
raw=Raw();Check((await user.LoadKVForValidationAsync(Capability())).State==PlayerKVGameLoadState.LoadedForValidation,"new epoch after successful cleanup");
var late=new TaskCompletionSource<HttpResponseMessage>(TaskCreationOptions.RunContinuationsAsynchronously);delayedBorrow=late.Task;
var oldRequest=server.SendKVForValidationAsync("__kv_validation/unauthorized");user.Logout();Check(!user.KVLoadedForValidation,"logout synchronously revoked");
late.SetResult(new HttpResponseMessage(HttpStatusCode.Unauthorized){Content=new StringContent("{}")});
Check(!(await oldRequest).IsCurrent,"late 401 body discarded");await user.CloseKVForValidationAsync();
Check(unauthorizedEvents==1 && auth==3 && Effects.Total==0 && user.KVIntents==null,"late 401 never reauthenticates or publishes another unauthorized event");
Pass("real-Agg-User-CustomServer-coordinator-KV-chain");
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings><Nullable>disable</Nullable>
<DefineConstants>$(DefineConstants);UNITY_EDITOR;SERVICE_HOST</DefineConstants>
<DefineConstants Condition="'$(AndroidProbe)'=='true'">$(DefineConstants);UNITY_ANDROID;AGG</DefineConstants>
<DefineConstants Condition="'$(PlayerProbe)'=='true'">SERVICE_HOST</DefineConstants>
<NoWarn>$(NoWarn);CS0436;CS1998</NoWarn>
<RestoreConfigFile>$(MSBuildThisFileDirectory)../../MinimalLoginTests/NuGet.Config</RestoreConfigFile>
</PropertyGroup>
<ItemGroup>
<Compile Remove="FactoryProbe.cs" Condition="'$(PlayerProbe)'!='true'" />
<Compile Remove="Program.cs" Condition="'$(PlayerProbe)'=='true'" />
<Compile Include="../../../../Client/Assets/Scripts/Services/PlayerData/PlayerKV*.cs" Exclude="../../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs" />
<Compile Include="../../../../Client/Assets/Scripts/Services/Account/S101AccountSession.cs" />
<Compile Include="../../../../Client/Assets/Scripts/Services/UserService.cs" />
<Compile Include="../../../../Client/Assets/Scripts/Services/UserService.KVLoad.cs" />
<Compile Include="../../../../Client/Assets/Scripts/Services/AggUserService.cs" />
<Compile Include="../../../../Client/Assets/Scripts/Core/PlayFabMgr.cs" />
<Compile Include="../../../../Client/Assets/Scripts/Core/CustomServerMgr.cs" />
<Reference Include="PlayFab"><HintPath>../../../../Client/Library/ScriptAssemblies/PlayFab.dll</HintPath></Reference>
<Reference Include="Newtonsoft.Json"><HintPath>../../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath></Reference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,139 @@
// Only SDK/engine/business dependencies are replaced. Four service sources are linked unchanged.
using PlayFab.ClientModels;
using Newtonsoft.Json;
// Legacy desktop fallback names AndroidDeviceId on CustomID request (unreachable with validated CustomId).
// Keep this compatibility DTO local; do not silently change the pre-existing PF branch in production.
namespace PlayFab.ClientModels { public class LoginWithCustomIDRequest { public string CustomId,AndroidDeviceId;public bool CreateAccount;public GetPlayerCombinedInfoRequestParams InfoRequestParameters; } }
static class Effects
{
internal static int SDK,Prefs,Events,Subscriptions,Helpers,Rewards,UI,Timers,Account;
internal static void Clear() { SDK=Prefs=Events=Subscriptions=Helpers=Rewards=UI=Timers=Account=0; }
internal static int Total => SDK+Prefs+Events+Subscriptions+Helpers+Rewards+UI+Timers+Account;
}
namespace UnityEngine
{
public class Object { public static implicit operator bool(Object value)=>value!=null; public static void DontDestroyOnLoad(Object o){} }
public class GameObject:Object
{
public static object Found;
public static T FindObjectOfType<T>() where T:class =>Found as T;
public T AddComponent<T>() where T:new(){Effects.Helpers++;return new T();}
}
public class MonoBehaviour:Object { public GameObject gameObject=new(); public T GetComponent<T>() where T:class=>null; }
public static class Debug { public static void Log(object o){} public static void LogWarning(object o){} public static void LogError(object o){} }
public static class Application { public static string version="synthetic",identifier="101",persistentDataPath=Path.GetTempPath(); }
public static class SystemInfo { public static string deviceUniqueIdentifier="101",deviceModel="stub"; }
public static class PlayerPrefs
{
public static readonly Dictionary<string,string> Data=new();
public static string GetString(string key,string fallback=""){Effects.Prefs++;return Data.GetValueOrDefault(key,fallback);}
public static void SetString(string key,string value){Effects.Prefs++;Data[key]=value;}
public static void Save(){Effects.Prefs++;}
}
}
namespace asap.core
{
public class InjectAttribute:Attribute{}
public interface IConfig { T Get<T>(string key,T fallback); void Set(string key,object value); }
public static class Awaiters { public static Task NextFrame=>Task.CompletedTask; }
}
sealed class Config:asap.core.IConfig
{
internal readonly Dictionary<string,object> Values=new();
public T Get<T>(string key,T fallback)=>Values.TryGetValue(key,out var v)?(T)v:fallback;
public void Set(string key,object value)=>Values[key]=value;
}
namespace UniRx
{
public sealed class Stream<T>
{ internal static Action<T> Handlers; public IDisposable Subscribe(Action<T> action){Effects.Subscriptions++;Handlers+=action;return new Subscription(()=>Handlers-=action);} }
public sealed class Subscription:IDisposable { readonly Action action; public Subscription(Action action)=>this.action=action;public void Dispose()=>action(); }
}
public static class GContext
{
public static readonly Container container=new();
public static UniRx.Stream<T> OnEvent<T>()=>new();
public static void Publish<T>(T value){Effects.Events++;UniRx.Stream<T>.Handlers?.Invoke(value);}
}
public sealed class Container
{ public readonly Dictionary<Type,object> Values=new();public T Resolve<T>()=> (T)Values[typeof(T)]; }
public static class GameDebug { public static void LogError(string s,params object[] a){} }
public static class ZZTimeHelper { public static DateTime UtcNow()=>new(2026,9,23,0,0,0,DateTimeKind.Utc); public static bool Adjusted; public static void AdjustTime(DateTime t)=>Adjusted=true;public static void WathForcusChange(){} }
public static class GConstant { public const string K_Event_API_URL="EventApiURL",K_AssetVersion="AssetVersion",Yidun_GooglePlayChannel="stub",Yidun_AndroidBusinessId="stub",K_PlayFab_Title="PFTitle",V_Debug_PF_Title="stub",K_PlayFab_TimeOut="PFTimeout"; }
namespace LitJson { public static class JsonMapper { public static string ToJson(object o)=>JsonConvert.SerializeObject(o);public static T ToObject<T>(string s)=>JsonConvert.DeserializeObject<T>(s); } }
namespace GameCore { }
namespace YiDun.Protector { public class YiDunManager { } }
namespace PlayFab
{
public static class PlayFabClientAPI
{
public static bool Logged;
public static bool IsClientLoggedIn(){Effects.SDK++;return Logged;}
public static void ForgetAllCredentials(){Effects.SDK++;Logged=false;}
public static void UpdateUserData(UpdateUserDataRequest r,Action<UpdateUserDataResult> ok,Action<PlayFabError> fail){Effects.SDK++;ok?.Invoke(new());}
}
public static class PlayFabSettings { public static string TitleId;public static int RequestTimeout; }
}
namespace PlayFab.Internal { public static class PlayFabWebRequest { public static void SkipCertificateValidation(){Effects.SDK++;} } }
namespace asap.playfab.async
{
public static class PlayFabClientAsyncAPI
{
internal static LoginResult Result;
internal static Func<Task<LoginResult>> LoginOverride;
public static Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> call)=>call();
public static Task<LoginResult> LoginWithCustomIDAsync(LoginWithCustomIDRequest r){Effects.SDK++;PlayFab.PlayFabClientAPI.Logged=Result!=null;return LoginOverride?.Invoke() ?? Task.FromResult(Result);}
public static Task<LoginResult> LoginWithAndroidDeviceIDAsync(LoginWithAndroidDeviceIDRequest r)=>LoginWithCustomIDAsync(new());
public static Task<GetPlayerProfileResult> GetPlayerProfileAsync(GetPlayerProfileRequest r){Effects.SDK++;return Task.FromResult(new GetPlayerProfileResult{PlayerProfile=new PlayerProfileModel{Locations=new(){new LocationModel()}}});}
public static Task<UpdateUserTitleDisplayNameResult> UpdateUserTitleDisplayNameAsync(UpdateUserTitleDisplayNameRequest r){Effects.SDK++;return Task.FromResult(new UpdateUserTitleDisplayNameResult{DisplayName=r.DisplayName});}
public static Task<EmptyResponse> UpdateAvatarUrlAsync(UpdateAvatarUrlRequest r){Effects.SDK++;return Task.FromResult(new EmptyResponse());}
public static Task<GetUserDataResult> GetUserDataAsync(GetUserDataRequest r){Effects.SDK++;return Task.FromResult(new GetUserDataResult{Data=new()});}
}
public static class PlayFabProfilesAsyncAPI
{ public static Task<PlayFab.ProfilesModels.SetProfileLanguageResponse> SetProfileLanguageAsync(PlayFab.ProfilesModels.SetProfileLanguageRequest r){Effects.SDK++;return Task.FromResult(new PlayFab.ProfilesModels.SetProfileLanguageResponse());} }
}
namespace tysdk
{
public enum EAccoutType {hwGoogle,hwFacebook,hwGuest,hwVkid,Apple,none}
public class LoginInfo { public bool isSuccess; public int userId;public string StrUserId=>userId.ToString(); }
public sealed class TYSdkFacade
{
public sealed class AccountInfo {public EAccoutType channel;public string userName,avatar;public int userId;public List<EAccoutType> linkedAccout=new();}
public static AccountInfo TYAccountInfo=new(){userId=101,channel=EAccoutType.hwGuest};
public static bool IsLoggedIn;
public static TYSdkFacade Instance=new();
internal static Func<Task<LoginInfo>> LoginOverride;
public Task<LoginInfo> LoginBySns(){Effects.SDK++;return LoginOverride?.Invoke() ?? Task.FromResult(new LoginInfo{isSuccess=true,userId=101});}
public Task<LoginInfo> LoginByToken()=>LoginBySns();public Task<LoginInfo> Login(EAccoutType type)=>LoginBySns();
public void Logout(){Effects.SDK++;IsLoggedIn=false;}
public Task<bool> LinkCheck(EAccoutType t){Effects.SDK++;return Task.FromResult(false);}
public Task<(int code,int userId)> LinkAccount(EAccoutType t){Effects.SDK++;return Task.FromResult((1,0));}
}
}
public static class ChannelManager { public static bool IsRustoreStore=>false; }
public class AFHelper { public static AFHelper Instance=new();public bool GetDLReferer(out string pid,out string uid){pid=uid=null;return false;} }
public static class GEvent
{
public sealed class Event:IDisposable { public Event AddContent(string k,object v)=>this;public void Dispose(){} }
public static Event GameEvent(string name){Effects.Events++;return new();}
public static Event LoginEvent()=>GameEvent("login");public static Event CompleteRegistrationEvent()=>GameEvent("registration");
public static void SetProp(string k,object v){Effects.Events++;}public static void SetUserProp(string k,object v,bool b){Effects.Events++;}
public static void SetProps(Dictionary<string,object> p){Effects.Events++;}public static void SetUserProps(Dictionary<string,object> p){Effects.Events++;}
public static void OnLogin(string a,string b){Effects.Events++;}
}
namespace game
{
public interface IChatService { void WaitingForLogin(); }
public interface ISettingService { string Lang {get;} }
public class MMTService { public string Init(int value){Effects.Events++;return value.ToString();} }
public class PlayerData { public int lv=1;public int Loads;public void LoadPlayerData(IDictionary<string,string> data){if(!ZZTimeHelper.Adjusted)throw new Exception("PF time not adjusted");Loads++;} }
public class ChangeAvatarEvent{}
public class GetClubPlayInfoEvent { public string id,name,avatarUrl; }
public class ItemData {public int id,count;public ulong curCount;}
public enum RewardType {NormalOne}
public class ShowData {public ShowData(){}public ShowData(List<ItemData> items,RewardType t){Effects.UI++;}}
public class PlayerItemData {public int GetItemCount(int id)=>0;public void AddItem(ItemData i,object o){Effects.Rewards++;}}
public class TapjoyHelper:UnityEngine.MonoBehaviour {public void Init(int balance,Action<string,int> earn,Action<string,int> changed){Effects.Helpers++;}}
}
namespace TapjoyUnity.Internal { public class TapjoyComponent:UnityEngine.MonoBehaviour{} }

View File

@@ -0,0 +1,47 @@
using game.PlayerDataStorage;
using Newtonsoft.Json;
static partial class Tests
{
static async Task RunShopSchemaTests()
{
await Run("M04-all-fields-strict-and-missing",() =>
{
var daily="{\"ID\":1,\"index\":2,\"lastID\":3,\"time\":\"raw\"}";
var raw=new Dictionary<string,string> {
["ShopPackBuyCount"]="{\"x\":1}",["ShopAllPackBuyCount"]="{\"2\":3}",["ShopGiftBuyCount"]="{\"4\":5}",
["Voucher"]="6",["ChainPackDataNew"]=daily,["TurntableAdData"]=daily,
["VIPAdPackData"]="{\"ID\":7,\"index\":8,\"day\":9,\"cdTime\":\"raw\",\"isOpen\":true}",
["BuyData"]="[1.5,2.5]",["AdvertAllCount"]="{\"-2\":10}",["DiamondShopRefreshCount"]="11",
["DiamondShopRefreshTime"]="2026-09-23T00:00:00.0000000Z",
["DiamondShopItemRecords"]="{\"12\":{\"ItemID\":13,\"Discount\":0.5,\"hasBought\":1}}",
["BlackFridayCouponDate"]="2026,9,23,1,2,3,14,15,1"
};
var a=PlayerKVSaveSchema.DecodeShop(raw,SchemaSnapshot(),new());
Check(a!=null && a.Daily["x"]==1 && a.All[2]==3 && a.Gift[4]==5 && a.Voucher==6,"counts");
Check(a.Chain.Id==1 && a.Chain.Index==2 && a.Chain.LastId==3 && a.Chain.Time=="raw" && a.Turntable.LastId==3,"daily");
Check(a.Vip.Id==7 && a.Vip.Index==8 && a.Vip.Day==9 && a.Vip.Open && a.Vip.Time=="raw","advert");
Check(a.Buy.SequenceEqual(new[]{1.5f,2.5f}) && a.Advert[-2]==10 && a.RefreshCount==11 && a.RefreshTime.Year==2026,"saved shop fields");
Check(a.Items[12].Id==13 && a.Items[12].Discount==.5f && a.Items[12].Bought==1 && a.Coupon.EventId==14 && a.Coupon.RedirectId==15 && a.Coupon.Bought,"nested values");
foreach(var key in raw.Keys)
{
var bad=new Dictionary<string,string>(raw) { [key]="null" };
Check(PlayerKVSaveSchema.DecodeShop(bad,SchemaSnapshot(),new())==null,"present bad key rejects");
bad[key]=null;
Check(PlayerKVSaveSchema.DecodeShop(bad,SchemaSnapshot(),new())==null,"present null rejects");
}
var errors=new PlayerKVSchemaDiagnostics();
Check(PlayerKVSaveSchema.DecodeShop(raw.ToDictionary(p=>p.Key,p=>"null"),SchemaSnapshot(),errors)==null && errors.TotalErrors==13,"all fields diagnosed");
var b=PlayerKVSaveSchema.DecodeShop(new Dictionary<string,string>(),SchemaSnapshot(),new());
Check(b.Daily.Count==0 && b.All.Count==0 && b.Gift.Count==0 && b.Advert.Count==0 && b.Items.Count==0 && b.Buy.Count==0 &&
b.Chain==null && b.Turntable==null && b.Vip==null && b.Voucher==0 && b.RefreshCount==0 && b.RefreshTime==default && b.Coupon.ActivateTime==default && !b.Coupon.Bought,"missing clears all");
foreach(var coupon in new[] { "2026,9,23,1,2,3,1,2", "2026,2,30,1,2,3,1", "2026,9,23,1,2,3,1,2,2", "2026,9,23,1,2,3,1,2,1,0" })
Check(PlayerKVSaveSchema.DecodeShop(new Dictionary<string,string>{["BlackFridayCouponDate"]=coupon},SchemaSnapshot(),new())==null,"bad coupon shape/date/bool");
b=PlayerKVSaveSchema.DecodeShop(new Dictionary<string,string>{["BlackFridayCouponDate"]="2026,9,23,1,2,3,1"},SchemaSnapshot(),new());
Check(b.Coupon.EventId==1 && b.Coupon.RedirectId==0 && !b.Coupon.Bought,"legacy seven-slot coupon");
foreach(var key in new[] { "BuyData","DiamondShopItemRecords" })
Check(PlayerKVSaveSchema.DecodeShop(new Dictionary<string,string>{[key]=key=="BuyData" ? "[1e999]" : "{\"1\":null}"},SchemaSnapshot(),new())==null,"invalid nested numeric");
return Task.CompletedTask;
});
}
}

View File

@@ -0,0 +1,40 @@
$ErrorActionPreference='Stop'
$root=(Resolve-Path (Join-Path $PSScriptRoot '../../..')).Path
Set-Location $root
$baseline=Get-Content 'CodexOutput/upserver/阶段4B2方案受审基线-修订2-2026-09-23.json' -Raw | ConvertFrom-Json
$partial=Get-Content "$PSScriptRoot/checkpoint2-partial-final-verification.json" -Raw | ConvertFrom-Json
$allowed=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
foreach($item in $partial.checks){if($item.authorizedChange){[void]$allowed.Add($item.path)}}
foreach($path in @('DataCenter/DataCenter.PlayerFishData.cs','DataCenter/EventPartnerGatherManager.cs','Services/UserService.cs','Services/AggUserService.cs','Core/CustomServerMgr.cs','Core/PlayFabMgr.cs','Stages/QuitAct.cs','EventPartnerGather/PartnerInfoManager.cs')){[void]$allowed.Add("Client/Assets/Scripts/$path")}
$checks=@(foreach($item in $baseline.files){$hash=(Get-FileHash -LiteralPath $item.path -Algorithm SHA256).Hash; [ordered]@{path=$item.path;baseline=$item.sha256;actual=$hash;unchanged=$hash -eq $item.sha256;authorizedChange=$allowed.Contains($item.path)}})
$outside=@($checks | Where-Object { !$_.unchanged -and !$_.authorizedChange })
$club=Get-Content 'Client/Assets/Scripts/Services/ClubService.cs' -Raw
$clubBase=Get-Content "$PSScriptRoot/baseline/ClubService.cs.txt" -Raw
$begin=$club.IndexOf(' // KV loading owns these three fields only;')
$end=$club.IndexOf(' public int ClubLureDonateCount', $begin)
$stripped=$club.Remove($begin,$end-$begin).Replace("`r`n","`n")
$clubOnlyKV=$stripped.Replace(" LureDonateData lureDonateData;`n`n"," LureDonateData lureDonateData;`n") -ceq $clubBase.Replace("`r`n","`n")
$sourcePaths=@($allowed | Where-Object { $_.EndsWith('.cs') })
$sourcePaths+=@(Get-ChildItem 'Client/Assets/Scripts/Services/PlayerData/PlayerKV*.cs' | ForEach-Object { $_.FullName.Substring($root.Length+1).Replace('\','/') })
$sourcePaths+=@('Client/Assets/Scripts/Services/UserService.KVLoad.cs','Client/Assets/Editor/KVGameLoadValidation.cs')
$methods=@();$flags=@()
foreach($path in ($sourcePaths | Sort-Object -Unique)){
$text=Get-Content -LiteralPath $path -Raw
foreach($match in [regex]::Matches($text,'\b(?:internal|public|private)\s+(?:override\s+|virtual\s+)?void\s+(ResetForKVLoad|ReplaceFromKVDecoded)\s*\([^)]*\)\s*\{')){
$start=$match.Index+$match.Length;$depth=1;$end=$start
while($end -lt $text.Length -and $depth -gt 0){if($text[$end] -eq '{'){$depth++};if($text[$end] -eq '}'){$depth--};$end++}
$body=$text.Substring($start,$end-$start)
# This is a bounded direct-call audit, not a substitute for constructor/helper inspection or runtime counters.
$hits=@([regex]::Matches($body,'\b(?:PlayerPrefs|PlayFabClientAPI|PlayFabClientAsyncAPI|TYSdkFacade|GEvent|Task\.Delay|Observable|Timer)\b|\b(?:Publish|Subscribe|SaveData|UpdateUserDataValue|LoadData|InitData|Init|ClearData|AddGiftFaceUIForce|AddItem)\s*\(') | ForEach-Object Value)
$record=[ordered]@{path=$path;method=$match.Groups[1].Value;line=1+([regex]::Matches($text.Substring(0,$match.Index),"`n")).Count;directForbiddenCalls=$hits}
$methods+=$record;if($hits.Count){$flags+=$record}
}
}
$compile=Get-Content "$PSScriptRoot/checkpoint4-final4-compile.log" -Raw | ConvertFrom-Json
$localCases=(Get-Content "$PSScriptRoot/checkpoint4-final-unity-cases.log" -Raw | ConvertFrom-Json).Result | ConvertFrom-Json | Where-Object caseId -ne 'B2-20'
$full=(Get-Content "$PSScriptRoot/checkpoint4-release-full.log" -Raw | ConvertFrom-Json).Result | ConvertFrom-Json
$result=[ordered]@{generatedAt=(Get-Date).ToString('o');baselineCount=$checks.Count;unchanged=@($checks | Where-Object unchanged).Count;authorizedChanges=@($checks|Where-Object{!$_.unchanged -and $_.authorizedChange}).Count;outside=$outside;clubOnlyAddedKVBlock=$clubOnlyKV;directAuditMethodCount=$methods.Count;directAuditFlags=$flags;compile=@{success=$compile.Success;errors=$compile.ErrorCount;warnings=$compile.WarningCount;newKVWarnings=@($compile.Warnings|Where-Object{$_.File -match 'PlayerKV|KVGameLoadValidation|UserService.KVLoad'}).Count};unityLocalCases=@($localCases);unityFull=$full;unityAssertions=($localCases.assertions | Measure-Object -Sum).Sum+$full.assertions;checks=$checks;methods=$methods}
$result | ConvertTo-Json -Depth 8 | Set-Content "$PSScriptRoot/checkpoint4-boundary-and-evidence.json"
[ordered]@{baselineCount=$checks.Count;outside=$outside.Count;clubOnlyKV=$clubOnlyKV;auditMethods=$methods.Count;directFlags=$flags.Count;unityAssertions=$result.unityAssertions;fullB220=$full.fullB220} | ConvertTo-Json
if($outside.Count -gt 0 -or !$clubOnlyKV -or $flags.Count -gt 0 -or !$full.success){exit 1}
exit 0

View File

@@ -0,0 +1,915 @@
using asap.core;
using cfg;
using GameCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;
using Newtonsoft.Json.Linq;
namespace game
{
public class ClubStateChangeEvent
{
public int state;
public bool joinClub;
}
public class ClubSttingUpdateEvent
{
}
public class GetClubPlayInfoEvent
{
public string id;
public string name;
public string avatarUrl;
}
public class LureDonateData
{
public DateTime DonateCountTime { get; set; }
public int ClubLureDonateCount { get; set; }//时间加次数
public DateTime ClubLureRequestCDEnd { get; set; }
}
public class ClubService //: IClubService
{
[Inject]
public IUserService userService { get; set; }
[Inject]
public ICustomServerMgr customServerMgr { get; set; }
[Inject]
public IChatService chatService { get; set; }
public bool IsOpenClubJoined = false;
public bool IsOpenClubRed = true;
public int socialTipforUnlocked;
public ClubInfo myClubInfo { get; private set; }
//public bool isAdm { get; private set; }
public EMemberRole memberRole { get; private set; } = EMemberRole.Member;
public DateTime? signTime { get; private set; }
LureDonateData lureDonateData;
public int ClubLureDonateCount => lureDonateData.ClubLureDonateCount;
public DateTime ClubLureRequestCDEnd => lureDonateData.ClubLureRequestCDEnd;
public Dictionary<long, string> internalIdToPlayerId = new Dictionary<long, string>();
public ClubItemData ClubItemData;
public List<SocialInfoItemData> clubPlayerInfos { get; private set; }
public List<EventPopupData> eventPopupDatas { get; private set; } = new List<EventPopupData>();
public EventPopupData eventPopupDataClub { get; private set; }
public int OpenPanelType = 0;
public async void OpenFishingSocialPanel(int type)
{
OpenPanelType = type;
await UIManager.Instance.ShowUI(UITypes.FishingSocialPanel);
OpenPanelType = 0;
}
public bool CanAccepted
{
get
{
return memberRole == EMemberRole.Owner
|| memberRole == EMemberRole.Vice
|| memberRole == EMemberRole.Elder;
}
}
public bool ApplyState { get; private set; }
public bool CanSetting
{
get
{
return memberRole == EMemberRole.Owner || memberRole == EMemberRole.Vice;
}
}
public void Init()
{
if (lureDonateData == null)
{
lureDonateData = new LureDonateData();
lureDonateData.DonateCountTime = ZZTimeHelper.UtcNow().UtcNowOffset();
lureDonateData.ClubLureDonateCount = 0;
lureDonateData.ClubLureRequestCDEnd = ZZTimeHelper.UtcNow().UtcNowOffset();
}
if (IsOpenClubJoined)
{
SetClubRed();
}
}
void SetClubRed()
{
if (myClubInfo == null)
{
RedPointManager.Instance.SetRedPointState(RedPointName.Home_Club, IsOpenClubRed);
}
else
{
var timer = ZZTimeHelper.UtcNow().UtcNowOffset();
bool isSign = signTime?.DayOfYear != timer.DayOfYear;
bool isCanLure = lureDonateData != null && lureDonateData.ClubLureRequestCDEnd <= timer;
RedPointManager.Instance.SetRedPointState(RedPointName.Home_Club, isCanLure || isSign);
}
}
public void SetLureDonateData(string value)
{
lureDonateData = Newtonsoft.Json.JsonConvert.DeserializeObject<LureDonateData>(value);
}
void SaveLureDonateData()
{
PlayFabMgr.Instance.UpdateUserDataValue("LureDonateData", Newtonsoft.Json.JsonConvert.SerializeObject(lureDonateData));
}
//刷新体力求助次数
public void RefreshLureDonate()
{
if (lureDonateData.DonateCountTime.Date != ZZTimeHelper.UtcNow().UtcNowOffset().Date)
{
lureDonateData.DonateCountTime = ZZTimeHelper.UtcNow().UtcNowOffset();
lureDonateData.ClubLureDonateCount = 0;
SaveLureDonateData();
}
}
public void SetOpenClubRed()
{
if (IsOpenClubRed)
{
IsOpenClubRed = false;
SetClubRed();
PlayFabMgr.Instance.UpdateUserDataValue("IsOpenClubRed", "1");
}
}
public bool IsClubJoined()
{
return myClubInfo != null;
}
public void SetClubSignin(string value)
{
if (string.IsNullOrEmpty(value))
{
signTime = null;
}
else
{
signTime = DateTime.Parse(value);
}
}
public async void GetSelfClub()
{
if (!userService.IsAuthorized || string.IsNullOrEmpty(userService.ApiPlayerId))
return;
var sessionVersion = customServerMgr.SessionVersion;
GetSelfClubRequest getSelfClubRequest = new GetSelfClubRequest()
{
playerId = userService.ApiPlayerId,
};
string json = await customServerMgr.ClubRequest("GetSelfClub", getSelfClubRequest);
if (sessionVersion != customServerMgr.SessionVersion) return;
if (string.IsNullOrWhiteSpace(json)) return;
GetSelfClubResp response;
try
{
var body = JObject.Parse(json);
if (!body.TryGetValue("clubInfo", out var club) ||
(club.Type != JTokenType.Null && club.Type != JTokenType.Object)) return;
response = body.ToObject<GetSelfClubResp>();
if (response == null) return;
if (club.Type == JTokenType.Object &&
(response.clubInfo == null || response.clubInfo.id == 0 ||
body["role"]?.Type != JTokenType.Integer || !Enum.IsDefined(typeof(EMemberRole), response.role))) return;
}
catch (Newtonsoft.Json.JsonException) { return; }
catch (OverflowException) { return; }
myClubInfo = response.clubInfo;
memberRole = myClubInfo == null ? EMemberRole.Member : response.role;
internalIdToPlayerId = response.playerIDs ?? new Dictionary<long, string>();
if (myClubInfo != null)
{
SetClubPlayerProfile(response.clubPlayerProfiles);
chatService.GetConversationList((long)myClubInfo.id);
}
SetClubRed();
MyClubInfoChange();
}
void MyClubInfoChange(bool joinClub = false)
{
GContext.Publish(new ClubStateChangeEvent() { state = 0, joinClub = joinClub });
}
public async Task<int> GetOnlineUsers()
{
HashSet<long> longs = await chatService.GetOnlineUsers(internalIdToPlayerId.Keys.ToList());
return longs == null ? 0 : longs.Count;
}
public void Refresh()
{
ClubInfoMap.Clear();
}
Dictionary<string, List<ClubInfo>> ClubInfoMap = new Dictionary<string, List<ClubInfo>>();
public async Task<List<ClubInfo>> GetClubs(string clubName)
{
if (ClubInfoMap.ContainsKey(clubName))
{
await Awaiters.NextFrame;
return ClubInfoMap[clubName];
}
GetClubsRequest getClubsRequest = new GetClubsRequest()
{
clubName = clubName,
};
string json = await customServerMgr.ClubRequest("GetClubs", getClubsRequest);
if (!string.IsNullOrEmpty(json) && json != "[]")
{
GetClubsResp clubDatas = Newtonsoft.Json.JsonConvert.DeserializeObject<GetClubsResp>(json);
ClubInfoMap[clubName] = clubDatas.clubInfo;
return clubDatas.clubInfo;
}
else
{
return null;
}
}
private static bool IsDecimalPlayerId(string id) =>
long.TryParse(id, System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture, out var number) && number > 0;
private bool TryGetClubSession(out string playerId, out int version)
{
playerId = userService.ApiPlayerId;
version = customServerMgr.SessionVersion;
return userService.IsAuthorized && IsDecimalPlayerId(playerId);
}
private bool IsClubSessionCurrent(string playerId, int version) =>
version == customServerMgr.SessionVersion && userService.IsAuthorized && playerId == userService.ApiPlayerId;
private static bool TryReadClubResponse<T>(string json, out T result, params int[] acceptedCodes) where T : class
{
result = null;
if (string.IsNullOrWhiteSpace(json)) return false;
try
{
var body = Newtonsoft.Json.Linq.JObject.Parse(json);
var code = body["code"];
if (code?.Type != Newtonsoft.Json.Linq.JTokenType.Integer || !acceptedCodes.Contains(code.Value<int>())) return false;
result = body.ToObject<T>();
return result != null;
}
catch (Newtonsoft.Json.JsonException) { return false; }
catch (OverflowException) { return false; }
}
public async Task<List<SocialInfoItemData>> GetClubDetails(ulong clubID, bool applyState = false)
{
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return null;
ApplyState = false;
GetClubDetailsRequest getClubDetailsRequest = new GetClubDetailsRequest()
{
id = clubID,
playerId = apiPlayerId,
applyState = applyState,
};
string json = await customServerMgr.ClubRequest("GetClubDetails", getClubDetailsRequest);
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return null;
if (!string.IsNullOrEmpty(json) && json != "[]")
{
GetClubDetailsResp clubDetails = Newtonsoft.Json.JsonConvert.DeserializeObject<GetClubDetailsResp>(json);
if (clubDetails.playerIDs == null)
{
return null;
}
List<SocialInfoItemData> clubPlayerInfos = new List<SocialInfoItemData>();
if (myClubInfo != null && clubID == myClubInfo.id)
{
internalIdToPlayerId = clubDetails.playerIDs;
//internalIds = clubDetails.internalIds;
}
List<long> longs1 = clubDetails.playerIDs.Keys.ToList();
HashSet<long> longs = await chatService.GetOnlineUsers(longs1);
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return null;
List<ClubPlayerProfile> clubPlayerProfiles = clubDetails.clubPlayerProfiles;
SetClubPlayerProfile(clubPlayerProfiles);
for (int i = 0; i < clubPlayerProfiles.Count; i++)
{
ClubPlayerProfile clubPlayerProfile = clubPlayerProfiles[i];
SocialInfoItemData clubPlayerInfo = new SocialInfoItemData();
clubPlayerInfo.playFabId = clubPlayerProfile.playerId;
userService.GetPlayInfo(clubPlayerInfo.playFabId);
clubPlayerInfo.lv = userService.GetPlayerLv(clubPlayerInfo.playFabId);
//clubPlayerInfo.rank = i + 1;
clubPlayerInfo.isOnline = longs != null && longs.Contains(clubPlayerProfile.internalId);
clubPlayerInfo.role = clubPlayerProfile.role;
if (clubPlayerProfile.lastLoginTime != null)
{
clubPlayerInfo.lastLoginTime = (DateTime)clubPlayerProfile.lastLoginTime;
}
clubPlayerInfos.Add(clubPlayerInfo);
}
clubPlayerInfos.Sort((x, y) =>
{
//if (y.isOnline != x.isOnline)
//{
// return y.isOnline.CompareTo(x.isOnline);
//}
//else
{
return y.lv.CompareTo(x.lv);
}
});
ApplyState = clubDetails.applyState;
return clubPlayerInfos;
}
return null;
}
public async Task<ClubInfo> CreateClub(ClubSettingData clubSettingData)
{
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return null;
var internalId = userService.InternalId;
var request = new CreateClubRequest { playerId = apiPlayerId, clubSettingData = clubSettingData };
var json = await customServerMgr.ClubRequest("CreateClub", request);
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return null;
// Code 1 means already a member; returning a club would charge the create UI again.
if (!TryReadClubResponse<CreateClubResp>(json, out var response, 0) || response.clubInfo == null || response.clubInfo.id == 0)
return null;
memberRole = EMemberRole.Owner;
myClubInfo = response.clubInfo;
internalIdToPlayerId[internalId] = apiPlayerId;
ShowPromote();
chatService.GetConversationList((long)myClubInfo.id);
MyClubInfoChange(true);
return myClubInfo;
}
public async void ShowPromote()
{
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
GameObject go = await UIManager.Instance.ShowUI(UITypes.ClubPromoteNoticePopupPanel);
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion) || go == null) return;
var ClubPromoteNoticePopupPanel = go.GetComponent<ClubPromoteNoticePopupPanel>();
ClubPromoteNoticePopupPanel.SetRole(memberRole);
}
public async Task<bool> UpdateClub(ClubSettingData clubSettingData)
{
UpdateClubRequest updateClubRequest = new UpdateClubRequest()
{
id = myClubInfo.id,
clubSettingData = clubSettingData,
};
string json = await customServerMgr.ClubRequest("UpdateClub", updateClubRequest);
if (!string.IsNullOrEmpty(json) && json != "[]")
{
UpdateClubResp createClubResp = Newtonsoft.Json.JsonConvert.DeserializeObject<UpdateClubResp>(json);
if (createClubResp.code == 0)
{
myClubInfo.clubSettingData = clubSettingData;
GContext.Publish(new ClubSttingUpdateEvent());
return true;
}
}
return false;
}
public async Task<bool> ApplyClub(ulong clubID)
{
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return false;
ApplyClubRequest applyClubRequest = new ApplyClubRequest()
{
playerId = apiPlayerId,
//entityKey = userService.entityKey.Id,
id = clubID,
};
string json = await customServerMgr.ClubRequest("ApplyClub", applyClubRequest);
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return false;
if (!string.IsNullOrEmpty(json) && json != "[]")
{
ApplyClubResp applyClubResp = Newtonsoft.Json.JsonConvert.DeserializeObject<ApplyClubResp>(json);
if (applyClubResp.code == 0)
{
memberRole = EMemberRole.Member;
ToastPanel.Show(LocalizationMgr.GetText("UI_ToastPanel_41"));
myClubInfo = applyClubResp.clubInfo;
internalIdToPlayerId = applyClubResp.playerIDs;
SetClubPlayerProfile(applyClubResp.clubPlayerProfiles);
//internalIds = applyClubResp.internalIds;
chatService.GetConversationList((long)myClubInfo.id);
MyClubInfoChange(true);
return true;
}
else if (applyClubResp.code == 1)
{
ToastPanel.Show(LocalizationMgr.GetText("UI_ToastPanel_44"));
}
else
{
ToastPanel.Show(LocalizationMgr.GetText("UI_ToastPanel_45"));
}
}
return false;
}
public async void HandleApply(bool isAgree, string applyId)
{
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
if (applyId == "" && isAgree) return;
if (applyId != "" && !IsDecimalPlayerId(applyId)) return;
if (myClubInfo == null) return;
if (isAgree && myClubInfo.curMembers >= myClubInfo.maxMembers)
{
//人数已满
return;
}
HandleApplyRequest handleApplyRequest = new HandleApplyRequest()
{
playerId = applyId,
//entityKey = entityID,
isAgree = isAgree,
id = myClubInfo.id,
};
string json = await customServerMgr.ClubRequest("HandleApply", handleApplyRequest);
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
if (!string.IsNullOrEmpty(json) && json != "[]")
{
if (!TryReadClubResponse<HandleApplyResp>(json, out var handleApplyResp, applyId == "" ? 4 : 0) || myClubInfo == null) return;
if (handleApplyResp.code == 0 || handleApplyResp.code == 4)
{
if (applyId == "")
{
clubPlayerInfos.Clear();
RedPointManager.Instance.SetRedPointState(RedPointName.ApplyList, false);
}
else
{
for (int i = 0; i < clubPlayerInfos.Count; i++)
{
if (clubPlayerInfos[i].playFabId == applyId)
{
clubPlayerInfos.RemoveAt(i);
RedPointManager.Instance.SetRedPointState(RedPointName.ApplyList, clubPlayerInfos.Count > 0
&& CanAccepted);
break;
}
}
}
if (isAgree) myClubInfo.curMembers++;
GContext.Publish(new ClubSttingUpdateEvent());
}
}
}
public async void GetApplyList()
{
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
if (myClubInfo == null) return;
GetApplyListRequest getApplyListRequest = new GetApplyListRequest()
{
playerId = apiPlayerId,
id = myClubInfo.id,
};
string json = await customServerMgr.ClubRequest("GetApplyList", getApplyListRequest);
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
if (!string.IsNullOrEmpty(json) && json != "[]")
{
GetApplyListResp getApplyListResp = Newtonsoft.Json.JsonConvert.DeserializeObject<GetApplyListResp>(json);
clubPlayerInfos = new List<SocialInfoItemData>();
SetClubPlayerProfile(getApplyListResp.clubPlayerProfiles);
for (int i = 0; i < getApplyListResp.userIDList.Count; i++)
{
SocialInfoItemData clubPlayerInfo = new SocialInfoItemData();
//clubPlayerInfo.rank = i + 1;
//clubPlayerInfo.entityKey = getApplyListResp.entityKeys[i];
clubPlayerInfo.playFabId = getApplyListResp.userIDList[i];
userService.GetPlayInfo(clubPlayerInfo.playFabId);
clubPlayerInfo.lv = userService.GetPlayerLv(clubPlayerInfo.playFabId);
clubPlayerInfos.Add(clubPlayerInfo);
}
clubPlayerInfos.Sort((x, y) => y.lv.CompareTo(x.lv));
RedPointManager.Instance.SetRedPointState(RedPointName.ApplyList, clubPlayerInfos.Count > 0 && CanAccepted);
}
}
void SetClubPlayerProfile(List<ClubPlayerProfile> clubPlayerProfiles)
{
if (clubPlayerProfiles == null)
{
return;
}
foreach (var item in clubPlayerProfiles)
{
if (!string.IsNullOrEmpty(item.displayName) || !string.IsNullOrEmpty(item.avatar))
{
userService.SetPlayInfo(item.playerId, item.displayName, item.avatar);
userService.SetPlayerLv(item.playerId, item.level);
}
}
}
public async void QuitClub(string appointId = "0")
{
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
if (!string.IsNullOrEmpty(appointId) && appointId != "0" && (!IsDecimalPlayerId(appointId) || appointId == apiPlayerId)) return;
var request = new QuitClubRequest { playerId = apiPlayerId, appointId = appointId };
var json = await customServerMgr.ClubRequest("QuitClub", request);
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
if (!TryReadClubResponse<QuitClubResp>(json, out var response, 0, 1)) return;
chatService.OnQuit();
myClubInfo = null;
ClearLikeCursorIds();
ToastPanel.Show(LocalizationMgr.GetText("UI_ToastPanel_46"));
MyClubInfoChange();
}
public async void Demote(string playerId)
{
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
if (!IsDecimalPlayerId(playerId) || playerId == apiPlayerId) return;
if (myClubInfo == null) return;
DemoteClubRequest demoteClubRequest = new DemoteClubRequest()
{
culbId = myClubInfo.id,
demoteId = playerId,
playerId = apiPlayerId,
};
string json = await customServerMgr.ClubRequest("DemoteClub", demoteClubRequest);
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
}
public async void Promote(string playerId, EMemberRole role)
{
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
if (!IsDecimalPlayerId(playerId) || playerId == apiPlayerId) return;
if (myClubInfo == null) return;
PromoteClubRequest promoteClubRequest = new PromoteClubRequest()
{
culbId = myClubInfo.id,
promoteId = playerId,
playerId = apiPlayerId,
role = role,
};
string json = await customServerMgr.ClubRequest("PromoteClub", promoteClubRequest);
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
}
public async void Kickout(string playerId)
{
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
if (!IsDecimalPlayerId(playerId) || playerId == apiPlayerId) return;
if (myClubInfo == null) return;
KickoutClubRequest kickoutClubRequest = new KickoutClubRequest()
{
culbId = myClubInfo.id,
kickoutId = playerId,
playerId = apiPlayerId,
};
string json = await customServerMgr.ClubRequest("KickoutClub", kickoutClubRequest);
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
if (!TryReadClubResponse<KickoutClubResp>(json, out var response, 0) || myClubInfo == null) return;
myClubInfo.curMembers = Math.Max(0, myClubInfo.curMembers - 1);
GContext.Publish(new ClubSttingUpdateEvent());
//if (!string.IsNullOrEmpty(json) && json != "[]")
//{
// KickoutClubResp quitClubResp = Newtonsoft.Json.JsonConvert.DeserializeObject<KickoutClubResp>(json);
//}
}
public async void ReadClubEvent()
{
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
ReadClubEventRequest readClubEventRequest = new ReadClubEventRequest()
{
playerId = apiPlayerId,
};
string json = await customServerMgr.ClubRequest("ReadClubEvent", readClubEventRequest);
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
//if (!string.IsNullOrEmpty(json) && json != "[]")
//{
// ReadClubEventResp readClubEventResp = Newtonsoft.Json.JsonConvert.DeserializeObject<ReadClubEventResp>(json);
//}
}
private int? signingSession;
public async Task ClubSingin()
{
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
if (myClubInfo == null) return;
if (signingSession == sessionVersion || signTime?.Date == ZZTimeHelper.UtcNow().UtcNowOffset().Date) return;
signingSession = sessionVersion;
try
{
ClubSinginRequest clubSinginRequest = new ClubSinginRequest()
{
playerId = apiPlayerId,
id = myClubInfo.id,
};
string json = await customServerMgr.ClubRequest("ClubSingin", clubSinginRequest);
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
if (!string.IsNullOrEmpty(json) && json != "[]")
{
if (!TryReadClubResponse<ClubSinginResp>(json, out var clubSinginResp, 0)) return;
signTime = ZZTimeHelper.UtcNow().UtcNowOffset();
PlayFabMgr.Instance.UpdateUserDataValue("ClubSignin", signTime.Value.ToString("yyyy-MM-dd HH:mm:ss"));
if (clubSinginResp.code == 0)
{
chatService.SendSystemMessage($"{MessageChatType.SI}_{apiPlayerId}");
//签到成功奖励
GContext.container.Resolve<PlayerItemData>().AddItemByDrop(GContext.container.Resolve<cfg.Tables>().TbServerConfig.ClubSignReward, null);
GContext.Publish(new ShowData());
#if AGG
using (var e = GEvent.GameEvent("guild_sign"))
{
e.AddContent("guild_id", myClubInfo.id)
.AddContent("drop_list", GContext.container.Resolve<cfg.Tables>().TbServerConfig.ClubSignReward);
}
#endif
}
}
SetClubRed();
}
finally
{
if (signingSession == sessionVersion) signingSession = null;
}
}
public void SendSystemMessage2(int index)
{
if (!TryGetClubSession(out var apiPlayerId, out _)) return;
chatService.SendSystemMessage2($"{apiPlayerId}_{index}");
}
/// <summary>
/// 获取被赠送的照片 以及其他事件
/// </summary>
///
int energyAddCount = 0;
public int GetEnergyAddCount()
{
int count = energyAddCount;
energyAddCount = 0;
return count;
}
public async void GetServiceEvent()
{
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
GetPictureRequest getPictureRequest = new GetPictureRequest()
{
playerId = apiPlayerId,
};
string json = await customServerMgr.ClubRequest("GetPictureList", getPictureRequest);
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
if (!string.IsNullOrEmpty(json) && json != "[]")
{
GetPictureResp getPictureResp = Newtonsoft.Json.JsonConvert.DeserializeObject<GetPictureResp>(json);
energyAddCount = 0;
//var pictureDatas = getPictureResp.pictureDataList;
//if (pictureDatas != null && pictureDatas.Count > 0)
//{
// List<int> pictureIDs = new List<int>();
// foreach (var pictureData in pictureDatas)
// {
// pictureIDs.Add(pictureData.pictureID);
// }
// GContext.container.Resolve<AlbumData>().AddPictureProgress(pictureIDs);
//}
eventPopupDatas = getPictureResp.eventPopupDatas;
if (eventPopupDatas != null && eventPopupDatas.Count > 0)
{
IReportService reportService = GContext.container.Resolve<IReportService>();
var playerData = GContext.container.Resolve<PlayerData>();
var tables = GContext.container.Resolve<Tables>();
//CampData campData = GContext.container.Resolve<CampData>();
foreach (var eventPopupData in eventPopupDatas)
{
if (eventPopupData.type == ReportDataType.Heist)
{
long content = long.Parse(eventPopupData.content);
if (content < 0)
{
content = -content;
}
//=====新规则=====
long newContent = content / 100;
long rangeT = content % 100;
float range = Mathf.Clamp(rangeT, 5, 15) * 0.1f;
int m = Mathf.Min(tables.TbGlobalConfig.BottleCashParamLimit, (int)newContent);
float mg = (1 + playerData.benefitsCashMag) * (1 + playerData.benefitsCashMagLimited);
m = Mathf.RoundToInt(m * mg * range);
//=====新规则=====
eventPopupData.content = m.ToString();
}
if (eventPopupData.IsRead == false)
{
if (eventPopupData.type == ReportDataType.Energy)
{
#if AGG
using (var e = GEvent.GameEvent("guild_request_lures_reward"))
{
e.AddContent("donate_userid_list", eventPopupData.content)
.AddContent("reward_hook", GContext.container.Resolve<cfg.Tables>().TbServerConfig.ClubLureDonateReward);
if (myClubInfo != null)
{
e.AddContent("guild_id", myClubInfo.id);
}
}
#endif
//事件弹窗
//添加体力
playerData.SetEnergy(playerData.Energy + GContext.container.Resolve<cfg.Tables>().TbServerConfig.ClubLureDonateReward);
energyAddCount++;
}
else if (eventPopupData.type == ReportDataType.Club)
{
//工会事件,只在工会界面调用,弹窗展示之后才设置为已读
eventPopupDataClub = eventPopupData;
}
else if (eventPopupData.type == ReportDataType.Aquarium)
{
reportService.AddReport(new ReportData()
{
type = eventPopupData.type,
id = eventPopupData.redirectID,
playFabId = eventPopupData.playerID,
content = eventPopupData.content,
});
}
else
{
//可能扣钱
if (eventPopupData.type == ReportDataType.Heist && playerData.gold > 0)
{
int content = int.Parse(eventPopupData.content);
reportService.AddReport(new ReportData()
{
type = eventPopupData.type,
id = eventPopupData.redirectID,
playFabId = eventPopupData.playerID,
content = eventPopupData.content,
});
ulong gold = (ulong)content;
if (gold >= playerData.gold)
{
playerData.SetGold(0);
}
else
{
playerData.SetGold(playerData.gold - gold);
}
}
else if (eventPopupData.type == ReportDataType.Bomb)
{
reportService.AddReport(new ReportData()
{
type = eventPopupData.type,
id = eventPopupData.redirectID,
playFabId = eventPopupData.playerID,
content = eventPopupData.content,
});
GContext.container.Resolve<CampDataMM>().SetIsFix(true);
}
}
}
}
if (energyAddCount > 0)
{
GContext.container.Resolve<IFaceUIService>().AddFaceCustomUIData(UITypes.EventReportPopupPanel, true);
}
CurRewardQCount curRewardQCount = new CurRewardQCount();
GContext.Publish(curRewardQCount);
int faceCount = GContext.container.Resolve<IFaceUIService>().GetFaceUICount();
if (curRewardQCount.count <= 0 && faceCount <= 0)
{
ShowHomeReport();
}
}
}
}
public void ShowHomeReport()
{
ReportData reportData = GContext.container.Resolve<IReportService>().GetReportData();
if (reportData != null)
{
GContext.Publish(reportData);
}
}
//消息点赞相关
List<long> LikeCursorIds;
string LikeCursorIdsKey = "LikeCursorIds";
void ClearLikeCursorIds()
{
LikeCursorIds = null;
PlayerPrefs.DeleteKey(LikeCursorIdsKey);
}
public async void SendLike(long cursorId)
{
ClubLikeRequest clubLikeRequest = new ClubLikeRequest()
{
cursorId = cursorId,
id = myClubInfo.id,
};
string json = await customServerMgr.ClubRequest("ClubLike", clubLikeRequest);
if (!string.IsNullOrEmpty(json) && json != "[]")
{
ClubLikeResp clubSinginResp = Newtonsoft.Json.JsonConvert.DeserializeObject<ClubLikeResp>(json);
LikeCursorIds.Add(cursorId);
PlayerPrefs.SetString(LikeCursorIdsKey, Newtonsoft.Json.JsonConvert.SerializeObject(LikeCursorIds));
}
}
public async Task<(bool, int)> GetLikeCount(long cursorId)
{
GetLikeCountRequest LikeCountRequest = new GetLikeCountRequest()
{
cursorId = cursorId,
id = myClubInfo.id,
};
LikeCursorIds = Newtonsoft.Json.JsonConvert.DeserializeObject<List<long>>(PlayerPrefs.GetString(LikeCursorIdsKey, "[]"));
bool isLike = LikeCursorIds.Contains(cursorId);
int count = 0;
if (isLike)
{
count = 1;
}
string json = await customServerMgr.ClubRequest("GetLikeCount", LikeCountRequest);
if (!string.IsNullOrEmpty(json) && json != "[]")
{
GetLikeCountResp clubSinginResp = Newtonsoft.Json.JsonConvert.DeserializeObject<GetLikeCountResp>(json);
count = clubSinginResp.count;
}
return (isLike, count);
}
//体力求助相关
//聊天消息
public async Task<bool> SendHelpEnergy()
{
//messageID
long messageID = await chatService.SendHelpEnergyMessage(1001);
if (messageID > 0)
{
#if AGG
using (var e = GEvent.GameEvent("guild_request_lures"))
{
e.AddContent("guild_id", myClubInfo.id);
}
#endif
//记录今日请求 体力互助
lureDonateData.ClubLureRequestCDEnd = ZZTimeHelper.UtcNow().UtcNowOffset().
AddHours(GContext.container.Resolve<cfg.Tables>().TbServerConfig.ClubLureRequestCd);
SaveLureDonateData();
SetClubRed();
return true;
}
return false;
}
//服务器消息状态
public async Task<string> SendHelpOthersEnergy(string playerId, long cursorId)
{
ClubHelpEnergyRequest clubHelpEnergyRequest = new ClubHelpEnergyRequest()
{
donateId = userService.UserId,
cursorId = cursorId,
playerId = playerId,
id = myClubInfo.id,
};
lureDonateData.ClubLureDonateCount++;
string json = await customServerMgr.ClubRequest("ClubHelpEnergy", clubHelpEnergyRequest);
if (!string.IsNullOrEmpty(json) && json != "[]")
{
#if AGG
using (var e = GEvent.GameEvent("guild_donate_lures"))
{
e.AddContent("guild_id", myClubInfo.id)
.AddContent("to_user_id", playerId)
;
}
#endif
ClubHelpEnergyResp clubHelpEnergyResp = Newtonsoft.Json.JsonConvert.DeserializeObject<ClubHelpEnergyResp>(json);
SaveLureDonateData();
return clubHelpEnergyResp.idContent;
}
else
{
lureDonateData.ClubLureDonateCount--;
}
return "";
}
public async Task<string> GetHelpEnergyState(long cursorId)
{
GetHelpEnergyStatusRequest helpEnergyRequest = new GetHelpEnergyStatusRequest()
{
cursorId = cursorId,
id = myClubInfo.id,
};
string json = await customServerMgr.ClubRequest("GetHelpEnergyStatus", helpEnergyRequest);
if (!string.IsNullOrEmpty(json) && json != "[]")
{
GetHelpEnergyStatusResp helpEnergyResp = Newtonsoft.Json.JsonConvert.DeserializeObject<GetHelpEnergyStatusResp>(json);
return helpEnergyResp.idContent;
}
return "";
}
}
}

View File

@@ -0,0 +1,16 @@
static bool Same(PlayerKVPlayerState a,PlayerKVPlayerState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.Core,b.Core) && SameDictionary(a.Modern,b.Modern,(x,y)=>Same(x,y)) && SameDictionary(a.VipScores,b.VipScores,(x,y)=>Same(x,y)) && SameList(a.NewRods,b.NewRods,(x,y)=>Same(x,y)) && Same(a.Season,b.Season) && Same(a.Appraise,b.Appraise) && Same(a.Building,b.Building));
static bool Same(PlayerKVShopState a,PlayerKVShopState b) => ReferenceEquals(a,b) || (a!=null && b!=null && SameDictionary(a.Daily,b.Daily,(x,y)=>Same(x,y)) && SameDictionary(a.All,b.All,(x,y)=>Same(x,y)) && SameDictionary(a.Gift,b.Gift,(x,y)=>Same(x,y)) && SameDictionary(a.Advert,b.Advert,(x,y)=>Same(x,y)) && SameDictionary(a.Items,b.Items,(x,y)=>Same(x,y)) && SameList(a.Buy,b.Buy,(x,y)=>Same(x,y)) && Same(a.Voucher,b.Voucher) && Same(a.RefreshCount,b.RefreshCount) && Same(a.RefreshTime,b.RefreshTime) && Same(a.Chain,b.Chain) && Same(a.Turntable,b.Turntable) && Same(a.Vip,b.Vip) && Same(a.Coupon,b.Coupon));
static bool Same(PlayerKVEventBaseState a,PlayerKVEventBaseState b) => ReferenceEquals(a,b) || (a!=null && b!=null && SameDictionary(a.Limited,b.Limited,(x,y)=>Same(x,y)) && SameDictionary(a.Fixed,b.Fixed,(x,y)=>Same(x,y)) && SameDictionary(a.Target,b.Target,(x,y)=>Same(x,y)) && SameDictionary(a.Rank,b.Rank,(x,y)=>Same(x,y)) && SameDictionary(a.Wheel,b.Wheel,(x,y)=>Same(x,y)) && Same(a.Coupon,b.Coupon) && Same(a.Operate,b.Operate) && Same(a.AllOperate,b.AllOperate) && Same(a.Free,b.Free) && Same(a.PvpToken,b.PvpToken));
static bool Same(PlayerKVCampState a,PlayerKVCampState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.IsFix,b.IsFix) && Same(a.Skyscraper,b.Skyscraper) && SameDictionary(a.Values,b.Values,(x,y)=>Same(x,y)));
static bool Same(PlayerKVClubState a,PlayerKVClubState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.SignTime,b.SignTime) && Same(a.DonateTime,b.DonateTime) && Same(a.DonateCount,b.DonateCount) && Same(a.RequestEnd,b.RequestEnd) && Same(a.OpenRed,b.OpenRed));
static bool Same(PlayerKVEventPackState a,PlayerKVEventPackState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.EventId,b.EventId) && Same(a.PurchaseCount,b.PurchaseCount) && Same(a.VipLevel,b.VipLevel) && Same(a.RedirectId,b.RedirectId));
static bool Same(PlayerKVPiggyBankState a,PlayerKVPiggyBankState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.IsPresent,b.IsPresent) && Same(a.currentEventID,b.currentEventID) && Same(a.purchaseCount,b.purchaseCount) && Same(a.VIPLvlWhenEventActivate,b.VIPLvlWhenEventActivate) && Same(a.savingProgress,b.savingProgress) && Same(a.redirectID,b.redirectID) && Same(a.doNeedTrigger,b.doNeedTrigger) && Same(a.isTriggered,b.isTriggered) && Same(a.inflation,b.inflation));
static bool Same(PlayerKVBargainState a,PlayerKVBargainState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.IsPresent,b.IsPresent) && Same(a.currentEventID,b.currentEventID) && Same(a.redirectID,b.redirectID) && Same(a.VIPLvlWhenEventActivate,b.VIPLvlWhenEventActivate) && Same(a.purchaseCount,b.purchaseCount) && Same(a.savingProgress,b.savingProgress) && Same(a.discountLvl,b.discountLvl) && Same(a.visualProgress,b.visualProgress) && Same(a.visualDiscountLvl,b.visualDiscountLvl) && Same(a.rodID,b.rodID) && Same(a.lastPackIDBought,b.lastPackIDBought) && Same(a.packID,b.packID) && Same(a.doNeedTrigger,b.doNeedTrigger) && Same(a.isTriggered,b.isTriggered) && Same(a.inflation,b.inflation) && SameList(a.selections,b.selections,(x,y)=>Same(x,y)));
static bool Same(PlayerKVShootingRangeState a,PlayerKVShootingRangeState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.IsPresent,b.IsPresent) && Same(a.EventId,b.EventId) && Same(a.CycleId,b.CycleId) && Same(a.RedirectId,b.RedirectId) && Same(a.AmmoCount,b.AmmoCount) && Same(a.CurrentStageIdx,b.CurrentStageIdx) && Same(a.ChainProgress,b.ChainProgress) && Same(a.RewardFlag,b.RewardFlag) && Same(a.TargetFlag,b.TargetFlag) && Same(a.HasBeenOpened,b.HasBeenOpened) && Same(a.Inflation,b.Inflation));
static bool Same(PlayerKVProgressPackState a,PlayerKVProgressPackState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.IsPresent,b.IsPresent) && Same(a.EventId,b.EventId) && Same(a.RedirectId,b.RedirectId) && Same(a.TokenProgress,b.TokenProgress) && Same(a.ChainProgress,b.ChainProgress));
static bool Same(PlayerKVShopItemValue a,PlayerKVShopItemValue b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.Id,b.Id) && Same(a.Discount,b.Discount) && Same(a.Bought,b.Bought));
static bool Same(PlayerKVDailyValue a,PlayerKVDailyValue b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.Id,b.Id) && Same(a.Index,b.Index) && Same(a.LastId,b.LastId) && Same(a.Time,b.Time));
static bool Same(PlayerKVAdvertValue a,PlayerKVAdvertValue b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.Id,b.Id) && Same(a.Index,b.Index) && Same(a.Day,b.Day) && Same(a.Time,b.Time) && Same(a.Open,b.Open));
static bool Same(PlayerKVCouponState a,PlayerKVCouponState b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.ActivateTime,b.ActivateTime) && Same(a.EventId,b.EventId) && Same(a.RedirectId,b.RedirectId) && Same(a.Bought,b.Bought));
static bool Same(PlayerKVLimitedValue a,PlayerKVLimitedValue b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.Id,b.Id) && Same(a.Duration,b.Duration) && Same(a.StartTime,b.StartTime));
static bool Same(PlayerKVFixedTimeValue a,PlayerKVFixedTimeValue b) => ReferenceEquals(a,b) || (a!=null && b!=null && Same(a.id,b.id) && Same(a.endTime,b.endTime));